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

1099 lines
37 KiB
TypeScript

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<string, { guestId: string; until: number }>();
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<T>(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();
}
}