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

687 lines
24 KiB
TypeScript

import { inlinePayload } from './clipboardPayload.js';
import type { CompleteInput } from '@hop/shared';
import { randomUUID } from 'node:crypto';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
import { 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', 'text', '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' | 'text' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
},
) {
const { db, cfg, limiter } = this.service;
this.verified(userId);
if (input.kind === 'file') this.service.admin.assertNewUploads();
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 === 'url' && input.byteSize > cfg.maxUrlBytes) ||
(input.kind === 'text' && input.byteSize > cfg.maxTextBytes) ||
input.files?.some((f) => f.size > this.service.admin.state.perFileBytes)
)
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.admin.assertNewUploads();
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: CompleteInput) {
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', '이미 처리된 전송입니다.');
const { values, bytes } = inlinePayload(
row.kind,
input,
this.service.cfg.maxTextBytes,
this.service.cfg.maxUrlBytes,
);
if (bytes !== 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,
...(row.kind === 'text' ? { text: value } : { 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', 'text', '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', 'text', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
if (row!.item.fileId) this.service.storage.availability.require(row!.item.fileId);
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);
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
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', 'text', '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 instr(lower(coalesce(${s.items.text},'')),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,
...(r.item.kind === 'text' ? { text: r.item.text } : {}),
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,
};
}
vaultFiles(userId: string) {
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
const rows = this.service.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),
inArray(s.access.role, ['self', 'recipient']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
eq(s.files.status, 'stored'),
),
)
.all();
return new Map(rows.map(({ file }) => [file.id, file.size]));
}
used(userId: string) {
return [...this.vaultFiles(userId).values()].reduce((sum, bytes) => sum + bytes, 0);
}
canStoreFile(userId: string, file: typeof s.files.$inferSelect) {
const files = this.vaultFiles(userId);
if (files.has(file.id)) return true;
const reserved = this.service.db
.select()
.from(s.reservations)
.where(and(eq(s.reservations.userId, userId), gt(s.reservations.expiresAt, Date.now())))
.all()
.reduce((sum, row) => sum + row.bytes, 0);
return (
[...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <=
this.service.admin.state.accountBytes
);
}
ensureQuota(userId: string, bytes: number) {
if (this.used(userId) + bytes > this.service.admin.state.accountBytes)
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);
}
}