You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
439 lines
16 KiB
439 lines
16 KiB
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 + '/' });
|
|
|
|
app.get('/ping', async (req, res) => {
|
|
res.send('pong v1.0.0');
|
|
});
|
|
|
|
app.get('/image-limits', async (req, res) => {
|
|
limitsResult = await retrievelimits(req, res);
|
|
if (!limitsResult.success) {
|
|
return limitsResult.result;
|
|
}
|
|
return res.status(200).send(JSON.stringify({
|
|
success: true,
|
|
doneImagesThisWeek: limitsResult.doneImagesThisWeek,
|
|
maxImagesPerWeek: limitsResult.maxImagesPerWeek,
|
|
nextWeekBeginDateTime: limitsResult.nextWeekBeginDateTime
|
|
}));
|
|
});
|
|
|
|
// 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 {
|
|
limitsResult = await retrievelimits(req, res);
|
|
if (!limitsResult.success) {
|
|
return limitsResult.result;
|
|
}
|
|
const doneImagesThisWeek = limitsResult.doneImagesThisWeek;
|
|
const maxImagesPerWeek = limitsResult.maxImagesPerWeek;
|
|
const issuerDid = limitsResult.issuerDid;
|
|
|
|
if (doneImagesThisWeek >= maxImagesPerWeek) {
|
|
return res.status(400).send(JSON.stringify({ success: false, message: 'You have reached your weekly limit of ' + maxImagesPerWeek + ' 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
|
|
}));
|
|
}
|
|
});
|
|
|
|
async function retrievelimits(req, res) {
|
|
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 maxImagesPerWeek = 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 (maxImagesPerWeek == 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 {
|
|
success: false,
|
|
result: 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();
|
|
maxImagesPerWeek = body.maxClaimsPerWeek / 4; // allowing fewer images than claims
|
|
|
|
await new Promise((resolve, reject) => {
|
|
db.run(
|
|
'INSERT INTO user (did, per_week) VALUES (?, ?)',
|
|
[issuerDid, maxImagesPerWeek],
|
|
(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 (maxImagesPerWeek == null) {
|
|
return {
|
|
success: false,
|
|
result: 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);
|
|
}
|
|
);
|
|
});
|
|
|
|
const nextWeekBeginDateTime = startOfWeekDate.plus({ week: 1 }); // luxon weeks start on Mondays
|
|
const nextWeekBeginDateTimeString = nextWeekBeginDateTime.toISO()
|
|
|
|
return {
|
|
success: true,
|
|
doneImagesThisWeek: imagesCount,
|
|
maxImagesPerWeek: maxImagesPerWeek,
|
|
nextWeekBeginDateTime: nextWeekBeginDateTimeString,
|
|
issuerDid: issuerDid,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
});
|
|
|
|
|