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}
|
* @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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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(' ')));
|
||||||
|
|
||||||
|
|
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,76 +1,49 @@
|
||||||
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.')
|
|
||||||
.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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.datasource.type === 'local') {
|
|
||||||
await mkdir(config.datasource.local.directory, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextServer = next({
|
|
||||||
dir: '.',
|
dir: '.',
|
||||||
dev,
|
dev,
|
||||||
quiet: !dev,
|
quiet: !dev,
|
||||||
|
@ -78,271 +51,138 @@ async function start() {
|
||||||
port: config.core.port,
|
port: config.core.port,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handle = nextServer.getRequestHandler();
|
// decorators
|
||||||
const router = Router({
|
server
|
||||||
defaultRoute: (req, res) => {
|
.register(notFound)
|
||||||
|
.register(postUrlDecorator)
|
||||||
|
.register(postFileDecorator)
|
||||||
|
.register(preFileDecorator)
|
||||||
|
.register(rawFileDecorator)
|
||||||
|
.register(dbFileDecorator);
|
||||||
|
|
||||||
|
server.addHook('onRequest', (req, reply, done) => {
|
||||||
if (config.features.headless) {
|
if (config.features.headless) {
|
||||||
const url = req.url.toLowerCase();
|
const url = req.url.toLowerCase();
|
||||||
if (!url.startsWith('/api') || url === '/api') return notFound(req, res, nextServer);
|
if (!url.startsWith('/api') || url === '/api') return reply.notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
handle(req, res);
|
done();
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.on('GET', '/favicon.ico', async (req, res) => {
|
server.addHook('onResponse', (req, reply, done) => {
|
||||||
if (!existsSync('./public/favicon.ico')) return notFound(req, res, nextServer);
|
if (config.core.logger || dev || process.env.DEBUG) {
|
||||||
|
if (req.url.startsWith('/_next')) return done();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get('/favicon.ico', async (_, reply) => {
|
||||||
|
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(req, res, params);
|
if (url) return urlsRoute.bind(server)(req, reply);
|
||||||
else return uploadsRoute(req, res, params);
|
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 {
|
} else {
|
||||||
router.on('GET', config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`, urlsRoute);
|
server
|
||||||
|
.route({
|
||||||
router.on('GET', config.uploader.route === '/' ? '/:id' : `${config.uploader.route}/:id`, uploadsRoute);
|
method: 'GET',
|
||||||
}
|
url: config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`,
|
||||||
|
handler: urlsRoute.bind(server),
|
||||||
router.on('GET', '/r/:id', async (req, res, params) => {
|
onResponse: urlsRouteOnResponse.bind(server),
|
||||||
if (params.id === '') return notFound(req, res, nextServer);
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
);
|
.route({
|
||||||
} else await rawFile(req, res, nextServer, params.id);
|
method: 'GET',
|
||||||
}
|
url: config.uploader.route === '/' ? '/:id' : `${config.uploader.route}/:id`,
|
||||||
|
handler: uploadsRoute.bind(server),
|
||||||
|
onResponse: uploadsRouteOnResponse.bind(server),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
await nextServer.prepare();
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const http = createServer((req, res) => {
|
server.get('/r/:id', rawRoute.bind(server));
|
||||||
router.lookup(req, res);
|
server.get('/', (_, reply) => reply.redirect('/dashboard'));
|
||||||
if (config.core.logger) log(req.url);
|
|
||||||
|
await server.listen({
|
||||||
|
port: config.core.port,
|
||||||
|
host: config.core.host ?? '0.0.0.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
http.on('error', (e) => {
|
server.logger
|
||||||
logger.error(e);
|
.info(`listening on ${config.core.host}:${config.core.port}`)
|
||||||
process.exit(1);
|
.info(
|
||||||
});
|
|
||||||
|
|
||||||
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${
|
`started ${dev ? 'development' : 'production'} zipline@${version} server${
|
||||||
config.features.headless ? ' (headless)' : ''
|
config.features.headless ? ' (headless)' : ''
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
||||||
stats(prisma);
|
clearInvites.bind(server)();
|
||||||
clearInvites(prisma);
|
stats.bind(server)();
|
||||||
|
|
||||||
setInterval(() => clearInvites(prisma), config.core.invites_interval * 1000);
|
setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
|
||||||
setInterval(() => stats(prisma), config.core.stats_interval * 1000);
|
setInterval(() => stats.bind(server)(), 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`);
|
||||||
}
|
}
|
||||||
|
|
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