Add plain text clipboard transfers and refine input feedback

This commit is contained in:
2026-10-13 14:07:03 +09:00
parent af004a2942
commit 8a8df417aa
32 changed files with 1263 additions and 197 deletions

View File

@@ -1,6 +1,8 @@
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 { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared';
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';
@@ -44,7 +46,7 @@ export class Accounts {
and(
eq(s.requests.id, id),
eq(s.requests.mode, 'email'),
inArray(s.requests.kind, ['url', 'file']),
inArray(s.requests.kind, ['url', 'text', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
@@ -124,7 +126,7 @@ export class Accounts {
userId: string,
input: {
email: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
@@ -154,7 +156,8 @@ export class Accounts {
if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
if (
input.count > cfg.maxItems ||
(input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
(input.kind === 'url' && input.byteSize > cfg.maxUrlBytes) ||
(input.kind === 'text' && input.byteSize > cfg.maxTextBytes) ||
input.files?.some((f) => f.size > cfg.maxFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
@@ -256,7 +259,7 @@ export class Accounts {
.where(eq(s.batches.id, batch.id))
.run();
}
complete(userId: string, id: string, input: { urls: string[] }) {
complete(userId: string, id: string, input: CompleteInput) {
const row = this.request(userId, id);
if (
row.senderUserId !== userId ||
@@ -273,13 +276,13 @@ export class Accounts {
.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)
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) {
@@ -291,7 +294,7 @@ export class Accounts {
batchId: batch.id,
kind: row.kind,
url: value,
...(row.kind === 'text' ? { text: value } : { url: value }),
createdAt: now,
expiresAt: now + this.service.cfg.urlRetentionDays * 86400000,
})
@@ -348,7 +351,7 @@ export class Accounts {
and(
eq(s.requests.mode, 'email'),
gt(s.requests.createdAt, Date.now() - 7 * 86400000),
inArray(s.requests.kind, ['url', 'file']),
inArray(s.requests.kind, ['url', 'text', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
@@ -391,7 +394,7 @@ export class Accounts {
and(
eq(s.access.id, id),
eq(s.access.userId, userId),
inArray(s.items.kind, ['url', 'file']),
inArray(s.items.kind, ['url', 'text', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
@@ -451,11 +454,11 @@ export class Accounts {
s.access.role,
view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender',
),
inArray(s.items.kind, ['url', 'file']),
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 EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
? 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(
@@ -486,6 +489,7 @@ export class Accounts {
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,