diff --git a/README.md b/README.md index 2392022..6ed1592 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ JWT=`node -e "$CODE"`; curl -X POST -H "Authorization: Bearer $JWT" -F "image=@. JWT=`node -e "$CODE"`; curl -X DELETE -H "Authorization: Bearer $JWT" http://localhost:3001/image/https%3A%2F%2Fgifts-image-test.s3.amazonaws.com%2F4599145c3a8792a678f458747f2d8512c680e8680bf5563c35b06cd770051ed2.png ``` +https://github.com/box/Makefile.test +`make -C test -j` + ## deploy to prod first time * Do the necessary steps from "setup" above, or `docker build` it. @@ -51,4 +54,4 @@ JWT=`node -e "$CODE"`; curl -X DELETE -H "Authorization: Bearer $JWT" http://loc ## deploy to prod subsequent times -* Update version in server.js file. Add CHANGELOG.md entry. +* Update version in server.js 'ping' endpoint. Add CHANGELOG.md entry. diff --git a/server.js b/server.js index 4c6f1a0..56b94ce 100644 --- a/server.js +++ b/server.js @@ -18,7 +18,7 @@ app.use(cors()); const port = process.env.PORT || 3002; // file name also referenced in flyway.conf and potentially in .env files or in environment variables -const dbFile = process.env.SQLITE_FILE || './image.sqlite'; +const dbFile = process.env.SQLITE_FILE || './image-db.sqlite'; const bucketName = process.env.S3_BUCKET_NAME || 'gifts-image-test'; const imageServer = process.env.DOWNLOAD_IMAGE_SERVER || 'test-image.timesafari.app'; @@ -54,7 +54,7 @@ const uploadDir = 'uploads'; const uploadMulter = multer({ dest: uploadDir + '/' }); app.get('/ping', async (req, res) => { - res.send('pong v1.0.0'); + res.send('pong - v 1.1.0'); // version }); app.get('/image-limits', async (req, res) => { @@ -70,13 +70,21 @@ app.get('/image-limits', async (req, res) => { })); }); -// POST endpoint to upload an image +/** + * POST endpoint to upload an image + * + * Send as FormData, with: + * - "image" file Blob + * - "claimType" (optional, eg. "GiveAction", "PlanAction", "profile") + * - "handleId" (optional) + * - "fileName" (optional, if you want to replace an previous image) + */ app.post('/image', uploadMulter.single('image'), async (req, res) => { const reqFile = req.file; if (reqFile == null) { return res.status(400).send(JSON.stringify({ success: false, message: 'No file uploaded.' })); } - if (reqFile.size > 10000000) { + if (reqFile.size > 10485760) { // 10MB fs.rm(reqFile.path, (err) => { if (err) { console.error("Error deleting too-large temp file", reqFile.path, "with error (but continuing):", err); @@ -102,41 +110,115 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => { fs.readFile(reqFile.path, async (err, data) => { if (err) throw err; // Handle error - const hashSum = crypto.createHash('sha256'); - hashSum.update(data); - const hashHex = hashSum.digest('hex'); + try { + let finalFileName; + if (req.body.fileName) { + finalFileName = req.body.fileName; + + // check if the file to replace was sent by this user earlier + const didForOriginal = await new Promise((resolve, reject) => { + db.get( + 'SELECT did FROM image WHERE did = ? and final_file = ?' + [ finalFileName, issuerDid ], + (dbErr, row) => { + if (dbErr) { + console.error(currentDate, 'Error getting image for user from database:', dbErr) + reject(dbErr); + } + resolve(row?.did); + } + ); + }); + if (!didForOriginal) { + return res.status(404).send(JSON.stringify({ success: false, message: 'No image entry found for user ' + issuerDid + ' for file ' + finalFileName })); + } - const fileName = hashHex + path.extname(reqFile.originalname); + // check if any other user recorded this image + const othersWhoSentImage = await new Promise((resolve, reject) => { + db.get( + 'SELECT did FROM image WHERE final_file = ? and did != ?', + [ url, issuerDid ], + (dbErr, row) => { + if (dbErr) { + console.error(currentDate, 'Error getting image for other users from database:', dbErr) + reject(dbErr); + } + resolve(row?.did); + } + ); + }); + if (othersWhoSentImage) { + return res.status(400).send(JSON.stringify({ success: false, message: 'Other users have also saved this image so it cannot be modified. You will have to replace your own references.' })); + } - try { + // remove from S3 + const params = { + Bucket: bucketName, // S3 Bucket name + Key: finalFileName, // File name to use in S3 + }; + const command = new DeleteObjectCommand(params); + const response = await s3Client.send(command); + if (response.$metadata.httpStatusCode !== 200 + && response.$metadata.httpStatusCode !== 202 + && response.$metadata.httpStatusCode !== 204) { + const errorTime = new Date().toISOString(); + console.error(errorTime, "Error deleting from S3 with bad HTTP status, with metadata:", response.$metadata); + return res.status(500).send(JSON.stringify({ + success: false, + message: "Got bad status of " + response.$metadata.httpStatusCode + " from S3. See server logs at " + errorTime + })); + } - // look to see if this image already exists - const imageUrl = await new Promise((resolve, reject) => { - db.get( - 'SELECT url FROM image WHERE final_file = ? and did = ?', - [ fileName, issuerDid ], - (dbErr, row) => { - if (dbErr) { - console.error(currentDate, 'Error getting image for user from database:', dbErr) - // continue anyway + // might as well remove from DB and add it all back again later + await new Promise((resolve, reject) => { + db.run( + 'DELETE FROM image where did = ? and final_file = ?', + [ issuerDid, finalFileName ], + (dbErr) => { + if (dbErr) { + const currentDate = new Date().toISOString(); + console.error(currentDate, "Error deleting record by", issuerDid, "named", finalFileName, "from database:", dbErr); + // don't continue because then we'll have storage we cannot track (and potentially limit) + reject(dbErr); + } + resolve(); } - resolve(row?.url); - } - ); - }); - if (imageUrl) { - return res.status(201).send(JSON.stringify({ success: true, url: imageUrl, message: 'This image already existed.' })); + ); + }); + } else { + const hashSum = crypto.createHash('sha256'); + hashSum.update(data); + const hashHex = hashSum.digest('hex'); + finalFileName = hashHex + path.extname(reqFile.originalname); + + // look to see if this image already exists for this user + const imageUrl = await new Promise((resolve, reject) => { + db.get( + 'SELECT url FROM image WHERE final_file = ? and did = ?', + [ finalFileName, issuerDid ], + (dbErr, row) => { + if (dbErr) { + console.error(currentDate, 'Error getting image for user from database:', dbErr) + // continue anyway + } + resolve(row?.url); + } + ); + }); + if (imageUrl) { + return res.status(201).send(JSON.stringify({ success: true, url: imageUrl, message: 'This image already existed.' })); + } } // record the upload in the database const currentDate = new Date().toISOString(); const localFile = reqFile.path.startsWith(uploadDir + '/') ? reqFile.path.substring(uploadDir.length + 1) : reqFile.path; - const finalUrl = `https://${imageServer}/${fileName}`; + const finalUrl = `https://${imageServer}/${finalFileName}`; const claimType = req.body.claimType; const handleId = req.body.handleId; await new Promise((resolve, reject) => { db.run( - 'INSERT INTO image (time, did, claim_type, handle_id, local_file, size, final_file, mime_type, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO image (time, did, claim_type, handle_id, local_file, size, final_file, mime_type, url, is_replacement) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ currentDate, issuerDid, @@ -144,9 +226,10 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => { handleId, localFile, reqFile.size, - fileName, + finalFileName, reqFile.mimetype, - finalUrl + finalUrl, + !!req.body.fileName, ], (dbErr) => { if (dbErr) { @@ -164,7 +247,7 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => { Body: data, Bucket: bucketName, // S3 Bucket name ContentType: reqFile.mimetype, // File content type - Key: fileName, // File name to use in S3 + Key: finalFileName, // File name to use in S3 }; if (process.env.S3_SET_ACL === 'true') { params.ACL = 'public-read'; @@ -186,7 +269,7 @@ app.post('/image', uploadMulter.single('image'), async (req, res) => { }); // AWS URL: https://gifts-image-test.s3.amazonaws.com/gifts-image-test/FILE // American Cloud URL: https://a2-west.americancloud.com/TENANT:giftsimagetest/FILE - return res.status(200).send(JSON.stringify({success: true, url: finalUrl})); + return res.status(201).send(JSON.stringify({success: true, url: finalUrl})); } } catch (uploadError) { const errorTime = new Date().toISOString(); @@ -244,7 +327,7 @@ app.delete('/image/:url', async (req, res) => { } // check if any other user recorded this image - const otherUserImage = await new Promise((resolve, reject) => { + const othersWhoSentImage = await new Promise((resolve, reject) => { db.get( 'SELECT did FROM image WHERE url = ? and did != ?', [ url, issuerDid ], @@ -258,7 +341,7 @@ app.delete('/image/:url', async (req, res) => { ); }); - if (!otherUserImage) { + if (!othersWhoSentImage) { // remove from S3 since nobody else recorded it const params = { Bucket: bucketName, // S3 Bucket name @@ -286,8 +369,8 @@ app.delete('/image/:url', async (req, res) => { (dbErr) => { if (dbErr) { const currentDate = new Date().toISOString(); - console.error(currentDate, "Error deleting record from", issuerDid, "into database:", dbErr); - // don't continue because then we'll have storage we cannot track (and potentially limit) + console.error(currentDate, "Error deleting record by", issuerDid, "with URL", url, "from database:", dbErr); + // we'll let them know that it's not all cleaned up so they can try again reject(dbErr); } resolve(); diff --git a/sql/migrations/V2__add_is_replacement.sql b/sql/migrations/V2__add_is_replacement.sql new file mode 100644 index 0000000..b7f43f0 --- /dev/null +++ b/sql/migrations/V2__add_is_replacement.sql @@ -0,0 +1,2 @@ + +ALTER TABLE image ADD COLUMN is_replacement BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..60d55da --- /dev/null +++ b/test/test.sh @@ -0,0 +1,18 @@ +# requires node & curl & jq + +HOST=http://localhost:3002 + +CODE='OWNER_DID="did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F"; OWNER_PRIVATE_KEY_HEX="2b6472c026ec2aa2c4235c994a63868fc9212d18b58f6cbfe861b52e71330f5b"; didJwt = require("did-jwt"); didJwt.createJWT({ exp: Math.floor(Date.now() / 1000) + 60, iat: Math.floor(Date.now() / 1000), iss: OWNER_DID }, { issuer: OWNER_DID, signer: didJwt.SimpleSigner(OWNER_PRIVATE_KEY_HEX) }).then(console.log)' + +# exit as soon as anything fails +set -e + +JWT=$(node -e "$CODE") +RESULT=$(curl -X POST -H "Authorization: Bearer $JWT" -F "image=@test1.png" $HOST/image) +echo $RESULT +URL=$(echo $RESULT | jq -r '.url') + +STATUS_CODE=$(curl -o out-test1.png -w "%{http_code}" $URL); +if [ $STATUS_CODE -ne 200 ]; then + echo "File is not accessible, received status code: $STATUS_CODE"; +fi