Clarify receive requests and add retry countdowns and device unblock controls
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user