diff --git a/.env.example b/.env.example index 3c469f7..1ae052e 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ SPACE_INVITE_CODE_LENGTH=4 SPACE_MAX_DEVICES=10 OFFLINE_GRACE_MS=3000 MAX_URL_PAYLOAD_SIZE_KB=1024 +MAX_TEXT_PAYLOAD_SIZE_KB=256 MAX_ITEMS_PER_TRANSFER=20 DEFAULT_URL_RETENTION_DAYS=7 GUEST_REQUESTS_PER_MINUTE=5 diff --git a/README.md b/README.md index 559f224..060feaf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 기기 사이를 가볍게. 가입 없이 접속한 브라우저 사이에서 수락 후 자료를 전달하는 서비스입니다. -현재 **Phase 1·2 검증 완료 · Phase 3 구현 진행 중**입니다. 파일 전송은 구현되어 있습니다. 로그인 수신 기록·7일 보관함은 구현되어 있으며 Resend API 어댑터의 실제 메일 수신 검증이 남아 있습니다. 관리자·PWA는 후속 단계입니다. 전체 설계는 [구현 계획](docs/IMPLEMENTATION_PLAN.md), 개발 기준은 [SPEC.md](SPEC.md)를 참고하세요. +파일·클립보드(링크/텍스트) 전송, 로그인 중 수신 기록·7일 보관함과 PWA를 구현했습니다. 자동 검증 범위는 [검증 기록](docs/VERIFICATION.md), 클립보드 동작과 256KiB 제한은 [클립보드 전송](docs/CLIPBOARD_TRANSFER.md)을 참고하세요. Resend 실제 메일 수신과 실기기 운영 경로는 별도로 확인합니다. 전체 설계는 [구현 계획](docs/IMPLEMENTATION_PLAN.md), 개발 기준은 [SPEC.md](SPEC.md)와 이후 사용자 수정안을 따릅니다. ## 로컬 개발 @@ -51,7 +51,7 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코 ## 익명 자료 -비로그인 화면에는 전송 내역을 표시하지 않습니다. 수락 후 링크는 새 탭으로 열고 파일은 다운로드합니다. 익명 기기 세션은 기본 30일이며 저장한 자료는 최대 7일입니다. 쿠키 삭제·만료·초기화 시 이전 익명 접근권은 복구할 수 없습니다. 로그인 전 수신 자료를 계정으로 가져오지 않습니다. 기기 세션 정리가 필요하면 브라우저의 사이트 데이터를 삭제합니다. 기기 이름은 인증된 신원이 아니며 전송은 서버를 경유합니다. E2EE나 P2P 전송을 제공한다고 안내하지 않습니다. +비로그인 화면에는 전송 내역을 표시하지 않습니다. 수락 후 링크는 새 탭으로 열고 파일은 다운로드합니다. 텍스트는 원문 미리보기와 클립보드 복사 버튼을 표시합니다. 익명 기기 세션은 기본 30일이며 저장한 자료는 최대 7일입니다. 쿠키 삭제·만료·초기화 시 이전 익명 접근권은 복구할 수 없습니다. 로그인 전 수신 자료를 계정으로 가져오지 않습니다. 기기 세션 정리가 필요하면 브라우저의 사이트 데이터를 삭제합니다. 기기 이름은 인증된 신원이 아니며 전송은 서버를 경유합니다. E2EE나 P2P 전송을 제공한다고 안내하지 않습니다. ## 업데이트와 백업 diff --git a/apps/server/src/accounts.ts b/apps/server/src/accounts.ts index 9a5fb25..b39ac08 100644 --- a/apps/server/src/accounts.ts +++ b/apps/server/src/accounts.ts @@ -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, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b12d859..9641d12 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -332,6 +332,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) devices: service.peers(g), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, spaceInviteCodeLength: cfg.spaceInviteCodeLength, + clipboardTextLimitBytes: cfg.maxTextBytes, locationDiscovery: { enabled: cfg.locationDiscoveryEnabled, radiusMeters: cfg.locationRadiusMeters, @@ -819,6 +820,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) note: noteInput, }) .strict(), + z + .object({ + kind: z.literal('text'), + text: z.string().min(1).max(1048576), + note: noteInput, + }) + .strict(), z .object({ kind: z.literal('file'), @@ -832,19 +840,26 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) const created = service.accounts.create(user.id, { email: user.email!, kind: input.kind, - count: input.kind === 'url' ? input.urls.length : input.files.length, + count: + input.kind === 'text' ? 1 : input.kind === 'url' ? input.urls.length : input.files.length, byteSize: - input.kind === 'url' - ? Buffer.byteLength(input.urls.join('\n')) - : input.files.reduce((sum, file) => sum + file.size, 0), + input.kind === 'text' + ? Buffer.byteLength(input.text, 'utf8') + : input.kind === 'url' + ? Buffer.byteLength(input.urls.join('\n')) + : input.files.reduce((sum, file) => sum + file.size, 0), ...(input.kind === 'file' ? { files: input.files } : {}), }); db.update(tables.requests) .set({ note: input.note || null }) .where(eq(tables.requests.id, created.requestId)) .run(); - if (input.kind === 'url') - service.accounts.complete(user.id, created.requestId, { urls: input.urls }); + if (input.kind !== 'file') + service.accounts.complete( + user.id, + created.requestId, + input.kind === 'text' ? { text: input.text } : { urls: input.urls }, + ); return created; }); service.notifyTransfer(result.requestId); diff --git a/apps/server/src/clipboardPayload.js b/apps/server/src/clipboardPayload.js new file mode 100644 index 0000000..6c9cf6d --- /dev/null +++ b/apps/server/src/clipboardPayload.js @@ -0,0 +1,24 @@ +import { parseUrls } from '@hop/shared'; +import { fail } from './security.js'; + +// Called only after the request's sender and acceptance state have been checked. +export function inlinePayload(kind, input, textLimit, urlLimit) { + if (kind === 'text') { + if (typeof input.text !== 'string' || !input.text.trim() || input.urls !== undefined) + fail(400, 'INVALID_TEXT', '보낼 텍스트를 입력해 주세요.'); + const text = input.text; + if (Buffer.from(text, 'utf8').toString('utf8') !== text) + fail(400, 'INVALID_TEXT', '올바른 UTF-8 텍스트가 필요합니다.'); + const bytes = Buffer.byteLength(text, 'utf8'); + if (bytes > textLimit) fail(413, 'TEXT_LIMIT', '텍스트 전송 크기 제한을 초과했습니다.'); + return { values: [text], bytes }; + } + if (kind !== 'url' || !Array.isArray(input.urls) || input.text !== undefined) + fail(400, 'TYPE_MISMATCH', '요청한 자료 종류와 본문이 다릅니다.'); + const parsed = parseUrls(input.urls.join('\n')); + if (parsed.invalid.length || parsed.urls.length !== input.urls.length) + fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.'); + const bytes = Buffer.byteLength(parsed.urls.join('\n'), 'utf8'); + if (bytes > urlLimit) fail(413, 'TRANSFER_LIMIT', '링크 전송 크기 제한을 초과했습니다.'); + return { values: parsed.urls, bytes }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e11a38f..215dd72 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -22,6 +22,7 @@ export type Config = { spaceMax: number; offlineGraceMs: number; maxUrlBytes: number; + maxTextBytes: number; maxItems: number; urlRetentionDays: number; sessionRate: number; @@ -68,6 +69,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters'); if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000) throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000'); + if (num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) > 1024) + throw Error('MAX_TEXT_PAYLOAD_SIZE_KB must not exceed 1024'); const spaceInviteCodeLength = num('SPACE_INVITE_CODE_LENGTH', 4); if (![4, 6].includes(spaceInviteCodeLength)) throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6'); @@ -100,6 +103,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { spaceMax: num('SPACE_MAX_DEVICES', 10), offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0), maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024, + maxTextBytes: num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) * 1024, maxItems: num('MAX_ITEMS_PER_TRANSFER', 20), urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)), sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5), diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 45bb951..b859960 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -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()!; diff --git a/apps/server/src/vault.js b/apps/server/src/vault.js index 68ecd1c..a45613e 100644 --- a/apps/server/src/vault.js +++ b/apps/server/src/vault.js @@ -88,17 +88,25 @@ export class Vault { forward(guest, current, accessId, targetDeviceId, ip) { const source = { userId: current.user.id, sessionId: current.session.id, accessId }; const row = this.authorize(source); - const input = row.item.url - ? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 } - : { - targetDeviceId, - kind: 'file', - byteSize: row.file.size, - count: 1, - files: [ - { id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type }, - ], - }; + const input = + row.item.kind === 'text' + ? { + targetDeviceId, + kind: 'text', + byteSize: Buffer.byteLength(row.item.text, 'utf8'), + count: 1, + } + : row.item.url + ? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 } + : { + targetDeviceId, + kind: 'file', + byteSize: row.file.size, + count: 1, + files: [ + { id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type }, + ], + }; const result = this.service.createRequest(guest, input, ip, true); this.service.db .insert(s.vaultTransfers) @@ -128,6 +136,7 @@ export class Vault { batchId: batch.id, kind: row.item.kind, url: row.item.url, + text: row.item.text, fileId: row.item.fileId, createdAt: now, expiresAt: now + 7 * 86400000, diff --git a/apps/server/test/clipboard-access.test.js b/apps/server/test/clipboard-access.test.js new file mode 100644 index 0000000..7c39614 --- /dev/null +++ b/apps/server/test/clipboard-access.test.js @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mayReadClipboardAutomatically } from '../../web/src/clipboardAccess.js'; + +test('클립보드 자동 읽기는 모바일 확인과 지원하지 않는 권한 API를 우회하지 않는다', async () => { + assert.equal(await mayReadClipboardAutomatically({}, true), false); + const nav = (state) => ({ permissions: { query: async () => ({ state }) } }); + assert.equal(await mayReadClipboardAutomatically(nav('granted'), true), true); + assert.equal(await mayReadClipboardAutomatically(nav('prompt'), true), false); + assert.equal(await mayReadClipboardAutomatically(nav('denied'), false), false); + assert.equal(await mayReadClipboardAutomatically(nav('prompt'), false), true); + assert.equal( + await mayReadClipboardAutomatically( + { + permissions: { + query: async () => { + throw new TypeError('unsupported'); + }, + }, + }, + false, + ), + false, + ); +}); diff --git a/apps/server/test/clipboard.test.js b/apps/server/test/clipboard.test.js new file mode 100644 index 0000000..1d88881 --- /dev/null +++ b/apps/server/test/clipboard.test.js @@ -0,0 +1,207 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { fixture } from './helpers.js'; +import { classifyClipboard, clipboardCharacterCount } from '@hop/shared'; +import * as s from '../src/db/schema.js'; + +const raw = + ' 첫 줄 😀\r\n日本語の文章\n\t URL은 https://example.com 안에 있어요. \n'; +function limits(f) { + f.cfg.sessionRate = 100; + f.cfg.ipRate = 200; + f.cfg.pairRate = 100; +} +async function textRequest(f, a, b, value, bytes = Buffer.byteLength(value)) { + return f.call( + a, + '/transfer-requests', + 'POST', + { targetDeviceId: b.deviceId, kind: 'text', count: 1, byteSize: bytes }, + randomUUID(), + ); +} +async function signed(f, client, email) { + const id = randomUUID(); + f.service.db + .insert(s.users) + .values({ + id, + email, + displayName: email, + passwordHash: await f.service.auth.password('1234'), + status: 'active', + verifiedAt: Date.now(), + createdAt: Date.now(), + }) + .run(); + const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' }); + assert.equal(login.statusCode, 200); + client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; + return id; +} + +test('클립보드는 전체 단일 URL만 링크로 판별하고 나머지 원문과 이모지를 보존한다', () => { + for (const [input, url] of [ + ['https://example.com/article', 'https://example.com/article'], + ['http://127.0.0.1/path', 'http://127.0.0.1/path'], + ['www.example.com', 'https://www.example.com/'], + ['example.com/path', 'https://example.com/path'], + ]) + assert.deepEqual(classifyClipboard(input), { kind: 'url', url }); + for (const value of [ + raw, + '', + ' ', + '여기 https://example.com 보세요', + 'https://one.test/\nhttps://two.test/', + 'javascript:alert(1)', + 'https://user:pass@example.com', + 'https://example.com/\n', + ]) + assert.deepEqual(classifyClipboard(value), { kind: 'text', text: value }); + assert.equal(clipboardCharacterCount('한글日本語😀'), 6); +}); + +test('텍스트는 수락 전 비공개이며 수락 후 원문·UTF-8·접근권·읽음·멱등성을 유지한다', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + const response = await textRequest(f, a, b, raw); + assert.equal(response.statusCode, 200, response.body); + const id = response.json().requestId; + assert.equal( + (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID())) + .statusCode, + 409, + ); + assert.equal((await f.call(b, '/device/items')).json().items.length, 0); + assert.equal((await f.call(b, '/device/snapshot')).json().requests[0].text, undefined); + assert.notEqual( + (await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, + 200, + ); + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.notEqual( + (await f.call(c, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID())) + .statusCode, + 200, + ); + const key = randomUUID(); + assert.equal( + (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode, + 200, + ); + assert.equal( + (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode, + 200, + ); + const item = (await f.call(b, '/device/items')).json().items[0]; + assert.equal(item.kind, 'text'); + assert.equal(item.text, raw); + assert.equal(item.url, null); + assert.equal((await f.call(c, '/device/items')).json().items.length, 0); + assert.equal(f.service.db.select().from(s.items).all().length, 1); + assert.equal(f.service.db.select().from(s.files).all().length, 0); + await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true }); + assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true); + assert(item.expiresAt - item.createdAt <= 7 * 86400000); + } finally { + await f.close(); + } +}); + +test('텍스트의 UTF-8 256KiB 한도와 본문 타입·빈 내용·승인 크기 변경을 검사한다', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + assert.equal(f.cfg.maxTextBytes, 256 * 1024); + assert.equal((await textRequest(f, a, b, '가'.repeat(87382))).statusCode, 413); + const boundary = 'x'.repeat(256 * 1024); + const id = (await textRequest(f, a, b, boundary)).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + assert.equal( + (await f.call(a, `/transfers/${id}/complete`, 'POST', { text: boundary }, randomUUID())) + .statusCode, + 200, + ); + const small = (await textRequest(f, a, b, 'x')).json().requestId; + await f.call(b, `/transfer-requests/${small}/accept`, 'POST', {}, randomUUID()); + for (const [body, status] of [ + [{ text: boundary + 'x' }, 413], + [{ text: ' ' }, 400], + [{ text: '変更 😀' }, 409], + [{ urls: ['https://example.com/'] }, 400], + [{ text: 'x', urls: [] }, 400], + ]) + assert.equal( + (await f.call(a, `/transfers/${small}/complete`, 'POST', body, randomUUID())).statusCode, + status, + ); + assert.equal( + (await f.call(a, `/transfers/${small}/complete`, 'POST', { text: 'x' }, randomUUID())) + .statusCode, + 200, + ); + } finally { + await f.close(); + } +}); + +test('로그인 중 받은 텍스트는 7일 기록하고 임시 저장·검색·보관함 재전송도 원문을 유지한다', async () => { + const f = await fixture(); + try { + limits(f); + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + await signed(f, b, 'text@hop.test'); + const id = (await textRequest(f, a, b, raw)).json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID()); + const receipt = (await f.call(b, '/inbox')).json().items[0]; + assert.equal(receipt.text, raw); + const saved = await f.call( + b, + '/vault', + 'POST', + { kind: 'text', text: raw, note: '保存 메모' }, + randomUUID(), + ); + assert.equal(saved.statusCode, 200, saved.body); + const vault = (await f.call(b, '/vault?search=' + encodeURIComponent('日本語'))).json().items; + assert.equal(vault.length, 1); + assert.equal(vault[0].text, raw); + const forwarded = await f.call( + b, + `/vault/${vault[0].accessId}/send`, + 'POST', + { targetDeviceId: a.deviceId }, + randomUUID(), + ); + assert.equal(forwarded.statusCode, 200, forwarded.body); + const sentId = forwarded.json().requestId; + await f.call(a, `/transfer-requests/${sentId}/accept`, 'POST', {}, randomUUID()); + assert( + (await f.call(a, '/device/items')) + .json() + .items.some((item) => item.role === 'recipient' && item.text === raw), + ); + f.service.db + .update(s.items) + .set({ expiresAt: Date.now() - 1 }) + .where(eq(s.items.id, vault[0].id)) + .run(); + assert.equal((await f.call(b, '/vault')).json().items.length, 0); + } finally { + await f.close(); + } +}); diff --git a/apps/server/test/phase1.test.ts b/apps/server/test/phase1.test.ts index e636d34..4b036ff 100644 --- a/apps/server/test/phase1.test.ts +++ b/apps/server/test/phase1.test.ts @@ -509,7 +509,7 @@ test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인 ); }); -test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => { +test('텍스트 요청은 한 항목만 허용하고 URL 요청에 다른 본문 타입을 확정하지 않는다', async () => { const f = await fixture(); try { const a = await f.create(), @@ -521,7 +521,7 @@ test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파 a, '/transfer-requests', 'POST', - { targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 }, + { targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 2 }, randomUUID(), ) ).statusCode, diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index 8da7d38..f21f9e7 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -33,6 +33,7 @@ import { clearAccountUploads, cancelTransfer, uploadProgress, + clipboardTextLimitBytes, } from './client'; const props = defineProps({ initialView: { type: String, default: 'vault' } }); const emit = defineEmits(['devices', 'send-item']); @@ -248,7 +249,7 @@ async function save(values) { '/vault', 'POST', { - kind: values.files ? 'file' : 'url', + kind: values.files ? 'file' : values.text !== undefined ? 'text' : 'url', ...(values.files ? { files: values.files.map((file) => ({ @@ -257,7 +258,9 @@ async function save(values) { type: file.type || 'application/octet-stream', })), } - : { urls: values.urls }), + : values.text !== undefined + ? { text: values.text } + : { urls: values.urls }), note: note.value, }, crypto.randomUUID(), @@ -317,7 +320,7 @@ function recordLinkOpen(item) { } async function copy(item) { await act(async () => { - await navigator.clipboard.writeText(item.url); + await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url); await read(item); message('복사했습니다.'); }); @@ -577,7 +580,7 @@ async function resendVerification() { @@ -592,8 +595,8 @@ async function resendVerification() { type="checkbox" v-model="selected" :value="item.accessId" - :aria-label="`${item.file?.name ?? item.url} 선택`" - />{{ item.file ? '파일' : '링크' }}{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}{{ item.peerName }} @@ -605,6 +608,9 @@ async function resendVerification() { >{{ size(item.file.size) }} +
{{
+ item.text
+ }}
{{ item.url }}