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();
}
});

393
apps/web/src/AdminPanel.vue Normal file
View File

@@ -0,0 +1,393 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import {
ShieldCheck,
RefreshCw,
Trash2,
ArrowLeft,
Pause,
Play,
Folder,
Globe,
HardDrive,
X,
} from '@lucide/vue';
import { api, showError } from './client';
const emit = defineEmits(['devices']);
const overview = ref(null),
files = ref([]),
cursor = ref(null),
events = ref([]),
view = ref('files'),
busy = ref(false),
loading = ref(true),
notice = ref(''),
confirmation = ref(null),
dialog = ref(null);
const maxConcurrent = ref(4),
capacity = ref(20),
countries = ref(''),
ips = ref('');
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
const size = (bytes) =>
bytes >= 1073741824
? `${(bytes / 1073741824).toFixed(1)} GiB`
: bytes >= 1048576
? `${(bytes / 1048576).toFixed(1)} MiB`
: `${Math.max(0, Math.round(bytes / 1024))} KiB`;
const date = (value) =>
value
? new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value))
: '확인 불가';
function region(location) {
if (!location) return '확인 불가';
return (
[
location.countryCode ? countryNames.of(location.countryCode) : null,
location.city || location.region,
]
.filter(Boolean)
.join(' · ') || '확인 불가'
);
}
function form(settings) {
maxConcurrent.value = settings.maxConcurrentUploads;
capacity.value = settings.totalStorageLimitMb / 1024;
countries.value = settings.blockedCountries.join(', ');
ips.value = settings.blockedIps.join('\n');
}
async function refresh(resetForm = true) {
const [summary, list, audit] = await Promise.all([
api('/admin/summary'),
api('/admin/files'),
api('/admin/audit'),
]);
overview.value = summary;
files.value = list.items;
cursor.value = list.nextCursor;
events.value = audit.events;
if (resetForm) form(summary.settings);
}
async function act(fn) {
if (busy.value) return;
busy.value = true;
notice.value = '';
try {
await fn();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
async function reload() {
await act(async () => {
await refresh();
notice.value = '최신 관리 정보를 불러왔어요.';
});
}
async function pauseUploads() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
uploadsPaused: !overview.value.settings.uploadsPaused,
});
overview.value.settings = settings;
notice.value = settings.uploadsPaused
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
: '신규 업로드를 다시 허용했어요.';
await refresh(false);
});
}
const split = (value) =>
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean);
function addBlockedIp(ip) {
ips.value = [...new Set([...split(ips.value), ip])].join('\n');
view.value = 'rules';
notice.value = 'IP를 입력했어요. 설정 저장을 누르면 차단됩니다.';
}
async function save() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
maxConcurrentUploads: Number(maxConcurrent.value),
totalStorageLimitMb: Math.round(Number(capacity.value) * 1024),
blockedCountries: split(countries.value),
blockedIps: split(ips.value),
});
overview.value.settings = settings;
form(settings);
notice.value = '관리 설정을 저장했어요.';
await refresh(false);
});
}
async function more() {
await act(async () => {
const result = await api('/admin/files?cursor=' + encodeURIComponent(cursor.value));
files.value.push(...result.items);
cursor.value = result.nextCursor;
});
}
let trigger;
async function confirm(file) {
trigger = document.activeElement;
confirmation.value = file;
await nextTick();
dialog.value?.querySelector('button')?.focus();
}
function closeConfirmation() {
confirmation.value = null;
trigger?.focus();
}
function dialogKey(event) {
if (event.key === 'Escape' && !busy.value) {
event.preventDefault();
closeConfirmation();
return;
}
if (event.key !== 'Tab') return;
const controls = [...dialog.value.querySelectorAll('button:not(:disabled)')],
first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
async function remove() {
const file = confirmation.value;
await act(async () => {
await api('/admin/files/' + file.id, 'DELETE');
closeConfirmation();
notice.value = '원본과 연결된 항목을 삭제했어요.';
await refresh(false);
});
}
const actionName = (action) =>
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
onMounted(async () => {
try {
await refresh();
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
});
</script>
<template>
<section class="admin-panel">
<header class="admin-heading">
<div>
<p class="admin-eyebrow"><ShieldCheck :size="17" />관리자</p>
<h1>서비스 관리</h1>
</div>
<div class="admin-toolbar">
<button class="button small" :disabled="busy || loading" @click="reload">
<RefreshCw :size="16" />새로고침</button
><button class="text-button" @click="emit('devices')">
<ArrowLeft :size="16" />메인으로
</button>
</div>
</header>
<p v-if="loading" role="status" class="admin-muted">관리 정보를 불러오고 있어요.</p>
<template v-if="overview">
<div class="admin-stats">
<article>
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
><small
>{{ size(overview.storedBytes) }} /
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
>
</article>
<article>
<HardDrive :size="21" /><span>디스크 여유</span
><strong>{{ size(overview.diskFreeBytes) }}</strong
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
</article>
<article>
<RefreshCw :size="21" /><span>업로드 처리</span
><strong
>{{ overview.activeRequests }} / {{ overview.settings.maxConcurrentUploads }}</strong
><small>파일 저장 작업 {{ overview.activeFileWrites }}개</small>
</article>
<article>
<ShieldCheck :size="21" /><span>활성 계정</span
><strong>{{ overview.activeAccounts }}명</strong
><small
>차단 국가 {{ overview.settings.blockedCountries.length }} · IP 규칙
{{ overview.settings.blockedIps.length }}</small
>
</article>
</div>
<div class="admin-upload-control">
<div>
<strong>{{
overview.settings.uploadsPaused ? '신규 업로드 중지됨' : '신규 업로드 허용 중'
}}</strong>
<p>이미 시작한 전송과 기존 파일 다운로드는 유지됩니다.</p>
</div>
<button class="button" :disabled="busy" @click="pauseUploads">
<Play v-if="overview.settings.uploadsPaused" :size="16" /><Pause v-else :size="16" />{{
overview.settings.uploadsPaused ? '업로드 재개' : '신규 업로드 중지'
}}
</button>
</div>
<section v-if="overview.requests.length" class="admin-activity">
<strong>진행 중인 업로드 요청</strong>
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
<code>{{ request.ip || 'IP 확인 불가' }}</code
><span
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
{{ date(request.startedAt) }}</span
><button
class="text-button"
:disabled="busy || !request.ip"
@click="addBlockedIp(request.ip)"
>
차단 규칙에 추가
</button>
</div>
</section>
<p v-if="notice" class="admin-notice" role="status">{{ notice }}</p>
<nav class="admin-tabs" aria-label="관리 항목">
<button :class="{ selected: view === 'files' }" @click="view = 'files'">저장 파일</button
><button :class="{ selected: view === 'rules' }" @click="view = 'rules'">
접속 · 용량 설정</button
><button :class="{ selected: view === 'audit' }" @click="view = 'audit'">관리 기록</button>
</nav>
<section v-if="view === 'files'" class="admin-files">
<p v-if="!files.length" class="admin-muted">저장된 파일이 없습니다.</p>
<article v-for="file in files" :key="file.id" class="admin-file">
<div class="admin-file-heading">
<strong>{{ file.name }}</strong
><span>{{ size(file.size) }}</span>
</div>
<div class="admin-file-info">
<span>{{
file.uploader?.name ||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
}}</span
><code>{{ file.path.split('/')[0] }}</code>
</div>
<div class="admin-file-info">
<Globe :size="15" /><span>최근 접속 지역 · {{ region(file.lastLocation) }}</span
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
</div>
<footer>
<small>보관 만료 · {{ date(file.expiresAt) }}까지</small
><button class="text-button admin-delete" :disabled="busy" @click="confirm(file)">
<Trash2 :size="16" />원본 삭제
</button>
</footer>
</article>
<button v-if="cursor" class="button small" :disabled="busy" @click="more">더 보기</button>
</section>
<form v-else-if="view === 'rules'" class="admin-settings" @submit.prevent="save">
<div class="admin-settings-grid">
<label
>동시에 처리할 업로드 수<input
v-model="maxConcurrent"
type="number"
min="1"
max="16"
required
:disabled="busy" /></label
><label
>전체 파일 저장 한도 (GiB)<input
v-model="capacity"
type="number"
min="1"
max="1024"
step="any"
required
:disabled="busy"
/></label>
</div>
<p class="admin-muted">
기기·개인별 한도와 디스크 최소 여유 공간도 함께 적용합니다. 한도를 줄여도 기존 파일은
삭제하지 않습니다.
</p>
<label
>차단할 국가 코드<input
v-model="countries"
placeholder="예: CN, RU"
:disabled="busy"
/><small>CN 중국 · RU 러시아 · US 미국 · JP 일본 · KR 대한민국</small></label
>
<label
>차단할 IP 또는 IP 대역<textarea
v-model="ips"
rows="4"
placeholder="203.0.113.10&#10;198.51.100.0/24"
:disabled="busy"
></textarea
><small>쉼표나 줄바꿈으로 구분합니다. IPv4·IPv6·CIDR을 지원합니다.</small></label
>
<p class="admin-muted">
기본 접속 정보 기준으로 차단합니다. 지역 정보가 없는 접속은 국가 규칙으로 차단되지
않습니다. 현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.
</p>
<button class="button primary" :disabled="busy">설정 저장</button>
</form>
<section v-else class="admin-audit">
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
<article v-for="event in events" :key="event.id">
<strong>{{ actionName(event.action) }}</strong
><span>{{ date(event.createdAt) }}</span
><small v-if="event.action === 'file-delete'">{{ event.details.name }}</small
><small v-else
>업로드 {{ event.details.uploadsPaused ? '중지' : '허용' }} · 동시
{{ event.details.maxConcurrentUploads }}개 · 국가
{{ event.details.blockedCountries?.join(', ') || '없음' }} · IP
{{ event.details.blockedIps?.length || 0 }}개</small
>
</article>
</section>
</template>
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
<section
ref="dialog"
class="modal admin-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="admin-confirm-title"
aria-describedby="admin-confirm-description"
@keydown="dialogKey"
>
<header class="modal-title">
<h2 id="admin-confirm-title">원본 파일을 삭제할까요?</h2>
<button
class="icon-button"
:disabled="busy"
aria-label="삭제 확인 닫기"
@click="closeConfirmation"
>
<X :size="20" />
</button>
</header>
<strong class="admin-confirm-file">{{ confirmation.name }}</strong>
<p id="admin-confirm-description">
원본과 모든 사용자의 연결된 항목이 삭제됩니다. 복원할 수 없습니다.
</p>
<div class="admin-confirm-actions">
<button class="button" :disabled="busy" @click="closeConfirmation">취소</button
><button class="button primary" :disabled="busy" @click="remove">원본 삭제 확인</button>
</div>
</section>
</div>
</section>
</template>

View File

@@ -40,6 +40,7 @@ import QRCode from 'qrcode';
import { version as appVersion } from '../package.json';
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
import AccountPanel from './AccountPanel.vue';
import AdminPanel from './AdminPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import LogoMark from './LogoMark.vue';
@@ -550,6 +551,12 @@ watch(
},
);
let timer;
watch(
() => account.value?.administrator,
(allowed) => {
if (!allowed && tab.value === 'admin') tab.value = 'devices';
},
);
onMounted(async () => {
applyTheme();
media.addEventListener('change', applyTheme);
@@ -816,6 +823,16 @@ async function spaceAction(space, action, memberId) {
<button role="menuitem" @click="openAbout">
<Info :size="18" /><span>서비스 정보</span>
</button>
<button
v-if="account?.administrator"
role="menuitem"
@click="
tab = 'admin';
closeHeaderMore();
"
>
<ShieldCheck :size="18" /><span>관리자</span>
</button>
</div>
</div>
</div>
@@ -1166,6 +1183,10 @@ async function spaceAction(space, action, memberId) {
</div>
</section>
</template>
<AdminPanel
v-else-if="tab === 'admin' && account?.administrator"
@devices="tab = 'devices'"
/>
<template v-else
><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault"
/></template>

View File

@@ -166,8 +166,13 @@ function connect() {
account.value = null;
window.dispatchEvent(new Event('hop-account-revoked'));
});
socket.on('service:blocked', () => showError(Error('이 접속은 서비스 이용이 제한되었습니다.')));
socket.on('connect_error', (err) => {
connected.value = false;
if (err.message === 'ACCESS_BLOCKED') {
showError(Error('이 접속은 서비스 이용이 제한되었습니다.'));
return;
}
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
reauthorizing = (async () => {
const session = await api('/guest/session', 'POST', {});

View File

@@ -3908,3 +3908,303 @@ a.button {
flex-shrink: 0;
margin: 0;
}
.admin-panel {
width: 100%;
min-width: 0;
padding-top: 32px;
}
.admin-heading,
.admin-toolbar,
.admin-upload-control,
.admin-file-heading,
.admin-file footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.admin-heading {
margin-bottom: 28px;
flex-wrap: wrap;
}
.admin-heading h1 {
margin: 6px 0 0;
font-size: 28px;
line-height: 1.3;
}
.admin-eyebrow {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.admin-toolbar {
justify-content: flex-end;
flex-wrap: wrap;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.admin-stats article {
display: flex;
flex-direction: column;
gap: 9px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
min-width: 0;
}
.admin-stats svg,
.admin-stats span,
.admin-stats small,
.admin-muted {
color: var(--muted);
}
.admin-stats strong {
font-size: 25px;
line-height: 1.3;
}
.admin-stats small {
font-size: 12px;
line-height: 1.5;
}
.admin-upload-control {
border: 1px solid var(--line);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.admin-upload-control p {
color: var(--muted);
font-size: 13px;
margin: 7px 0 0;
}
.admin-upload-control button {
flex-shrink: 0;
}
.admin-notice {
font-size: 14px;
margin: 16px 0;
}
.admin-tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid var(--line);
margin: 24px 0;
}
.admin-tabs button {
background: none;
border: 0;
border-bottom: 2px solid transparent;
color: var(--muted);
font: inherit;
padding: 12px 0;
cursor: pointer;
}
.admin-tabs button.selected {
color: var(--ink);
border-bottom-color: var(--ink);
}
.admin-files {
display: grid;
gap: 14px;
}
.admin-file {
min-width: 0;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
padding: 22px;
}
.admin-file-heading {
align-items: flex-start;
margin-bottom: 14px;
}
.admin-file-heading strong {
overflow-wrap: anywhere;
min-width: 0;
}
.admin-file-heading > span {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.admin-file-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
margin-top: 9px;
}
.admin-file-info code {
font-size: 12px;
overflow-wrap: anywhere;
max-width: 100%;
}
.admin-file footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.admin-file footer small {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.admin-delete {
flex-shrink: 0;
}
.admin-settings {
display: grid;
gap: 22px;
max-width: 840px;
}
.admin-settings label {
display: grid;
gap: 10px;
font-size: 15px;
font-weight: 500;
}
.admin-settings input,
.admin-settings textarea {
width: 100%;
font-size: 16px;
min-width: 0;
}
.admin-settings small {
color: var(--muted);
font-weight: 400;
line-height: 1.5;
}
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.admin-muted {
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.admin-settings > .button {
justify-self: start;
}
.admin-audit {
display: grid;
gap: 12px;
}
.admin-audit article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
}
.admin-audit article span,
.admin-audit small {
color: var(--muted);
font-size: 13px;
}
.admin-audit small {
grid-column: 1/-1;
overflow-wrap: anywhere;
line-height: 1.6;
}
.admin-confirm .modal-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-confirm-file {
display: block;
margin: 22px 0 12px;
overflow-wrap: anywhere;
}
.admin-confirm p {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.admin-confirm-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 639px) {
.admin-panel {
padding-top: 16px;
}
.admin-heading {
gap: 16px;
margin-bottom: 20px;
}
.admin-heading h1 {
font-size: 24px;
}
.admin-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-stats article {
padding: 15px;
}
.admin-stats strong {
font-size: 22px;
}
.admin-upload-control {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.admin-settings-grid {
grid-template-columns: 1fr;
}
.admin-tabs {
gap: 18px;
}
.admin-tabs button {
font-size: 14px;
}
.admin-file {
padding: 17px;
}
.admin-file footer {
align-items: flex-start;
}
.admin-settings > .button {
justify-self: stretch;
}
}
.admin-activity {
display: grid;
gap: 12px;
margin: 20px 0;
padding: 18px;
border: 1px solid var(--line);
border-radius: 14px;
}
.admin-activity > div {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
font-size: 13px;
}
.admin-activity code {
overflow-wrap: anywhere;
}
.admin-activity span {
color: var(--muted);
}