feat(backend): add fastify api, auth, prisma schema and jobs

This commit is contained in:
Anton
2026-04-23 16:04:44 +03:00
parent 5f6a551b6c
commit 2972090c48
34 changed files with 1313 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import fp from 'fastify-plugin';
import fastifyJwt from '@fastify/jwt';
import fastifyCookie from '@fastify/cookie';
import { env } from '../config/env.js';
import { UnauthorizedError } from '../utils/errors.js';
export const AUTH_COOKIE = 'fw_auth';
const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
export default fp(async (app) => {
await app.register(fastifyCookie, {
secret: env.COOKIE_SECRET,
parseOptions: {},
});
await app.register(fastifyJwt, {
secret: env.JWT_SECRET,
cookie: { cookieName: AUTH_COOKIE, signed: false },
sign: { expiresIn: `${AUTH_COOKIE_MAX_AGE}s` },
});
app.decorate('authenticate', async (request) => {
try {
await request.jwtVerify({ onlyCookie: true });
} catch {
throw new UnauthorizedError();
}
});
// helpers for routes
app.decorate('setAuthCookie', ((reply, token) => {
reply.setCookie(AUTH_COOKIE, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: AUTH_COOKIE_MAX_AGE,
});
}) as never);
app.decorate('clearAuthCookie', ((reply) => {
reply.clearCookie(AUTH_COOKIE, { path: '/' });
}) as never);
});
declare module 'fastify' {
interface FastifyInstance {
setAuthCookie: (reply: import('fastify').FastifyReply, token: string) => void;
clearAuthCookie: (reply: import('fastify').FastifyReply) => void;
}
}

View File

@@ -0,0 +1,10 @@
import fp from 'fastify-plugin';
import fastifyCors from '@fastify/cors';
import { env } from '../config/env.js';
export default fp(async (app) => {
await app.register(fastifyCors, {
origin: env.NODE_ENV === 'production' ? env.PUBLIC_APP_URL : true,
credentials: true,
});
});

View File

@@ -0,0 +1,25 @@
import fp from 'fastify-plugin';
import { nanoid } from 'nanoid';
import { env } from '../config/env.js';
export const GUEST_COOKIE = 'fw_gid';
const GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2; // 2 years
export default fp(async (app) => {
app.addHook('onRequest', async (request, reply) => {
const existing = request.cookies[GUEST_COOKIE];
if (existing && existing.length >= 16 && existing.length <= 64) {
request.guestId = existing;
return;
}
const id = nanoid(24);
request.guestId = id;
reply.setCookie(GUEST_COOKIE, id, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: GUEST_COOKIE_MAX_AGE,
});
});
});

View File

@@ -0,0 +1,13 @@
import fp from 'fastify-plugin';
import fastifyMultipart from '@fastify/multipart';
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; // 8 MB
export default fp(async (app) => {
await app.register(fastifyMultipart, {
limits: {
fileSize: MAX_UPLOAD_BYTES,
files: 1,
},
});
});

View File

@@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
import fp from 'fastify-plugin';
export default fp(async (app) => {
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'error' }, { emit: 'event', level: 'warn' }],
});
prisma.$on('error', (e) => app.log.error({ prisma: e }, 'prisma error'));
prisma.$on('warn', (e) => app.log.warn({ prisma: e }, 'prisma warn'));
await prisma.$connect();
app.decorate('prisma', prisma);
app.addHook('onClose', async () => {
await prisma.$disconnect();
});
});

View File

@@ -0,0 +1,8 @@
import fp from 'fastify-plugin';
import fastifyRateLimit from '@fastify/rate-limit';
export default fp(async (app) => {
await app.register(fastifyRateLimit, {
global: false,
});
});

View File

@@ -0,0 +1,20 @@
import fp from 'fastify-plugin';
import fastifyStatic from '@fastify/static';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { env } from '../config/env.js';
export default fp(async (app) => {
const uploadsRoot = resolve(env.UPLOADS_DIR);
mkdirSync(resolve(uploadsRoot, 'og'), { recursive: true });
mkdirSync(resolve(uploadsRoot, 'upload'), { recursive: true });
mkdirSync(resolve(uploadsRoot, 'avatar'), { recursive: true });
await app.register(fastifyStatic, {
root: uploadsRoot,
prefix: '/uploads/',
decorateReply: false,
index: false,
list: false,
});
});