const { DeleteObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3'); const cors = require('cors'); const crypto = require('crypto'); const didJwt = require('did-jwt'); const { Resolver } = require('did-resolver'); const express = require('express'); const { getResolver } = require('ethr-did-resolver'); const fs = require('fs'); const { DateTime } = require('luxon'); const multer = require('multer'); const path = require('path'); const sqlite3 = require('sqlite3').verbose(); require('dotenv').config() const app = express(); app.use(cors()); const port = process.env.PORT || 3001; // file name also referenced in flyway.conf and potentially in .env files or in environment variables 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'; const ethrDidResolver = getResolver; const resolver = new Resolver({ ...ethrDidResolver({ infuraProjectId: process.env.INFURA_PROJECT_ID || 'fake-infura-project-id' }) }) // Open a connection to the SQLite database const db = new sqlite3.Database(dbFile, (err) => { if (err) { console.error('Error opening database:', err); } }); const endorserApiUrl = process.env.ENDORSER_API_URL || 'https://test-api.endorser.ch'; // Configure AWS const s3Client = new S3Client({ endpoint: 'https://' + process.env.S3_ENDPOINT_SERVER, region: process.env.S3_REGION, forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY } }); const uploadDir = 'uploads'; const uploadMulter = multer({ dest: uploadDir + '/' }); // POST endpoint to upload an 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) { fs.rm(reqFile.path, (err) => { if (err) { console.error("Error deleting too-large temp file", reqFile.path, "with error (but continuing):", err); } }); return res.status(400).send(JSON.stringify({success: false, message: 'File size is too large. Maximum file size is 10MB.'})); } try { const decoded = await decodeJwt(req, res) if (!decoded.success) { return decoded.result; } const issuerDid = decoded.issuerDid; const jwt = decoded.jwt; // Check the user's limits, first from the DB and then from the server let limitPerWeek = await new Promise((resolve, reject) => { db.get( 'SELECT per_week FROM user WHERE did = ?', [ issuerDid ], (dbErr, row) => { if (dbErr) { console.error('Error getting user record from database (but continuing):', dbErr) // may not matter, so continue } resolve(row?.per_week); } ); }); if (limitPerWeek == null) { const headers = { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' } const response = await fetch(endorserApiUrl + '/api/report/rateLimits', { headers }); if (response.status !== 200) { console.error("Got bad response of", response.status, "when checking rate limits for", issuerDid); return res.status(400).send(JSON.stringify({ success: false, message: 'Got bad status of ' + response.status + ' when checking limits with endorser server. Verify that the account exists and that the JWT works for that server.'})); } else { const body = await response.json(); limitPerWeek = body.maxClaimsPerWeek / 4; // allowing fewer images than claims await new Promise((resolve, reject) => { db.run( 'INSERT INTO user (did, per_week) VALUES (?, ?)', [issuerDid, limitPerWeek], (dbErr) => { if (dbErr) { console.error("Error inserting user record for", issuerDid, "into database (but continuing):", dbErr); // we can continue... it just means we'll check the endorser server again next time } resolve(); } ); }); } } if (limitPerWeek == null) { return res.status(400).send(JSON.stringify({ success: false, message: 'Unable to determine rate limits for this user. Verify that the account exists and that the JWT works for that server.' })); } // check the user's claims so far this week const startOfWeekDate = DateTime.utc().startOf('week') // luxon weeks start on Mondays const startOfWeekString = startOfWeekDate.toISO() const imagesCount = await new Promise((resolve, reject) => { db.get( 'SELECT COUNT(*) AS week_count FROM image WHERE did = ? AND time >= ?', [issuerDid, startOfWeekString], (dbErr, row) => { if (dbErr) { console.error(currentDate, "Error counting records for", issuerDid, "into database (but continuing):", dbErr); // we can continue... it just means we'll check the endorser server again next time } resolve(row?.week_count); } ); }); if (imagesCount >= limitPerWeek) { return res.status(400).send(JSON.stringify({ success: false, message: 'You have reached your weekly limit of ' + limitPerWeek + ' images.' })); } // Read the file from the temporary location 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'); const fileName = hashHex + path.extname(reqFile.originalname); try { // 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 } 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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)', [ currentDate, issuerDid, claimType, handleId, localFile, reqFile.size, fileName, reqFile.mimetype, finalUrl ], (dbErr) => { if (dbErr) { console.error(currentDate, "Error inserting record from", issuerDid, "into database:", dbErr); // don't continue because then we'll have storage we cannot track (and potentially limit) reject(dbErr); } resolve(); } ); }); // send to S3 const params = { Body: data, Bucket: bucketName, // S3 Bucket name ContentType: reqFile.mimetype, // File content type Key: fileName, // File name to use in S3 }; if (process.env.S3_SET_ACL === 'true') { params.ACL = 'public-read'; } const command = new PutObjectCommand(params); const response = await s3Client.send(command); if (response.$metadata.httpStatusCode !== 200) { const errorTime = new Date().toISOString(); console.error(errorTime, "Error uploading to 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 })); } else { fs.rm(reqFile.path, (err) => { if (err) { console.error("Error deleting temp file", reqFile.path, "with error (but continuing):", err); } }); // 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})); } } catch (uploadError) { const errorTime = new Date().toISOString(); console.error(errorTime, "Error uploading to S3:", uploadError); return res.status(500).send(JSON.stringify({ success: false, message: "Got error uploading file. See server logs at " + errorTime + " Error Details: " + uploadError })); } }) } catch (error) { const errorTime = new Date().toISOString(); console.error(errorTime, "Error processing image upload:", error); res.status(500).send(JSON.stringify({ success: false, message: "Got error processing image upload. See server logs at " + errorTime + " Error Details: " + error })); } }); /** * DELETE endpoint, with 204 on successful delete * returns { success: true } if successful * returns { success: false, message: string } if not successful */ app.delete('/image/:url', async (req, res) => { try { const decoded = await decodeJwt(req, res) if (!decoded.success) { return decoded.result; } const issuerDid = decoded.issuerDid; const url = req.params.url; const currentDate = new Date().toISOString(); // look for file name of this image const thisUserImageFile = await new Promise((resolve, reject) => { db.get( 'SELECT final_file FROM image WHERE url = ? and did = ?', [ url, issuerDid ], (dbErr, row) => { if (dbErr) { console.error(currentDate, 'Error getting image for user from database:', dbErr) reject(dbErr); } resolve(row?.final_file); } ); }); if (!thisUserImageFile) { console.error('No image entry found for user', issuerDid, '& URL', url, 'so returning 404.'); return res.status(404).send(JSON.stringify({ success: false, message: 'No image entry found for user ' + issuerDid + ' & URL ' + url })); } // check if any other user recorded this image const otherUserImage = await new Promise((resolve, reject) => { db.get( 'SELECT did FROM image WHERE url = ? 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 (!otherUserImage) { // remove from S3 since nobody else recorded it const params = { Bucket: bucketName, // S3 Bucket name Key: thisUserImageFile, // 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 })); } } // now remove the DB record for requesting user await new Promise((resolve, reject) => { db.run( 'DELETE FROM image where url = ? AND did = ?', [ url, issuerDid ], (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) reject(dbErr); } resolve(); } ); }); return res.status(204).send(JSON.stringify({ success: true })); } catch (error) { const errorTime = new Date().toISOString(); console.error(errorTime, "Error processing image delete:", error); return res.status(500).send(JSON.stringify({ success: false, message: "Got error processing image delete. See server logs at " + errorTime + " Error Details: " + error })); } }); /** * retrieve Bearer JWT from Authorization header and return either: * { success: true, issuerDid: string, jwt: string } * ... or ... * { success: false, result: HTTP send result } */ async function decodeJwt(req, res) { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) { return { success: false, result: res.status(401).send(JSON.stringify({success: false, message: 'Missing "Bearer JWT" in Authorization header.'})) }; } const jwt = auth.substring('Bearer '.length); const verified = await didJwt.verifyJWT(jwt, { resolver }); if (!verified.verified) { const errorTime = new Date().toISOString(); console.error(errorTime, 'Got invalid JWT in Authorization header:', verified); return { success: false, result: res.status(401).send(JSON.stringify({ success: false, message: 'Got invalid JWT in Authorization header. See server logs at ' + errorTime })) }; } return { success: true, issuerDid: verified.issuer, jwt: jwt }; } app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); // Close the database connection when the Node.js app ends process.on('SIGINT', () => { db.close((err) => { if (err) { console.error('Error closing DB connection:', err); return; } process.exit(0); }); });