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,3 +1,5 @@
import { inlinePayload } from './clipboardPayload.js';
import type { CompleteInput } from '@hop/shared';
import type { Server } from 'socket.io';
import type { Auth } from './auth.js';
import type { Accounts } from './accounts.js';
@@ -13,14 +15,7 @@ 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';
import { 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;
@@ -133,7 +128,7 @@ export class Service {
.from(s.requests)
.where(
and(
inArray(s.requests.kind, ['url', 'file']),
inArray(s.requests.kind, ['url', 'text', 'file']),
or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)),
),
)
@@ -580,7 +575,7 @@ export class Service {
g: Guest,
input: {
targetDeviceId: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
@@ -598,7 +593,8 @@ export class Service {
)
fail(403, 'TARGET_UNAVAILABLE', '이 기기로 보낼 수 없습니다.');
if (
(input.kind !== 'file' && input.byteSize > this.cfg.maxUrlBytes) ||
(input.kind === 'url' && input.byteSize > this.cfg.maxUrlBytes) ||
(input.kind === 'text' && input.byteSize > this.cfg.maxTextBytes) ||
input.count > this.cfg.maxItems ||
input.files?.some((f) => f.size > this.cfg.maxFileBytes)
)
@@ -756,7 +752,7 @@ export class Service {
this.notifyRequests();
this.refresh();
}
complete(g: Guest, id: string, input: { urls: string[] }) {
complete(g: Guest, id: string, input: CompleteInput) {
const row = this.request(g, id);
if (row.kind === 'file')
fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.');
@@ -770,14 +766,13 @@ export class Service {
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)
const { values, bytes } = inlinePayload(
row.kind,
input,
this.cfg.maxTextBytes,
this.cfg.maxUrlBytes,
);
if (bytes !== row.byteSize || values.length !== row.count)
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
const now = Date.now();
for (const value of values) {
@@ -789,7 +784,7 @@ export class Service {
batchId: batch.id,
kind: row.kind,
url: value,
...(row.kind === 'text' ? { text: value } : { url: value }),
createdAt: now,
expiresAt: now + this.cfg.urlRetentionDays * day,
})
@@ -811,7 +806,7 @@ export class Service {
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
inArray(s.requests.kind, ['url', 'file']),
inArray(s.requests.kind, ['url', 'text', 'file']),
gt(s.requests.createdAt, Date.now() - 7 * day),
or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)),
),
@@ -867,7 +862,7 @@ export class Service {
.where(
and(
eq(s.access.guestId, g.id),
inArray(s.items.kind, ['url', 'file']),
inArray(s.items.kind, ['url', 'text', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
after
@@ -896,6 +891,7 @@ export class Service {
batchId: r.item.batchId,
kind: r.item.kind as 'url' | 'file',
url: r.item.url,
...(r.item.kind === 'text' ? { text: r.item.text } : {}),
file: r.item.fileId
? (() => {
const f = this.db.select().from(s.files).where(eq(s.files.id, r.item.fileId!)).get()!;