Add verified administrator controls and upload abuse limits

This commit is contained in:
2026-10-14 20:37:55 +09:00
parent c3573b605c
commit 3786baf60c
23 changed files with 1941 additions and 22 deletions

View File

@@ -0,0 +1,16 @@
CREATE TABLE `admin_settings` (
`name` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updatedAt` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `admin_audit` (
`id` text PRIMARY KEY NOT NULL,
`actorUserId` text,
`action` text NOT NULL,
`details` text NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`actorUserId`) REFERENCES `users`(`id`) ON DELETE SET NULL
);
--> statement-breakpoint
CREATE INDEX `admin_audit_created` ON `admin_audit` (`createdAt`);

View File

@@ -85,6 +85,13 @@
"when": 1791771741603,
"tag": "0011_single-room-index",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1791973081784,
"tag": "0012_admin_controls",
"breakpoints": true
}
]
}
}

View File

@@ -134,6 +134,7 @@ export class Accounts {
) {
const { db, cfg, limiter } = this.service;
this.verified(userId);
if (input.kind === 'file') this.service.admin.assertNewUploads();
limiter.hit(`remote-target:${userId}`, 30, 3600000);
const recipient = db
.select()

View File

@@ -0,0 +1,352 @@
import { randomUUID } from 'node:crypto';
import { statfsSync } from 'node:fs';
import { unlink } from 'node:fs/promises';
import ipaddr from 'ipaddr.js';
import { z } from 'zod';
import { and, eq, gt, lt, or, desc, inArray, sql } from 'drizzle-orm';
import * as s from './db/schema.js';
import { ApiError, fail, normalizeIp } from './security.js';
import { visitorRegion } from './ownerInfo.js';
export const ADMIN_EMAIL = 'hello@noname.company';
function rule(value) {
const [address, prefix] = value.includes('/')
? ipaddr.parseCIDR(value)
: [ipaddr.parse(value), undefined];
if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) {
if (prefix !== undefined && prefix < 96) throw Error('Invalid mapped prefix');
return [address.toIPv4Address(), prefix === undefined ? 32 : prefix - 96];
}
return [address, prefix ?? (address.kind() === 'ipv4' ? 32 : 128)];
}
function validRule(value) {
try {
rule(value);
return true;
} catch {
return false;
}
}
export function matchesIp(ip, rules) {
if (!ip) return false;
try {
const address = ipaddr.process(ip);
return rules.some(
([network, prefix]) => address.kind() === network.kind() && address.match(network, prefix),
);
} catch {
return false;
}
}
const fields = {
uploadsPaused: z.boolean(),
maxConcurrentUploads: z.number().int().min(1).max(16),
totalStorageLimitMb: z.number().int().min(1024).max(1048576),
blockedCountries: z
.array(
z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z]{2}$/)
.refine((code) => code !== 'XX'),
)
.max(250),
blockedIps: z.array(z.string().trim().max(100).refine(validRule)).max(100),
};
const stateSchema = z.object({ revision: z.number().int().min(0), ...fields }).strict();
export const adminPatch = z
.object(fields)
.partial()
.extend({ revision: z.number().int().min(0) })
.strict()
.refine((body) => Object.keys(body).length > 1, '변경할 설정이 필요합니다.');
export class Administration {
constructor(service) {
this.service = service;
this.inflight = new Map();
const row = service.db
.select()
.from(s.adminSettings)
.where(eq(s.adminSettings.name, 'controls'))
.get();
this.state = row
? stateSchema.parse(JSON.parse(row.value))
: {
revision: 0,
uploadsPaused: false,
maxConcurrentUploads: service.cfg.maxConcurrentUploads,
totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576,
blockedCountries: [],
blockedIps: [],
};
this.compile();
}
compile() {
this.ipRules = this.state.blockedIps.map(rule);
this.countries = new Set(this.state.blockedCountries);
}
isAdmin(user) {
return Boolean(user?.status === 'active' && user.verifiedAt && user.email === ADMIN_EMAIL);
}
require(token) {
const current = this.service.auth.require(token, true);
if (!this.isAdmin(current.user))
fail(403, 'ADMIN_REQUIRED', '관리자 계정만 사용할 수 있습니다.');
return current.user;
}
blocked(req) {
const context = visitorRegion(req, this.service.cfg);
return (
matchesIp(context.ip ?? normalizeIp(req.raw.socket.remoteAddress ?? ''), this.ipRules) ||
Boolean(context.countryCode && this.countries.has(context.countryCode))
);
}
assertAllowed(req) {
if (this.blocked(req))
fail(403, 'ACCESS_BLOCKED', '이 접속은 서비스 이용이 제한되어 있습니다.');
}
assertNewUploads() {
if (this.state.uploadsPaused)
throw new ApiError(
503,
'UPLOADS_PAUSED',
'새 파일 업로드를 잠시 중지했습니다. 나중에 다시 시도해 주세요.',
);
}
admit(req, reply, actor) {
if (
this.inflight.size >= this.state.maxConcurrentUploads ||
[...this.inflight.values()].filter((value) => value.actor === actor).length >= 2
)
throw new ApiError(
429,
'UPLOAD_BUSY',
'서버가 다른 파일을 처리하고 있어요. 잠시 후 다시 시도해 주세요.',
Date.now() + 1000,
'sender',
);
const key = Symbol('upload'),
context = visitorRegion(req, this.service.cfg);
this.inflight.set(key, {
actor,
startedAt: Date.now(),
ip: context.ip,
countryCode: context.countryCode,
raw: req.raw,
response: reply.raw,
});
const release = () => {
this.inflight.delete(key);
reply.raw.off('finish', release);
reply.raw.off('close', release);
req.raw.off('aborted', release);
};
reply.raw.once('finish', release);
reply.raw.once('close', release);
req.raw.once('aborted', release);
return release;
}
audit(actor, action, details) {
this.service.db
.insert(s.adminAudit)
.values({
id: randomUUID(),
actorUserId: actor.id,
action,
details: JSON.stringify(details),
createdAt: Date.now(),
})
.run();
}
patch(actor, input, req) {
if (input.revision !== this.state.revision)
fail(
409,
'ADMIN_SETTINGS_CHANGED',
'다른 화면에서 설정이 변경됐어요. 새로고침 후 다시 저장해 주세요.',
);
const next = stateSchema.parse({ ...this.state, ...input, revision: this.state.revision + 1 });
next.blockedCountries = [...new Set(next.blockedCountries)].sort();
next.blockedIps = [
...new Set(
next.blockedIps.map((value) => {
const [address, prefix] = rule(value);
return value.includes('/') ? address.toString() + '/' + prefix : address.toString();
}),
),
].sort();
const context = visitorRegion(req, this.service.cfg),
rules = next.blockedIps.map(rule);
if (
(context.countryCode && next.blockedCountries.includes(context.countryCode)) ||
matchesIp(context.ip, rules) ||
matchesIp(normalizeIp(req.raw.socket.remoteAddress ?? ''), rules)
)
fail(400, 'ADMIN_SELF_BLOCK', '현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.');
this.service.db.transaction(() => {
this.service.db
.insert(s.adminSettings)
.values({ name: 'controls', value: JSON.stringify(next), updatedAt: Date.now() })
.onConflictDoUpdate({
target: s.adminSettings.name,
set: { value: JSON.stringify(next), updatedAt: Date.now() },
})
.run();
this.audit(actor, 'settings-change', {
uploadsPaused: next.uploadsPaused,
maxConcurrentUploads: next.maxConcurrentUploads,
totalStorageLimitMb: next.totalStorageLimitMb,
blockedCountries: next.blockedCountries,
blockedIps: next.blockedIps,
});
});
this.state = next;
this.compile();
for (const entry of this.inflight.values())
if (
matchesIp(entry.ip ?? normalizeIp(entry.raw.socket.remoteAddress ?? ''), this.ipRules) ||
(entry.countryCode && this.countries.has(entry.countryCode))
) {
entry.raw.emit('aborted');
entry.raw.destroy();
entry.response.destroy();
}
for (const socket of this.service.io?.sockets.sockets.values() ?? [])
if (this.blocked({ raw: socket.request, headers: socket.request.headers })) {
socket.emit('service:blocked');
socket.disconnect(true);
}
return { ...this.state };
}
summary() {
const db = this.service.db;
const stored = db
.select({ count: sql`count(*)`, bytes: sql`coalesce(sum(${s.files.size}),0)` })
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.get();
const reserved = db
.select({ bytes: sql`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now()))
.get();
const accounts = db
.select({ count: sql`count(*)` })
.from(s.users)
.where(eq(s.users.status, 'active'))
.get();
const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true });
return {
settings: { ...this.state },
storedFiles: Number(stored.count),
storedBytes: Number(stored.bytes),
reservedBytes: Number(reserved.bytes),
diskFreeBytes: Number(disk.bavail * disk.bsize),
activeAccounts: Number(accounts.count),
activeRequests: this.inflight.size,
activeFileWrites: this.service.storage.activeUploadCount,
requests: [...this.inflight.values()].map(({ actor, raw, response, ...value }) => value),
};
}
/** @param {{limit?:number,cursor?:string}} input */
files({ limit = 50, cursor }) {
let before;
if (cursor) {
try {
before = z
.object({ time: z.number().int().min(0), id: z.uuid() })
.strict()
.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')));
} catch {
fail(400, 'INVALID_CURSOR', '목록 위치를 확인할 수 없습니다.');
}
}
const rows = this.service.db
.select()
.from(s.files)
.where(
and(
inArray(s.files.status, ['stored', 'cleaning']),
before
? or(
lt(s.files.updatedAt, before.time),
and(eq(s.files.updatedAt, before.time), lt(s.files.id, before.id)),
)
: undefined,
),
)
.orderBy(desc(s.files.updatedAt), desc(s.files.id))
.limit(limit + 1)
.all();
const page = rows.slice(0, limit),
last = page.at(-1);
const items = page
.filter((file) => this.service.storage.availability.check(file))
.map((file) => {
const owner = this.service.storage.layout.ownerInfo.details(file.storageKey),
events = owner ? this.service.storage.layout.ownerInfo.read(owner).events : [];
const expiry = this.service.db
.select({ at: sql`max(${s.items.expiresAt})` })
.from(s.items)
.where(eq(s.items.fileId, file.id))
.get().at;
return {
id: file.id,
name: file.name,
size: file.size,
status: file.status,
createdAt: file.updatedAt,
expiresAt: expiry,
path: file.storageKey,
uploader: owner
? {
kind: owner.kind,
id: owner.id,
name: this.service.storage.layout.ownerInfo.name(owner),
}
: null,
lastLocation: events.at(-1) ?? null,
};
});
return {
items,
nextCursor:
rows.length > limit && last
? Buffer.from(JSON.stringify({ time: last.updatedAt, id: last.id })).toString('base64url')
: null,
};
}
async deleteFile(actor, id) {
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
if (!file) fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
if (['pending', 'uploading'].includes(file.status))
fail(409, 'UPLOAD_ACTIVE', '업로드 중인 파일은 완료 후 삭제해 주세요.');
try {
await unlink(this.service.storage.layout.path(file));
} catch (error) {
if (!['ENOENT', 'ENOTDIR'].includes(error.code))
fail(503, 'STORAGE_UNAVAILABLE', '파일 저장소에 접근할 수 없습니다.');
}
this.service.storage.availability.missing(file.id);
this.audit(actor, 'file-delete', { fileId: id, name: file.name, path: file.storageKey });
await this.service.storage.layout.ownerInfo.cleanup();
return { ok: true };
}
events() {
return this.service.db
.select()
.from(s.adminAudit)
.orderBy(desc(s.adminAudit.createdAt), desc(s.adminAudit.id))
.limit(50)
.all()
.map((event) => ({ ...event, details: JSON.parse(event.details) }));
}
cleanup() {
this.service.db
.delete(s.adminAudit)
.where(lt(s.adminAudit.createdAt, Date.now() - this.service.cfg.auditDays * 86400000))
.run();
}
}

View File

@@ -31,6 +31,7 @@ import { openDatabase } from './db/index.js';
import { Service, type Guest } from './service.js';
import { Presence } from './presence.js';
import { visitorRegion } from './ownerInfo.js';
import { adminPatch } from './administration.js';
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
import { CHUNK_BYTES } from './resumable.js';
@@ -45,6 +46,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
: false,
logController: new PrivateLogs(),
trustProxy: false,
requestTimeout: 300000,
bodyLimit: cfg.maxUrlBytes * 6 + 16384,
});
await app.register(cookie);
@@ -72,7 +74,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
// cookie authentication and the session-bound CSRF token are also required by io.use.
const sameOriginFetch =
!req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin';
callback(null, allowed(req.headers.origin) || sameOriginFetch);
callback(
null,
(allowed(req.headers.origin) || sameOriginFetch) &&
!service.admin.blocked({ raw: req, headers: req.headers }),
);
},
});
const presence = new Presence(io, cfg, () => service.refresh());
@@ -82,6 +88,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
service.accounts = new Accounts(service);
io.use((socket, next) => {
try {
service.admin.assertAllowed({ raw: socket.request, headers: socket.request.headers });
const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest;
const guest = service.guest(token);
if (!guest || socket.handshake.auth.csrfToken !== service.csrf(token!))
@@ -246,6 +253,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
);
service.admin.assertAllowed(req);
if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store');
const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable');
@@ -253,6 +261,8 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
`${resumable ? 'chunk' : 'api'}-ip:${ip(req) ?? req.raw.socket.remoteAddress}`,
resumable ? 6000 : 240,
);
if (req.url.split('?')[0].startsWith('/api/admin/'))
service.admin.require(req.cookies.hop_account);
if (resumable) service.limiter.hit(`chunk-guest:${auth(req).id}`, 1200);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
if (!allowed(req.headers.origin))
@@ -266,6 +276,15 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
csrf(req);
}
}
const path = req.url.split('?')[0];
if (
(req.method === 'POST' &&
(path === '/api/uploads' || /^\/api\/uploads\/resumable\/[^/]+\/complete$/.test(path))) ||
(req.method === 'PUT' && /^\/api\/uploads\/resumable\/[^/]+$/.test(path))
) {
const current = service.auth.session(req.cookies.hop_account);
service.admin.admit(req, reply, current?.user.id ?? auth(req).id);
}
}
});
app.setErrorHandler((err, req, reply) => {
@@ -318,6 +337,35 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
expires: new Date(expiresAt),
});
app.get('/api/health', async () => ({ status: 'ok', phase: 1 }));
app.get('/api/admin/summary', async (req) => {
service.admin.require(req.cookies.hop_account);
return service.admin.summary();
});
app.get('/api/admin/files', async (req) => {
service.admin.require(req.cookies.hop_account);
const input = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().max(200).optional(),
})
.strict()
.parse(req.query);
return service.admin.files(input);
});
app.patch('/api/admin/settings', { bodyLimit: 16384 }, async (req) =>
service.admin.patch(
service.admin.require(req.cookies.hop_account),
adminPatch.parse(req.body),
req,
),
);
app.delete('/api/admin/files/:id', async (req) =>
service.admin.deleteFile(service.admin.require(req.cookies.hop_account), id(req)),
);
app.get('/api/admin/audit', async (req) => {
service.admin.require(req.cookies.hop_account);
return { events: service.admin.events() };
});
app.get('/api/ops/proxy-diagnostic', async (req) => {
if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.');
const given = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
@@ -332,7 +380,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
resolved: Boolean(ip(req)),
};
});
app.post('/api/guest/session', async (req, reply) => {
app.post('/api/guest/session', { bodyLimit: 1024 }, async (req, reply) => {
service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20);
let token = req.cookies.hop_guest,
guest = service.guest(token);
@@ -727,12 +775,17 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
app.get('/api/auth/me', async (req) => {
const current = service.auth.session(req.cookies.hop_account);
return {
account: current ? service.auth.serialize(current.user) : null,
account: current
? {
...service.auth.serialize(current.user),
administrator: service.admin.isAdmin(current.user),
}
: null,
registrationOpen: cfg.registrationMode === 'open',
passwordMinLength: PASSWORD_MIN_LENGTH,
};
});
app.post('/api/auth/register', async (req) => {
app.post('/api/auth/register', { bodyLimit: 16384 }, async (req) => {
const input = registration.parse(req.body);
if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.');
return service.auth.register(
@@ -771,7 +824,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
ip(req) ?? 'unknown',
),
);
app.post('/api/auth/login', async (req, reply) => {
app.post('/api/auth/login', { bodyLimit: 16384 }, async (req, reply) => {
const input = credentials.parse(req.body);
const result = await service.auth.login(
input.email,
@@ -1010,6 +1063,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
const cleanStorage = () => {
storageCleanup = service.storage
.cleanup()
.then(() => service.admin.cleanup())
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
};
cleanStorage();

View File

@@ -33,6 +33,9 @@ export type Config = {
maxFileBytes: number;
guestQuota: number;
totalGuestQuota: number;
totalStorageQuota: number;
maxConcurrentUploads: number;
auditDays: number;
minFreeBytes: number;
fileRetentionDays: number;
orphanHours: number;
@@ -72,6 +75,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
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 (num('MAX_CONCURRENT_UPLOADS', 4) > 16)
throw Error('MAX_CONCURRENT_UPLOADS must not exceed 16');
if (num('TOTAL_STORAGE_LIMIT_MB', 20480, 1024) > 1048576)
throw Error('TOTAL_STORAGE_LIMIT_MB must not exceed 1048576');
if (![4, 6].includes(spaceInviteCodeLength))
throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6');
return {
@@ -114,6 +121,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576,
totalStorageQuota: num('TOTAL_STORAGE_LIMIT_MB', 20480, 1024) * 1048576,
maxConcurrentUploads: num('MAX_CONCURRENT_UPLOADS', 4),
auditDays: num('AUDIT_RETENTION_DAYS', 30),
minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24),

View File

@@ -352,3 +352,19 @@ export const idempotency = sqliteTable(
check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const adminSettings = sqliteTable('admin_settings', {
name: text().primaryKey(),
value: text().notNull(),
updatedAt: integer().notNull(),
});
export const adminAudit = sqliteTable(
'admin_audit',
{
id: text().primaryKey(),
actorUserId: text().references(() => users.id, { onDelete: 'set null' }),
action: text().notNull(),
details: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [index('admin_audit_created').on(t.createdAt)],
);

View File

@@ -7,6 +7,7 @@ import {
renameSync,
unlinkSync,
statSync,
rmdirSync,
} from 'node:fs';
import { readdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
@@ -142,7 +143,7 @@ export class OwnerInfo {
for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const owner = this.details(entry.name + '/file');
if (!owner || !existsSync(owner.path)) continue;
if (!owner) continue;
const live = this.service.db
.select({ id: s.files.id })
.from(s.files)
@@ -157,8 +158,12 @@ export class OwnerInfo {
try {
unlinkSync(owner.path);
} catch {}
try {
rmdirSync(dirname(owner.path));
} catch {}
continue;
}
if (!existsSync(owner.path)) continue;
const { events, changed } = this.read(owner);
if (changed) this.write(owner, events);
}

View File

@@ -4,6 +4,7 @@ import type { Server } from 'socket.io';
import type { Auth } from './auth.js';
import type { Accounts } from './accounts.js';
import { Storage } from './storage.js';
import { Administration } from './administration.js';
import { ResumableUploads } from './resumable.js';
import { NearbyLocations } from './nearby.js';
import { Vault } from './vault.js';
@@ -28,12 +29,14 @@ export class Service {
accounts!: Accounts;
io?: Server;
storage: Storage;
admin: Administration;
resumable: ResumableUploads;
vault: Vault;
constructor(
readonly db: DB,
readonly cfg: Config,
) {
this.admin = new Administration(this);
this.storage = new Storage(this);
this.resumable = new ResumableUploads(this);
this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) =>
@@ -585,6 +588,7 @@ export class Service {
storageUserId?: string,
) {
this.cleanup();
if (input.kind === 'file' && !storedSource) this.admin.assertNewUploads();
const target = this.deviceGuest(input.targetDeviceId);
if (
target &&

View File

@@ -26,6 +26,9 @@ export class Storage {
readonly temp: string;
readonly originals: string;
readonly availability: FileAvailability;
get activeUploadCount() {
return this.active.size;
}
constructor(readonly service: Service) {
this.temp = resolve(service.cfg.uploadDir, 'temporary');
this.originals = resolve(service.cfg.uploadDir, 'originals');
@@ -141,6 +144,13 @@ export class Storage {
.from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now()))
.get()!.bytes;
const storedBytes = db
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.get()!.bytes;
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)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
@@ -267,6 +277,14 @@ export class Storage {
file = this.fileRows(requestId).find((f) => f.position === position)!;
if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
if (this.active.size >= this.service.admin.state.maxConcurrentUploads)
throw new ApiError(
429,
'UPLOAD_BUSY',
'서버가 다른 파일을 처리하고 있어요. 잠시 후 다시 시도해 주세요.',
Date.now() + 1000,
'sender',
);
if (
file.status === 'uploading' ||
[...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`)

View File

@@ -0,0 +1,439 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { io } from 'socket.io-client';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { eq } from 'drizzle-orm';
import { Administration, adminPatch, matchesIp } from '../src/administration.js';
async function login(f, email = 'hello@noname.company', verified = true) {
const c = await f.create(),
id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
role: 'user',
status: verified ? 'active' : 'pending',
verifiedAt: verified ? Date.now() : null,
createdAt: Date.now(),
})
.run();
const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200);
c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return c;
}
async function patch(f, c, changes, headers = {}) {
return f.call(
c,
'/admin/settings',
'PATCH',
{ revision: f.service.admin.state.revision, ...changes },
undefined,
headers,
);
}
async function fileRequest(f, a, b, size = 4) {
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: size,
count: 1,
files: [{ name: 'private.txt', size, type: 'text/plain' }],
},
randomUUID(),
);
assert.equal(r.statusCode, 200, r.body);
return r.json().requestId;
}
test('only the verified active hello account can view and mutate admin APIs; guest CSRF still applies', async () => {
const f = await fixture();
try {
const guest = await f.create(),
other = await login(f, 'other@hop.test'),
admin = await login(f),
caseAlias = await login(f, 'Hello@noname.company'),
pending = await login(f, 'pending@hop.test', false);
for (const path of ['/admin/summary', '/admin/files', '/admin/audit']) {
assert.equal((await f.call(null, path)).statusCode, 401);
assert.equal((await f.call(guest, path)).statusCode, 401);
assert.equal((await f.call(other, path)).statusCode, 403);
assert.equal((await f.call(caseAlias, path)).statusCode, 403);
assert.equal((await f.call(pending, path)).statusCode, 403);
assert.equal((await f.call(admin, path)).statusCode, 200);
}
assert.equal((await f.call(admin, '/auth/me')).json().account.administrator, true);
assert.equal((await f.call(other, '/auth/me')).json().account.administrator, false);
assert.equal((await f.call(caseAlias, '/auth/me')).json().account.administrator, false);
assert.equal((await patch(f, other, { uploadsPaused: true })).statusCode, 403);
assert.equal(
(await patch(f, admin, { uploadsPaused: true }, { 'x-csrf-token': 'bad' })).statusCode,
403,
);
assert.equal((await f.call(other, `/admin/files/${randomUUID()}`, 'DELETE')).statusCode, 403);
const state = f.service.admin.state;
assert.equal(state.uploadsPaused, false);
f.service.db
.update(s.users)
.set({ status: 'suspended' })
.where(eq(s.users.email, 'hello@noname.company'))
.run();
assert.equal((await f.call(admin, '/admin/summary')).statusCode, 401);
} finally {
await f.close();
}
});
test('unverified hello is not an administrator even with an admin role', async () => {
const f = await fixture();
try {
const c = await login(f, 'hello@noname.company', false);
f.service.db
.update(s.users)
.set({ role: 'admin' })
.where(eq(s.users.email, 'hello@noname.company'))
.run();
assert.equal((await f.call(c, '/auth/me')).json().account.administrator, false);
assert.equal((await f.call(c, '/admin/summary')).statusCode, 403);
} finally {
await f.close();
}
});
test('pause blocks new guest and personal file requests but keeps links and accepted uploads; settings and audit persist', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const r = await patch(f, admin, { uploadsPaused: true, maxConcurrentUploads: 3 });
assert.equal(r.statusCode, 200);
const body = Buffer.from(
'--boundary\r\nContent-Disposition: form-data; name="file"; filename="private.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--boundary--\r\n',
);
const uploaded = await f.call(
a,
`/uploads?requestId=${id}&fileIndex=0`,
'POST',
body,
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=boundary' },
);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const denied = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: 4,
count: 1,
files: [{ name: 'new.txt', size: 4, type: 'text/plain' }],
},
randomUUID(),
);
assert.equal(denied.json().code, 'UPLOADS_PAUSED');
const own = await f.call(
admin,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'own.txt', size: 4, type: 'text/plain' }] },
randomUUID(),
);
assert.equal(own.json().code, 'UPLOADS_PAUSED');
assert.equal((await f.request(a, b)).statusCode, 200);
assert.equal(new Administration(f.service).state.uploadsPaused, true);
assert.equal(new Administration(f.service).state.maxConcurrentUploads, 3);
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'settings-change');
const stale = await f.call(admin, '/admin/settings', 'PATCH', {
revision: 0,
uploadsPaused: false,
});
assert.equal(stale.statusCode, 409);
} finally {
await f.close();
}
});
test('country and CIDR rules cover HTTP and WebSocket; untrusted location headers are ignored and self-block is rejected', async () => {
const f = await fixture();
let socket;
try {
const admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
const actor = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR' };
assert.equal(
(
await patch(
f,
admin,
{ blockedCountries: ['CN'], blockedIps: ['1.1.1.0/24', '2001:db8::/32'] },
actor,
)
).statusCode,
200,
);
assert.equal(matchesIp('2001:db8::123', f.service.admin.ipRules), true);
assert.equal(matchesIp('2001:db9::123', f.service.admin.ipRules), false);
assert.equal(matchesIp('::ffff:1.1.1.12', f.service.admin.ipRules), true);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '8.8.8.8',
'cf-ipcountry': 'CN',
})
).json().code,
'ACCESS_BLOCKED',
);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '1.1.1.99',
'cf-ipcountry': 'US',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '8.8.8.8',
})
).statusCode,
200,
'unknown country is not guessed',
);
assert.equal(
(await patch(f, admin, { blockedCountries: ['KR'] }, actor)).json().code,
'ADMIN_SELF_BLOCK',
);
assert.equal(
(await patch(f, admin, { blockedIps: ['8.8.0.0/16'] }, actor)).json().code,
'ADMIN_SELF_BLOCK',
);
assert.equal((await patch(f, admin, { blockedIps: ['bad'] }, actor)).statusCode, 400);
assert.equal((await patch(f, admin, { maxConcurrentUploads: 100 }, actor)).statusCode, 400);
socket = io(f.url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: guest.csrf },
extraHeaders: {
Origin: f.cfg.appUrl,
Cookie: guest.cookie,
'CF-Connecting-IP': '8.8.8.8',
'CF-IPCountry': 'CN',
},
});
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('blocked socket unexpectedly connected')), 2000);
socket.once('connect_error', () => {
clearTimeout(timer);
resolve();
});
socket.once('connect', () => reject(Error('blocked socket connected')));
});
f.cfg.trustedProxies = [];
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '1.1.1.99',
'cf-ipcountry': 'CN',
})
).statusCode,
200,
);
} finally {
socket?.disconnect();
await f.close();
}
});
test('a newly blocked IP aborts its admitted request while keeping its raw HTTP objects private', async () => {
const f = await fixture();
try {
const admin = await login(f);
const raw = new EventEmitter(),
response = new EventEmitter();
raw.socket = { remoteAddress: '127.0.0.1' };
let destroyed = 0;
raw.destroy = () => destroyed++;
response.destroy = () => destroyed++;
f.service.admin.admit(
{ raw, headers: { 'x-forwarded-for': '8.8.8.8' } },
{ raw: response },
'attacker',
);
const summary = f.service.admin.summary();
assert.equal(summary.requests[0].ip, '8.8.8.8');
assert.equal('raw' in summary.requests[0], false);
assert.equal((await patch(f, admin, { blockedIps: ['8.8.8.8'] })).statusCode, 200);
assert.equal(destroyed, 2);
assert.equal(f.service.admin.inflight.size, 0);
} finally {
await f.close();
}
});
test('a failed admin settings commit does not change the effective policy or audit ledger', async () => {
const f = await fixture();
try {
const admin = await login(f);
assert.equal((await patch(f, admin, { maxConcurrentUploads: 3 })).statusCode, 200);
f.sqlite.exec(
"CREATE TRIGGER reject_admin BEFORE UPDATE ON admin_settings BEGIN SELECT RAISE(ABORT, 'test settings failure'); END",
);
assert.equal((await patch(f, admin, { uploadsPaused: true })).statusCode, 500);
assert.equal(f.service.admin.state.uploadsPaused, false);
assert.equal(new Administration(f.service).state.uploadsPaused, false);
assert.equal(f.service.admin.events().length, 1);
} finally {
await f.close();
}
});
test('global quota includes existing reservations across different upload owners', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
assert.equal((await patch(f, admin, { totalStorageLimitMb: 1024 })).statusCode, 200);
const one = await fileRequest(f, a, b, 700 * 1048576),
two = await fileRequest(f, c, b, 700 * 1048576);
assert.equal(
(await f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const denied = await f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID());
assert.equal(denied.statusCode, 507);
assert.equal(denied.json().code, 'STORAGE_QUOTA');
assert.equal((await f.call(admin, '/admin/summary')).json().reservedBytes, 700 * 1048576);
} finally {
await f.close();
}
});
test('body admission rejects extra uploads before parsing, releases slots on abort/response and enforces per-owner concurrency', async () => {
const f = await fixture();
try {
const c = await f.create();
f.service.admin.state.maxConcurrentUploads = 1;
const req = { raw: new EventEmitter(), headers: {} };
req.raw.socket = { remoteAddress: '127.0.0.1' };
const reply = { raw: new EventEmitter() };
const release = f.service.admin.admit(req, reply, 'owner');
const busy = await f.call(
c,
`/uploads/resumable/${randomUUID()}`,
'PUT',
Buffer.alloc(10),
undefined,
{ 'content-type': 'application/octet-stream', 'upload-offset': '0' },
);
assert.equal(busy.statusCode, 429);
assert.equal(busy.json().code, 'UPLOAD_BUSY');
assert.equal(Number(busy.headers['retry-after']), 1);
req.raw.emit('aborted');
release();
assert.equal(f.service.admin.inflight.size, 0);
const next = await f.call(
c,
`/uploads/resumable/${randomUUID()}`,
'PUT',
Buffer.alloc(10),
undefined,
{ 'content-type': 'application/octet-stream', 'upload-offset': '0' },
);
assert.equal(next.statusCode, 404);
assert.equal(f.service.admin.inflight.size, 0);
f.service.admin.state.maxConcurrentUploads = 4;
const releases = [
f.service.admin.admit(req, reply, 'owner'),
f.service.admin.admit(req, reply, 'owner'),
];
assert.throws(
() => f.service.admin.admit(req, reply, 'owner'),
(error) => error.code === 'UPLOAD_BUSY',
);
releases.forEach((stop) => stop());
const oversized = await f.call(null, '/guest/session', 'POST', { value: 'a'.repeat(2000) });
assert.equal(oversized.statusCode, 413);
} finally {
await f.close();
}
});
test('admin deletion removes physical bytes and all shared references; listing is paginated and audit entries expire', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const payload = Buffer.from(
'--delete\r\nContent-Disposition: form-data; name="file"; filename="private.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n',
);
await f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': 'multipart/form-data; boundary=delete',
});
const row = f.service.storage.fileRows(id)[0],
path = f.service.storage.layout.path(row),
access = (await f.call(b, '/device/items')).json().items[0];
const secondId = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${secondId}/accept`, 'POST', {}, randomUUID());
const secondUpload = await f.call(
a,
`/uploads?requestId=${secondId}&fileIndex=0`,
'POST',
payload,
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=delete' },
);
assert.equal(secondUpload.statusCode, 200, secondUpload.body);
const secondRow = f.service.storage.fileRows(secondId)[0];
const list = (await f.call(admin, '/admin/files?limit=1')).json();
assert.equal(list.items[0].name, 'private.txt');
assert.equal(list.items[0].uploader.id, row.ownerGuestId);
assert.ok(list.nextCursor);
const nextPage = (await f.call(admin, '/admin/files?limit=1&cursor=' + list.nextCursor)).json();
assert.equal(nextPage.items.length, 1);
assert.notEqual(list.items[0].id, nextPage.items[0].id);
assert.deepEqual(
new Set([list.items[0].id, nextPage.items[0].id]),
new Set([row.id, secondRow.id]),
);
assert.equal(nextPage.nextCursor, null);
assert.equal((await f.call(admin, '/admin/files?cursor=bad')).statusCode, 400);
assert.equal((await f.call(admin, `/admin/files/${row.id}`, 'DELETE')).statusCode, 200);
assert.equal(existsSync(path), false);
assert.equal((await f.call(b, `/access/${access.accessId}/download`)).statusCode, 404);
assert.equal((await f.call(admin, '/admin/summary')).json().storedBytes, 4);
assert.equal((await f.call(admin, `/admin/files/${secondRow.id}`, 'DELETE')).statusCode, 200);
assert.equal((await f.call(admin, '/admin/summary')).json().storedBytes, 0);
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'file-delete');
f.service.db
.update(s.adminAudit)
.set({ createdAt: Date.now() - 31 * 86400000 })
.run();
f.service.admin.cleanup();
assert.equal(f.service.admin.events().length, 0);
} finally {
await f.close();
}
});