Add verified administrator controls and upload abuse limits
This commit is contained in:
16
apps/server/drizzle/0012_admin_controls.sql
Normal file
16
apps/server/drizzle/0012_admin_controls.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
352
apps/server/src/administration.js
Normal file
352
apps/server/src/administration.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
439
apps/server/test/admin.test.js
Normal file
439
apps/server/test/admin.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user