Skip to content

Commit e21ba2c

Browse files
authored
Add basic workflow for transfering a device to a different user (#525)
* Add basic workflow for transfering a device to a different user * Add script to generate local docs * Change claim schema * Expire token after successful transfer * Add TTL to config * Add config to tests * Move TTL config to models * Add date param to create token for expires at * Add PUT and GET methods for transfers * Add tests for claim model * Add checks for expiresAt property * Add unique index for boxId to avoid more than one transfer token for a device * Add API tests for transfer a device * Add test for duplicate device id * Add test for complete transfer of a device * Reject claim box if token was not found * Fix doc types and references * Use timestamp plugin * Add pre save hook to set default expiresAt * Check date for timestamps in the past
1 parent 0daaf6f commit e21ba2c

File tree

13 files changed

+792
-36
lines changed

13 files changed

+792
-36
lines changed

config/config.example.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
// Should be the naked domain and a port
142142
// No Default
143143
"url": "mqtt-osem-integration:3925"
144-
}
144+
},
145145
},
146146
"password": {
147147
// Minimum required password length
@@ -151,6 +151,10 @@
151151
// Default: 13
152152
"salt_factor": 12
153153
},
154+
"claims_ttl": {
155+
"amount": 1,
156+
"unit": "d"
157+
},
154158
// Location on the filesystem where images should be saved
155159
// Should always end with a trailing slash
156160
// Default: "./userimages/"

packages/api/lib/controllers/boxesController.js

Lines changed: 250 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,16 @@
3737
*/
3838

3939
const
40-
{ Box } = require('@sensebox/opensensemap-api-models'),
40+
{ Box, User, Claim } = require('@sensebox/opensensemap-api-models'),
4141
{ addCache, clearCache, checkContentType, redactEmail, postToSlack } = require('../helpers/apiUtils'),
4242
{ point } = require('@turf/helpers'),
4343
classifyTransformer = require('../transformers/classifyTransformer'),
4444
{
4545
retrieveParameters,
4646
parseAndValidateTimeParamsForFindAllBoxes,
4747
validateFromToTimeParams,
48-
checkPrivilege
48+
checkPrivilege,
49+
validateDateNotPast
4950
} = require('../helpers/userParamHelpers'),
5051
handleError = require('../helpers/errorHandler'),
5152
jsonstringify = require('stringify-stream');
@@ -487,24 +488,185 @@ const deleteBox = async function deleteBox (req, res, next) {
487488
}
488489
};
489490

491+
/**
492+
* @api {get} /boxes/transfer/:senseBoxId Get transfer information for a senseBox
493+
* @apiDescription Get transfer information for a senseBox
494+
* @apiName getTransfer
495+
* @apiGroup Boxes
496+
* @apiUse JWTokenAuth
497+
* @apiUse BoxIdParam
498+
*/
499+
const getTransfer = async function getTransfer (req, res, next) {
500+
const { boxId } = req._userParams;
501+
try {
502+
const transfer = await Claim.findClaimByDeviceID(boxId);
503+
res.send(200, {
504+
data: transfer,
505+
});
506+
} catch (err) {
507+
handleError(err, next);
508+
}
509+
};
510+
511+
/**
512+
* @api {post} /boxes/transfer Mark a senseBox for transferring to a different user
513+
* @apiDescription This will mark a senseBox for transfering it to a different user account
514+
* @apiName createTransfer
515+
* @apiGroup Boxes
516+
* @apiParam (RequestBody) {String} boxId ID of the senseBox you want to transfer.
517+
* @apiParam (RequestBody) {RFC3339Date} expiresAt Expiration date for transfer token (default: 24 hours from now).
518+
* @apiUse JWTokenAuth
519+
*/
520+
const createTransfer = async function createTransfer (req, res, next) {
521+
const { boxId, date } = req._userParams;
522+
try {
523+
const transferCode = await req.user.transferBox(boxId, date);
524+
res.send(201, {
525+
message: 'Box successfully prepared for transfer',
526+
data: transferCode,
527+
});
528+
} catch (err) {
529+
handleError(err, next);
530+
}
531+
};
532+
533+
/**
534+
* @api {put} /boxes/transfer/:senseBoxId Update a transfer token
535+
* @apiDescription Update the expiration date of a transfer token
536+
* @apiName updateTransfer
537+
* @apiGroup Boxes
538+
* @apiParam (RequestBody) {String} Transfer token you want to update.
539+
* @apiParam (RequestBody) {RFC3339Date} expiresAt Expiration date for transfer token (default: 24 hours from now).
540+
* @apiUse JWTokenAuth
541+
* @apiUse BoxIdParam
542+
*/
543+
const updateTransfer = async function updateTransfer (req, res, next) {
544+
const { boxId, token, date } = req._userParams;
545+
try {
546+
const transfer = await req.user.updateTransfer(boxId, token, date);
547+
res.send(200, {
548+
message: 'Transfer successfully updated',
549+
data: transfer,
550+
});
551+
} catch (err) {
552+
handleError(err, next);
553+
}
554+
};
555+
556+
/**
557+
* @api {delete} /boxes/transfer Revoke transfer token and remove senseBox from transfer
558+
* @apiDescription This will revoke the transfer token and remove the senseBox from transfer
559+
* @apiName removeTransfer
560+
* @apiGroup Boxes
561+
* @apiParam (RequestBody) {String} boxId ID of the senseBox you want to remove from transfer.
562+
* @apiParam (RequestBody) {String} token Transfer token you want to revoke.
563+
* @apiUse JWTokenAuth
564+
*/
565+
const removeTransfer = async function removeTransfer (req, res, next) {
566+
const { boxId, token } = req._userParams;
567+
try {
568+
await req.user.removeTransfer(boxId, token);
569+
res.send(204);
570+
} catch (err) {
571+
handleError(err, next);
572+
}
573+
};
574+
575+
/**
576+
* @api {post} /boxes/claim Claim a senseBox marked for transfer
577+
* @apiDescription This will claim a senseBox marked for transfer
578+
* @apiName claimBox
579+
* @apiGroup Boxes
580+
* @apiUse ContentTypeJSON
581+
* @apiParam (RequestBody) {String} token the token to claim a senseBox
582+
* @apiUse JWTokenAuth
583+
*/
584+
const claimBox = async function claimBox (req, res, next) {
585+
const { token } = req._userParams;
586+
587+
try {
588+
const { owner, claim } = await req.user.claimBox(token);
589+
await User.transferOwnershipOfBox(owner, claim.boxId);
590+
591+
await claim.expireToken();
592+
593+
res.send(200, { message: 'Device successfully claimed!' });
594+
} catch (err) {
595+
handleError(err, next);
596+
}
597+
};
598+
490599
module.exports = {
491600
// auth required
492601
deleteBox: [
493602
checkContentType,
494603
retrieveParameters([
495604
{ predef: 'boxId', required: true },
496-
{ predef: 'password' }
605+
{ predef: 'password' },
606+
]),
607+
checkPrivilege,
608+
deleteBox,
609+
],
610+
getTransfer: [
611+
retrieveParameters([{ predef: 'boxId', required: true }]),
612+
checkPrivilege,
613+
getTransfer,
614+
],
615+
createTransfer: [
616+
retrieveParameters([
617+
{ predef: 'boxId', required: true },
618+
{ predef: 'dateNoDefault' },
619+
]),
620+
validateDateNotPast,
621+
checkPrivilege,
622+
createTransfer,
623+
],
624+
updateTransfer: [
625+
retrieveParameters([
626+
{ predef: 'boxId', required: true },
627+
{ name: 'token', dataType: 'String' },
628+
{ predef: 'dateNoDefault', required: true },
497629
]),
630+
validateDateNotPast,
498631
checkPrivilege,
499-
deleteBox
632+
updateTransfer,
633+
],
634+
removeTransfer: [
635+
retrieveParameters([
636+
{ predef: 'boxId', required: true },
637+
{ name: 'token', dataType: 'String' },
638+
]),
639+
checkPrivilege,
640+
removeTransfer,
641+
],
642+
claimBox: [
643+
checkContentType,
644+
retrieveParameters([{ name: 'token', dataType: 'String' }]),
645+
claimBox,
500646
],
501647
getSketch: [
502648
retrieveParameters([
503649
{ predef: 'boxId', required: true },
504-
{ name: 'serialPort', dataType: 'String', allowedValues: ['Serial1', 'Serial2'] },
505-
{ name: 'soilDigitalPort', dataType: 'String', allowedValues: ['A', 'B', 'C'] },
506-
{ name: 'soundMeterPort', dataType: 'String', allowedValues: ['A', 'B', 'C'] },
507-
{ name: 'windSpeedPort', dataType: 'String', allowedValues: ['A', 'B', 'C'] },
650+
{
651+
name: 'serialPort',
652+
dataType: 'String',
653+
allowedValues: ['Serial1', 'Serial2'],
654+
},
655+
{
656+
name: 'soilDigitalPort',
657+
dataType: 'String',
658+
allowedValues: ['A', 'B', 'C'],
659+
},
660+
{
661+
name: 'soundMeterPort',
662+
dataType: 'String',
663+
allowedValues: ['A', 'B', 'C'],
664+
},
665+
{
666+
name: 'windSpeedPort',
667+
dataType: 'String',
668+
allowedValues: ['A', 'B', 'C'],
669+
},
508670
{ name: 'ssid', dataType: 'StringWithEmpty' },
509671
{ name: 'password', dataType: 'StringWithEmpty' },
510672
{ name: 'devEUI', dataType: 'StringWithEmpty' },
@@ -513,7 +675,7 @@ module.exports = {
513675
{ name: 'display_enabled', allowedValues: ['true', 'false'] },
514676
]),
515677
checkPrivilege,
516-
getSketch
678+
getSketch,
517679
],
518680
updateBox: [
519681
checkContentType,
@@ -531,21 +693,25 @@ module.exports = {
531693
{ name: 'addons', dataType: 'object' },
532694
{ predef: 'location' },
533695
{ name: 'useAuth', allowedValues: ['true', 'false'] },
534-
{ name: 'generate_access_token', allowedValues: ['true', 'false'] }
696+
{ name: 'generate_access_token', allowedValues: ['true', 'false'] },
535697
]),
536698
checkPrivilege,
537-
updateBox
699+
updateBox,
538700
],
539701
// no auth required
540702
getBoxLocations: [
541703
retrieveParameters([
542704
{ predef: 'boxId', required: true },
543-
{ name: 'format', defaultValue: 'json', allowedValues: ['json', 'geojson'] },
705+
{
706+
name: 'format',
707+
defaultValue: 'json',
708+
allowedValues: ['json', 'geojson'],
709+
},
544710
{ predef: 'toDate' },
545711
{ predef: 'fromDate' },
546712
validateFromToTimeParams,
547713
]),
548-
getBoxLocations
714+
getBoxLocations,
549715
],
550716
postNewBox: [
551717
checkContentType,
@@ -555,44 +721,100 @@ module.exports = {
555721
{ name: 'exposure', allowedValues: Box.BOX_VALID_EXPOSURES },
556722
{ name: 'model', allowedValues: Box.BOX_VALID_MODELS },
557723
{ name: 'sensors', dataType: ['object'] },
558-
{ name: 'sensorTemplates', dataType: ['String'], allowedValues: ['hdc1080', 'bmp280', 'sds 011', 'tsl45315', 'veml6070', 'bme680', 'smt50', 'soundlevelmeter', 'windspeed', 'scd30', 'dps310'] },
559-
{ name: 'serialPort', dataType: 'String', defaultValue: 'Serial1', allowedValues: ['Serial1', 'Serial2'] },
560-
{ name: 'soilDigitalPort', dataType: 'String', defaultValue: 'A', allowedValues: ['A', 'B', 'C'] },
561-
{ name: 'soundMeterPort', dataType: 'String', defaultValue: 'B', allowedValues: ['A', 'B', 'C'] },
562-
{ name: 'windSpeedPort', dataType: 'String', defaultValue: 'C', allowedValues: ['A', 'B', 'C'] },
724+
{
725+
name: 'sensorTemplates',
726+
dataType: ['String'],
727+
allowedValues: [
728+
'hdc1080',
729+
'bmp280',
730+
'sds 011',
731+
'tsl45315',
732+
'veml6070',
733+
'bme680',
734+
'smt50',
735+
'soundlevelmeter',
736+
'windspeed',
737+
'scd30',
738+
'dps310',
739+
],
740+
},
741+
{
742+
name: 'serialPort',
743+
dataType: 'String',
744+
defaultValue: 'Serial1',
745+
allowedValues: ['Serial1', 'Serial2'],
746+
},
747+
{
748+
name: 'soilDigitalPort',
749+
dataType: 'String',
750+
defaultValue: 'A',
751+
allowedValues: ['A', 'B', 'C'],
752+
},
753+
{
754+
name: 'soundMeterPort',
755+
dataType: 'String',
756+
defaultValue: 'B',
757+
allowedValues: ['A', 'B', 'C'],
758+
},
759+
{
760+
name: 'windSpeedPort',
761+
dataType: 'String',
762+
defaultValue: 'C',
763+
allowedValues: ['A', 'B', 'C'],
764+
},
563765
{ name: 'mqtt', dataType: 'object' },
564766
{ name: 'ttn', dataType: 'object' },
565767
{ name: 'useAuth', allowedValues: ['true', 'false'] },
566-
{ predef: 'location', required: true }
768+
{ predef: 'location', required: true },
567769
]),
568-
postNewBox
770+
postNewBox,
569771
],
570772
getBox: [
571773
retrieveParameters([
572774
{ predef: 'boxId', required: true },
573-
{ name: 'format', defaultValue: 'json', allowedValues: ['json', 'geojson'] }
775+
{
776+
name: 'format',
777+
defaultValue: 'json',
778+
allowedValues: ['json', 'geojson'],
779+
},
574780
]),
575-
getBox
781+
getBox,
576782
],
577783
getBoxes: [
578784
retrieveParameters([
579785
{ name: 'name', dataType: 'String' },
580786
{ name: 'limit', dataType: 'Number', defaultValue: 5, min: 1, max: 20 },
581-
{ name: 'exposure', allowedValues: Box.BOX_VALID_EXPOSURES, dataType: ['String'] },
787+
{
788+
name: 'exposure',
789+
allowedValues: Box.BOX_VALID_EXPOSURES,
790+
dataType: ['String'],
791+
},
582792
{ name: 'model', dataType: ['StringWithEmpty'] },
583793
{ name: 'grouptag', dataType: ['StringWithEmpty'] },
584794
{ name: 'phenomenon', dataType: 'StringWithEmpty' },
585795
{ name: 'date', dataType: ['RFC 3339'] },
586-
{ name: 'format', defaultValue: 'json', allowedValues: ['json', 'geojson'] },
587-
{ name: 'classify', defaultValue: 'false', allowedValues: ['true', 'false'] },
588-
{ name: 'minimal', defaultValue: 'false', allowedValues: ['true', 'false'] },
796+
{
797+
name: 'format',
798+
defaultValue: 'json',
799+
allowedValues: ['json', 'geojson'],
800+
},
801+
{
802+
name: 'classify',
803+
defaultValue: 'false',
804+
allowedValues: ['true', 'false'],
805+
},
806+
{
807+
name: 'minimal',
808+
defaultValue: 'false',
809+
allowedValues: ['true', 'false'],
810+
},
589811
{ name: 'full', defaultValue: 'false', allowedValues: ['true', 'false'] },
590812
{ name: 'near' },
591813
{ name: 'maxDistance' },
592814
{ predef: 'bbox' },
593815
]),
594816
parseAndValidateTimeParamsForFindAllBoxes,
595817
addCache('5 minutes', 'getBoxes'),
596-
getBoxes
597-
]
818+
getBoxes,
819+
],
598820
};

0 commit comments

Comments
 (0)