import { rename, readFile, unlink, mkdir, mkdtemp, rmdir } from 'fs/promises'; import { createWriteStream } from 'fs'; import { tmpdir } from 'node:os'; import { promisify } from 'util'; import { pipeline } from 'stream'; import filenamify from "filenamify"; import Fastify from 'fastify' import FastifyMultipartPlugin from '@fastify/multipart' import FastifyStaticPlugin from '@fastify/static' import Database from 'better-sqlite3'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import {fileTypeFromFile} from 'file-type'; import {filesize} from "filesize"; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime.js' dayjs.extend(relativeTime, { thresholds: [ { l: 's', r: 1 }, { l: 'm', r: 1 }, { l: 'mm', r: 59, d: 'minute' }, { l: 'h', r: 1 }, { l: 'hh', r: 23, d: 'hour' }, { l: 'd', r: 1 }, { l: 'dd', r: 29, d: 'day' }, { l: 'M', r: 1 }, { l: 'MM', r: 11, d: 'month' }, { l: 'y', r: 1 }, { l: 'yy', d: 'year' } ] }); const fastify = Fastify({logger: true, bodyLimit: 104857600, trustProxy: '127.0.0.1' }); const db = new Database('tmpfiles.db', { verbose: console.log }); db.pragma('journal_mode = WAL'); db.exec("CREATE TABLE IF NOT EXISTS tmpfiles (filename CHAR, size INT, filetype CHAR, visits INT, visitsMax INT, expiry TEXT)"); const __dirname = dirname(fileURLToPath(import.meta.url)); const pump = promisify(pipeline); const notfound = await readFile(join(__dirname, "./templates/404.html")); const statement = db.prepare(`INSERT INTO tmpfiles(filename, size, filetype, visits, visitsMax, expiry) VALUES(?, ?, ?, ?, ?, ?)`); const download = await readFile(join(__dirname, "/templates/download.html")); const api = await readFile(join(__dirname, "/templates/api.html")); fastify.register(FastifyMultipartPlugin); fastify.register(FastifyStaticPlugin, { root: join(__dirname, 'public'), wildcard: false }); function makeDLTemplate(file, id, hostname) { var response = download.toString() .replace("__DLHOST__", hostname) .replaceAll("__DLURL__", file.filename) .replace("__DSIZE__", filesize(file.size)) .replaceAll("__DID__", id) .replace("__DTIME__", dayjs.unix(file.expiry).fromNow(true)); if(file.filetype.startsWith("image/")) { response = response.replace("__DPREV__", ``); }else{ response = response.replace("__DPREV__", ""); } return response; } fastify.get('/api.html', (request, reply) => { return reply.status(200).type("text/html").send(api.toString().replaceAll("__APIHOST__", `${request.protocol}://${request.hostname}`)) }) fastify.get('/:id/:filename', (request, reply) => { if(!/^\d+$/.test(request.params["id"])) { reply.code(400); return reply.send("Bad Request") } const id = request.params["id"]; const filename = filenamify(request.params["filename"]); const file = db.prepare("SELECT * FROM tmpfiles WHERE rowid = ? AND filename = ?").get(id, filename); if(typeof file === "undefined") { return reply.status(404).type("text/html").send(notfound); } const expired = dayjs() > dayjs.unix(file.expiry) || (file.visits >= file.visitsMax && file.visitsMax); if(expired) { return reply.status(404).type("text/html").send(notfound); } return reply.status(200).type("text/html").send(makeDLTemplate(file, id, `${request.protocol}://${request.hostname}`)); }); fastify.get('/dl/:id/:filename', (request, reply) => { if(!/^\d+$/.test(request.params["id"])) { reply.code(400); return reply.send("Bad Request") } const id = request.params["id"]; const filename = filenamify(request.params["filename"]); const file = db.prepare("SELECT * FROM tmpfiles WHERE rowid = ? AND filename = ?").get(id, filename); if(typeof file === "undefined") { return reply.status(404).type("text/html").send(notfound); } const expired = dayjs() > dayjs.unix(file.expiry) || (file.visits >= file.visitsMax && file.visitsMax); if(expired) { return reply.status(404).type("text/html").send(notfound); } db.prepare("UPDATE tmpfiles SET visits = ? WHERE rowid = ? AND filename = ?").run(file.visits + 1, id, filename); return reply.header("Content-Disposition", "attachment").sendFile(filename, join(__dirname, "files", id)); }); fastify.post('/api/v1/upload', async (req, reply) => { if(!req.headers['content-length'] || !req.isMultipart) { reply.code(400); return reply.send({status: "error", message: "Bad Request, make sure your request matches the API documentation."}); }else if(Number(req.headers['content-length']) > req.routeOptions.bodyLimit) { reply.code(413); return reply.send({status: "error", message: "Request Entity Too Large"}); } const data = await req.file(); if(data == null) { reply.code(400); return reply.send({status: "error", message: "Bad Request, the file field is required."}); } const filename = filenamify(data.filename); const tempdir = await mkdtemp(join(tmpdir(), 'tmpfiles-')); const pending = join(tempdir, filename); await pump(data.file, createWriteStream(pending)); if (data.file.truncated) { // This shouldn't happen given the check above await unlink(pending); reply.code(413); return reply.send({status: "error", message: "Request Entity Too Large"}); }else{ var exp; try { exp = Number(data.fields["max_time"].value); }catch{ exp = 60; }; if(Number.isNaN(exp) || exp < 1 || exp > 120) { await unlink(pending); reply.code(400); return reply.send({status: "error", message: "Bad Request, max_views must be between 1 and 120."}); } var max; try { max = Number(data.fields["max_views"].value); }catch{ max = 60; }; if(Number.isNaN(max) || max < 0 || max > 1000) { await unlink(pending); reply.code(400); return reply.send({status: "error", message: "Bad Request, max_views must be between 0 and 1000."}); } var mime; const filetype = await fileTypeFromFile(pending); if(typeof filetype === "undefined") { mime = "application/octet-stream"; }else{ mime = filetype.mime; } const expiry = dayjs().add(exp, "minutes").unix(); const record = statement.run(filename, Number(req.headers['content-length']), mime, 0, max, Number(expiry)); if(record.changes) { await mkdir(join(__dirname, "files", record.lastInsertRowid.toString())); await rename(pending, join(__dirname, "files", record.lastInsertRowid.toString(), filename)); await rmdir(dirname(pending)); return reply.send({status: "success", data: { url: `${req.protocol}://${req.hostname}/${record.lastInsertRowid}/${filename}`}}); }else{ return reply.status(500).type("text/plain").send({status: "error", message: "Internal Server Error"}); } } }); fastify.post('/', async (req, reply) => { if(!req.headers['content-length'] || !req.isMultipart) { reply.code(400); return reply.send("Bad Request") }else if(Number(req.headers['content-length']) > req.routeOptions.bodyLimit) { reply.code(413); return reply.send("Request Entity Too Large"); } const data = await req.file(); if(data == null) { reply.code(400); return reply.send("Bad Request"); } const filename = filenamify(data.filename); const tempdir = await mkdtemp(join(tmpdir(), 'tmpfiles-')); const pending = join(tempdir, filename); await pump(data.file, createWriteStream(pending)); if (data.file.truncated) { // This shouldn't happen given the check above await unlink(pending); reply.code(413); return reply.send("Request Entity Too Large"); }else{ if(!data.fields["max_time"] || !data.fields["max_views"]) { await unlink(pending); reply.code(400); return reply.send("Bad Request") } var exp = Number(data.fields["max_time"].value); if(Number.isNaN(exp) || exp < 1 || exp > 120) { await unlink(pending); reply.code(400); return reply.send("Bad Request"); } var max = Number(data.fields["max_views"].value); if(Number.isNaN(max) || max < 0 || max > 1000) { await unlink(pending); reply.code(400); return reply.send("Bad Request"); } var mime; const filetype = await fileTypeFromFile(pending); if(typeof filetype === "undefined") { mime = "application/octet-stream"; }else{ mime = filetype.mime; } const expiry = dayjs().add(exp, "minutes").unix(); const record = statement.run(filename, Number(req.headers['content-length']), mime, 0, max, Number(expiry)); if(record.changes) { await mkdir(join(__dirname, "files", record.lastInsertRowid.toString())); await rename(pending, join(__dirname, "files", record.lastInsertRowid.toString(), filename)); await rmdir(dirname(pending)); return reply.redirect(302, `${req.protocol}://${req.hostname}/${record.lastInsertRowid}/${filename}`); }else{ return reply.status(500).type("text/plain").send("Internal Server Error"); } } }); fastify.setNotFoundHandler((_, res) => { res.status(404).type("text/html").send(notfound); }); fastify.setErrorHandler((_, __, res) => { res.status(500).type("text/plain").send("Internal Server Error"); }); fastify.listen({ port: 8080 }, (err, address) => { if (err) throw err; console.log(`Server is now listening on ${address}`); })