2022-06-19 00:25:21 +00:00
import Config from "../config" ;
import busboy , { BusboyHeaders } from "@fastify/busboy" ;
import { v4 as uuidv4 } from "uuid" ;
import path from "path" ;
import fs from "fs" ;
import fileType from "file-type" ;
import readChunk from "read-chunk" ;
import crypto from "crypto" ;
import isUtf8 from "is-utf8" ;
import log from "../log" ;
import contentDisposition from "content-disposition" ;
import type { Socket } from "socket.io" ;
import { Request , Response } from "express" ;
2021-04-03 10:32:49 +00:00
// Map of allowed mime types to their respecive default filenames
// that will be rendered in browser without forcing them to be downloaded
const inlineContentDispositionTypes = {
"application/ogg" : "media.ogx" ,
"audio/midi" : "audio.midi" ,
"audio/mpeg" : "audio.mp3" ,
"audio/ogg" : "audio.ogg" ,
"audio/vnd.wave" : "audio.wav" ,
2021-05-06 00:02:23 +00:00
"audio/x-flac" : "audio.flac" ,
"audio/x-m4a" : "audio.m4a" ,
2021-04-03 10:32:49 +00:00
"image/bmp" : "image.bmp" ,
"image/gif" : "image.gif" ,
"image/jpeg" : "image.jpg" ,
"image/png" : "image.png" ,
"image/webp" : "image.webp" ,
"image/avif" : "image.avif" ,
2021-05-08 08:10:45 +00:00
"image/jxl" : "image.jxl" ,
2021-04-03 10:32:49 +00:00
"text/plain" : "text.txt" ,
"video/mp4" : "video.mp4" ,
"video/ogg" : "video.ogv" ,
"video/webm" : "video.webm" ,
} ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
const uploadTokens = new Map ( ) ;
2018-09-03 07:30:05 +00:00
class Uploader {
2022-06-19 00:25:21 +00:00
constructor ( socket : Socket ) {
2019-02-19 15:12:08 +00:00
socket . on ( "upload:auth" , ( ) = > {
const token = uuidv4 ( ) ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
socket . emit ( "upload:auth" , token ) ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
// Invalidate the token in one minute
2020-07-15 09:29:02 +00:00
const timeout = Uploader . createTokenTimeout ( token ) ;
uploadTokens . set ( token , timeout ) ;
} ) ;
socket . on ( "upload:ping" , ( token ) = > {
if ( typeof token !== "string" ) {
return ;
}
let timeout = uploadTokens . get ( token ) ;
if ( ! timeout ) {
return ;
}
clearTimeout ( timeout ) ;
timeout = Uploader . createTokenTimeout ( token ) ;
uploadTokens . set ( token , timeout ) ;
2019-02-19 15:12:08 +00:00
} ) ;
2018-09-03 07:30:05 +00:00
}
2022-06-19 00:25:21 +00:00
static createTokenTimeout ( this : void , token : string ) {
2020-07-15 09:29:02 +00:00
return setTimeout ( ( ) = > uploadTokens . delete ( token ) , 60 * 1000 ) ;
2018-09-03 07:30:05 +00:00
}
2022-06-19 00:25:21 +00:00
// TODO: type
static router ( this : void , express : any ) {
2019-02-19 15:12:08 +00:00
express . get ( "/uploads/:name/:slug*?" , Uploader . routeGetFile ) ;
express . post ( "/uploads/new/:token" , Uploader . routeUploadFile ) ;
}
2022-06-19 00:25:21 +00:00
static async routeGetFile ( this : void , req : Request , res : Response ) {
2019-02-19 15:12:08 +00:00
const name = req . params . name ;
const nameRegex = /^[0-9a-f]{16}$/ ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
if ( ! nameRegex . test ( name ) ) {
return res . status ( 404 ) . send ( "Not found" ) ;
}
const folder = name . substring ( 0 , 2 ) ;
2022-05-01 19:12:39 +00:00
const uploadPath = Config . getFileUploadPath ( ) ;
2019-02-19 15:12:08 +00:00
const filePath = path . join ( uploadPath , folder , name ) ;
2020-02-26 08:07:40 +00:00
let detectedMimeType = await Uploader . getFileType ( filePath ) ;
2019-02-19 15:12:08 +00:00
// doesn't exist
if ( detectedMimeType === null ) {
return res . status ( 404 ) . send ( "Not found" ) ;
}
2020-07-15 09:29:02 +00:00
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
2021-04-03 10:32:49 +00:00
let slug = req . params . slug ;
const isInline = detectedMimeType in inlineContentDispositionTypes ;
let disposition = isInline ? "inline" : "attachment" ;
if ( ! slug && isInline ) {
slug = inlineContentDispositionTypes [ detectedMimeType ] ;
}
if ( slug ) {
disposition = contentDisposition ( slug . trim ( ) , {
fallback : false ,
type : disposition ,
} ) ;
}
2019-02-19 15:12:08 +00:00
2021-05-06 00:02:23 +00:00
// Send a more common mime type for audio files
// so that browsers can play them correctly
2020-02-26 08:07:40 +00:00
if ( detectedMimeType === "audio/vnd.wave" ) {
detectedMimeType = "audio/wav" ;
2021-05-06 00:02:23 +00:00
} else if ( detectedMimeType === "audio/x-flac" ) {
detectedMimeType = "audio/flac" ;
2022-02-12 01:42:59 +00:00
} else if ( detectedMimeType === "audio/x-m4a" ) {
detectedMimeType = "audio/mp4" ;
2022-04-12 00:50:00 +00:00
} else if ( detectedMimeType === "video/quicktime" ) {
detectedMimeType = "video/mp4" ;
2020-02-26 08:07:40 +00:00
}
2021-04-03 10:32:49 +00:00
res . setHeader ( "Content-Disposition" , disposition ) ;
2019-02-19 15:12:08 +00:00
res . setHeader ( "Cache-Control" , "max-age=86400" ) ;
res . contentType ( detectedMimeType ) ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
return res . sendFile ( filePath ) ;
}
2022-06-19 00:25:21 +00:00
static routeUploadFile ( this : void , req : Request , res : Response ) {
let busboyInstance : NodeJS.WritableStream | busboy | null | undefined ;
let uploadUrl : string | URL ;
let randomName : string ;
let destDir : fs.PathLike ;
let destPath : fs.PathLike | null ;
let streamWriter : fs.WriteStream | null ;
2019-02-19 15:12:08 +00:00
const doneCallback = ( ) = > {
// detach the stream and drain any remaining data
if ( busboyInstance ) {
req . unpipe ( busboyInstance ) ;
req . on ( "readable" , req . read . bind ( req ) ) ;
busboyInstance . removeAllListeners ( ) ;
busboyInstance = null ;
2018-09-03 07:30:05 +00:00
}
2019-02-19 15:12:08 +00:00
// close the output file stream
if ( streamWriter ) {
streamWriter . end ( ) ;
streamWriter = null ;
}
} ;
2018-09-03 07:30:05 +00:00
2022-06-19 00:25:21 +00:00
const abortWithError = ( err : any ) = > {
2019-02-19 15:12:08 +00:00
doneCallback ( ) ;
// if we ended up erroring out, delete the output file from disk
if ( destPath && fs . existsSync ( destPath ) ) {
fs . unlinkSync ( destPath ) ;
destPath = null ;
2018-09-03 07:30:05 +00:00
}
2019-02-19 15:12:08 +00:00
return res . status ( 400 ) . json ( { error : err.message } ) ;
} ;
// if the authentication token is incorrect, bail out
if ( uploadTokens . delete ( req . params . token ) !== true ) {
2019-08-25 17:14:34 +00:00
return abortWithError ( Error ( "Invalid upload token" ) ) ;
2019-02-19 15:12:08 +00:00
}
// if the request does not contain any body data, bail out
2022-06-19 00:25:21 +00:00
if ( req . headers [ "content-length" ] && parseInt ( req . headers [ "content-length" ] ) < 1 ) {
2019-02-19 15:12:08 +00:00
return abortWithError ( Error ( "Length Required" ) ) ;
}
// Only allow multipart, as busboy can throw an error on unsupported types
2022-06-19 00:25:21 +00:00
if (
! (
req . headers [ "content-type" ] &&
req . headers [ "content-type" ] . startsWith ( "multipart/form-data" )
)
) {
2019-02-19 15:12:08 +00:00
return abortWithError ( Error ( "Unsupported Content Type" ) ) ;
}
// create a new busboy processor, it is wrapped in try/catch
// because it can throw on malformed headers
try {
busboyInstance = new busboy ( {
2022-06-19 00:25:21 +00:00
headers : req.headers as BusboyHeaders ,
2019-02-19 15:12:08 +00:00
limits : {
files : 1 , // only allow one file per upload
fileSize : Uploader.getMaxFileSize ( ) ,
} ,
} ) ;
} catch ( err ) {
return abortWithError ( err ) ;
}
// Any error or limit from busboy will abort the upload with an error
busboyInstance . on ( "error" , abortWithError ) ;
busboyInstance . on ( "partsLimit" , ( ) = > abortWithError ( Error ( "Parts limit reached" ) ) ) ;
busboyInstance . on ( "filesLimit" , ( ) = > abortWithError ( Error ( "Files limit reached" ) ) ) ;
busboyInstance . on ( "fieldsLimit" , ( ) = > abortWithError ( Error ( "Fields limit reached" ) ) ) ;
// generate a random output filename for the file
// we use do/while loop to prevent the rare case of generating a file name
// that already exists on disk
do {
randomName = crypto . randomBytes ( 8 ) . toString ( "hex" ) ;
2022-05-01 19:12:39 +00:00
destDir = path . join ( Config . getFileUploadPath ( ) , randomName . substring ( 0 , 2 ) ) ;
2019-02-19 15:12:08 +00:00
destPath = path . join ( destDir , randomName ) ;
} while ( fs . existsSync ( destPath ) ) ;
// we split the filename into subdirectories (by taking 2 letters from the beginning)
// this helps avoid file system and certain tooling limitations when there are
// too many files on one folder
try {
2020-03-16 11:53:48 +00:00
fs . mkdirSync ( destDir , { recursive : true } ) ;
2022-06-19 00:25:21 +00:00
} catch ( err : any ) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
log . error ( ` Error ensuring ${ destDir } exists for uploads: ${ err . message } ` ) ;
2019-02-19 15:12:08 +00:00
return abortWithError ( err ) ;
}
2021-08-31 19:27:43 +00:00
// Open a file stream for writing
streamWriter = fs . createWriteStream ( destPath ) ;
streamWriter . on ( "error" , abortWithError ) ;
2022-06-19 00:25:21 +00:00
busboyInstance . on (
"file" ,
(
fieldname : any ,
fileStream : {
on : (
arg0 : string ,
arg1 : { ( err : any ) : Response < any , Record < string , any > > ; ( ) : void }
) = > void ;
unpipe : ( arg0 : any ) = > void ;
read : { bind : ( arg0 : any ) = > any } ;
pipe : ( arg0 : any ) = > void ;
} ,
filename : string | number | boolean
) = > {
uploadUrl = ` ${ randomName } / ${ encodeURIComponent ( filename ) } ` ;
if ( Config . values . fileUpload . baseUrl ) {
uploadUrl = new URL ( uploadUrl , Config . values . fileUpload . baseUrl ) . toString ( ) ;
} else {
uploadUrl = ` uploads/ ${ uploadUrl } ` ;
}
// if the busboy data stream errors out or goes over the file size limit
// abort the processing with an error
// @ts-expect-error Argument of type '(err: any) => Response<any, Record<string, any>>' is not assignable to parameter of type '{ (err: any): Response<any, Record<string, any>>; (): void; }'.ts(2345)
fileStream . on ( "error" , abortWithError ) ;
fileStream . on ( "limit" , ( ) = > {
fileStream . unpipe ( streamWriter ) ;
fileStream . on ( "readable" , fileStream . read . bind ( fileStream ) ) ;
return abortWithError ( Error ( "File size limit reached" ) ) ;
} ) ;
// Attempt to write the stream to file
fileStream . pipe ( streamWriter ) ;
2019-10-31 11:21:22 +00:00
}
2022-06-19 00:25:21 +00:00
) ;
2021-08-31 19:27:43 +00:00
busboyInstance . on ( "finish" , ( ) = > {
doneCallback ( ) ;
2021-04-13 18:39:45 +00:00
2021-08-31 19:27:43 +00:00
if ( ! uploadUrl ) {
return res . status ( 400 ) . json ( { error : "Missing file" } ) ;
2021-04-01 14:46:45 +00:00
}
2021-08-31 19:27:43 +00:00
// upload was done, send the generated file url to the client
res . status ( 200 ) . json ( {
url : uploadUrl ,
} ) ;
2018-09-03 07:30:05 +00:00
} ) ;
2019-02-19 15:12:08 +00:00
// pipe request body to busboy for processing
return req . pipe ( busboyInstance ) ;
2018-09-03 07:30:05 +00:00
}
static getMaxFileSize() {
2022-05-01 19:12:39 +00:00
const configOption = Config . values . fileUpload . maxFileSize ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
// Busboy uses Infinity to allow unlimited file size
if ( configOption < 1 ) {
return Infinity ;
2018-09-03 07:30:05 +00:00
}
2019-02-19 15:12:08 +00:00
// maxFileSize is in bytes, but config option is passed in as KB
2018-09-03 07:30:05 +00:00
return configOption * 1024 ;
}
2019-01-30 15:36:37 +00:00
// Returns null if an error occurred (e.g. file not found)
// Returns a string with the type otherwise
2022-06-19 00:25:21 +00:00
static async getFileType ( filePath : string ) {
2018-09-03 07:30:05 +00:00
try {
2020-02-06 10:41:43 +00:00
const buffer = await readChunk ( filePath , 0 , 5120 ) ;
2018-09-03 07:30:05 +00:00
2019-01-30 15:36:37 +00:00
// returns {ext, mime} if found, null if not.
2020-01-08 14:11:01 +00:00
const file = await fileType . fromBuffer ( buffer ) ;
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
// if a file type was detected correctly, return it
2019-01-30 15:36:37 +00:00
if ( file ) {
return file . mime ;
}
2019-02-19 15:12:08 +00:00
// if the buffer is a valid UTF-8 buffer, use text/plain
2019-01-30 15:36:37 +00:00
if ( isUtf8 ( buffer ) ) {
return "text/plain" ;
}
2018-09-03 07:30:05 +00:00
2019-02-19 15:12:08 +00:00
// otherwise assume it's random binary data
2019-01-30 15:36:37 +00:00
return "application/octet-stream" ;
2022-06-19 00:25:21 +00:00
} catch ( e : any ) {
2019-01-30 15:36:37 +00:00
if ( e . code !== "ENOENT" ) {
2022-06-19 00:25:21 +00:00
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
2019-01-30 15:36:37 +00:00
log . warn ( ` Failed to read ${ filePath } : ${ e . message } ` ) ;
}
2018-09-03 07:30:05 +00:00
}
2019-01-30 15:36:37 +00:00
return null ;
2018-09-03 07:30:05 +00:00
}
}
2022-06-19 00:25:21 +00:00
export default Uploader ;