Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

View File

@@ -30,7 +30,8 @@ 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 } from './security.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({
@@ -49,6 +50,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
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;
@@ -58,7 +62,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
(origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)),
);
const ip = (req: FastifyRequest) =>
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies);
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies, cfg.clientIpSource);
const io = new Server(app.server, {
serveClient: false,
maxHttpBufferSize: 16384,
@@ -93,6 +97,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
socket.request.socket.remoteAddress,
socket.request.headers,
cfg.trustedProxies,
cfg.clientIpSource,
);
next();
} catch {
@@ -164,10 +169,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
);
if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store');
service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240);
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', '허용되지 않은 접속 경로입니다.');
fail(
403,
'ORIGIN_INVALID',
`서비스 주소 ${cfg.appUrl}에서 접속해 주세요. 현재 접속 주소가 서버 설정과 다릅니다.`,
);
if (req.url.split('?')[0] !== '/api/guest/session') {
auth(req);
csrf(req);
@@ -209,6 +223,20 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
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,
@@ -409,6 +437,50 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
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({