Add plain text clipboard transfers and refine input feedback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 전송을 제공한다고 안내하지 않습니다.
|
||||
|
||||
## 업데이트와 백업
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
apps/server/src/clipboardPayload.js
Normal file
24
apps/server/src/clipboardPayload.js
Normal 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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
apps/server/test/clipboard-access.test.js
Normal file
25
apps/server/test/clipboard-access.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
207
apps/server/test/clipboard.test.js
Normal file
207
apps/server/test/clipboard.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
apps/web/src/clipboardAccess.js
Normal file
14
apps/web/src/clipboardAccess.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -338,3 +338,9 @@ Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재
|
||||
모바일 기기 설정은 하단 시트로 열리고 입력창을 자동으로 선택하지 않습니다. 사용자가 입력창을 누르면 키보드가 열린 가시 영역에 맞춰 시트 높이를 조정합니다. 로그인·기기명·URL 등 입력 글자는 16px 이상으로 적용하며 화면 확대 기능 자체를 차단하지 않습니다. 파일 전송 중에는 선택한 파일명 아래에 진행률을 표시합니다.
|
||||
|
||||
헤더에는 로그인/보관함과 더보기만 표시합니다. **더보기 → 앱 설치 / 테마 / 서비스 정보**에서 보조 기능을 사용하세요.
|
||||
|
||||
## 클립보드 전송 업데이트
|
||||
|
||||
일반 텍스트·단일 링크 자동 판별과 텍스트 수신 미리보기·복사를 추가했습니다. 이번 변경은 기존 텍스트 컬럼을 재사용하므로 DB 마이그레이션·YAML 변경 없이 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 `.env`에 새 설정이 없어도 텍스트 한도는 256KiB입니다. 한도를 별도로 조정할 때만 `MAX_TEXT_PAYLOAD_SIZE_KB`를 변경하고 환경설정을 재배포하세요.
|
||||
|
||||
브라우저마다 클립보드 권한 방식이 다릅니다. 첫 읽기가 제한되어도 직접 입력하거나 ‘다시 가져오기’를 사용할 수 있습니다. ‘다시 가져오기’에서 브라우저의 붙여넣기 확인이 뜨는 것은 권한 확인 절차입니다. 기기 카드 자체의 텍스트 선택은 차단하고 입력창·받은 텍스트의 직접 선택과 붙여넣기는 유지합니다.
|
||||
|
||||
29
docs/CLIPBOARD_TRANSFER.md
Normal file
29
docs/CLIPBOARD_TRANSFER.md
Normal 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를 사용한다.
|
||||
|
||||
파일 용량은 기존 파일 저장 한도에 따라 계산한다. 텍스트는 인라인 본문 제한과 기존 요청 빈도 제한을 적용한다. 좌표·클립보드 원문을 로그나 오프라인 캐시에 추가하지 않는다.
|
||||
@@ -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)을 따른다.
|
||||
|
||||
@@ -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·파일·미완료 조각 업로드 보존 및 이어 보내기를 확인했다.
|
||||
|
||||
@@ -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"
|
||||
|
||||
36
packages/shared/src/clipboard.js
Normal file
36
packages/shared/src/clipboard.js
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
|
||||
@@ -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
213
tests/e2e/clipboard.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user