Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
667
apps/server/src/accounts.ts
Normal file
667
apps/server/src/accounts.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { parseUrls, 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', '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' | 'file';
|
||||
byteSize: number;
|
||||
count: number;
|
||||
files?: { name: string; size: number; type: string }[];
|
||||
},
|
||||
) {
|
||||
const { db, cfg, limiter } = this.service;
|
||||
this.verified(userId);
|
||||
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 !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
|
||||
input.files?.some((f) => f.size > cfg.maxFileBytes)
|
||||
)
|
||||
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.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: { urls: string[] }) {
|
||||
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', '이미 처리된 전송입니다.');
|
||||
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;
|
||||
if (Buffer.byteLength(values.join('\n')) !== 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,
|
||||
|
||||
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', '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', 'file']),
|
||||
isNull(s.access.deletedAt),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
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);
|
||||
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', '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 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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
used(userId: string) {
|
||||
const db = this.service.db;
|
||||
const owned = db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning'])))
|
||||
.all();
|
||||
const accounted = new Map(owned.map((f) => [f.id, f.size]));
|
||||
const preserved = 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),
|
||||
or(eq(s.access.favorite, true), eq(s.access.role, 'self')),
|
||||
isNull(s.access.deletedAt),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const { file } of preserved) accounted.set(file.id, file.size);
|
||||
return [...accounted.values()].reduce((a, b) => a + b, 0);
|
||||
}
|
||||
ensureQuota(userId: string, bytes: number) {
|
||||
if (this.used(userId) + bytes > this.service.cfg.userQuota)
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user