import type { Server } from 'socket.io'; import type { Auth } from './auth.js'; import type { Accounts } from './accounts.js'; import { Storage } from './storage.js'; import { ResumableUploads } from './resumable.js'; import { NearbyLocations } from './nearby.js'; import { Vault } from './vault.js'; import { randomUUID } from 'node:crypto'; import { newInviteCode } from './inviteCode.js'; import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; import type { DB } from './db/index.js'; import * as s from './db/schema.js'; import type { Config } from './config.js'; import type { Presence } from './presence.js'; import { fail, hash, hmac, randomToken, Limiter } from './security.js'; import { parseUrls, type Device, type Item, type Peer, type Space, type TransferRequest, } from '@hop/shared'; export type Guest = typeof s.guests.$inferSelect; export type RequestRow = typeof s.requests.$inferSelect; const day = 86400000; export class Service { readonly limiter = new Limiter(); presence!: Presence; nearby: NearbyLocations; readonly locationAcceptances = new Map(); auth!: Auth; accounts!: Accounts; io?: Server; storage: Storage; resumable: ResumableUploads; vault: Vault; constructor( readonly db: DB, readonly cfg: Config, ) { this.storage = new Storage(this); this.resumable = new ResumableUploads(this); this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) => this.refresh(ids), ); this.vault = new Vault(this); } guest(token: string | undefined) { if (!token) return undefined; return this.db .select() .from(s.guests) .where( and( eq(s.guests.tokenHash, hash(token)), isNull(s.guests.revokedAt), gt(s.guests.expiresAt, Date.now()), ), ) .get(); } device(guestId: string): Device { const d = this.db.select().from(s.devices).where(eq(s.devices.guestId, guestId)).get(); if (!d) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다.'); return { id: d!.id, name: d!.name, code: d!.code, discoverable: d!.discoverable, info: this.presence?.info(guestId), }; } deviceGuest(id: string) { return this.db.select().from(s.devices).where(eq(s.devices.id, id)).get(); } createGuest(platform: string) { const now = Date.now(), token = randomToken(), id = randomUUID(); const name = /android/i.test(platform) ? 'Android' : /ipad/i.test(platform) ? 'iPad' : /iphone/i.test(platform) ? 'iPhone' : /mac/i.test(platform) ? 'Mac' : /windows/i.test(platform) ? 'Windows PC' : /linux/i.test(platform) ? 'Linux PC' : '나의 기기'; this.db.transaction(() => { this.db .insert(s.guests) .values({ id, tokenHash: hash(token), expiresAt: now + this.cfg.guestDays * day, createdAt: now, }) .run(); this.db .insert(s.devices) .values({ id: randomUUID(), guestId: id, name, code: randomToken().slice(0, 6).toUpperCase(), discoverable: true, lastSeen: now, }) .run(); }); return { token, guest: this.guest(token)! }; } csrf(token: string) { return hmac(this.cfg.sessionSecret, `csrf:${token}`); } patchDevice(g: Guest, input: { name?: string; discoverable?: boolean }) { this.db .update(s.devices) .set({ ...input, lastSeen: Date.now() }) .where(eq(s.devices.guestId, g.id)) .run(); if (input.discoverable === false) this.nearby.clearGuest(g.id); this.refresh(); } reset(g: Guest) { for (const row of this.db .select() .from(s.requests) .where( and( inArray(s.requests.kind, ['url', 'file']), or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), ), ) .all()) this.storage.release(row.id); this.db.transaction(() => { this.db.update(s.guests).set({ revokedAt: Date.now() }).where(eq(s.guests.id, g.id)).run(); this.db .update(s.access) .set({ deletedAt: Date.now() }) .where(eq(s.access.guestId, g.id)) .run(); this.db .update(s.requests) .set({ status: 'cancelled' }) .where( and( or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), inArray(s.requests.status, ['pending', 'accepted']), ), ) .run(); this.db .update(s.spaces) .set({ closedAt: Date.now() }) .where(eq(s.spaces.creatorGuestId, g.id)) .run(); }); this.presence.revoke(g.id); this.notifyRequests(); } isBlocked(a: string, b: string) { return Boolean( this.db .select() .from(s.blocks) .where( or( and(eq(s.blocks.guestId, a), eq(s.blocks.targetGuestId, b)), and(eq(s.blocks.guestId, b), eq(s.blocks.targetGuestId, a)), ), ) .get(), ); } sharedSpace(a: string, b: string) { const da = this.device(a).id, db = this.device(b).id; const mine = this.db .select({ spaceId: s.members.spaceId }) .from(s.members) .innerJoin(s.spaces, eq(s.members.spaceId, s.spaces.id)) .where( and( eq(s.members.deviceId, da), eq(s.members.status, 'approved'), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()), ), ) .all(); return mine.some((row) => Boolean( this.db .select() .from(s.members) .where( and( eq(s.members.spaceId, row.spaceId), eq(s.members.deviceId, db), eq(s.members.status, 'approved'), ), ) .get(), ), ); } activeRoom(guestId: string) { return this.db .select({ id: s.spaces.id }) .from(s.spaces) .innerJoin(s.members, eq(s.members.spaceId, s.spaces.id)) .where( and( eq(s.members.deviceId, this.device(guestId).id), inArray(s.members.status, ['pending', 'approved']), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()), ), ) .get(); } scope(a: string, b: string) { if (this.isBlocked(a, b)) return false; if (this.activeRoom(a) || this.activeRoom(b)) return this.sharedSpace(a, b); return ( this.presence.sameNetwork(a, b) || (this.cfg.locationDiscoveryEnabled && this.nearby.nearby(a, b)) ); } restrictTransfers(leaving: string[] = []) { const active = this.db .select({ request: s.requests, batch: s.batches }) .from(s.requests) .innerJoin(s.batches, eq(s.batches.requestId, s.requests.id)) .where( and( eq(s.requests.mode, 'local'), inArray(s.requests.status, ['pending', 'accepted']), inArray(s.batches.status, ['waiting', 'uploading']), ), ) .all(); for (const { request, batch } of active) { if ( !leaving.includes(request.senderGuestId!) && !leaving.includes(request.recipientGuestId!) && this.scope(request.senderGuestId!, request.recipientGuestId!) ) continue; this.storage.release(request.id); this.db .update(s.requests) .set({ status: 'cancelled' }) .where(eq(s.requests.id, request.id)) .run(); this.db.update(s.batches).set({ status: 'failed' }).where(eq(s.batches.id, batch.id)).run(); this.notifyTransfer(request.id); } } canReceive(a: string, b: string, requireActive = true) { const guest = this.db .select() .from(s.guests) .where( and(eq(s.guests.id, b), isNull(s.guests.revokedAt), gt(s.guests.expiresAt, Date.now())), ) .get(); return Boolean( guest && (requireActive ? this.presence.active(b) : this.presence.online(b)) && this.device(b).discoverable && this.scope(a, b), ); } peers(g: Guest): Peer[] { if (!this.device(g.id).discoverable) return []; return this.presence .ids() .filter((id) => id !== g.id && this.canReceive(g.id, id, false)) .map((id) => ({ ...this.device(id), sources: [ ...(!this.activeRoom(g.id) && this.presence.sameNetwork(g.id, id) ? ['network' as const] : []), ...(this.sharedSpace(g.id, id) ? ['space' as const] : []), ...(!this.activeRoom(g.id) && this.cfg.locationDiscoveryEnabled && this.nearby.nearby(g.id, id) ? ['nearby' as const] : []), ], })); } refresh(subset?: string[]) { if (!this.presence) return; for (const id of subset ?? this.presence.ids()) { if (!this.presence.online(id)) continue; const g = this.db.select().from(s.guests).where(eq(s.guests.id, id)).get(); if (!g || g.revokedAt || g.expiresAt <= Date.now()) { this.presence.revoke(id); continue; } this.presence.emit(id, 'device:updated', { devices: this.peers(g) }); this.presence.emit(id, 'space:updated', {}); } } space(g: Guest, id: string): Space { const row = this.db .select() .from(s.spaces) .where( and(eq(s.spaces.id, id), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now())), ) .get(); if (!row) fail(404, 'SPACE_NOT_FOUND', '연결 공간이 없거나 만료되었습니다.'); const myDevice = this.device(g.id); const member = this.db .select() .from(s.members) .where(and(eq(s.members.spaceId, id), eq(s.members.deviceId, myDevice.id))) .get(); if (row!.creatorGuestId !== g.id && (!member || member.status === 'removed')) fail(404, 'SPACE_NOT_FOUND', '연결 공간을 확인할 수 없습니다.'); const all = this.db .select({ device: s.devices, status: s.members.status }) .from(s.members) .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) .where(and(eq(s.members.spaceId, id), inArray(s.members.status, ['pending', 'approved']))) .all(); return { id, creatorDeviceId: this.device(row!.creatorGuestId).id, approvalRequired: row!.approvalRequired, expiresAt: row!.expiresAt, members: all .filter( (m) => row!.creatorGuestId === g.id || (member?.status === 'approved' && m.status === 'approved') || m.device.id === myDevice.id, ) .map((m) => ({ device: { id: m.device.id, name: m.device.name, code: m.device.code, discoverable: m.device.discoverable, }, status: m.status as 'pending' | 'approved', })), }; } listSpaces(g: Guest) { const mine = this.db .select({ id: s.spaces.id }) .from(s.spaces) .innerJoin(s.members, eq(s.members.spaceId, s.spaces.id)) .where( and( eq(s.members.deviceId, this.device(g.id).id), inArray(s.members.status, ['approved', 'pending']), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()), ), ) .all(); return mine.map((row) => this.space(g, row.id)); } createSpace(g: Guest, approvalRequired: boolean) { this.limiter.hit(`space-create:${g.id}`, 5); this.cleanup(); if (this.activeRoom(g.id)) fail(409, 'SPACE_LIMIT', '현재 방에서 먼저 나가 주세요.'); const now = Date.now(), id = randomUUID(), token = randomToken(); // The unique DB constraint includes historical spaces; never reuse their codes. const code = newInviteCode(this.cfg.spaceInviteCodeLength, (candidate: string) => Boolean( this.db .select({ id: s.spaces.id }) .from(s.spaces) .where(eq(s.spaces.codeHash, hmac(this.cfg.discoverySecret, candidate))) .get(), ), ) ?? fail(503, 'SPACE_CODE_BUSY', '초대 코드를 만들지 못했습니다. 잠시 후 다시 시도해 주세요.'); this.db.transaction(() => { this.db .insert(s.spaces) .values({ id, creatorGuestId: g.id, tokenHash: hash(token), codeHash: hmac(this.cfg.discoverySecret, code), approvalRequired, expiresAt: now + this.cfg.spaceMinutes * 60000, createdAt: now, }) .run(); this.db .insert(s.members) .values({ id: randomUUID(), spaceId: id, deviceId: this.device(g.id).id, status: 'approved', joinedAt: now, }) .run(); }); this.nearby.clearGuest(g.id); this.restrictTransfers(); this.refresh(); return { space: this.space(g, id), code, token }; } joinSpace(g: Guest, input: { code?: string; token?: string }, ip: string) { this.cleanup(); this.limiter.hit(`space-join-ip:${ip}`, 20); this.limiter.hit(`space-join:${g.id}`, 10); const condition = input.token ? eq(s.spaces.tokenHash, hash(input.token)) : eq(s.spaces.codeHash, hmac(this.cfg.discoverySecret, input.code!)); const row = this.db .select() .from(s.spaces) .where(and(condition, isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()))) .get(); if (!row) fail(404, 'SPACE_NOT_FOUND', '코드가 올바르지 않거나 만료되었습니다.'); this.db.transaction(() => { const deviceId = this.device(g.id).id; const existing = this.db .select() .from(s.members) .where(and(eq(s.members.spaceId, row!.id), eq(s.members.deviceId, deviceId))) .get(); if (existing?.status === 'removed') fail(403, 'SPACE_REMOVED', '이 공간에 다시 참여할 수 없습니다.'); if (existing) return; const count = this.db .select() .from(s.members) .where( and(eq(s.members.spaceId, row!.id), inArray(s.members.status, ['pending', 'approved'])), ) .all().length; if (count >= this.cfg.spaceMax) fail(409, 'SPACE_FULL', '연결 공간이 가득 찼습니다.'); if (this.activeRoom(g.id)) fail(409, 'SPACE_LIMIT', '현재 방에서 먼저 나가 주세요.'); this.db .insert(s.members) .values({ id: randomUUID(), spaceId: row!.id, deviceId, status: row!.approvalRequired ? 'pending' : 'approved', joinedAt: Date.now(), }) .run(); }); this.nearby.clearGuest(g.id); this.restrictTransfers(); this.refresh(); return { space: this.space(g, row!.id) }; } ownerSpace(g: Guest, id: string) { const row = this.db .select() .from(s.spaces) .where( and( eq(s.spaces.id, id), eq(s.spaces.creatorGuestId, g.id), isNull(s.spaces.closedAt), gt(s.spaces.expiresAt, Date.now()), ), ) .get(); if (!row) fail(404, 'SPACE_NOT_FOUND', '연결 공간을 관리할 수 없습니다.'); return row!; } approve(g: Guest, id: string, deviceId: string) { this.ownerSpace(g, id); const member = this.db .select() .from(s.members) .where( and( eq(s.members.spaceId, id), eq(s.members.deviceId, deviceId), eq(s.members.status, 'pending'), ), ) .get(); if (!member) fail(404, 'MEMBER_NOT_FOUND', '승인할 참여자가 없습니다.'); this.db.update(s.members).set({ status: 'approved' }).where(eq(s.members.id, member!.id)).run(); this.refresh(); } remove(g: Guest, id: string, deviceId: string) { if (this.device(g.id).id !== deviceId) this.ownerSpace(g, id); else this.space(g, id); if (this.ownerOrNull(g, id) && this.device(g.id).id === deviceId) fail(409, 'CREATOR_LEAVE', '공간 생성자는 공간을 닫아 주세요.'); const member = this.db .select() .from(s.members) .where(and(eq(s.members.spaceId, id), eq(s.members.deviceId, deviceId))) .get(); if (!member) fail(404, 'MEMBER_NOT_FOUND', '참여자를 찾을 수 없습니다.'); this.db.update(s.members).set({ status: 'removed' }).where(eq(s.members.id, member!.id)).run(); this.restrictTransfers([this.deviceGuest(deviceId)!.guestId]); this.refresh(); } ownerOrNull(g: Guest, id: string) { return this.db .select() .from(s.spaces) .where(and(eq(s.spaces.id, id), eq(s.spaces.creatorGuestId, g.id))) .get(); } closeSpace(g: Guest, id: string) { this.ownerSpace(g, id); const guests = this.db .select({ guestId: s.devices.guestId }) .from(s.members) .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) .where(eq(s.members.spaceId, id)) .all() .map((m) => m.guestId); this.db.update(s.spaces).set({ closedAt: Date.now() }).where(eq(s.spaces.id, id)).run(); this.db.update(s.members).set({ status: 'removed' }).where(eq(s.members.spaceId, id)).run(); this.restrictTransfers(guests); this.refresh(); } idem(g: Guest, 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.db.transaction(() => { const row = this.db .select() .from(s.idempotency) .where( and( eq(s.idempotency.guestId, g.id), 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.db .insert(s.idempotency) .values({ id: randomUUID(), guestId: g.id, operation, key: key!, payloadHash, response: JSON.stringify(result), createdAt: Date.now(), }) .run(); return result; }); } createRequest( g: Guest, input: { targetDeviceId: string; kind: 'url' | 'file'; byteSize: number; count: number; files?: { name: string; size: number; type: string }[]; }, ip: string, storedSource = false, ) { this.cleanup(); const target = this.deviceGuest(input.targetDeviceId); if ( !target || target.guestId === g.id || !this.device(g.id).discoverable || !this.canReceive(g.id, target.guestId) ) fail(403, 'TARGET_UNAVAILABLE', '이 기기로 보낼 수 없습니다.'); if ( (input.kind !== 'file' && input.byteSize > this.cfg.maxUrlBytes) || input.count > this.cfg.maxItems || input.files?.some((f) => f.size > this.cfg.maxFileBytes) ) fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); this.limiter.hit(`request-ip:${ip}`, this.cfg.ipRate); this.limiter.hit(`request-guest:${g.id}`, this.cfg.sessionRate); this.limiter.hit(`request-pair:${g.id}:${target!.guestId}`, this.cfg.pairRate); const rejected = this.db .select() .from(s.requests) .where( and( eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, target!.guestId), eq(s.requests.status, 'rejected'), gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs), ), ) .get(); if (rejected) fail(429, 'REJECTION_COOLDOWN', '상대가 거절했습니다. 잠시 후 다시 시도해 주세요.'); const active = this.db .select() .from(s.requests) .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) .where( and( eq(s.requests.senderGuestId, g.id), inArray(s.requests.status, ['pending', 'accepted']), inArray(s.batches.status, ['waiting', 'uploading']), ), ) .get(); if (active) fail(409, 'TRANSFER_ACTIVE', '진행 중인 전송을 먼저 완료하거나 취소해 주세요.'); const now = Date.now(), id = randomUUID(); this.db .insert(s.requests) .values({ id, senderGuestId: g.id, recipientGuestId: target!.guestId, kind: input.kind, byteSize: input.byteSize, count: input.count, manifest: input.files ? JSON.stringify(input.files) : null, status: 'pending', createdAt: now, expiresAt: now + this.cfg.requestSeconds * 1000, }) .run(); this.db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run(); if (input.files && !storedSource) this.storage.createManifest(g, id, input.files); return { requestId: id }; } request(g: Guest, id: string) { const row = this.db .select() .from(s.requests) .where( and( eq(s.requests.id, id), or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), ), ) .get(); if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.'); return row! as RequestRow & { senderGuestId: string; recipientGuestId: string }; } accept(g: Guest, id: string) { const row = this.request(g, id); if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.'); if (row.status !== 'pending' || row.expiresAt <= Date.now()) fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.'); const permission = this.locationAcceptances.get(id); const reserved = this.cfg.locationDiscoveryEnabled && permission?.guestId === g.id && permission.until > Date.now() && !this.activeRoom(g.id) && !this.activeRoom(row.senderGuestId) && this.presence.active(g.id) && this.presence.active(row.senderGuestId) && this.device(g.id).discoverable && this.device(row.senderGuestId).discoverable && !this.isBlocked(row.senderGuestId, g.id); this.locationAcceptances.delete(id); if (!this.canReceive(row.senderGuestId, g.id) && !reserved) fail(409, 'TARGET_UNAVAILABLE', '현재 연결 상태에서 수락할 수 없습니다.'); if (row.kind === 'file' && !this.vault.source(id)) { const sender = this.db .select() .from(s.guests) .where(eq(s.guests.id, row.senderGuestId)) .get()!; this.storage.reserve(sender, id); } const now = Date.now(); this.db .update(s.requests) .set({ status: 'accepted', acceptedAt: now, startBy: now + this.cfg.uploadStartHours * 3600000, }) .where(eq(s.requests.id, id)) .run(); return { requestId: id }; } reject(g: Guest, id: string) { const row = this.request(g, id); if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 거절할 수 있습니다.'); if (row.status !== 'pending' || row.expiresAt <= Date.now()) fail(409, 'REQUEST_STATE', '이미 처리된 요청입니다.'); this.db .update(s.requests) .set({ status: 'rejected', rejectedAt: Date.now() }) .where(eq(s.requests.id, id)) .run(); } cancel(g: Guest, id: string) { const row = this.request(g, id); if (row.senderGuestId !== g.id) fail(403, 'FORBIDDEN', '발신 기기만 취소할 수 있습니다.'); if (!['pending', 'accepted'].includes(row.status)) fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.'); const batch = this.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!; if (batch.status === 'stored') fail(409, 'TRANSFER_STORED', '완료한 전송은 회수할 수 없습니다.'); this.storage.release(id); this.db.update(s.requests).set({ status: 'cancelled' }).where(eq(s.requests.id, id)).run(); this.db.update(s.batches).set({ status: 'failed' }).where(eq(s.batches.id, batch.id)).run(); } block(g: Guest, id: string) { const row = this.request(g, id); if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 차단할 수 있습니다.'); this.db.transaction(() => { this.db .insert(s.blocks) .values({ id: randomUUID(), guestId: g.id, targetGuestId: row.senderGuestId }) .onConflictDoNothing() .run(); this.db .update(s.requests) .set({ status: 'rejected', rejectedAt: Date.now() }) .where( and( eq(s.requests.senderGuestId, row.senderGuestId), eq(s.requests.recipientGuestId, g.id), eq(s.requests.status, 'pending'), ), ) .run(); }); this.storage.release(id); this.notifyRequests(); this.refresh(); } complete(g: Guest, id: string, input: { urls: string[] }) { const row = this.request(g, id); if (row.kind === 'file') fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.'); if (row.senderGuestId !== g.id) fail(403, 'FORBIDDEN', '발신 기기만 전송할 수 있습니다.'); if ( row.status !== 'accepted' || !row.startBy || row.startBy <= Date.now() || this.isBlocked(g.id, row.recipientGuestId) ) fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.'); const batch = this.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; const bytes = Buffer.byteLength(values.join('\n'), 'utf8'); if (bytes !== row.byteSize || values.length !== row.count || bytes > this.cfg.maxUrlBytes) fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.'); const now = Date.now(); for (const value of values) { const itemId = randomUUID(); this.db .insert(s.items) .values({ id: itemId, batchId: batch.id, kind: row.kind, url: value, createdAt: now, expiresAt: now + this.cfg.urlRetentionDays * day, }) .run(); this.accounts.createAccess(row, itemId); } this.db .update(s.batches) .set({ status: 'stored', storedAt: now }) .where(eq(s.batches.id, batch.id)) .run(); return { requestId: id, batchId: batch.id }; } listRequests(g: Guest): TransferRequest[] { this.cleanup(); return this.db .select({ request: s.requests, batch: s.batches }) .from(s.requests) .innerJoin(s.batches, eq(s.requests.id, s.batches.requestId)) .where( and( inArray(s.requests.kind, ['url', 'file']), gt(s.requests.createdAt, Date.now() - 7 * day), or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)), ), ) .orderBy(desc(s.requests.createdAt), desc(s.requests.id)) .limit(100) .all() .map(({ request: r, batch: b }) => ({ id: r.id, batchId: b.id, senderDevice: this.device(r.senderGuestId!), recipientDevice: this.device(r.recipientGuestId!), 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, files: r.kind === 'file' ? (this.vault.source(r.id) ? JSON.parse(r.manifest!) : this.storage.fileRows(r.id)).map( (f: any) => ({ id: f.id, name: f.name, size: f.size, type: f.type, status: f.status, }), ) : undefined, direction: r.senderGuestId === g.id ? 'sent' : 'received', })); } listItems(g: Guest, cursor?: string, limit = 30) { let after: { createdAt: number; id: string } | undefined; if (cursor) { try { after = JSON.parse(Buffer.from(cursor, 'base64url').toString()); if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string') throw Error(); } catch { fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.'); } } const rows = this.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.guestId, g.id), inArray(s.items.kind, ['url', 'file']), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), 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(limit + 1) .all(); const result: Item[] = rows.slice(0, limit).map((r) => { const peer = this.device( (r.access.role === 'sender' ? r.request.recipientGuestId : r.request.senderGuestId)!, ); const recipient = this.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, file: r.item.fileId ? (() => { const f = this.db.select().from(s.files).where(eq(s.files.id, r.item.fileId!)).get()!; return { id: f.id, name: f.name, size: f.size, type: f.type }; })() : undefined, role: r.access.role, read: r.access.read, peerName: peer.name, peerCode: peer.code, createdAt: r.item.createdAt, expiresAt: r.item.expiresAt, recipientRead: recipient?.read ?? false, }; }); const last = result.at(-1); return { items: result, nextCursor: rows.length > limit && last ? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString( 'base64url', ) : null, }; } accessDetail(g: Guest, id: string) { const row = this.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.guestId, g.id), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), ), ) .get(); if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); return row!; } read(g: Guest, id: string, value: boolean) { const row = this.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.guestId, g.id), isNull(s.access.deletedAt), gt(s.items.expiresAt, Date.now()), ), ) .get(); if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.'); this.db .update(s.access) .set({ read: value }) .where(and(eq(s.access.itemId, row!.item.id), eq(s.access.role, row!.access.role))) .run(); const all = this.db.select().from(s.access).where(eq(s.access.itemId, row!.item.id)).all(); for (const a of all) { if (a.guestId) this.presence.emit(a.guestId, 'access:updated', {}); if (a.userId) this.accounts.notify(a.userId); } } notifyRequests() { for (const id of this.presence.ids()) this.presence.emit(id, 'request:updated', {}); } notifyTransfer(id: string) { const row = this.db.select().from(s.requests).where(eq(s.requests.id, id)).get(); if (row?.senderUserId) this.accounts.notify(row.senderUserId); if (row?.recipientUserId) this.accounts.notify(row.recipientUserId); const receipt = this.db .select() .from(s.receiptAccounts) .where(eq(s.receiptAccounts.requestId, id)) .get(); if (receipt) this.accounts.notify(receipt.userId); if (row) for (const g of [row.senderGuestId, row.recipientGuestId]) { if (!g) continue; this.presence.emit(g, 'request:updated', {}); this.presence.emit(g, 'transfer:updated', {}); } } cleanup() { const now = Date.now(); const pending = this.db .select() .from(s.requests) .where(and(eq(s.requests.status, 'pending'), lt(s.requests.expiresAt, now))) .all(); const approved = this.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.status, 'accepted'), lt(s.requests.startBy, now), inArray(s.batches.status, ['waiting', 'uploading']), ), ) .all(); this.db.transaction(() => { for (const r of pending) this.db.update(s.requests).set({ status: 'expired' }).where(eq(s.requests.id, r.id)).run(); for (const r of approved) { this.storage.release(r.request.id); this.db .update(s.requests) .set({ status: 'expired' }) .where(eq(s.requests.id, r.request.id)) .run(); this.db .update(s.batches) .set({ status: 'failed' }) .where(eq(s.batches.id, r.batch.id)) .run(); } const dead = this.db .select({ id: s.items.id }) .from(s.items) .where( sql`NOT EXISTS (SELECT 1 FROM item_access a WHERE a.itemId = ${s.items.id} AND (a.deletedAt IS NULL OR a.deletedAt > ${now - this.cfg.undoSeconds * 1000}) AND ${s.items.expiresAt} > ${now})`, ) .all(); for (const item of dead) { const accesses = this.db.select().from(s.access).where(eq(s.access.itemId, item.id)).all(); for (const access of accesses) { for (const source of this.db .select() .from(s.vaultTransfers) .where(eq(s.vaultTransfers.accessId, access.id)) .all()) { const batch = this.db .select() .from(s.batches) .where(eq(s.batches.requestId, source.requestId)) .get()!; if (batch.status !== 'stored') { this.db .update(s.requests) .set({ status: 'expired' }) .where(eq(s.requests.id, source.requestId)) .run(); this.db .update(s.batches) .set({ status: 'failed' }) .where(eq(s.batches.id, batch.id)) .run(); } } this.db.delete(s.vaultTransfers).where(eq(s.vaultTransfers.accessId, access.id)).run(); } this.db.delete(s.access).where(eq(s.access.itemId, item.id)).run(); this.db.delete(s.items).where(eq(s.items.id, item.id)).run(); } this.db.run( sql`UPDATE transfer_requests SET note = NULL, manifest = NULL WHERE createdAt < ${now - 7 * day} AND NOT EXISTS (SELECT 1 FROM transfer_batches b INNER JOIN items i ON i.batchId = b.id WHERE b.requestId = transfer_requests.id AND i.expiresAt > ${now})`, ); const expiredSpaces = this.db .select() .from(s.spaces) .where(and(isNull(s.spaces.closedAt), lt(s.spaces.expiresAt, now))) .all(); for (const space of expiredSpaces) { const guests = this.db .select({ guestId: s.devices.guestId }) .from(s.members) .innerJoin(s.devices, eq(s.members.deviceId, s.devices.id)) .where(eq(s.members.spaceId, space.id)) .all() .map((member) => member.guestId); this.db.update(s.spaces).set({ closedAt: now }).where(eq(s.spaces.id, space.id)).run(); this.db .update(s.members) .set({ status: 'removed' }) .where(eq(s.members.spaceId, space.id)) .run(); this.restrictTransfers(guests); } if (expiredSpaces.length) this.refresh(); for (const guestId of this.presence.ids()) { const guest = this.db.select().from(s.guests).where(eq(s.guests.id, guestId)).get(); if (!guest || guest.revokedAt || guest.expiresAt <= now) this.presence.revoke(guestId); } this.db .delete(s.idempotency) .where(lt(s.idempotency.createdAt, now - 30 * day)) .run(); }); if (pending.length || approved.length) this.notifyRequests(); this.limiter.prune(); } }