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 . AWS_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 . AWS_ENDPOINT_SERVER ,
region : process . env . AWS_REGION ,
forcePathStyle : true ,
credentials : {
accessKeyId : process . env . AWS_ACCESS_KEY ,
secretAccessKey : process . env . AWS_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 ) ;
} ) ;
} ) ;