import Fastify, { LogController, type FastifyRequest } from 'fastify'; class PrivateLogs extends LogController { constructor() { super({ disableRequestLogging: true }); } } import cookie from '@fastify/cookie'; import multipart from '@fastify/multipart'; import { createReadStream } from 'node:fs'; import serveStatic from '@fastify/static'; import { Server } from 'socket.io'; import { timingSafeEqual } from 'node:crypto'; import { existsSync } from 'node:fs'; import { z } from 'zod'; import { devicePatch, requestInput, spaceInput, joinInput, completeInput, emailRequestInput, } from '@hop/shared'; import { createResendMailer } from './mail.js'; import { Auth, normalizeEmail, type Mailer } from './auth.js'; import { Accounts } from './accounts.js'; import * as tables from './db/schema.js'; import { eq, and, inArray } from 'drizzle-orm'; import type { StorageSubject } from './storage.js'; import type { Config } from './config.js'; import { openDatabase } from './db/index.js'; import { Service, type Guest } from './service.js'; import { Presence } from './presence.js'; import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js'; import { CHUNK_BYTES } from './resumable.js'; export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) { const app = Fastify({ logger: cfg.production ? { level: 'info', redact: ['req.headers.cookie', 'req.headers.authorization', 'req.url'], serializers: { req: (r) => ({ method: r.method }) }, } : false, logController: new PrivateLogs(), trustProxy: false, bodyLimit: cfg.maxUrlBytes * 6 + 16384, }); await app.register(cookie); await app.register(multipart, { limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 }, }); app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body), ); const { db, sqlite } = openDatabase(cfg.databasePath), service = new Service(db, cfg); let devOrigin: string | undefined; const allowed = (origin: string | undefined) => Boolean( origin && (origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)), ); const ip = (req: FastifyRequest) => clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies, cfg.clientIpSource); const io = new Server(app.server, { serveClient: false, maxHttpBufferSize: 16384, allowRequest: (req, callback) => { // Browsers omit Origin for same-origin polling GETs. Fetch Metadata is browser-owned; // cookie authentication and the session-bound CSRF token are also required by io.use. const sameOriginFetch = !req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin'; callback(null, allowed(req.headers.origin) || sameOriginFetch); }, }); const presence = new Presence(io, cfg, () => service.refresh()); service.presence = presence; service.io = io; service.auth = new Auth(service, options.mailer ?? createResendMailer(cfg)); service.accounts = new Accounts(service); io.use((socket, next) => { try { const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest; const guest = service.guest(token); if (!guest || socket.handshake.auth.csrfToken !== service.csrf(token!)) return next(new Error('SESSION_EXPIRED')); socket.data.guest = guest; const account = service.auth.session( app.parseCookie(socket.request.headers.cookie ?? '').hop_account, ); if (account) { socket.data.accountId = account.user.id; socket.data.accountSessionId = account.session.id; } socket.data.ip = clientIp( socket.request.socket.remoteAddress, socket.request.headers, cfg.trustedProxies, cfg.clientIpSource, ); next(); } catch { next(new Error('SESSION_EXPIRED')); } }); io.on('connection', (socket) => { presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip); if (socket.data.accountId) { socket.join(`account:${socket.data.accountId}`); socket.join(`account-session:${socket.data.accountSessionId}`); } const currentGuest = () => { const guest = service.guest(app.parseCookie(socket.request.headers.cookie ?? '').hop_guest); if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.'); return guest!; }; socket.on('location:update', (input, acknowledge) => { try { const guest = currentGuest(); service.limiter.hit(`location-guest:${guest.id}`, 6); service.limiter.hit( `location-ip:${socket.data.ip ?? socket.request.socket.remoteAddress}`, 120, ); if (!cfg.locationDiscoveryEnabled) fail(409, 'LOCATION_DISABLED', '근처 검색을 사용할 수 없습니다.'); if (!service.device(guest.id).discoverable || service.activeRoom(guest.id)) fail(409, 'LOCATION_UNAVAILABLE', '현재 기기 상태에서는 근처 검색을 사용할 수 없습니다.'); const position = z .object({ latitude: z.number().finite().min(-90).max(90), longitude: z.number().finite().min(-180).max(180), accuracy: z.number().finite().min(0).max(100000), }) .strict() .parse(input); if (position.accuracy > Math.max(100, cfg.locationRadiusMeters)) fail( 409, 'LOCATION_INACCURATE', '위치 오차가 커서 근처 검색을 켜지 못했어요. 다시 확인하거나 QR로 연결해 주세요.', ); service.nearby.set(guest.id, socket.id, position); if (typeof acknowledge === 'function') acknowledge({ ok: true }); } catch (error) { if (typeof acknowledge === 'function') acknowledge({ ok: false, code: error instanceof ApiError ? error.code : 'LOCATION_INVALID', message: error instanceof ApiError ? error.message : '위치를 확인하지 못했습니다.', }); } }); socket.on('location:accept', (input) => { try { const guest = currentGuest(); service.limiter.hit(`location-accept:${guest.id}`, 20); const requestId = z.object({ requestId: z.uuid() }).strict().parse(input).requestId; const request = service.request(guest, requestId); if ( request.recipientGuestId === guest.id && request.status === 'pending' && request.expiresAt > Date.now() && cfg.locationDiscoveryEnabled && service.nearby.nearby(request.senderGuestId, guest.id) && service.canReceive(request.senderGuestId, guest.id) ) service.locationAcceptances.set(requestId, { guestId: guest.id, until: Date.now() + 15000, }); } catch { /* Invalid messages cannot grant an acceptance. */ } }); socket.on('location:clear', () => service.nearby.clear(socket.id)); socket.on('disconnect', () => service.nearby.clear(socket.id)); }); const auth = (req: FastifyRequest) => { const guest = service.guest(req.cookies.hop_guest); if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다. 새로 접속해 주세요.'); return guest!; }; const account = (req: FastifyRequest, verified = false) => service.auth.require(req.cookies.hop_account, verified); const requestSubject = (req: FastifyRequest, id: string): StorageSubject => { const row = db.select().from(tables.requests).where(eq(tables.requests.id, id)).get(); const user = service.auth.session(req.cookies.hop_account)?.user; if ( row?.mode === 'email' && user && (row.senderUserId === user.id || row.recipientUserId === user.id) ) { account(req, true); return { id: user.id, kind: 'user', sessionId: account(req, true).session.id }; } return auth(req); }; const accessSubject = (req: FastifyRequest, id: string): StorageSubject => { const row = db.select().from(tables.access).where(eq(tables.access.id, id)).get(), user = service.auth.session(req.cookies.hop_account)?.user; if (user && row?.userId === user.id) { account(req, true); return { id: user.id, kind: 'user', sessionId: account(req, true).session.id }; } return auth(req); }; const readAccess = (subject: StorageSubject, id: string, value: boolean) => { if (subject.kind === 'user') service.accounts.read(subject.id, id, value); else service.read(subject as Guest, id, value); }; const csrf = (req: FastifyRequest) => { const token = req.cookies.hop_guest; const actual = req.headers['x-csrf-token']; if (!token || typeof actual !== 'string') fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다. 새로고침해 주세요.'); const expected = Buffer.from(service.csrf(token!)); const given = Buffer.from(actual as string); if (given.length !== expected.length || !timingSafeEqual(given, expected)) fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다.'); }; const key = (req: FastifyRequest) => typeof req.headers['idempotency-key'] === 'string' ? req.headers['idempotency-key'] : undefined; const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id; app.addHook('onRequest', async (req, reply) => { reply .header('X-Content-Type-Options', 'nosniff') .header('Referrer-Policy', 'no-referrer') .header('X-Frame-Options', 'DENY') .header( 'Permissions-Policy', `camera=(), microphone=(), geolocation=${cfg.locationDiscoveryEnabled ? '(self)' : '()'}, bluetooth=()`, ); reply.header( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", ); if (req.url.startsWith('/api/')) { reply.header('Cache-Control', 'no-store'); const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable'); service.limiter.hit( `${resumable ? 'chunk' : 'api'}-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, resumable ? 6000 : 240, ); if (resumable) service.limiter.hit(`chunk-guest:${auth(req).id}`, 1200); if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { if (!allowed(req.headers.origin)) fail( 403, 'ORIGIN_INVALID', `서비스 주소 ${cfg.appUrl}에서 접속해 주세요. 현재 접속 주소가 서버 설정과 다릅니다.`, ); if (req.url.split('?')[0] !== '/api/guest/session') { auth(req); csrf(req); } } } }); app.setErrorHandler((err, req, reply) => { let status = 500, code = 'INTERNAL_ERROR', message = '처리 중 오류가 발생했습니다.'; if (err instanceof ApiError) { status = err.status; code = err.code; message = err.message; } else if (err instanceof z.ZodError) { status = 400; code = 'VALIDATION_ERROR'; message = '입력 내용을 확인해 주세요.'; } else if ( err && typeof err === 'object' && 'statusCode' in err && typeof err.statusCode === 'number' && err.statusCode < 500 ) { status = err.statusCode; code = 'INVALID_REQUEST'; message = '요청 형식이나 크기를 확인해 주세요.'; } reply.status(status).send({ code, message, requestId: req.id }); }); const setCookie = (reply: any, token: string, expiresAt: number) => reply.setCookie('hop_guest', token, { httpOnly: true, secure: cfg.production, sameSite: 'lax', path: '/', expires: new Date(expiresAt), }); app.get('/api/health', async () => ({ status: 'ok', phase: 1 })); app.get('/api/ops/proxy-diagnostic', async (req) => { if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.'); const given = req.headers.authorization?.replace(/^Bearer /, '') ?? ''; if (!timingSafeEqual(Buffer.from(hash(given)), Buffer.from(hash(cfg.proxyDiagnosticToken!)))) fail(403, 'OPERATOR_REQUIRED', '운영자 확인이 필요합니다.'); const raw = req.headers['cf-connecting-ip']; const candidate = typeof raw === 'string' ? normalizeIp(raw.trim()) : null; return { peerIp: normalizeIp(req.raw.socket.remoteAddress ?? ''), cloudflareClientIpPresent: publicIp(candidate), source: cfg.clientIpSource, resolved: Boolean(ip(req)), }; }); app.post('/api/guest/session', async (req, reply) => { service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20); let token = req.cookies.hop_guest, guest = service.guest(token); if (!guest) { const created = service.createGuest(req.headers['user-agent'] ?? ''); token = created.token; guest = created.guest; } setCookie(reply, token!, guest.expiresAt); return { device: service.device(guest.id), csrfToken: service.csrf(token!), expiresAt: guest.expiresAt, }; }); app.get('/api/device/snapshot', async (req) => { const g = auth(req); return db.transaction(() => { const r = service.listRequests(g), list = service.listItems(g); return { device: service.device(g.id), devices: service.peers(g), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, spaceInviteCodeLength: cfg.spaceInviteCodeLength, clipboardTextLimitBytes: cfg.maxTextBytes, locationDiscovery: { enabled: cfg.locationDiscoveryEnabled, radiusMeters: cfg.locationRadiusMeters, refreshMs: 120000, }, requests: r, items: list.items, nextCursor: list.nextCursor, spaces: service.listSpaces(g), }; }); }); app.get('/api/device/me', async (req) => ({ device: service.device(auth(req).id), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, })); app.patch('/api/device/me', async (req) => { const guest = auth(req); service.patchDevice(guest, devicePatch.parse(req.body)); return { device: service.device(guest.id) }; }); app.post('/api/device/reset', async (req, reply) => { const guest = auth(req); service.reset(guest); const created = service.createGuest(req.headers['user-agent'] ?? ''); setCookie(reply, created.token, created.guest.expiresAt); return { device: service.device(created.guest.id), csrfToken: service.csrf(created.token), expiresAt: created.guest.expiresAt, }; }); app.get('/api/discovery/devices', async (req) => ({ devices: service.peers(auth(req)), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, })); app.get('/api/spaces', async (req) => ({ spaces: service.listSpaces(auth(req)) })); app.post('/api/spaces', async (req) => service.createSpace(auth(req), spaceInput.parse(req.body).approvalRequired), ); app.post('/api/spaces/join', async (req) => service.joinSpace(auth(req), joinInput.parse(req.body), ip(req) ?? 'unknown'), ); app.post('/api/spaces/:id/members/:memberId/approve', async (req) => { const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params); service.approve(auth(req), p.id, p.memberId); return { ok: true }; }); app.delete('/api/spaces/:id/members/:memberId', async (req) => { const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params); service.remove(auth(req), p.id, p.memberId); return { ok: true }; }); app.delete('/api/spaces/:id', async (req) => { service.closeSpace(auth(req), id(req)); return { ok: true }; }); app.get('/api/transfer-requests', async (req) => { const mode = (req.query as { mode?: string }).mode; return { requests: mode === 'email' ? service.accounts.listRequests(account(req, true).user.id) : service.listRequests(auth(req)), }; }); app.post('/api/transfer-requests', async (req) => { if ((req.body as { mode?: string })?.mode === 'email') { const user = account(req, true).user, input = emailRequestInput.parse(req.body); input.email = normalizeEmail(input.email); if (input.email !== user.email) fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.'); const result = service.accounts.idem(user.id, 'request-create', key(req), input, () => service.accounts.create(user.id, input), ); service.notifyTransfer(result.requestId); return result; } const g = auth(req), input = requestInput.parse(req.body); const result = service.idem(g, 'request-create', key(req), input, () => service.createRequest(g, input, ip(req) ?? 'unknown'), ); service.notifyTransfer(result.requestId); return result; }); app.post('/api/transfer-requests/:id/accept', async (req) => { const requestId = id(req), subject = requestSubject(req, requestId); let result; if (subject.kind === 'user') { const input = z .object({ allowSender: z.boolean().default(false) }) .strict() .parse(req.body); result = service.accounts.idem(subject.id, `accept:${requestId}`, key(req), input, () => service.accounts.accept(subject.id, requestId, input.allowSender), ); } else { service.request(subject as Guest, requestId); result = service.idem(subject as Guest, `accept:${requestId}`, key(req), {}, () => (() => { const result = service.accept(subject as Guest, requestId); service.vault.bindReceipt(requestId, service.auth.session(req.cookies.hop_account)); service.vault.deliver(requestId); return result; })(), ); } service.notifyTransfer(requestId); return result; }); app.post('/api/transfer-requests/:id/reject', async (req) => { const requestId = id(req), subject = requestSubject(req, requestId); if (subject.kind === 'user') service.accounts.reject(subject.id, requestId); else service.reject(subject as Guest, requestId); service.notifyTransfer(requestId); return { ok: true }; }); app.post('/api/transfer-requests/:id/cancel', async (req) => { const requestId = id(req), subject = requestSubject(req, requestId); if (subject.kind === 'user') service.accounts.cancel(subject.id, requestId); else service.cancel(subject as Guest, requestId); service.notifyTransfer(requestId); return { ok: true }; }); app.post('/api/transfer-requests/:id/block', async (req) => { const requestId = id(req), subject = requestSubject(req, requestId); if (subject.kind === 'user') { const row = service.accounts.request(subject.id, requestId); if (row.recipientUserId !== subject.id) fail(403, 'FORBIDDEN', '수신 계정만 차단할 수 있습니다.'); service.accounts.rule(subject.id, row.senderUserId!, 'block'); } else service.block(subject as Guest, requestId); return { ok: true }; }); app.post('/api/transfers/:id/complete', async (req) => { const requestId = id(req), subject = requestSubject(req, requestId), input = completeInput.parse(req.body); if (service.vault.source(requestId)) fail(409, 'VAULT_TRANSFER_MANAGED', '보관함 전송은 서버에서 처리합니다.'); let result; if (subject.kind === 'user') result = service.accounts.idem(subject.id, `complete:${requestId}`, key(req), input, () => service.accounts.complete(subject.id, requestId, input), ); else { service.request(subject as Guest, requestId); result = service.idem(subject as Guest, `complete:${requestId}`, key(req), input, () => service.complete(subject as Guest, requestId, input), ); } service.notifyTransfer(requestId); return result; }); app.get('/api/device/items', async (req) => { const q = z .object({ cursor: z.string().max(512).optional(), limit: z.coerce.number().int().min(1).max(100).default(30), }) .parse(req.query); return service.listItems(auth(req), q.cursor, q.limit); }); app.get('/api/access/:id', async (req) => ({ item: accessSubject(req, id(req)).kind === 'user' ? service.accounts.access(account(req, true).user.id, id(req)).item : service.accessDetail(auth(req), id(req)).item, })); app.patch('/api/access/:id/read', async (req) => { const body = z.object({ read: z.boolean() }).strict().parse(req.body); readAccess(accessSubject(req, id(req)), id(req), body.read); return { ok: true }; }); const resumableSubject = (req: FastifyRequest) => { const file = db .select() .from(tables.files) .where(eq(tables.files.id, id(req))) .get(); if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.'); return requestSubject(req, file!.requestId); }; app.post('/api/uploads/resumable', async (req) => { const input = z .object({ requestId: z.uuid(), fileIndex: z .number() .int() .min(0) .max(cfg.maxItems - 1), fingerprint: z.string().regex(/^[0-9a-f]{64}$/), }) .strict() .parse(req.body); return service.resumable.start( requestSubject(req, input.requestId), input.requestId, input.fileIndex, input.fingerprint, ); }); app.get('/api/uploads/resumable/:id', async (req) => service.resumable.status(resumableSubject(req), id(req)), ); app.put('/api/uploads/resumable/:id', { bodyLimit: CHUNK_BYTES }, async (req) => { const offset = z.coerce .number() .int() .min(0) .max(cfg.maxFileBytes) .parse(req.headers['upload-offset']); return service.resumable.append(resumableSubject(req), id(req), offset, req.body); }); app.post('/api/uploads/resumable/:id/complete', async (req) => service.resumable.complete(resumableSubject(req), id(req), req), ); app.post('/api/uploads', async (req) => { const input = z .object({ requestId: z.uuid(), fileIndex: z.coerce .number() .int() .min(0) .max(cfg.maxItems - 1), }) .parse(req.query); const uploadKey = z .string() .regex(/^[A-Za-z0-9_-]{8,128}$/) .parse(key(req)); if (service.vault.source(input.requestId)) fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.'); return service.storage.upload( requestSubject(req, input.requestId), input.requestId, input.fileIndex, req, uploadKey, ); }); app.delete('/api/uploads/:id', async (req) => { service.storage.cancel( (() => { const file = db .select() .from(tables.files) .where(eq(tables.files.uploadId, id(req))) .get(); const user = service.auth.session(req.cookies.hop_account)?.user; return user && file?.ownerUserId === user.id ? { id: user.id, kind: 'user' as const } : auth(req); })(), id(req), ); return { ok: true }; }); app.get('/api/access/:id/download', async (req, reply) => { const accessId = id(req), g = accessSubject(req, accessId); const { file, path } = service.storage.download(g, accessId); const fallback = file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download'; const encoded = encodeURIComponent(file.name).replace( /['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(), ); reply .header( 'Content-Disposition', `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`, ) .header('Content-Length', file.size) .type('application/octet-stream'); readAccess(g, accessId, true); return reply.send(createReadStream(path)); }); app.get('/api/access/:id/preview', async (req, reply) => reply .type('image/webp') .send(await service.storage.preview(accessSubject(req, id(req)), id(req))), ); app.post('/api/access/delete-batch', async (req) => { const body = z .object({ ids: z.array(z.uuid()).min(1).max(100) }) .strict() .parse(req.body), g = accessSubject(req, body.ids[0]!); const result = service.storage.deleteAccess(g, body.ids); if (g.kind === 'user') service.accounts.notify(g.id); else presence.emit(g.id, 'access:updated', {}); return result; }); app.post('/api/deletion-operations/:id/undo', async (req) => { const operation = db .select() .from(tables.deletions) .where(eq(tables.deletions.id, id(req))) .get(); const user = service.auth.session(req.cookies.hop_account)?.user; const g: StorageSubject = user && operation?.userId === user.id ? { id: user.id, kind: 'user' } : auth(req); const result = service.storage.undo(g, id(req)); if (g.kind === 'user') service.accounts.notify(g.id); else presence.emit(g.id, 'access:updated', {}); return result; }); const credentials = z .object({ email: z.string().max(254), password: z.string().min(1).max(1024) }) .strict(); const registration = credentials .extend({ passwordConfirmation: z.string().max(1024), displayName: z .string() .trim() .min(1) .max(40) .refine((v) => !/[\x00-\x1f\x7f<>]/.test(v)), setupToken: z.string().min(32).max(256).optional(), }) .refine((v) => v.password === v.passwordConfirmation); const tokenInput = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(); const clearAccount = (reply: any) => reply.clearCookie('hop_account', { path: '/', httpOnly: true, secure: cfg.production, sameSite: 'lax', }); app.get('/api/auth/me', async (req) => { const current = service.auth.session(req.cookies.hop_account); return { account: current ? service.auth.serialize(current.user) : null, registrationOpen: cfg.registrationMode === 'open', passwordMinLength: cfg.production ? 12 : 1, }; }); app.post('/api/auth/register', async (req) => { const input = registration.parse(req.body); if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.'); return service.auth.register( input.email, input.password, input.displayName, ip(req) ?? 'unknown', ); }); app.post('/api/auth/setup', async (req) => { const input = registration.parse(req.body); if (!input.setupToken) fail(403, 'SETUP_DISABLED', '초기화 토큰이 필요합니다.'); return service.auth.register( input.email, input.password, input.displayName, ip(req) ?? 'unknown', input.setupToken, ); }); app.post('/api/auth/verify-email', async (req) => service.auth.verify(tokenInput.parse(req.body).token), ); app.post('/api/auth/verify-email-change', async (req) => service.auth.verifyEmailChange(tokenInput.parse(req.body).token), ); app.post('/api/auth/resend-verification', async (req) => service.auth.resend( z .object({ email: z.string().max(254) }) .strict() .parse(req.body).email, 'verify', ip(req) ?? 'unknown', ), ); app.post('/api/auth/login', async (req, reply) => { const input = credentials.parse(req.body); const result = await service.auth.login( input.email, input.password, ip(req) ?? 'unknown', req.cookies.hop_account, ); reply.setCookie('hop_account', result.token, { httpOnly: true, secure: cfg.production, sameSite: 'lax', path: '/', expires: new Date(result.expiresAt), }); return { account: result.account }; }); app.post('/api/auth/logout', async (req, reply) => { service.auth.logout(req.cookies.hop_account); clearAccount(reply); return { ok: true }; }); app.post('/api/auth/forgot-password', async (req) => service.auth.resend( z .object({ email: z.string().max(254) }) .strict() .parse(req.body).email, 'reset', ip(req) ?? 'unknown', ), ); app.post('/api/auth/reset-password', async (req) => { const input = tokenInput .extend({ password: z.string().max(1024), passwordConfirmation: z.string().max(1024) }) .refine((v) => v.password === v.passwordConfirmation) .parse(req.body); return service.auth.resetPassword(input.token, input.password); }); app.post('/api/auth/change-password', async (req, reply) => { const input = z .object({ currentPassword: z.string().max(1024), password: z.string().max(1024), passwordConfirmation: z.string().max(1024), }) .strict() .refine((v) => v.password === v.passwordConfirmation) .parse(req.body); await service.auth.changePassword(account(req).user, input.currentPassword, input.password); clearAccount(reply); return { ok: true }; }); app.post('/api/auth/change-email', async (req) => { const input = z .object({ email: z.string().max(254), password: z.string().max(1024) }) .strict() .parse(req.body); await service.auth.changeEmail(account(req).user, input.email, input.password); return { ok: true }; }); for (const view of ['inbox', 'vault', 'sent', 'favorites'] as const) app.get(`/api/${view}`, async (req) => { const input = z .object({ cursor: z.string().max(512).optional(), limit: z.coerce.number().int().min(1).max(100).default(30), search: z.string().max(256).optional(), kind: z.enum(['url', 'file']).optional(), read: z.enum(['true', 'false']).optional(), }) .parse(req.query); return service.accounts.list(account(req, true).user.id, view, { ...input, read: input.read === undefined ? undefined : input.read === 'true', }); }); app.get('/api/vault/contents', async (req) => { const input = z .object({ cursor: z.string().max(512).optional(), limit: z.coerce.number().int().min(1).max(100).default(30), }) .parse(req.query); return service.accounts.list(account(req, true).user.id, 'contents', input); }); const noteInput = z .string() .trim() .max(200) .refine((value) => !/[\r\n]/.test(value)) .default(''); app.post('/api/vault', async (req) => { const user = account(req, true).user; const input = z .discriminatedUnion('kind', [ z .object({ kind: z.literal('url'), urls: z.array(z.string().max(cfg.maxUrlBytes)).min(1).max(cfg.maxItems), note: noteInput, }) .strict(), z .object({ kind: z.literal('text'), text: z.string().min(1).max(1048576), note: noteInput, }) .strict(), z .object({ kind: z.literal('file'), files: requestInput.shape.files.unwrap().min(1).max(cfg.maxItems), note: noteInput, }) .strict(), ]) .parse(req.body); const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => { const created = service.accounts.create(user.id, { email: user.email!, kind: input.kind, count: input.kind === 'text' ? 1 : input.kind === 'url' ? input.urls.length : input.files.length, byteSize: input.kind === 'text' ? Buffer.byteLength(input.text, 'utf8') : input.kind === 'url' ? Buffer.byteLength(input.urls.join('\n')) : input.files.reduce((sum, file) => sum + file.size, 0), ...(input.kind === 'file' ? { files: input.files } : {}), }); db.update(tables.requests) .set({ note: input.note || null }) .where(eq(tables.requests.id, created.requestId)) .run(); if (input.kind !== 'file') service.accounts.complete( user.id, created.requestId, input.kind === 'text' ? { text: input.text } : { urls: input.urls }, ); return created; }); service.notifyTransfer(result.requestId); return result; }); app.patch('/api/vault/:id/note', async (req) => { const input = z.object({ note: noteInput }).strict().parse(req.body); service.vault.note(account(req, true).user.id, id(req), input.note); return { ok: true }; }); app.post('/api/vault/:id/send', async (req) => { const guest = auth(req), current = account(req, true), accessId = id(req); const input = z .object({ targetDeviceId: requestInput.shape.targetDeviceId }) .strict() .parse(req.body); const result = service.accounts.idem( current.user.id, `vault-send:${accessId}`, key(req), { ...input, guestId: guest.id, sessionId: current.session.id }, () => service.vault.forward(guest, current, accessId, input.targetDeviceId, ip(req) ?? 'unknown'), ); service.notifyTransfer(result.requestId); return result; }); app.patch('/api/access/:id/favorite', async (req) => { account(req, true); fail(410, 'PERMANENT_RETENTION_REMOVED', '모든 자료는 최대 7일간 보관됩니다.'); }); app.post('/api/access/:id/save-to-account', async (req) => { account(req, true); fail(410, 'GUEST_IMPORT_REMOVED', '로그인 중 받은 자료만 자동 기록합니다.'); }); app.get('/api/account/preferences', async (req) => ({ receivePolicy: account(req, true).user.receivePolicy, })); app.patch('/api/account/preferences', async (req) => { const input = z .object({ receivePolicy: z.enum(['requests', 'allowed', 'none']) }) .strict() .parse(req.body), user = account(req, true).user; db.update(tables.users).set(input).where(eq(tables.users.id, user.id)).run(); service.accounts.notify(user.id); return { ok: true }; }); app.get('/api/account/rules', async (req) => ({ rules: db .select({ id: tables.rules.targetUserId, action: tables.rules.action, email: tables.users.email, displayName: tables.users.displayName, }) .from(tables.rules) .innerJoin(tables.users, eq(tables.rules.targetUserId, tables.users.id)) .where(eq(tables.rules.userId, account(req, true).user.id)) .all(), })); app.put('/api/account/rules/:targetUserId', async (req) => { const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId, input = z .object({ action: z.enum(['allow', 'block']) }) .strict() .parse(req.body); service.accounts.rule(account(req, true).user.id, target, input.action); return { ok: true }; }); app.delete('/api/account/rules/:targetUserId', async (req) => { const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId, user = account(req, true).user; db.delete(tables.rules) .where(and(eq(tables.rules.userId, user.id), eq(tables.rules.targetUserId, target))) .run(); service.accounts.notify(user.id); return { ok: true }; }); app.get('/api/account/storage', async (req) => ({ usedBytes: service.accounts.used(account(req, true).user.id), limitBytes: cfg.userQuota, })); const webRoot = cfg.webRoot; if (webRoot && existsSync(webRoot)) { await app.register(serveStatic, { root: webRoot, index: 'index.html' }); app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.url.startsWith('/socket.io/')) return reply .status(404) .send({ code: 'NOT_FOUND', message: '경로를 찾을 수 없습니다.', requestId: req.id }); return reply.sendFile('index.html'); }); } let storageCleanup: Promise | undefined; const cleanStorage = () => { storageCleanup = service.storage .cleanup() .catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed')); }; cleanStorage(); const storageTimer = setInterval(cleanStorage, 60000); storageTimer.unref(); const cleanup = setInterval(() => { for (const [id, permission] of service.locationAcceptances) if (permission.until <= Date.now()) service.locationAcceptances.delete(id); service.cleanup(); service.auth.enforceSessions(); }, 1000); cleanup.unref(); app.addHook('preClose', async () => { service.storage.close(); io.disconnectSockets(true); }); app.addHook('onClose', async () => { clearInterval(cleanup); clearInterval(storageTimer); presence.close(); service.nearby.close(); service.storage.close(); await storageCleanup; await new Promise((resolve) => io.close(() => resolve())); sqlite.close(); }); return { app, io, service, sqlite, setDevOrigin: (value: string) => { devOrigin = value; }, }; }