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