diff --git a/README.md b/README.md
index 6414eb4..922c834 100644
--- a/README.md
+++ b/README.md
@@ -85,9 +85,9 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GiB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다.
-국가와 IP 규칙은 HTTP·기기 연결에 적용하고, 규칙에 해당하는 진행 중 업로드와 연결도 종료합니다. IPv4·IPv6와 IP 대역을 지원하며 현재 관리자 접속을 차단하는 설정은 거절합니다. 국가는 검증된 Cloudflare 제공 값만 사용하고, 미제공 국가는 추정하지 않습니다. VPN·실제 거주지 추적은 하지 않습니다. 도시 정보 설정은 [배포 문서](deploy.md#전송-파일이-저장되는-곳과-정리-시점)를 참고하세요.
+네트워크 탭에서 국가·IP 규칙과 자동 차단을 관리합니다. 규칙은 HTTP·기기 연결에 적용하고 차단된 진행 중 업로드와 연결도 종료합니다. IPv4·IPv6와 IP 대역을 지원하며 현재 관리자 접속을 차단하는 설정은 거절합니다. 국가는 검증된 Cloudflare 제공 값만 사용하고, 미제공 국가는 추정하지 않습니다. VPN·실제 거주지 추적은 하지 않습니다. 도시 정보 설정은 [배포 문서](deploy.md#전송-파일이-저장되는-곳과-정리-시점)를 참고하세요.
-관리 설정은 DB에 저장되어 재시작 후 유지하며 `.env`는 초기 기본값으로 사용합니다. 설정 변경·원본 삭제 기록은 기본 30일 보관합니다. 이번 업데이트는 관리 테이블을 추가하는 마이그레이션을 자동 적용하며 기존 계정·파일·볼륨·`docker-compose.yaml`을 유지합니다. 앱의 자원 제한은 대규모 네트워크 공격을 모두 차단하지는 못하므로 외부 트래픽 대응은 Cloudflare 등 앞단 설정도 함께 사용합니다.
+관리 설정은 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).
@@ -132,3 +132,17 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
전송·수신·보관함·관리자 화면은 같은 용량 표시 함수를 사용합니다. 1MB 미만은 KB, 1MB 이상은 MB, 1GB 이상은 GB이며 KB/MB/GB는 1,000 단위입니다. 예: `938KB`, `1.1MB`, `1.7GB`. 기존 서버 저장 한도와 환경변수의 MiB 기준은 유지합니다. 관리자 화면의 GB 설정을 바꾸지 않고 다른 규칙만 저장할 때는 반올림된 표시값 때문에 기존 용량이 바뀌지 않습니다.
파일별 접속 정보는 DB에 최초 전송 요청 당시의 값을 저장하여 이후 같은 사람이 다른 지역에서 전송해도 바뀌지 않습니다. 같은 요청의 여러 파일은 같은 요청 당시 정보를 사용하며 원본 재전송은 원래 정보를 유지합니다. 기본 최대 7일 보관하고 실패·삭제한 파일의 기록은 주기 정리합니다. 기존 파일은 `owner-info.json`에 동일한 요청의 기록이 있을 때만 복원하고, 없으면 다른 파일의 최근 정보로 추정하지 않습니다. 파일 내용·GPS 좌표를 추가로 수집하지 않으며 관리자 외에는 제공하지 않습니다.
+
+## 네트워크·사용자 관리
+
+관리자 탭은 **저장 파일 / 용량 설정 / 네트워크 / 사용자 / 관리 기록**입니다. 국가는 `일본(JP)`처럼 이름과 코드를 함께 표시합니다. 네트워크에는 자동 블랙리스트·블랙리스트·화이트리스트를 둡니다. 국가 또는 IPv4/IPv6 주소·CIDR을 추가 모달에서 한 항목씩 등록하고 표에서 선택 제거한 뒤 네트워크 설정을 저장합니다. 기존 수동 차단 규칙은 유지합니다.
+
+블랙리스트·자동 차단이 우선합니다. 화이트리스트가 비어 있으면 공개하고, 항목이 있으면 등록 국가 **또는** IP/대역에 해당하는 접속만 허용합니다. 국가가 확인되지 않은 접속은 허용 IP와 일치해야 합니다. 현재 관리자 자신을 차단하는 수동 규칙은 저장할 수 없습니다.
+
+자동 차단은 기본 꺼짐이며 관리자가 기준을 확인하고 켭니다. 기본 감지 기간 10분에 로그인 실패 10회, 요청 제한 위반 20회, 파일 전송 시도 30회 초과 또는 요청 파일 용량 10GB 초과를 감지하면 해당 IP를 30분 차단합니다. 모두 관리자에서 변경할 수 있습니다. 수락 전 파일 요청도 계산하며 같은 요청 키의 재시도는 중복 계산하지 않습니다. 로그인 보관함 업로드와 기기 전송은 같은 원본 공인 IP 기준을 공유하므로 기기 쿠키를 새로 받아도 우회하지 못합니다. 정상 조각 전송·서버 혼잡·거절 대기는 이 기준의 제한 위반으로 세지 않습니다. 같은 공인 IP를 공유하는 사람에게 함께 영향을 줄 수 있습니다.
+
+자동 차단 목록에는 IP·국가·사유·차단/해제 시각을 표시하고 선택 해제·검색·더 보기를 지원합니다. 시간 만료로 해제되며 차단 목록은 재시작 뒤 유지합니다. 감지 중인 횟수 창은 메모리에 두고 재시작 시 초기화합니다. 확인되지 않은 프록시 주소를 공인 사용자 IP로 추정해 자동 차단하지 않습니다. 기존 인증된 관리자 세션은 자동 차단 해제를 위해 접속할 수 있지만 수동 블랙리스트·화이트리스트를 우회하지 않습니다.
+
+사용자 탭은 이메일·계정 상태·최근 접속 시각·국가/지역/IP를 관리자에게만 보여 줍니다. 로그인·인증·로그인 상태 API 이용부터 기록하며, 같은 접속 정보는 약 1분에 한 번만 갱신합니다. 마지막 접속 시각은 유지하고 위치/IP는 기본 30일 이후 제거합니다. 과거 지역은 추정하지 않습니다. **더보기**에서 재설정 메일 발송과 계정 삭제를 사용합니다. 재설정은 기존 일회용 메일 절차를 사용하며 사용자가 완료해야 비밀번호와 로그인 상태가 바뀝니다. 삭제는 이메일 확인 후 로그인·토큰·보관함 접근권을 폐기하고 계정을 익명화합니다. 다른 수신자의 파일은 유지하며 관리자 자신은 삭제할 수 없습니다.
+
+관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다.
diff --git a/apps/server/drizzle/0014_network_protection.sql b/apps/server/drizzle/0014_network_protection.sql
new file mode 100644
index 0000000..0964cb5
--- /dev/null
+++ b/apps/server/drizzle/0014_network_protection.sql
@@ -0,0 +1,19 @@
+CREATE TABLE `automatic_blocks` (
+ `ip` text PRIMARY KEY NOT NULL,
+ `countryCode` text,
+ `reason` text NOT NULL,
+ `createdAt` integer NOT NULL,
+ `expiresAt` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `automatic_block_expiry` ON `automatic_blocks` (`expiresAt`);
+--> statement-breakpoint
+CREATE TABLE `user_activity` (
+ `userId` text PRIMARY KEY NOT NULL,
+ `lastSeenAt` integer NOT NULL,
+ `ip` text,
+ `countryCode` text,
+ `region` text,
+ `city` text,
+ FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE
+);
diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json
index 0140b50..1eadbf8 100644
--- a/apps/server/drizzle/meta/_journal.json
+++ b/apps/server/drizzle/meta/_journal.json
@@ -99,6 +99,13 @@
"when": 1792030497381,
"tag": "0013_file_upload_contexts",
"breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "6",
+ "when": 1792031695946,
+ "tag": "0014_network_protection",
+ "breakpoints": true
}
]
}
diff --git a/apps/server/src/administration.js b/apps/server/src/administration.js
index a95e2e4..4a1c058 100644
--- a/apps/server/src/administration.js
+++ b/apps/server/src/administration.js
@@ -3,12 +3,26 @@ 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 { and, eq, gt, lt, or, desc, inArray, sql, isNotNull } from 'drizzle-orm';
import * as s from './db/schema.js';
import { ApiError, fail, normalizeIp } from './security.js';
import { visitorRegion } from './ownerInfo.js';
+import { AutomaticBlocks } from './automaticBlocks.js';
export const ADMIN_EMAIL = 'hello@noname.company';
+export function settingsChanges(before, after) {
+ const changes = [];
+ for (const field of Object.keys(fields)) {
+ if (!(field in before) || !(field in after)) continue;
+ if (Array.isArray(after[field])) {
+ const added = after[field].filter((value) => !before[field].includes(value)),
+ removed = before[field].filter((value) => !after[field].includes(value));
+ if (added.length || removed.length) changes.push({ field, added, removed });
+ } else if (before[field] !== after[field])
+ changes.push({ field, before: before[field], after: after[field] });
+ }
+ return changes;
+}
function rule(value) {
const [address, prefix] = value.includes('/')
? ipaddr.parseCIDR(value)
@@ -53,6 +67,37 @@ const fields = {
)
.max(250),
blockedIps: z.array(z.string().trim().max(100).refine(validRule)).max(100),
+ blacklistEnabled: z.boolean(),
+ allowedCountries: z
+ .array(
+ z
+ .string()
+ .trim()
+ .toUpperCase()
+ .regex(/^[A-Z]{2}$/)
+ .refine((code) => code !== 'XX'),
+ )
+ .max(250),
+ allowedIps: z.array(z.string().trim().max(100).refine(validRule)).max(100),
+ autoBlockEnabled: z.boolean(),
+ autoWindowMinutes: z.number().int().min(1).max(1440),
+ autoLoginFailures: z.number().int().min(3).max(1000),
+ autoRateViolations: z.number().int().min(3).max(1000),
+ autoUploadRequests: z.number().int().min(3).max(10000),
+ autoUploadBytes: z.number().int().min(1000000).max(1e12),
+ autoBlockMinutes: z.number().int().min(1).max(10080),
+};
+const networkDefaults = {
+ blacklistEnabled: true,
+ allowedCountries: [],
+ allowedIps: [],
+ autoBlockEnabled: false,
+ autoWindowMinutes: 10,
+ autoLoginFailures: 10,
+ autoRateViolations: 20,
+ autoUploadRequests: 30,
+ autoUploadBytes: 10e9,
+ autoBlockMinutes: 30,
};
const stateSchema = z.object({ revision: z.number().int().min(0), ...fields }).strict();
export const adminPatch = z
@@ -61,6 +106,26 @@ export const adminPatch = z
.extend({ revision: z.number().int().min(0) })
.strict()
.refine((body) => Object.keys(body).length > 1, '변경할 설정이 필요합니다.');
+export const networkRuleInput = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('country'), value: fields.blockedCountries.element }).strict(),
+ z.object({ kind: z.literal('ip'), value: fields.blockedIps.element }).strict(),
+]);
+export function normalizeNetworkRule(input) {
+ if (input.kind === 'country') return input;
+ const [address, prefix] = rule(input.value);
+ return {
+ ...input,
+ value: input.value.includes('/') ? `${address}/${prefix}` : address.toString(),
+ };
+}
+export const automaticBlockRemoval = z
+ .object({
+ ips: z
+ .array(z.string().refine((value) => Boolean(normalizeIp(value))))
+ .min(1)
+ .max(100),
+ })
+ .strict();
export class Administration {
constructor(service) {
@@ -72,7 +137,7 @@ export class Administration {
.where(eq(s.adminSettings.name, 'controls'))
.get();
this.state = row
- ? stateSchema.parse(JSON.parse(row.value))
+ ? stateSchema.parse({ ...networkDefaults, ...JSON.parse(row.value) })
: {
revision: 0,
uploadsPaused: false,
@@ -80,12 +145,16 @@ export class Administration {
totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576,
blockedCountries: [],
blockedIps: [],
+ ...networkDefaults,
};
+ this.automatic = new AutomaticBlocks(service);
this.compile();
}
compile() {
this.ipRules = this.state.blockedIps.map(rule);
this.countries = new Set(this.state.blockedCountries);
+ this.allowedIpRules = this.state.allowedIps.map(rule);
+ this.allowedCountries = new Set(this.state.allowedCountries);
}
isAdmin(user) {
return Boolean(user?.status === 'active' && user.verifiedAt && user.email === ADMIN_EMAIL);
@@ -98,9 +167,32 @@ export class Administration {
}
blocked(req) {
const context = visitorRegion(req, this.service.cfg);
+ const direct = normalizeIp(req.raw.socket.remoteAddress ?? '');
+ if (this.policyDenied({ ...context, ip: context.ip ?? direct })) return true;
+ const token =
+ req.cookies?.hop_account ??
+ /(?:^|;\s*)hop_account=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
+ // Existing verified administrator sessions can recover an automatic IP ban.
return (
- matchesIp(context.ip ?? normalizeIp(req.raw.socket.remoteAddress ?? ''), this.ipRules) ||
- Boolean(context.countryCode && this.countries.has(context.countryCode))
+ this.state.autoBlockEnabled &&
+ this.automatic.has(context.ip) &&
+ !this.isAdmin(token ? this.service.auth?.session(token)?.user : null)
+ );
+ }
+ policyDenied(context, state = this.state) {
+ const blackIps = state === this.state ? this.ipRules : state.blockedIps.map(rule),
+ whiteIps = state === this.state ? this.allowedIpRules : state.allowedIps.map(rule),
+ blackCountries = state === this.state ? this.countries : new Set(state.blockedCountries),
+ whiteCountries =
+ state === this.state ? this.allowedCountries : new Set(state.allowedCountries);
+ if (
+ state.blacklistEnabled &&
+ (matchesIp(context.ip, blackIps) || blackCountries.has(context.countryCode))
+ )
+ return true;
+ return (
+ Boolean(state.allowedCountries.length || state.allowedIps.length) &&
+ !(matchesIp(context.ip, whiteIps) || whiteCountries.has(context.countryCode))
);
}
assertAllowed(req) {
@@ -153,7 +245,7 @@ export class Administration {
.insert(s.adminAudit)
.values({
id: randomUUID(),
- actorUserId: actor.id,
+ actorUserId: actor?.id ?? null,
action,
details: JSON.stringify(details),
createdAt: Date.now(),
@@ -169,6 +261,10 @@ export class Administration {
);
const next = stateSchema.parse({ ...this.state, ...input, revision: this.state.revision + 1 });
next.blockedCountries = [...new Set(next.blockedCountries)].sort();
+ next.allowedCountries = [...new Set(next.allowedCountries)].sort();
+ next.allowedIps = [
+ ...new Set(next.allowedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
+ ].sort();
next.blockedIps = [
...new Set(
next.blockedIps.map((value) => {
@@ -180,11 +276,15 @@ export class Administration {
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)
+ this.policyDenied(
+ { ...context, ip: context.ip ?? normalizeIp(req.raw.socket.remoteAddress ?? '') },
+ next,
+ ) ||
+ (next.blacklistEnabled && matchesIp(normalizeIp(req.raw.socket.remoteAddress ?? ''), rules))
)
fail(400, 'ADMIN_SELF_BLOCK', '현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.');
+ const changes = settingsChanges(this.state, next);
+ if (!changes.length) return { ...this.state };
this.service.db.transaction(() => {
this.service.db
.insert(s.adminSettings)
@@ -194,20 +294,27 @@ export class Administration {
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.audit(actor, 'settings-change', { changes });
});
+ if (
+ Object.keys(networkDefaults).some(
+ (key) => key.startsWith('auto') && next[key] !== this.state[key],
+ )
+ )
+ this.automatic.resetCounters();
this.state = next;
this.compile();
+ this.disconnectBlocked();
+ return { ...this.state };
+ }
+ disconnectBlocked() {
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))
+ this.policyDenied({
+ ip: entry.ip ?? normalizeIp(entry.raw.socket.remoteAddress ?? ''),
+ countryCode: entry.countryCode,
+ }) ||
+ (this.state.autoBlockEnabled && this.automatic.has(entry.ip))
) {
entry.raw.emit('aborted');
entry.raw.destroy();
@@ -218,7 +325,6 @@ export class Administration {
socket.emit('service:blocked');
socket.disconnect(true);
}
- return { ...this.state };
}
summary() {
const db = this.service.db;
@@ -254,6 +360,9 @@ export class Administration {
activeAccounts: Number(accounts.count),
activeRequests: this.inflight.size,
activeFileWrites: this.service.storage.activeUploadCount,
+ automaticBlockCount: [...this.automatic.active.values()].filter(
+ (row) => row.expiresAt > Date.now(),
+ ).length,
requests: [...this.inflight.values()].map(({ actor, raw, response, ...value }) => value),
};
}
@@ -347,9 +456,236 @@ export class Administration {
.orderBy(desc(s.adminAudit.createdAt), desc(s.adminAudit.id))
.limit(50)
.all()
- .map((event) => ({ ...event, details: JSON.parse(event.details) }));
+ .map((event) => this.eventDetails(event));
+ }
+ eventDetails(event) {
+ let details = JSON.parse(event.details);
+ if (event.action === 'settings-change' && !details.changes) {
+ const prior = this.service.db
+ .select()
+ .from(s.adminAudit)
+ .where(
+ and(
+ eq(s.adminAudit.action, 'settings-change'),
+ lt(s.adminAudit.createdAt, event.createdAt),
+ ),
+ )
+ .orderBy(desc(s.adminAudit.createdAt))
+ .limit(1)
+ .get();
+ const previous = prior ? JSON.parse(prior.details) : null;
+ details = {
+ changes: previous && !previous.changes ? settingsChanges(previous, details) : [],
+ legacy: true,
+ };
+ }
+ return { ...event, details };
+ }
+ eventPage(offset = 0) {
+ const rows = this.service.db
+ .select()
+ .from(s.adminAudit)
+ .orderBy(desc(s.adminAudit.createdAt), desc(s.adminAudit.id))
+ .limit(51)
+ .offset(offset)
+ .all();
+ return {
+ events: rows.slice(0, 50).map((row) => this.eventDetails(row)),
+ nextOffset: rows.length > 50 ? offset + 50 : null,
+ };
+ }
+ removeEvents(ids, all = false) {
+ const query = this.service.db.delete(s.adminAudit);
+ if (all) query.run();
+ else query.where(inArray(s.adminAudit.id, ids)).run();
+ return { ok: true };
+ }
+ recordUser(req, authenticatedUserId) {
+ try {
+ this.recordUserContext(req, authenticatedUserId);
+ } catch {
+ req.log?.warn('User activity metadata could not be recorded');
+ }
+ }
+ recordUserContext(req, authenticatedUserId) {
+ const userId =
+ authenticatedUserId ?? this.service.auth.session(req.cookies.hop_account)?.user.id;
+ if (!userId) return;
+ const context = visitorRegion(req, this.service.cfg),
+ old = this.service.db
+ .select()
+ .from(s.userActivity)
+ .where(eq(s.userActivity.userId, userId))
+ .get();
+ if (
+ old &&
+ old.lastSeenAt > Date.now() - 60000 &&
+ ['ip', 'countryCode', 'region', 'city'].every((key) => old[key] === context[key])
+ )
+ return;
+ const row = {
+ userId,
+ lastSeenAt: Date.now(),
+ ip: context.ip,
+ countryCode: context.countryCode,
+ region: context.region,
+ city: context.city,
+ };
+ this.service.db
+ .insert(s.userActivity)
+ .values(row)
+ .onConflictDoUpdate({ target: s.userActivity.userId, set: row })
+ .run();
+ }
+ users({ offset = 0, q = '' } = {}) {
+ const rows = this.service.db
+ .select({
+ id: s.users.id,
+ email: s.users.email,
+ name: s.users.displayName,
+ status: s.users.status,
+ verifiedAt: s.users.verifiedAt,
+ createdAt: s.users.createdAt,
+ lastSeenAt: s.userActivity.lastSeenAt,
+ ip: s.userActivity.ip,
+ countryCode: s.userActivity.countryCode,
+ region: s.userActivity.region,
+ city: s.userActivity.city,
+ })
+ .from(s.users)
+ .leftJoin(s.userActivity, eq(s.userActivity.userId, s.users.id))
+ .where(
+ and(
+ inArray(s.users.status, ['pending', 'active', 'suspended']),
+ q ? sql`instr(lower(${s.users.email}),lower(${q})) > 0` : undefined,
+ ),
+ )
+ .orderBy(desc(s.users.createdAt), desc(s.users.id))
+ .limit(51)
+ .offset(offset)
+ .all();
+ const cutoff = Date.now() - this.service.cfg.auditDays * 86400000;
+ return {
+ users: rows
+ .slice(0, 50)
+ .map((row) =>
+ row.lastSeenAt && row.lastSeenAt < cutoff
+ ? { ...row, ip: null, countryCode: null, region: null, city: null }
+ : row,
+ ),
+ nextOffset: rows.length > 50 ? offset + 50 : null,
+ };
+ }
+ async resetUserPassword(actor, id) {
+ const user = this.service.auth.user(id);
+ if (!user.email || !['pending', 'active'].includes(user.status))
+ fail(409, 'ACCOUNT_UNAVAILABLE', '이 계정에 재설정 메일을 보낼 수 없습니다.');
+ await this.service.auth.resend(user.email, 'reset', `admin:${actor.id}`);
+ this.audit(actor, 'password-reset-email', { userId: id, email: user.email });
+ return { ok: true };
+ }
+ async deleteUser(actor, id, email) {
+ const user = this.service.auth.user(id),
+ db = this.service.db,
+ now = Date.now();
+ if (id === actor.id || user.email === ADMIN_EMAIL)
+ fail(403, 'ADMIN_ACCOUNT_PROTECTED', '관리자 계정은 여기서 삭제할 수 없습니다.');
+ if (user.status === 'deleted' || !user.email)
+ fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
+ if (email !== user.email)
+ fail(
+ 409,
+ 'ACCOUNT_CONFIRMATION_CHANGED',
+ '대상 이메일이 일치하지 않습니다. 목록을 다시 확인해 주세요.',
+ );
+ const pending = db
+ .select({ id: s.requests.id })
+ .from(s.requests)
+ .innerJoin(s.batches, eq(s.batches.requestId, s.requests.id))
+ .where(
+ and(
+ or(
+ eq(s.requests.senderUserId, id),
+ eq(s.requests.recipientUserId, id),
+ inArray(
+ s.requests.id,
+ db
+ .select({ id: s.vaultTransfers.requestId })
+ .from(s.vaultTransfers)
+ .where(eq(s.vaultTransfers.userId, id)),
+ ),
+ ),
+ inArray(s.batches.status, ['waiting', 'uploading']),
+ inArray(s.requests.status, ['pending', 'accepted']),
+ ),
+ )
+ .all();
+ db.transaction(() => {
+ db.update(s.users)
+ .set({
+ status: 'deleted',
+ email: null,
+ passwordHash: null,
+ verifiedAt: null,
+ displayName: '삭제된 계정',
+ receivePolicy: 'none',
+ })
+ .where(eq(s.users.id, id))
+ .run();
+ db.update(s.accountSessions)
+ .set({ revokedAt: now })
+ .where(eq(s.accountSessions.userId, id))
+ .run();
+ db.update(s.accountTokens).set({ usedAt: now }).where(eq(s.accountTokens.userId, id)).run();
+ db.update(s.access)
+ .set({ deletedAt: now - this.service.cfg.undoSeconds * 1000 - 1, note: null })
+ .where(eq(s.access.userId, id))
+ .run();
+ db.delete(s.userActivity).where(eq(s.userActivity.userId, id)).run();
+ for (const request of pending) {
+ db.update(s.requests)
+ .set({ status: 'cancelled' })
+ .where(eq(s.requests.id, request.id))
+ .run();
+ db.update(s.batches)
+ .set({ status: 'failed' })
+ .where(eq(s.batches.requestId, request.id))
+ .run();
+ }
+ this.audit(actor, 'account-delete', { userId: id, email: user.email });
+ });
+ this.service.auth.revoke(id);
+ for (const request of pending) {
+ this.service.storage.release(request.id);
+ this.service.notifyTransfer(request.id);
+ }
+ const owner = this.service.storage.layout.ownerInfo.details(`user-${id}/file`);
+ try {
+ const info = this.service.storage.layout.ownerInfo;
+ if (owner) info.write(owner, info.read(owner).events);
+ } catch {}
+ try {
+ await this.service.storage.cleanup();
+ } catch {}
+ return { ok: true };
}
cleanup() {
+ this.automatic.prune();
+ this.service.db
+ .update(s.userActivity)
+ .set({ ip: null, countryCode: null, region: null, city: null })
+ .where(
+ and(
+ lt(s.userActivity.lastSeenAt, Date.now() - this.service.cfg.auditDays * 86400000),
+ or(
+ isNotNull(s.userActivity.ip),
+ isNotNull(s.userActivity.countryCode),
+ isNotNull(s.userActivity.region),
+ isNotNull(s.userActivity.city),
+ ),
+ ),
+ )
+ .run();
this.service.db
.delete(s.adminAudit)
.where(lt(s.adminAudit.createdAt, Date.now() - this.service.cfg.auditDays * 86400000))
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 7039b98..26be1a1 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -31,7 +31,12 @@ import { openDatabase } from './db/index.js';
import { Service, type Guest } from './service.js';
import { Presence } from './presence.js';
import { visitorRegion } from './ownerInfo.js';
-import { adminPatch } from './administration.js';
+import {
+ adminPatch,
+ networkRuleInput,
+ normalizeNetworkRule,
+ automaticBlockRemoval,
+} from './administration.js';
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
import { CHUNK_BYTES } from './resumable.js';
@@ -253,7 +258,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
);
- service.admin.assertAllowed(req);
+ const localHealth =
+ ['GET', 'HEAD'].includes(req.method) &&
+ req.url.split('?')[0] === '/api/health' &&
+ ['127.0.0.1', '::1'].includes(normalizeIp(req.raw.socket.remoteAddress ?? '') ?? '') &&
+ !req.headers['cf-connecting-ip'] &&
+ !req.headers['x-forwarded-for'];
+ if (!localHealth) service.admin.assertAllowed(req);
if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store');
const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable');
@@ -285,9 +296,21 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
const current = service.auth.session(req.cookies.hop_account);
service.admin.admit(req, reply, current?.user.id ?? auth(req).id);
}
+ service.admin.recordUser(req);
}
});
app.setErrorHandler((err, req, reply) => {
+ if (
+ err instanceof ApiError &&
+ err.code === 'RATE_LIMIT' &&
+ !req.url.startsWith('/api/uploads/resumable')
+ ) {
+ try {
+ service.admin.automatic.observe(req, 'rate');
+ } catch {
+ req.log.warn('Automatic network protection could not be recorded');
+ }
+ }
let status = 500,
code = 'INTERNAL_ERROR',
message = '처리 중 오류가 발생했습니다.';
@@ -364,8 +387,69 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
);
app.get('/api/admin/audit', async (req) => {
service.admin.require(req.cookies.hop_account);
- return { events: service.admin.events() };
+ const { offset } = z
+ .object({ offset: z.coerce.number().int().min(0).max(100000).default(0) })
+ .strict()
+ .parse(req.query);
+ return service.admin.eventPage(offset);
});
+ app.post('/api/admin/audit/remove', { bodyLimit: 16384 }, async (req) => {
+ service.admin.require(req.cookies.hop_account);
+ const input = z
+ .union([
+ z.object({ all: z.literal(true) }).strict(),
+ z.object({ ids: z.array(z.uuid()).min(1).max(100) }).strict(),
+ ])
+ .parse(req.body);
+ return service.admin.removeEvents('ids' in input ? input.ids : [], 'all' in input);
+ });
+ app.get('/api/admin/users', async (req) => {
+ service.admin.require(req.cookies.hop_account);
+ return service.admin.users(
+ z
+ .object({
+ offset: z.coerce.number().int().min(0).max(100000).default(0),
+ q: z.string().trim().max(254).default(''),
+ })
+ .strict()
+ .parse(req.query),
+ );
+ });
+ app.post('/api/admin/users/:id/password-reset', { bodyLimit: 1024 }, async (req) =>
+ service.admin.resetUserPassword(service.admin.require(req.cookies.hop_account), id(req)),
+ );
+ app.post('/api/admin/users/:id/delete', { bodyLimit: 1024 }, async (req) =>
+ service.admin.deleteUser(
+ service.admin.require(req.cookies.hop_account),
+ id(req),
+ z
+ .object({ email: z.string().max(254) })
+ .strict()
+ .parse(req.body).email,
+ ),
+ );
+ app.post('/api/admin/network-rules/validate', { bodyLimit: 1024 }, async (req) => {
+ service.admin.require(req.cookies.hop_account);
+ return normalizeNetworkRule(networkRuleInput.parse(req.body));
+ });
+ app.get('/api/admin/automatic-blocks', async (req) => {
+ service.admin.require(req.cookies.hop_account);
+ return service.admin.automatic.list(
+ z
+ .object({
+ offset: z.coerce.number().int().min(0).max(10000).default(0),
+ q: z.string().trim().max(100).default(''),
+ })
+ .strict()
+ .parse(req.query),
+ );
+ });
+ app.post('/api/admin/automatic-blocks/remove', { bodyLimit: 16384 }, async (req) =>
+ service.admin.automatic.remove(
+ service.admin.require(req.cookies.hop_account),
+ automaticBlockRemoval.parse(req.body).ips,
+ ),
+ );
app.get('/api/ops/proxy-diagnostic', async (req) => {
if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.');
const given = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
@@ -485,6 +569,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
input.email = normalizeEmail(input.email);
if (input.email !== user.email)
fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.');
+ if (input.kind === 'file') service.admin.automatic.fileRequest(req, input.byteSize);
const result = service.accounts.idem(user.id, 'request-create', key(req), input, () =>
service.accounts.create(user.id, input),
);
@@ -494,6 +579,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
}
const g = auth(req),
input = requestInput.parse(req.body);
+ if (input.kind === 'file') service.admin.automatic.fileRequest(req, input.byteSize);
const result = service.idem(g, 'request-create', key(req), input, () =>
service.createRequest(
g,
@@ -808,6 +894,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
});
app.post('/api/auth/verify-email', async (req, reply) => {
const result = service.auth.verify(tokenInput.parse(req.body).token, req.cookies.hop_account);
+ service.admin.recordUser(req, result.account.id);
setAccountCookie(reply, result);
return { ok: true, account: result.account };
});
@@ -826,12 +913,21 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
);
app.post('/api/auth/login', { bodyLimit: 16384 }, async (req, reply) => {
const input = credentials.parse(req.body);
- const result = await service.auth.login(
- input.email,
- input.password,
- ip(req) ?? 'unknown',
- req.cookies.hop_account,
- );
+ let result;
+ try {
+ result = await service.auth.login(
+ input.email,
+ input.password,
+ ip(req) ?? 'unknown',
+ req.cookies.hop_account,
+ );
+ } catch (error) {
+ if (error instanceof ApiError && error.code === 'LOGIN_FAILED')
+ service.admin.automatic.observe(req, 'login');
+ throw error;
+ }
+ service.admin.automatic.loginSuccess(req);
+ service.admin.recordUser(req, result.account.id);
setAccountCookie(reply, result);
return { account: result.account };
});
@@ -937,6 +1033,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
.strict(),
])
.parse(req.body);
+ if (input.kind === 'file')
+ service.admin.automatic.fileRequest(
+ req,
+ input.files.reduce((sum, file) => sum + file.size, 0),
+ );
const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => {
const created = service.accounts.create(user.id, {
email: user.email!,
diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts
index 0e47a89..3975b4c 100644
--- a/apps/server/src/auth.ts
+++ b/apps/server/src/auth.ts
@@ -231,10 +231,16 @@ export class Auth {
const valid = await argon2
.verify(user?.passwordHash ?? (await dummy), password)
.catch(() => false);
- if (!valid || !user || !['pending', 'active'].includes(user.status))
+ const currentUser = user ? this.user(user.id) : null;
+ if (
+ !valid ||
+ !currentUser ||
+ !['pending', 'active'].includes(currentUser.status) ||
+ currentUser.passwordHash !== user?.passwordHash
+ )
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
const previous = this.session(oldToken);
- const result = this.createSession(user!, oldToken);
+ const result = this.createSession(currentUser!, oldToken);
if (previous) this.endSession(previous);
return result;
}
diff --git a/apps/server/src/automaticBlocks.js b/apps/server/src/automaticBlocks.js
new file mode 100644
index 0000000..83d5093
--- /dev/null
+++ b/apps/server/src/automaticBlocks.js
@@ -0,0 +1,134 @@
+import { eq, gt, lte, inArray } from 'drizzle-orm';
+import * as s from './db/schema.js';
+import { publicIp, ApiError, hash } from './security.js';
+import { visitorRegion } from './ownerInfo.js';
+
+export class AutomaticBlocks {
+ constructor(service) {
+ this.service = service;
+ this.counters = new Map();
+ this.seenRequests = new Map();
+ this.active = new Map(
+ service.db
+ .select()
+ .from(s.automaticBlocks)
+ .where(gt(s.automaticBlocks.expiresAt, Date.now()))
+ .all()
+ .map((row) => [row.ip, row]),
+ );
+ }
+ has(ip) {
+ return Boolean(ip && this.active.get(ip)?.expiresAt > Date.now());
+ }
+ resetCounters() {
+ this.counters.clear();
+ this.seenRequests.clear();
+ }
+ prune() {
+ const now = Date.now();
+ for (const [key, value] of this.counters) if (value.until <= now) this.counters.delete(key);
+ for (const [key, until] of this.seenRequests) if (until <= now) this.seenRequests.delete(key);
+ for (const [ip, row] of this.active) if (row.expiresAt <= now) this.active.delete(ip);
+ this.service.db.delete(s.automaticBlocks).where(lte(s.automaticBlocks.expiresAt, now)).run();
+ }
+ observe(req, event, bytes = 0, requestKey) {
+ const settings = this.service.admin.state;
+ if (!settings.autoBlockEnabled) return false;
+ const context = visitorRegion(req, this.service.cfg),
+ ip = context.ip;
+ // Never turn an unidentified proxy/NAS relay address into a ban for all visitors.
+ if (!publicIp(ip)) return false;
+ const token = req.cookies?.hop_account;
+ if (token && this.service.admin.isAdmin(this.service.auth.session(token)?.user)) return false;
+ if (this.has(ip)) return true;
+ const now = Date.now(),
+ until = now + settings.autoWindowMinutes * 60000;
+ if (event === 'upload') {
+ const key = `${ip}|${hash(requestKey)}`;
+ if ((this.seenRequests.get(key) ?? 0) > now) return false;
+ this.seenRequests.set(key, until);
+ while (this.seenRequests.size > 10000)
+ this.seenRequests.delete(this.seenRequests.keys().next().value);
+ }
+ const key = `${ip}|${event}`,
+ previous = this.counters.get(key),
+ counter = previous && previous.until > now ? previous : { count: 0, bytes: 0, until };
+ counter.count++;
+ counter.bytes += bytes;
+ this.counters.set(key, counter);
+ while (this.counters.size > 10000) this.counters.delete(this.counters.keys().next().value);
+ const reason =
+ event === 'login' && counter.count >= settings.autoLoginFailures
+ ? 'login-failures'
+ : event === 'rate' && counter.count >= settings.autoRateViolations
+ ? 'rate-violations'
+ : event === 'upload' && counter.count > settings.autoUploadRequests
+ ? 'file-requests'
+ : event === 'upload' && counter.bytes > settings.autoUploadBytes
+ ? 'file-volume'
+ : null;
+ if (!reason || this.active.size >= 10000) return false;
+ const row = {
+ ip,
+ countryCode: context.countryCode,
+ reason,
+ createdAt: now,
+ expiresAt: now + settings.autoBlockMinutes * 60000,
+ };
+ this.service.db.transaction(() => {
+ this.service.db
+ .insert(s.automaticBlocks)
+ .values(row)
+ .onConflictDoUpdate({
+ target: s.automaticBlocks.ip,
+ set: row,
+ })
+ .run();
+ this.service.admin.audit(null, 'automatic-block', {
+ ...row,
+ count: counter.count,
+ bytes: counter.bytes,
+ });
+ });
+ this.active.set(ip, row);
+ this.service.admin.disconnectBlocked();
+ return true;
+ }
+ fileRequest(req, bytes) {
+ const key = `${req.method}:${req.url.split('?')[0]}:${req.cookies?.hop_guest ?? ''}:${req.headers['idempotency-key'] ?? req.id}`;
+ if (this.observe(req, 'upload', bytes, key))
+ throw new ApiError(
+ 403,
+ 'ACCESS_BLOCKED',
+ '반복적인 파일 전송으로 이 IP의 이용이 잠시 제한됐어요.',
+ );
+ }
+ loginSuccess(req) {
+ const ip = visitorRegion(req, this.service.cfg).ip;
+ if (ip) this.counters.delete(`${ip}|login`);
+ }
+ list({ offset = 0, q = '' } = {}) {
+ const rows = [...this.active.values()]
+ .filter((row) => row.expiresAt > Date.now() && row.ip.includes(q))
+ .sort((a, b) => b.createdAt - a.createdAt || a.ip.localeCompare(b.ip));
+ return {
+ items: rows.slice(offset, offset + 50),
+ total: rows.length,
+ nextOffset: offset + 50 < rows.length ? offset + 50 : null,
+ };
+ }
+ remove(actor, ips) {
+ this.service.db.transaction(() => {
+ this.service.db.delete(s.automaticBlocks).where(inArray(s.automaticBlocks.ip, ips)).run();
+ this.service.admin.audit(actor, 'automatic-unblock', { ips });
+ });
+ for (const ip of ips) {
+ this.active.delete(ip);
+ for (const key of this.counters.keys())
+ if (key.startsWith(`${ip}|`)) this.counters.delete(key);
+ for (const key of this.seenRequests.keys())
+ if (key.startsWith(`${ip}|`)) this.seenRequests.delete(key);
+ }
+ return { ok: true };
+ }
+}
diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts
index bb206f3..f0e17dc 100644
--- a/apps/server/src/db/schema.ts
+++ b/apps/server/src/db/schema.ts
@@ -368,6 +368,27 @@ export const adminSettings = sqliteTable('admin_settings', {
value: text().notNull(),
updatedAt: integer().notNull(),
});
+export const automaticBlocks = sqliteTable(
+ 'automatic_blocks',
+ {
+ ip: text().primaryKey(),
+ countryCode: text(),
+ reason: text().notNull(),
+ createdAt: integer().notNull(),
+ expiresAt: integer().notNull(),
+ },
+ (t) => [index('automatic_block_expiry').on(t.expiresAt)],
+);
+export const userActivity = sqliteTable('user_activity', {
+ userId: text()
+ .primaryKey()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ lastSeenAt: integer().notNull(),
+ ip: text(),
+ countryCode: text(),
+ region: text(),
+ city: text(),
+});
export const adminAudit = sqliteTable(
'admin_audit',
{
diff --git a/apps/server/test/admin-management.test.js b/apps/server/test/admin-management.test.js
new file mode 100644
index 0000000..24bab9c
--- /dev/null
+++ b/apps/server/test/admin-management.test.js
@@ -0,0 +1,349 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { randomUUID } from 'node:crypto';
+import { eq } from 'drizzle-orm';
+import { fixture } from './helpers.js';
+import * as s from '../src/db/schema.js';
+import { auditChangeLines, formatCountry } from '../../web/src/displayFormat.js';
+
+async function account(f, email) {
+ const c = await f.create();
+ f.service.db
+ .insert(s.users)
+ .values({
+ id: randomUUID(),
+ email,
+ displayName: email,
+ passwordHash: await f.service.auth.password('12345678'),
+ status: 'active',
+ verifiedAt: Date.now(),
+ createdAt: Date.now(),
+ })
+ .run();
+ const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
+ assert.equal(response.statusCode, 200, response.body);
+ c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
+ return { ...c, userId: response.json().account.id, email };
+}
+const h = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR', 'cf-ipcity': 'Daejeon' };
+
+test(
+ 'audit shows only differences, skips no-op saves, compares compatible old entries and supports individual/all deletion with authorization',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const admin = await account(f, 'hello@noname.company'),
+ user = await account(f, 'user@hop.test');
+ let response = await f.call(admin, '/admin/settings', 'PATCH', {
+ revision: 0,
+ maxConcurrentUploads: 5,
+ });
+ assert.equal(response.statusCode, 200);
+ response = await f.call(admin, '/admin/settings', 'PATCH', {
+ revision: 1,
+ maxConcurrentUploads: 10,
+ blockedCountries: ['CN'],
+ });
+ assert.equal(response.statusCode, 200);
+ const events = (await f.call(admin, '/admin/audit')).json().events;
+ const latest = events.find((e) => e.details.changes?.some((c) => c.after === 10));
+ assert.deepEqual(latest.details.changes, [
+ { field: 'maxConcurrentUploads', before: 5, after: 10 },
+ { field: 'blockedCountries', added: ['CN'], removed: [] },
+ ]);
+ assert.deepEqual(latest.details.changes.flatMap(auditChangeLines), [
+ '동시에 처리할 업로드 수 5 > 10',
+ '블랙리스트 추가: 중국(CN)',
+ ]);
+ const count = events.length;
+ await f.call(admin, '/admin/settings', 'PATCH', { revision: 2, maxConcurrentUploads: 10 });
+ assert.equal((await f.call(admin, '/admin/audit')).json().events.length, count);
+ assert.equal(
+ (await f.call(user, '/admin/audit/remove', 'POST', { all: true })).statusCode,
+ 403,
+ );
+ assert.equal(
+ (await f.call(admin, '/admin/audit/remove', 'POST', { ids: [latest.id] })).statusCode,
+ 200,
+ );
+ assert.equal(
+ (await f.call(admin, '/admin/audit')).json().events.some((e) => e.id === latest.id),
+ false,
+ );
+ const before = {
+ uploadsPaused: false,
+ maxConcurrentUploads: 5,
+ blockedCountries: [],
+ blockedIps: [],
+ totalStorageLimitMb: 20480,
+ };
+ for (const [offset, body] of [
+ [2000, before],
+ [1000, { ...before, maxConcurrentUploads: 6 }],
+ ])
+ f.service.db
+ .insert(s.adminAudit)
+ .values({
+ id: randomUUID(),
+ actorUserId: admin.userId,
+ action: 'settings-change',
+ details: JSON.stringify(body),
+ createdAt: Date.now() - offset,
+ })
+ .run();
+ const old = (await f.call(admin, '/admin/audit'))
+ .json()
+ .events.find((e) => e.details.legacy && e.details.changes.length);
+ assert.deepEqual(old.details.changes, [
+ { field: 'maxConcurrentUploads', before: 5, after: 6 },
+ ]);
+ assert.equal(
+ (await f.call(admin, '/admin/audit/remove', 'POST', { all: true })).statusCode,
+ 200,
+ );
+ assert.deepEqual((await f.call(admin, '/admin/audit')).json().events, []);
+ assert.equal(
+ f.service.admin.state.maxConcurrentUploads,
+ 10,
+ 'deleting audit entries never changes settings',
+ );
+ assert.equal(formatCountry('JP'), '일본(JP)');
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'user list keeps basic last-seen country/IP with throttled updates, expires location data and never exposes credentials',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const admin = await account(f, 'hello@noname.company'),
+ user = await account(f, 'user@hop.test'),
+ guest = await f.create();
+ f.cfg.clientIpSource = 'cloudflare';
+ await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
+ const first = f.service.db
+ .select()
+ .from(s.userActivity)
+ .where(eq(s.userActivity.userId, user.userId))
+ .get();
+ await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
+ assert.equal(
+ f.service.db
+ .select()
+ .from(s.userActivity)
+ .where(eq(s.userActivity.userId, user.userId))
+ .get().lastSeenAt,
+ first.lastSeenAt,
+ );
+ const response = await f.call(
+ admin,
+ '/admin/users?q=user@hop.test',
+ 'GET',
+ undefined,
+ undefined,
+ h,
+ ),
+ list = response.json().users;
+ assert.equal(list.length, 1);
+ assert.equal(list[0].email, user.email);
+ assert.equal(list[0].countryCode, 'KR');
+ assert.equal(list[0].city, 'Daejeon');
+ assert.equal(list[0].ip, '8.8.4.4');
+ assert.equal(response.body.includes('passwordHash'), false);
+ assert.equal(response.body.includes('tokenHash'), false);
+ assert.equal((await f.call(user, '/admin/users')).statusCode, 403);
+ assert.equal((await f.call(guest, '/admin/users')).statusCode, 401);
+ f.service.db
+ .update(s.userActivity)
+ .set({ lastSeenAt: Date.now() - 31 * 86400000 })
+ .where(eq(s.userActivity.userId, user.userId))
+ .run();
+ const expired = (
+ await f.call(admin, '/admin/users?q=user@hop.test', 'GET', undefined, undefined, h)
+ ).json().users[0];
+ assert.equal(expired.ip, null);
+ assert.equal(expired.countryCode, null);
+ assert.ok(expired.lastSeenAt);
+ f.service.admin.cleanup();
+ assert.equal(
+ f.service.db
+ .select()
+ .from(s.userActivity)
+ .where(eq(s.userActivity.userId, user.userId))
+ .get().city,
+ null,
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'admin reset sends a one-use email link; password and sessions change only when the user completes it',
+ { timeout: 20000 },
+ async () => {
+ const mail = [];
+ const f = await fixture(false, async (message) => mail.push(message));
+ try {
+ const admin = await account(f, 'hello@noname.company'),
+ user = await account(f, 'user@hop.test');
+ assert.equal(
+ (await f.call(user, `/admin/users/${admin.userId}/password-reset`, 'POST', {})).statusCode,
+ 403,
+ );
+ assert.equal(
+ (await f.call(admin, `/admin/users/${user.userId}/password-reset`, 'POST', {})).statusCode,
+ 200,
+ );
+ assert.equal(mail.length, 1);
+ assert.equal(mail[0].to, user.email);
+ assert.ok((await f.call(user, '/auth/me')).json().account);
+ const token = /#reset-password=([^\s]+)/.exec(mail[0].text)[1];
+ const reset = await f.call(user, '/auth/reset-password', 'POST', {
+ token,
+ password: 'new-password',
+ passwordConfirmation: 'new-password',
+ });
+ assert.equal(reset.statusCode, 200, reset.body);
+ assert.equal((await f.call(user, '/auth/me')).json().account, null);
+ const guest = await f.create();
+ assert.equal(
+ (await f.call(guest, '/auth/login', 'POST', { email: user.email, password: '12345678' }))
+ .statusCode,
+ 401,
+ );
+ assert.equal(
+ (
+ await f.call(guest, '/auth/login', 'POST', {
+ email: user.email,
+ password: 'new-password',
+ })
+ ).statusCode,
+ 200,
+ );
+ assert.equal(
+ (
+ await f.call(guest, '/auth/reset-password', 'POST', {
+ token,
+ password: 'again-password',
+ passwordConfirmation: 'again-password',
+ })
+ ).statusCode,
+ 400,
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'admin deletion confirms email, protects the administrator, revokes credentials and preserves shared recipient files',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const admin = await account(f, 'hello@noname.company'),
+ user = await account(f, 'user@hop.test'),
+ recipient = await f.create();
+ const created = await f.call(
+ user,
+ '/vault',
+ 'POST',
+ { kind: 'file', files: [{ name: 'shared.txt', size: 4, type: 'text/plain' }] },
+ randomUUID(),
+ );
+ assert.equal(created.statusCode, 200, created.body);
+ const id = created.json().requestId,
+ payload = Buffer.from(
+ '--delete\r\nContent-Disposition: form-data; name="file"; filename="shared.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n',
+ );
+ const uploaded = await f.call(
+ user,
+ `/uploads?requestId=${id}&fileIndex=0`,
+ 'POST',
+ payload,
+ randomUUID(),
+ { 'content-type': 'multipart/form-data; boundary=delete' },
+ );
+ assert.equal(uploaded.statusCode, 200, uploaded.body);
+ const file = f.service.storage.fileRows(id)[0],
+ item = f.service.db.select().from(s.items).where(eq(s.items.fileId, file.id)).get(),
+ accessId = randomUUID();
+ f.service.db
+ .insert(s.access)
+ .values({
+ id: accessId,
+ itemId: item.id,
+ guestId: f.service.deviceGuest(recipient.deviceId).guestId,
+ role: 'recipient',
+ read: false,
+ })
+ .run();
+ assert.equal(
+ (await f.call(admin, `/admin/users/${admin.userId}/delete`, 'POST', { email: admin.email }))
+ .statusCode,
+ 403,
+ );
+ assert.equal(
+ (
+ await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', {
+ email: 'wrong@hop.test',
+ })
+ ).statusCode,
+ 409,
+ );
+ assert.equal(
+ (await f.call(user, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
+ .statusCode,
+ 403,
+ );
+ assert.equal(
+ (await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
+ .statusCode,
+ 200,
+ );
+ const deleted = f.service.auth.user(user.userId);
+ assert.equal(deleted.status, 'deleted');
+ assert.equal(deleted.email, null);
+ assert.equal(deleted.passwordHash, null);
+ assert.equal((await f.call(user, '/auth/me')).json().account, null);
+ assert.equal(
+ (await f.call(admin, '/admin/users')).json().users.some((u) => u.id === user.userId),
+ false,
+ );
+ const download = await f.call(recipient, `/access/${accessId}/download`);
+ assert.equal(download.statusCode, 200, download.body);
+ assert.equal(download.body, 'test');
+ assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'account-delete');
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'account deletion during password verification cannot issue a usable new login session',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const user = await account(f, 'race@hop.test');
+ const login = f.service.auth.login(user.email, '12345678', '8.8.8.8');
+ f.service.db
+ .update(s.users)
+ .set({ status: 'deleted', email: null, passwordHash: null })
+ .where(eq(s.users.id, user.userId))
+ .run();
+ await assert.rejects(login, (error) => error.code === 'LOGIN_FAILED');
+ } finally {
+ await f.close();
+ }
+ },
+);
diff --git a/apps/server/test/network.test.js b/apps/server/test/network.test.js
new file mode 100644
index 0000000..67f0643
--- /dev/null
+++ b/apps/server/test/network.test.js
@@ -0,0 +1,487 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { randomUUID } from 'node:crypto';
+import { eq } from 'drizzle-orm';
+import { io } from 'socket.io-client';
+import { fixture } from './helpers.js';
+import * as s from '../src/db/schema.js';
+import { Administration } from '../src/administration.js';
+
+const actorHeaders = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR' };
+const headers = (ip = '8.8.8.8', country = 'KR') => ({
+ 'cf-connecting-ip': ip,
+ ...(country ? { 'cf-ipcountry': country } : {}),
+});
+async function login(f, email = 'hello@noname.company') {
+ const c = await f.create();
+ f.service.db
+ .insert(s.users)
+ .values({
+ id: randomUUID(),
+ email,
+ displayName: email,
+ passwordHash: await f.service.auth.password('12345678'),
+ status: 'active',
+ verifiedAt: Date.now(),
+ createdAt: Date.now(),
+ })
+ .run();
+ const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
+ assert.equal(response.statusCode, 200, response.body);
+ c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
+ return c;
+}
+async function patch(f, admin, body) {
+ const response = await f.call(
+ admin,
+ '/admin/settings',
+ 'PATCH',
+ { revision: f.service.admin.state.revision, ...body },
+ undefined,
+ actorHeaders,
+ );
+ assert.equal(response.statusCode, 200, response.body);
+ return response.json();
+}
+async function autoFixture(settings = {}) {
+ const f = await fixture(),
+ admin = await login(f),
+ guest = await f.create();
+ f.cfg.clientIpSource = 'cloudflare';
+ await patch(f, admin, {
+ autoBlockEnabled: true,
+ autoLoginFailures: 3,
+ autoRateViolations: 3,
+ ...settings,
+ });
+ return { f, admin, guest };
+}
+
+test(
+ 'existing saved settings retain blacklist rules while new whitelist and automatic protection default to empty/off',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const legacy = {
+ revision: 4,
+ uploadsPaused: false,
+ maxConcurrentUploads: 3,
+ totalStorageLimitMb: 12000,
+ blockedCountries: ['JP'],
+ blockedIps: ['1.1.1.0/24'],
+ };
+ f.service.db
+ .insert(s.adminSettings)
+ .values({ name: 'controls', value: JSON.stringify(legacy), updatedAt: Date.now() })
+ .run();
+ const restored = new Administration(f.service);
+ assert.equal(restored.state.revision, 4);
+ assert.deepEqual(restored.state.blockedCountries, ['JP']);
+ assert.equal(restored.state.blacklistEnabled, true);
+ assert.equal(restored.state.autoBlockEnabled, false);
+ assert.deepEqual(restored.state.allowedCountries, []);
+ assert.deepEqual(restored.state.allowedIps, []);
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'whitelist is an OR of countries and IP ranges, excludes unknown origins, preserves blacklist priority and prevents operator lockout',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ try {
+ const admin = await login(f),
+ guest = await f.create();
+ f.cfg.clientIpSource = 'cloudflare';
+ await patch(f, admin, {
+ allowedCountries: ['KR'],
+ allowedIps: ['1.1.1.0/24', '2001:db8::/32'],
+ blockedIps: ['1.1.1.5'],
+ });
+ for (const [ip, country, status] of [
+ ['8.8.8.8', 'KR', 200],
+ ['1.1.1.6', 'JP', 200],
+ ['9.9.9.9', 'JP', 403],
+ ['1.1.1.5', 'JP', 403],
+ ['9.9.9.9', null, 403],
+ ])
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers(ip, country)))
+ .statusCode,
+ status,
+ );
+ assert.equal(f.service.admin.policyDenied({ ip: '2001:db8::123', countryCode: null }), false);
+ assert.equal(f.service.admin.policyDenied({ ip: '2001:db9::123', countryCode: null }), true);
+ const denied = await f.call(
+ admin,
+ '/admin/settings',
+ 'PATCH',
+ { revision: f.service.admin.state.revision, allowedCountries: ['JP'], allowedIps: [] },
+ undefined,
+ actorHeaders,
+ );
+ assert.equal(denied.json().code, 'ADMIN_SELF_BLOCK');
+ assert.deepEqual(f.service.admin.state.allowedCountries, ['KR']);
+ assert.equal(
+ (await f.call(null, '/health')).statusCode,
+ 200,
+ 'local container health checks survive a country-only whitelist',
+ );
+ assert.equal(
+ (await f.call(guest, '/health', 'GET', undefined, undefined, headers('9.9.9.9', 'JP')))
+ .statusCode,
+ 403,
+ 'proxied public health requests still follow the whitelist',
+ );
+ await patch(f, admin, { blacklistEnabled: false });
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.5', 'JP')))
+ .statusCode,
+ 200,
+ );
+ await patch(f, admin, { allowedCountries: [], allowedIps: [] });
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('9.9.9.9', null)))
+ .statusCode,
+ 200,
+ );
+ const invalid = await f.call(
+ admin,
+ '/admin/network-rules/validate',
+ 'POST',
+ { kind: 'ip', value: '1.1.1.999/24' },
+ undefined,
+ actorHeaders,
+ );
+ assert.equal(invalid.statusCode, 400);
+ const normalized = await f.call(
+ admin,
+ '/admin/network-rules/validate',
+ 'POST',
+ { kind: 'country', value: 'jp' },
+ undefined,
+ actorHeaders,
+ );
+ assert.deepEqual(normalized.json(), { kind: 'country', value: 'JP' });
+ assert.equal((await f.call(guest, '/admin/automatic-blocks')).statusCode, 401);
+ assert.equal(
+ (
+ await f.call(guest, '/admin/network-rules/validate', 'POST', {
+ kind: 'country',
+ value: 'KR',
+ })
+ ).statusCode,
+ 401,
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'activating a whitelist disconnects existing excluded WebSockets and denies a new handshake',
+ { timeout: 20000 },
+ async () => {
+ const f = await fixture();
+ let socket;
+ try {
+ const admin = await login(f),
+ guest = await f.create();
+ f.cfg.clientIpSource = 'cloudflare';
+ socket = io(f.url, {
+ transports: ['websocket'],
+ auth: { csrfToken: guest.csrf },
+ extraHeaders: {
+ Origin: 'http://hop.test',
+ Cookie: guest.cookie,
+ ...headers('1.1.1.1', 'JP'),
+ },
+ reconnection: false,
+ });
+ await new Promise((resolve, reject) => {
+ socket.once('connect', resolve);
+ socket.once('connect_error', reject);
+ });
+ const disconnected = new Promise((resolve) => socket.once('disconnect', resolve));
+ await patch(f, admin, { allowedCountries: ['KR'] });
+ await disconnected;
+ assert.equal(socket.connected, false);
+ const failed = new Promise((resolve) => socket.once('connect_error', resolve));
+ socket.connect();
+ await failed;
+ assert.equal(socket.connected, false);
+ } finally {
+ socket?.disconnect();
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'automatic login bans persist, can be recovered by an existing verified admin, and unblock/expiry restore access',
+ { timeout: 20000 },
+ async () => {
+ const { f, admin, guest } = await autoFixture();
+ try {
+ for (let i = 0; i < 3; i++) {
+ const response = await f.call(
+ guest,
+ '/auth/login',
+ 'POST',
+ { email: 'missing@hop.test', password: 'incorrect' },
+ undefined,
+ headers(),
+ );
+ assert.equal(response.json().code, 'LOGIN_FAILED');
+ }
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 403,
+ );
+ assert.equal(new Administration(f.service).automatic.has('8.8.8.8'), true);
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.1')))
+ .statusCode,
+ 200,
+ );
+ assert.equal(
+ (await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode,
+ 200,
+ 'admin recovery session remains usable',
+ );
+ const list = (
+ await f.call(
+ admin,
+ '/admin/automatic-blocks?q=8.8',
+ 'GET',
+ undefined,
+ undefined,
+ actorHeaders,
+ )
+ ).json();
+ assert.equal(list.total, 1);
+ assert.equal(list.items[0].reason, 'login-failures');
+ const removal = await f.call(
+ admin,
+ '/admin/automatic-blocks/remove',
+ 'POST',
+ { ips: ['8.8.8.8'] },
+ undefined,
+ actorHeaders,
+ );
+ assert.equal(removal.statusCode, 200);
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 200,
+ );
+ const fake = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() };
+ for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(fake, 'login');
+ f.service.admin.automatic.active.get('8.8.8.8').expiresAt = Date.now() - 1;
+ f.service.db
+ .update(s.automaticBlocks)
+ .set({ expiresAt: Date.now() - 1 })
+ .run();
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 200,
+ );
+ f.service.admin.automatic.prune();
+ assert.equal(f.service.db.select().from(s.automaticBlocks).all().length, 0);
+ await patch(f, admin, { blockedIps: ['8.8.8.8'] });
+ assert.equal(
+ (await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode,
+ 403,
+ 'admin sessions never bypass manual blacklist',
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'repeated file attempts and declared byte budgets cover idempotent retries and multiple guest sessions on the same IP',
+ { timeout: 20000 },
+ async () => {
+ const { f, admin, guest } = await autoFixture({ autoUploadRequests: 3 });
+ try {
+ const receiver = await f.create();
+ await f.space(guest, receiver);
+ const input = {
+ targetDeviceId: receiver.deviceId,
+ kind: 'file',
+ count: 1,
+ byteSize: 4,
+ files: [{ name: 'a.txt', size: 4, type: 'text/plain' }],
+ },
+ key = randomUUID();
+ const first = await f.call(guest, '/transfer-requests', 'POST', input, key, headers());
+ assert.equal(first.statusCode, 200, first.body);
+ for (let i = 0; i < 4; i++)
+ assert.equal(
+ (await f.call(guest, '/transfer-requests', 'POST', input, key, headers())).statusCode,
+ 200,
+ );
+ assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|upload').count, 1);
+ await f.call(guest, '/transfer-requests', 'POST', input, randomUUID(), headers());
+ const other = await f.create();
+ await f.call(other, '/transfer-requests', 'POST', input, randomUUID(), headers());
+ const blocked = await f.call(
+ other,
+ '/transfer-requests',
+ 'POST',
+ input,
+ randomUUID(),
+ headers(),
+ );
+ assert.equal(blocked.statusCode, 403);
+ assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-requests');
+ await f.call(
+ admin,
+ '/admin/automatic-blocks/remove',
+ 'POST',
+ { ips: ['8.8.8.8'] },
+ undefined,
+ actorHeaders,
+ );
+ await patch(f, admin, { autoUploadRequests: 30, autoUploadBytes: 1000000 });
+ const huge = {
+ ...input,
+ byteSize: 1000001,
+ files: [{ name: 'a.mp4', size: 1000001, type: 'video/mp4' }],
+ };
+ const volume = await f.call(
+ other,
+ '/transfer-requests',
+ 'POST',
+ huge,
+ randomUUID(),
+ headers(),
+ );
+ assert.equal(volume.statusCode, 403);
+ assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume');
+ assert.equal(f.service.storage.fileRows(first.json().requestId).length, 1);
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'automatic rate protection counts deliberate limit violations but ignores normal chunk requests and untrusted origin headers',
+ { timeout: 20000 },
+ async () => {
+ const { f, guest } = await autoFixture();
+ try {
+ for (let i = 0; i < 240; i++) f.service.limiter.hit('api-ip:8.8.8.8', 240);
+ for (let i = 0; i < 3; i++)
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 429,
+ );
+ assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'rate-violations');
+ const fake = { raw: { socket: { remoteAddress: '10.0.0.2' } }, headers: headers('1.1.1.1') };
+ for (let i = 0; i < 4; i++)
+ assert.equal(f.service.admin.automatic.observe(fake, 'login'), false);
+ assert.equal(f.service.admin.automatic.has('1.1.1.1'), false);
+ for (let i = 0; i < 6000; i++) f.service.limiter.hit('chunk-ip:9.9.9.9', 6000);
+ for (let i = 0; i < 4; i++)
+ assert.equal(
+ (
+ await f.call(
+ guest,
+ `/uploads/resumable/${randomUUID()}`,
+ 'PUT',
+ Buffer.from('x'),
+ undefined,
+ { 'content-type': 'application/octet-stream', ...headers('9.9.9.9') },
+ )
+ ).statusCode,
+ 429,
+ );
+ assert.equal(f.service.admin.automatic.has('9.9.9.9'), false);
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'signed-in vault file requests share the same IP byte budget and deduplicate retry keys',
+ { timeout: 20000 },
+ async () => {
+ const { f, guest } = await autoFixture({ autoUploadBytes: 1000000 });
+ try {
+ const user = await login(f, 'user@hop.test'),
+ key = randomUUID(),
+ body = { kind: 'file', files: [{ name: 'vault.txt', size: 600000, type: 'text/plain' }] };
+ const first = await f.call(user, '/vault', 'POST', body, key, headers());
+ assert.equal(first.statusCode, 200, first.body);
+ assert.equal((await f.call(user, '/vault', 'POST', body, key, headers())).statusCode, 200);
+ const second = await f.call(
+ user,
+ '/vault',
+ 'POST',
+ { kind: 'file', files: [{ name: 'second.txt', size: 500000, type: 'text/plain' }] },
+ randomUUID(),
+ headers(),
+ );
+ assert.equal(second.statusCode, 403);
+ assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume');
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 403,
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
+
+test(
+ 'successful login resets only login failures, automatic disable preserves recoverable entries, and reenabling applies them',
+ { timeout: 20000 },
+ async () => {
+ const { f, admin, guest } = await autoFixture();
+ try {
+ await login(f, 'success@hop.test');
+ const req = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() };
+ f.service.admin.automatic.observe(req, 'login');
+ f.service.admin.automatic.observe(req, 'rate');
+ assert.equal(
+ (
+ await f.call(
+ guest,
+ '/auth/login',
+ 'POST',
+ { email: 'success@hop.test', password: '12345678' },
+ undefined,
+ headers(),
+ )
+ ).statusCode,
+ 200,
+ );
+ assert.equal(f.service.admin.automatic.counters.has('8.8.8.8|login'), false);
+ assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|rate').count, 1);
+ for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(req, 'login');
+ await patch(f, admin, { autoBlockEnabled: false });
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 200,
+ );
+ assert.equal(f.service.admin.automatic.has('8.8.8.8'), true);
+ await patch(f, admin, { autoBlockEnabled: true });
+ assert.equal(
+ (await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
+ 403,
+ );
+ } finally {
+ await f.close();
+ }
+ },
+);
diff --git a/apps/web/src/AdminAudit.vue b/apps/web/src/AdminAudit.vue
new file mode 100644
index 0000000..849ee0e
--- /dev/null
+++ b/apps/web/src/AdminAudit.vue
@@ -0,0 +1,187 @@
+
+
+ {{ notice }} 관리 기록이 없습니다. 저장된 관리 기록 전체를 삭제합니다. 설정과 파일, 자동 차단 목록은 유지됩니다.관리 기록을 모두 삭제할까요?
+
+
{{ request.ip || 'IP 확인 불가' }}{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
+ >{{ request.countryCode ? formatCountry(request.countryCode) : '지역 확인 불가' }} ·
{{ date(request.startedAt) }}