Make admin quotas editable and simplify uploader storage

This commit is contained in:
2026-10-15 12:51:42 +09:00
parent 4d527f74e6
commit 328fad2e36
26 changed files with 1101 additions and 205 deletions

View File

@@ -159,7 +159,7 @@ export class Accounts {
input.count > cfg.maxItems ||
(input.kind === 'url' && input.byteSize > cfg.maxUrlBytes) ||
(input.kind === 'text' && input.byteSize > cfg.maxTextBytes) ||
input.files?.some((f) => f.size > cfg.maxFileBytes)
input.files?.some((f) => f.size > this.service.admin.state.perFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
if (policy === 'request') {
@@ -555,11 +555,11 @@ export class Accounts {
.reduce((sum, row) => sum + row.bytes, 0);
return (
[...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <=
this.service.cfg.userQuota
this.service.admin.state.accountBytes
);
}
ensureQuota(userId: string, bytes: number) {
if (this.used(userId) + bytes > this.service.cfg.userQuota)
if (this.used(userId) + bytes > this.service.admin.state.accountBytes)
fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.');
}
favorite(userId: string, id: string, value: boolean) {

View File

@@ -24,6 +24,18 @@ export function settingsChanges(before, after) {
return changes;
}
function rule(value) {
if (value.includes('*')) {
// Only whole, trailing IPv4 octets may be wildcards.
if (!/^(?:\d{1,3}\.){0,3}\*(?:\.\*){0,3}$/.test(value)) throw Error('Invalid IPv4 wildcard');
const octets = value.split('.'),
first = octets.indexOf('*');
if (
octets.length !== 4 ||
octets.slice(0, first).some((part) => Number(part) > 255 || String(Number(part)) !== part)
)
throw Error('Invalid IPv4 wildcard');
value = octets.map((part) => (part === '*' ? '0' : part)).join('.') + '/' + first * 8;
}
const [address, prefix] = value.includes('/')
? ipaddr.parseCIDR(value)
: [ipaddr.parse(value), undefined];
@@ -56,6 +68,11 @@ const fields = {
uploadsPaused: z.boolean(),
maxConcurrentUploads: z.number().int().min(1).max(16),
totalStorageLimitMb: z.number().int().min(1024).max(1048576),
accountBytes: z.number().int().min(1).max(1e12),
deviceBytes: z.number().int().min(1).max(1e12),
devicesTotalBytes: z.number().int().min(1).max(1e12),
perFileBytes: z.number().int().min(1).max(1e12),
minDiskFreeBytes: z.number().int().min(0).max(1e12),
blockedCountries: z
.array(
z
@@ -115,7 +132,7 @@ export function normalizeNetworkRule(input) {
const [address, prefix] = rule(input.value);
return {
...input,
value: input.value.includes('/') ? `${address}/${prefix}` : address.toString(),
value: /[/*]/.test(input.value) ? `${address}/${prefix}` : address.toString(),
};
}
export const automaticBlockRemoval = z
@@ -136,8 +153,15 @@ export class Administration {
.from(s.adminSettings)
.where(eq(s.adminSettings.name, 'controls'))
.get();
const capacityDefaults = {
accountBytes: service.cfg.userQuota,
deviceBytes: service.cfg.guestQuota,
devicesTotalBytes: service.cfg.totalGuestQuota,
perFileBytes: service.cfg.maxFileBytes,
minDiskFreeBytes: service.cfg.minFreeBytes,
};
this.state = row
? stateSchema.parse({ ...networkDefaults, ...JSON.parse(row.value) })
? stateSchema.parse({ ...networkDefaults, ...capacityDefaults, ...JSON.parse(row.value) })
: {
revision: 0,
uploadsPaused: false,
@@ -146,6 +170,7 @@ export class Administration {
blockedCountries: [],
blockedIps: [],
...networkDefaults,
...capacityDefaults,
};
this.automatic = new AutomaticBlocks(service);
this.compile();
@@ -266,12 +291,7 @@ export class Administration {
...new Set(next.allowedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
].sort();
next.blockedIps = [
...new Set(
next.blockedIps.map((value) => {
const [address, prefix] = rule(value);
return value.includes('/') ? address.toString() + '/' + prefix : address.toString();
}),
),
...new Set(next.blockedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
].sort();
const context = visitorRegion(req, this.service.cfg),
rules = next.blockedIps.map(rule);
@@ -347,11 +367,11 @@ export class Administration {
return {
settings: { ...this.state },
limits: {
perFileBytes: this.service.cfg.maxFileBytes,
accountBytes: this.service.cfg.userQuota,
deviceBytes: this.service.cfg.guestQuota,
devicesTotalBytes: this.service.cfg.totalGuestQuota,
minDiskFreeBytes: this.service.cfg.minFreeBytes,
perFileBytes: this.state.perFileBytes,
accountBytes: this.state.accountBytes,
deviceBytes: this.state.deviceBytes,
devicesTotalBytes: this.state.devicesTotalBytes,
minDiskFreeBytes: this.state.minDiskFreeBytes,
},
storedFiles: Number(stored.count),
storedBytes: Number(stored.bytes),
@@ -419,7 +439,14 @@ export class Administration {
? {
kind: owner.kind,
id: owner.id,
name: this.service.storage.layout.ownerInfo.name(owner),
email:
owner.kind === 'user'
? (this.service.db
.select({ email: s.users.email })
.from(s.users)
.where(eq(s.users.id, owner.id))
.get()?.email ?? null)
: null,
}
: null,
lastLocation: this.service.storage.layout.ownerInfo.fileLocation(file),
@@ -659,7 +686,7 @@ export class Administration {
this.service.storage.release(request.id);
this.service.notifyTransfer(request.id);
}
const owner = this.service.storage.layout.ownerInfo.details(`user-${id}/file`);
const owner = this.service.storage.layout.ownerInfo.details(`${id}/file`);
try {
const info = this.service.storage.layout.ownerInfo;
if (owner) info.write(owner, info.read(owner).events);

View File

@@ -719,11 +719,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
service.resumable.status(resumableSubject(req), id(req)),
);
app.put('/api/uploads/resumable/:id', { bodyLimit: CHUNK_BYTES }, async (req) => {
// The resumable handler validates the offset against the accepted file manifest.
// A later change to the per-file limit must not interrupt this upload.
const offset = z.coerce
.number()
.int()
.min(0)
.max(cfg.maxFileBytes)
.max(Number.MAX_SAFE_INTEGER)
.parse(req.headers['upload-offset']);
return service.resumable.append(resumableSubject(req), id(req), offset, req.body);
});
@@ -1147,7 +1149,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
});
app.get('/api/account/storage', async (req) => ({
usedBytes: service.accounts.used(account(req, true).user.id),
limitBytes: cfg.userQuota,
limitBytes: service.admin.state.accountBytes,
}));
const webRoot = cfg.webRoot;
if (webRoot && existsSync(webRoot)) {

View File

@@ -13,15 +13,16 @@ import { readdir } from 'node:fs/promises';
import { basename, dirname, extname, resolve } from 'node:path';
import { and, eq, inArray } from 'drizzle-orm';
import * as s from './db/schema.js';
import { OwnerInfo, OWNER_INFO_NAME } from './ownerInfo.js';
import { OwnerInfo, OWNER_INFO_NAME, OWNER_FOLDER } from './ownerInfo.js';
export function fileExtension(name) {
const extension = extname(name).toLowerCase();
return /^\.[a-z0-9]{1,16}$/.test(extension) ? extension : '';
}
export function ownerDirectory(subject) {
if (!/^[a-zA-Z0-9-]{1,80}$/.test(subject.id)) throw Error('Invalid storage owner');
return `${subject.kind === 'user' ? 'user' : 'guest'}-${subject.id}`;
const match = OWNER_FOLDER.exec(subject.id);
if (!match || match[1]) throw Error('Invalid storage owner');
return subject.id;
}
export function newStorageKey(name, subject) {
return `${ownerDirectory(subject)}/${randomUUID()}${fileExtension(name)}`;
@@ -51,7 +52,7 @@ export class FileLayout {
const parts = (legacyCompleted ? key.slice('completed/'.length) : key).split('/');
if (
parts.length > (legacyCompleted ? 1 : 2) ||
(parts.length === 2 && !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(parts[0])) ||
(parts.length === 2 && !OWNER_FOLDER.test(parts[0])) ||
parts.some((name) => !name || basename(name) !== name || ['.', '..'].includes(name))
)
throw Error('Invalid stored file key');
@@ -188,7 +189,7 @@ export class FileLayout {
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (prefix || !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(entry.name)) continue;
if (prefix || !OWNER_FOLDER.test(entry.name)) continue;
for (const child of await readdir(resolve(root, entry.name), { withFileTypes: true }))
if (!child.isDirectory() && child.name !== OWNER_INFO_NAME)
yield {

View File

@@ -16,6 +16,7 @@ import * as s from './db/schema.js';
import { clientIp } from './security.js';
export const OWNER_INFO_NAME = 'owner-info.json';
export const OWNER_FOLDER = /^(?:(user|guest)-)?([a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12})$/;
export function visitorRegion(req, cfg) {
const ip = clientIp(
req.raw.socket.remoteAddress,
@@ -62,13 +63,20 @@ export class OwnerInfo {
this.layout = layout;
}
details(key) {
const match = /^((user|guest)-([a-zA-Z0-9-]{1,80}))\//.exec(key);
if (!match) return null;
const directory = key.split('/')[0],
match = OWNER_FOLDER.exec(directory);
if (!key.includes('/') || !match) return null;
const id = match[2];
const kind =
match[1] ||
(this.service.db.select({ id: s.users.id }).from(s.users).where(eq(s.users.id, id)).get()
? 'user'
: 'guest');
return {
directory: match[1],
kind: match[2],
id: match[3],
path: resolve(this.layout.originals, match[1], OWNER_INFO_NAME),
directory,
kind,
id,
path: resolve(this.layout.originals, directory, OWNER_INFO_NAME),
};
}
name(owner) {

View File

@@ -613,7 +613,7 @@ export class Service {
(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)
input.files?.some((f) => f.size > this.admin.state.perFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
const rejected = this.db

View File

@@ -126,6 +126,8 @@ export class Storage {
.where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now())))
.get(),
);
// Honor space already reserved by accepted transfers when limits are reduced.
if (!missing.length) return;
const total = missing.reduce((sum, file) => sum + file.size, 0);
if (g.kind === 'user') {
const reserved = db
@@ -135,9 +137,15 @@ export class Storage {
.get()!.bytes;
this.service.accounts.ensureQuota(g.id, reserved + total);
}
if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota)
if (
g.kind !== 'user' &&
this.used(g.id) + this.reserved(g.id) + total > this.service.admin.state.deviceBytes
)
fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.');
if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota)
if (
g.kind !== 'user' &&
this.used() + this.reserved() + total > this.service.admin.state.devicesTotalBytes
)
fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.');
const allReserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
@@ -152,7 +160,10 @@ export class Storage {
if (storedBytes + allReserved + total > this.service.admin.state.totalStorageLimitMb * 1048576)
fail(507, 'STORAGE_QUOTA', '서버의 전체 파일 저장 한도를 초과했습니다.');
const disk = statfsSync(this.originals, { bigint: true });
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
if (
BigInt(allReserved + total + this.service.admin.state.minDiskFreeBytes) >
disk.bavail * disk.bsize
)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
for (const file of missing)
db.insert(s.reservations)
@@ -245,7 +256,7 @@ export class Storage {
let bytes = 0,
count = 0;
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
limits: { files: 1, fields: 0, parts: 1, fileSize: existing.size + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
@@ -317,7 +328,7 @@ export class Storage {
let bytes = 0;
const checksum = createHash('sha256');
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
limits: { files: 1, fields: 0, parts: 1, fileSize: file!.size + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.');
@@ -327,7 +338,7 @@ export class Storage {
const counter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > file!.size || bytes > cfg.maxFileBytes)
if (bytes > file!.size)
return callback(
new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'),
);

View File

@@ -0,0 +1,366 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { statfsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import Database from 'better-sqlite3';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { Administration } from '../src/administration.js';
import { CHUNK_BYTES } from '../src/resumable.js';
async function signed(f, email) {
const client = await f.create(),
id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200, response.body);
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return client;
}
async function patch(f, admin, values) {
const response = await f.call(admin, '/admin/settings', 'PATCH', {
revision: f.service.admin.state.revision,
...values,
});
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
async function request(f, sender, receiver, bytes = 8) {
return f.call(
sender,
'/transfer-requests',
'POST',
{
targetDeviceId: receiver.deviceId,
kind: 'file',
count: 1,
byteSize: bytes,
files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }],
},
randomUUID(),
);
}
async function accept(f, receiver, response) {
assert.equal(response.statusCode, 200, response.body);
const id = response.json().requestId;
const result = await f.call(
receiver,
`/transfer-requests/${id}/accept`,
'POST',
{},
randomUUID(),
);
assert.equal(result.statusCode, 200, result.body);
return id;
}
const own = (f, sender, bytes = 8) =>
f.call(
sender,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }] },
randomUUID(),
);
async function upload(f, sender, id, bytes) {
return f.call(
sender,
`/uploads?requestId=${id}&fileIndex=0`,
'POST',
Buffer.concat([
Buffer.from(
'--quota\r\nContent-Disposition: form-data; name="file"; filename="quota.txt"\r\nContent-Type: text/plain\r\n\r\n',
),
bytes,
Buffer.from('\r\n--quota--\r\n'),
]),
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=quota' },
);
}
test('administrator limits persist, report to clients, reject invalid updates and enforce single-file/account quotas immediately', async () => {
const f = await fixture(false, undefined, { maxFileBytes: 4 });
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'quota@hop.test'),
a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT');
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
const updated = await patch(f, admin, {
perFileBytes: 16,
accountBytes: 12,
deviceBytes: 24,
devicesTotalBytes: 32,
minDiskFreeBytes: 0,
});
const restored = new Administration(f.service);
for (const key of [
'perFileBytes',
'accountBytes',
'deviceBytes',
'devicesTotalBytes',
'minDiskFreeBytes',
])
assert.equal(restored.state[key], updated[key]);
assert.equal((await f.call(user, '/account/storage')).json().limitBytes, 12);
const id = await accept(f, b, await request(f, a, b));
// Runtime increase must also override the original multipart plugin's 4-byte limit.
assert.equal((await upload(f, a, id, Buffer.from('12345678'))).statusCode, 200);
const saved = await own(f, user);
assert.equal(saved.statusCode, 200, saved.body);
assert.equal(
(await own(f, user)).json().code,
'ACCOUNT_QUOTA',
'pending reservations count toward the account limit',
);
await patch(f, admin, { accountBytes: 24 });
assert.equal((await own(f, user)).statusCode, 200);
assert.equal(
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
const list = (await f.call(admin, '/admin/files')).json().items;
const accountFile = list.find((file) => file.uploader?.email === 'quota@hop.test');
assert.ok(accountFile);
assert.equal(accountFile.path.split('/')[0], accountFile.uploader.id);
assert.equal(accountFile.uploader.kind, 'user');
assert.equal('name' in accountFile.uploader, false);
const guestFile = list.find((file) => file.uploader?.kind === 'guest');
assert.equal(guestFile.uploader.email, null);
assert.equal(guestFile.path.split('/')[0], guestFile.uploader.id);
const before = f.service.admin.state.revision;
for (const invalid of [
{ perFileBytes: 0 },
{ accountBytes: -1 },
{ deviceBytes: 1.2 },
{ devicesTotalBytes: 1e12 + 1 },
{ minDiskFreeBytes: -1 },
]) {
assert.equal(
(await f.call(admin, '/admin/settings', 'PATCH', { revision: before, ...invalid }))
.statusCode,
400,
);
assert.equal(f.service.admin.state.revision, before);
}
const audits = (await f.call(admin, '/admin/audit')).json().events;
assert.ok(
audits.some((event) =>
event.details.changes?.some(
(change) => change.field === 'perFileBytes' && change.before === 4 && change.after === 16,
),
),
);
} finally {
await f.close();
}
});
test('administrator device, combined device and minimum disk limits govern new reservations without changing existing files', async () => {
const f = await fixture();
try {
const admin = await signed(f, 'hello@noname.company'),
a = await f.create(),
b = await f.create();
await f.space(a, b);
await patch(f, admin, { deviceBytes: 4 });
let pending = await request(f, a, b);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'GUEST_QUOTA',
);
await patch(f, admin, { deviceBytes: 16, devicesTotalBytes: 4 });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'TOTAL_QUOTA',
);
await patch(f, admin, { devicesTotalBytes: 24 });
const disk = statfsSync(f.cfg.uploadDir),
free = disk.bavail * disk.bsize;
if (free + 1 <= 1e12) {
await patch(f, admin, { minDiskFreeBytes: free + 1 });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'DISK_FULL',
);
}
await patch(f, admin, { minDiskFreeBytes: 0 });
const id = await accept(f, b, pending);
await patch(f, admin, { deviceBytes: 1, devicesTotalBytes: 1, perFileBytes: 1 });
assert.equal(
(await upload(f, a, id, Buffer.from('12345678'))).statusCode,
200,
'accepted reservation survives reduced limits',
);
const item = (await f.call(b, '/device/items'))
.json()
.items.find((item) => item.file?.name === 'quota.txt');
assert.deepEqual(
(await f.call(b, `/access/${item.accessId}/download`)).rawPayload,
Buffer.from('12345678'),
);
assert.equal(f.service.admin.summary().storedFiles, 1);
} finally {
await f.close();
}
});
test('resumable offsets and completion honor the accepted manifest after reducing the file and account limits', async () => {
const f = await fixture(false, undefined, { maxFileBytes: 4 });
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'resume@hop.test');
await patch(f, admin, { perFileBytes: CHUNK_BYTES + 7, accountBytes: CHUNK_BYTES + 7 });
const response = await own(f, user, CHUNK_BYTES + 7),
bytes = Buffer.alloc(CHUNK_BYTES + 7, 120);
assert.equal(response.statusCode, 200, response.body);
const requestId = response.json().requestId,
hash = (value) => createHash('sha256').update(value).digest(),
fingerprint = hash(
Buffer.concat([hash(bytes.subarray(0, CHUNK_BYTES)), hash(bytes.subarray(CHUNK_BYTES))]),
).toString('hex');
const started = await f.call(user, '/uploads/resumable', 'POST', {
requestId,
fileIndex: 0,
fingerprint,
});
assert.equal(started.statusCode, 200, started.body);
const id = started.json().uploadId;
const put = (offset, body) =>
f.call(user, `/uploads/resumable/${id}`, 'PUT', body, undefined, {
'content-type': 'application/octet-stream',
'upload-offset': String(offset),
});
assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200);
await patch(f, admin, { perFileBytes: 1, accountBytes: 1 });
assert.equal(
(await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
200,
'offset is checked against manifest, not a stale startup limit',
);
const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {});
assert.equal(completed.statusCode, 200, completed.body);
assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
} finally {
await f.close();
}
});
test('offline transfer reset removes only transfer/guest data and keeps accounts, authentication, settings and user information', async () => {
const f = await fixture(),
folder = await mkdtemp(resolve(tmpdir(), 'hop-reset-test-'));
let database;
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'keep@hop.test');
const saved = await own(f, user);
assert.equal(
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
await patch(f, admin, { accountBytes: 3e9 });
const path = resolve(folder, 'snapshot.sqlite');
await f.sqlite.backup(path);
database = new Database(path);
const kept = [
'users',
'account_sessions',
'account_tokens',
'user_rules',
'user_activity',
'admin_settings',
'admin_audit',
'__drizzle_migrations',
];
const before = kept.map((table) =>
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
);
const script = fileURLToPath(
new URL('../../../scripts/reset-transfer-records.mjs', import.meta.url),
);
const denied = spawnSync(process.execPath, [script], {
env: { ...process.env, DATABASE_PATH: path },
encoding: 'utf8',
});
assert.notEqual(denied.status, 0);
assert.equal(database.prepare('SELECT count(*) AS count FROM files').get().count, 1);
const cleared = spawnSync(process.execPath, [script, '--confirm-reset-transfer-records'], {
env: { ...process.env, DATABASE_PATH: path },
encoding: 'utf8',
});
assert.equal(cleared.status, 0, cleared.stderr);
for (const table of [
'files',
'items',
'item_access',
'transfer_requests',
'transfer_batches',
'quota_reservations',
'file_upload_contexts',
'guest_sessions',
'browser_devices',
'idempotency_records',
])
assert.equal(
database.prepare(`SELECT count(*) AS count FROM "${table}"`).get().count,
0,
table,
);
kept.forEach((table, index) =>
assert.equal(
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
before[index],
table,
),
);
assert.deepEqual(database.pragma('foreign_key_check'), []);
assert.equal(f.service.admin.summary().storedFiles, 1, 'only isolated DB copy is reset');
} finally {
database?.close();
await f.close();
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -89,7 +89,7 @@ test('signed-in uploads use the account folder; download retains the key, origin
b = await f.create(),
userId = await login(f, a);
const value = await file(f, a, b);
assert.match(value.row.storageKey, new RegExp(`^user-${userId}/[a-f0-9-]{36}\\.jpg$`));
assert.match(value.row.storageKey, new RegExp(`^${userId}/[a-f0-9-]{36}\\.jpg$`));
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
const expiry = f.service.db
.select()
@@ -119,7 +119,7 @@ test('anonymous upload uses its guest folder even when a signed-in recipient add
b = await f.create();
await login(f, b);
const value = await file(f, a, b, 'received.mp4');
assert.equal(value.row.storageKey.split('/')[0], `guest-${value.row.ownerGuestId}`);
assert.equal(value.row.storageKey.split('/')[0], value.row.ownerGuestId);
assert.match(value.row.storageKey, /\.mp4$/);
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 200);
@@ -149,8 +149,8 @@ test('the same account on different devices shares one upload folder with unique
c.cookie += '; ' + String(loginResponse.headers['set-cookie']).split(';')[0];
const one = await file(f, a, b),
two = await file(f, c, d);
assert.equal(one.row.storageKey.split('/')[0], `user-${id}`);
assert.equal(two.row.storageKey.split('/')[0], `user-${id}`);
assert.equal(one.row.storageKey.split('/')[0], id);
assert.equal(two.row.storageKey.split('/')[0], id);
assert.notEqual(one.row.storageKey, two.row.storageKey);
assert.deepEqual(readFileSync(f.service.storage.layout.path(one.row)), one.bytes);
assert.deepEqual(readFileSync(f.service.storage.layout.path(two.row)), two.bytes);
@@ -176,7 +176,7 @@ test('old flat originals and completed files migrate to their recorded upload ow
.run();
f.service.storage.layout.prepare();
const stored = current(f, value.row);
assert.equal(stored.storageKey, `guest-${value.row.ownerGuestId}/${legacy}.png`);
assert.equal(stored.storageKey, `${value.row.ownerGuestId}/${legacy}.png`);
assert.equal(stored.id, value.row.id);
assert.equal(stored.checksum, value.row.checksum);
assert.equal(stored.updatedAt, value.row.updatedAt);
@@ -218,7 +218,7 @@ test('migration waits for all active readers before moving a legacy file', async
await once(second.stream, 'close');
assert.deepEqual(Buffer.concat(chunks), value.bytes);
f.service.storage.layout.prepare();
assert.equal(current(f, value.row).storageKey, `guest-${value.row.ownerGuestId}/${key}.jpg`);
assert.equal(current(f, value.row).storageKey, `${value.row.ownerGuestId}/${key}.jpg`);
} finally {
await f.close();
}

View File

@@ -19,7 +19,11 @@ export function urlFor(value: string) {
}
const origin = 'http://hop.test';
type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string };
export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) {
export async function fixture(
discovery = false,
mailer?: import('../src/auth.js').Mailer,
overrides: Partial<import('../src/config.js').Config> = {},
) {
const folder = await mkdtemp(join(tmpdir(), 'hop-api-'));
const cfg = {
...loadConfig({
@@ -33,6 +37,7 @@ export async function fixture(discovery = false, mailer?: import('../src/auth.js
minFreeBytes: 0,
trustedProxies: ['127.0.0.1', '::1'],
offlineGraceMs: 20,
...overrides,
};
const runtime = await createApp(cfg, { mailer });
await runtime.app.listen({ port: 0, host: '127.0.0.1' });

View File

@@ -485,3 +485,83 @@ test(
}
},
);
test('IPv4 trailing wildcards normalize to CIDR for both lists, preserve range boundaries and reject invalid patterns or operator lockout', async () => {
const f = await fixture();
try {
const admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
for (const [value, expected] of [
['203.0.113.*', '203.0.113.0/24'],
['203.0.*.*', '203.0.0.0/16'],
['203.*.*.*', '203.0.0.0/8'],
['*.*.*.*', '0.0.0.0/0'],
]) {
const response = await f.call(
admin,
'/admin/network-rules/validate',
'POST',
{ kind: 'ip', value },
undefined,
actorHeaders,
);
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().value, expected);
}
for (const value of [
'203.*.113.*',
'203.0.113.1*',
'203.0.113.* /24',
'256.0.113.*',
'203.0.*',
'203.00.113.*',
'2001:db8::*',
])
assert.equal(
(
await f.call(
admin,
'/admin/network-rules/validate',
'POST',
{ kind: 'ip', value },
undefined,
actorHeaders,
)
).statusCode,
400,
value,
);
await patch(f, admin, {
blockedIps: ['1.1.1.*', '1.1.1.0/24'],
allowedIps: ['8.8.4.*', '1.1.*.*'],
});
assert.deepEqual(f.service.admin.state.blockedIps, ['1.1.1.0/24']);
assert.deepEqual(new Administration(f.service).state.allowedIps, ['1.1.0.0/16', '8.8.4.0/24']);
for (const [ip, status] of [
['1.1.1.0', 403],
['1.1.1.255', 403],
['1.1.2.0', 200],
['1.2.0.0', 403],
['8.8.4.255', 200],
['8.8.5.0', 403],
])
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers(ip))).statusCode,
status,
ip,
);
const lockout = await f.call(
admin,
'/admin/settings',
'PATCH',
{ revision: f.service.admin.state.revision, blockedIps: ['8.8.4.*'] },
undefined,
actorHeaders,
);
assert.equal(lockout.json().code, 'ADMIN_SELF_BLOCK');
assert.deepEqual(f.service.admin.state.blockedIps, ['1.1.1.0/24']);
} finally {
await f.close();
}
});

View File

@@ -130,7 +130,7 @@ test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
const f = await fixture();
try {
f.cfg.totalGuestQuota = 4;
f.service.admin.state.devicesTotalBytes = 4;
const a = await f.create(),
b = await f.create(),
c = await f.create();
@@ -162,7 +162,7 @@ test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
const f = await fixture();
try {
f.cfg.guestQuota = 3;
f.service.admin.state.deviceBytes = 3;
const a = await f.create(),
b = await f.create();
await f.space(a, b);
@@ -173,7 +173,7 @@ test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.guestQuota = 4;
f.service.admin.state.deviceBytes = 4;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,

View File

@@ -395,7 +395,7 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
f.cfg.totalGuestQuota = 1;
f.service.admin.state.devicesTotalBytes = 1;
const bytes = Buffer.from('account file');
const response = await legacyRequest(f, a, {
email: 'bob@example.com',
@@ -407,12 +407,12 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409);
f.cfg.userQuota = bytes.length - 1;
f.service.admin.state.accountBytes = bytes.length - 1;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.userQuota = bytes.length;
f.service.admin.state.accountBytes = bytes.length;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,

View File

@@ -49,7 +49,7 @@ test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과
const f = await fixture();
try {
limits(f);
f.cfg.userQuota = 7;
f.service.admin.state.accountBytes = 7;
const sender = await f.create(),
receiver = await f.create(),
outsider = await f.create();