From 2972090c488b1f6789a9da08843f44ce5530d72a Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 23 Apr 2026 16:04:44 +0300 Subject: [PATCH] feat(backend): add fastify api, auth, prisma schema and jobs --- apps/backend/.dockerignore | 6 + apps/backend/package.json | 50 ++++++++ apps/backend/prisma/schema.prisma | 81 ++++++++++++ apps/backend/prisma/seed.ts | 37 ++++++ apps/backend/scripts/hash-password.ts | 27 ++++ apps/backend/src/app.ts | 86 +++++++++++++ apps/backend/src/auth/users.registry.ts | 30 +++++ apps/backend/src/auth/users.registry.types.ts | 7 ++ apps/backend/src/config/env.ts | 89 +++++++++++++ apps/backend/src/index.ts | 32 +++++ apps/backend/src/jobs/purge-trash.ts | 48 +++++++ apps/backend/src/modules/auth/auth.routes.ts | 43 +++++++ apps/backend/src/modules/auth/auth.service.ts | 25 ++++ .../src/modules/images/images.routes.ts | 50 ++++++++ apps/backend/src/modules/images/og.service.ts | 93 ++++++++++++++ .../src/modules/images/storage.service.ts | 38 ++++++ apps/backend/src/modules/meta/meta.routes.ts | 7 ++ .../src/modules/profile/profile.routes.ts | 30 +++++ .../src/modules/public/public.routes.ts | 58 +++++++++ .../src/modules/wishes/wishes.routes.ts | 75 +++++++++++ .../src/modules/wishes/wishes.service.ts | 117 ++++++++++++++++++ apps/backend/src/plugins/auth.ts | 51 ++++++++ apps/backend/src/plugins/cors.ts | 10 ++ apps/backend/src/plugins/guest.ts | 25 ++++ apps/backend/src/plugins/multipart.ts | 13 ++ apps/backend/src/plugins/prisma.ts | 18 +++ apps/backend/src/plugins/rate-limit.ts | 8 ++ apps/backend/src/plugins/static.ts | 20 +++ apps/backend/src/types/fastify.d.ts | 22 ++++ apps/backend/src/utils/errors.ts | 49 ++++++++ apps/backend/src/utils/password.ts | 11 ++ apps/backend/src/utils/version.ts | 31 +++++ apps/backend/tsconfig.build.json | 10 ++ apps/backend/tsconfig.json | 16 +++ 34 files changed, 1313 insertions(+) create mode 100644 apps/backend/.dockerignore create mode 100644 apps/backend/package.json create mode 100644 apps/backend/prisma/schema.prisma create mode 100644 apps/backend/prisma/seed.ts create mode 100644 apps/backend/scripts/hash-password.ts create mode 100644 apps/backend/src/app.ts create mode 100644 apps/backend/src/auth/users.registry.ts create mode 100644 apps/backend/src/auth/users.registry.types.ts create mode 100644 apps/backend/src/config/env.ts create mode 100644 apps/backend/src/index.ts create mode 100644 apps/backend/src/jobs/purge-trash.ts create mode 100644 apps/backend/src/modules/auth/auth.routes.ts create mode 100644 apps/backend/src/modules/auth/auth.service.ts create mode 100644 apps/backend/src/modules/images/images.routes.ts create mode 100644 apps/backend/src/modules/images/og.service.ts create mode 100644 apps/backend/src/modules/images/storage.service.ts create mode 100644 apps/backend/src/modules/meta/meta.routes.ts create mode 100644 apps/backend/src/modules/profile/profile.routes.ts create mode 100644 apps/backend/src/modules/public/public.routes.ts create mode 100644 apps/backend/src/modules/wishes/wishes.routes.ts create mode 100644 apps/backend/src/modules/wishes/wishes.service.ts create mode 100644 apps/backend/src/plugins/auth.ts create mode 100644 apps/backend/src/plugins/cors.ts create mode 100644 apps/backend/src/plugins/guest.ts create mode 100644 apps/backend/src/plugins/multipart.ts create mode 100644 apps/backend/src/plugins/prisma.ts create mode 100644 apps/backend/src/plugins/rate-limit.ts create mode 100644 apps/backend/src/plugins/static.ts create mode 100644 apps/backend/src/types/fastify.d.ts create mode 100644 apps/backend/src/utils/errors.ts create mode 100644 apps/backend/src/utils/password.ts create mode 100644 apps/backend/src/utils/version.ts create mode 100644 apps/backend/tsconfig.build.json create mode 100644 apps/backend/tsconfig.json diff --git a/apps/backend/.dockerignore b/apps/backend/.dockerignore new file mode 100644 index 0000000..19ae36c --- /dev/null +++ b/apps/backend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +uploads +.env +.env.* +*.log diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..4f763d3 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@family-wishlist/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "echo 'skip'", + "prisma:generate": "prisma generate", + "prisma:push": "prisma db push", + "prisma:migrate": "prisma migrate dev", + "prisma:migrate:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "seed": "tsx prisma/seed.ts", + "hash-password": "tsx scripts/hash-password.ts" + }, + "dependencies": { + "@family-wishlist/shared": "workspace:*", + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "@fastify/helmet": "^11.1.1", + "@fastify/jwt": "^8.0.1", + "@fastify/multipart": "^8.3.0", + "@fastify/rate-limit": "^9.1.0", + "@fastify/sensible": "^5.6.0", + "@fastify/static": "^7.0.4", + "@prisma/client": "^5.19.1", + "bcryptjs": "^2.4.3", + "fastify": "^4.28.1", + "fastify-type-provider-zod": "^2.0.0", + "nanoid": "^5.0.7", + "node-cron": "^3.0.3", + "open-graph-scraper": "^6.8.3", + "pino-pretty": "^11.2.2", + "undici": "^6.19.8", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.16.5", + "@types/node-cron": "^3.0.11", + "prisma": "^5.19.1", + "tsx": "^4.19.1", + "typescript": "^5.6.2" + } +} diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma new file mode 100644 index 0000000..7344d3a --- /dev/null +++ b/apps/backend/prisma/schema.prisma @@ -0,0 +1,81 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// -------------------------------------------------------------------- +// Users +// +// There are exactly two users in this application. Their credentials +// (username + bcrypt hash) live in env — see apps/backend/src/config/env.ts. +// The DB stores only "public" fields to scope wishes and serve public +// profiles. The password hash is intentionally NOT stored here; this keeps +// the single source of truth for credentials in env and limits the +// blast-radius of a DB dump. +// -------------------------------------------------------------------- +model User { + id String @id + username String @unique + slug String @unique + displayName String + bio String? + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + wishes Wish[] +} + +enum WishStatus { + ACTIVE + ARCHIVED + COMPLETED + DELETED +} + +enum ImageSource { + DEFAULT + OG + UPLOADED +} + +model Wish { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + price Decimal? @db.Decimal(12, 2) + currency String @default("RUB") + url String? + comment String? + imageUrl String? + imageSource ImageSource @default(DEFAULT) + status WishStatus @default(ACTIVE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + completedAt DateTime? + deletedAt DateTime? + sourceWishId String? + + views GuestView[] + + @@index([userId, status]) + @@index([deletedAt]) + @@index([createdAt]) +} + +model GuestView { + id String @id @default(cuid()) + guestId String + wishId String + wish Wish @relation(fields: [wishId], references: [id], onDelete: Cascade) + seenAt DateTime @default(now()) + + @@unique([guestId, wishId]) + @@index([guestId]) +} diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts new file mode 100644 index 0000000..b2eb6c9 --- /dev/null +++ b/apps/backend/prisma/seed.ts @@ -0,0 +1,37 @@ +import { PrismaClient } from '@prisma/client'; +import { resolveUsers } from '../src/config/env.js'; + +const prisma = new PrismaClient(); + +async function main(): Promise { + const users = resolveUsers(); + for (const u of users) { + await prisma.user.upsert({ + where: { username: u.username }, + update: { + id: u.id, + slug: u.slug, + displayName: u.displayName, + }, + create: { + id: u.id, + username: u.username, + slug: u.slug, + displayName: u.displayName, + }, + }); + // eslint-disable-next-line no-console + console.log(`seeded user: ${u.username} (slug=${u.slug}, id=${u.id})`); + } +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (err) => { + // eslint-disable-next-line no-console + console.error(err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/backend/scripts/hash-password.ts b/apps/backend/scripts/hash-password.ts new file mode 100644 index 0000000..2ef0289 --- /dev/null +++ b/apps/backend/scripts/hash-password.ts @@ -0,0 +1,27 @@ +// Local CLI helper — produces a bcrypt hash for an env USER*_PASSWORD_HASH value. +// Usage: +// pnpm hash-password "mySuperSecret" +// +// The plain password is only present in argv/RAM during this invocation. +// It is NOT logged anywhere; only the hash is printed to stdout. Copy it into .env. + +import { hashPassword } from '../src/utils/password.js'; + +async function main(): Promise { + const raw = process.argv.slice(2).join(' ').trim(); + if (!raw) { + // eslint-disable-next-line no-console + console.error('Usage: pnpm hash-password ""'); + process.exit(1); + } + if (raw.length < 8) { + // eslint-disable-next-line no-console + console.error('Password must be at least 8 characters.'); + process.exit(1); + } + const hash = await hashPassword(raw); + // Print ONLY the hash, nothing else, so it is trivial to redirect/copy. + process.stdout.write(hash + '\n'); +} + +void main(); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 0000000..6220d62 --- /dev/null +++ b/apps/backend/src/app.ts @@ -0,0 +1,86 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import helmet from '@fastify/helmet'; +import sensible from '@fastify/sensible'; +import { ZodError } from 'zod'; +import { env } from './config/env.js'; +import { HttpError } from './utils/errors.js'; + +import prismaPlugin from './plugins/prisma.js'; +import corsPlugin from './plugins/cors.js'; +import rateLimitPlugin from './plugins/rate-limit.js'; +import authPlugin from './plugins/auth.js'; +import guestPlugin from './plugins/guest.js'; +import staticPlugin from './plugins/static.js'; +import multipartPlugin from './plugins/multipart.js'; + +import authRoutes from './modules/auth/auth.routes.js'; +import profileRoutes from './modules/profile/profile.routes.js'; +import wishesRoutes from './modules/wishes/wishes.routes.js'; +import imagesRoutes from './modules/images/images.routes.js'; +import publicRoutes from './modules/public/public.routes.js'; +import metaRoutes from './modules/meta/meta.routes.js'; +import { registerPurgeTrashJob } from './jobs/purge-trash.js'; + +export async function buildApp(): Promise { + const app = Fastify({ + logger: { + level: env.LOG_LEVEL, + transport: + env.NODE_ENV === 'development' + ? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss', singleLine: true } } + : undefined, + redact: { + paths: ['req.body.password', 'req.headers.cookie', 'req.headers.authorization'], + remove: true, + }, + }, + trustProxy: true, + bodyLimit: 1 * 1024 * 1024, + }); + + app.setErrorHandler((err, request, reply) => { + if (err instanceof HttpError) { + return reply.code(err.statusCode).send({ + error: err.code, + message: err.message, + details: err.details, + }); + } + if (err instanceof ZodError) { + return reply.code(400).send({ + error: 'VALIDATION', + message: 'Invalid input', + details: err.flatten(), + }); + } + if ((err as { statusCode?: number }).statusCode === 429) { + return reply.code(429).send({ error: 'RATE_LIMITED', message: err.message }); + } + request.log.error({ err }, 'Unhandled error'); + return reply.code(500).send({ error: 'INTERNAL', message: 'Internal server error' }); + }); + + await app.register(helmet, { + contentSecurityPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, + }); + await app.register(sensible); + await app.register(corsPlugin); + await app.register(rateLimitPlugin); + await app.register(authPlugin); + await app.register(guestPlugin); + await app.register(staticPlugin); + await app.register(multipartPlugin); + await app.register(prismaPlugin); + + await app.register(metaRoutes, { prefix: '/api' }); + await app.register(authRoutes, { prefix: '/api/auth' }); + await app.register(profileRoutes, { prefix: '/api/profile' }); + await app.register(wishesRoutes, { prefix: '/api/wishes' }); + await app.register(imagesRoutes, { prefix: '/api/wishes' }); + await app.register(publicRoutes, { prefix: '/api/public' }); + + registerPurgeTrashJob(app); + + return app; +} diff --git a/apps/backend/src/auth/users.registry.ts b/apps/backend/src/auth/users.registry.ts new file mode 100644 index 0000000..bf405e6 --- /dev/null +++ b/apps/backend/src/auth/users.registry.ts @@ -0,0 +1,30 @@ +import { resolveUsers } from '../config/env.js'; +import type { RegistryUser } from './users.registry.types.js'; + +// Built once on process start from env. Source of truth for credentials. +// DB contains only "public" copies (id, username, slug, displayName) — never the hash. +const users: RegistryUser[] = resolveUsers(); +const byUsername = new Map(users.map((u) => [u.username, u] as const)); +const byId = new Map(users.map((u) => [u.id, u] as const)); + +export const usersRegistry = { + all(): readonly RegistryUser[] { + return users; + }, + findByUsername(username: string): RegistryUser | undefined { + return byUsername.get(username); + }, + findById(id: string): RegistryUser | undefined { + return byId.get(id); + }, +}; + +// Pre-computed bcrypt hash of a random string, used for timing-safe compare +// when the requested username does not exist. Generated lazily on first need. +let dummyHashCache: string | null = null; +export async function getDummyHash(): Promise { + if (dummyHashCache) return dummyHashCache; + const { default: bcrypt } = await import('bcryptjs'); + dummyHashCache = await bcrypt.hash('__not_a_real_password__', 10); + return dummyHashCache; +} diff --git a/apps/backend/src/auth/users.registry.types.ts b/apps/backend/src/auth/users.registry.types.ts new file mode 100644 index 0000000..c9476f6 --- /dev/null +++ b/apps/backend/src/auth/users.registry.types.ts @@ -0,0 +1,7 @@ +export interface RegistryUser { + id: string; + username: string; + passwordHash: string; + slug: string; + displayName: string; +} diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts new file mode 100644 index 0000000..a4fa4eb --- /dev/null +++ b/apps/backend/src/config/env.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import crypto from 'node:crypto'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + BACKEND_PORT: z.coerce.number().int().positive().default(3000), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + + DATABASE_URL: z.string().url(), + PUBLIC_APP_URL: z.string().url().default('http://localhost:8080'), + UPLOADS_DIR: z.string().default('./uploads'), + + JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'), + COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 chars'), + + USER1_USERNAME: z.string().min(3).max(64), + USER1_PASSWORD_HASH: z.string().min(20, 'USER1_PASSWORD_HASH must be a bcrypt hash'), + USER1_SLUG: z.string().min(3).max(32), + USER1_DISPLAY_NAME: z.string().min(1).max(64), + + USER2_USERNAME: z.string().min(3).max(64), + USER2_PASSWORD_HASH: z.string().min(20, 'USER2_PASSWORD_HASH must be a bcrypt hash'), + USER2_SLUG: z.string().min(3).max(32), + USER2_DISPLAY_NAME: z.string().min(1).max(64), +}); + +export type Env = z.infer; + +function parseEnv(): Env { + const parsed = envSchema.safeParse(process.env); + if (!parsed.success) { + // eslint-disable-next-line no-console + console.error('\nInvalid environment configuration:\n'); + for (const issue of parsed.error.issues) { + // eslint-disable-next-line no-console + console.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + process.exit(1); + } + return parsed.data; +} + +export const env = parseEnv(); + +export interface EnvUserConfig { + id: string; + username: string; + passwordHash: string; + slug: string; + displayName: string; +} + +function stableUserId(username: string): string { + // 24-char stable id derived from username so DB seed can upsert deterministically + // without depending on any external secret. + return 'u_' + crypto.createHash('sha256').update(`user:${username}`).digest('hex').slice(0, 22); +} + +export function resolveUsers(): EnvUserConfig[] { + const usernames = new Set(); + const slugs = new Set(); + const users: EnvUserConfig[] = [ + { + id: stableUserId(env.USER1_USERNAME), + username: env.USER1_USERNAME, + passwordHash: env.USER1_PASSWORD_HASH, + slug: env.USER1_SLUG, + displayName: env.USER1_DISPLAY_NAME, + }, + { + id: stableUserId(env.USER2_USERNAME), + username: env.USER2_USERNAME, + passwordHash: env.USER2_PASSWORD_HASH, + slug: env.USER2_SLUG, + displayName: env.USER2_DISPLAY_NAME, + }, + ]; + for (const u of users) { + if (usernames.has(u.username)) { + throw new Error(`Duplicate USER*_USERNAME: ${u.username}`); + } + if (slugs.has(u.slug)) { + throw new Error(`Duplicate USER*_SLUG: ${u.slug}`); + } + usernames.add(u.username); + slugs.add(u.slug); + } + return users; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000..9a1a356 --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,32 @@ +import { buildApp } from './app.js'; +import { env } from './config/env.js'; + +async function main(): Promise { + const app = await buildApp(); + try { + await app.listen({ port: env.BACKEND_PORT, host: '0.0.0.0' }); + } catch (err) { + app.log.error({ err }, 'failed to start'); + process.exit(1); + } + + const shutdown = async (signal: NodeJS.Signals) => { + app.log.info({ signal }, 'shutting down'); + try { + await app.close(); + process.exit(0); + } catch (err) { + app.log.error({ err }, 'shutdown error'); + process.exit(1); + } + }; + + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); +} + +void main(); diff --git a/apps/backend/src/jobs/purge-trash.ts b/apps/backend/src/jobs/purge-trash.ts new file mode 100644 index 0000000..c85cd6f --- /dev/null +++ b/apps/backend/src/jobs/purge-trash.ts @@ -0,0 +1,48 @@ +import cron from 'node-cron'; +import type { FastifyInstance } from 'fastify'; +import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared'; +import { deleteLocalImageIfAny } from '../modules/images/storage.service.js'; + +async function purge(app: FastifyInstance): Promise { + const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000); + const victims = await app.prisma.wish.findMany({ + where: { status: 'DELETED', deletedAt: { lt: cutoff } }, + select: { id: true, imageUrl: true }, + }); + if (victims.length === 0) return 0; + + await Promise.all(victims.map((v) => deleteLocalImageIfAny(v.imageUrl))); + const res = await app.prisma.wish.deleteMany({ + where: { id: { in: victims.map((v) => v.id) } }, + }); + return res.count; +} + +export function registerPurgeTrashJob(app: FastifyInstance): void { + // Run daily at 03:17 (chosen to avoid common cron rush). + const task = cron.schedule( + '17 3 * * *', + async () => { + try { + const count = await purge(app); + if (count > 0) app.log.info({ count }, 'trash: purged expired wishes'); + } catch (err) { + app.log.error({ err }, 'trash: purge failed'); + } + }, + { scheduled: true, timezone: 'UTC' }, + ); + + app.addHook('onClose', async () => { + task.stop(); + }); + + // Also run once on startup to catch up if backend was offline for a while. + setTimeout(() => { + purge(app) + .then((count) => { + if (count > 0) app.log.info({ count }, 'trash: startup purge'); + }) + .catch((err) => app.log.error({ err }, 'trash: startup purge failed')); + }, 5_000); +} diff --git a/apps/backend/src/modules/auth/auth.routes.ts b/apps/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..08cdb9f --- /dev/null +++ b/apps/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from 'fastify'; +import { loginSchema } from '@family-wishlist/shared'; +import { verifyCredentials } from './auth.service.js'; +import { usersRegistry } from '../../auth/users.registry.js'; +import { UnauthorizedError } from '../../utils/errors.js'; + +export default async function authRoutes(app: FastifyInstance) { + app.post( + '/login', + { + config: { + rateLimit: { max: 5, timeWindow: '10 minutes' }, + }, + }, + async (request, reply) => { + const body = loginSchema.parse(request.body); + const user = await verifyCredentials(body.username, body.password); + const token = await reply.jwtSign({ id: user.id, username: user.username }); + app.setAuthCookie(reply, token); + return { + id: user.id, + username: user.username, + slug: user.slug, + displayName: user.displayName, + }; + }, + ); + + app.post('/logout', async (_request, reply) => { + app.clearAuthCookie(reply); + return { ok: true }; + }); + + app.get( + '/me', + { preHandler: [app.authenticate] }, + async (request) => { + const u = usersRegistry.findById(request.user.id); + if (!u) throw new UnauthorizedError(); + return { id: u.id, username: u.username, slug: u.slug, displayName: u.displayName }; + }, + ); +} diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..a4cc88d --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,25 @@ +import { verifyPassword } from '../../utils/password.js'; +import { getDummyHash, usersRegistry } from '../../auth/users.registry.js'; +import { InvalidCredentialsError } from '../../utils/errors.js'; + +export interface AuthenticatedUser { + id: string; + username: string; + slug: string; + displayName: string; +} + +export async function verifyCredentials( + username: string, + password: string, +): Promise { + const user = usersRegistry.findByUsername(username); + // Always run bcrypt.compare to keep response time stable regardless of whether + // the username exists. Otherwise an attacker could enumerate usernames by timing. + const hash = user?.passwordHash ?? (await getDummyHash()); + const ok = await verifyPassword(password, hash); + if (!user || !ok) { + throw new InvalidCredentialsError(); + } + return { id: user.id, username: user.username, slug: user.slug, displayName: user.displayName }; +} diff --git a/apps/backend/src/modules/images/images.routes.ts b/apps/backend/src/modules/images/images.routes.ts new file mode 100644 index 0000000..0060f2a --- /dev/null +++ b/apps/backend/src/modules/images/images.routes.ts @@ -0,0 +1,50 @@ +import type { FastifyInstance } from 'fastify'; +import { MAX_UPLOAD_BYTES } from '../../plugins/multipart.js'; +import { WishesService } from '../wishes/wishes.service.js'; +import { ValidationError } from '../../utils/errors.js'; +import { deleteLocalImageIfAny, saveUploadedImage } from './storage.service.js'; +import { fetchOgImageForWish } from './og.service.js'; + +export default async function imagesRoutes(app: FastifyInstance) { + app.addHook('preHandler', app.authenticate); + const wishes = new WishesService(app.prisma); + + app.post('/:id/image', async (request) => { + const { id } = request.params as { id: string }; + const current = await wishes.getOwned(request.user.id, id); + + const data = await request.file(); + if (!data) throw new ValidationError('No file uploaded'); + + const buffer = await data.toBuffer(); + if (buffer.byteLength > MAX_UPLOAD_BYTES) { + throw new ValidationError('File too large'); + } + + const { imageUrl } = await saveUploadedImage(id, data.mimetype, buffer); + await deleteLocalImageIfAny(current.imageUrl); + + return app.prisma.wish.update({ + where: { id }, + data: { imageUrl, imageSource: 'UPLOADED' }, + }); + }); + + app.post('/:id/image/refresh-og', async (request) => { + const { id } = request.params as { id: string }; + const wish = await wishes.getOwned(request.user.id, id); + if (!wish.url) throw new ValidationError('Wish has no url'); + await fetchOgImageForWish(app, id, wish.url); + return app.prisma.wish.findUniqueOrThrow({ where: { id } }); + }); + + app.delete('/:id/image', async (request) => { + const { id } = request.params as { id: string }; + const wish = await wishes.getOwned(request.user.id, id); + await deleteLocalImageIfAny(wish.imageUrl); + return app.prisma.wish.update({ + where: { id }, + data: { imageUrl: null, imageSource: 'DEFAULT' }, + }); + }); +} diff --git a/apps/backend/src/modules/images/og.service.ts b/apps/backend/src/modules/images/og.service.ts new file mode 100644 index 0000000..1b801ac --- /dev/null +++ b/apps/backend/src/modules/images/og.service.ts @@ -0,0 +1,93 @@ +import type { FastifyInstance } from 'fastify'; +import { request as undiciRequest } from 'undici'; +import ogs from 'open-graph-scraper'; +import { writeFile } from 'node:fs/promises'; +import { resolve, extname } from 'node:path'; +import { nanoid } from 'nanoid'; +import { env } from '../../config/env.js'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); +const FETCH_TIMEOUT_MS = 10_000; + +interface DownloadResult { + buffer: Buffer; + ext: string; + contentType: string; +} + +async function downloadImage(url: string): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await undiciRequest(url, { + method: 'GET', + signal: controller.signal, + headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' }, + }); + if (res.statusCode >= 400) return null; + const contentType = (res.headers['content-type']?.toString() ?? '').split(';')[0].trim(); + if (!ALLOWED_MIME.has(contentType)) return null; + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of res.body) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > MAX_IMAGE_BYTES) return null; + chunks.push(buf); + } + const buffer = Buffer.concat(chunks); + const extFromCt = contentType.split('/')[1] ?? 'jpg'; + const extFromUrl = extname(new URL(url).pathname).replace('.', '').toLowerCase(); + const ext = ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extFromUrl) + ? extFromUrl + : extFromCt; + return { buffer, ext, contentType }; + } finally { + clearTimeout(timer); + } + } catch { + return null; + } +} + +export async function fetchOgImageForWish( + app: FastifyInstance, + wishId: string, + pageUrl: string, +): Promise { + try { + const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS }); + if (parsed.error || !parsed.result) return; + const imageEntry = parsed.result.ogImage; + const imageUrl = Array.isArray(imageEntry) ? imageEntry[0]?.url : imageEntry?.url; + if (!imageUrl) return; + + const absolute = new URL(imageUrl, pageUrl).toString(); + const dl = await downloadImage(absolute); + if (!dl) return; + + const filename = `${wishId}-${nanoid(8)}.${dl.ext}`; + const absPath = resolve(env.UPLOADS_DIR, 'og', filename); + await writeFile(absPath, dl.buffer); + + const current = await app.prisma.wish.findUnique({ where: { id: wishId } }); + if (!current) return; + if (current.imageSource === 'UPLOADED') return; // do not overwrite user upload + + await app.prisma.wish.update({ + where: { id: wishId }, + data: { imageUrl: `/uploads/og/${filename}`, imageSource: 'OG' }, + }); + } catch (err) { + app.log.warn({ err, wishId, pageUrl }, 'OG image fetch failed'); + } +} + +export function enqueueOgFetch(app: FastifyInstance, wishId: string, pageUrl: string): void { + // Fire-and-forget. Errors are swallowed inside fetchOgImageForWish. + setImmediate(() => { + void fetchOgImageForWish(app, wishId, pageUrl); + }); +} diff --git a/apps/backend/src/modules/images/storage.service.ts b/apps/backend/src/modules/images/storage.service.ts new file mode 100644 index 0000000..ca8977c --- /dev/null +++ b/apps/backend/src/modules/images/storage.service.ts @@ -0,0 +1,38 @@ +import { writeFile, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { nanoid } from 'nanoid'; +import { env } from '../../config/env.js'; +import { ValidationError } from '../../utils/errors.js'; + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +export async function saveUploadedImage( + wishId: string, + mime: string, + buffer: Buffer, +): Promise<{ imageUrl: string }> { + const ext = MIME_TO_EXT[mime]; + if (!ext) throw new ValidationError('Unsupported image type'); + const filename = `${wishId}-${nanoid(8)}.${ext}`; + const relative = `/uploads/upload/${filename}`; + const absPath = resolve(env.UPLOADS_DIR, 'upload', filename); + await writeFile(absPath, buffer); + return { imageUrl: relative }; +} + +export async function deleteLocalImageIfAny(imageUrl: string | null): Promise { + if (!imageUrl) return; + if (!imageUrl.startsWith('/uploads/')) return; + const rel = imageUrl.replace(/^\/uploads\//, ''); + const absPath = resolve(env.UPLOADS_DIR, rel); + try { + await unlink(absPath); + } catch { + // already gone — ignore + } +} diff --git a/apps/backend/src/modules/meta/meta.routes.ts b/apps/backend/src/modules/meta/meta.routes.ts new file mode 100644 index 0000000..0f04cf2 --- /dev/null +++ b/apps/backend/src/modules/meta/meta.routes.ts @@ -0,0 +1,7 @@ +import type { FastifyInstance } from 'fastify'; +import { getBackendVersion } from '../../utils/version.js'; + +export default async function metaRoutes(app: FastifyInstance) { + app.get('/version', async () => ({ backend: getBackendVersion() })); + app.get('/health', async () => ({ status: 'ok', ts: new Date().toISOString() })); +} diff --git a/apps/backend/src/modules/profile/profile.routes.ts b/apps/backend/src/modules/profile/profile.routes.ts new file mode 100644 index 0000000..90c9187 --- /dev/null +++ b/apps/backend/src/modules/profile/profile.routes.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import { updateProfileSchema } from '@family-wishlist/shared'; +import { ConflictError, NotFoundError } from '../../utils/errors.js'; +import { Prisma } from '@prisma/client'; + +export default async function profileRoutes(app: FastifyInstance) { + app.addHook('preHandler', app.authenticate); + + app.get('/', async (request) => { + const profile = await app.prisma.user.findUnique({ where: { id: request.user.id } }); + if (!profile) throw new NotFoundError('Profile'); + return profile; + }); + + app.patch('/', async (request) => { + const body = updateProfileSchema.parse(request.body); + try { + const updated = await app.prisma.user.update({ + where: { id: request.user.id }, + data: body, + }); + return updated; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new ConflictError('Slug is already taken'); + } + throw err; + } + }); +} diff --git a/apps/backend/src/modules/public/public.routes.ts b/apps/backend/src/modules/public/public.routes.ts new file mode 100644 index 0000000..0d670f0 --- /dev/null +++ b/apps/backend/src/modules/public/public.routes.ts @@ -0,0 +1,58 @@ +import type { FastifyInstance } from 'fastify'; +import { markSeenSchema } from '@family-wishlist/shared'; +import { NotFoundError } from '../../utils/errors.js'; + +export default async function publicRoutes(app: FastifyInstance) { + app.get('/:slug', async (request) => { + const { slug } = request.params as { slug: string }; + const user = await app.prisma.user.findUnique({ + where: { slug }, + select: { slug: true, displayName: true, bio: true, avatarUrl: true }, + }); + if (!user) throw new NotFoundError('Profile'); + return user; + }); + + app.get('/:slug/wishes', async (request) => { + const { slug } = request.params as { slug: string }; + const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } }); + if (!user) throw new NotFoundError('Profile'); + + const wishes = await app.prisma.wish.findMany({ + where: { userId: user.id, status: { in: ['ACTIVE', 'COMPLETED'] } }, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + + const wishIds = wishes.map((w) => w.id); + const seen = wishIds.length + ? await app.prisma.guestView.findMany({ + where: { guestId: request.guestId, wishId: { in: wishIds } }, + select: { wishId: true }, + }) + : []; + const seenSet = new Set(seen.map((s) => s.wishId)); + return wishes.map((w) => ({ + ...w, + isNewForGuest: w.status === 'ACTIVE' && !seenSet.has(w.id), + })); + }); + + app.post('/:slug/views', async (request) => { + const { slug } = request.params as { slug: string }; + const body = markSeenSchema.parse(request.body); + + const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } }); + if (!user) throw new NotFoundError('Profile'); + + // Filter wishIds to those that actually belong to this user (avoid cross-user pollution). + const owned = await app.prisma.wish.findMany({ + where: { userId: user.id, id: { in: body.wishIds } }, + select: { id: true }, + }); + if (owned.length === 0) return { marked: 0 }; + + const data = owned.map((w) => ({ guestId: request.guestId, wishId: w.id })); + const res = await app.prisma.guestView.createMany({ data, skipDuplicates: true }); + return { marked: res.count }; + }); +} diff --git a/apps/backend/src/modules/wishes/wishes.routes.ts b/apps/backend/src/modules/wishes/wishes.routes.ts new file mode 100644 index 0000000..5881b08 --- /dev/null +++ b/apps/backend/src/modules/wishes/wishes.routes.ts @@ -0,0 +1,75 @@ +import type { FastifyInstance } from 'fastify'; +import { + createWishSchema, + updateWishSchema, + wishStatusQuery, + NEW_BADGE_DAYS, +} from '@family-wishlist/shared'; +import { WishesService } from './wishes.service.js'; +import { enqueueOgFetch } from '../images/og.service.js'; + +export default async function wishesRoutes(app: FastifyInstance) { + app.addHook('preHandler', app.authenticate); + const service = new WishesService(app.prisma); + + app.get('/', async (request) => { + const qs = wishStatusQuery.parse((request.query as { status?: string })?.status ?? 'active'); + const wishes = await service.list(request.user.id, qs); + return wishes.map((w) => ({ + ...w, + isNewForOwner: + w.status === 'ACTIVE' && + Date.now() - w.createdAt.getTime() < NEW_BADGE_DAYS * 24 * 60 * 60 * 1000, + })); + }); + + app.get('/:id', async (request) => { + const { id } = request.params as { id: string }; + return service.getOwned(request.user.id, id); + }); + + app.post('/', async (request, reply) => { + const input = createWishSchema.parse(request.body); + const wish = await service.create(request.user.id, input); + if (wish.url) enqueueOgFetch(app, wish.id, wish.url); + reply.code(201); + return wish; + }); + + app.patch('/:id', async (request) => { + const { id } = request.params as { id: string }; + const input = updateWishSchema.parse(request.body); + const updated = await service.update(request.user.id, id, input); + if (input.url !== undefined && updated.url) { + enqueueOgFetch(app, updated.id, updated.url); + } + return updated; + }); + + app.delete('/:id', async (request) => { + const { id } = request.params as { id: string }; + return service.softDelete(request.user.id, id); + }); + + app.post('/:id/archive', async (request) => { + const { id } = request.params as { id: string }; + return service.archive(request.user.id, id); + }); + + app.post('/:id/complete', async (request) => { + const { id } = request.params as { id: string }; + return service.complete(request.user.id, id); + }); + + app.post('/:id/restore', async (request) => { + const { id } = request.params as { id: string }; + return service.restore(request.user.id, id); + }); + + app.post('/:id/duplicate', async (request, reply) => { + const { id } = request.params as { id: string }; + const wish = await service.duplicate(request.user.id, id); + reply.code(201); + return wish; + }); +} diff --git a/apps/backend/src/modules/wishes/wishes.service.ts b/apps/backend/src/modules/wishes/wishes.service.ts new file mode 100644 index 0000000..a5702c4 --- /dev/null +++ b/apps/backend/src/modules/wishes/wishes.service.ts @@ -0,0 +1,117 @@ +import type { PrismaClient, Wish, WishStatus } from '@prisma/client'; +import { + ConflictError, + ForbiddenError, + NotFoundError, +} from '../../utils/errors.js'; +import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared'; + +type Status = 'active' | 'archived' | 'completed' | 'deleted'; + +const statusMap: Record = { + active: 'ACTIVE', + archived: 'ARCHIVED', + completed: 'COMPLETED', + deleted: 'DELETED', +}; + +export class WishesService { + constructor(private readonly prisma: PrismaClient) {} + + list(userId: string, status: Status): Promise { + return this.prisma.wish.findMany({ + where: { userId, status: statusMap[status] }, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + } + + async getOwned(userId: string, id: string): Promise { + const wish = await this.prisma.wish.findUnique({ where: { id } }); + if (!wish) throw new NotFoundError('Wish'); + if (wish.userId !== userId) throw new ForbiddenError(); + return wish; + } + + create(userId: string, input: CreateWishInput): Promise { + return this.prisma.wish.create({ + data: { + userId, + title: input.title, + price: input.price ?? null, + currency: input.currency ?? 'RUB', + url: input.url ?? null, + comment: input.comment ?? null, + }, + }); + } + + async update(userId: string, id: string, input: UpdateWishInput): Promise { + await this.getOwned(userId, id); + const data: Record = {}; + if (input.title !== undefined) data.title = input.title; + if (input.price !== undefined) data.price = input.price ?? null; + if (input.currency !== undefined) data.currency = input.currency ?? 'RUB'; + if (input.url !== undefined) data.url = input.url ?? null; + if (input.comment !== undefined) data.comment = input.comment ?? null; + return this.prisma.wish.update({ where: { id }, data }); + } + + async archive(userId: string, id: string): Promise { + const wish = await this.getOwned(userId, id); + if (wish.status === 'ARCHIVED') return wish; + if (wish.status === 'DELETED') { + throw new ConflictError('Cannot archive a deleted wish; restore it first'); + } + return this.prisma.wish.update({ + where: { id }, + data: { status: 'ARCHIVED', archivedAt: new Date(), completedAt: null, deletedAt: null }, + }); + } + + async complete(userId: string, id: string): Promise { + const wish = await this.getOwned(userId, id); + if (wish.status === 'COMPLETED') return wish; + if (wish.status === 'DELETED') { + throw new ConflictError('Cannot complete a deleted wish; restore it first'); + } + return this.prisma.wish.update({ + where: { id }, + data: { status: 'COMPLETED', completedAt: new Date(), archivedAt: null, deletedAt: null }, + }); + } + + async softDelete(userId: string, id: string): Promise { + const wish = await this.getOwned(userId, id); + if (wish.status === 'DELETED') return wish; + return this.prisma.wish.update({ + where: { id }, + data: { status: 'DELETED', deletedAt: new Date() }, + }); + } + + async restore(userId: string, id: string): Promise { + const wish = await this.getOwned(userId, id); + if (wish.status === 'ACTIVE') return wish; + return this.prisma.wish.update({ + where: { id }, + data: { status: 'ACTIVE', archivedAt: null, completedAt: null, deletedAt: null }, + }); + } + + async duplicate(userId: string, id: string): Promise { + const source = await this.getOwned(userId, id); + return this.prisma.wish.create({ + data: { + userId, + title: source.title, + price: source.price, + currency: source.currency, + url: source.url, + comment: source.comment, + imageUrl: source.imageSource === 'UPLOADED' ? null : source.imageUrl, + imageSource: source.imageSource === 'UPLOADED' ? 'DEFAULT' : source.imageSource, + sourceWishId: source.id, + }, + }); + } +} diff --git a/apps/backend/src/plugins/auth.ts b/apps/backend/src/plugins/auth.ts new file mode 100644 index 0000000..68f29d6 --- /dev/null +++ b/apps/backend/src/plugins/auth.ts @@ -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; + } +} diff --git a/apps/backend/src/plugins/cors.ts b/apps/backend/src/plugins/cors.ts new file mode 100644 index 0000000..cae0871 --- /dev/null +++ b/apps/backend/src/plugins/cors.ts @@ -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, + }); +}); diff --git a/apps/backend/src/plugins/guest.ts b/apps/backend/src/plugins/guest.ts new file mode 100644 index 0000000..672a78c --- /dev/null +++ b/apps/backend/src/plugins/guest.ts @@ -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, + }); + }); +}); diff --git a/apps/backend/src/plugins/multipart.ts b/apps/backend/src/plugins/multipart.ts new file mode 100644 index 0000000..e304e62 --- /dev/null +++ b/apps/backend/src/plugins/multipart.ts @@ -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, + }, + }); +}); diff --git a/apps/backend/src/plugins/prisma.ts b/apps/backend/src/plugins/prisma.ts new file mode 100644 index 0000000..01635e8 --- /dev/null +++ b/apps/backend/src/plugins/prisma.ts @@ -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(); + }); +}); diff --git a/apps/backend/src/plugins/rate-limit.ts b/apps/backend/src/plugins/rate-limit.ts new file mode 100644 index 0000000..bc1e854 --- /dev/null +++ b/apps/backend/src/plugins/rate-limit.ts @@ -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, + }); +}); diff --git a/apps/backend/src/plugins/static.ts b/apps/backend/src/plugins/static.ts new file mode 100644 index 0000000..9672078 --- /dev/null +++ b/apps/backend/src/plugins/static.ts @@ -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, + }); +}); diff --git a/apps/backend/src/types/fastify.d.ts b/apps/backend/src/types/fastify.d.ts new file mode 100644 index 0000000..755aefa --- /dev/null +++ b/apps/backend/src/types/fastify.d.ts @@ -0,0 +1,22 @@ +import type { PrismaClient } from '@prisma/client'; +import type { RegistryUser } from '../auth/users.registry.types.js'; + +declare module 'fastify' { + interface FastifyInstance { + prisma: PrismaClient; + authenticate: (request: import('fastify').FastifyRequest) => Promise; + } + + interface FastifyRequest { + user: { id: string; username: string }; + authUser?: RegistryUser; + guestId: string; + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { id: string; username: string }; + user: { id: string; username: string }; + } +} diff --git a/apps/backend/src/utils/errors.ts b/apps/backend/src/utils/errors.ts new file mode 100644 index 0000000..5ac32a2 --- /dev/null +++ b/apps/backend/src/utils/errors.ts @@ -0,0 +1,49 @@ +export class HttpError extends Error { + readonly statusCode: number; + readonly code: string; + readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + +export class InvalidCredentialsError extends HttpError { + constructor() { + super(401, 'INVALID_CREDENTIALS', 'Invalid username or password'); + } +} + +export class UnauthorizedError extends HttpError { + constructor(message = 'Not authenticated') { + super(401, 'UNAUTHORIZED', message); + } +} + +export class NotFoundError extends HttpError { + constructor(what = 'Resource') { + super(404, 'NOT_FOUND', `${what} not found`); + } +} + +export class ConflictError extends HttpError { + constructor(message: string) { + super(409, 'CONFLICT', message); + } +} + +export class ValidationError extends HttpError { + constructor(message: string, details?: unknown) { + super(400, 'VALIDATION', message, details); + } +} + +export class ForbiddenError extends HttpError { + constructor(message = 'Forbidden') { + super(403, 'FORBIDDEN', message); + } +} diff --git a/apps/backend/src/utils/password.ts b/apps/backend/src/utils/password.ts new file mode 100644 index 0000000..48dd64d --- /dev/null +++ b/apps/backend/src/utils/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcryptjs'; + +export const BCRYPT_ROUNDS = 12; + +export async function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, BCRYPT_ROUNDS); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/apps/backend/src/utils/version.ts b/apps/backend/src/utils/version.ts new file mode 100644 index 0000000..7bde6e4 --- /dev/null +++ b/apps/backend/src/utils/version.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let cached: string | null = null; + +export function getBackendVersion(): string { + if (cached) return cached; + // Walk up until we find apps/backend/package.json. + const candidates = [ + resolve(__dirname, '../../package.json'), + resolve(__dirname, '../../../package.json'), + resolve(process.cwd(), 'package.json'), + ]; + for (const p of candidates) { + try { + const raw = readFileSync(p, 'utf-8'); + const pkg = JSON.parse(raw) as { name?: string; version?: string }; + if (pkg.name === '@family-wishlist/backend' && pkg.version) { + cached = pkg.version; + return cached; + } + } catch { + // try next + } + } + cached = '0.0.0'; + return cached; +} diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json new file mode 100644 index 0000000..6dd1d98 --- /dev/null +++ b/apps/backend/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "dist"] +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..cce9d29 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "./", + "outDir": "./dist", + "noEmit": true, + "types": ["node"], + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*", "scripts/**/*", "prisma/seed.ts"] +}