try {
// Check if config.json exists
require ( './config.json' ) ;
} catch ( err ) {
console . error ( 'No config.json found! Please run \'npm run setup\'' ) ;
process . exit ( 1 ) ;
}
// Load the config
const { host , port , useSsl , resourceIdSize , diskFilePath , gfyIdSize , resourceIdType , isProxied , s3enabled , saveAsOriginal } = require ( './config.json' ) ;
//#region Imports
const fs = require ( 'fs-extra' ) ;
const express = require ( 'express' ) ;
const helmet = require ( "helmet" ) ;
const escape = require ( 'escape-html' ) ;
const useragent = require ( 'express-useragent' ) ;
const rateLimit = require ( "express-rate-limit" ) ;
const fetch = require ( 'node-fetch' ) ;
const marked = require ( 'marked' ) ;
const { DateTime } = require ( 'luxon' ) ;
const { WebhookClient , MessageEmbed } = require ( 'discord.js' ) ;
const OpenGraph = require ( './ogp' ) ;
const Thumbnail = require ( './thumbnails' ) ;
const Vibrant = require ( './vibrant' ) ;
const Hash = require ( './hash' ) ;
const { uploadLocal , uploadS3 , deleteS3 } = require ( './storage' ) ;
const { path , saveData , log , verify , getTrueHttp , getTrueDomain , renameFile , generateToken , generateId , formatBytes , arrayEquals , getS3url , downloadTempS3 , sanitize } = require ( './utils' ) ;
const { CODE _NO _CONTENT , CODE _BAD _REQUEST , CODE _UNAUTHORIZED , CODE _NOT _FOUND } = require ( './MagicNumbers.json' ) ;
//#endregion
//#region Variables, module setup
const ASS _LOGO = 'https://cdn.discordapp.com/icons/848274994375294986/8d339d4a2f3f54b2295e5e0ff62bd9e6.png?size=1024' ;
const app = express ( ) ;
// Configure filename and location settings
let users = { } ;
let data = { } ;
//#endregion
/ * *
* Operations to run to ensure ass can start properly
* /
function preStartup ( ) {
// Make sure data.json exists
if ( ! fs . existsSync ( path ( 'data.json' ) ) ) {
fs . writeJsonSync ( path ( 'data.json' ) , data , { spaces : 4 } ) ;
log ( 'File [data.json] created' ) ;
} else log ( 'File [data.json] exists' ) ;
// Make sure auth.json exists and generate the first key
if ( ! fs . existsSync ( path ( 'auth.json' ) ) ) {
const token = generateToken ( ) ;
users [ token ] = { username : 'ass' , count : 0 } ;
fs . writeJsonSync ( path ( 'auth.json' ) , { users } , { spaces : 4 } ) ;
log ( ` File [auth.json] created \n !! Important: save this token in a secure spot: ${ Object . keys ( users ) [ 0 ] } \n ` ) ;
} else log ( 'File [auth.json] exists' ) ;
// Read users and data
users = fs . readJsonSync ( path ( 'auth.json' ) ) . users || { } ;
data = fs . readJsonSync ( path ( 'data.json' ) ) ;
log ( 'Users & data read from filesystem' ) ;
// Monitor auth.json for changes (triggered by running 'npm run new-token')
fs . watch ( path ( 'auth.json' ) , { persistent : false } , ( eventType ) => eventType === 'change' && fs . readJson ( path ( 'auth.json' ) )
. then ( ( json ) => ! ( arrayEquals ( Object . keys ( users ) , Object . keys ( json . users ) ) ) && ( users = json . users ) && log ( ` New token added: ${ Object . keys ( users ) [ Object . keys ( users ) . length - 1 ] } ` ) ) // skipcq: JS-0243
. catch ( console . error ) ) ;
// Create thumbnails directory
fs . ensureDirSync ( path ( diskFilePath , 'thumbnails' ) ) ;
}
/ * *
* Builds the router
* ///todo: make this separate
* /
function startup ( ) {
// Enable/disable Express features
app . enable ( 'case sensitive routing' ) ;
app . disable ( "x-powered-by" ) ;
// Set Express variables
app . set ( 'trust proxy' , isProxied ) ;
app . set ( 'view engine' , 'pug' ) ;
// Express middleware
app . use ( useragent . express ( ) ) ;
// Helmet security middleware
app . use ( helmet . noSniff ( ) ) ;
app . use ( helmet . ieNoOpen ( ) ) ;
app . use ( helmet . xssFilter ( ) ) ;
app . use ( helmet . referrerPolicy ( ) ) ;
app . use ( helmet . dnsPrefetchControl ( ) ) ;
useSsl && app . use ( helmet . hsts ( { preload : true } ) ) ; // skipcq: JS-0093
// Rate limit middleware
app . use ( rateLimit ( {
windowMs : 1000 * 60 , // 60 seconds // skipcq: JS-0074
max : 90 // Limit each IP to 30 requests per windowMs // skipcq: JS-0074
} ) ) ;
// Don't process favicon requests (custom middleware)
app . use ( ( req , res , next ) => ( req . url . includes ( 'favicon.ico' ) ? res . sendStatus ( CODE _NO _CONTENT ) : next ( ) ) ) ;
// Index
app . get ( '/' , ( _req , res , next ) =>
fs . readFile ( path ( 'README.md' ) )
. then ( ( bytes ) => bytes . toString ( ) )
. then ( marked )
. then ( ( d ) => res . render ( 'index' , { data : d } ) )
. catch ( next ) ) ;
// Block unauthorized requests and attempt token sanitization
app . post ( '/' , ( req , res , next ) => {
req . token = req . headers . authorization . replace ( /[^\da-z]/gi , '' ) ; // Strip anything that isn't a digit or ASCII letter
! verify ( req , users ) ? res . sendStatus ( CODE _UNAUTHORIZED ) : next ( ) ; // skipcq: JS-0093
} ) ;
// Generate ID's to use for other functions
app . post ( '/' , ( req , _res , next ) => ( req . randomId = generateId ( 'random' , 32 , null , null ) , next ( ) ) ) ; // skipcq: JS-0074, JS-0086, JS-0090
app . post ( '/' , ( req , _res , next ) => ( req . deleteId = generateId ( 'random' , 32 , null , null ) , next ( ) ) ) ; // skipcq: JS-0074, JS-0086, JS-0090
// Upload file (local & S3) // skipcq: JS-0093
s3enabled
? app . post ( '/' , ( req , res , next ) => uploadS3 ( req , res , ( error ) => ( ( error ) && console . error ( error ) , next ( ) ) ) ) // skipcq: JS-0090
: app . post ( '/' , uploadLocal , ( { next } ) => next ( ) ) ;
// Pre-response operations
app . post ( '/' , ( req , _res , next ) => {
req . file . randomId = req . randomId ;
req . file . deleteId = req . deleteId ;
// Sanitize filename just in case Multer didn't catch it
req . file . originalname = sanitize ( req . file . originalname ) ;
// Download a temp copy to work with if using S3 storage
( s3enabled ? downloadTempS3 ( req . file ) : new Promise ( ( resolve ) => resolve ( ) ) )
// Generate the Thumbnail, Vibrant, and SHA1 hash
. then ( ( ) => Promise . all ( [ Thumbnail ( req . file ) , Vibrant ( req . file ) , Hash ( req . file ) ] ) )
// skipcq: JS-0086
. then ( ( [ thumbnail , vibrant , sha1 ] ) => (
req . file . thumbnail = thumbnail , // skipcq: JS-0090
req . file . vibrant = vibrant , // skipcq: JS-0090
req . file . sha1 = sha1 // skipcq: JS-0090
) )
// Remove the temp file if using S3 storage, otherwise rename the local file
. then ( ( ) => ( s3enabled ? fs . remove ( path ( diskFilePath , req . file . originalname ) ) : renameFile ( req , saveAsOriginal ? req . file . originalname : req . file . sha1 ) ) )
. then ( ( ) => next ( ) )
. catch ( ( err ) => next ( err ) ) ;
} ) ;
// Process uploaded file
app . post ( '/' , ( req , res ) => {
// Load overrides
const trueDomain = getTrueDomain ( req . headers [ "x-ass-domain" ] ) ;
const generator = req . headers [ "x-ass-access" ] || resourceIdType ;
// Get the uploaded time in milliseconds
req . file . timestamp = DateTime . now ( ) . toMillis ( ) ;
// Keep track of the token that uploaded the resource
req . file . token = req . token ;
// Attach any embed overrides, if necessary
req . file . opengraph = {
title : req . headers [ 'x-ass-og-title' ] ,
description : req . headers [ 'x-ass-og-description' ] ,
author : req . headers [ 'x-ass-og-author' ] ,
authorUrl : req . headers [ 'x-ass-og-author-url' ] ,
provider : req . headers [ 'x-ass-og-provider' ] ,
providerUrl : req . headers [ 'x-ass-og-provider-url' ] ,
color : req . headers [ 'x-ass-og-color' ]
} ;
// Save the file information
const resourceId = generateId ( generator , resourceIdSize , req . headers [ 'x-ass-gfycat' ] || gfyIdSize , req . file . originalname ) ;
data [ resourceId . split ( '.' ) [ 0 ] ] = req . file ;
saveData ( data ) ;
// Log the upload
const logInfo = ` ${ req . file . originalname } ( ${ req . file . mimetype } ) ` ;
log ( ` Uploaded: ${ logInfo } (user: ${ users [ req . token ] ? users [ req . token ] . username : '<token-only>' } ) ` ) ;
// Build the URLs
const resourceUrl = ` ${ getTrueHttp ( ) } ${ trueDomain } / ${ resourceId } ` ;
const thumbnailUrl = ` ${ getTrueHttp ( ) } ${ trueDomain } / ${ resourceId } /thumbnail ` ;
const deleteUrl = ` ${ getTrueHttp ( ) } ${ trueDomain } / ${ resourceId } /delete/ ${ req . file . deleteId } ` ;
// Send the response
res . type ( 'json' ) . send ( { resource : resourceUrl , thumbnail : thumbnailUrl , delete : deleteUrl } )
. on ( 'finish' , ( ) => {
// After we have sent the user the response, also send a Webhook to Discord (if headers are present)
if ( req . headers [ 'x-ass-webhook-client' ] && req . headers [ 'x-ass-webhook-token' ] ) {
// Build the webhook client & embed
const whc = new WebhookClient ( req . headers [ 'x-ass-webhook-client' ] , req . headers [ 'x-ass-webhook-token' ] ) ;
const embed = new MessageEmbed ( )
. setTitle ( logInfo )
. setURL ( resourceUrl )
. setDescription ( ` **Size:** \` ${ formatBytes ( req . file . size ) } \` \n **[Delete]( ${ deleteUrl } )** ` )
. setThumbnail ( thumbnailUrl )
. setColor ( req . file . vibrant )
. setTimestamp ( req . file . timestamp ) ;
// Send the embed to the webhook, then delete the client after to free resources
whc . send ( null , {
username : req . headers [ 'x-ass-webhook-username' ] || 'ass' ,
avatarURL : req . headers [ 'x-ass-webhook-avatar' ] || ASS _LOGO ,
embeds : [ embed ]
} ) . then ( ( ) => whc . destroy ( ) ) ;
}
// Also update the users upload count
if ( ! users [ req . token ] ) {
const generateUsername = ( ) => generateId ( 'random' , 20 , null ) ; // skipcq: JS-0074
let username = generateUsername ( ) ;
while ( Object . values ( users ) . findIndex ( ( user ) => user . username === username ) !== - 1 ) // skipcq: JS-0073
username = generateUsername ( ) ;
users [ req . token ] = { username , count : 0 } ;
}
users [ req . token ] . count += 1 ;
fs . writeJsonSync ( path ( 'auth.json' ) , { users } , { spaces : 4 } )
} ) ;
} ) ;
// Middleware for parsing the resource ID and handling 404
app . use ( '/:resourceId' , ( req , res , next ) => {
// Parse the resource ID
req . ass = { resourceId : escape ( req . params . resourceId ) . split ( '.' ) [ 0 ] } ;
// If the ID is invalid, return 404. Otherwise, continue normally // skipcq: JS-0093
( ! req . ass . resourceId || ! data [ req . ass . resourceId ] ) ? res . sendStatus ( CODE _NOT _FOUND ) : next ( ) ;
} ) ;
// View file
app . get ( '/:resourceId' , ( req , res ) => {
const { resourceId } = req . ass ;
const fileData = data [ resourceId ] ;
const requiredItems = {
randomId : fileData . randomId ,
originalname : escape ( fileData . originalname ) ,
mimetype : fileData . mimetype ,
size : fileData . size ,
timestamp : fileData . timestamp ,
opengraph : fileData . opengraph ,
vibrant : fileData . vibrant ,
} ;
// If the client is a social bot (such as Discord or Instagram), send an Open Graph embed
if ( req . useragent . isBot ) return res . type ( 'html' ) . send ( new OpenGraph ( getTrueHttp ( ) , getTrueDomain ( ) , resourceId , requiredItems ) . build ( ) ) ;
else res . redirect ( ` ${ req . url } /direct ` ) ;
} ) ;
// Direct resource
app . get ( '/:resourceId/direct*' , ( req , res ) => {
const { resourceId } = req . ass ;
const fileData = data [ resourceId ] ;
// Return the file differently depending on what storage option was used
const uploaders = {
s3 : ( ) => fetch ( getS3url ( fileData . randomId , fileData . mimetype ) ) . then ( ( file ) => {
file . headers . forEach ( ( value , header ) => res . setHeader ( header , value ) ) ;
file . body . pipe ( res ) ;
} ) ,
local : ( ) => {
res . header ( 'Accept-Ranges' , 'bytes' ) . header ( 'Content-Length' , fileData . size ) . type ( fileData . mimetype ) ;
fs . createReadStream ( path ( fileData . path ) ) . pipe ( res ) ;
}
} ;
uploaders [ s3enabled ? 's3' : 'local' ] ( ) ;
} ) ;
// Thumbnail response
app . get ( '/:resourceId/thumbnail' , ( req , res ) => {
const { resourceId } = req . ass ;
// Read the file and send it to the client
fs . readFile ( path ( diskFilePath , 'thumbnails/' , data [ resourceId ] . thumbnail ) )
. then ( ( fileData ) => res . type ( 'jpg' ) . send ( fileData ) )
. catch ( console . error ) ;
} ) ;
// oEmbed response for clickable authors/providers
// https://oembed.com/
// https://old.reddit.com/r/discordapp/comments/82p8i6/a_basic_tutorial_on_how_to_get_the_most_out_of/
app . get ( '/:resourceId/oembed.json' , ( req , res ) => {
const { resourceId } = req . ass ;
// Build the oEmbed object & send the response
const { opengraph , mimetype } = data [ resourceId ] ;
res . type ( 'json' ) . send ( {
version : '1.0' ,
type : mimetype . includes ( 'video' ) ? 'video' : 'photo' ,
author _name : opengraph . author ,
author _url : opengraph . authorUrl ,
provider _name : opengraph . provider ,
provider _url : opengraph . providerUrl
} ) ;
} ) ;
// Delete file
app . get ( '/:resourceId/delete/:deleteId' , ( req , res ) => {
const { resourceId } = req . ass ;
const deleteId = escape ( req . params . deleteId ) ;
const fileData = data [ resourceId ] ;
// If the delete ID doesn't match, don't delete the file
if ( deleteId !== fileData . deleteId ) return res . sendStatus ( CODE _UNAUTHORIZED ) ;
// If the ID is invalid, return 400 because we are unable to process the resource
if ( ! resourceId || ! fileData ) return res . sendStatus ( CODE _BAD _REQUEST ) ;
log ( ` Deleted: ${ fileData . originalname } ( ${ fileData . mimetype } ) ` ) ;
// Save the file information
Promise . all ( [ s3enabled ? deleteS3 ( fileData ) : fs . rmSync ( path ( fileData . path ) ) , fs . rmSync ( path ( diskFilePath , 'thumbnails/' , fileData . thumbnail ) ) ] )
. then ( ( ) => {
delete data [ resourceId ] ;
saveData ( data ) ;
res . type ( 'text' ) . send ( 'File has been deleted!' ) ;
} )
. catch ( console . error ) ;
} ) ;
// Host the server
app . listen ( port , host , ( ) => log ( ` Server started on [ ${ host } : ${ port } ] \n Authorized users: ${ Object . keys ( users ) . length } \n Available files: ${ Object . keys ( data ) . length } ` ) ) ;
}
preStartup ( ) ;
startup ( ) ;