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

@@ -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

View File

@@ -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 전송을 제공한다고 안내하지 않습니다.
## 업데이트와 백업

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,

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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),

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()!;

View File

@@ -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,

View File

@@ -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,
);
});

View File

@@ -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<script>window.__xss = true</script>';
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();
}
});

View File

@@ -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,

View File

@@ -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() {
<input
type="search"
v-model="search"
placeholder="링크, 파일, 메모 검색"
placeholder="링크, 텍스트, 파일, 메모 검색"
aria-label="계정 자료 검색"
/><button class="button small">검색</button>
</form>
@@ -592,8 +595,8 @@ async function resendVerification() {
type="checkbox"
v-model="selected"
:value="item.accessId"
:aria-label="`${item.file?.name ?? item.url} 선택`"
/><span>{{ item.file ? '파일' : '링크' }}</span
:aria-label="`${item.file?.name ?? item.url ?? item.text} 선택`"
/><span>{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</span
><span v-if="view === 'inbox'">{{ item.peerName }}</span
><time>{{ date(item.createdAt) }}</time>
</div>
@@ -605,6 +608,9 @@ async function resendVerification() {
><span>{{ size(item.file.size) }}</span>
</div>
</div>
<pre v-else-if="item.kind === 'text'" class="item-body account-text-preview">{{
item.text
}}</pre>
<p v-else class="item-body">{{ item.url }}</p>
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
@@ -624,6 +630,7 @@ async function resendVerification() {
><template v-else
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
><a
v-if="item.url"
class="button small"
:href="item.url"
target="_blank"
@@ -687,7 +694,8 @@ async function resendVerification() {
</button>
</header>
<div v-if="!saveKind" class="save-kind-choices">
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
<button class="button" @click="saveKind = 'clipboard'">
<Copy :size="22" />클립보드 저장</button
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
</div>
<template v-else>
@@ -711,7 +719,8 @@ async function resendVerification() {
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
.map(([, value]) => value)
"
@links="save({ urls: $event })"
@clipboard="save($event)"
:text-limit-bytes="clipboardTextLimitBytes"
@files="save({ files: $event })"
@cancel="cancelSave"
/>

View File

@@ -14,6 +14,7 @@ import {
Check,
X,
Copy,
ClipboardPaste,
Settings,
Sun,
Moon,
@@ -37,6 +38,7 @@ import {
} from '@lucide/vue';
import QRCode from 'qrcode';
import { version as appVersion } from '../package.json';
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
@@ -52,6 +54,7 @@ import {
connected,
spaceInviteCodeLength,
locationSettings,
clipboardTextLimitBytes,
publishLocation,
clearLocation,
reserveLocationAcceptance,
@@ -144,6 +147,31 @@ createNearbySearch({
});
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
const receivedTexts = receiver.receivedTexts;
const receivedLinks = receiver.receivedLinks;
watch(
() => receivedTexts.value[0]?.accessId,
async (id) => {
if (!id) return;
await nextTick();
document.querySelector('.received-text-dialog > .button')?.focus();
},
);
function trapReceivedFocus(event) {
const controls = [
...event.currentTarget.querySelectorAll('button:not(:disabled), [tabindex="0"]'),
];
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
watch([requests, items], ([currentRequests, currentItems]) => {
receiver.reconcile(currentRequests, currentItems);
});
@@ -533,14 +561,34 @@ function selectPeer(peer) {
selectionEpoch.value++;
forwardedItem.value = null;
}
async function submitLinks(urls) {
async function submitClipboard(content) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await send(selected.value.id, 'url', { urls });
await send(
selected.value.id,
content.kind,
content.kind === 'text' ? { text: content.text } : { urls: content.urls },
);
});
}
async function copyReceived(item) {
await act(async () => {
try {
if (!navigator.clipboard?.writeText) throw Error('unavailable');
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
} catch {
throw Error('복사하지 못했어요. 내용을 선택해 직접 복사해 주세요.');
}
notify('클립보드에 복사했어요.');
await markRead(item);
});
}
async function closeReceivedText(item) {
receivedTexts.value = receivedTexts.value.filter((value) => value.accessId !== item.accessId);
await act(() => markRead(item));
}
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
@@ -736,8 +784,8 @@ async function spaceAction(space, action, memberId) {
target="_blank"
rel="noopener noreferrer"
@click="receiver.opened(item)"
>링크 열기 <ArrowUpRight :size="15"
/></a>
>링크 열기 <ArrowUpRight :size="15" /></a
><button class="button small" @click="copyReceived(item)">주소 복사</button>
<button
class="icon-button"
aria-label="안내 닫기"
@@ -747,6 +795,53 @@ async function spaceAction(space, action, memberId) {
</button>
</article>
</section>
<div
v-if="receivedTexts.length"
class="receive-backdrop text-receive-backdrop"
@keydown.esc="closeReceivedText(receivedTexts[0])"
>
<section
class="received-text-dialog"
role="dialog"
aria-modal="true"
aria-label="받은 텍스트"
@keydown.tab="trapReceivedFocus"
>
<header>
<h2>
<ClipboardPaste :size="20" />{{ receivedTexts[0].peerName }}에서 텍스트를 보냈어요
</h2>
<button
class="icon-button"
aria-label="받은 텍스트 닫기"
@click="closeReceivedText(receivedTexts[0])"
>
<X :size="20" />
</button>
</header>
<pre class="received-text-preview" tabindex="0" aria-label="받은 텍스트 내용">{{
receivedTexts[0].text
}}</pre>
<button
class="button primary"
:disabled="busy"
@click="copyReceived(receivedTexts[0])"
>
<Copy :size="18" />클립보드에 복사
</button>
</section>
</div>
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
<Link :size="18" /><span>링크를 열었어요.</span
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
><button
class="icon-button"
aria-label="링크 수신 안내 닫기"
@click="receivedLinks = []"
>
<X :size="16" />
</button>
</div>
<div v-if="incoming.length" class="receive-backdrop">
<article
v-for="r in incoming.slice(0, 1)"
@@ -763,8 +858,8 @@ async function spaceAction(space, action, memberId) {
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
>
<p>
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
{{ r.byteSize.toLocaleString() }}바이트
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
</p>
</div>
@@ -1011,7 +1106,8 @@ async function spaceAction(space, action, memberId) {
:disabled="!connected"
@cancel="cancelCurrentTransfer"
@files="submitFiles"
@links="submitLinks"
@clipboard="submitClipboard"
:text-limit-bytes="clipboardTextLimitBytes"
:transfer-source="forwardedItem"
:initial-item="itemToSend"
:vault-enabled="Boolean(account)"
@@ -1166,12 +1262,20 @@ async function spaceAction(space, action, memberId) {
</template>
<template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label
>기기 이름<input
<label>
<span class="name-field-heading"
><span>기기 이름</span
><small id="device-name-count" class="name-count"
>{{ nameDraft.length }}/{{ DEVICE_NAME_MAX_LENGTH }}</small
></span
>
<input
v-model="nameDraft"
aria-label="기기 이름"
maxlength="40"
required /></label
aria-describedby="device-name-count"
:maxlength="DEVICE_NAME_MAX_LENGTH"
required
/> </label
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
</form>
<label class="settings-row discovery-setting">

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import {
FileUp,
File as FileIcon,
@@ -11,9 +11,11 @@ import {
Check,
ArrowUpRight,
} from '@lucide/vue';
import { parseUrls } from '@hop/shared';
import { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
import { mayReadClipboardAutomatically } from './clipboardAccess';
const props = defineProps({
targetName: String,
textLimitBytes: { type: Number, default: 262144 },
targetKey: String,
clipboardKey: Number,
busy: Boolean,
@@ -30,20 +32,28 @@ const props = defineProps({
initialItem: Object,
vaultOwner: String,
});
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('file'),
const emit = defineEmits(['files', 'clipboard', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('url'),
files = ref([]),
urlInput = ref(''),
clipboardManaged = ref(false),
reading = ref(false),
feedback = ref(''),
selectedVault = ref(null),
fileInput = ref(null);
fileInput = ref(null),
editor = ref(null),
compositionKind = ref(null);
let generation = 0;
const parsed = computed(() => parseUrls(urlInput.value));
const validUrl = computed(
const clipboardOnly = computed(() => ['url', 'clipboard'].includes(props.contentMode));
const classified = computed(() => classifyClipboard(urlInput.value));
const editorKind = computed(() => compositionKind.value ?? classified.value.kind);
const contentBytes = computed(() => new TextEncoder().encode(urlInput.value).length);
const characters = computed(() => clipboardCharacterCount(urlInput.value));
const validClipboard = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
Boolean(urlInput.value.trim()) &&
((classified.value.kind === 'url' && classified.value.url.length <= 8192) ||
(classified.value.kind === 'text' && contentBytes.value <= props.textLimitBytes)),
);
const retryable = computed(
() =>
@@ -57,14 +67,15 @@ const mutable = computed(
);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
? validClipboard.value && !reading.value && !compositionKind.value
: source.value === 'file'
? files.value.length > 0
: Boolean(selectedVault.value),
);
async function loadClipboard(force = false) {
async function loadClipboard(force = false, explicit = false) {
if (
!props.targetKey ||
(!explicit && reading.value) ||
props.transfer ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
@@ -81,23 +92,23 @@ async function loadClipboard(force = false) {
reading.value = true;
feedback.value = '';
try {
if (!explicit && !(await mayReadClipboardAutomatically())) return;
if (current !== generation || !mutable.value || document.visibilityState === 'hidden') return;
if (!navigator.clipboard?.readText) throw Error('Unavailable');
const result = parseUrls(await navigator.clipboard.readText());
if (current !== generation) return;
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
const result = await navigator.clipboard.readText();
if (current !== generation || !mutable.value) return;
if (result.trim()) {
source.value = 'url';
files.value = [];
selectedVault.value = null;
urlInput.value = result.urls.join('\n');
urlInput.value = result;
clipboardManaged.value = true;
} else if (force || clipboardManaged.value) {
urlInput.value = '';
clipboardManaged.value = false;
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
} else if (explicit) {
feedback.value = '클립보드가 비어 있어요. 직접 입력하거나 붙여넣어 주세요.';
}
} catch {
if (current === generation && source.value === 'url')
feedback.value = '링크를 직접 붙여넣어 주세요.';
feedback.value = '클립보드를 읽지 못했어요. 직접 입력하거나 붙여넣어 주세요.';
} finally {
if (current === generation) reading.value = false;
}
@@ -113,12 +124,13 @@ watch(
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
source.value = props.contentMode === 'url' ? 'url' : 'file';
source.value = props.contentMode === 'file' ? 'file' : 'url';
}
if (props.initialItem) {
source.value = 'vault';
selectedVault.value = props.initialItem;
} else void loadClipboard(true);
} else if (current[0] !== previous?.[0] || current[1] !== previous?.[1])
void loadClipboard(true);
},
{ immediate: true },
);
@@ -175,23 +187,6 @@ watch(
}
},
);
watch(
() => props.disabled,
(disabled) => {
if (!disabled) void loadClipboard();
},
);
const focus = () => void loadClipboard();
const visible = () => {
if (document.visibilityState === 'visible') focus();
};
window.addEventListener('focus', focus);
document.addEventListener('visibilitychange', visible);
onUnmounted(() => {
generation++;
window.removeEventListener('focus', focus);
document.removeEventListener('visibilitychange', visible);
});
function editUrl() {
generation++;
reading.value = false;
@@ -199,7 +194,7 @@ function editUrl() {
feedback.value = '';
}
function choose(list) {
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
if (!mutable.value || clipboardOnly.value || !list?.length) return;
generation++;
reading.value = false;
files.value = Array.from(list);
@@ -224,7 +219,7 @@ function toLink() {
urlInput.value = '';
source.value = 'url';
clipboardManaged.value = false;
void loadClipboard(true);
void loadClipboard(true, true);
}
function toVault() {
if (!mutable.value || !props.vaultEnabled) return;
@@ -247,15 +242,48 @@ function pasted(event) {
return;
}
if (props.contentMode === 'file' || props.transfer) return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
urlInput.value = result.urls.join('\n');
}
const value = event.clipboardData?.getData('text/plain');
if (value === undefined) return;
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
if (event.target === editor.value) {
const start = event.target.selectionStart ?? urlInput.value.length;
const end = event.target.selectionEnd ?? start;
urlInput.value = urlInput.value.slice(0, start) + value + urlInput.value.slice(end);
void nextTick(() => {
editor.value?.focus({ preventScroll: true });
editor.value?.setSelectionRange(start + value.length, start + value.length);
});
} else urlInput.value = value;
}
function edited(event) {
urlInput.value = event.target.value;
const focused = document.activeElement === event.target;
const start = event.target.selectionStart,
end = event.target.selectionEnd;
editUrl();
if (focused)
void nextTick(() => {
if (editor.value && editor.value !== event.target) {
editor.value.focus({ preventScroll: true });
editor.value.setSelectionRange(start, end);
}
});
}
function newline(event) {
if (event.target.tagName !== 'INPUT' || event.isComposing || compositionKind.value) return;
event.preventDefault();
const start = event.target.selectionStart ?? urlInput.value.length;
const end = event.target.selectionEnd ?? start;
urlInput.value = urlInput.value.slice(0, start) + '\n' + urlInput.value.slice(end);
editUrl();
void nextTick(() => {
editor.value?.focus({ preventScroll: true });
editor.value?.setSelectionRange(start + 1, start + 1);
});
}
function submit() {
if (props.transfer && !retryable.value) {
@@ -263,7 +291,13 @@ function submit() {
return;
}
if (!ready.value || !mutable.value) return;
if (source.value === 'url') emit('links', parsed.value.urls);
if (source.value === 'url')
emit(
'clipboard',
classified.value.kind === 'url'
? { kind: 'url', urls: [classified.value.url] }
: { kind: 'text', text: urlInput.value },
);
else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value);
}
@@ -286,7 +320,7 @@ function size(value) {
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<input
v-if="contentMode !== 'url'"
v-if="!clipboardOnly"
ref="fileInput"
type="file"
multiple
@@ -297,32 +331,63 @@ function size(value) {
/>
<div
class="content-pane"
:class="{ 'clipboard-pane': source === 'url' }"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<template v-if="source === 'url'">
<div class="content-heading">
<strong><Link :size="18" />보낼 링크</strong
<strong
><Link v-if="editorKind === 'url'" :size="18" /><ClipboardPaste v-else :size="18" />{{
editorKind === 'url' ? '보낼 링크' : '보낼 텍스트'
}}</strong
><button
class="text-button clipboard-load"
:disabled="!mutable || reading"
@click="loadClipboard(true)"
@click="loadClipboard(true, true)"
>
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '다시 가져오기' }}
</button>
</div>
<textarea
v-model="urlInput"
aria-label="보낼 URL"
placeholder="https://example.com"
rows="3"
<component
:is="editorKind === 'url' ? 'input' : 'textarea'"
ref="editor"
:value="urlInput"
:aria-label="editorKind === 'url' ? '보낼 URL' : '보낼 텍스트'"
:placeholder="
classified.kind === 'url' ? 'https://example.com' : '내용을 입력하거나 붙여넣어 주세요.'
"
:rows="editorKind === 'text' ? 6 : undefined"
:disabled="!mutable"
@input="editUrl"
class="current-url"
@input="edited"
@compositionstart="
compositionKind = classified.kind;
editUrl();
"
@compositionend="
compositionKind = null;
edited($event);
"
@keydown.enter="newline"
:class="editorKind === 'url' ? 'current-url' : 'current-text'"
/>
<small v-if="classified.kind === 'text'" class="clipboard-count"
>{{ characters.toLocaleString() }}자</small
>
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
HTTP 또는 HTTPS 링크를 입력해 주세요.
<p
v-if="classified.kind === 'url' && classified.url.length > 8192"
class="content-feedback"
role="status"
>
링크가 너무 길어요. 최대 8,192자까지 보낼 수 있어요.
</p>
<p
v-if="classified.kind === 'text' && contentBytes > textLimitBytes"
class="content-feedback"
role="status"
>
텍스트는 최대 {{ Math.floor(textLimitBytes / 1024) }}KiB까지 보낼 수 있어요.
</p>
</template>
<template v-else-if="source === 'file'">
@@ -370,11 +435,17 @@ function size(value) {
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
</div>
<div v-if="selectedVault" class="current-vault">
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
selectedVault.note || selectedVault.file?.name || selectedVault.url
<FileIcon v-if="selectedVault.file" :size="28" /><ClipboardPaste
v-else-if="selectedVault.kind === 'text'"
:size="28"
/><Link v-else :size="28" /><strong>{{
selectedVault.note ||
selectedVault.file?.name ||
selectedVault.url ||
selectedVault.text
}}</strong
><small v-if="selectedVault.note">{{
selectedVault.file?.name || selectedVault.url
selectedVault.file?.name || selectedVault.url || selectedVault.text
}}</small
><button
v-if="!transfer"
@@ -395,11 +466,14 @@ function size(value) {
:disabled="!mutable"
@click="selectVault(item)"
>
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url }}</strong
<FileIcon v-if="item.file" :size="18" /><ClipboardPaste
v-else-if="item.kind === 'text'"
:size="18"
/><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url || item.text }}</strong
><small
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
{{ item.file ? '파일' : '링크' }}</small
{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</small
></span
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
</button>
@@ -453,7 +527,7 @@ function size(value) {
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
<Link :size="14" />대신 링크 붙여넣기</button
<Link :size="14" />대신 클립보드에서 가져오기</button
><button
v-if="vaultEnabled && source !== 'vault'"
class="text-button"

View File

@@ -28,6 +28,7 @@ export const pendingPeers = ref([]);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
export const clipboardTextLimitBytes = ref(262144);
export const spaceInviteCodeLength = ref(4);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) {
@@ -147,6 +148,7 @@ export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
device.value = snapshot.device;
peers.value = snapshot.devices;
clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144;
pendingPeers.value = pendingPeers.value.filter(
(id) => !snapshot.devices.some((peer) => peer.id === id),
);
@@ -182,7 +184,7 @@ export const refresh = createRefreshQueue(async () => {
}
});
export async function send(targetDeviceId, kind, values) {
const content = values.urls.join('\n');
const content = kind === 'text' ? values.text : values.urls.join('\n');
const { requestId } = await api(
'/transfer-requests',
'POST',
@@ -190,7 +192,7 @@ export async function send(targetDeviceId, kind, values) {
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: values.urls.length,
count: kind === 'text' ? 1 : values.urls.length,
},
crypto.randomUUID(),
);
@@ -204,7 +206,12 @@ async function deliver(id) {
if (!draft) return;
completing.add(id);
try {
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
await api(
`/transfers/${id}/complete`,
'POST',
draft.text !== undefined ? { text: draft.text } : { urls: draft.urls },
draft.key,
);
drafts.delete(id);
await refresh();
return true;

View File

@@ -0,0 +1,14 @@
// Safari/Firefox may require a native Paste confirmation. Do not trigger it on selection.
export async function mayReadClipboardAutomatically(
nav = navigator,
touch = nav.maxTouchPoints > 0 ||
(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches),
) {
try {
if (!nav.permissions?.query) return false;
const { state } = await nav.permissions.query({ name: 'clipboard-read' });
return state === 'granted' || (state === 'prompt' && !touch);
} catch {
return false;
}
}

View File

@@ -5,6 +5,8 @@ import { ref } from 'vue';
export function createDeliveryReceiver({ download, markRead, onError }) {
const pending = new Map();
const blockedLinks = ref([]);
const receivedTexts = ref([]);
const receivedLinks = ref([]);
function reserve(request) {
if (pending.has(request.id)) return;
const tabs = [];
@@ -45,13 +47,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
for (const item of items.filter((i) => i.batchId === request.batchId)) {
if (delivery.handled.has(item.accessId)) continue;
delivery.handled.add(item.accessId);
if (item.file) {
if (item.kind === 'text' && typeof item.text === 'string') {
receivedTexts.value.push(item);
} else if (item.file) {
void download(item).catch(onError);
} else if (item.url) {
const tab = delivery.tabs.shift();
if (tab && !tab.closed) {
try {
tab.location.replace(item.url);
receivedLinks.value = [item];
void markRead(item).catch(onError);
} catch {
tab.close();
@@ -70,12 +75,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
function dispose() {
for (const id of pending.keys()) abort(id);
blockedLinks.value = [];
receivedTexts.value = [];
receivedLinks.value = [];
}
return {
reserve,
abort,
reconcile,
blockedLinks,
receivedTexts,
receivedLinks,
opened,
dispose,
hasPending: () => pending.size > 0,

View File

@@ -3538,3 +3538,175 @@ a.button {
align-items: stretch;
}
}
.initial-device-loading {
display: grid;
gap: 16px;
}
.initial-device-loading p {
margin: 0;
}
.name-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.name-count {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.peer-card,
.qr-connect-card {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.single-composer input.current-url {
min-height: 44px;
height: 44px;
}
.current-text {
width: 100%;
min-height: 150px;
height: 190px;
resize: none;
padding: 12px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.clipboard-count {
display: block;
margin-top: 8px;
text-align: right;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.received-text-dialog {
width: min(560px, 100%);
max-height: calc(100dvh - 48px);
overflow: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 22px;
padding: 24px;
}
.received-text-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.received-text-dialog h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 17px;
font-weight: 600;
overflow-wrap: anywhere;
}
.received-text-dialog h2 svg {
flex-shrink: 0;
}
.received-text-preview,
.account-text-preview {
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 4;
font-family: inherit;
line-height: 1.7;
max-height: 320px;
overflow: auto;
}
.received-text-preview {
padding: 16px;
border-radius: 14px;
background: var(--soft);
margin: 20px 0;
font-size: 14px;
}
.received-text-dialog > .button {
width: 100%;
min-height: 44px;
border-radius: 24px;
}
.received-link-notice {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
width: max-content;
max-width: calc(100vw - 32px);
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
box-shadow: 0 8px 32px #0004;
font-size: 12px;
}
.received-link-notice .text-button {
font-size: 12px;
white-space: nowrap;
}
.received-link-notice > svg {
flex-shrink: 0;
}
@media (max-width: 639px) {
.received-text-dialog {
padding: 20px;
}
.received-text-dialog h2 {
font-size: 16px;
}
.received-text-preview {
max-height: 45dvh;
}
}
.current-vault strong,
.current-vault small {
white-space: pre-wrap;
max-height: 180px;
overflow: auto;
}
.vault-choice strong {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
overflow-wrap: anywhere;
}
.content-pane.clipboard-pane {
display: flex;
flex-direction: column;
gap: 12px;
}
.clipboard-pane .content-heading {
margin: 0;
flex-shrink: 0;
}
.clipboard-pane .current-text {
flex: 1;
min-height: 48px;
height: 0;
}
.clipboard-pane .current-url,
.clipboard-pane .clipboard-count,
.clipboard-pane .content-feedback,
.clipboard-pane .composer-transfer-state {
flex-shrink: 0;
margin: 0;
}

View File

@@ -338,3 +338,9 @@ Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재
모바일 기기 설정은 하단 시트로 열리고 입력창을 자동으로 선택하지 않습니다. 사용자가 입력창을 누르면 키보드가 열린 가시 영역에 맞춰 시트 높이를 조정합니다. 로그인·기기명·URL 등 입력 글자는 16px 이상으로 적용하며 화면 확대 기능 자체를 차단하지 않습니다. 파일 전송 중에는 선택한 파일명 아래에 진행률을 표시합니다.
헤더에는 로그인/보관함과 더보기만 표시합니다. **더보기 → 앱 설치 / 테마 / 서비스 정보**에서 보조 기능을 사용하세요.
## 클립보드 전송 업데이트
일반 텍스트·단일 링크 자동 판별과 텍스트 수신 미리보기·복사를 추가했습니다. 이번 변경은 기존 텍스트 컬럼을 재사용하므로 DB 마이그레이션·YAML 변경 없이 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 `.env`에 새 설정이 없어도 텍스트 한도는 256KiB입니다. 한도를 별도로 조정할 때만 `MAX_TEXT_PAYLOAD_SIZE_KB`를 변경하고 환경설정을 재배포하세요.
브라우저마다 클립보드 권한 방식이 다릅니다. 첫 읽기가 제한되어도 직접 입력하거나 ‘다시 가져오기’를 사용할 수 있습니다. ‘다시 가져오기’에서 브라우저의 붙여넣기 확인이 뜨는 것은 권한 확인 절차입니다. 기기 카드 자체의 텍스트 선택은 차단하고 입력창·받은 텍스트의 직접 선택과 붙여넣기는 유지합니다.

View File

@@ -0,0 +1,29 @@
# 클립보드 전송
2026-10-13 수정안은 기존 링크 전송을 링크·텍스트 자동 판별 방식으로 확장한다. 파일 전송, 수락 정책, 기기 발견과 보관함을 유지한다.
## 화면과 동작
- 전송 대상 기기를 선택하면 첫 클립보드 읽기를 시도한다. 브라우저가 별도 확인을 요구하는 모바일 환경이나 권한 조회를 지원하지 않는 Safari/Firefox에서는 자동 읽기를 생략하고 빈 입력창을 제공한다. 직접 입력·붙여넣기·‘다시 가져오기’는 그대로 사용할 수 있다.
- 전체 내용이 단일 HTTP/HTTPS URL이면 한 줄 입력창, 나머지는 여러 줄 텍스트 입력창을 표시한다. `www.example.com`과 일반 도메인 주소에는 전송 시 HTTPS를 붙인다. 여러 URL이나 URL을 포함한 문장은 텍스트로 보낸다. URL의 자격 증명과 HTTP/HTTPS 이외의 스킴은 자동 열기 대상으로 취급하지 않는다.
- 텍스트는 줄바꿈·탭·앞뒤 공백을 그대로 보관하고 전달한다. 화면에는 글자 수를 표시하며 최대 크기는 UTF-8 기준 **256KiB**다. 공백만 있는 내용은 보내지 않는다.
- 창 포커스·클립보드 변경으로 입력 중 내용을 바꾸지 않는다. 첫 진입과 ‘다시 가져오기’에서만 읽는다. 빈 클립보드나 읽기 실패는 현재 입력을 유지한다.
- 파일을 선택하면 클립보드 콘텐츠를 대체한다. 한 번에 파일 또는 클립보드만 보내고 전송 버튼도 하나다. 로그인 사용자는 기존 보관함 자료도 가져올 수 있다.
- 링크는 수락 후 자동으로 열고 주소 복사를 보조 동작으로 제공한다. 파일은 기존처럼 다운로드한다.
- 텍스트는 수락 후 중앙 미리보기를 표시한다. 원문은 HTML로 실행하지 않고 스크롤 가능한 영역에 보여준다. 가장 큰 버튼은 ‘클립보드에 복사’이며 복사 완료를 알린다. 읽음은 복사·닫기 동작으로 반영한다.
- 익명 화면에는 과거 전송 내역을 제공하지 않는다. 수락한 탭에서만 도착 동작을 처리하며 새로고침으로 텍스트 미리보기나 링크 열기를 재실행하지 않는다.
- 로그인 중 받은 텍스트는 기존 받은 항목에 자동 저장한다. 직접 저장·수신 기록·보관함 재전송 모두 기존 7일 만료·계정 세션 경계를 적용한다.
브라우저의 시스템 붙여넣기 안내는 웹사이트가 숨기거나 우회할 수 없다. 의도적인 클립보드 버튼 동작에서만 표시되도록 읽기 경로를 제한한다. [WebKit 설명](https://webkit.org/blog/10855/async-clipboard-api/).
## 서버와 데이터
기존 `transfer_requests.kind`와 `items.kind`에 `text`가 있고 `items.text` 컬럼도 있으므로 DB 구조 변경이나 별도 파일 생성은 필요 없다. 코드·이미지만 재빌드해 배포한다.
- `POST /api/transfer-requests`: `{ targetDeviceId, kind: "text", byteSize, count: 1 }`로 본문 없이 요청한다. 크기는 실제 UTF-8 바이트 수다.
- 기존 수락 이후 `POST /api/transfers/:id/complete`: `{ text: "원문" }`으로 확정한다. 본문 종류, 승인 크기, 발신자, 수락·만료·차단·CSRF·Origin·멱등성을 검증한다.
- 기존 `{ urls: [...] }` API와 여러 URL 항목도 유지한다. 새 클립보드 UI에서는 여러 URL을 한 텍스트로 취급한다.
- `POST /api/vault`: `{ kind: "text", text, note? }`로 직접 저장한다. 보관함 검색·복사·기기 전송도 지원한다. 외부 이메일 전송은 다시 열지 않는다.
- `MAX_TEXT_PAYLOAD_SIZE_KB=256`을 기본값으로 적용한다. 최대 설정값은 1024KiB이며 프런트엔드도 서버가 제공한 한도로 검사한다. 기존 `.env`에 값이 없으면 256KiB를 사용한다.
파일 용량은 기존 파일 저장 한도에 따라 계산한다. 텍스트는 인라인 본문 제한과 기존 요청 빈도 제한을 적용한다. 좌표·클립보드 원문을 로그나 오프라인 캐시에 추가하지 않는다.

View File

@@ -33,7 +33,7 @@
- `GET /api/inbox`: 로그인 중 수신 기록.
- `GET /api/vault`: 자신의 임시 저장 목록, 검색·페이지네이션.
- `GET /api/vault/contents`: 직접 저장·받은 항목 중 전송할 수 있는 본인 자료. 보관함 원본 조회는 계정 전환과 겹친 이전 응답을 화면에 반영하지 않는다.
- `POST /api/vault`: 링크 또는 파일 저장 요청과 선택적 메모. 링크는 즉시 확정하고 파일은 기존 인증 업로드 API를 사용한다.
- `POST /api/vault`: 링크·텍스트 또는 파일 저장 요청과 선택적 메모. 링크·텍스트는 즉시 확정하고 파일은 기존 인증 업로드 API를 사용한다.
- `PATCH /api/vault/:accessId/note`: 개인 메모 수정.
- `POST /api/vault/:accessId/send`: 선택한 기기에 기존 자료 전송 요청.
- 기존 기기 요청의 수락/거절/취소와 파일 다운로드 API는 유지한다. 새 이메일 원격 전송, 영구 즐겨찾기와 익명 자료의 사후 계정 이동은 410으로 거절한다.
@@ -49,3 +49,5 @@
- 파일은 8MiB 조각으로 전송하며 같은 파일 내용 검증·이미 받은 위치·중복 조각·서버 재시작을 처리한다. 자동 재시도 실패 시 기존 단일 버튼이 다시 전송으로 바뀐다. 완료 전후 취소와 접근 범위 검사는 유지한다.
- 운영 자동 발견은 신뢰한 인접 프록시에서 온 CF-Connecting-IP만 사용하고, 원본 IP와 프록시 진단 결과는 일반 사용자에게 공개하지 않는다. 목록 변화는 접속 이벤트로 갱신하고 대기 중 반복 탐색 문구를 표시하지 않는다. 위치 허용 기반 100m 근처 검색도 제공하며 상세 동작은 NEARBY_DISCOVERY.md를 따른다.
- PWA 설치: Android 등 설치 이벤트 지원 브라우저는 설치 창, iPhone은 홈 화면 추가 안내. 설치 앱 안에서는 버튼을 숨긴다. Service Worker는 민감한 자료나 API를 캐시하지 않고 오프라인에서는 인터넷 연결 안내만 제공한다.
2026-10-13부터 일반 텍스트도 클립보드 전송·직접 저장·수신 기록·보관함 기기 전송에 포함한다. 세부 판별과 256KiB 제한은 [클립보드 전송](CLIPBOARD_TRANSFER.md)을 따른다.

View File

@@ -121,3 +121,11 @@
- 발견 허용은 실제 체크박스 입력과 switch 역할을 유지한 Tailwind 토글로 표시한다. 키보드 포커스·비활성·동작 줄이기 상태를 지원하고 아래 구분선을 제거했다.
- 이름 저장 버튼은 ‘저장’으로 변경하고 입력창과 44px 높이·둥근 모서리를 맞췄다. PC 폼의 이전 30px 고정 높이를 없애 제목과 입력창이 겹치지 않도록 했다.
- 전체 빌드, PC/모바일 레이아웃·테마별 로고 색·기기명 유지·토글 및 방 관리·파일/링크 전송·PWA 브라우저 검증을 통과했다. PNG 크기·청록색 점 보존과 maskable의 불투명 배경·중앙 안전 영역도 확인했다. 실제 Galaxy/iPhone의 설치 아이콘 반영은 운영 재배포 후 확인한다.
## 2026-10-13 클립보드 텍스트 확장
- 기존 URL/파일 API와 DB 텍스트 컬럼을 재사용해 텍스트 요청·수락 후 확정·조회·계정 기록·보관함 저장/재전송을 확장했다. 마이그레이션이나 텍스트용 파일 생성은 없다. UTF-8 기준 기본 한도는 사용자 선택대로 256KiB다.
- API 테스트 80개 통과: 수락 전 비공개, 발신자/수신자 접근 격리, 원문과 CRLF·공백·한글·일본어·이모지 보존, 256KiB 경계, 본문 타입/크기 변경, 빈 내용, 멱등성, 7일 기록·검색·재전송·만료를 검사했다.
- 새 두 브라우저 테스트에서 단일 URL·www 주소는 링크로 열고, 일반 문장·여러 줄·여러 URL·URL 포함 문장·장문은 텍스트 미리보기와 복사로 처리했다. 미리보기 스크롤·복사 피드백·HTML 이스케이프·입력 중 포커스/클립보드 변화 보존·명시적 다시 읽기·빈/거절된 클립보드·직접 입력·URL/텍스트 전환·크기 제한을 검증했다.
- 초기 스켈레톤과 안내 문구 사이 간격, 기기명 현재/최대 40자 표시, 카드 텍스트 선택 방지와 모바일/미지원 브라우저에서 의도하지 않은 클립보드 읽기 방지를 검증했다. Safari의 네이티브 붙여넣기 확인은 공식 WebKit 동작을 참고했으며 실제 기기 확인은 운영 재배포 후 진행한다.
- 기존 복수 URL API·수락 후 자동 탭 열기·파일 자동 다운로드·101MiB 조각 업로드·계정 보관함·방 승인/내보내기·PWA·위치 자동 발견의 회귀 검증을 통과했다. 운영 Docker 이미지 빌드와 재시작 뒤 원문 유니코드 텍스트·DB·파일·미완료 조각 업로드 보존 및 이어 보내기를 확인했다.

View File

@@ -20,7 +20,8 @@
"test:e2e:layout": "node --import tsx tests/e2e/layout.mjs",
"test:e2e:chunks": "node --import tsx tests/e2e/chunks.mjs",
"test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs",
"test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs"
"test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs",
"test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs"
},
"engines": {
"node": ">=22.12"

View File

@@ -0,0 +1,36 @@
/** @param {string} value @returns {{kind: 'url', url: string} | {kind: 'text', text: string}} */
export function classifyClipboard(value) {
const candidate = value.trim();
const text = () => ({ kind: /** @type {'text'} */ ('text'), text: value });
if (!candidate || /[\r\n]/.test(value) || /\s/.test(candidate)) return text();
// Recognize a bare domain without interpreting an ordinary sentence as a URL.
const bareDomain =
/^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+[\p{L}]{2,}(?::\d{1,5})?(?:[/?#][^\s]*)?$/u.test(
candidate,
);
try {
if (!/^https?:\/\//i.test(candidate) && !bareDomain) return text();
const url = new URL(/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`);
if (
!['http:', 'https:'].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password
)
return text();
return { kind: 'url', url: url.href };
} catch {
return text();
}
}
/** @param {string} value */
export function clipboardCharacterCount(value) {
if (typeof Intl.Segmenter === 'function') {
let count = 0;
for (const _ of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value))
count++;
return count;
}
return Array.from(value).length;
}

View File

@@ -1,11 +1,13 @@
import { z } from 'zod';
export { classifyClipboard, clipboardCharacterCount } from './clipboard.js';
export const DEVICE_NAME_MAX_LENGTH = 40;
export const devicePatch = z
.object({
name: z
.string()
.trim()
.min(1)
.max(40)
.max(DEVICE_NAME_MAX_LENGTH)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v), '제어문자와 HTML 문자는 사용할 수 없습니다.')
.optional(),
discoverable: z.boolean().optional(),
@@ -25,7 +27,7 @@ export const fileMetadata = z
export const requestInput = z
.object({
targetDeviceId: z.uuid(),
kind: z.enum(['url', 'file']),
kind: z.enum(['url', 'text', 'file']),
byteSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
count: z.number().int().min(1).max(1000),
files: z.array(fileMetadata).max(1000).optional(),
@@ -36,13 +38,13 @@ export const requestInput = z
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
: !v.files && (v.kind !== 'text' || v.count === 1),
);
export const completeInput = z
.object({
urls: z.array(z.string().max(8192)).min(1).max(20),
})
.strict();
export const completeInput = z.union([
z.object({ urls: z.array(z.string().max(8192)).min(1).max(20) }).strict(),
z.object({ text: z.string().min(1).max(1048576) }).strict(),
]);
export type CompleteInput = z.infer<typeof completeInput>;
export const spaceInput = z.object({ approvalRequired: z.boolean().default(false) }).strict();
export const joinInput = z
.object({
@@ -73,7 +75,7 @@ export type TransferRequest = {
batchId: string;
senderDevice: Device | null;
recipientDevice: Device | null;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
@@ -94,8 +96,9 @@ export type Item = {
accessId: string;
id: string;
batchId: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
url: string | null;
text?: string | null;
file?: { id: string; name: string; size: number; type: string };
role: 'sender' | 'recipient' | 'self';
read: boolean;
@@ -149,7 +152,7 @@ export type AccountItem = Item & {
export type EmailRequest = {
id: string;
batchId: string;
kind: 'url' | 'file';
kind: 'url' | 'text' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
@@ -177,5 +180,5 @@ export const emailRequestInput = z
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
: !v.files && (v.kind !== 'text' || v.count === 1),
);

View File

@@ -2,6 +2,7 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"allowJs": true,
"moduleResolution": "NodeNext",
"outDir": "dist",
"declaration": true

View File

@@ -26,6 +26,8 @@ try {
'-e',
'APP_URL=https://hop.test',
'-e',
'PAIR_REQUESTS_PER_MINUTE=4',
'-e',
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
'-e',
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
@@ -47,7 +49,7 @@ try {
);
const fileState = JSON.parse(
node(
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
),
);
docker('restart', name);
@@ -59,6 +61,13 @@ try {
);
assert.equal(result.device.name, 'Docker 저장 검증');
assert.equal(result.device.id, state.id);
const preservedText = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`,
),
);
assert.equal(preservedText, fileState.text.value);
const restored = node(
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`,
);
@@ -73,7 +82,7 @@ try {
assert.equal(integrity, 'ok');
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
console.log(
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
);
} catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));

213
tests/e2e/clipboard.mjs Normal file
View File

@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import { chromium, expect } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-clipboard-'));
const runtime = await createApp({
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
PAIR_REQUESTS_PER_MINUTE: '100',
GUEST_REQUESTS_PER_MINUTE: '100',
IP_REQUESTS_PER_MINUTE: '200',
}),
webRoot: resolve('apps/web/dist'),
});
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
runtime.setDevOrigin(url);
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({
viewport: { width: 1280, height: 900 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const b = await browser.newContext({
viewport: { width: 390, height: 844 },
permissions: ['clipboard-read', 'clipboard-write'],
});
for (const context of [a, b])
await context.addInitScript(() => {
window.__clipboard = '';
window.__copied = null;
window.__readCalls = 0;
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => {
window.__readCalls++;
if (window.__denied) throw new DOMException('Denied', 'NotAllowedError');
return window.__clipboard;
},
writeText: async (value) => {
window.__copied = value;
},
},
});
});
await b.route('https://example.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Received link</title>' }),
);
await b.route('https://www.example.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<title>Received bare link</title>',
}),
);
const sender = await a.newPage(),
receiver = await b.newPage(),
errors = [];
for (const page of [sender, receiver])
page.on('pageerror', (error) => errors.push(error.message));
await Promise.all([sender.goto(url), receiver.goto(url)]);
await sender.locator('.peer-card').first().waitFor();
await receiver.locator('.peer-card').first().waitFor();
const select = async (value) => {
await sender.evaluate((value) => {
window.__clipboard = value;
}, value);
await sender.locator('.peer-card').first().click();
await expect(sender.locator('.content-submit')).toBeEnabled();
assert.equal(await sender.locator('.content-submit').count(), 1);
};
for (const [value, address] of [
['https://example.com/article', 'https://example.com/article'],
['www.example.com', 'https://www.example.com/'],
]) {
await select(value);
await expect(sender.getByLabel('보낼 URL', { exact: true })).toHaveValue(value);
assert.equal(await sender.getByLabel('보낼 URL').evaluate((el) => el.tagName), 'INPUT');
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const opened = receiver.waitForEvent('popup');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const tab = await opened;
await tab.waitForURL(address);
assert.equal(await tab.evaluate(() => window.opener), null);
await tab.close();
await receiver.getByRole('button', { name: '주소 복사', exact: true }).click();
assert.equal(await receiver.evaluate(() => window.__copied), address);
await receiver.getByRole('button', { name: '링크 수신 안내 닫기', exact: true }).click();
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
}
const values = [
'일반 문장으로 보내는 첫 번째 텍스트입니다.',
' 한글 日本語 😀👨‍👩‍👧‍👦\r\n\t두 번째 줄 \n마지막 줄\n',
'이 글에는 https://example.com/article 주소가 포함되어 있어요.',
'https://one.test/\nhttps://two.test/',
'장문을 여러 줄로 전달합니다. 日本語 😀\n'.repeat(500),
'<script>window.__clipboardXss = true</script>\n<div>HTML도 원문입니다.</div>',
];
for (const value of values) {
await select(value);
const input = sender.getByLabel('보낼 텍스트', { exact: true });
assert.equal(await input.evaluate((el) => el.tagName), 'TEXTAREA');
assert.equal(await sender.locator('.clipboard-count').count(), 1);
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
assert.equal(
await receiver.locator('.received-text-dialog').count(),
0,
'수락 전 본문을 표시하지 않음',
);
await receiver.getByRole('button', { name: '수락', exact: true }).click();
await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor();
assert.equal(await receiver.locator('.received-text-preview').textContent(), value);
assert.equal(await receiver.evaluate(() => window.__clipboardXss), undefined);
assert.equal(b.pages().length, 1, '텍스트는 새 탭을 열지 않음');
await receiver.getByRole('button', { name: '클립보드에 복사', exact: true }).click();
assert.equal(await receiver.evaluate(() => window.__copied), value);
await receiver.getByText('클립보드에 복사했어요.', { exact: true }).waitFor();
if (value.length > 1000) {
assert.equal(
await receiver
.locator('.received-text-preview')
.evaluate((el) => el.scrollHeight > el.clientHeight),
true,
);
await receiver.screenshot({ path: 'tests/e2e/clipboard-receive-mobile.png' });
}
await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click();
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
}
// Editing survives focus and clipboard changes; only explicit refresh replaces it.
await select('initial clipboard');
const input = sender.getByLabel('보낼 텍스트', { exact: true });
await input.fill('내가 작성 중인 내용');
await sender.evaluate(() => {
window.__clipboard = 'new clipboard';
window.dispatchEvent(new Event('focus'));
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(input).toHaveValue('내가 작성 중인 내용');
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await expect(input).toHaveValue('new clipboard');
await sender.evaluate(() => {
window.__clipboard = '';
});
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await expect(input).toHaveValue('new clipboard');
await sender.evaluate(() => {
window.__denied = true;
});
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await expect(input).toHaveValue('new clipboard');
// URL/textarea replacement preserves focus and accepts an explicit newline.
await input.focus();
await sender.keyboard.press('ControlOrMeta+A');
await sender.keyboard.type('https://example.com/manual');
await expect(sender.getByLabel('보낼 URL')).toHaveValue('https://example.com/manual');
await sender.keyboard.press('Enter');
await sender.keyboard.type('내용을 덧붙입니다.');
await expect(sender.getByLabel('보낼 텍스트')).toHaveValue(
'https://example.com/manual\n내용을 덧붙입니다.',
);
await sender.getByLabel('보낼 텍스트').fill('x'.repeat(256 * 1024 + 1));
await expect(sender.locator('.content-submit')).toBeDisabled();
await sender.getByLabel('보낼 텍스트').fill('');
await expect(sender.locator('.content-submit')).toBeDisabled();
assert.equal(
await sender.locator('.clipboard-pane').evaluate((el) => el.scrollTop),
0,
'입력창 포커스가 전송 영역 제목을 밀어내지 않음',
);
await sender.screenshot({ path: 'tests/e2e/clipboard-empty-desktop.png' });
await sender.getByLabel('보낼 텍스트').fill('권한이 없어도 직접 입력한 텍스트');
await expect(sender.locator('.content-submit')).toBeEnabled();
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).click();
await receiver.getByRole('dialog', { name: '받은 텍스트' }).waitFor();
assert.equal(
await receiver.locator('.received-text-preview').textContent(),
'권한이 없어도 직접 입력한 텍스트',
);
await receiver.getByRole('button', { name: '받은 텍스트 닫기' }).click();
await receiver.reload();
await receiver.locator('.peer-card').first().waitFor();
assert.equal(
await receiver.locator('.received-text-dialog').count(),
0,
'익명 재접속에 과거 텍스트를 다시 표시하지 않음',
);
assert.deepEqual(errors, []);
console.log(
'PASS: 단일 URL·www 주소, 일반 문장·여러 줄·한글/일본어/이모지·URL 포함 문장·여러 URL, 수락 전 비공개, 수신 원문 미리보기·스크롤·복사, HTML 이스케이프, 명시적 새로 읽기와 수정 보존, 빈/거절된 클립보드의 직접 입력, URL/텍스트 편집 전환, 256KiB 제한, 익명 재조회 재실행 없음.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

View File

@@ -39,12 +39,46 @@ try {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => window.__layoutClipboard ?? 'https://example.com/layout',
readText: async () => {
window.__layoutReads = (window.__layoutReads ?? 0) + 1;
return window.__layoutClipboard ?? 'https://example.com/layout';
},
writeText: async () => {},
},
});
});
let releaseStartup;
const startupGate = new Promise((resolve) => {
releaseStartup = resolve;
});
await page.route('**/api/device/snapshot', async (route) => {
await startupGate;
await route.continue();
});
await page.addInitScript(() => {
const query = navigator.permissions.query.bind(navigator.permissions);
Object.defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: (descriptor) =>
descriptor.name === 'clipboard-read' && window.__clipboardNeedsPaste
? Promise.reject(new TypeError('unsupported clipboard permission'))
: query(descriptor),
},
});
});
await page.goto(url);
await page.locator('.initial-device-loading').waitFor();
const loadingCard = await page.locator('.initial-device-loading .peer-card').boundingBox();
const loadingText = await page.locator('.initial-device-loading p').boundingBox();
assert(
loadingText.y - loadingCard.y - loadingCard.height >= 12,
'초기 안내 문구와 스켈레톤 사이 간격',
);
await page.screenshot({ path: 'tests/e2e/initial-loading.png' });
releaseStartup();
await page.locator('.initial-device-loading').waitFor({ state: 'hidden' });
await page.unroute('**/api/device/snapshot');
await page.locator('.app-shell[data-online="true"]').waitFor();
await page.locator('.wordmark .brand-logo svg').waitFor();
const letterColor = () =>
@@ -178,7 +212,36 @@ try {
);
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
}
await page.evaluate(() => {
window.__clipboardNeedsPaste = true;
});
const readsBeforeSelection = await page.evaluate(() => window.__layoutReads ?? 0);
await page.locator('.peer-card').first().click();
await page.getByLabel('보낼 텍스트', { exact: true }).waitFor();
assert.equal(
await page.evaluate(() => window.__layoutReads ?? 0),
readsBeforeSelection,
'명시적 확인이 필요한 환경에서는 카드 선택만으로 클립보드를 읽지 않음',
);
assert.equal(
await page
.locator('.peer-card')
.first()
.evaluate((el) => getComputedStyle(el).userSelect),
'none',
);
await page.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await page.waitForFunction(
() => document.querySelector('.current-url')?.value === 'https://example.com/layout',
);
assert.equal(
await page.evaluate(() => window.__layoutReads),
readsBeforeSelection + 1,
'사용자가 붙여넣기를 선택하면 클립보드를 읽음',
);
await page.evaluate(() => {
window.__clipboardNeedsPaste = false;
});
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
await sheet.waitFor();
const bounds = await sheet.boundingBox();
@@ -206,11 +269,11 @@ try {
await page.evaluate(() => {
window.__layoutClipboard = '';
});
await page.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await page.getByLabel('보낼 URL').waitFor();
await page.getByRole('button', { name: '대신 클립보드에서 가져오기', exact: true }).click();
await page.getByLabel('보낼 텍스트').waitFor();
assert.equal(await page.locator('.selected-files').count(), 0);
assert.equal(await page.locator('.content-submit').isDisabled(), true);
await page.getByLabel('보낼 URL').fill('https://example.com/manual');
await page.locator('.current-url, .current-text').fill('https://example.com/manual');
assert.equal(await page.locator('.content-submit').isEnabled(), true);
const linkBounds = await sheet.boundingBox();
assert.equal(linkBounds.height, bounds.height);
@@ -315,6 +378,7 @@ try {
assert(Math.abs(desktopInput.y - desktopSave.y) < 1, 'PC 입력창과 버튼이 같은 위치에 정렬됨');
await page.screenshot({ path: 'tests/e2e/device-settings-desktop.png' });
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
assert.equal(await page.locator('.name-count').innerText(), '9/40');
await page.getByRole('button', { name: '저장', exact: true }).click();
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
await page.locator('.footer-device-summary').click();

View File

@@ -117,8 +117,8 @@ try {
};
});
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await sender.getByLabel('보낼 URL').fill('https://example.com/nearby');
await sender.locator('.current-url, .current-text').fill('https://example.com/nearby');
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const opened = receiver.waitForEvent('popup');
void opened.catch(() => {});

View File

@@ -107,6 +107,11 @@ try {
window.__hopClipboard = 'https://example.com/focus-refresh';
window.dispatchEvent(new Event('focus'));
});
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
secret,
);
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
'https://example.com/focus-refresh',
@@ -123,7 +128,7 @@ try {
await sender.evaluate(() => {
window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
});
await sender.getByRole('button', { name: '클립보드 가져오기', exact: true }).click();
await sender.getByRole('button', { name: '다시 가져오기', exact: true }).click();
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
secret,
@@ -218,7 +223,7 @@ try {
window.__hopClipboardDenied = true;
});
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
await sender.getByLabel('보낼 텍스트', { exact: true }).waitFor();
assert.equal(await sender.locator('.clipboard-link-card').count(), 0);
const fileBytes = Buffer.from('브라우저 파일 전달 검증');
const dragged = await sender.evaluateHandle(() => {
@@ -326,16 +331,29 @@ try {
window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.';
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
assert.equal(
await second.locator('.clipboard-link-card').count(),
0,
'일반 텍스트를 링크로 제안하면 안 됨',
);
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await second.getByLabel('보낼 텍스트', { exact: true }).waitFor();
const urls = ['https://example.com/path?x=1', 'http://192.168.1.10/private'];
await second.getByRole('textbox', { name: '보낼 URL' }).fill(urls.join('\n'));
await second.getByRole('button', { name: '전송하기', exact: true }).click();
await second.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
// Old API callers may still send multiple URLs; the new clipboard UI treats these as text.
const sourceCookie = (await a.cookies(url)).find((cookie) => cookie.name === 'hop_guest');
const targetCookie = (await c.cookies(url)).find((cookie) => cookie.name === 'hop_guest');
const targetGuest = runtime.service.guest(targetCookie.value);
const legacyHeaders = {
Origin: url,
'X-CSRF-Token': runtime.service.csrf(sourceCookie.value),
'Idempotency-Key': crypto.randomUUID(),
};
const legacyRequest = await a.request.post(`${url}/api/transfer-requests`, {
headers: legacyHeaders,
data: {
targetDeviceId: runtime.service.device(targetGuest.id).id,
kind: 'url',
count: 2,
byteSize: Buffer.byteLength(urls.join('\n')),
},
});
assert.equal(legacyRequest.status(), 200);
const legacyId = (await legacyRequest.json()).requestId;
await urlReceiver.getByRole('button', { name: '수락', exact: true }).waitFor();
assert(!(await urlReceiver.locator('body').innerText()).includes(urls[0]));
await c.route('https://example.com/**', (route) =>
@@ -347,9 +365,11 @@ try {
const opened = [];
urlReceiver.on('popup', (page) => opened.push(page));
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
await second
.getByRole('button', { name: '전송 취소', exact: false })
.waitFor({ state: 'hidden' });
const legacyComplete = await a.request.post(`${url}/api/transfers/${legacyId}/complete`, {
headers: { ...legacyHeaders, 'Idempotency-Key': crypto.randomUUID() },
data: { urls },
});
assert.equal(legacyComplete.status(), 200);
assert.equal(opened.length, 2);
await Promise.all(
opened.map((page) => page.waitForURL((address) => urls.includes(address.href))),
@@ -365,8 +385,8 @@ try {
window.open = () => null;
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await second.getByRole('textbox', { name: '보낼 URL' }).fill('https://example.com/blocked');
await second.locator('.current-url, .current-text').fill('https://example.com/blocked');
await second.getByRole('button', { name: '전송하기', exact: true }).click();
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
await urlReceiver.locator('.delivery-blocked').waitFor();
@@ -405,7 +425,7 @@ try {
await second.locator('.device-name').filter({ hasText: '재연결된 Mac' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·복수 URL 전달과 일반 텍스트 무시, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·기존 복수 URL API 호환과 클립보드 입력, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
);
} catch (error) {
console.error(error);

View File

@@ -106,8 +106,8 @@ try {
await receiver.locator('.room-leave').waitFor();
await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await sender.getByLabel('보낼 URL').fill('https://example.com/received');
await sender.locator('.current-url, .current-text').fill('https://example.com/received');
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const automaticallyOpened = receiver.waitForEvent('popup');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
@@ -125,14 +125,14 @@ try {
await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click();
await sender
.getByRole('dialog', { name: '보관함에 담기' })
.getByRole('button', { name: '링크 저장', exact: true })
.getByRole('button', { name: '클립보드 저장', exact: true })
.click();
assert.equal(
await sender.getByRole('dialog', { name: '보관함에 담기' }).locator('input[type=file]').count(),
0,
);
await sender.getByLabel('한 줄 메모', { exact: true }).fill('나중에 읽을 글');
await sender.getByLabel('보낼 URL').fill('https://example.com/saved');
await sender.locator('.current-url, .current-text').fill('https://example.com/saved');
await sender.getByRole('button', { name: '저장하기', exact: true }).click();
await sender.locator('.item-card').filter({ hasText: '나중에 읽을 글' }).waitFor();
await sender.getByRole('button', { name: '메모 수정', exact: true }).click();