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

@@ -46,6 +46,8 @@ MAX_FILE_SIZE_MB=2048
USER_STORAGE_LIMIT_MB=2048 USER_STORAGE_LIMIT_MB=2048
GUEST_STORAGE_LIMIT_MB=2048 GUEST_STORAGE_LIMIT_MB=2048
TOTAL_GUEST_STORAGE_LIMIT_MB=20480 TOTAL_GUEST_STORAGE_LIMIT_MB=20480
TOTAL_STORAGE_LIMIT_MB=20480
MAX_CONCURRENT_UPLOADS=4
MIN_FREE_DISK_MB=5120 MIN_FREE_DISK_MB=5120
DEFAULT_FILE_RETENTION_DAYS=7 DEFAULT_FILE_RETENTION_DAYS=7
EMAIL_REQUEST_RETENTION_DAYS=7 EMAIL_REQUEST_RETENTION_DAYS=7

View File

@@ -75,13 +75,25 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
계정 메일 토큰은 일회용·해시 저장이며 링크 fragment를 사용합니다. 인증 전 계정 업로드·원격 전송·수락을 금지합니다. 이메일 정규화는 양끝 공백 제거와 도메인 소문자화이며 로컬 파트 대소문자·점·별칭은 보존합니다. ‘내 계정에 저장’은 기존 익명 접근권을 제거하고 계정 보관함으로 이동하며 원래 만료와 상대 접근권을 유지합니다. 계정 메일 토큰은 일회용·해시 저장이며 링크 fragment를 사용합니다. 인증 전 계정 업로드·원격 전송·수락을 금지합니다. 이메일 정규화는 양끝 공백 제거와 도메인 소문자화이며 로컬 파트 대소문자·점·별칭은 보존합니다. ‘내 계정에 저장’은 기존 익명 접근권을 제거하고 계정 보관함으로 이동하며 원래 만료와 상대 접근권을 유지합니다.
최초 관리자 초기화는 `SETUP_TOKEN`(32자 이상 무작위 비밀값)과 빈 사용자 DB 조건으로만 가능합니다. `/api/auth/setup`도 이메일 인증을 거치며, 실제 메일 수신 검증 후 실행 절차를 완성합니다. 최초 계정 초기화는 `SETUP_TOKEN`(32자 이상 무작위 비밀값)과 빈 사용자 DB 조건으로만 가능합니다. `/api/auth/setup`도 이메일 인증을 거칩니다. 서비스 관리 화면의 권한은 아래 관리자 계정 기준으로 별도 확인합니다.
## 서비스 관리
이메일 인증을 완료한 **hello@noname.company** 계정으로 로그인하면 **더보기 → 관리자**가 표시됩니다. 서버도 매 요청에서 동일 계정의 인증·활성 상태를 확인합니다. 다른 계정이나 이메일 로컬 파트의 대소문자가 다른 계정에는 권한을 주지 않습니다.
저장 파일·업로드 주체·최근 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다.
관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GiB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다.
국가와 IP 규칙은 HTTP·기기 연결에 적용하고, 규칙에 해당하는 진행 중 업로드와 연결도 종료합니다. IPv4·IPv6와 IP 대역을 지원하며 현재 관리자 접속을 차단하는 설정은 거절합니다. 국가는 검증된 Cloudflare 제공 값만 사용하고, 미제공 국가는 추정하지 않습니다. VPN·실제 거주지 추적은 하지 않습니다. 도시 정보 설정은 [배포 문서](deploy.md#전송-파일이-저장되는-곳과-정리-시점)를 참고하세요.
관리 설정은 DB에 저장되어 재시작 후 유지하며 `.env`는 초기 기본값으로 사용합니다. 설정 변경·원본 삭제 기록은 기본 30일 보관합니다. 이번 업데이트는 관리 테이블을 추가하는 마이그레이션을 자동 적용하며 기존 계정·파일·볼륨·`docker-compose.yaml`을 유지합니다. 앱의 자원 제한은 대규모 네트워크 공격을 모두 차단하지는 못하므로 외부 트래픽 대응은 Cloudflare 등 앞단 설정도 함께 사용합니다.
기술 참고: [Fastify 프록시 설정](https://fastify.dev/docs/latest/Reference/Server/), [Drizzle SQLite](https://orm.drizzle.team/docs/sqlite/get-started-sqlite), [Tailwind Vite](https://tailwindcss.com/docs/installation/using-vite). 기술 참고: [Fastify 프록시 설정](https://fastify.dev/docs/latest/Reference/Server/), [Drizzle SQLite](https://orm.drizzle.team/docs/sqlite/get-started-sqlite), [Tailwind Vite](https://tailwindcss.com/docs/installation/using-vite).
## 현재 전송 UX ## 현재 전송 UX
[파일·링크 통합 수정안](docs/TRANSFER_UX.md)을 우선합니다. 일반 텍스트 전송과 유형 탭은 제거했습니다. 기기를 누르면 클립보드 URL을 제안하며, 권한 거부나 일반 텍스트는 무시하고 파일 선택을 유지합니다. 링크 직접 붙여넣기도 가능합니다. 읽기만으로 전송하지 않으며 수락 후에만 전달합니다. 수신과 완료 카드는 뷰포트 상단에 표시됩니다. 기본 테마는 검정이며 라이트·시스템 전환을 유지합니다. [클립보드 전송 수정안](docs/CLIPBOARD_TRANSFER.md)을 따릅니다. 파일과 클립보드 중 하나를 선택하고 링크·일반 텍스트는 자동 판별합니다. 기기를 누르면 클립보드를 읽어 제안하며, 권한 거부 시에도 직접 입력할 수 있습니다. 입력 중인 내용을 자동으로 덮어쓰지 않으며 수락 후에만 전달합니다. 수신과 완료 카드는 뷰포트 상단에 표시됩니다. 기본 테마는 검정이며 라이트·시스템 전환을 유지합니다.
## 개발 테스트 계정 ## 개발 테스트 계정

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, "when": 1791771741603,
"tag": "0011_single-room-index", "tag": "0011_single-room-index",
"breakpoints": true "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; const { db, cfg, limiter } = this.service;
this.verified(userId); this.verified(userId);
if (input.kind === 'file') this.service.admin.assertNewUploads();
limiter.hit(`remote-target:${userId}`, 30, 3600000); limiter.hit(`remote-target:${userId}`, 30, 3600000);
const recipient = db const recipient = db
.select() .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 { Service, type Guest } from './service.js';
import { Presence } from './presence.js'; import { Presence } from './presence.js';
import { visitorRegion } from './ownerInfo.js'; import { visitorRegion } from './ownerInfo.js';
import { adminPatch } from './administration.js';
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js'; import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
import { CHUNK_BYTES } from './resumable.js'; import { CHUNK_BYTES } from './resumable.js';
@@ -45,6 +46,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
: false, : false,
logController: new PrivateLogs(), logController: new PrivateLogs(),
trustProxy: false, trustProxy: false,
requestTimeout: 300000,
bodyLimit: cfg.maxUrlBytes * 6 + 16384, bodyLimit: cfg.maxUrlBytes * 6 + 16384,
}); });
await app.register(cookie); 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. // cookie authentication and the session-bound CSRF token are also required by io.use.
const sameOriginFetch = const sameOriginFetch =
!req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin'; !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()); 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); service.accounts = new Accounts(service);
io.use((socket, next) => { io.use((socket, next) => {
try { try {
service.admin.assertAllowed({ raw: socket.request, headers: socket.request.headers });
const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest; const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest;
const guest = service.guest(token); const guest = service.guest(token);
if (!guest || socket.handshake.auth.csrfToken !== service.csrf(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', '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'", "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/')) { if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store'); reply.header('Cache-Control', 'no-store');
const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable'); 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 ? 'chunk' : 'api'}-ip:${ip(req) ?? req.raw.socket.remoteAddress}`,
resumable ? 6000 : 240, 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 (resumable) service.limiter.hit(`chunk-guest:${auth(req).id}`, 1200);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
if (!allowed(req.headers.origin)) if (!allowed(req.headers.origin))
@@ -266,6 +276,15 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
csrf(req); 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) => { app.setErrorHandler((err, req, reply) => {
@@ -318,6 +337,35 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
expires: new Date(expiresAt), expires: new Date(expiresAt),
}); });
app.get('/api/health', async () => ({ status: 'ok', phase: 1 })); 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) => { app.get('/api/ops/proxy-diagnostic', async (req) => {
if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.'); if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.');
const given = req.headers.authorization?.replace(/^Bearer /, '') ?? ''; const given = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
@@ -332,7 +380,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
resolved: Boolean(ip(req)), 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); service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20);
let token = req.cookies.hop_guest, let token = req.cookies.hop_guest,
guest = service.guest(token); guest = service.guest(token);
@@ -727,12 +775,17 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
app.get('/api/auth/me', async (req) => { app.get('/api/auth/me', async (req) => {
const current = service.auth.session(req.cookies.hop_account); const current = service.auth.session(req.cookies.hop_account);
return { 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', registrationOpen: cfg.registrationMode === 'open',
passwordMinLength: PASSWORD_MIN_LENGTH, 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); const input = registration.parse(req.body);
if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.'); if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.');
return service.auth.register( return service.auth.register(
@@ -771,7 +824,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
ip(req) ?? 'unknown', 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 input = credentials.parse(req.body);
const result = await service.auth.login( const result = await service.auth.login(
input.email, input.email,
@@ -1010,6 +1063,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
const cleanStorage = () => { const cleanStorage = () => {
storageCleanup = service.storage storageCleanup = service.storage
.cleanup() .cleanup()
.then(() => service.admin.cleanup())
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed')); .catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
}; };
cleanStorage(); cleanStorage();

View File

@@ -33,6 +33,9 @@ export type Config = {
maxFileBytes: number; maxFileBytes: number;
guestQuota: number; guestQuota: number;
totalGuestQuota: number; totalGuestQuota: number;
totalStorageQuota: number;
maxConcurrentUploads: number;
auditDays: number;
minFreeBytes: number; minFreeBytes: number;
fileRetentionDays: number; fileRetentionDays: number;
orphanHours: 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) if (num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) > 1024)
throw Error('MAX_TEXT_PAYLOAD_SIZE_KB must not exceed 1024'); throw Error('MAX_TEXT_PAYLOAD_SIZE_KB must not exceed 1024');
const spaceInviteCodeLength = num('SPACE_INVITE_CODE_LENGTH', 4); 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)) if (![4, 6].includes(spaceInviteCodeLength))
throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6'); throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6');
return { return {
@@ -114,6 +121,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576, maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576, guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 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, minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)), fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24), 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`), 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, renameSync,
unlinkSync, unlinkSync,
statSync, statSync,
rmdirSync,
} from 'node:fs'; } from 'node:fs';
import { readdir } from 'node:fs/promises'; import { readdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
@@ -142,7 +143,7 @@ export class OwnerInfo {
for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) { for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const owner = this.details(entry.name + '/file'); const owner = this.details(entry.name + '/file');
if (!owner || !existsSync(owner.path)) continue; if (!owner) continue;
const live = this.service.db const live = this.service.db
.select({ id: s.files.id }) .select({ id: s.files.id })
.from(s.files) .from(s.files)
@@ -157,8 +158,12 @@ export class OwnerInfo {
try { try {
unlinkSync(owner.path); unlinkSync(owner.path);
} catch {} } catch {}
try {
rmdirSync(dirname(owner.path));
} catch {}
continue; continue;
} }
if (!existsSync(owner.path)) continue;
const { events, changed } = this.read(owner); const { events, changed } = this.read(owner);
if (changed) this.write(owner, events); 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 { Auth } from './auth.js';
import type { Accounts } from './accounts.js'; import type { Accounts } from './accounts.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { Administration } from './administration.js';
import { ResumableUploads } from './resumable.js'; import { ResumableUploads } from './resumable.js';
import { NearbyLocations } from './nearby.js'; import { NearbyLocations } from './nearby.js';
import { Vault } from './vault.js'; import { Vault } from './vault.js';
@@ -28,12 +29,14 @@ export class Service {
accounts!: Accounts; accounts!: Accounts;
io?: Server; io?: Server;
storage: Storage; storage: Storage;
admin: Administration;
resumable: ResumableUploads; resumable: ResumableUploads;
vault: Vault; vault: Vault;
constructor( constructor(
readonly db: DB, readonly db: DB,
readonly cfg: Config, readonly cfg: Config,
) { ) {
this.admin = new Administration(this);
this.storage = new Storage(this); this.storage = new Storage(this);
this.resumable = new ResumableUploads(this); this.resumable = new ResumableUploads(this);
this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) => this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) =>
@@ -585,6 +588,7 @@ export class Service {
storageUserId?: string, storageUserId?: string,
) { ) {
this.cleanup(); this.cleanup();
if (input.kind === 'file' && !storedSource) this.admin.assertNewUploads();
const target = this.deviceGuest(input.targetDeviceId); const target = this.deviceGuest(input.targetDeviceId);
if ( if (
target && target &&

View File

@@ -26,6 +26,9 @@ export class Storage {
readonly temp: string; readonly temp: string;
readonly originals: string; readonly originals: string;
readonly availability: FileAvailability; readonly availability: FileAvailability;
get activeUploadCount() {
return this.active.size;
}
constructor(readonly service: Service) { constructor(readonly service: Service) {
this.temp = resolve(service.cfg.uploadDir, 'temporary'); this.temp = resolve(service.cfg.uploadDir, 'temporary');
this.originals = resolve(service.cfg.uploadDir, 'originals'); this.originals = resolve(service.cfg.uploadDir, 'originals');
@@ -141,6 +144,13 @@ export class Storage {
.from(s.reservations) .from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now())) .where(gt(s.reservations.expiresAt, Date.now()))
.get()!.bytes; .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 }); const disk = statfsSync(this.originals, { bigint: true });
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize) if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.'); fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
@@ -267,6 +277,14 @@ export class Storage {
file = this.fileRows(requestId).find((f) => f.position === position)!; file = this.fileRows(requestId).find((f) => f.position === position)!;
if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.'); if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.'); 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 ( if (
file.status === 'uploading' || file.status === 'uploading' ||
[...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`) [...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 { version as appVersion } from '../package.json';
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared'; import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
import AccountPanel from './AccountPanel.vue'; import AccountPanel from './AccountPanel.vue';
import AdminPanel from './AdminPanel.vue';
import SendComposer from './SendComposer.vue'; import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue'; import InstallButton from './InstallButton.vue';
import LogoMark from './LogoMark.vue'; import LogoMark from './LogoMark.vue';
@@ -550,6 +551,12 @@ watch(
}, },
); );
let timer; let timer;
watch(
() => account.value?.administrator,
(allowed) => {
if (!allowed && tab.value === 'admin') tab.value = 'devices';
},
);
onMounted(async () => { onMounted(async () => {
applyTheme(); applyTheme();
media.addEventListener('change', applyTheme); media.addEventListener('change', applyTheme);
@@ -816,6 +823,16 @@ async function spaceAction(space, action, memberId) {
<button role="menuitem" @click="openAbout"> <button role="menuitem" @click="openAbout">
<Info :size="18" /><span>서비스 정보</span> <Info :size="18" /><span>서비스 정보</span>
</button> </button>
<button
v-if="account?.administrator"
role="menuitem"
@click="
tab = 'admin';
closeHeaderMore();
"
>
<ShieldCheck :size="18" /><span>관리자</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1166,6 +1183,10 @@ async function spaceAction(space, action, memberId) {
</div> </div>
</section> </section>
</template> </template>
<AdminPanel
v-else-if="tab === 'admin' && account?.administrator"
@devices="tab = 'devices'"
/>
<template v-else <template v-else
><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault" ><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault"
/></template> /></template>

View File

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

View File

@@ -3908,3 +3908,303 @@ a.button {
flex-shrink: 0; flex-shrink: 0;
margin: 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);
}

View File

@@ -203,6 +203,9 @@ https://hop.noname.company
| `USER_STORAGE_LIMIT_MB=2048` | 로그인 계정 **개인별** 보관함 2GiB. 임시 저장·받은 파일 합산, 같은 원본은 한 계정에서 한 번 계산 | | `USER_STORAGE_LIMIT_MB=2048` | 로그인 계정 **개인별** 보관함 2GiB. 임시 저장·받은 파일 합산, 같은 원본은 한 계정에서 한 번 계산 |
| `GUEST_STORAGE_LIMIT_MB=2048` | 비로그인 기기 세션이 서버에 올리는 파일 총량 2GiB. 로그인 후 기기 간 전송도 해당 기기 세션을 사용 | | `GUEST_STORAGE_LIMIT_MB=2048` | 비로그인 기기 세션이 서버에 올리는 파일 총량 2GiB. 로그인 후 기기 간 전송도 해당 기기 세션을 사용 |
| `TOTAL_GUEST_STORAGE_LIMIT_MB=20480` | 서버 전체 익명 업로드 원본의 합계 20GiB | | `TOTAL_GUEST_STORAGE_LIMIT_MB=20480` | 서버 전체 익명 업로드 원본의 합계 20GiB |
| `TOTAL_STORAGE_LIMIT_MB=20480` | 로그인·비로그인 전체 원본과 유효 업로드 예약 합계 20GiB. 관리자 설정의 초기값 |
| `MAX_CONCURRENT_UPLOADS=4` | 동시에 처리할 업로드 HTTP 요청·파일 저장 작업의 상한. 관리자 설정의 초기값 |
| `AUDIT_RETENTION_DAYS=30` | 관리자 설정 변경·원본 삭제 기록의 보관 일수 |
| `MIN_FREE_DISK_MB=5120` | NAS에 최소 5GiB 여유 공간 확보 | | `MIN_FREE_DISK_MB=5120` | NAS에 최소 5GiB 여유 공간 확보 |
| `DEFAULT_FILE_RETENTION_DAYS=7` | 정상 저장한 파일 최대 7일 후 삭제 | | `DEFAULT_FILE_RETENTION_DAYS=7` | 정상 저장한 파일 최대 7일 후 삭제 |
| `ORPHAN_UPLOAD_RETENTION_HOURS=24` | 중단·방치한 업로드 조각 정리 기준 24시간 | | `ORPHAN_UPLOAD_RETENTION_HOURS=24` | 중단·방치한 업로드 조각 정리 기준 24시간 |
@@ -228,7 +231,7 @@ NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps →
앱 시작 시 기존 평면 `originals` 및 `completed`의 파일을 DB에 기록된 업로드 소유자 폴더로 이동하고 확장자도 적용합니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 `guest-기기세션ID` 폴더로 이동하며 로그인 계정을 추정하지 않습니다. 파일 ID·다운로드 주소·만료일·사용량과 기존 자료는 유지합니다. 진행 중인 파일 읽기가 있으면 이동을 미루고 DB 변경 실패 시 원래 경로로 복원합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 기록으로 경로와 DB를 맞춥니다. 빈 기존 `completed` 폴더만 제거하며 새 `completed` 폴더는 만들지 않습니다. 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다. 앱 시작 시 기존 평면 `originals` 및 `completed`의 파일을 DB에 기록된 업로드 소유자 폴더로 이동하고 확장자도 적용합니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 `guest-기기세션ID` 폴더로 이동하며 로그인 계정을 추정하지 않습니다. 파일 ID·다운로드 주소·만료일·사용량과 기존 자료는 유지합니다. 진행 중인 파일 읽기가 있으면 이동을 미루고 DB 변경 실패 시 원래 경로로 복원합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 기록으로 경로와 DB를 맞춥니다. 빈 기존 `completed` 폴더만 제거하며 새 `completed` 폴더는 만들지 않습니다. 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
각 사용자 폴더의 `owner-info.json`에서 업로드 요청 시 확인한 국가·지역·도시·접속 IP와 업로드 주체를 확인합니다. 최대 최근 20개·기본 7일의 기록이며 마지막 원본과 대기 업로드가 모두 사라지면 정보 파일도 정리합니다. 좌표·세션 쿠키·CSRF·파일 본문은 저장하지 않습니다. 일반 사용자의 API·보관함·공개 URL로 제공하지 않습니다. 외부 GeoIP 조회 없이 기본 제공 값만 사용하며 미제공 지역과 과거 자료는 확인 불가입니다. 각 사용자 폴더의 `owner-info.json`에서 업로드 요청 시 확인한 국가·지역·도시·접속 IP와 업로드 주체를 확인합니다. 최대 최근 20개·기본 7일의 기록이며 마지막 원본과 대기 업로드가 모두 사라지면 정보 파일과 빈 사용자 폴더도 정리합니다. 좌표·세션 쿠키·CSRF·파일 본문은 저장하지 않습니다. 일반 사용자의 API·보관함·공개 URL로 제공하지 않습니다. 외부 GeoIP 조회 없이 기본 제공 값만 사용하며 미제공 지역과 과거 자료는 확인 불가입니다.
지역은 `CLIENT_IP_SOURCE=cloudflare`일 때 검증된 인접 프록시에서 온 Cloudflare 헤더만 사용합니다. 국가 정보는 IP geolocation, 도시 정보는 **Cloudflare → Rules → Settings/Managed Transforms → Add visitor location headers**를 활성화하면 전달받을 수 있습니다. 현재 대시보드 메뉴가 다르면 [Cloudflare 공식 설정 문서](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers)를 참고하세요. CF 좌표·우편번호는 무시합니다. VPN·통신사망 등으로 실제 거주지와 다를 수 있습니다. 악용 대응용 관리자 화면·국가/IP 차단은 별도로 관리하며 실제 규칙은 운영자가 지정합니다. 지역은 `CLIENT_IP_SOURCE=cloudflare`일 때 검증된 인접 프록시에서 온 Cloudflare 헤더만 사용합니다. 국가 정보는 IP geolocation, 도시 정보는 **Cloudflare → Rules → Settings/Managed Transforms → Add visitor location headers**를 활성화하면 전달받을 수 있습니다. 현재 대시보드 메뉴가 다르면 [Cloudflare 공식 설정 문서](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers)를 참고하세요. CF 좌표·우편번호는 무시합니다. VPN·통신사망 등으로 실제 거주지와 다를 수 있습니다. 악용 대응용 관리자 화면·국가/IP 차단은 별도로 관리하며 실제 규칙은 운영자가 지정합니다.
@@ -237,7 +240,7 @@ NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps →
- **다운로드 실패·수신자 이탈:** 이미 저장된 원본은 바로 삭제되지 않습니다. 받은 항목에 저장된 파일은 로그인 후 만료 전에 다시 다운로드할 수 있습니다. 개인 용량 초과로 받은 항목에 저장되지 않았거나 비로그인으로 받은 경우, 사이트를 떠난 뒤 이전 다운로드를 다시 찾는 UI는 제공하지 않습니다. 이때는 다시 전송하는 방법을 사용합니다. 다운로드 시작 표시는 기기에 저장이 끝났다는 확인을 의미하지 않습니다. - **다운로드 실패·수신자 이탈:** 이미 저장된 원본은 바로 삭제되지 않습니다. 받은 항목에 저장된 파일은 로그인 후 만료 전에 다시 다운로드할 수 있습니다. 개인 용량 초과로 받은 항목에 저장되지 않았거나 비로그인으로 받은 경우, 사이트를 떠난 뒤 이전 다운로드를 다시 찾는 UI는 제공하지 않습니다. 이때는 다시 전송하는 방법을 사용합니다. 다운로드 시작 표시는 기기에 저장이 끝났다는 확인을 의미하지 않습니다.
- **업로드 도중 이탈:** 완료되지 않은 업로드는 받은 항목에 생성되지 않습니다. 취소된 조각은 제거하며, 남은 미완료·고아 업로드는 기본 24시간 후 정리합니다. 브라우저를 떠난 뒤 자동 전송·자동 다운로드 재개는 보장하지 않습니다. - **업로드 도중 이탈:** 완료되지 않은 업로드는 받은 항목에 생성되지 않습니다. 취소된 조각은 제거하며, 남은 미완료·고아 업로드는 기본 24시간 후 정리합니다. 브라우저를 떠난 뒤 자동 전송·자동 다운로드 재개는 보장하지 않습니다.
- **만료·삭제:** 기본 보관 기간은 항목별 7일이며, 정리 작업은 약 1분 간격입니다. 같은 원본을 사용하는 유효 접근 권한이 하나라도 남아 있으면 유지합니다. 모두 만료·삭제되면 삭제 실행 취소 시간 이후 원본을 제거합니다. 재배포·재시작만으로 파일이 삭제되지는 않습니다. - **만료·삭제:** 기본 보관 기간은 항목별 7일이며, 정리 작업은 약 1분 간격입니다. 같은 원본을 사용하는 유효 접근 권한이 하나라도 남아 있으면 유지합니다. 모두 만료·삭제되면 삭제 실행 취소 시간 이후 원본을 제거합니다. 재배포·재시작만으로 파일이 삭제되지는 않습니다.
- **관리자 접근:** NAS·Docker 볼륨과 DB를 읽을 권한이 있는 관리자는 파일과 연결 정보를 확인할 수 있습니다. 종단 간 암호화나 파일 자체 암호화는 적용하지 않습니다. 앱에는 관리자가 모든 사용자 파일을 열람하는 별도 화면이 없으며, 일반 계정은 허용된 본인 항목만 조회할 수 있습니다. - **관리자 접근:** NAS·Docker 볼륨과 DB를 읽을 권한이 있는 관리자는 파일과 연결 정보를 확인할 수 있습니다. 종단 간 암호화나 파일 자체 암호화는 적용하지 않습니다. 앱의 서비스 관리 화면에서는 인증된 `hello@noname.company`만 파일 메타데이터와 최근 지역/IP를 조회·원본 삭제할 수 있습니다. 파일 내용을 내려받는 관리자 API는 제공하지 않으며 일반 계정은 허용된 본인 항목만 조회할 수 있습니다.
개인 한도가 차도 **수신·다운로드는 계속**합니다. 새로 받은 파일은 받은 항목에 저장하지 않고 짧은 안내를 표시합니다. 직접 저장은 용량 부족 시 거절합니다. 링크·메모는 파일 용량 합계에 포함하지 않습니다. 기존 10GiB 기본값은 설정 스크립트가 2GiB로 바꾸며, 다른 값으로 조정한 설정과 기존 자료는 유지합니다. 개인 한도가 차도 **수신·다운로드는 계속**합니다. 새로 받은 파일은 받은 항목에 저장하지 않고 짧은 안내를 표시합니다. 직접 저장은 용량 부족 시 거절합니다. 링크·메모는 파일 용량 합계에 포함하지 않습니다. 기존 10GiB 기본값은 설정 스크립트가 2GiB로 바꾸며, 다른 값으로 조정한 설정과 기존 자료는 유지합니다.
@@ -471,3 +474,26 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다.
수신 파일 용량은 0.1MB 이상부터 MB, 1GB 이상부터 GB로 표시합니다. 그보다 작은 파일은 KB 또는 ‘1 KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 단위는 MB=1,000,000바이트, GB=1,000,000,000바이트입니다. 서버의 MiB/GiB 기준 저장·파일 한도는 유지합니다. 수신 파일 용량은 0.1MB 이상부터 MB, 1GB 이상부터 GB로 표시합니다. 그보다 작은 파일은 KB 또는 ‘1 KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 단위는 MB=1,000,000바이트, GB=1,000,000,000바이트입니다. 서버의 MiB/GiB 기준 저장·파일 한도는 유지합니다.
## 관리자 화면과 업로드 보호
이메일 인증을 완료한 `hello@noname.company` 계정으로 로그인한 뒤 **더보기 → 관리자**를 선택합니다. 별도의 관리자 비밀번호나 환경변수 없이 이 정확한 활성·인증 계정만 권한을 갖습니다. 서버에서도 권한을 검사하며 다른 일반 계정에는 메뉴와 API를 제공하지 않습니다.
저장 파일의 업로드 주체·최근 제공 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 해당 업로드 주체의 최근 기록이며 모든 파일마다 정확한 위치를 추적한 정보는 아닙니다.
**접속 · 용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도 1~1024GiB를 변경합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 기존 개인·기기 용량 제한, 파일당 크기 제한, 최소 5GiB 디스크 여유와 전송 요청 제한도 유지합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
**신규 업로드 중지**는 새 파일 전송·임시 저장을 거절합니다. 중지 전에 수락한 파일, 이미 저장된 원본의 재전송·다운로드, 링크·텍스트 전송은 계속됩니다. 한도를 낮춰도 기존 자료를 자동 삭제하지 않습니다.
국가 차단은 `CN, RU`처럼 두 자리 코드를 입력하고, IP는 `203.0.113.10`, `198.51.100.0/24`, IPv6 주소/대역을 쉼표나 줄바꿈으로 구분합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용하므로 미제공 국가는 국가 규칙으로 차단하지 않습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
관리 설정은 DB의 `admin_settings`, 변경·원본 삭제 이력은 `admin_audit`에 보관합니다. 이력은 기본 30일이며 설정은 재시작 뒤 유지됩니다. 한 번 저장한 관리 설정은 `.env` 초기값보다 우선하고 이후에도 관리자 화면에서 수정합니다. 화면은 수동 새로고침만 사용하며 상시 목록 조회를 하지 않습니다.
이번 변경은 관리 테이블만 추가하며 기존 계정·자료를 초기화하지 않습니다. `docker-compose.yaml`과 볼륨·포트·네트워크·`pull_policy: build`는 변경하지 않습니다. 새 환경변수를 추가하지 않아도 기본값으로 작동합니다. 평소와 같이 다음 명령으로 적용합니다.
```sh
git pull --ff-only origin main
sudo docker compose -f docker-compose.yaml up -d --build
```
앱의 업로드 제한과 차단은 저장 공간·동시 작업을 보호하지만 앱에 도달하기 전의 대규모 트래픽 공격까지 해결하지는 않습니다. 필요하면 Cloudflare 등 앞단의 접근·요청 제한도 함께 적용합니다.

View File

@@ -1,6 +1,6 @@
# 검증 기록 # 검증 기록
현재 적용 범위는 [파일·링크 통합 수정안](TRANSFER_UX.md)이다. 아래 초기 단계의 텍스트 관련 기록은 수정 이전 검증 이력이다. 현재 전송 범위는 [클립보드 전송 수정안](CLIPBOARD_TRANSFER.md)의 파일·링크·일반 텍스트다. 아래 단계별 기록은 당시 구현의 검증 이력이며 현재 동작은 최신 항목을 따른다.
## Phase 1 — 2026-10-08 ## Phase 1 — 2026-10-08
@@ -181,3 +181,13 @@
- 네이티브 파일 선택창의 숨김/복귀 때 위치 재등록보다 POST가 먼저 나가던 상황을 수정 전 브라우저에서 재현했다. 위치 기반 대상은 위치 수신 확인까지 기다리며 같은 네트워크의 잠깐 끊긴 대상은 서버가 재접속 상태로 구분한다. 현재 연결을 최대 5초 기다리고 같은 요청 키로만 재시도해 첫 클릭의 초안·파일을 유지한다. 권한 없음·차단·다른 공간은 재시도하지 않는다. 연결 확인 중 버튼 안내를 적용했다. 개인정보 보호를 위해 숨김 시 좌표 제거는 유지한다. - 네이티브 파일 선택창의 숨김/복귀 때 위치 재등록보다 POST가 먼저 나가던 상황을 수정 전 브라우저에서 재현했다. 위치 기반 대상은 위치 수신 확인까지 기다리며 같은 네트워크의 잠깐 끊긴 대상은 서버가 재접속 상태로 구분한다. 현재 연결을 최대 5초 기다리고 같은 요청 키로만 재시도해 첫 클릭의 초안·파일을 유지한다. 권한 없음·차단·다른 공간은 재시도하지 않는다. 연결 확인 중 버튼 안내를 적용했다. 개인정보 보호를 위해 숨김 시 좌표 제거는 유지한다.
- 로그아웃 후 메인 기기 화면을 표시한다. 사용자 폴더의 owner-info.json에는 검증된 기본 접속 IP·국가·지역·도시만 업로드 요청 시 기록하며 원문·좌표·인증 값은 기록하지 않는다. 제공되지 않은 지역과 과거 파일은 추정하지 않는다. 최대 20개·기본 7일의 기록만 유지하고 마지막 원본/대기 업로드가 사라지면 제거한다. 공개 URL·일반 보관함에서는 제공하지 않는다. - 로그아웃 후 메인 기기 화면을 표시한다. 사용자 폴더의 owner-info.json에는 검증된 기본 접속 IP·국가·지역·도시만 업로드 요청 시 기록하며 원문·좌표·인증 값은 기록하지 않는다. 제공되지 않은 지역과 과거 파일은 추정하지 않는다. 최대 20개·기본 7일의 기록만 유지하고 마지막 원본/대기 업로드가 사라지면 제거한다. 공개 URL·일반 보관함에서는 제공하지 않는다.
- API 114개·전체 빌드·타입 검사 통과. 실제 Chromium에서 파일 선택창 위치 복귀 대기와 단일 요청, 같은 네트워크의 재접속 중 자동 재시도·동일 키·파일 유지·모바일 수락 다운로드, 보관함 원본 재전송·수동 삭제·로그아웃 후 메인 화면을 확인했다. 운영 Docker의 사용자 폴더·확장자·호스트 파일 가시성·재시작 후 재다운로드·수동 삭제·8MiB 조각 재개·Unicode 텍스트와 인증 세션 보존·SQLite integrity도 통과했다. 실제 NAS 배포는 수행하지 않았다. - API 114개·전체 빌드·타입 검사 통과. 실제 Chromium에서 파일 선택창 위치 복귀 대기와 단일 요청, 같은 네트워크의 재접속 중 자동 재시도·동일 키·파일 유지·모바일 수락 다운로드, 보관함 원본 재전송·수동 삭제·로그아웃 후 메인 화면을 확인했다. 운영 Docker의 사용자 폴더·확장자·호스트 파일 가시성·재시작 후 재다운로드·수동 삭제·8MiB 조각 재개·Unicode 텍스트와 인증 세션 보존·SQLite integrity도 통과했다. 실제 NAS 배포는 수행하지 않았다.
## 2026-10-14 서비스 관리·국가/IP 차단·업로드 자원 제한
- 인증된 활성 `hello@noname.company`에만 더보기 관리자 메뉴와 서버 API 권한을 제공한다. 일반·비로그인·미인증·정지 계정, 이메일 로컬 파트 대소문자가 다른 별도 계정과 위조 CSRF의 접근을 거절하는 테스트를 통과했다. 관리자 API는 본문 파싱 전에도 권한을 확인한다.
- 파일 목록·업로드 주체·최근 기본 지역/IP·전체 용량·예약·디스크 여유·진행 중 요청을 표시한다. 두 실제 파일의 커서 페이지 이동, 원본 삭제와 수신 항목 무효화·실제 다운로드 404·용량 회수, 삭제 확인/취소와 관리 이력 만료를 확인했다. 파일 내용 열람/다운로드 API는 추가하지 않았다. 화면은 수동 갱신만 사용한다.
- 신규 업로드 중지/재개·동시 처리 기본 4개·로그인/비로그인 전체 파일과 예약 합계 기본 20GiB를 적용했다. 실제 조각 PUT의 본문 처리 전 동시 요청 제한과 Retry-After, 주체당 2개, 중단/완료 후 자리 반환, 여러 업로드 주체의 원자적 전체 예약과 기존 개인 한도, 중지 전 수락 업로드·링크 유지와 신규 임시 저장 거절을 확인했다. 기존 파일 한도·5GiB 최소 디스크 여유와 조각 전송을 유지하며 들어오는 요청에 5분 타임아웃을 적용했다.
- 국가 및 IPv4/IPv6·CIDR 규칙을 HTTP·Socket.IO에 적용한다. 검증된 Cloudflare 제공 국가만 사용하고 위조 전달 헤더·미제공 지역은 추정하지 않는다. 실제 차단 응답과 연결 거절, 매핑 IPv4/IPv6 대역, 현재 관리자 자신을 차단하는 규칙의 거절을 검증했다. 진행 중 요청의 차단 시 abort/destroy·처리 자리 회수도 검증했다. 기본 차단 목록은 비어 있다.
- 설정 변경은 DB 트랜잭션과 감사 이력으로 저장하고 변경 버전 충돌을 거절한다. DB 오류 롤백 뒤 적용 중 정책 보존과 별도 인스턴스 재로드, 관리 기록 30일 정리를 확인했다. 업로드 주체 정보는 마지막 원본/대기 업로드가 사라지면 제거하고 빈 사용자 폴더도 정리한다.
- API 123개·추가 페이지 이동/별도 계정 격리를 포함한 관리자 9개·전체 타입 검사와 빌드·포맷 검사를 통과했다. 실제 Chromium에서 관리자 전용 메뉴·일반 계정 API 403·지역/IP 표시·일시 중지/재개·규칙 저장·파일 삭제 확인과 이력·390px 모바일 배치/16px 입력·로그아웃 후 권한 제거를 확인했다. 기존 파일/링크 전송·보관함·주변 위치 검색·첫 파일 전송 재접속 브라우저 회귀도 통과했다.
- 최종 운영 Docker 이미지에서 추가 관리 테이블의 자동 마이그레이션, 관리자 인증과 설정 재시작 보존, 기존 파일/텍스트·세션·인증·8MiB 조각 재개·SQLite integrity·사용자별 경로·호스트 파일 수동 삭제를 확인했다. 격리 자원만 사용하고 종료 후 제거했다. `docker-compose.yaml`과 실제 NAS 컨테이너·볼륨·자료·Cloudflare 규칙은 변경하거나 재배포하지 않았다. 도시 정보는 Cloudflare 전달 설정이 있어야 제공되며 대규모 네트워크 공격은 별도 앞단 대응이 필요하다.

View File

@@ -23,7 +23,8 @@
"test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs", "test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs",
"test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs", "test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs",
"test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs", "test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs",
"test:e2e:reconnect": "node --import tsx tests/e2e/transfer-reconnect.mjs" "test:e2e:reconnect": "node --import tsx tests/e2e/transfer-reconnect.mjs",
"test:e2e:admin": "node --import tsx tests/e2e/admin.mjs"
}, },
"engines": { "engines": {
"node": ">=22.12" "node": ">=22.12"

View File

@@ -90,7 +90,7 @@ try {
const database=new Database('/data/database/hop.sqlite'), db=drizzle(database); const database=new Database('/data/database/hop.sqlite'), db=drizzle(database);
const oldId=randomUUID(), nextId=randomUUID(); const oldId=randomUUID(), nextId=randomUUID();
db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run(); db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run();
db.insert(users).values({id:nextId,email:'verified@docker.hop.test',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run(); db.insert(users).values({id:nextId,email:'hello@noname.company',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run();
db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run(); db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run();
database.close(); database.close();
const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'}); const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'});
@@ -112,6 +112,14 @@ try {
`), `),
); );
} }
if (verifiedAccount) {
const saved = JSON.parse(
node(
`const base='http://127.0.0.1:${port}', cookie=${JSON.stringify(verifiedAccount.cookie)};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'},body:'{}'})).json();const r=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({revision:0,maxConcurrentUploads:3,blockedCountries:['CN']})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
),
);
assert.equal(saved.maxConcurrentUploads, 3);
}
const fileState = JSON.parse( const fileState = JSON.parse(
node( node(
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`, `import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
@@ -135,6 +143,14 @@ try {
assert.equal(restored.passwordMinLength, 8); assert.equal(restored.passwordMinLength, 8);
assert.equal(restored.account.id, verifiedAccount.userId); assert.equal(restored.account.id, verifiedAccount.userId);
assert.equal(restored.account.verified, true); assert.equal(restored.account.verified, true);
assert.equal(restored.account.administrator, true);
const settings = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/admin/summary',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify((await r.json()).settings));`,
),
);
assert.equal(settings.maxConcurrentUploads, 3);
assert.deepEqual(settings.blockedCountries, ['CN']);
} }
const preservedText = JSON.parse( const preservedText = JSON.parse(
node( node(
@@ -208,7 +224,7 @@ try {
} }
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}'); assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
console.log( console.log(
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token and session persistence after restart.' : ''}`, `PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
); );
} catch (error) { } catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' })); console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));

213
tests/e2e/admin.mjs Normal file
View File

@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
import { users } from '../../apps/server/src/db/schema.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-admin-e2e-'));
const runtime = await createApp(
{
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
LOCATION_DISCOVERY_ENABLED: 'false',
CLIENT_IP_SOURCE: 'cloudflare',
TRUSTED_PROXY_CIDRS: '127.0.0.1,::1',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
},
{ mailer: async () => {} },
);
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
runtime.setDevOrigin(url);
const passwordHash = await runtime.service.auth.password('12345678');
for (const email of ['hello@noname.company', 'other@hop.test'])
runtime.service.db
.insert(users)
.values({
id: randomUUID(),
email,
displayName: email,
passwordHash,
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const errors = [];
async function page(width = 1280, ip = '8.8.4.4', country = 'KR') {
const context = await browser.newContext({
viewport: { width, height: 900 },
isMobile: width < 600,
hasTouch: width < 600,
extraHTTPHeaders: {
'CF-Connecting-IP': ip,
'CF-IPCountry': country,
'CF-IPCity': country === 'CN' ? 'Beijing' : 'Daejeon',
'CF-Region': country === 'CN' ? 'Beijing' : 'Daejeon',
},
});
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
return page;
}
const sender = await page(1280, '1.1.1.1', 'CN'),
receiver = await page(390, '8.8.8.8', 'KR');
async function session(page) {
return page.evaluate(async () => {
const response = await fetch('/api/guest/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return response.json();
});
}
const a = await session(sender),
b = await session(receiver);
async function call(page, csrf, path, body, method = 'POST') {
return page.evaluate(
async ({ csrf, path, body, method }) => {
const response = await fetch('/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(body),
});
const value = await response.json();
if (!response.ok) throw Error(value.message);
return value;
},
{ csrf, path, body, method },
);
}
const invite = await call(sender, a.csrfToken, '/spaces', {});
await call(receiver, b.csrfToken, '/spaces/join', { code: invite.code });
const transfer = await call(sender, a.csrfToken, '/transfer-requests', {
targetDeviceId: b.device.id,
kind: 'file',
count: 1,
byteSize: 4,
files: [{ name: 'sample.mp4', size: 4, type: 'video/mp4' }],
});
await call(receiver, b.csrfToken, '/transfer-requests/' + transfer.requestId + '/accept', {});
await sender.evaluate(
async ({ csrf, id }) => {
const body = new FormData();
body.append('file', new Blob(['test'], { type: 'video/mp4' }), 'sample.mp4');
const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() },
body,
});
if (!response.ok) throw Error(await response.text());
},
{ csrf: a.csrfToken, id: transfer.requestId },
);
async function login(page, email) {
await page.locator('.login-button').click();
await page.locator('.account-auth').waitFor();
await page.getByLabel('이메일', { exact: true }).fill(email);
await page.getByLabel('비밀번호', { exact: true }).fill('12345678');
await page
.locator('.account-auth')
.getByRole('button', { name: '로그인', exact: true })
.click();
await page.locator('.account-workspace').waitFor();
}
const normal = await page();
await login(normal, 'other@hop.test');
await normal.getByRole('button', { name: '더보기', exact: true }).click();
assert.equal(await normal.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.equal(await normal.evaluate(async () => (await fetch('/api/admin/summary')).status), 403);
const admin = await page();
await login(admin, 'hello@noname.company');
await admin.getByRole('button', { name: '더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '관리자', exact: true }).click();
await admin.locator('.admin-stats').waitFor();
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
assert.match(await admin.locator('.admin-file').innerText(), /중국 · Beijing/);
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
await admin.getByRole('button', { name: '신규 업로드 중지', exact: true }).click();
await admin.getByText('신규 업로드 중지됨', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.uploadsPaused, true);
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
await admin.getByRole('button', { name: '접속 · 용량 설정', exact: true }).click();
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
await admin.getByLabel('전체 파일 저장 한도 (GiB)').fill('16');
await admin.getByLabel('차단할 국가 코드').fill('CN');
await admin.getByLabel('차단할 IP 또는 IP 대역').fill('9.9.9.0/24');
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3);
assert.equal(runtime.service.admin.state.totalStorageLimitMb, 16384);
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await sender.evaluate(async () => (await fetch('/api/device/me')).status), 403);
await admin.getByRole('button', { name: '저장 파일', exact: true }).click();
await admin.screenshot({ path: 'tests/e2e/admin-desktop.png', fullPage: true });
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('alertdialog').waitFor();
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.equal(await admin.locator('.admin-file').count(), 1);
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click();
await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.summary().storedBytes, 0);
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
const mobile = await page(390);
await login(mobile, 'hello@noname.company');
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
await mobile.getByRole('menuitem', { name: '관리자', exact: true }).click();
await mobile.locator('.admin-stats').waitFor();
await mobile.getByRole('button', { name: '접속 · 용량 설정', exact: true }).click();
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
assert.equal(
await mobile
.getByLabel('차단할 국가 코드')
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize) >= 16),
true,
);
await mobile.screenshot({ path: 'tests/e2e/admin-mobile.png', fullPage: true });
await mobile.getByRole('button', { name: '메인으로', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.locator('.login-button').click();
await mobile.getByRole('button', { name: '로그아웃', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.deepEqual(errors, []);
console.log(
'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, pause/resume, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

View File

@@ -123,9 +123,7 @@ try {
document.dispatchEvent(new Event('visibilitychange')); document.dispatchEvent(new Event('visibilitychange'));
}); });
await receiver.locator('button.peer-card').waitFor({ state: 'hidden' }); await receiver.locator('button.peer-card').waitFor({ state: 'hidden' });
await sender await sender.locator('.send-dialog input[type="file"]').setInputFiles({
.locator('.send-dialog input[type="file"]')
.setInputFiles({
name: 'picker.txt', name: 'picker.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
buffer: Buffer.from('file picker return'), buffer: Buffer.from('file picker return'),