Add plain text clipboard transfers and refine input feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user