Initial hop. implementation and UGREEN NAS deployment guide

This commit is contained in:
2026-10-12 13:37:50 +09:00
commit 439c2ae61b
93 changed files with 42434 additions and 0 deletions

667
apps/server/src/accounts.ts Normal file
View File

@@ -0,0 +1,667 @@
import { randomUUID } from 'node:crypto';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
import { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared';
import type { Service, RequestRow, Guest } from './service.js';
import type { User } from './auth.js';
import * as s from './db/schema.js';
import { fail, hash } from './security.js';
export class Accounts {
constructor(readonly service: Service) {}
verified(userId: string) {
const user = this.service.auth.user(userId);
if (!this.service.auth.serialize(user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return user;
}
notify(userId: string) {
const io = this.service.io;
if (!io) return;
for (const socket of io.sockets.sockets.values()) {
if (socket.data.accountId !== userId) continue;
const session = this.service.db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, socket.data.accountSessionId))
.get();
const user = this.service.auth.user(userId);
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(user.status)
) {
socket.leave(`account:${userId}`);
socket.emit('account:revoked', {});
}
}
io.to(`account:${userId}`).emit('inbox:updated', {});
}
request(userId: string, id: string) {
const row = this.service.db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.id, id),
eq(s.requests.mode, 'email'),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.get();
if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.');
return row!;
}
policy(senderId: string, recipientId: string) {
const recipient = this.verified(recipientId);
this.verified(senderId);
if (senderId === recipientId) return 'allow';
const rule = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, recipientId), eq(s.rules.targetUserId, senderId)))
.get();
if (rule?.action === 'block' || recipient.receivePolicy === 'none') return 'block';
if (rule?.action === 'allow') return 'allow';
if (recipient.receivePolicy === 'allowed') return 'block';
return 'request';
}
canTransfer(row: RequestRow) {
try {
return (
row.mode === 'email' &&
row.status === 'accepted' &&
this.policy(row.senderUserId!, row.recipientUserId!) !== 'block'
);
} catch {
return false;
}
}
idem<T>(
userId: string,
operation: string,
key: string | undefined,
payload: unknown,
fn: () => T,
): T {
if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key))
fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.');
const payloadHash = hash(JSON.stringify(payload));
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.idempotency)
.where(
and(
eq(s.idempotency.userId, userId),
eq(s.idempotency.operation, operation),
eq(s.idempotency.key, key!),
),
)
.get();
if (row) {
if (row.payloadHash !== payloadHash)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.');
return JSON.parse(row.response) as T;
}
const result = fn();
this.service.db
.insert(s.idempotency)
.values({
id: randomUUID(),
userId,
operation,
key: key!,
payloadHash,
response: JSON.stringify(result),
createdAt: Date.now(),
})
.run();
return result;
});
}
create(
userId: string,
input: {
email: string;
kind: 'url' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
},
) {
const { db, cfg, limiter } = this.service;
this.verified(userId);
limiter.hit(`remote-target:${userId}`, 30, 3600000);
const recipient = db
.select()
.from(s.users)
.where(
and(
eq(s.users.email, input.email),
eq(s.users.status, 'active'),
sql`${s.users.verifiedAt} IS NOT NULL`,
),
)
.get();
if (!recipient) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
let policy: string;
try {
policy = this.policy(userId, recipient!.id);
} catch {
policy = 'block';
}
if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
if (
input.count > cfg.maxItems ||
(input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
input.files?.some((f) => f.size > cfg.maxFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
if (policy === 'request') {
limiter.hit(`remote-new:${userId}`, 20, 86400000);
const pending = db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.recipientUserId, recipient!.id),
eq(s.requests.status, 'pending'),
gt(s.requests.expiresAt, Date.now()),
),
)
.all();
if (pending.length >= 50) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
}
const now = Date.now(),
id = randomUUID();
db.insert(s.requests)
.values({
id,
mode: 'email',
senderUserId: userId,
recipientUserId: recipient!.id,
kind: input.kind,
byteSize: input.byteSize,
count: input.count,
manifest: input.files ? JSON.stringify(input.files) : null,
status: policy === 'allow' ? 'accepted' : 'pending',
createdAt: now,
expiresAt: now + cfg.emailRequestDays * 86400000,
acceptedAt: policy === 'allow' ? now : null,
startBy: policy === 'allow' ? now + cfg.uploadStartHours * 3600000 : null,
})
.run();
db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run();
if (input.files) {
this.service.storage.createManifest({ id: userId, kind: 'user' }, id, input.files);
if (policy === 'allow') this.service.storage.reserve({ id: userId, kind: 'user' }, id);
}
return { requestId: id, requiresAcceptance: policy !== 'allow' };
}
accept(userId: string, id: string, allowSender = false) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId) fail(403, 'FORBIDDEN', '수신 계정만 수락할 수 있습니다.');
if (
row.status !== 'pending' ||
row.expiresAt <= Date.now() ||
this.policy(row.senderUserId!, userId) === 'block'
)
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file')
this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id);
if (allowSender) this.rule(userId, row.senderUserId!, 'allow');
const now = Date.now();
this.service.db
.update(s.requests)
.set({
status: 'accepted',
acceptedAt: now,
startBy: now + this.service.cfg.uploadStartHours * 3600000,
})
.where(eq(s.requests.id, id))
.run();
return { requestId: id };
}
reject(userId: string, id: string) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId || row.status !== 'pending')
fail(409, 'REQUEST_STATE', '거절할 수 없는 요청입니다.');
this.service.db
.update(s.requests)
.set({ status: 'rejected', rejectedAt: Date.now() })
.where(eq(s.requests.id, id))
.run();
}
cancel(userId: string, id: string) {
const row = this.request(userId, id),
batch = this.service.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!;
if (
row.senderUserId !== userId ||
!['pending', 'accepted'].includes(row.status) ||
batch.status === 'stored'
)
fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.');
this.service.storage.release(id);
this.service.db
.update(s.requests)
.set({ status: 'cancelled' })
.where(eq(s.requests.id, id))
.run();
this.service.db
.update(s.batches)
.set({ status: 'failed' })
.where(eq(s.batches.id, batch.id))
.run();
}
complete(userId: string, id: string, input: { urls: string[] }) {
const row = this.request(userId, id);
if (
row.senderUserId !== userId ||
!this.canTransfer(row) ||
!row.startBy ||
row.startBy <= Date.now()
)
fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.');
if (row.kind === 'file')
fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.');
const batch = this.service.db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, id))
.get()!;
if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.');
if (row.kind !== 'url')
fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.');
const parsed = parseUrls(input.urls.join('\n'));
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
const values = parsed.urls;
if (Buffer.byteLength(values.join('\n')) !== row.byteSize || values.length !== row.count)
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
const now = Date.now();
for (const value of values) {
const itemId = randomUUID();
this.service.db
.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.kind,
url: value,
createdAt: now,
expiresAt: now + this.service.cfg.urlRetentionDays * 86400000,
})
.run();
this.createAccess(row, itemId);
}
this.service.db
.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
return { requestId: id, batchId: batch.id };
}
createAccess(row: RequestRow, itemId: string) {
if (row.mode === 'local') {
for (const [guestId, role] of [
[row.senderGuestId!, 'sender'],
[row.recipientGuestId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, guestId, role, read: false })
.run();
this.service.vault.receipt(row, itemId);
} else if (row.senderUserId === row.recipientUserId)
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId,
userId: row.senderUserId!,
role: 'self',
read: false,
note: row.note,
})
.run();
else
for (const [userId, role] of [
[row.senderUserId!, 'sender'],
[row.recipientUserId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, userId, role, read: false })
.run();
}
listRequests(userId: string): EmailRequest[] {
this.verified(userId);
return this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.mode, 'email'),
gt(s.requests.createdAt, Date.now() - 7 * 86400000),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.orderBy(desc(s.requests.createdAt), desc(s.requests.id))
.limit(100)
.all()
.map(({ request: r, batch: b }) => ({
id: r.id,
batchId: b.id,
kind: r.kind as 'url' | 'file',
byteSize: r.byteSize,
count: r.count,
status: r.status,
transferStatus: b.status,
createdAt: r.createdAt,
expiresAt: r.expiresAt,
startBy: r.startBy,
direction: r.senderUserId === userId ? 'sent' : 'received',
sender: this.service.auth.serialize(this.service.auth.user(r.senderUserId!)),
recipient: this.service.auth.serialize(this.service.auth.user(r.recipientUserId!)),
files:
r.kind === 'file'
? this.service.storage.fileRows(r.id).map((f) => ({
id: f.id,
name: f.name,
size: f.size,
type: f.type,
status: f.status,
}))
: undefined,
}));
}
access(userId: string, id: string) {
this.verified(userId);
const row = this.service.db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, id),
eq(s.access.userId, userId),
inArray(s.items.kind, ['url', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
return row!;
}
read(userId: string, id: string, value: boolean) {
const row = this.access(userId, id);
this.service.db
.update(s.access)
.set({ read: value })
.where(and(eq(s.access.itemId, row.item.id), eq(s.access.role, row.access.role)))
.run();
for (const access of this.service.db
.select()
.from(s.access)
.where(eq(s.access.itemId, row.item.id))
.all()) {
if (access.userId) this.notify(access.userId);
if (access.guestId) this.service.presence.emit(access.guestId, 'access:updated', {});
}
}
list(
userId: string,
view: 'inbox' | 'vault' | 'sent' | 'favorites' | 'contents',
query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean },
) {
this.verified(userId);
let after: { createdAt: number; id: string } | undefined;
if (query.cursor) {
try {
after = JSON.parse(Buffer.from(query.cursor, 'base64url').toString());
if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string')
throw Error();
} catch {
fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.');
}
}
const rows = this.service.db
.select({ access: s.access, item: s.items, request: s.requests })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.batches, eq(s.items.batchId, s.batches.id))
.innerJoin(s.requests, eq(s.batches.requestId, s.requests.id))
.where(
and(
eq(s.access.userId, userId),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
view === 'contents'
? inArray(s.access.role, ['self', 'recipient'])
: view === 'favorites'
? eq(s.access.favorite, true)
: eq(
s.access.role,
view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender',
),
inArray(s.items.kind, ['url', 'file']),
query.kind ? eq(s.items.kind, query.kind as any) : undefined,
query.read !== undefined ? eq(s.access.read, query.read) : undefined,
query.search
? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
: undefined,
after
? or(
lt(s.items.createdAt, after.createdAt),
and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)),
)
: undefined,
),
)
.orderBy(desc(s.items.createdAt), desc(s.access.id))
.limit(query.limit + 1)
.all();
const result: AccountItem[] = rows.slice(0, query.limit).map((r) => {
const peerId =
r.access.role === 'sender' ? r.request.recipientUserId : r.request.senderUserId;
const peer = peerId ? this.service.auth.user(peerId) : null;
const file = r.item.fileId
? this.service.db.select().from(s.files).where(eq(s.files.id, r.item.fileId)).get()
: null;
const recipient = this.service.db
.select()
.from(s.access)
.where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient')))
.get();
return {
accessId: r.access.id,
id: r.item.id,
batchId: r.item.batchId,
kind: r.item.kind as 'url' | 'file',
url: r.item.url,
role: r.access.role,
read: r.access.read,
favorite: r.access.favorite,
note: r.access.note,
file: file ? { id: file.id, name: file.name, size: file.size, type: file.type } : undefined,
peerName:
peer?.displayName ??
(r.request.senderGuestId
? this.service.device(r.request.senderGuestId).name
: '익명 기기'),
peerCode: '',
senderEmail: peer?.email ?? undefined,
senderName: peer?.displayName,
createdAt: r.item.createdAt,
expiresAt: r.item.expiresAt,
recipientRead: recipient?.read ?? false,
};
});
const last = result.at(-1);
return {
items: result,
nextCursor:
rows.length > query.limit && last
? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString(
'base64url',
)
: null,
};
}
used(userId: string) {
const db = this.service.db;
const owned = db
.select()
.from(s.files)
.where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning'])))
.all();
const accounted = new Map(owned.map((f) => [f.id, f.size]));
const preserved = db
.select({ file: s.files })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.files, eq(s.items.fileId, s.files.id))
.where(
and(
eq(s.access.userId, userId),
or(eq(s.access.favorite, true), eq(s.access.role, 'self')),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.all();
for (const { file } of preserved) accounted.set(file.id, file.size);
return [...accounted.values()].reduce((a, b) => a + b, 0);
}
ensureQuota(userId: string, bytes: number) {
if (this.used(userId) + bytes > this.service.cfg.userQuota)
fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.');
}
favorite(userId: string, id: string, value: boolean) {
this.service.db.transaction(() => {
const row = this.access(userId, id);
if (value && !row.access.favorite && row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
if (file.ownerUserId !== userId && row.access.role !== 'self')
this.ensureQuota(userId, file.size);
}
this.service.db.update(s.access).set({ favorite: value }).where(eq(s.access.id, id)).run();
});
this.notify(userId);
}
move(g: Guest, userId: string, id: string) {
this.verified(userId);
return this.service.db.transaction(() => {
const row = this.service.accessDetail(g, id);
if (row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
this.ensureQuota(userId, file.size);
if (file.ownerGuestId === g.id)
this.service.db
.update(s.files)
.set({ ownerGuestId: null, ownerUserId: userId })
.where(eq(s.files.id, file.id))
.run();
}
const existing = this.service.db
.select()
.from(s.access)
.where(
and(
eq(s.access.itemId, row.item.id),
eq(s.access.userId, userId),
eq(s.access.role, 'self'),
),
)
.get();
if (existing)
this.service.db
.update(s.access)
.set({ deletedAt: null })
.where(eq(s.access.id, existing.id))
.run();
else
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId: row.item.id,
userId,
role: 'self',
read: row.access.read,
})
.run();
this.service.db
.update(s.access)
.set({ deletedAt: Date.now() })
.where(eq(s.access.id, row.access.id))
.run();
return { ok: true };
});
}
rule(userId: string, target: string, action: 'allow' | 'block') {
this.verified(userId);
if (target === userId)
fail(400, 'RULE_INVALID', '자기 계정에는 수신 규칙을 적용하지 않습니다.');
const candidate = this.service.db
.select()
.from(s.requests)
.where(and(eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId)))
.get();
const existing = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, userId), eq(s.rules.targetUserId, target)))
.get();
if (!candidate && !existing)
fail(404, 'SENDER_NOT_FOUND', '수신 관계가 있는 발신자만 관리할 수 있습니다.');
this.service.db
.insert(s.rules)
.values({ id: randomUUID(), userId, targetUserId: target, action })
.onConflictDoUpdate({ target: [s.rules.userId, s.rules.targetUserId], set: { action } })
.run();
if (action === 'block') {
const affected = this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.senderUserId, target),
eq(s.requests.recipientUserId, userId),
inArray(s.requests.status, ['pending', 'accepted']),
inArray(s.batches.status, ['waiting', 'uploading']),
),
)
.all();
for (const { request } of affected) {
this.service.storage.release(request.id);
this.service.db
.update(s.requests)
.set({
status: request.status === 'pending' ? 'rejected' : 'cancelled',
rejectedAt: Date.now(),
})
.where(eq(s.requests.id, request.id))
.run();
}
this.notify(target);
}
this.notify(userId);
}
}

828
apps/server/src/app.ts Normal file
View File

@@ -0,0 +1,828 @@
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 } from './security.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 },
});
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);
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,
);
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 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=(), 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');
service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
if (!allowed(req.headers.origin))
fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.');
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.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,
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 };
});
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('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 === 'url' ? input.urls.length : input.files.length,
byteSize:
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 === 'url')
service.accounts.complete(user.id, created.requestId, { 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 cleanup = setInterval(() => {
service.cleanup();
service.auth.enforceSessions();
storageCleanup = service.storage
.cleanup()
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
}, 1000);
cleanup.unref();
app.addHook('preClose', async () => {
service.storage.close();
io.disconnectSockets(true);
});
app.addHook('onClose', async () => {
clearInterval(cleanup);
presence.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;
},
};
}

374
apps/server/src/auth.ts Normal file
View File

@@ -0,0 +1,374 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
import type { Service } from './service.js';
import type { Account } from '@hop/shared';
import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
export type Mailer = (mail: Mail) => Promise<void>;
export type User = typeof s.users.$inferSelect;
const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 };
let dummy: Promise<string> | undefined;
export function normalizeEmail(value: string) {
const trimmed = value.trim();
const split = trimmed.lastIndexOf('@');
if (
split < 1 ||
split === trimmed.length - 1 ||
trimmed.length > 254 ||
/[\s\x00-\x1f\x7f]/.test(trimmed)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
const local = trimmed.slice(0, split),
domain = trimmed.slice(split + 1).toLowerCase();
if (
!/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) ||
!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
return `${local}@${domain}`;
}
export class Auth {
readonly sendMail: Mailer;
readonly mailReady: boolean;
constructor(
readonly service: Service,
mailer?: Mailer,
) {
this.mailReady = Boolean(mailer);
this.sendMail =
mailer ??
(async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'));
if (service.cfg.registrationMode === 'open' && !this.mailReady)
throw Error('Mail provider must be configured before opening registration');
}
serialize(user: User): Account {
return {
id: user.id,
email: user.email ?? '',
displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName,
role: user.role,
status: user.status,
verified: Boolean(user.verifiedAt && user.status === 'active'),
};
}
user(id: string) {
const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
return user!;
}
session(token: string | undefined) {
if (!token) return undefined;
const row = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(
and(
eq(s.accountSessions.tokenHash, hash(token)),
isNull(s.accountSessions.revokedAt),
gt(s.accountSessions.expiresAt, Date.now()),
inArray(s.users.status, ['pending', 'active']),
),
)
.get();
return row;
}
require(token: string | undefined, verified = false) {
const current = this.session(token);
if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.');
if (verified && !this.serialize(current!.user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return current!;
}
async password(value: string) {
const minimum = this.service.cfg.production ? 12 : 1;
if (Array.from(value).length < minimum || value.length > 1024)
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
}
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
this.service.db
.update(s.accountTokens)
.set({ usedAt: now })
.where(
and(
eq(s.accountTokens.userId, user.id),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
),
)
.run();
this.service.db
.insert(s.accountTokens)
.values({
id: randomUUID(),
userId: user.id,
purpose,
tokenHash: hash(token),
value,
createdAt: now,
expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000,
})
.run();
});
const action =
purpose === 'verify'
? 'verify'
: purpose === 'reset'
? 'reset-password'
: 'verify-email-change';
await this.sendMail({
idempotencyKey: `auth-${purpose}-${hash(token)}`,
to: value ?? user.email!,
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
});
}
async register(
email: string,
password: string,
displayName: string,
ip: string,
setupToken?: string,
) {
const { db, cfg, limiter } = this.service;
limiter.hit(`register-ip:${ip}`, 5, 3600000);
let admin = false;
if (setupToken) {
if (
!cfg.setupToken ||
hash(setupToken) !== hash(cfg.setupToken) ||
db.select().from(s.users).limit(1).get()
)
fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.');
admin = true;
} else if (cfg.registrationMode !== 'open')
fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.');
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const normalized = normalizeEmail(email),
passwordHash = await this.password(password);
if (db.select().from(s.users).where(eq(s.users.email, normalized)).get())
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
const user = db.transaction(() => {
if (admin && db.select().from(s.users).limit(1).get())
fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.');
const id = randomUUID();
db.insert(s.users)
.values({
id,
email: normalized,
passwordHash,
displayName,
role: admin ? 'admin' : 'user',
status: 'pending',
createdAt: Date.now(),
})
.run();
return this.user(id);
});
await this.token(user, 'verify');
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
}
consume(
token: string,
purpose: 'verify' | 'reset' | 'email-change',
fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void,
) {
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.accountTokens)
.where(
and(
eq(s.accountTokens.tokenHash, hash(token)),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
gt(s.accountTokens.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
const user = this.user(row!.userId);
if (['suspended', 'deleted'].includes(user.status))
fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
fn(row!, user);
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.id, row!.id))
.run();
return { ok: true };
});
}
verify(token: string) {
return this.consume(token, 'verify', (_t, user) => {
this.service.db
.update(s.users)
.set({ status: 'active', verifiedAt: Date.now() })
.where(eq(s.users.id, user.id))
.run();
});
}
async login(email: string, password: string, ip: string, oldToken?: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`login-ip:${ip}`, 20);
this.service.limiter.hit(`login-email:${hash(normalized)}`, 10);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
dummy ??= argon2.hash(randomToken(), passwordOptions);
const valid = await argon2
.verify(user?.passwordHash ?? (await dummy), password)
.catch(() => false);
if (!valid || !user || !['pending', 'active'].includes(user.status))
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
if (oldToken)
this.service.db
.update(s.accountSessions)
.set({ revokedAt: now })
.where(eq(s.accountSessions.tokenHash, hash(oldToken)))
.run();
this.service.db
.insert(s.accountSessions)
.values({
id: randomUUID(),
userId: user!.id,
tokenHash: hash(token),
createdAt: now,
lastSeen: now,
expiresAt: now + 30 * 86400000,
})
.run();
});
return { token, account: this.serialize(user!), expiresAt: now + 30 * 86400000 };
}
logout(token: string | undefined) {
const current = this.session(token);
if (!current) return;
this.service.storage.abortSession(current.session.id);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.id, current.session.id))
.run();
this.service.storage.abortSession(current.session.id);
this.service.io?.to(`account-session:${current.session.id}`).emit('account:revoked', {});
this.service.io
?.in(`account-session:${current.session.id}`)
.socketsLeave(`account:${current.user.id}`);
}
revoke(userId: string) {
this.service.storage.abortUser(userId);
this.service.storage.abortUser(userId);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.userId, userId))
.run();
this.service.io?.to(`account:${userId}`).emit('account:revoked', {});
this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`);
}
enforceSessions() {
for (const socket of this.service.io?.sockets.sockets.values() ?? []) {
const id = socket.data.accountSessionId;
if (!id) continue;
const session = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(eq(s.accountSessions.id, id))
.get();
if (
!session ||
session.session.revokedAt ||
session.session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(session.user.status)
) {
socket.leave(`account:${socket.data.accountId}`);
socket.leave(`account-session:${id}`);
socket.data.accountId = undefined;
socket.data.accountSessionId = undefined;
socket.emit('account:revoked', {});
}
}
}
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
if (
user &&
((purpose === 'verify' && user.status === 'pending') ||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
)
await this.token(user, purpose);
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
}
async resetPassword(token: string, password: string) {
const passwordHash = await this.password(password);
let userId = '';
const result = this.consume(token, 'reset', (_t, user) => {
userId = user.id;
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.userId, user.id))
.run();
});
this.revoke(userId);
return result;
}
async changePassword(user: User, currentPassword: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const passwordHash = await this.password(password);
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.revoke(user.id);
}
async changeEmail(user: User, email: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const normalized = normalizeEmail(email);
if (
normalized === user.email ||
this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
await this.token(user, 'email-change', normalized);
}
async verifyEmailChange(token: string) {
let previous = '',
next = '';
const result = this.consume(token, 'email-change', (t, user) => {
if (
!t.value ||
this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
previous = user.email!;
next = t.value!;
this.service.db
.update(s.users)
.set({ email: t.value, verifiedAt: Date.now(), status: 'active' })
.where(eq(s.users.id, user.id))
.run();
});
await this.sendMail({
to: previous,
subject: 'hop. 이메일 주소 변경 안내',
text: `계정 이메일이 ${next}로 변경되었습니다.`,
});
return result;
}
}

106
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,106 @@
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
export type Config = {
production: boolean;
appUrl: string;
databasePath: string;
sessionSecret: string;
discoverySecret: string;
discoveryEnabled: boolean;
trustedProxies: string[];
port: number;
host: string;
guestDays: number;
requestSeconds: number;
uploadStartHours: number;
spaceMinutes: number;
spaceMax: number;
offlineGraceMs: number;
maxUrlBytes: number;
maxItems: number;
urlRetentionDays: number;
sessionRate: number;
ipRate: number;
pairRate: number;
rejectCooldownMs: number;
uploadDir: string;
maxFileBytes: number;
guestQuota: number;
totalGuestQuota: number;
minFreeBytes: number;
fileRetentionDays: number;
orphanHours: number;
undoSeconds: number;
registrationMode: 'open' | 'closed';
setupToken?: string;
mailFrom: string;
resendApiKey?: string;
userQuota: number;
emailRequestDays: number;
webRoot?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const production = env.NODE_ENV === 'production';
const num = (key: string, fallback: number, min = 1) => {
const n = Number(env[key] ?? fallback);
if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`);
return n;
};
const secret = (key: string) => {
const v = env[key];
if (production && (!v || v.length < 32 || v.startsWith('replace-')))
throw Error(`${key} must be a random secret (32+ characters)`);
return v && v.length >= 32 ? v : randomBytes(32).toString('base64url');
};
const port = num('PORT', 0, 0);
if (production && port === 0) throw Error('Set PORT explicitly before deployment');
const appUrl = env.APP_URL ?? 'http://127.0.0.1';
if (production && !appUrl.startsWith('https://'))
throw Error('Production APP_URL must use HTTPS');
return {
production,
appUrl,
databasePath:
env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)),
sessionSecret: secret('SESSION_SECRET'),
discoverySecret: secret('DISCOVERY_SECRET'),
discoveryEnabled:
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
? !production
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((v) => v.trim())
.filter(Boolean),
port,
host: env.HOST ?? '127.0.0.1',
guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30),
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576,
minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24),
undoSeconds: num('DELETE_UNDO_SECONDS', 5),
registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed',
setupToken: env.SETUP_TOKEN,
resendApiKey: env.RESEND_API_KEY?.trim() || undefined,
mailFrom: env.MAIL_FROM ?? 'hop. <hello@noname.company>',
userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576,
emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7),
webRoot: env.WEB_ROOT,
};
}

View File

@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { sql } from 'drizzle-orm';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from './schema.js';
export function openDatabase(path: string) {
if (path !== ':memory:') mkdirSync(dirname(resolve(path)), { recursive: true });
const sqlite = new Database(path);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
const db = drizzle(sqlite, { schema });
// SQLite table rebuilds require foreign_keys OFF outside the transaction. Check all references
// before committing, then restore enforcement before accepting any application request.
sqlite.pragma('foreign_keys = OFF');
try {
sqlite.exec(
'CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
const migrations = readMigrationFiles({
migrationsFolder: fileURLToPath(new URL('../../drizzle', import.meta.url)),
});
db.transaction((tx) => {
const latest = sqlite
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1')
.get() as { created_at: number } | undefined;
for (const migration of migrations) {
if (latest && migration.folderMillis <= latest.created_at) continue;
// A development server applied the first account schema before its deletion-column
// correction was generated. Preserve that ledger/data; migration 0005 applies the sole
// remaining structural difference. Accept only this exact observed legacy revision.
const legacy = sqlite
.prepare('SELECT hash FROM __drizzle_migrations WHERE created_at = ?')
.get(1791448938074) as { hash: string } | undefined;
const legacyAccountRevision =
migration === migrations[4] &&
legacy?.hash === 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068';
if (!legacyAccountRevision)
for (const statement of migration.sql) tx.run(sql.raw(statement));
sqlite
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
if ((sqlite.pragma('foreign_key_check') as unknown[]).length)
throw Error('Migration foreign-key check failed');
});
} catch (error) {
sqlite.close();
throw error;
} finally {
if (sqlite.open) sqlite.pragma('foreign_keys = ON');
}
return { db, sqlite };
}
export type DB = ReturnType<typeof openDatabase>['db'];

View File

@@ -0,0 +1,354 @@
import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text().primaryKey(),
email: text().unique(),
displayName: text().notNull(),
passwordHash: text(),
role: text({ enum: ['user', 'admin'] })
.notNull()
.default('user'),
status: text({ enum: ['pending', 'active', 'suspended', 'deleted'] }).notNull(),
verifiedAt: integer(),
createdAt: integer().notNull(),
receivePolicy: text({ enum: ['requests', 'allowed', 'none'] })
.notNull()
.default('requests'),
});
export const accountSessions = sqliteTable(
'account_sessions',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
tokenHash: text().notNull().unique(),
createdAt: integer().notNull(),
lastSeen: integer().notNull(),
expiresAt: integer().notNull(),
revokedAt: integer(),
},
(t) => [index('account_session_user').on(t.userId, t.expiresAt)],
);
export const accountTokens = sqliteTable(
'account_tokens',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
purpose: text({ enum: ['verify', 'reset', 'email-change'] }).notNull(),
tokenHash: text().notNull().unique(),
value: text(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
usedAt: integer(),
},
(t) => [index('account_token_expiry').on(t.expiresAt)],
);
export const rules = sqliteTable(
'user_rules',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
targetUserId: text()
.notNull()
.references(() => users.id),
action: text({ enum: ['allow', 'block'] }).notNull(),
},
(t) => [
uniqueIndex('user_rule_pair').on(t.userId, t.targetUserId),
check('rule_not_self', sql`${t.userId} <> ${t.targetUserId}`),
],
);
export const guests = sqliteTable(
'guest_sessions',
{
id: text().primaryKey(),
tokenHash: text().notNull().unique(),
expiresAt: integer().notNull(),
revokedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('guest_expiry').on(t.expiresAt)],
);
export const devices = sqliteTable(
'browser_devices',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.unique()
.references(() => guests.id),
name: text().notNull(),
code: text().notNull().unique(),
discoverable: integer({ mode: 'boolean' }).notNull().default(true),
lastSeen: integer().notNull(),
},
(t) => [check('device_name_length', sql`length(${t.name}) between 1 and 40`)],
);
export const spaces = sqliteTable(
'connection_spaces',
{
id: text().primaryKey(),
creatorGuestId: text()
.notNull()
.references(() => guests.id),
tokenHash: text().notNull().unique(),
codeHash: text().notNull().unique(),
approvalRequired: integer({ mode: 'boolean' }).notNull(),
expiresAt: integer().notNull(),
closedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('space_expiry').on(t.expiresAt)],
);
export const members = sqliteTable(
'space_members',
{
id: text().primaryKey(),
spaceId: text()
.notNull()
.references(() => spaces.id),
deviceId: text()
.notNull()
.references(() => devices.id),
status: text({ enum: ['pending', 'approved', 'removed'] }).notNull(),
joinedAt: integer().notNull(),
},
(t) => [
uniqueIndex('space_device').on(t.spaceId, t.deviceId),
uniqueIndex('member_single_active_device')
.on(t.deviceId)
.where(sql`${t.status} IN ('pending', 'approved')`),
],
);
export const blocks = sqliteTable(
'guest_blocks',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.references(() => guests.id),
targetGuestId: text()
.notNull()
.references(() => guests.id),
},
(t) => [uniqueIndex('guest_block_pair').on(t.guestId, t.targetGuestId)],
);
export const requests = sqliteTable(
'transfer_requests',
{
id: text().primaryKey(),
mode: text({ enum: ['local', 'email'] })
.notNull()
.default('local'),
senderGuestId: text().references(() => guests.id),
recipientGuestId: text().references(() => guests.id),
senderUserId: text().references(() => users.id),
recipientUserId: text().references(() => users.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
byteSize: integer().notNull(),
manifest: text(),
note: text(),
count: integer().notNull(),
status: text({ enum: ['pending', 'accepted', 'rejected', 'expired', 'cancelled'] }).notNull(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
acceptedAt: integer(),
rejectedAt: integer(),
startBy: integer(),
},
(t) => [
index('requests_sender_time').on(t.senderGuestId, t.createdAt),
index('requests_recipient_time').on(t.recipientGuestId, t.createdAt),
index('requests_expiry').on(t.status, t.expiresAt),
check(
'request_different_guests',
sql`${t.mode} <> 'local' OR ${t.senderGuestId} <> ${t.recipientGuestId}`,
),
check(
'request_sender_subject',
sql`(${t.senderGuestId} IS NOT NULL) + (${t.senderUserId} IS NOT NULL) = 1`,
),
check(
'request_recipient_subject',
sql`(${t.recipientGuestId} IS NOT NULL) + (${t.recipientUserId} IS NOT NULL) = 1`,
),
check(
'request_mode_subjects',
sql`(${t.mode} = 'local' AND ${t.senderGuestId} IS NOT NULL AND ${t.recipientGuestId} IS NOT NULL) OR (${t.mode} = 'email' AND ${t.senderUserId} IS NOT NULL AND ${t.recipientUserId} IS NOT NULL)`,
),
index('requests_sender_user').on(t.senderUserId, t.createdAt),
index('requests_recipient_user').on(t.recipientUserId, t.createdAt),
],
);
export const batches = sqliteTable('transfer_batches', {
id: text().primaryKey(),
requestId: text()
.notNull()
.unique()
.references(() => requests.id),
status: text({ enum: ['waiting', 'uploading', 'stored', 'failed'] }).notNull(),
storedAt: integer(),
});
export const files = sqliteTable(
'files',
{
id: text().primaryKey(),
requestId: text()
.notNull()
.references(() => requests.id),
ownerGuestId: text().references(() => guests.id),
ownerUserId: text().references(() => users.id),
position: integer().notNull(),
storageKey: text().notNull().unique(),
name: text().notNull(),
type: text().notNull(),
size: integer().notNull(),
checksum: text(),
status: text({ enum: ['pending', 'uploading', 'stored', 'failed', 'cleaning'] }).notNull(),
uploadId: text(),
uploadKey: text(),
updatedAt: integer().notNull(),
},
(t) => [
uniqueIndex('request_file_position').on(t.requestId, t.position),
index('files_owner_state').on(t.ownerGuestId, t.status),
check('file_nonnegative_size', sql`${t.size} >= 0`),
check(
'file_owner_subject',
sql`(${t.ownerGuestId} IS NOT NULL) + (${t.ownerUserId} IS NOT NULL) = 1`,
),
],
);
export const reservations = sqliteTable(
'quota_reservations',
{
id: text().primaryKey(),
fileId: text()
.notNull()
.unique()
.references(() => files.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
bytes: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
check('reservations_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
index('quota_guest_expiry').on(t.guestId, t.expiresAt),
check('reservation_nonnegative', sql`${t.bytes} >= 0`),
],
);
export const deletions = sqliteTable(
'deletion_operations',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
undoUntil: integer().notNull(),
createdAt: integer().notNull(),
restoredAt: integer(),
},
(t) => [
check('deletion_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const deletionTargets = sqliteTable('deletion_targets', {
id: text().primaryKey(),
operationId: text()
.notNull()
.references(() => deletions.id),
accessId: text().notNull(),
});
export const items = sqliteTable(
'items',
{
id: text().primaryKey(),
batchId: text()
.notNull()
.references(() => batches.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
text: text(),
url: text(),
fileId: text().references(() => files.id),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
index('items_expiry').on(t.expiresAt),
check(
'item_payload',
sql`(${t.kind} = 'text' AND ${t.text} IS NOT NULL AND ${t.url} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'url' AND ${t.url} IS NOT NULL AND ${t.text} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'file' AND ${t.fileId} IS NOT NULL AND ${t.text} IS NULL AND ${t.url} IS NULL)`,
),
],
);
export const access = sqliteTable(
'item_access',
{
id: text().primaryKey(),
itemId: text()
.notNull()
.references(() => items.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
role: text({ enum: ['sender', 'recipient', 'self'] }).notNull(),
read: integer({ mode: 'boolean' }).notNull().default(false),
deletedAt: integer(),
favorite: integer({ mode: 'boolean' }).notNull().default(false),
note: text(),
},
(t) => [
check('access_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
uniqueIndex('access_subject_role').on(t.itemId, t.guestId, t.role),
uniqueIndex('access_account_role').on(t.itemId, t.userId, t.role),
index('access_account').on(t.userId, t.deletedAt),
index('access_guest').on(t.guestId, t.deletedAt),
],
);
export const receiptAccounts = sqliteTable('request_receipt_accounts', {
requestId: text()
.primaryKey()
.references(() => requests.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const vaultTransfers = sqliteTable('vault_transfers', {
requestId: text()
.primaryKey()
.references(() => requests.id),
accessId: text()
.notNull()
.references(() => access.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const idempotency = sqliteTable(
'idempotency_records',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
operation: text().notNull(),
key: text().notNull(),
payloadHash: text().notNull(),
response: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [
uniqueIndex('idempotency_scope').on(t.guestId, t.operation, t.key),
uniqueIndex('account_idempotency_scope').on(t.userId, t.operation, t.key),
check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);

48
apps/server/src/mail.ts Normal file
View File

@@ -0,0 +1,48 @@
import { randomUUID } from 'node:crypto';
import type { Config } from './config.js';
import type { Mailer } from './auth.js';
import { ApiError } from './security.js';
/** The API key stays in the server process. Delivery receipts and provider error bodies
* never become public API responses or application logs. */
export function createResendMailer(
cfg: Pick<Config, 'resendApiKey' | 'mailFrom'>,
fetcher: typeof fetch = fetch,
): Mailer | undefined {
if (!cfg.resendApiKey) return undefined;
return async (mail) => {
try {
const response = await fetcher('https://api.resend.com/emails', {
method: 'POST',
redirect: 'error',
headers: {
Authorization: `Bearer ${cfg.resendApiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': mail.idempotencyKey ?? randomUUID(),
},
body: JSON.stringify({
from: cfg.mailFrom,
to: [mail.to],
subject: mail.subject,
text: mail.text,
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error('Delivery rejected');
const receipt: unknown = await response.json();
if (
!receipt ||
typeof receipt !== 'object' ||
!('id' in receipt) ||
typeof receipt.id !== 'string'
)
throw new Error('Invalid receipt');
} catch {
throw new ApiError(
503,
'MAIL_DELIVERY_FAILED',
'메일을 보내지 못했습니다. 잠시 후 다시 시도해 주세요.',
);
}
};
}

13
apps/server/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { loadConfig } from './config.js';
import { createApp } from './app.js';
const cfg = loadConfig();
const runtime = await createApp(cfg);
await runtime.app.listen({ port: cfg.port, host: cfg.host });
const address = runtime.app.server.address();
if (!address || typeof address === 'string') throw Error('No server address');
if (!cfg.production) runtime.setDevOrigin(`http://${cfg.host}:${address.port}`);
process.stdout.write(`hop. ready at http://${cfg.host}:${address.port}\n`);
for (const signal of ['SIGTERM', 'SIGINT'] as const)
process.on(signal, () => {
void runtime.app.close();
});

View File

@@ -0,0 +1,77 @@
import type { Server, Socket } from 'socket.io';
import type { Config } from './config.js';
import { hmac, publicIp } from './security.js';
export class Presence {
private connections = new Map<string, Map<string, string | null>>();
private graceIps = new Map<string, Set<string | null>>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private io: Server,
private cfg: Config,
private changed: () => void,
) {}
connect(guestId: string, socket: Socket, ip: string | null) {
const timer = this.timers.get(guestId);
if (timer) clearTimeout(timer);
this.timers.delete(guestId);
this.graceIps.delete(guestId);
const sockets = this.connections.get(guestId) ?? new Map();
sockets.set(socket.id, ip);
this.connections.set(guestId, sockets);
socket.join(`guest:${guestId}`);
this.changed();
socket.on('disconnect', () => this.disconnect(guestId, socket.id));
}
private disconnect(guestId: string, id: string) {
const sockets = this.connections.get(guestId);
const oldIp = sockets?.get(id) ?? null;
sockets?.delete(id);
if (sockets?.size) return;
this.graceIps.set(guestId, new Set([oldIp]));
const timer = setTimeout(() => {
this.connections.delete(guestId);
this.graceIps.delete(guestId);
this.timers.delete(guestId);
this.changed();
}, this.cfg.offlineGraceMs);
timer.unref();
this.timers.set(guestId, timer);
}
online(id: string) {
return this.connections.has(id);
}
active(id: string) {
return Boolean(this.connections.get(id)?.size);
}
sameNetwork(a: string, b: string) {
if (!this.cfg.discoveryEnabled) return false;
const groups = (id: string) =>
new Set(
[...(this.connections.get(id)?.values() ?? []), ...(this.graceIps.get(id) ?? [])]
.filter(
(ip) => publicIp(ip) || (!this.cfg.production && (ip === '127.0.0.1' || ip === '::1')),
)
.map((ip) => hmac(this.cfg.discoverySecret, ip!)),
);
const ag = groups(a);
return [...groups(b)].some((v) => ag.has(v));
}
emit(id: string, event: string, payload: unknown) {
this.io.to(`guest:${id}`).emit(event, payload);
}
ids() {
return [...this.connections.keys()].filter((id) => this.online(id));
}
revoke(id: string) {
this.io.in(`guest:${id}`).disconnectSockets(true);
this.connections.delete(id);
this.graceIps.delete(id);
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.timers.delete(id);
this.changed();
}
close() {
for (const t of this.timers.values()) clearTimeout(t);
}
}

View File

@@ -0,0 +1,84 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import ipaddr from 'ipaddr.js';
import type { IncomingHttpHeaders } from 'node:http';
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export const fail = (status: number, code: string, message: string): never => {
throw new ApiError(status, code, message);
};
export const randomToken = () => randomBytes(32).toString('base64url');
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
export const hmac = (secret: string, value: string) =>
createHmac('sha256', secret).update(value).digest('hex');
export function normalizeIp(value: string): string | null {
try {
return ipaddr.process(value).toString();
} catch {
return null;
}
}
export function clientIp(
remote: string | undefined,
headers: IncomingHttpHeaders,
trusted: string[],
): string | null {
const first = remote ? normalizeIp(remote) : null;
if (!first) return null;
const trusts = (v: string) =>
trusted.some((rule) => {
try {
const a = ipaddr.process(v);
const cidr = rule.includes('/')
? ipaddr.parseCIDR(rule)
: ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [
ipaddr.IPv4 | ipaddr.IPv6,
number,
]);
return a.kind() === cidr[0].kind() && a.match(cidr);
} catch {
return false;
}
});
if (!trusts(first)) return first;
const raw = headers['x-forwarded-for'];
if (typeof raw !== 'string') return null;
const chain = raw.split(',').map((v) => normalizeIp(v.trim()));
if (chain.some((v) => !v)) return null;
let current = first;
for (const next of chain.reverse()) {
if (!trusts(current)) break;
current = next!;
}
return current;
}
export function publicIp(ip: string | null): boolean {
if (!ip) return false;
try {
return ipaddr.process(ip).range() === 'unicast';
} catch {
return false;
}
}
export class Limiter {
private windows = new Map<string, { count: number; until: number }>();
hit(key: string, max: number, windowMs = 60000) {
const now = Date.now();
let w = this.windows.get(key);
if (!w || w.until <= now) {
w = { count: 0, until: now + windowMs };
this.windows.set(key, w);
}
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
}
prune() {
const now = Date.now();
for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key);
}
}

1047
apps/server/src/service.ts Normal file

File diff suppressed because it is too large Load Diff

596
apps/server/src/storage.ts Normal file
View File

@@ -0,0 +1,596 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, statfsSync, existsSync } from 'node:fs';
import { createWriteStream, createReadStream } from 'node:fs';
import { rename, unlink, readdir, stat, open } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm';
import sharp from 'sharp';
import type { FastifyRequest } from 'fastify';
import type { Guest, Service } from './service.js';
import * as s from './db/schema.js';
import { ApiError, fail } from './security.js';
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
export class Storage {
private active = new Map<
string,
{ guestId: string; controller: AbortController; fileId: string; sessionId?: string }
>();
private cleaning = false;
private cleanupPromise: Promise<void> | undefined;
readonly temp: string;
readonly originals: string;
constructor(readonly service: Service) {
this.temp = resolve(service.cfg.uploadDir, 'temporary');
this.originals = resolve(service.cfg.uploadDir, 'originals');
mkdirSync(this.temp, { recursive: true, mode: 0o700 });
mkdirSync(this.originals, { recursive: true, mode: 0o700 });
}
private request(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.request(g.id, id)
: this.service.request(g as Guest, id);
}
private access(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.access(g.id, id)
: this.service.accessDetail(g as Guest, id);
}
private blocked(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user'
? !this.service.accounts.canTransfer(row)
: this.service.isBlocked(g.id, row.recipientGuestId!);
}
private isSender(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user' ? row.senderUserId === g.id : row.senderGuestId === g.id;
}
fileRows(id: string) {
return this.service.db.select().from(s.files).where(eq(s.files.requestId, id)).all();
}
createManifest(
g: StorageSubject,
requestId: string,
manifest: { name: string; size: number; type: string }[],
) {
for (const [position, file] of manifest.entries())
this.service.db
.insert(s.files)
.values({
id: randomUUID(),
requestId,
ownerGuestId: g.kind === 'user' ? null : g.id,
ownerUserId: g.kind === 'user' ? g.id : null,
position,
name: file.name,
size: file.size,
type: file.type,
storageKey: randomUUID(),
status: 'pending',
updatedAt: Date.now(),
})
.run();
}
private used(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
.from(s.files)
.where(
and(
inArray(s.files.status, ['stored', 'cleaning']),
sql`${s.files.ownerGuestId} IS NOT NULL`,
guestId ? eq(s.files.ownerGuestId, guestId) : undefined,
),
)
.get()!.bytes;
}
private reserved(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(
and(
gt(s.reservations.expiresAt, Date.now()),
sql`${s.reservations.guestId} IS NOT NULL`,
guestId ? eq(s.reservations.guestId, guestId) : undefined,
),
)
.get()!.bytes;
}
reserve(g: StorageSubject, requestId: string) {
const { db, cfg } = this.service;
const files = this.fileRows(requestId).filter((f) => f.status !== 'stored');
const missing = files.filter(
(file) =>
!db
.select()
.from(s.reservations)
.where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now())))
.get(),
);
const total = missing.reduce((sum, file) => sum + file.size, 0);
if (g.kind === 'user') {
const reserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(and(eq(s.reservations.userId, g.id), gt(s.reservations.expiresAt, Date.now())))
.get()!.bytes;
this.service.accounts.ensureQuota(g.id, reserved + total);
}
if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota)
fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.');
if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota)
fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.');
const allReserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now()))
.get()!.bytes;
const disk = statfsSync(this.originals, { bigint: true });
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
for (const file of missing)
db.insert(s.reservations)
.values({
id: randomUUID(),
fileId: file.id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
bytes: file.size,
expiresAt: Date.now() + cfg.orphanHours * 3600000,
})
.onConflictDoUpdate({
target: s.reservations.fileId,
set: { expiresAt: Date.now() + cfg.orphanHours * 3600000 },
})
.run();
}
release(requestId: string) {
const db = this.service.db;
for (const file of this.fileRows(requestId)) {
for (const [uploadId, upload] of this.active)
if (upload.fileId === file.id) {
upload.controller.abort();
this.active.delete(uploadId);
}
db.delete(s.reservations).where(eq(s.reservations.fileId, file.id)).run();
if (file.status !== 'stored')
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
}
}
async upload(
g: StorageSubject,
requestId: string,
position: number,
request: FastifyRequest,
key: string,
) {
const { db, cfg } = this.service;
let file: typeof s.files.$inferSelect;
let uploadId: string;
const existing = this.fileRows(requestId).find((f) => f.position === position);
if (existing?.status === 'stored') {
const row = this.request(g, requestId);
if (!this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.');
if (existing.uploadKey !== key) fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
const checksum = createHash('sha256');
let bytes = 0,
count = 0;
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
count++;
if (part.filename !== existing.name)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
for await (const chunk of part.file) {
bytes += chunk.length;
if (bytes > existing.size)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
checksum.update(chunk);
}
}
if (count !== 1 || bytes !== existing.size || checksum.digest('hex') !== existing.checksum)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
return { uploadId: existing.uploadId!, fileId: existing.id, status: 'stored' };
}
db.transaction(() => {
const row = this.request(g, requestId);
if (row.kind !== 'file' || !this.isSender(g, row))
fail(403, 'UPLOAD_FORBIDDEN', '이 요청에 파일을 올릴 수 없습니다.');
if (
row.status !== 'accepted' ||
!row.startBy ||
row.startBy <= Date.now() ||
this.blocked(g, row)
)
fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.');
file = this.fileRows(requestId).find((f) => f.position === position)!;
if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
if (
file.status === 'uploading' ||
[...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`)
.length >= (g.kind === 'user' ? 2 : 1)
)
fail(409, 'UPLOAD_ACTIVE', '진행 중인 업로드를 먼저 끝내 주세요.');
this.reserve(g, requestId);
uploadId = randomUUID();
db.update(s.files)
.set({ status: 'uploading', uploadId, uploadKey: key, updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
db.update(s.batches)
.set({ status: 'uploading' })
.where(eq(s.batches.requestId, requestId))
.run();
});
const controller = new AbortController(),
tempPath = resolve(this.temp, uploadId!);
this.active.set(uploadId!, {
guestId: `${g.kind ?? 'guest'}:${g.id}`,
controller,
sessionId: g.sessionId,
fileId: file!.id,
});
request.raw.once('aborted', () => controller.abort());
this.service.notifyTransfer(requestId);
try {
let parts = 0;
let bytes = 0;
const checksum = createHash('sha256');
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.');
parts++;
if (part.filename !== file!.name)
fail(409, 'METADATA_MISMATCH', '요청한 파일 이름과 다릅니다.');
const counter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > file!.size || bytes > cfg.maxFileBytes)
return callback(
new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'),
);
checksum.update(chunk);
callback(null, chunk);
},
});
await pipeline(
part.file,
counter,
createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }),
{ signal: controller.signal },
);
if (part.file.truncated || bytes !== file!.size)
fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.');
}
if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
const originalPath = resolve(this.originals, file!.storageKey);
await rename(tempPath, originalPath);
try {
db.transaction(() => {
if (g.kind === 'user') {
const session = g.sessionId
? db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, g.sessionId))
.get()
: null;
if (
!session ||
session.userId !== g.id ||
session.revokedAt ||
session.expiresAt <= Date.now()
)
fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.');
}
const row = this.request(g, requestId),
current = db.select().from(s.files).where(eq(s.files.id, file!.id)).get();
if (
row.status !== 'accepted' ||
this.blocked(g, row) ||
current?.uploadId !== uploadId ||
current.status !== 'uploading'
)
fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었거나 권한이 변경되었습니다.');
const batch = db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, requestId))
.get()!,
now = Date.now(),
itemId = randomUUID();
db.update(s.files)
.set({ status: 'stored', checksum: checksum.digest('hex'), updatedAt: now })
.where(eq(s.files.id, file!.id))
.run();
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: 'file',
fileId: file!.id,
createdAt: now,
expiresAt: now + cfg.fileRetentionDays * 86400000,
})
.run();
this.service.accounts.createAccess(row, itemId);
const all = this.fileRows(requestId);
db.update(s.batches)
.set({
status: all.every((f) => f.status === 'stored') ? 'stored' : 'waiting',
storedAt: now,
})
.where(eq(s.batches.id, batch.id))
.run();
});
} catch (error) {
await unlink(originalPath).catch(() => {});
throw error;
}
return { uploadId: uploadId!, fileId: file!.id, status: 'stored' };
} catch (error) {
await unlink(tempPath).catch(() => {});
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(and(eq(s.files.id, file!.id), eq(s.files.uploadId, uploadId!)))
.run();
db.update(s.batches)
.set({ status: 'waiting' })
.where(and(eq(s.batches.requestId, requestId), eq(s.batches.status, 'uploading')))
.run();
if (error instanceof ApiError) throw error;
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOSPC')
fail(507, 'DISK_FULL', '서버 저장 공간이 부족합니다.');
fail(400, 'UPLOAD_FAILED', '업로드가 중단되었습니다. 파일을 다시 선택해 재시도해 주세요.');
} finally {
this.active.delete(uploadId!);
this.service.notifyTransfer(requestId);
}
}
cancel(g: StorageSubject, id: string) {
const file = this.service.db
.select()
.from(s.files)
.where(
and(
eq(s.files.uploadId, id),
g.kind === 'user' ? eq(s.files.ownerUserId, g.id) : eq(s.files.ownerGuestId, g.id),
),
)
.get();
if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.');
if (file!.status !== 'uploading')
fail(409, 'UPLOAD_NOT_ACTIVE', '진행 중인 업로드만 취소할 수 있습니다.');
this.active.get(id)?.controller.abort();
this.service.db
.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file!.id))
.run();
}
download(g: StorageSubject, accessId: string) {
const row = this.access(g, accessId);
if (row.item.kind !== 'file' || !row.item.fileId)
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
const file = this.service.db
.select()
.from(s.files)
.where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored')))
.get();
if (!file || !existsSync(resolve(this.originals, file.storageKey)))
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
return { file: file!, path: resolve(this.originals, file!.storageKey) };
}
async preview(g: StorageSubject, id: string) {
const { path } = this.download(g, id);
try {
const handle = await open(path, 'r');
const magic = Buffer.alloc(16);
try {
await handle.read(magic, 0, 16, 0);
} finally {
await handle.close();
}
const raster =
magic.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
magic.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
/^GIF8[79]a/.test(magic.toString('ascii')) ||
(magic.toString('ascii', 0, 4) === 'RIFF' && magic.toString('ascii', 8, 12) === 'WEBP') ||
(magic.toString('ascii', 4, 8) === 'ftyp' &&
['avif', 'avis', 'heic', 'heix', 'mif1'].includes(magic.toString('ascii', 8, 12)));
if (!raster) throw Error();
const image = sharp(path, { limitInputPixels: 40000000, animated: false });
const metadata = await image.metadata();
if (!['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'].includes(metadata.format ?? ''))
throw Error();
return await image
.resize(720, 720, { fit: 'inside', withoutEnlargement: true })
.rotate()
.webp({ quality: 78 })
.toBuffer();
} catch {
fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.');
}
}
deleteAccess(g: StorageSubject, ids: string[]) {
const { db, cfg } = this.service;
return db.transaction(() => {
const unique = [...new Set(ids)],
rows = unique.map((id) => this.access(g, id));
const id = randomUUID(),
now = Date.now();
db.insert(s.deletions)
.values({
id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
createdAt: now,
undoUntil: now + cfg.undoSeconds * 1000,
})
.run();
for (const row of rows) {
db.update(s.access).set({ deletedAt: now }).where(eq(s.access.id, row.access.id)).run();
db.insert(s.deletionTargets)
.values({ id: randomUUID(), operationId: id, accessId: row.access.id })
.run();
}
return { operationId: id, undoUntil: now + cfg.undoSeconds * 1000 };
});
}
undo(g: StorageSubject, id: string) {
const { db } = this.service;
return db.transaction(() => {
const operation = db
.select()
.from(s.deletions)
.where(
and(
eq(s.deletions.id, id),
g.kind === 'user' ? eq(s.deletions.userId, g.id) : eq(s.deletions.guestId, g.id),
),
)
.get();
if (!operation) fail(404, 'DELETE_NOT_FOUND', '삭제 작업을 찾을 수 없습니다.');
if (operation!.restoredAt) return { restored: true };
if (operation!.undoUntil <= Date.now())
fail(409, 'UNDO_EXPIRED', '실행 취소 시간이 지났습니다.');
const targets = db
.select()
.from(s.deletionTargets)
.where(eq(s.deletionTargets.operationId, id))
.all();
let count = 0;
for (const target of targets) {
const row = db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, target.accessId),
g.kind === 'user' ? eq(s.access.userId, g.id) : eq(s.access.guestId, g.id),
eq(s.access.deletedAt, operation!.createdAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (row) {
db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run();
count++;
}
}
db.update(s.deletions).set({ restoredAt: Date.now() }).where(eq(s.deletions.id, id)).run();
return { restored: count > 0 };
});
}
cleanup(): Promise<void> {
if (this.cleanupPromise) return this.cleanupPromise;
this.cleanupPromise = this.performCleanup().finally(() => {
this.cleanupPromise = undefined;
});
return this.cleanupPromise;
}
private async performCleanup() {
if (this.cleaning) return;
this.cleaning = true;
try {
const { db, cfg } = this.service,
now = Date.now();
const expired = db
.select()
.from(s.reservations)
.where(sql`${s.reservations.expiresAt} <= ${now}`)
.all();
for (const r of expired) {
const file = db.select().from(s.files).where(eq(s.files.id, r.fileId)).get();
if (file) this.release(file.requestId);
}
for (const file of db.select().from(s.files).where(eq(s.files.status, 'uploading')).all()) {
if (!file.uploadId || !this.active.has(file.uploadId)) {
db.update(s.files)
.set({ status: 'failed', updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
}
const all = db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all();
for (const file of all) {
const refs = db
.select({ id: s.items.id })
.from(s.items)
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.items.fileId, file.id),
gt(s.items.expiresAt, now),
orLive(now, cfg.undoSeconds),
),
)
.all();
if (refs.length) continue;
db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run();
try {
await unlink(resolve(this.originals, file.storageKey));
} catch (error) {
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
continue;
}
db.update(s.files)
.set({ status: 'failed', checksum: null, updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
const validKeys = new Set(
db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all()
.map((f) => f.storageKey),
);
for (const name of await readdir(this.originals)) {
if (validKeys.has(name)) continue;
const path = resolve(this.originals, name),
metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
for (const name of await readdir(this.temp)) {
if (this.active.has(name)) continue;
const path = resolve(this.temp, name);
const metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
} finally {
this.cleaning = false;
}
}
abortSession(id: string) {
for (const upload of this.active.values())
if (upload.sessionId === id) upload.controller.abort();
}
abortUser(id: string) {
for (const upload of this.active.values())
if (upload.guestId === `user:${id}`) upload.controller.abort();
}
close() {
for (const upload of this.active.values()) upload.controller.abort();
}
}
function orLive(now: number, undoSeconds: number) {
return sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${now - undoSeconds * 1000})`;
}

141
apps/server/src/vault.js Normal file
View File

@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import * as s from './db/schema.js';
import { fail } from './security.js';
export class Vault {
constructor(service) {
this.service = service;
}
bindReceipt(requestId, current) {
if (!current || !this.service.auth.serialize(current.user).verified) return;
this.service.db
.insert(s.receiptAccounts)
.values({ requestId, userId: current.user.id, sessionId: current.session.id })
.onConflictDoNothing()
.run();
}
receipt(request, itemId) {
const { db, auth } = this.service;
const binding = db
.select()
.from(s.receiptAccounts)
.where(eq(s.receiptAccounts.requestId, request.id))
.get();
if (!binding) return;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, binding.sessionId))
.get();
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
session.userId !== binding.userId
)
return;
if (!auth.serialize(auth.user(binding.userId)).verified) return;
db.insert(s.access)
.values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false })
.onConflictDoNothing()
.run();
}
source(requestId) {
return this.service.db
.select()
.from(s.vaultTransfers)
.where(eq(s.vaultTransfers.requestId, requestId))
.get();
}
authorize(source) {
const { db, accounts } = this.service;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, source.sessionId))
.get();
if (
!session ||
session.userId !== source.userId ||
session.revokedAt ||
session.expiresAt <= Date.now()
)
fail(401, 'SESSION_REVOKED', '보낸 계정의 세션이 종료되었습니다.');
const row = accounts.access(source.userId, source.accessId);
if (!['self', 'recipient'].includes(row.access.role))
fail(403, 'VAULT_REQUIRED', '보관함에 있는 자료만 보낼 수 있습니다.');
if (row.item.fileId) {
const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get();
if (!file || file.status !== 'stored')
fail(404, 'FILE_NOT_FOUND', '파일이 없거나 만료되었습니다.');
return { ...row, file };
}
return row;
}
forward(guest, current, accessId, targetDeviceId, ip) {
const source = { userId: current.user.id, sessionId: current.session.id, accessId };
const row = this.authorize(source);
const input = row.item.url
? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 }
: {
targetDeviceId,
kind: 'file',
byteSize: row.file.size,
count: 1,
files: [
{ id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type },
],
};
const result = this.service.createRequest(guest, input, ip, true);
this.service.db
.insert(s.vaultTransfers)
.values({ ...source, requestId: result.requestId })
.run();
return result;
}
deliver(requestId) {
const source = this.source(requestId);
if (!source) return;
const { db, accounts } = this.service;
const row = this.authorize(source);
const request = db.select().from(s.requests).where(eq(s.requests.id, requestId)).get();
const batch = db.select().from(s.batches).where(eq(s.batches.requestId, requestId)).get();
if (batch.status === 'stored') return;
if (
request.status !== 'accepted' ||
request.startBy <= Date.now() ||
this.service.isBlocked(request.senderGuestId, request.recipientGuestId)
)
fail(409, 'REQUEST_STATE', '유효한 수락이 필요합니다.');
const now = Date.now(),
itemId = randomUUID();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.item.kind,
url: row.item.url,
fileId: row.item.fileId,
createdAt: now,
expiresAt: now + 7 * 86400000,
})
.run();
accounts.createAccess(request, itemId);
db.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
}
note(userId, accessId, note) {
const row = this.service.accounts.access(userId, accessId);
if (row.access.role !== 'self')
fail(403, 'VAULT_REQUIRED', '임시 보관함 자료만 수정할 수 있습니다.');
this.service.db
.update(s.access)
.set({ note: note || null })
.where(and(eq(s.access.id, accessId), eq(s.access.userId, userId)))
.run();
this.service.accounts.notify(userId);
}
}