1099 lines
37 KiB
TypeScript
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();
|
|
}
|
|
}
|