263 lines
9.5 KiB
JavaScript
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}`);
|
||
|
})
|