Clarify receive requests and add retry countdowns and device unblock controls
This commit is contained in:
@@ -31,9 +31,9 @@ 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
|
||||
IP_REQUESTS_PER_MINUTE=20
|
||||
PAIR_REQUESTS_PER_MINUTE=2
|
||||
GUEST_REQUESTS_PER_MINUTE=30
|
||||
IP_REQUESTS_PER_MINUTE=120
|
||||
PAIR_REQUESTS_PER_MINUTE=10
|
||||
REJECT_COOLDOWN_SECONDS=300
|
||||
# Resend 발송 설정과 실제 인증 메일 수신을 확인한 뒤 공개 가입을 엽니다.
|
||||
REGISTRATION_MODE=closed
|
||||
|
||||
@@ -108,3 +108,5 @@ NAS의 파일 원본은 `/volume1/docker/projects/apps/hop/uploads/originals`에
|
||||
로고 원본은 `apps/web/public/logo.svg`, 아이콘 원본은 `apps/web/public/icon.svg`입니다. 헤더·서비스 정보 로고는 SVG 경로를 사용하므로 기기 기본 폰트에 영향을 받지 않습니다. 흰 글자는 테마에 맞춰 변경하고 청록색 점은 유지합니다. 아이콘 원본을 교체한 뒤 `node scripts/generate-icons.mjs`를 실행하면 192px·512px·Android maskable PNG가 다시 생성됩니다.
|
||||
|
||||
기기 이름은 서버 DB에 저장되며 동일 브라우저의 기기 세션 쿠키가 유효한 동안 유지됩니다. 쿠키를 지우거나 다른 브라우저로 접속하면 새 기기로 인식됩니다.
|
||||
|
||||
전송 제한의 기본값은 상대 10회·기기 세션 30회·같은 IP 전체 120회/분입니다. 거절 후 5분 대기는 유지하며 전송 버튼에 재시도까지 남은 초를 표시합니다. 기존 NAS `.env` 한도 변경은 [deploy.md](deploy.md)의 전송 요청 제한 안내를 따릅니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하며 차단은 확인 후 실행합니다. 푸터의 내 기기 설정에서 차단한 기기를 관리·해제할 수 있습니다. `npm run test:e2e:receive`로 새 수신·제한·차단 흐름을 검증합니다.
|
||||
|
||||
@@ -281,7 +281,23 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
code = 'INVALID_REQUEST';
|
||||
message = '요청 형식이나 크기를 확인해 주세요.';
|
||||
}
|
||||
reply.status(status).send({ code, message, requestId: req.id });
|
||||
const retryAfterSeconds =
|
||||
err instanceof ApiError && err.retryAt
|
||||
? Math.max(1, Math.ceil((err.retryAt - Date.now()) / 1000))
|
||||
: undefined;
|
||||
if (retryAfterSeconds) reply.header('Retry-After', retryAfterSeconds);
|
||||
reply.status(status).send({
|
||||
code,
|
||||
message,
|
||||
requestId: req.id,
|
||||
...(retryAfterSeconds
|
||||
? {
|
||||
retryAfterSeconds,
|
||||
retryAt: (err as ApiError).retryAt,
|
||||
limitScope: (err as ApiError).limitScope,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
const setCookie = (reply: any, token: string, expiresAt: number) =>
|
||||
reply.setCookie('hop_guest', token, {
|
||||
@@ -354,6 +370,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
service.patchDevice(guest, devicePatch.parse(req.body));
|
||||
return { device: service.device(guest.id) };
|
||||
});
|
||||
app.get('/api/device/blocks', async (req) => ({ blocks: service.listBlocks(auth(req)) }));
|
||||
app.delete('/api/device/blocks/:id', async (req) => {
|
||||
service.unblock(auth(req), id(req));
|
||||
return { ok: true };
|
||||
});
|
||||
app.post('/api/device/reset', async (req, reply) => {
|
||||
const guest = auth(req);
|
||||
service.reset(guest);
|
||||
|
||||
@@ -106,9 +106,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
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),
|
||||
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
|
||||
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
|
||||
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 30),
|
||||
ipRate: num('IP_REQUESTS_PER_MINUTE', 120),
|
||||
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 10),
|
||||
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
|
||||
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
|
||||
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
|
||||
|
||||
@@ -6,6 +6,8 @@ export class ApiError extends Error {
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
public retryAt?: number,
|
||||
public limitScope?: 'pair' | 'sender' | 'ip',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -77,13 +79,37 @@ export function publicIp(ip: string | null): boolean {
|
||||
export class Limiter {
|
||||
private windows = new Map<string, { count: number; until: number }>();
|
||||
hit(key: string, max: number, windowMs = 60000) {
|
||||
this.hitMany([{ key, max }], windowMs);
|
||||
}
|
||||
hitMany(limits: { key: string; max: number }[], windowMs = 60000) {
|
||||
const now = Date.now();
|
||||
let w = this.windows.get(key);
|
||||
if (!w || w.until <= now) {
|
||||
w = { count: 0, until: now + windowMs };
|
||||
this.windows.set(key, w);
|
||||
const windows = limits.map(({ key, max }) => {
|
||||
const previous = this.windows.get(key);
|
||||
const window =
|
||||
previous && previous.until > now ? previous : { count: 0, until: now + windowMs };
|
||||
return { key, max, window };
|
||||
});
|
||||
const exhausted = windows
|
||||
.filter(({ max, window }) => window.count >= max)
|
||||
.sort((a, b) => b.window.until - a.window.until)[0];
|
||||
if (exhausted) {
|
||||
const scope = exhausted.key.startsWith('request-pair:')
|
||||
? 'pair'
|
||||
: exhausted.key.startsWith('request-ip:')
|
||||
? 'ip'
|
||||
: 'sender';
|
||||
throw new ApiError(
|
||||
429,
|
||||
'RATE_LIMIT',
|
||||
'요청이 많아요. 잠시 후 다시 시도해 주세요.',
|
||||
exhausted.window.until,
|
||||
scope,
|
||||
);
|
||||
}
|
||||
for (const { key, window } of windows) {
|
||||
window.count++;
|
||||
this.windows.set(key, window);
|
||||
}
|
||||
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
|
||||
}
|
||||
prune() {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { DB } from './db/index.js';
|
||||
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 { fail, hash, hmac, randomToken, Limiter, ApiError } from './security.js';
|
||||
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;
|
||||
@@ -599,9 +599,6 @@ export class Service {
|
||||
input.files?.some((f) => f.size > this.cfg.maxFileBytes)
|
||||
)
|
||||
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
|
||||
this.limiter.hit(`request-ip:${ip}`, this.cfg.ipRate);
|
||||
this.limiter.hit(`request-guest:${g.id}`, this.cfg.sessionRate);
|
||||
this.limiter.hit(`request-pair:${g.id}:${target!.guestId}`, this.cfg.pairRate);
|
||||
const rejected = this.db
|
||||
.select()
|
||||
.from(s.requests)
|
||||
@@ -613,9 +610,16 @@ export class Service {
|
||||
gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(s.requests.rejectedAt))
|
||||
.get();
|
||||
if (rejected)
|
||||
fail(429, 'REJECTION_COOLDOWN', '상대가 거절했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
throw new ApiError(
|
||||
429,
|
||||
'REJECTION_COOLDOWN',
|
||||
'상대가 거절했어요. 잠시 후 다시 시도해 주세요.',
|
||||
rejected.rejectedAt! + this.cfg.rejectCooldownMs,
|
||||
'pair',
|
||||
);
|
||||
const active = this.db
|
||||
.select()
|
||||
.from(s.requests)
|
||||
@@ -629,6 +633,11 @@ export class Service {
|
||||
)
|
||||
.get();
|
||||
if (active) fail(409, 'TRANSFER_ACTIVE', '진행 중인 전송을 먼저 완료하거나 취소해 주세요.');
|
||||
this.limiter.hitMany([
|
||||
{ key: `request-ip:${ip}`, max: this.cfg.ipRate },
|
||||
{ key: `request-guest:${g.id}`, max: this.cfg.sessionRate },
|
||||
{ key: `request-pair:${g.id}:${target!.guestId}`, max: this.cfg.pairRate },
|
||||
]);
|
||||
const now = Date.now(),
|
||||
id = randomUUID();
|
||||
this.db
|
||||
@@ -752,6 +761,37 @@ export class Service {
|
||||
this.notifyRequests();
|
||||
this.refresh();
|
||||
}
|
||||
listBlocks(g: Guest) {
|
||||
return this.db
|
||||
.select({ deviceId: s.devices.id, name: s.devices.name, code: s.devices.code })
|
||||
.from(s.blocks)
|
||||
.innerJoin(s.devices, eq(s.blocks.targetGuestId, s.devices.guestId))
|
||||
.where(eq(s.blocks.guestId, g.id))
|
||||
.all();
|
||||
}
|
||||
unblock(g: Guest, targetDeviceId: string) {
|
||||
const target = this.deviceGuest(targetDeviceId);
|
||||
if (!target) return;
|
||||
this.db.transaction(() => {
|
||||
const removed = this.db
|
||||
.delete(s.blocks)
|
||||
.where(and(eq(s.blocks.guestId, g.id), eq(s.blocks.targetGuestId, target.guestId)))
|
||||
.run();
|
||||
if (removed.changes)
|
||||
this.db
|
||||
.update(s.requests)
|
||||
.set({ rejectedAt: null })
|
||||
.where(
|
||||
and(
|
||||
eq(s.requests.senderGuestId, target.guestId),
|
||||
eq(s.requests.recipientGuestId, g.id),
|
||||
eq(s.requests.status, 'rejected'),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
complete(g: Guest, id: string, input: CompleteInput) {
|
||||
const row = this.request(g, id);
|
||||
if (row.kind === 'file')
|
||||
|
||||
121
apps/server/test/transfer-controls.test.js
Normal file
121
apps/server/test/transfer-controls.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture } from './helpers.js';
|
||||
import { Limiter, ApiError } from '../src/security.js';
|
||||
|
||||
test('rate windows report their deadline; denied retries do not charge other quotas', (t) => {
|
||||
let now = 1000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
const limiter = new Limiter();
|
||||
const limits = [
|
||||
{ key: 'request-guest:a', max: 2 },
|
||||
{ key: 'request-pair:a:b', max: 1 },
|
||||
];
|
||||
limiter.hitMany(limits);
|
||||
now += 5000;
|
||||
for (let i = 0; i < 20; i++)
|
||||
assert.throws(
|
||||
() => limiter.hitMany(limits),
|
||||
(err) => {
|
||||
assert(err instanceof ApiError);
|
||||
assert.equal(err.status, 429);
|
||||
assert.equal(err.retryAt, 61000);
|
||||
assert.equal(err.limitScope, 'pair');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
limiter.hitMany([
|
||||
{ key: 'request-guest:a', max: 2 },
|
||||
{ key: 'request-pair:a:c', max: 1 },
|
||||
]);
|
||||
assert.throws(() => limiter.hit('request-guest:a', 2));
|
||||
now = 61000;
|
||||
assert.doesNotThrow(() => limiter.hitMany(limits));
|
||||
});
|
||||
|
||||
test('transfer limit returns Retry-After and cooldown metadata without creating a request', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.pairRate = 2;
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await f.request(a, b);
|
||||
assert.equal(response.statusCode, 200);
|
||||
await f.call(a, `/transfer-requests/${response.json().requestId}/cancel`, 'POST', {});
|
||||
}
|
||||
const limited = await f.request(a, b);
|
||||
assert.equal(limited.statusCode, 429);
|
||||
assert.equal(limited.json().code, 'RATE_LIMIT');
|
||||
assert.equal(limited.json().limitScope, 'pair');
|
||||
assert(Number(limited.headers['retry-after']) > 0);
|
||||
assert(Number(limited.headers['retry-after']) <= 60);
|
||||
assert.equal(Number(limited.headers['retry-after']), limited.json().retryAfterSeconds);
|
||||
assert(limited.json().retryAt > Date.now());
|
||||
const next = await f.request(a, b);
|
||||
assert.equal(next.json().retryAt, limited.json().retryAt);
|
||||
assert.equal(
|
||||
(await f.call(b, '/device/snapshot')).json().requests.filter((r) => r.status === 'pending')
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejection cooldown reports time and does not use a successful-transfer quota', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.sessionRate = 2;
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
await f.space(a, c);
|
||||
const request = await f.request(a, b);
|
||||
await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {});
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const denied = await f.request(a, b);
|
||||
assert.equal(denied.statusCode, 429);
|
||||
assert.equal(denied.json().code, 'REJECTION_COOLDOWN');
|
||||
assert.equal(denied.json().limitScope, 'pair');
|
||||
assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290);
|
||||
}
|
||||
assert.equal((await f.request(a, c)).statusCode, 200);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('block list is private; unblock only removes the current device block and never revives a request', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
const id = (await f.request(a, b)).json().requestId;
|
||||
await f.call(b, `/transfer-requests/${id}/block`, 'POST', {});
|
||||
assert.equal((await f.call(null, '/device/blocks')).statusCode, 401);
|
||||
assert.deepEqual((await f.call(a, '/device/blocks')).json().blocks, []);
|
||||
const blocks = (await f.call(b, '/device/blocks')).json().blocks;
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0].deviceId, a.deviceId);
|
||||
assert.deepEqual(Object.keys(blocks[0]).sort(), ['code', 'deviceId', 'name']);
|
||||
await f.call(c, `/device/blocks/${a.deviceId}`, 'DELETE');
|
||||
assert.equal((await f.call(b, '/device/blocks')).json().blocks.length, 1);
|
||||
assert.equal((await f.request(a, b)).statusCode, 403);
|
||||
assert.equal((await f.call(b, `/device/blocks/${a.deviceId}`, 'DELETE')).statusCode, 200);
|
||||
assert.deepEqual((await f.call(b, '/device/blocks')).json().blocks, []);
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, crypto.randomUUID()))
|
||||
.statusCode,
|
||||
409,
|
||||
);
|
||||
assert.equal((await f.request(a, b)).statusCode, 200);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
items,
|
||||
spaces,
|
||||
error,
|
||||
transferCooldown,
|
||||
bootstrap,
|
||||
api,
|
||||
refresh,
|
||||
@@ -94,6 +95,35 @@ const tab = ref('devices'),
|
||||
invite = ref(null),
|
||||
qr = ref(''),
|
||||
now = ref(Date.now());
|
||||
const blockedDevices = ref([]),
|
||||
confirmBlockId = ref(null);
|
||||
const retrySeconds = computed(() =>
|
||||
transferCooldown.value.targetDeviceId &&
|
||||
transferCooldown.value.targetDeviceId !== selected.value?.id
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.ceil((transferCooldown.value.until - Math.max(now.value, Date.now())) / 1000),
|
||||
),
|
||||
);
|
||||
const modalTitle = computed(() =>
|
||||
modal.value === 'connect'
|
||||
? connectTitle.value
|
||||
: modal.value === 'about'
|
||||
? '서비스 정보'
|
||||
: modal.value === 'blocks'
|
||||
? '차단한 기기'
|
||||
: '내 기기 설정',
|
||||
);
|
||||
function contentLabel(kind) {
|
||||
return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크';
|
||||
}
|
||||
function transferSize(bytes) {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes >= 1e5) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1)} KB`;
|
||||
return '1 KB 미만';
|
||||
}
|
||||
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
|
||||
const validJoinCode = computed(() =>
|
||||
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
|
||||
@@ -186,6 +216,10 @@ async function receiveAction(r, action) {
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestAction(r.id, action);
|
||||
if (action === 'block') {
|
||||
confirmBlockId.value = null;
|
||||
notify('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (action === 'accept') receiver.abort(r.id);
|
||||
throw error;
|
||||
@@ -563,6 +597,20 @@ async function openDeviceSettings() {
|
||||
if (!matchMedia('(max-width: 639px)').matches)
|
||||
document.querySelector('.device-settings-form input')?.focus();
|
||||
}
|
||||
async function openBlockedDevices() {
|
||||
await act(async () => {
|
||||
blockedDevices.value = (await api('/device/blocks')).blocks;
|
||||
modal.value = 'blocks';
|
||||
});
|
||||
}
|
||||
async function unblockDevice(peer) {
|
||||
await act(async () => {
|
||||
await api(`/device/blocks/${peer.deviceId}`, 'DELETE');
|
||||
blockedDevices.value = (await api('/device/blocks')).blocks;
|
||||
await refresh();
|
||||
notify('차단을 해제했어요.');
|
||||
});
|
||||
}
|
||||
async function saveName() {
|
||||
await act(async () => {
|
||||
await patchDevice({ name: nameDraft.value });
|
||||
@@ -879,20 +927,37 @@ async function spaceAction(space, action, memberId) {
|
||||
aria-label="전송 수락 요청"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
|
||||
<div class="incoming-info">
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
|
||||
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
|
||||
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
|
||||
</p>
|
||||
<div class="consent-heading">
|
||||
<div class="incoming-icon" aria-hidden="true">
|
||||
<FileIcon v-if="r.kind === 'file'" :size="28" /><ClipboardPaste
|
||||
v-else-if="r.kind === 'text'"
|
||||
:size="28"
|
||||
/><Link v-else :size="28" />
|
||||
</div>
|
||||
<div class="incoming-info">
|
||||
<h2 class="consent-kind">{{ contentLabel(r.kind) }} 전송 요청</h2>
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
<template v-if="r.kind === 'file'"
|
||||
>{{ r.count }}개 ·
|
||||
<template v-if="r.count > 1"
|
||||
>총 {{ transferSize(r.byteSize) }} ·
|
||||
</template></template
|
||||
>
|
||||
<span
|
||||
>{{
|
||||
Math.max(0, Math.ceil((r.expiresAt - Math.max(now, Date.now())) / 1000))
|
||||
}}초 남음</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="r.files" class="request-files">
|
||||
<li v-for="file in r.files" :key="file.id">
|
||||
{{ file.name }} · {{ size(file.size) }}
|
||||
<span class="request-file-name">{{ file.name }}</span
|
||||
><span class="request-file-size">{{ transferSize(file.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="incoming-actions">
|
||||
@@ -903,16 +968,31 @@ async function spaceAction(space, action, memberId) {
|
||||
>
|
||||
<Check :size="16" /> 수락</button
|
||||
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
|
||||
거절</button
|
||||
><button
|
||||
class="text-button muted"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
<div class="consent-footer">
|
||||
<template v-if="confirmBlockId === r.id"
|
||||
><span>이 기기를 차단할까요?</span
|
||||
><button class="text-button" :disabled="busy" @click="confirmBlockId = null">
|
||||
취소</button
|
||||
><button
|
||||
class="text-button block-confirm"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단하기
|
||||
</button></template
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
class="text-button block-device"
|
||||
:disabled="busy"
|
||||
@click="confirmBlockId = r.id"
|
||||
>
|
||||
이 기기 차단
|
||||
</button>
|
||||
</div>
|
||||
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -1115,6 +1195,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:clipboard-key="selectionEpoch"
|
||||
:read-clipboard-on-selection="true"
|
||||
:busy="busy"
|
||||
:retry-seconds="retrySeconds"
|
||||
:transfer="currentTransfer"
|
||||
:progress="
|
||||
Object.entries(visibleUploadProgress)
|
||||
@@ -1147,14 +1228,7 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
<section
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="
|
||||
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
|
||||
"
|
||||
>
|
||||
<section class="modal" role="dialog" aria-modal="true" :aria-label="modalTitle">
|
||||
<header>
|
||||
<div class="connect-dialog-title">
|
||||
<button
|
||||
@@ -1167,13 +1241,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
modal === 'connect'
|
||||
? connectTitle
|
||||
: modal === 'about'
|
||||
? '서비스 정보'
|
||||
: '내 기기 설정'
|
||||
}}
|
||||
{{ modalTitle }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
|
||||
@@ -1278,6 +1346,21 @@ async function spaceAction(space, action, memberId) {
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="modal === 'blocks'">
|
||||
<p v-if="!blockedDevices.length" class="empty-blocks">차단한 기기가 없어요.</p>
|
||||
<ul v-else class="blocked-device-list">
|
||||
<li v-for="peer in blockedDevices" :key="peer.deviceId">
|
||||
<div>
|
||||
<strong>{{ peer.name }}</strong
|
||||
><small>#{{ peer.code }}</small>
|
||||
</div>
|
||||
<button class="button small" :disabled="busy" @click="unblockDevice(peer)">
|
||||
차단 해제
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="text-button" @click="modal = 'device'">내 기기 설정으로 돌아가기</button>
|
||||
</template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label>
|
||||
@@ -1296,6 +1379,10 @@ async function spaceAction(space, action, memberId) {
|
||||
/> </label
|
||||
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
|
||||
</form>
|
||||
<div class="device-blocks-setting">
|
||||
<span>차단한 기기</span
|
||||
><button class="button small" :disabled="busy" @click="openBlockedDevices">관리</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = defineProps({
|
||||
targetKey: String,
|
||||
clipboardKey: Number,
|
||||
busy: Boolean,
|
||||
retrySeconds: { type: Number, default: 0 },
|
||||
disabled: Boolean,
|
||||
transfer: Object,
|
||||
progress: Array,
|
||||
@@ -290,7 +291,7 @@ function submit() {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
if (!ready.value || !mutable.value) return;
|
||||
if (!ready.value || !mutable.value || props.retrySeconds > 0) return;
|
||||
if (source.value === 'url')
|
||||
emit(
|
||||
'clipboard',
|
||||
@@ -514,10 +515,21 @@ function size(value) {
|
||||
<button
|
||||
class="button primary content-submit"
|
||||
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
|
||||
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
|
||||
:disabled="
|
||||
busy || disabled || (!transfer && retrySeconds > 0) || ((!transfer || retryable) && !ready)
|
||||
"
|
||||
@click="submit"
|
||||
>
|
||||
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
|
||||
{{
|
||||
!transfer && retrySeconds > 0
|
||||
? `${retrySeconds}초 후 전송 가능`
|
||||
: retryable
|
||||
? '다시 전송'
|
||||
: transfer
|
||||
? '전송 취소'
|
||||
: saveMode
|
||||
? '저장하기'
|
||||
: '전송하기'
|
||||
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="retryable" class="content-switches">
|
||||
|
||||
@@ -26,6 +26,7 @@ export const device = ref(null),
|
||||
discoveryEnabled = ref(false),
|
||||
error = ref('');
|
||||
export const pendingPeers = ref([]);
|
||||
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
@@ -60,11 +61,28 @@ export async function api(path, method = 'GET', body, key, signal) {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok)
|
||||
if (!response.ok) {
|
||||
const retryAfterSeconds = Number(
|
||||
payload.retryAfterSeconds ?? response.headers.get('Retry-After'),
|
||||
);
|
||||
if (
|
||||
response.status === 429 &&
|
||||
retryAfterSeconds > 0 &&
|
||||
Number.isFinite(retryAfterSeconds) &&
|
||||
method === 'POST' &&
|
||||
(path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path))
|
||||
) {
|
||||
transferCooldown.value = {
|
||||
until: Date.now() + Math.ceil(retryAfterSeconds) * 1000,
|
||||
targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null,
|
||||
};
|
||||
}
|
||||
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
@@ -154,9 +172,13 @@ export function showError(err) {
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
: raw;
|
||||
error.value = message;
|
||||
const displayed =
|
||||
err?.retryAfterSeconds > 0
|
||||
? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.`
|
||||
: message;
|
||||
error.value = displayed;
|
||||
errorTimer = setTimeout(() => {
|
||||
if (error.value === message) error.value = '';
|
||||
if (error.value === displayed) error.value = '';
|
||||
}, 5000);
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
|
||||
@@ -2419,6 +2419,123 @@ a.button {
|
||||
.consent-card .text-button {
|
||||
color: #666;
|
||||
}
|
||||
.consent-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.consent-card .incoming-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
}
|
||||
.consent-kind {
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.consent-card .incoming-info strong {
|
||||
font-size: 15px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.consent-card .incoming-info small {
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-card .incoming-info p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-card .request-files {
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 160px;
|
||||
}
|
||||
.consent-card .request-files li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.request-file-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request-file-size {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.consent-card .incoming-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.consent-card .incoming-actions .button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
.consent-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-footer .text-button {
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
.consent-footer .block-confirm {
|
||||
color: #a33;
|
||||
}
|
||||
.device-blocks-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.blocked-device-list {
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.blocked-device-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.blocked-device-list li > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.blocked-device-list strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.blocked-device-list small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-blocks {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.composer-transfer-state {
|
||||
margin: 16px 0;
|
||||
font-size: 13px;
|
||||
|
||||
18
deploy.md
18
deploy.md
@@ -442,3 +442,21 @@ pull_policy: build
|
||||
가입 비밀번호는 최소 8자입니다. 인증 메일의 링크를 열고 **이메일 인증 완료하기**를 누르면 해당 계정으로 자동 로그인하며 보관함의 받은 항목을 표시합니다. 다른 계정으로 로그인한 브라우저에서는 이전 세션을 종료하고 인증한 계정으로 전환합니다. 다른 기기의 로그인은 유지합니다. 만료·재사용된 링크로는 현재 로그인을 변경하지 않습니다.
|
||||
|
||||
이번 인증 변경은 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 계정·파일은 유지하고 YAML·환경변수·DB 구조는 변경하지 않습니다. 기존 DB·업로드를 초기화하지 않습니다.
|
||||
|
||||
## 전송 요청 제한과 차단 해제
|
||||
|
||||
새 기본 한도는 같은 상대 10회/분, 기기 세션 전체 30회/분, 같은 IP 전체 120회/분입니다. 로그인 중 기기 전송에도 같은 기준을 적용합니다. 파일 개수가 아닌 전송 요청 횟수를 계산하며, 거절·취소한 전송 요청도 이미 사용한 횟수에 포함됩니다. 제한된 재시도나 진행 중 전송 때문에 거절된 요청은 다른 한도를 추가로 소모하지 않습니다.
|
||||
|
||||
제한 창은 첫 허용 요청부터 60초이며 반복 재시도로 종료 시간이 연장되지 않습니다. 상대가 거절하면 별도로 기본 300초 동안 그 상대에게 재요청할 수 없습니다. 서버는 `Retry-After`와 남은 시간을 반환하고, 전송 버튼에 카운트를 표시합니다. 대기 중에도 입력·파일 선택은 유지하며 시간이 끝나면 전송 버튼이 다시 활성화됩니다.
|
||||
|
||||
**기존 NAS `.env`에 이전 기본값이 있으면 한 번 아래 명령으로 그 값만 변경합니다. 직접 조정한 다른 값과 비밀값은 유지합니다.** hop 폴더에서 실행하세요:
|
||||
|
||||
```sh
|
||||
sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/^IP_REQUESTS_PER_MINUTE=20$/IP_REQUESTS_PER_MINUTE=120/' -e 's/^PAIR_REQUESTS_PER_MINUTE=2$/PAIR_REQUESTS_PER_MINUTE=10/' .env
|
||||
```
|
||||
|
||||
이후 Git pull과 같은 `docker-compose.yaml`로 빌드·재배포합니다. YAML이나 DB 구조 변경은 없습니다. 명시적인 `.env` 값이 새 기본값보다 우선합니다.
|
||||
|
||||
수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다.
|
||||
|
||||
수신 파일 용량은 0.1MB 이상부터 MB, 1GB 이상부터 GB로 표시합니다. 그보다 작은 파일은 KB 또는 ‘1 KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 단위는 MB=1,000,000바이트, GB=1,000,000,000바이트입니다. 서버의 MiB/GiB 기준 저장·파일 한도는 유지합니다.
|
||||
|
||||
@@ -160,3 +160,10 @@
|
||||
- Git에서 관리하는 `docker-compose.yaml` 하나로 통합했다. 중복 설정 파일을 제거하고 설정·자동 발견 스크립트와 배포 문서에서 같은 파일을 사용한다. 초기 설정은 `.env`와 저장 권한만 준비하며 Compose 파일의 내용을 변경하지 않는다.
|
||||
- 현재 NAS 값으로 이전 설정과 새 파일을 실제 Docker Compose로 해석해 전체 설정이 동일한지 비교했다. 프로젝트 `hop`, 컨테이너 `hop`, 네트워크 `hop_hop_network`, DB `hop_hop_database`, 사용자 `1000:10`, 포트 `192.168.50.146:50189:50189`, 빌드 정책, 소스·환경파일·업로드 경로와 재시작·로그 설정이 동일했다. 새 파일은 `name: hop`으로 별도 `-p` 없이 기존 프로젝트를 지정한다.
|
||||
- NAS 설정 검증에서 Compose 파일 미변경, 비밀값 유지, 환경파일 권한, 기존 저장소 식별자, `nocopy`, 실행 중 권한 변경 거절을 확인했다. 기존 운영 이미지로 격리한 실제 Compose 컨테이너를 시작해 health, UID/GID, 임시 DB 볼륨과 업로드 바인드 연결을 확인하고 테스트 자원을 정리했다. 셸 문법·포맷 검사도 통과했다. 운영 NAS의 컨테이너·볼륨·자료에는 접근하거나 재배포하지 않았다.
|
||||
|
||||
## 2026-10-14 전송 제한 안내와 수신 화면
|
||||
|
||||
- 기본 전송 한도를 상대 10회·기기 세션 30회·IP 120회/분으로 확대했다. 거절 후 300초 대기는 유지한다. 제한 응답에 `Retry-After`와 남은 시간을 제공하고 전송 버튼에 카운트를 표시하며 대기 중 입력·파일 선택을 유지한다. 여러 제한을 함께 확인하여 제한된 재시도가 다른 한도를 소모하거나 기존 창의 종료 시간을 연장하지 않도록 했다. 기존 NAS의 명시적인 `.env` 값은 유지하므로 이전 기본값만 변경하는 명령을 deploy.md에 기록했다.
|
||||
- 수신 카드에 유형별 아이콘·제목, 수락/거절 2:1 버튼, 오른쪽 하단 차단 확인을 적용했다. 현재 기기만 자신의 차단 목록을 조회·해제할 수 있으며 해제해도 이전 요청은 되살리지 않는다. 수신 파일 목록의 기본 들여쓰기를 제거하고 0.1MB 이상은 MB·1GB 이상은 GB로 표시하며 한 파일의 용량을 중복 표시하지 않는다. 기기 이름 관련 안내 문구는 제거했다.
|
||||
- API 102개 통과. 새 검증에서 실제 제한 응답의 종료 시간·헤더, 반복 재시도의 다른 한도 보존, 거절 대기, 차단 목록 비공개·다른 기기의 해제 불가·해제 후 재전송과 기존 요청 재수락 불가를 확인했다. 전체 빌드·타입 검사와 기존 파일/링크·클립보드·반응형 레이아웃 브라우저 검증도 통과했다.
|
||||
- 새 실제 Chromium 검증에서 모바일/PC 버튼 비율, 파일 용량 0.1/1.3MB·1.7GB, 파일 목록 여백·가로 넘침 없음, 초안 유지·카운트 종료 후 재활성화, 차단 확인 취소·실행·관리 화면 해제를 확인하고 스크린샷을 확인했다. 브라우저 카운트 검사는 3초 제한 응답을 주입했고 실제 60초 제한·서버 권한은 API 테스트로 검증했다. NAS 재배포와 실물 Safari 검증은 수행하지 않았다.
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"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:clipboard": "node --import tsx tests/e2e/clipboard.mjs"
|
||||
"test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs",
|
||||
"test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
|
||||
@@ -281,7 +281,7 @@ try {
|
||||
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
|
||||
await page.getByRole('button', { name: '전송 화면 닫기' }).click();
|
||||
await page.locator('.footer-device-summary').click();
|
||||
const settings = page.getByRole('dialog', { name: '설정', exact: true });
|
||||
const settings = page.getByRole('dialog', { name: '내 기기 설정', exact: true });
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('기기 이름', { exact: true })
|
||||
@@ -380,7 +380,7 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
await sheet.waitFor({ state: 'hidden' });
|
||||
await page.locator('.footer-device-summary').click();
|
||||
const deviceModal = page.getByRole('dialog', { name: '설정', exact: true });
|
||||
const deviceModal = page.getByRole('dialog', { name: '내 기기 설정', exact: true });
|
||||
const titleBounds = await deviceModal.locator('header').boundingBox();
|
||||
const formBounds = await deviceModal.locator('.device-settings-form').boundingBox();
|
||||
assert(
|
||||
|
||||
205
tests/e2e/receive-controls.mjs
Normal file
205
tests/e2e/receive-controls.mjs
Normal file
@@ -0,0 +1,205 @@
|
||||
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-receive-controls-'));
|
||||
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 } }),
|
||||
b = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const sender = await a.newPage(),
|
||||
receiver = await b.newPage(),
|
||||
errors = [];
|
||||
sender.setDefaultTimeout(10000);
|
||||
receiver.setDefaultTimeout(10000);
|
||||
for (const page of [sender, receiver]) page.on('pageerror', (err) => errors.push(err.message));
|
||||
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
await receiver.locator('.peer-card').first().waitFor();
|
||||
async function api(page, path, method = 'GET', body) {
|
||||
return page.evaluate(
|
||||
async ({ path, method, body }) => {
|
||||
if (!window.__testCsrf) {
|
||||
const s = await fetch('/api/guest/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
window.__testCsrf = (await s.json()).csrfToken;
|
||||
}
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.__testCsrf,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!response.ok) throw Error(`${response.status}: ${await response.text()}`);
|
||||
return response.json();
|
||||
},
|
||||
{ path, method, body },
|
||||
);
|
||||
}
|
||||
const recipientId = (await api(receiver, '/device/me')).device.id;
|
||||
async function request(kind, size = 10) {
|
||||
return (
|
||||
await api(sender, '/transfer-requests', 'POST', {
|
||||
targetDeviceId: recipientId,
|
||||
kind,
|
||||
byteSize: size,
|
||||
count: 1,
|
||||
...(kind === 'file'
|
||||
? {
|
||||
files: [
|
||||
{
|
||||
name: 'Screenshot_20261012_152625_Samsung Browser.jpg',
|
||||
size,
|
||||
type: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
).requestId;
|
||||
}
|
||||
for (const [kind, size, label] of [
|
||||
['text', 10, '텍스트'],
|
||||
['url', 20, '링크'],
|
||||
['file', 1333196, '파일'],
|
||||
['file', 100000, '파일'],
|
||||
['file', 1700000000, '파일'],
|
||||
]) {
|
||||
const id = await request(kind, size);
|
||||
const card = receiver.getByRole('dialog', { name: '전송 수락 요청' });
|
||||
await expect(card.locator('.consent-kind')).toHaveText(`${label} 전송 요청`);
|
||||
assert.equal(await card.locator('.request-note').count(), 0);
|
||||
assert.equal(await card.locator('.incoming-icon svg').count(), 1);
|
||||
const ratio = await card.evaluate((el) => {
|
||||
const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(),
|
||||
b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect();
|
||||
return a.width / b.width;
|
||||
});
|
||||
assert(Math.abs(ratio - 2) < 0.02);
|
||||
assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0);
|
||||
if (kind === 'file') {
|
||||
await expect(card.locator('.request-file-size')).toHaveText(
|
||||
size === 1700000000 ? '1.7 GB' : size === 100000 ? '0.1 MB' : '1.3 MB',
|
||||
);
|
||||
const spacing = await card.locator('.request-files').evaluate((el) => ({
|
||||
padding: getComputedStyle(el).paddingLeft,
|
||||
margin: getComputedStyle(el).marginLeft,
|
||||
overflow: el.scrollWidth > el.clientWidth,
|
||||
}));
|
||||
assert.deepEqual(spacing, { padding: '0px', margin: '0px', overflow: false });
|
||||
assert(
|
||||
!(await card
|
||||
.locator('.incoming-info p')
|
||||
.textContent()
|
||||
.then((v) => /바이트|MB|GB/.test(v))),
|
||||
'single file size is shown once',
|
||||
);
|
||||
if (size === 1333196)
|
||||
await receiver.screenshot({ path: 'tests/e2e/receive-file-mobile.png' });
|
||||
}
|
||||
if (kind === 'text') {
|
||||
await receiver.setViewportSize({ width: 1280, height: 900 });
|
||||
const desktop = await card.evaluate((el) => {
|
||||
const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(),
|
||||
b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect();
|
||||
return a.width / b.width;
|
||||
});
|
||||
assert(Math.abs(desktop - 2) < 0.02);
|
||||
await receiver.screenshot({ path: 'tests/e2e/receive-text-desktop.png' });
|
||||
await receiver.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
await api(sender, `/transfer-requests/${id}/cancel`, 'POST', {});
|
||||
await card.waitFor({ state: 'hidden' });
|
||||
}
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender
|
||||
.getByLabel('보낼 텍스트', { exact: true })
|
||||
.fill('제한이 걸려도 이 초안은 유지됩니다.');
|
||||
await sender.route('**/api/transfer-requests', (route) =>
|
||||
route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Retry-After': '3' },
|
||||
body: JSON.stringify({
|
||||
code: 'RATE_LIMIT',
|
||||
message: '전송 요청이 많아요.',
|
||||
retryAfterSeconds: 3,
|
||||
limitScope: 'pair',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
const submit = sender.locator('.content-submit');
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(submit).toHaveText(/\d+초 후 전송 가능/);
|
||||
await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toBeEnabled();
|
||||
await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toHaveValue(
|
||||
'제한이 걸려도 이 초안은 유지됩니다.',
|
||||
);
|
||||
await expect(submit).toHaveText('전송하기', { timeout: 5500 });
|
||||
await expect(submit).toBeEnabled();
|
||||
await sender.unroute('**/api/transfer-requests');
|
||||
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
|
||||
await request('text');
|
||||
const card = receiver.getByRole('dialog', { name: '전송 수락 요청' });
|
||||
await card.getByRole('button', { name: '이 기기 차단', exact: true }).click();
|
||||
assert.equal(
|
||||
(await api(receiver, '/device/blocks')).blocks.length,
|
||||
0,
|
||||
'first click does not block',
|
||||
);
|
||||
await card.locator('.consent-footer').getByRole('button', { name: '취소', exact: true }).click();
|
||||
await card.getByRole('button', { name: '이 기기 차단', exact: true }).click();
|
||||
await card.getByRole('button', { name: '차단하기', exact: true }).click();
|
||||
await card.waitFor({ state: 'hidden' });
|
||||
await expect(receiver.locator('.peer-card')).toHaveCount(0);
|
||||
await receiver.getByRole('button', { name: '내 기기 설정', exact: true }).click();
|
||||
await receiver.getByRole('button', { name: '관리', exact: true }).click();
|
||||
const blocked = receiver.getByRole('dialog', { name: '차단한 기기', exact: true });
|
||||
await expect(blocked.locator('.blocked-device-list li')).toHaveCount(1);
|
||||
await blocked.getByRole('button', { name: '차단 해제', exact: true }).click();
|
||||
await expect(blocked.getByText('차단한 기기가 없어요.')).toBeVisible();
|
||||
await expect(receiver.locator('.peer-card')).toHaveCount(1);
|
||||
assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: content icons/titles, desktop/mobile 2:1 buttons, single file sizes 0.1/1.3MB and 1.7GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user