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();
|
||||
}
|
||||
});
|
||||
393
apps/web/src/AdminPanel.vue
Normal file
393
apps/web/src/AdminPanel.vue
Normal 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 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user