Files
hop.noname.company/apps/server/src/app.ts

1039 lines
38 KiB
TypeScript

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 serveStatic from '@fastify/static';
import { Server } from 'socket.io';
import { timingSafeEqual } from 'node:crypto';
import { existsSync } from 'node:fs';
import { z } from 'zod';
import {
PASSWORD_MIN_LENGTH,
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 = '요청 형식이나 크기를 확인해 주세요.';
}
const retryAfterSeconds =
err instanceof ApiError && err.retryAt
? Math.max(1, Math.ceil((err.retryAt - Date.now()) / 1000))
: undefined;
if (retryAfterSeconds) reply.header('Retry-After', retryAfterSeconds);
reply.status(status).send({
code,
message,
requestId: req.id,
...(retryAfterSeconds
? {
retryAfterSeconds,
retryAt: (err as ApiError).retryAt,
limitScope: (err as ApiError).limitScope,
}
: {}),
});
});
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.get('/api/device/blocks', async (req) => ({ blocks: service.listBlocks(auth(req)) }));
app.delete('/api/device/blocks/:id', async (req) => {
service.unblock(auth(req), id(req));
return { ok: true };
});
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, stream, access } = await service.storage.openDownload(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');
try {
readAccess(g, accessId, true);
} catch (error) {
stream.destroy();
throw error;
}
reply.raw.once('finish', () => {
if (reply.statusCode === 200 && stream.readableEnded && access.role === 'recipient') {
try {
service.storage.layout.downloaded(file.id);
} catch (err) {
req.log.warn({ err }, 'Completed file relocation will be retried');
}
}
});
return reply.send(stream);
});
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',
});
const setAccountCookie = (reply: any, result: { token: string; expiresAt: number }) =>
reply.setCookie('hop_account', result.token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(result.expiresAt),
});
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: PASSWORD_MIN_LENGTH,
};
});
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, reply) => {
const result = service.auth.verify(tokenInput.parse(req.body).token, req.cookies.hop_account);
setAccountCookie(reply, result);
return { ok: true, account: result.account };
});
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,
);
setAccountCookie(reply, result);
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<void> | 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<void>((resolve) => io.close(() => resolve()));
sqlite.close();
});
return {
app,
io,
service,
sqlite,
setDevOrigin: (value: string) => {
devOrigin = value;
},
};
}