refactor: migrate to fastify

- (maybe) faster http server
- easy to develop on
This commit is contained in:
diced 2022-12-07 19:21:26 -08:00
parent ea1a0b7fc8
commit eadfa09570
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
19 changed files with 1857 additions and 1165 deletions

View file

@ -2,15 +2,6 @@
* @type {import('next').NextConfig} * @type {import('next').NextConfig}
**/ **/
module.exports = { module.exports = {
async redirects() {
return [
{
source: '/',
destination: '/dashboard',
permanent: true,
},
];
},
images: { images: {
domains: [ domains: [
// For sharex icon in manage user // For sharex icon in manage user

View file

@ -50,6 +50,8 @@
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0", "dotenv-expand": "^9.0.0",
"exiftool-vendored": "^18.6.0", "exiftool-vendored": "^18.6.0",
"fastify": "^4.10.2",
"fastify-plugin": "^4.4.0",
"fflate": "^0.7.4", "fflate": "^0.7.4",
"find-my-way": "^7.3.1", "find-my-way": "^7.3.1",
"katex": "^0.16.3", "katex": "^0.16.3",

View file

@ -22,6 +22,10 @@ export default class Logger {
this.name = name; this.name = name;
} }
child(name: string) {
return new Logger(`${this.name}::${name}`);
}
info(...args: any[]): this { info(...args: any[]): this {
process.stdout.write(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' '))); process.stdout.write(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));

View file

@ -0,0 +1,36 @@
import { Image } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import exts from '../../lib/exts';
function dbFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('dbFile', dbFile);
done();
async function dbFile(this: FastifyReply, image: Image) {
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return this.server.nextHandle(this.request.raw, this.raw);
const data = await this.server.datasource.get(image.file);
if (!data) return this.notFound();
const size = await this.server.datasource.size(image.file);
this.header('Content-Length', size);
this.header('Content-Type', image.mimetype);
return this.send(data);
}
}
export default fastifyPlugin(dbFileDecorator, {
name: 'dbFile',
decorators: {
fastify: ['prisma', 'datasource', 'nextHandle', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
dbFile: (image: Image) => Promise<void>;
}
}

View file

@ -0,0 +1,30 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function notFound(fastify: FastifyInstance, _: unknown, done: () => void) {
fastify.decorateReply('notFound', notFound);
done();
function notFound(this: FastifyReply) {
if (this.server.config.features.headless) {
return this.callNotFound();
} else {
return this.server.nextServer.render404(this.request.raw, this.raw);
}
}
}
export default fastifyPlugin(notFound, {
name: 'notFound',
fastify: '4.x',
decorators: {
fastify: ['config', 'nextServer'],
},
dependencies: ['config', 'next'],
});
declare module 'fastify' {
interface FastifyReply {
notFound: () => void;
}
}

View file

@ -0,0 +1,39 @@
import { Image } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function postFileDecorator(fastify: FastifyInstance, _: unknown, done: () => void) {
fastify.decorateReply('postFile', postFile);
done();
async function postFile(this: FastifyReply, file: Image) {
const nFile = await this.server.prisma.image.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
if (nFile.maxViews && nFile.views >= nFile.maxViews) {
await this.server.datasource.delete(file.file);
await this.server.prisma.image.delete({ where: { id: nFile.id } });
this.server.logger
.child('file')
.info(`File ${file.file} has been deleted due to max views (${nFile.maxViews})`);
return true;
}
}
}
export default fastifyPlugin(postFileDecorator, {
name: 'postFile',
decorators: {
fastify: ['prisma', 'datasource', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
postFile: (file: Image) => Promise<boolean>;
}
}

View file

@ -0,0 +1,42 @@
import { Url } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function postUrlDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('postUrl', postUrl);
done();
async function postUrl(this: FastifyReply, url: Url) {
const nUrl = await this.server.prisma.url.update({
where: {
id: url.id,
},
data: {
views: { increment: 1 },
},
});
if (nUrl.maxViews && nUrl.views >= nUrl.maxViews) {
await this.server.prisma.url.delete({
where: {
id: nUrl.id,
},
});
this.server.logger.child('url').info(`url deleted due to max views ${JSON.stringify(nUrl)}`);
}
}
}
export default fastifyPlugin(postUrlDecorator, {
name: 'postUrl',
decorators: {
fastify: ['prisma', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
postUrl: (url: Url) => Promise<void>;
}
}

View file

@ -0,0 +1,34 @@
import { Image } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function preFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('preFile', preFile);
done();
async function preFile(this: FastifyReply, file: Image) {
if (file.expires_at && file.expires_at < new Date()) {
await this.server.datasource.delete(file.file);
await this.server.prisma.image.delete({ where: { id: file.id } });
this.server.logger.child('file').info(`File ${file.file} expired and was deleted.`);
return true;
}
return false;
}
}
export default fastifyPlugin(preFileDecorator, {
name: 'preFile',
decorators: {
fastify: ['prisma', 'datasource', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
preFile: (file: Image) => Promise<boolean>;
}
}

View file

@ -0,0 +1,34 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { guess } from '../../lib/mimes';
import { extname } from 'path';
import fastifyPlugin from 'fastify-plugin';
function rawFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('rawFile', rawFile);
done();
async function rawFile(this: FastifyReply, id: string) {
const data = await this.server.datasource.get(id);
if (!data) return this.notFound();
const mimetype = await guess(extname(id).slice(1));
const size = await this.server.datasource.size(id);
this.header('Content-Length', size);
this.header('Content-Type', mimetype);
return this.send(data);
}
}
export default fastifyPlugin(rawFileDecorator, {
name: 'rawFile',
decorators: {
fastify: ['datasource', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
rawFile: (id: string) => Promise<void>;
}
}

View file

@ -1,348 +1,188 @@
import { Image, PrismaClient, Url } from '@prisma/client';
import Router from 'find-my-way';
import { createReadStream, existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import { createServer, IncomingMessage, OutgoingMessage, ServerResponse } from 'http';
import next from 'next';
import { NextServer, RequestHandler } from 'next/dist/server/next';
import { extname } from 'path';
import { version } from '../../package.json'; import { version } from '../../package.json';
import config from '../lib/config'; import config from '../lib/config';
import datasource from '../lib/datasource'; import datasource from '../lib/datasource';
import exts from '../lib/exts';
import Logger from '../lib/logger'; import Logger from '../lib/logger';
import { guess } from '../lib/mimes'; import { getStats } from './util';
import { getStats, log, migrations, redirect } from './util';
import fastify, { FastifyInstance } from 'fastify';
import { createReadStream, existsSync } from 'fs';
import dbFileDecorator from './decorators/dbFile';
import notFound from './decorators/notFound';
import postFileDecorator from './decorators/postFile';
import postUrlDecorator from './decorators/postUrl';
import preFileDecorator from './decorators/preFile';
import rawFileDecorator from './decorators/rawFile';
import configPlugin from './plugins/config';
import datasourcePlugin from './plugins/datasource';
import loggerPlugin from './plugins/logger';
import nextPlugin from './plugins/next';
import prismaPlugin from './plugins/prisma';
import rawRoute from './routes/raw';
import uploadsRoute, { uploadsRouteOnResponse } from './routes/uploads';
import urlsRoute, { urlsRouteOnResponse } from './routes/urls';
const dev = process.env.NODE_ENV === 'development'; const dev = process.env.NODE_ENV === 'development';
const logger = Logger.get('server'); const logger = Logger.get('server');
const server = fastify();
if (dev) {
server.addHook('onRoute', (opts) => {
logger.child('route').debug(JSON.stringify(opts));
});
}
start(); start();
async function start() { async function start() {
logger.debug('Starting server'); logger.debug('Starting server');
// annoy user if they didnt change secret from default "changethis" // plugins
if (config.core.secret === 'changethis') { server
logger .register(loggerPlugin)
.error('Secret is not set!') .register(configPlugin, config)
.error( .register(datasourcePlugin, datasource)
'Running Zipline as is, without a randomized secret is not recommended and leaves your instance at risk!' .register(prismaPlugin)
) .register(nextPlugin, {
.error('Please change your secret in the config file or environment variables.') dir: '.',
.error( dev,
'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.' quiet: !dev,
) hostname: config.core.host,
.error('It is recomended to use a secret that is alphanumeric and randomized.') port: config.core.port,
.error('A way you can generate this is through a password manager you may have.');
process.exit(1);
}
process.env.DATABASE_URL = config.core.database_url;
await migrations();
const prisma = new PrismaClient();
const admin = await prisma.user.findFirst({
where: {
id: 1,
OR: {
username: 'administrator',
},
},
});
if (admin) {
logger.debug('setting main administrator user to a superAdmin');
await prisma.user.update({
where: {
id: admin.id,
},
data: {
superAdmin: true,
},
}); });
}
if (config.datasource.type === 'local') { // decorators
await mkdir(config.datasource.local.directory, { recursive: true }); server
} .register(notFound)
.register(postUrlDecorator)
.register(postFileDecorator)
.register(preFileDecorator)
.register(rawFileDecorator)
.register(dbFileDecorator);
const nextServer = next({ server.addHook('onRequest', (req, reply, done) => {
dir: '.', if (config.features.headless) {
dev, const url = req.url.toLowerCase();
quiet: !dev, if (!url.startsWith('/api') || url === '/api') return reply.notFound();
hostname: config.core.host, }
port: config.core.port,
done();
}); });
const handle = nextServer.getRequestHandler(); server.addHook('onResponse', (req, reply, done) => {
const router = Router({ if (config.core.logger || dev || process.env.DEBUG) {
defaultRoute: (req, res) => { if (req.url.startsWith('/_next')) return done();
if (config.features.headless) {
const url = req.url.toLowerCase();
if (!url.startsWith('/api') || url === '/api') return notFound(req, res, nextServer);
}
handle(req, res); server.logger.child('response').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
}, server.logger.child('response').debug(
JSON.stringify({
method: req.method,
url: req.url,
headers: req.headers,
body: req.headers['content-type']?.startsWith('application/json') ? req.body : undefined,
})
);
}
done();
}); });
router.on('GET', '/favicon.ico', async (req, res) => { server.get('/favicon.ico', async (_, reply) => {
if (!existsSync('./public/favicon.ico')) return notFound(req, res, nextServer); if (!existsSync('./public/favicon.ico')) return reply.notFound();
const favicon = createReadStream('./public/favicon.ico'); const favicon = createReadStream('./public/favicon.ico');
res.setHeader('Content-Type', 'image/x-icon'); return reply.type('image/x-icon').send(favicon);
favicon.pipe(res);
}); });
const urlsRoute = async (req, res, params) => {
if (params.id === '') return notFound(req, res, nextServer);
else if (params.id === 'dashboard' && !config.features.headless)
return nextServer.render(req, res as ServerResponse, '/dashboard');
const url = await prisma.url.findFirst({
where: {
OR: [{ id: params.id }, { vanity: params.id }, { invisible: { invis: decodeURI(params.id) } }],
},
});
if (!url) return notFound(req, res, nextServer);
redirect(res, url.destination);
postUrl(url, prisma);
};
const uploadsRoute = async (req, res, params) => {
if (params.id === '') return notFound(req, res, nextServer);
else if (params.id === 'dashboard' && !config.features.headless)
return nextServer.render(req, res as ServerResponse, '/dashboard');
const image = await prisma.image.findFirst({
where: {
OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }],
},
});
if (!image) return rawFile(req, res, nextServer, params.id);
else {
const failed = await preFile(image, prisma);
if (failed) return notFound(req, res, nextServer);
if (image.password || image.embed || image.mimetype.startsWith('text/'))
redirect(res, `/view/${image.file}`);
else fileDb(req, res, nextServer, handle, image);
postFile(image, prisma);
}
};
// makes sure to handle both in one route as you cant have two handlers with the same route // makes sure to handle both in one route as you cant have two handlers with the same route
if (config.urls.route === '/' && config.uploader.route === '/') { if (config.urls.route === '/' && config.uploader.route === '/') {
router.on('GET', '/:id', async (req, res, params) => { server.route({
if (params.id === '') return notFound(req, res, nextServer); method: 'GET',
else if (params.id === 'dashboard' && !config.features.headless) url: '/:id',
return nextServer.render(req, res as ServerResponse, '/dashboard'); handler: async (req, reply) => {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
else if (id === 'dashboard' && !config.features.headless)
return server.nextServer.render(req.raw, reply.raw, '/dashboard');
const url = await prisma.url.findFirst({ const url = await server.prisma.url.findFirst({
where: { where: {
OR: [{ id: params.id }, { vanity: params.id }, { invisible: { invis: decodeURI(params.id) } }], OR: [{ id: id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
}, },
});
if (url) return urlsRoute.bind(server)(req, reply);
else return uploadsRoute.bind(server)(req, reply);
},
onResponse: async (req, reply, done) => {
if (reply.statusCode === 200) {
const { id } = req.params as { id: string };
const url = await server.prisma.url.findFirst({
where: {
OR: [{ id: id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
},
});
if (url) urlsRouteOnResponse.bind(server)(req, reply, done);
else uploadsRouteOnResponse.bind(server)(req, reply, done);
}
done();
},
});
} else {
server
.route({
method: 'GET',
url: config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`,
handler: urlsRoute.bind(server),
onResponse: urlsRouteOnResponse.bind(server),
})
.route({
method: 'GET',
url: config.uploader.route === '/' ? '/:id' : `${config.uploader.route}/:id`,
handler: uploadsRoute.bind(server),
onResponse: uploadsRouteOnResponse.bind(server),
}); });
if (url) return urlsRoute(req, res, params);
else return uploadsRoute(req, res, params);
});
} else {
router.on('GET', config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`, urlsRoute);
router.on('GET', config.uploader.route === '/' ? '/:id' : `${config.uploader.route}/:id`, uploadsRoute);
} }
router.on('GET', '/r/:id', async (req, res, params) => { server.get('/r/:id', rawRoute.bind(server));
if (params.id === '') return notFound(req, res, nextServer); server.get('/', (_, reply) => reply.redirect('/dashboard'));
const image = await prisma.image.findFirst({ await server.listen({
where: { port: config.core.port,
OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }], host: config.core.host ?? '0.0.0.0',
},
});
if (!image) await rawFile(req, res, nextServer, params.id);
else {
const failed = await preFile(image, prisma);
if (failed) return notFound(req, res, nextServer);
if (image.password) {
res.setHeader('Content-Type', 'application/json');
res.statusCode = 403;
return res.end(
JSON.stringify({
error: "can't view a raw file that has a password",
url: `/view/${image.file}`,
code: 403,
})
);
} else await rawFile(req, res, nextServer, params.id);
}
}); });
try { server.logger
await nextServer.prepare(); .info(`listening on ${config.core.host}:${config.core.port}`)
} catch (e) { .info(
console.log(e); `started ${dev ? 'development' : 'production'} zipline@${version} server${
process.exit(1); config.features.headless ? ' (headless)' : ''
} }`
);
const http = createServer((req, res) => { clearInvites.bind(server)();
router.lookup(req, res); stats.bind(server)();
if (config.core.logger) log(req.url);
});
http.on('error', (e) => { setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
logger.error(e); setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000);
process.exit(1);
});
http.on('listening', () => {
logger.info(`listening on ${config.core.host}:${config.core.port}`);
});
http.listen(config.core.port, config.core.host ?? '0.0.0.0');
logger.info(
`started ${dev ? 'development' : 'production'} zipline@${version} server${
config.features.headless ? ' (headless)' : ''
}`
);
stats(prisma);
clearInvites(prisma);
setInterval(() => clearInvites(prisma), config.core.invites_interval * 1000);
setInterval(() => stats(prisma), config.core.stats_interval * 1000);
} }
async function notFound(req: IncomingMessage, res: ServerResponse, nextServer: NextServer) { async function stats(this: FastifyInstance) {
if (config.features.headless) { const stats = await getStats(this.prisma, this.datasource);
res.statusCode = 404; await this.prisma.stats.create({
res.setHeader('Content-Type', 'application/json');
return res.end(JSON.stringify({ error: 'not found', url: req.url, code: 404 }));
} else {
return nextServer.render404(req, res);
}
}
async function preFile(file: Image, prisma: PrismaClient) {
if (file.expires_at && file.expires_at < new Date()) {
await datasource.delete(file.file);
await prisma.image.delete({ where: { id: file.id } });
Logger.get('file').info(`File ${file.file} expired and was deleted.`);
return true;
}
return false;
}
async function postFile(file: Image, prisma: PrismaClient) {
const nFile = await prisma.image.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
if (nFile.maxViews && nFile.views >= nFile.maxViews) {
await datasource.delete(file.file);
await prisma.image.delete({ where: { id: nFile.id } });
Logger.get('file').info(`File ${file.file} has been deleted due to max views (${nFile.maxViews})`);
return true;
}
}
async function postUrl(url: Url, prisma: PrismaClient) {
const nUrl = await prisma.url.update({
where: {
id: url.id,
},
data: {
views: { increment: 1 },
},
});
if (nUrl.maxViews && nUrl.views >= nUrl.maxViews) {
await prisma.url.delete({
where: {
id: nUrl.id,
},
});
Logger.get('url').debug(`url deleted due to max views ${JSON.stringify(nUrl)}`);
}
}
async function rawFile(req: IncomingMessage, res: OutgoingMessage, nextServer: NextServer, id: string) {
const data = await datasource.get(id);
if (!data) return notFound(req, res as ServerResponse, nextServer);
const mimetype = await guess(extname(id));
const size = await datasource.size(id);
res.setHeader('Content-Type', mimetype);
res.setHeader('Content-Length', size);
data.pipe(res);
data.on('error', (e) => {
logger.debug(`error while serving raw file ${id}: ${e}`);
notFound(req, res as ServerResponse, nextServer);
});
data.on('end', () => res.end());
}
async function fileDb(
req: IncomingMessage,
res: OutgoingMessage,
nextServer: NextServer,
handle: RequestHandler,
image: Image
) {
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return handle(req, res as ServerResponse);
const data = await datasource.get(image.file);
if (!data) return notFound(req, res as ServerResponse, nextServer);
const size = await datasource.size(image.file);
res.setHeader('Content-Type', image.mimetype);
res.setHeader('Content-Length', size);
data.pipe(res);
data.on('error', (e) => {
logger.debug(`error while serving raw file ${image.file}: ${e}`);
notFound(req, res as ServerResponse, nextServer);
});
data.on('end', () => res.end());
}
async function stats(prisma: PrismaClient) {
const stats = await getStats(prisma, datasource);
await prisma.stats.create({
data: { data: {
data: stats, data: stats,
}, },
}); });
logger.debug(`stats updated ${JSON.stringify(stats)}`); this.logger.child('stats').debug(`stats updated ${JSON.stringify(stats)}`);
} }
async function clearInvites(prisma: PrismaClient) { async function clearInvites(this: FastifyInstance) {
const { count } = await prisma.invite.deleteMany({ const { count } = await this.prisma.invite.deleteMany({
where: { where: {
OR: [ OR: [
{ {
@ -355,5 +195,5 @@ async function clearInvites(prisma: PrismaClient) {
}, },
}); });
logger.debug(`deleted ${count} used invites`); logger.child('invites').debug(`deleted ${count} used invites`);
} }

View file

@ -0,0 +1,45 @@
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { mkdir } from 'fs/promises';
import type { Config } from '../../lib/config/Config';
async function configPlugin(fastify: FastifyInstance, config: Config) {
fastify.decorate('config', config);
if (config.core.secret === 'changethis') {
fastify.logger
.error('Secret is not set!')
.error(
'Running Zipline as is, without a randomized secret is not recommended and leaves your instance at risk!'
)
.error('Please change your secret in the config file or environment variables.')
.error(
'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.'
)
.error('It is recomended to use a secret that is alphanumeric and randomized.')
.error('A way you can generate this is through a password manager you may have.');
process.exit(1);
}
if (config.datasource.type === 'local') {
await mkdir(config.datasource.local.directory, { recursive: true });
}
return;
}
export default fastifyPlugin(configPlugin, {
name: 'config',
fastify: '4.x',
decorators: {
fastify: ['logger'],
},
dependencies: ['logger'],
});
declare module 'fastify' {
interface FastifyInstance {
config: Config;
}
}

View file

@ -0,0 +1,19 @@
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import type { Datasource } from '../../lib/datasources';
function datasourcePlugin(fastify: FastifyInstance, datasource: Datasource, done: () => void) {
fastify.decorate('datasource', datasource);
done();
}
export default fastifyPlugin(datasourcePlugin, {
name: 'datasource',
fastify: '4.x',
});
declare module 'fastify' {
interface FastifyInstance {
datasource: Datasource;
}
}

View file

@ -0,0 +1,20 @@
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import Logger from '../../lib/logger';
function loggerPlugin(fastify: FastifyInstance, _: unknown, done: () => void) {
fastify.decorate('logger', Logger.get('server'));
done();
}
export default fastifyPlugin(loggerPlugin, {
name: 'logger',
fastify: '4.x',
});
declare module 'fastify' {
interface FastifyInstance {
logger: Logger;
}
}

View file

@ -0,0 +1,48 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { IncomingMessage, OutgoingMessage, ServerResponse } from 'http';
import next from 'next';
import { NextServerOptions } from 'next/dist/server/next';
async function nextPlugin(fastify: FastifyInstance, options: NextServerOptions) {
const nextServer = next(options);
const handle = nextServer.getRequestHandler();
await nextServer.prepare();
fastify
.decorate('nextServer', nextServer)
.decorate('nextHandle', handle)
.decorate('next', route.bind(fastify));
fastify.next('/*');
function route(path, opts: any = { method: 'get' }) {
this[opts.method.toLowerCase()](path, opts, handler);
async function handler(req: FastifyRequest, reply: FastifyReply) {
for (const [key, value] of Object.entries(reply.getHeaders())) {
reply.raw.setHeader(key, value);
}
await handle(req.raw, reply.raw);
reply.hijack();
}
}
return;
}
export default fastifyPlugin(nextPlugin, {
name: 'next',
fastify: '4.x',
});
declare module 'fastify' {
interface FastifyInstance {
nextServer: ReturnType<typeof next>;
next: (path: string, opts?: { method: string }) => void;
nextHandle: (req: IncomingMessage, res: OutgoingMessage | ServerResponse) => Promise<void>;
}
}

View file

@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { migrations } from '../util';
async function prismaPlugin(fastify: FastifyInstance, _, done) {
process.env.DATABASE_URL = fastify.config.core?.database_url;
await migrations();
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
done();
}
export default fastifyPlugin(prismaPlugin, {
name: 'prisma',
fastify: '4.x',
decorators: {
fastify: ['config'],
},
dependencies: ['config'],
});
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}

29
src/server/routes/raw.ts Normal file
View file

@ -0,0 +1,29 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default async function rawRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
const image = await this.prisma.image.findFirst({
where: {
OR: [{ file: id }, { invisible: { invis: decodeURI(id) } }],
},
});
if (!image) return reply.rawFile(id);
else {
const failed = await reply.preFile(image);
if (failed) return reply.notFound();
if (image.password) {
return reply
.type('application/json')
.code(403)
.send({
error: "can't view a raw file that has a password",
url: `/view/${image.file}`,
code: 403,
});
} else return reply.rawFile(image.file);
}
}

View file

@ -0,0 +1,43 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default async function uploadsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
else if (id === 'dashboard' && !this.config.features.headless)
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
const image = await this.prisma.image.findFirst({
where: {
OR: [{ file: id }, { invisible: { invis: decodeURI(id) } }],
},
});
if (!image) return reply.rawFile(id);
const failed = await reply.preFile(image);
if (failed) return reply.notFound();
if (image.password || image.embed || image.mimetype.startsWith('text/'))
return reply.redirect(`/view/${image.file}`);
else return reply.dbFile(image);
}
export async function uploadsRouteOnResponse(
this: FastifyInstance,
req: FastifyRequest,
reply: FastifyReply,
done: () => void
) {
if (reply.statusCode === 200) {
const { id } = req.params as { id: string };
const file = await this.prisma.image.findFirst({
where: {
OR: [{ file: id }, { invisible: { invis: decodeURI(id) } }],
},
});
reply.postFile(file);
}
done();
}

40
src/server/routes/urls.ts Normal file
View file

@ -0,0 +1,40 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default async function urlsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
else if (id === 'dashboard' && !this.config.features.headless)
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
const url = await this.prisma.url.findFirst({
where: {
OR: [{ id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
},
});
if (!url) return reply.notFound();
reply.redirect(url.destination);
reply.postUrl(url);
}
export async function urlsRouteOnResponse(
this: FastifyInstance,
req: FastifyRequest,
reply: FastifyReply,
done: () => void
) {
if (reply.statusCode === 200) {
const { id } = req.params as { id: string };
const url = await this.prisma.url.findFirst({
where: {
OR: [{ id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
},
});
reply.postUrl(url);
}
done();
}

2076
yarn.lock

File diff suppressed because it is too large Load diff