tmpfiles/index.js

263 lines
9.5 KiB
JavaScript

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__", `<img id="img_preview" src="/dl/${id}/${file.filename}">`);
}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}`);
})