1039 lines
38 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|