refactor: migrate to fastify
- (maybe) faster http server - easy to develop on
This commit is contained in:
parent
ea1a0b7fc8
commit
eadfa09570
19 changed files with 1857 additions and 1165 deletions
|
@ -2,15 +2,6 @@
|
|||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
domains: [
|
||||
// For sharex icon in manage user
|
||||
|
|
|
@ -50,6 +50,8 @@
|
|||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^9.0.0",
|
||||
"exiftool-vendored": "^18.6.0",
|
||||
"fastify": "^4.10.2",
|
||||
"fastify-plugin": "^4.4.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.3.1",
|
||||
"katex": "^0.16.3",
|
||||
|
|
|
@ -22,6 +22,10 @@ export default class Logger {
|
|||
this.name = name;
|
||||
}
|
||||
|
||||
child(name: string) {
|
||||
return new Logger(`${this.name}::${name}`);
|
||||
}
|
||||
|
||||
info(...args: any[]): this {
|
||||
process.stdout.write(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
|
||||
|
||||
|
|
36
src/server/decorators/dbFile.ts
Normal file
36
src/server/decorators/dbFile.ts
Normal 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>;
|
||||
}
|
||||
}
|
30
src/server/decorators/notFound.ts
Normal file
30
src/server/decorators/notFound.ts
Normal 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;
|
||||
}
|
||||
}
|
39
src/server/decorators/postFile.ts
Normal file
39
src/server/decorators/postFile.ts
Normal 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>;
|
||||
}
|
||||
}
|
42
src/server/decorators/postUrl.ts
Normal file
42
src/server/decorators/postUrl.ts
Normal 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>;
|
||||
}
|
||||
}
|
34
src/server/decorators/preFile.ts
Normal file
34
src/server/decorators/preFile.ts
Normal 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>;
|
||||
}
|
||||
}
|
34
src/server/decorators/rawFile.ts
Normal file
34
src/server/decorators/rawFile.ts
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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 config from '../lib/config';
|
||||
import datasource from '../lib/datasource';
|
||||
import exts from '../lib/exts';
|
||||
import Logger from '../lib/logger';
|
||||
import { guess } from '../lib/mimes';
|
||||
import { getStats, log, migrations, redirect } from './util';
|
||||
import { getStats } 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 logger = Logger.get('server');
|
||||
|
||||
const server = fastify();
|
||||
|
||||
if (dev) {
|
||||
server.addHook('onRoute', (opts) => {
|
||||
logger.child('route').debug(JSON.stringify(opts));
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
async function start() {
|
||||
logger.debug('Starting server');
|
||||
|
||||
// annoy user if they didnt change secret from default "changethis"
|
||||
if (config.core.secret === 'changethis') {
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
// plugins
|
||||
server
|
||||
.register(loggerPlugin)
|
||||
.register(configPlugin, config)
|
||||
.register(datasourcePlugin, datasource)
|
||||
.register(prismaPlugin)
|
||||
.register(nextPlugin, {
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: !dev,
|
||||
hostname: config.core.host,
|
||||
port: config.core.port,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
await mkdir(config.datasource.local.directory, { recursive: true });
|
||||
}
|
||||
// decorators
|
||||
server
|
||||
.register(notFound)
|
||||
.register(postUrlDecorator)
|
||||
.register(postFileDecorator)
|
||||
.register(preFileDecorator)
|
||||
.register(rawFileDecorator)
|
||||
.register(dbFileDecorator);
|
||||
|
||||
const nextServer = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: !dev,
|
||||
hostname: config.core.host,
|
||||
port: config.core.port,
|
||||
server.addHook('onRequest', (req, reply, done) => {
|
||||
if (config.features.headless) {
|
||||
const url = req.url.toLowerCase();
|
||||
if (!url.startsWith('/api') || url === '/api') return reply.notFound();
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
const handle = nextServer.getRequestHandler();
|
||||
const router = Router({
|
||||
defaultRoute: (req, res) => {
|
||||
if (config.features.headless) {
|
||||
const url = req.url.toLowerCase();
|
||||
if (!url.startsWith('/api') || url === '/api') return notFound(req, res, nextServer);
|
||||
}
|
||||
server.addHook('onResponse', (req, reply, done) => {
|
||||
if (config.core.logger || dev || process.env.DEBUG) {
|
||||
if (req.url.startsWith('/_next')) return done();
|
||||
|
||||
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) => {
|
||||
if (!existsSync('./public/favicon.ico')) return notFound(req, res, nextServer);
|
||||
server.get('/favicon.ico', async (_, reply) => {
|
||||
if (!existsSync('./public/favicon.ico')) return reply.notFound();
|
||||
|
||||
const favicon = createReadStream('./public/favicon.ico');
|
||||
res.setHeader('Content-Type', 'image/x-icon');
|
||||
|
||||
favicon.pipe(res);
|
||||
return reply.type('image/x-icon').send(favicon);
|
||||
});
|
||||
|
||||
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
|
||||
if (config.urls.route === '/' && config.uploader.route === '/') {
|
||||
router.on('GET', '/:id', 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');
|
||||
server.route({
|
||||
method: 'GET',
|
||||
url: '/:id',
|
||||
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({
|
||||
where: {
|
||||
OR: [{ id: params.id }, { vanity: params.id }, { invisible: { invis: decodeURI(params.id) } }],
|
||||
},
|
||||
const url = await server.prisma.url.findFirst({
|
||||
where: {
|
||||
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) => {
|
||||
if (params.id === '') return notFound(req, res, nextServer);
|
||||
server.get('/r/:id', rawRoute.bind(server));
|
||||
server.get('/', (_, reply) => reply.redirect('/dashboard'));
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }],
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
await server.listen({
|
||||
port: config.core.port,
|
||||
host: config.core.host ?? '0.0.0.0',
|
||||
});
|
||||
|
||||
try {
|
||||
await nextServer.prepare();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
process.exit(1);
|
||||
}
|
||||
server.logger
|
||||
.info(`listening on ${config.core.host}:${config.core.port}`)
|
||||
.info(
|
||||
`started ${dev ? 'development' : 'production'} zipline@${version} server${
|
||||
config.features.headless ? ' (headless)' : ''
|
||||
}`
|
||||
);
|
||||
|
||||
const http = createServer((req, res) => {
|
||||
router.lookup(req, res);
|
||||
if (config.core.logger) log(req.url);
|
||||
});
|
||||
clearInvites.bind(server)();
|
||||
stats.bind(server)();
|
||||
|
||||
http.on('error', (e) => {
|
||||
logger.error(e);
|
||||
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);
|
||||
setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
|
||||
setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000);
|
||||
}
|
||||
|
||||
async function notFound(req: IncomingMessage, res: ServerResponse, nextServer: NextServer) {
|
||||
if (config.features.headless) {
|
||||
res.statusCode = 404;
|
||||
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({
|
||||
async function stats(this: FastifyInstance) {
|
||||
const stats = await getStats(this.prisma, this.datasource);
|
||||
await this.prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`stats updated ${JSON.stringify(stats)}`);
|
||||
this.logger.child('stats').debug(`stats updated ${JSON.stringify(stats)}`);
|
||||
}
|
||||
|
||||
async function clearInvites(prisma: PrismaClient) {
|
||||
const { count } = await prisma.invite.deleteMany({
|
||||
async function clearInvites(this: FastifyInstance) {
|
||||
const { count } = await this.prisma.invite.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
|
@ -355,5 +195,5 @@ async function clearInvites(prisma: PrismaClient) {
|
|||
},
|
||||
});
|
||||
|
||||
logger.debug(`deleted ${count} used invites`);
|
||||
logger.child('invites').debug(`deleted ${count} used invites`);
|
||||
}
|
||||
|
|
45
src/server/plugins/config.ts
Normal file
45
src/server/plugins/config.ts
Normal 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;
|
||||
}
|
||||
}
|
19
src/server/plugins/datasource.ts
Normal file
19
src/server/plugins/datasource.ts
Normal 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;
|
||||
}
|
||||
}
|
20
src/server/plugins/logger.ts
Normal file
20
src/server/plugins/logger.ts
Normal 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;
|
||||
}
|
||||
}
|
48
src/server/plugins/next.ts
Normal file
48
src/server/plugins/next.ts
Normal 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>;
|
||||
}
|
||||
}
|
30
src/server/plugins/prisma.ts
Normal file
30
src/server/plugins/prisma.ts
Normal 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
29
src/server/routes/raw.ts
Normal 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);
|
||||
}
|
||||
}
|
43
src/server/routes/uploads.ts
Normal file
43
src/server/routes/uploads.ts
Normal 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
40
src/server/routes/urls.ts
Normal 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();
|
||||
}
|
Loading…
Reference in a new issue