Add network protection and administrator account support
This commit is contained in:
18
README.md
18
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)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다.
|
||||
|
||||
19
apps/server/drizzle/0014_network_protection.sql
Normal file
19
apps/server/drizzle/0014_network_protection.sql
Normal file
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
134
apps/server/src/automaticBlocks.js
Normal file
134
apps/server/src/automaticBlocks.js
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
349
apps/server/test/admin-management.test.js
Normal file
349
apps/server/test/admin-management.test.js
Normal file
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
487
apps/server/test/network.test.js
Normal file
487
apps/server/test/network.test.js
Normal file
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
187
apps/web/src/AdminAudit.vue
Normal file
187
apps/web/src/AdminAudit.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { Trash2, RefreshCw, X } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatAdminDate as date, auditChangeLines } from './displayFormat';
|
||||
const events = ref([]),
|
||||
nextOffset = ref(null),
|
||||
selected = ref([]),
|
||||
busy = ref(false),
|
||||
notice = ref(''),
|
||||
deletingAll = ref(false),
|
||||
dialog = ref(null);
|
||||
const actionName = (action) =>
|
||||
({
|
||||
'settings-change': '관리 설정 변경',
|
||||
'file-delete': '원본 파일 삭제',
|
||||
'automatic-block': '자동 IP 차단',
|
||||
'automatic-unblock': '자동 차단 해제',
|
||||
'password-reset-email': '비밀번호 재설정 메일 발송',
|
||||
'account-delete': '사용자 계정 삭제',
|
||||
})[action] || action;
|
||||
function lines(event) {
|
||||
if (event.action === 'settings-change')
|
||||
return event.details.changes?.length
|
||||
? event.details.changes.flatMap(auditChangeLines)
|
||||
: ['변경 전 정보가 없어 상세 비교할 수 없습니다.'];
|
||||
if (event.action === 'file-delete') return [event.details.name];
|
||||
if (event.action === 'password-reset-email' || event.action === 'account-delete')
|
||||
return [event.details.email];
|
||||
if (event.action === 'automatic-unblock')
|
||||
return ['자동 차단 해제: ' + event.details.ips.join(', ')];
|
||||
const reasons = {
|
||||
'login-failures': '로그인 실패 반복',
|
||||
'rate-violations': '요청 제한 반복 초과',
|
||||
'file-requests': '파일 전송 시도 반복',
|
||||
'file-volume': '요청 파일 용량 초과',
|
||||
};
|
||||
return [
|
||||
`${event.details.ip} · ${reasons[event.details.reason] || event.details.reason}`,
|
||||
`${date(event.details.expiresAt)} 해제`,
|
||||
];
|
||||
}
|
||||
async function load(more = false) {
|
||||
const result = await api('/admin/audit?offset=' + (more ? nextOffset.value : 0));
|
||||
events.value = more ? [...events.value, ...result.events] : result.events;
|
||||
nextOffset.value = result.nextOffset;
|
||||
if (!more) selected.value = [];
|
||||
}
|
||||
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 remove(ids) {
|
||||
await act(async () => {
|
||||
for (let offset = 0; offset < ids.length; offset += 100)
|
||||
await api('/admin/audit/remove', 'POST', { ids: ids.slice(offset, offset + 100) });
|
||||
await load();
|
||||
notice.value = '선택한 관리 기록을 삭제했어요.';
|
||||
});
|
||||
}
|
||||
let trigger;
|
||||
async function confirmAll() {
|
||||
trigger = document.activeElement;
|
||||
deletingAll.value = true;
|
||||
await nextTick();
|
||||
dialog.value?.querySelector('button')?.focus();
|
||||
}
|
||||
function close() {
|
||||
deletingAll.value = false;
|
||||
trigger?.focus();
|
||||
}
|
||||
async function removeAll() {
|
||||
await act(async () => {
|
||||
await api('/admin/audit/remove', 'POST', { all: true });
|
||||
close();
|
||||
await load();
|
||||
notice.value = '관리 기록을 모두 삭제했어요.';
|
||||
});
|
||||
}
|
||||
function modalKey(event) {
|
||||
if (event.key === 'Escape' && !busy.value) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
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();
|
||||
}
|
||||
}
|
||||
onMounted(() => load().catch(showError));
|
||||
</script>
|
||||
<template>
|
||||
<section class="admin-audit-panel">
|
||||
<div class="admin-toolbar">
|
||||
<button class="button small" :disabled="busy" @click="act(() => load())">
|
||||
<RefreshCw :size="15" />기록 새로고침</button
|
||||
><button
|
||||
class="button small"
|
||||
:disabled="busy || !selected.length"
|
||||
@click="remove([...selected])"
|
||||
>
|
||||
<Trash2 :size="15" />선택 기록 삭제</button
|
||||
><button class="text-button" :disabled="busy || !events.length" @click="confirmAll">
|
||||
전체 기록 삭제
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
|
||||
<div class="admin-audit">
|
||||
<article v-for="event in events" :key="event.id">
|
||||
<label class="audit-selection"
|
||||
><input
|
||||
v-model="selected"
|
||||
type="checkbox"
|
||||
:value="event.id"
|
||||
:disabled="busy"
|
||||
:aria-label="`${actionName(event.action)} ${date(event.createdAt)} 기록 선택`"
|
||||
/><strong>{{ actionName(event.action) }}</strong></label
|
||||
><span>{{ date(event.createdAt) }}</span
|
||||
><small v-for="(line, index) in lines(event)" :key="index">{{ line }}</small
|
||||
><button
|
||||
class="text-button audit-remove"
|
||||
:disabled="busy"
|
||||
:aria-label="`${actionName(event.action)} 기록 삭제`"
|
||||
@click="remove([event.id])"
|
||||
>
|
||||
<Trash2 :size="15" />삭제
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
v-if="nextOffset !== null"
|
||||
class="button small"
|
||||
:disabled="busy"
|
||||
@click="act(() => load(true))"
|
||||
>
|
||||
더 보기
|
||||
</button>
|
||||
<Teleport to="body"
|
||||
><div v-if="deletingAll" class="modal-backdrop" @click.self="!busy && close()">
|
||||
<section
|
||||
ref="dialog"
|
||||
class="modal admin-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="audit-delete-title"
|
||||
@keydown="modalKey"
|
||||
>
|
||||
<header class="modal-title">
|
||||
<h2 id="audit-delete-title">관리 기록을 모두 삭제할까요?</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="기록 삭제 확인 닫기"
|
||||
:disabled="busy"
|
||||
@click="close"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<p>저장된 관리 기록 전체를 삭제합니다. 설정과 파일, 자동 차단 목록은 유지됩니다.</p>
|
||||
<div class="admin-confirm-actions">
|
||||
<button class="button" :disabled="busy" @click="close">취소</button
|
||||
><button class="button primary" :disabled="busy" @click="removeAll">
|
||||
전체 삭제 확인
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div></Teleport
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
X,
|
||||
} from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatSize as size, formatAdminDate as date } from './displayFormat';
|
||||
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
|
||||
import NetworkPanel from './NetworkPanel.vue';
|
||||
import AdminAudit from './AdminAudit.vue';
|
||||
import AdminUsers from './AdminUsers.vue';
|
||||
const emit = defineEmits(['devices']);
|
||||
const overview = ref(null),
|
||||
files = ref([]),
|
||||
cursor = ref(null),
|
||||
events = ref([]),
|
||||
view = ref('files'),
|
||||
busy = ref(false),
|
||||
loading = ref(true),
|
||||
@@ -27,14 +29,12 @@ const overview = ref(null),
|
||||
dialog = ref(null);
|
||||
const maxConcurrent = ref(4),
|
||||
capacity = ref(20),
|
||||
countries = ref(''),
|
||||
ips = ref('');
|
||||
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
|
||||
pendingBlockedIp = ref(null);
|
||||
function region(location) {
|
||||
if (!location) return '확인 불가';
|
||||
return (
|
||||
[
|
||||
location.countryCode ? countryNames.of(location.countryCode) : null,
|
||||
location.countryCode ? formatCountry(location.countryCode) : null,
|
||||
location.city || location.region,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -44,19 +44,12 @@ function region(location) {
|
||||
function form(settings) {
|
||||
maxConcurrent.value = settings.maxConcurrentUploads;
|
||||
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
|
||||
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'),
|
||||
]);
|
||||
const [summary, list] = await Promise.all([api('/admin/summary'), api('/admin/files')]);
|
||||
overview.value = summary;
|
||||
files.value = list.items;
|
||||
cursor.value = list.nextCursor;
|
||||
events.value = audit.events;
|
||||
if (resetForm) form(summary.settings);
|
||||
}
|
||||
async function act(fn) {
|
||||
@@ -90,15 +83,13 @@ async function pauseUploads() {
|
||||
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를 입력했어요. 설정 저장을 누르면 차단됩니다.';
|
||||
pendingBlockedIp.value = ip;
|
||||
view.value = 'network';
|
||||
}
|
||||
async function networkSaved(settings) {
|
||||
overview.value.settings = settings;
|
||||
await refresh(false);
|
||||
}
|
||||
async function save() {
|
||||
await act(async () => {
|
||||
@@ -110,8 +101,6 @@ async function save() {
|
||||
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
|
||||
? overview.value.settings.totalStorageLimitMb
|
||||
: Math.round((Number(capacity.value) * 1e9) / 1048576),
|
||||
blockedCountries: split(countries.value),
|
||||
blockedIps: split(ips.value),
|
||||
});
|
||||
overview.value.settings = settings;
|
||||
form(settings);
|
||||
@@ -164,8 +153,6 @@ async function remove() {
|
||||
await refresh(false);
|
||||
});
|
||||
}
|
||||
const actionName = (action) =>
|
||||
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refresh();
|
||||
@@ -240,7 +227,7 @@ onMounted(async () => {
|
||||
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
|
||||
<code>{{ request.ip || 'IP 확인 불가' }}</code
|
||||
><span
|
||||
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
|
||||
>{{ request.countryCode ? formatCountry(request.countryCode) : '지역 확인 불가' }} ·
|
||||
{{ date(request.startedAt) }}</span
|
||||
><button
|
||||
class="text-button"
|
||||
@@ -254,8 +241,10 @@ onMounted(async () => {
|
||||
<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 === 'rules' }" @click="view = 'rules'">용량 설정</button
|
||||
><button :class="{ selected: view === 'network' }" @click="view = 'network'">
|
||||
네트워크</button
|
||||
><button :class="{ selected: view === 'users' }" @click="view = 'users'">사용자</button
|
||||
><button :class="{ selected: view === 'audit' }" @click="view = 'audit'">관리 기록</button>
|
||||
</nav>
|
||||
<section v-if="view === 'files'" class="admin-files">
|
||||
@@ -340,42 +329,18 @@ onMounted(async () => {
|
||||
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
|
||||
파일은 삭제하지 않습니다.
|
||||
</p>
|
||||
<label
|
||||
>차단할 국가 코드<input
|
||||
v-model="countries"
|
||||
placeholder="예: CN, RU"
|
||||
:disabled="busy"
|
||||
/><small>CN 중국 · RU 러시아 · US 미국 · JP 일본 · KR 대한민국</small></label
|
||||
>
|
||||
<label
|
||||
>차단할 IP 또는 IP 대역<textarea
|
||||
v-model="ips"
|
||||
rows="4"
|
||||
placeholder="203.0.113.10 198.51.100.0/24"
|
||||
:disabled="busy"
|
||||
></textarea
|
||||
><small>쉼표나 줄바꿈으로 구분합니다. IPv4·IPv6·CIDR을 지원합니다.</small></label
|
||||
>
|
||||
<p class="admin-muted">
|
||||
기본 접속 정보 기준으로 차단합니다. 지역 정보가 없는 접속은 국가 규칙으로 차단되지
|
||||
않습니다. 현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.
|
||||
</p>
|
||||
<button class="button primary" :disabled="busy">설정 저장</button>
|
||||
</form>
|
||||
<section v-else class="admin-audit">
|
||||
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
|
||||
<article v-for="event in events" :key="event.id">
|
||||
<strong>{{ actionName(event.action) }}</strong
|
||||
><span>{{ date(event.createdAt) }}</span
|
||||
><small v-if="event.action === 'file-delete'">{{ event.details.name }}</small
|
||||
><small v-else
|
||||
>업로드 {{ event.details.uploadsPaused ? '중지' : '허용' }} · 동시
|
||||
{{ event.details.maxConcurrentUploads }}개 · 국가
|
||||
{{ event.details.blockedCountries?.join(', ') || '없음' }} · IP
|
||||
{{ event.details.blockedIps?.length || 0 }}개</small
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<NetworkPanel
|
||||
v-else-if="view === 'network'"
|
||||
:settings="overview.settings"
|
||||
:suggested-ip="pendingBlockedIp"
|
||||
@consumed="pendingBlockedIp = null"
|
||||
@saved="networkSaved"
|
||||
@activity="refresh(false)"
|
||||
/>
|
||||
<AdminUsers v-else-if="view === 'users'" @changed="refresh(false)" />
|
||||
<AdminAudit v-else-if="view === 'audit'" />
|
||||
</template>
|
||||
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
|
||||
<section
|
||||
|
||||
238
apps/web/src/AdminUsers.vue
Normal file
238
apps/web/src/AdminUsers.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { RefreshCw, MoreHorizontal, X } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatAdminDate as date, formatCountry } from './displayFormat';
|
||||
const emit = defineEmits(['changed']);
|
||||
const users = ref([]),
|
||||
nextOffset = ref(null),
|
||||
query = ref(''),
|
||||
busy = ref(false),
|
||||
notice = ref('');
|
||||
const menu = ref(null),
|
||||
menuStyle = ref({}),
|
||||
menuElement = ref(null),
|
||||
action = ref(null),
|
||||
confirmation = ref(''),
|
||||
dialog = ref(null);
|
||||
let trigger;
|
||||
const status = (user) =>
|
||||
user.status === 'suspended'
|
||||
? '정지'
|
||||
: user.status === 'pending' || !user.verifiedAt
|
||||
? '이메일 인증 전'
|
||||
: '인증된 계정';
|
||||
async function fetchUsers(more = false) {
|
||||
const result = await api(
|
||||
`/admin/users?offset=${more ? nextOffset.value : 0}&q=${encodeURIComponent(query.value.trim())}`,
|
||||
);
|
||||
users.value = more ? [...users.value, ...result.users] : result.users;
|
||||
nextOffset.value = result.nextOffset;
|
||||
}
|
||||
async function act(work) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
notice.value = '';
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
const load = (more = false) => act(() => fetchUsers(more));
|
||||
async function openMenu(user, event) {
|
||||
trigger = event.currentTarget;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
menuStyle.value = {
|
||||
top: Math.max(8, Math.min(rect.bottom + 5, innerHeight - 130)) + 'px',
|
||||
left: Math.max(8, Math.min(rect.right - 230, innerWidth - 238)) + 'px',
|
||||
};
|
||||
menu.value = user;
|
||||
await nextTick();
|
||||
menuElement.value?.querySelector('button')?.focus();
|
||||
}
|
||||
function closeMenu() {
|
||||
menu.value = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
async function choose(kind) {
|
||||
const user = menu.value;
|
||||
closeMenu();
|
||||
action.value = { kind, user };
|
||||
confirmation.value = '';
|
||||
await nextTick();
|
||||
dialog.value?.querySelector('button')?.focus();
|
||||
}
|
||||
function close() {
|
||||
action.value = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
async function perform() {
|
||||
await act(async () => {
|
||||
const { user, kind } = action.value;
|
||||
if (kind === 'delete')
|
||||
await api(`/admin/users/${user.id}/delete`, 'POST', { email: confirmation.value });
|
||||
else await api(`/admin/users/${user.id}/password-reset`, 'POST', {});
|
||||
close();
|
||||
await fetchUsers();
|
||||
emit('changed');
|
||||
notice.value = kind === 'delete' ? '계정을 삭제했어요.' : '비밀번호 재설정 메일을 보냈어요.';
|
||||
});
|
||||
}
|
||||
function trap(event, element, exit) {
|
||||
if (event.key === 'Escape' && !busy.value) {
|
||||
event.preventDefault();
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = [...element.querySelectorAll('button:not(:disabled),input: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();
|
||||
}
|
||||
}
|
||||
onMounted(() => load());
|
||||
</script>
|
||||
<template>
|
||||
<section class="admin-users">
|
||||
<form class="admin-toolbar" @submit.prevent="load()">
|
||||
<label class="network-search"
|
||||
>이메일 검색<input v-model="query" placeholder="이메일 주소" /></label
|
||||
><button class="button small" :disabled="busy"><RefreshCw :size="15" />사용자 조회</button>
|
||||
</form>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
<p class="admin-muted">
|
||||
로그인 계정의 마지막 접속 정보입니다. 접속 지역은 IP 기반 추정값이며, 이전 접속 기록이 없는
|
||||
계정은 확인 불가로 표시합니다.
|
||||
</p>
|
||||
<div class="network-table-wrap">
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자</th>
|
||||
<th>최근 접속</th>
|
||||
<th>접속 지역 · IP</th>
|
||||
<th><span class="sr-only">계정 관리</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>
|
||||
<strong>{{ user.email }}</strong
|
||||
><small
|
||||
><template v-if="user.name && user.name !== user.email">{{ user.name }} · </template
|
||||
>{{ status(user) }}</small
|
||||
>
|
||||
</td>
|
||||
<td>{{ user.lastSeenAt ? date(user.lastSeenAt) : '접속 기록 없음' }}</td>
|
||||
<td>
|
||||
{{ formatCountry(user.countryCode)
|
||||
}}<template v-if="user.city || user.region">
|
||||
· {{ user.city || user.region }}</template
|
||||
><small>{{ user.ip || 'IP 확인 불가' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="menu?.id === user.id"
|
||||
:aria-label="`${user.email} 더보기`"
|
||||
:disabled="busy"
|
||||
@click="openMenu(user, $event)"
|
||||
>
|
||||
<MoreHorizontal :size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!users.length && !busy">
|
||||
<td colspan="4" class="network-empty">표시할 사용자가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button v-if="nextOffset !== null" class="button small" :disabled="busy" @click="load(true)">
|
||||
더 보기
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="menu" class="admin-user-menu-backdrop" @click.self="closeMenu">
|
||||
<div
|
||||
ref="menuElement"
|
||||
role="menu"
|
||||
class="admin-user-menu"
|
||||
:style="menuStyle"
|
||||
@keydown="trap($event, menuElement, closeMenu)"
|
||||
>
|
||||
<button role="menuitem" @click="choose('reset')">비밀번호 재설정 메일 보내기</button
|
||||
><button
|
||||
v-if="menu.email !== 'hello@noname.company'"
|
||||
role="menuitem"
|
||||
class="admin-delete"
|
||||
@click="choose('delete')"
|
||||
>
|
||||
계정 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action" class="modal-backdrop" @click.self="!busy && close()">
|
||||
<section
|
||||
ref="dialog"
|
||||
class="modal admin-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-action-title"
|
||||
@keydown="trap($event, dialog, close)"
|
||||
>
|
||||
<header class="modal-title">
|
||||
<h2 id="user-action-title">
|
||||
{{ action.kind === 'delete' ? '계정을 삭제할까요?' : '비밀번호 재설정 메일 보내기' }}
|
||||
</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="계정 관리 확인 닫기"
|
||||
:disabled="busy"
|
||||
@click="close"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<strong class="admin-confirm-file">{{ action.user.email }}</strong
|
||||
><template v-if="action.kind === 'delete'"
|
||||
><p>
|
||||
계정과 보관함 접근권을 삭제하고 모든 로그인을 종료합니다. 다른 사람이 받은 파일은 기존
|
||||
보관 정책에 따라 유지됩니다. 복원할 수 없습니다.
|
||||
</p>
|
||||
<label class="admin-delete-email"
|
||||
>확인을 위해 대상 이메일 입력<input
|
||||
v-model="confirmation"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
:disabled="busy" /></label
|
||||
></template>
|
||||
<p v-else>
|
||||
이 계정의 이메일로 일회용 비밀번호 재설정 링크를 보냅니다. 사용자가 링크에서 새
|
||||
비밀번호를 정하며, 완료 후 기존 로그인은 종료됩니다.
|
||||
</p>
|
||||
<div class="admin-confirm-actions">
|
||||
<button class="button" :disabled="busy" @click="close">취소</button
|
||||
><button
|
||||
class="button primary"
|
||||
:disabled="busy || (action.kind === 'delete' && confirmation !== action.user.email)"
|
||||
@click="perform"
|
||||
>
|
||||
{{ action.kind === 'delete' ? '계정 삭제 확인' : '메일 보내기' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
525
apps/web/src/NetworkPanel.vue
Normal file
525
apps/web/src/NetworkPanel.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { Plus, Trash2, X, RefreshCw } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatAdminDate as date, formatCountry as country } from './displayFormat';
|
||||
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
|
||||
const emit = defineEmits(['saved', 'activity', 'consumed']);
|
||||
const draft = reactive({}),
|
||||
busy = ref(false),
|
||||
notice = ref('');
|
||||
const selectedBlack = ref([]),
|
||||
selectedWhite = ref([]),
|
||||
selectedAuto = ref([]);
|
||||
const automatic = ref([]),
|
||||
autoTotal = ref(0),
|
||||
nextOffset = ref(null),
|
||||
search = ref('');
|
||||
const modal = ref(null),
|
||||
kind = ref('country'),
|
||||
value = ref(''),
|
||||
dialog = ref(null);
|
||||
const ruleName = (entry) => (entry.kind === 'country' ? country(entry.value) : entry.value);
|
||||
const reasonName = (reason) =>
|
||||
({
|
||||
'login-failures': '로그인 실패 반복',
|
||||
'rate-violations': '요청 제한 반복 초과',
|
||||
'file-requests': '파일 전송 시도 반복',
|
||||
'file-volume': '요청 파일 용량 초과',
|
||||
})[reason] || reason;
|
||||
function fill(settings) {
|
||||
for (const key of [
|
||||
'blacklistEnabled',
|
||||
'allowedCountries',
|
||||
'allowedIps',
|
||||
'blockedCountries',
|
||||
'blockedIps',
|
||||
'autoBlockEnabled',
|
||||
'autoWindowMinutes',
|
||||
'autoLoginFailures',
|
||||
'autoRateViolations',
|
||||
'autoUploadRequests',
|
||||
'autoUploadBytes',
|
||||
'autoBlockMinutes',
|
||||
])
|
||||
draft[key] = Array.isArray(settings[key]) ? [...settings[key]] : settings[key];
|
||||
selectedBlack.value = [];
|
||||
selectedWhite.value = [];
|
||||
}
|
||||
watch(
|
||||
() => props.settings.revision,
|
||||
() => fill(props.settings),
|
||||
{ immediate: true },
|
||||
);
|
||||
const uploadGb = computed({
|
||||
get: () => draft.autoUploadBytes / 1e9,
|
||||
set: (number) => {
|
||||
draft.autoUploadBytes = Math.round(Number(number) * 1e9);
|
||||
},
|
||||
});
|
||||
const entries = (list) => [
|
||||
...draft[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({
|
||||
kind: 'country',
|
||||
value,
|
||||
id: 'country:' + value,
|
||||
})),
|
||||
...draft[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({
|
||||
kind: 'ip',
|
||||
value,
|
||||
id: 'ip:' + value,
|
||||
})),
|
||||
];
|
||||
const black = computed(() => entries('black')),
|
||||
white = computed(() => entries('white'));
|
||||
async function act(work) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
notice.value = '';
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function loadAutomatic(more = false) {
|
||||
const offset = more ? nextOffset.value : 0;
|
||||
const result = await api(
|
||||
`/admin/automatic-blocks?offset=${offset}&q=${encodeURIComponent(search.value.trim())}`,
|
||||
);
|
||||
automatic.value = more ? [...automatic.value, ...result.items] : result.items;
|
||||
autoTotal.value = result.total;
|
||||
nextOffset.value = result.nextOffset;
|
||||
if (!more) selectedAuto.value = [];
|
||||
}
|
||||
loadAutomatic().catch(showError);
|
||||
async function save() {
|
||||
await act(async () => {
|
||||
const settings = await api('/admin/settings', 'PATCH', {
|
||||
revision: props.settings.revision,
|
||||
...draft,
|
||||
});
|
||||
fill(settings);
|
||||
emit('saved', settings);
|
||||
notice.value = '네트워크 설정을 저장했어요.';
|
||||
});
|
||||
}
|
||||
let trigger;
|
||||
async function open(list) {
|
||||
trigger = document.activeElement;
|
||||
modal.value = list;
|
||||
kind.value = 'country';
|
||||
value.value = '';
|
||||
await nextTick();
|
||||
dialog.value?.querySelector('select')?.focus();
|
||||
}
|
||||
watch(
|
||||
() => props.suggestedIp,
|
||||
async (ip) => {
|
||||
if (!ip) return;
|
||||
await open('black');
|
||||
kind.value = 'ip';
|
||||
value.value = ip;
|
||||
emit('consumed');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close() {
|
||||
modal.value = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
async function add() {
|
||||
await act(async () => {
|
||||
const entry = await api('/admin/network-rules/validate', 'POST', {
|
||||
kind: kind.value,
|
||||
value: value.value.trim(),
|
||||
});
|
||||
const field =
|
||||
modal.value === 'black'
|
||||
? entry.kind === 'country'
|
||||
? 'blockedCountries'
|
||||
: 'blockedIps'
|
||||
: entry.kind === 'country'
|
||||
? 'allowedCountries'
|
||||
: 'allowedIps';
|
||||
draft[field] = [...new Set([...draft[field], entry.value])];
|
||||
close();
|
||||
notice.value = '표에 추가했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
|
||||
});
|
||||
}
|
||||
function remove(list) {
|
||||
const selected = list === 'black' ? selectedBlack : selectedWhite;
|
||||
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
|
||||
ips = list === 'black' ? 'blockedIps' : 'allowedIps';
|
||||
draft[countries] = draft[countries].filter(
|
||||
(value) => !selected.value.includes('country:' + value),
|
||||
);
|
||||
draft[ips] = draft[ips].filter((value) => !selected.value.includes('ip:' + value));
|
||||
selected.value = [];
|
||||
notice.value = '표에서 제거했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
|
||||
}
|
||||
async function removeAutomatic() {
|
||||
await act(async () => {
|
||||
const selected = [...selectedAuto.value];
|
||||
for (let offset = 0; offset < selected.length; offset += 100)
|
||||
await api('/admin/automatic-blocks/remove', 'POST', {
|
||||
ips: selected.slice(offset, offset + 100),
|
||||
});
|
||||
await loadAutomatic();
|
||||
emit('activity');
|
||||
notice.value = '선택한 IP의 자동 차단을 해제했어요.';
|
||||
});
|
||||
}
|
||||
function modalKey(event) {
|
||||
if (event.key === 'Escape' && !busy.value) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = [
|
||||
...dialog.value.querySelectorAll(
|
||||
'button:not(:disabled),input:not(:disabled),select: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();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="network-panel">
|
||||
<p class="admin-muted">
|
||||
목록과 감지 기준을 편집한 뒤 네트워크 설정을 저장하세요. 블랙리스트와 자동 차단이 우선
|
||||
적용됩니다.
|
||||
</p>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
<form @submit.prevent="save">
|
||||
<section class="network-section" aria-labelledby="automatic-title">
|
||||
<header>
|
||||
<h2 id="automatic-title">자동 블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.autoBlockEnabled"
|
||||
aria-label="자동 블랙리스트 사용"
|
||||
:disabled="busy"
|
||||
@click="draft.autoBlockEnabled = !draft.autoBlockEnabled"
|
||||
>
|
||||
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
|
||||
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
|
||||
</p>
|
||||
<div class="network-auto-fields">
|
||||
<label
|
||||
>감지 기간 (분)<input
|
||||
v-model.number="draft.autoWindowMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label
|
||||
>로그인 실패 (회)<input
|
||||
v-model.number="draft.autoLoginFailures"
|
||||
type="number"
|
||||
min="3"
|
||||
max="1000"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label
|
||||
>요청 제한 위반 (회)<input
|
||||
v-model.number="draft.autoRateViolations"
|
||||
type="number"
|
||||
min="3"
|
||||
max="1000"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label
|
||||
>파일 전송 시도 (회)<input
|
||||
v-model.number="draft.autoUploadRequests"
|
||||
type="number"
|
||||
min="3"
|
||||
max="10000"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label
|
||||
>요청한 파일 용량 (GB)<input
|
||||
v-model="uploadGb"
|
||||
type="number"
|
||||
min="0.001"
|
||||
max="1000"
|
||||
step="0.001"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label
|
||||
>자동 차단 시간 (분)<input
|
||||
v-model.number="draft.autoBlockMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10080"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
</div>
|
||||
<p class="admin-muted">
|
||||
감지 기간 안에 로그인 실패·제한 위반이 설정 횟수에 도달하거나, 파일 전송 횟수·요청 용량을
|
||||
초과하면 해당 IP를 차단합니다. 수락 전 파일 전송 시도도 계산하며 같은 요청의 재시도는 중복
|
||||
계산하지 않습니다. 같은 공인 IP를 쓰는 사람은 기준을 함께 적용받습니다. 자동 차단은 설정한
|
||||
시간 뒤 해제되고, 기존 관리자 로그인은 자동 차단을 해제할 수 있습니다.
|
||||
</p>
|
||||
<div class="network-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button small"
|
||||
:disabled="busy || !selectedAuto.length"
|
||||
@click="removeAutomatic"
|
||||
>
|
||||
<Trash2 :size="15" />선택 차단 해제</button
|
||||
><span class="admin-muted">{{ autoTotal }}개</span
|
||||
><label class="network-search"
|
||||
>IP 검색<input
|
||||
v-model="search"
|
||||
@keydown.enter.prevent="act(() => loadAutomatic())" /></label
|
||||
><button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="자동 차단 목록 새로고침"
|
||||
:disabled="busy"
|
||||
@click="act(() => loadAutomatic())"
|
||||
>
|
||||
<RefreshCw :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="network-table-wrap">
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>선택</th>
|
||||
<th>IP · 국가</th>
|
||||
<th>차단 사유</th>
|
||||
<th>차단 / 해제 시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in automatic" :key="entry.ip">
|
||||
<td>
|
||||
<input
|
||||
v-model="selectedAuto"
|
||||
type="checkbox"
|
||||
:value="entry.ip"
|
||||
:disabled="busy"
|
||||
:aria-label="`${entry.ip} 자동 차단 선택`"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ entry.ip }}</code
|
||||
><small v-if="entry.countryCode">{{ country(entry.countryCode) }}</small>
|
||||
</td>
|
||||
<td>{{ reasonName(entry.reason) }}</td>
|
||||
<td>
|
||||
{{ date(entry.createdAt) }}<small>{{ date(entry.expiresAt) }} 해제</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!automatic.length">
|
||||
<td colspan="4" class="network-empty">자동 차단된 IP가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
v-if="nextOffset !== null"
|
||||
type="button"
|
||||
class="button small"
|
||||
:disabled="busy"
|
||||
@click="act(() => loadAutomatic(true))"
|
||||
>
|
||||
더 보기
|
||||
</button>
|
||||
</section>
|
||||
<section class="network-section" aria-labelledby="blacklist-title">
|
||||
<header>
|
||||
<h2 id="blacklist-title">블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.blacklistEnabled"
|
||||
aria-label="블랙리스트 사용"
|
||||
:disabled="busy"
|
||||
@click="draft.blacklistEnabled = !draft.blacklistEnabled"
|
||||
>
|
||||
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
|
||||
</p>
|
||||
<div class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('black')">
|
||||
<Plus :size="15" />블랙리스트 추가</button
|
||||
><button
|
||||
type="button"
|
||||
class="button small"
|
||||
:disabled="busy || !selectedBlack.length"
|
||||
@click="remove('black')"
|
||||
>
|
||||
<Trash2 :size="15" />선택 항목 제거
|
||||
</button>
|
||||
</div>
|
||||
<div class="network-table-wrap">
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in black" :key="entry.id">
|
||||
<td>
|
||||
<input
|
||||
v-model="selectedBlack"
|
||||
type="checkbox"
|
||||
:value="entry.id"
|
||||
:aria-label="`${ruleName(entry)} 차단 규칙 선택`"
|
||||
:disabled="busy"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ entry.kind === 'country' ? '국가' : 'IP / 대역' }}</td>
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!black.length">
|
||||
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="network-section" aria-labelledby="whitelist-title">
|
||||
<header>
|
||||
<h2 id="whitelist-title">화이트리스트</h2>
|
||||
<span class="admin-muted">{{ white.length ? '등록한 곳만 허용' : '공개' }}</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
비어 있으면 공개합니다. 항목이 있으면 등록한 국가 또는 IP·IP 대역과 일치하는 접속만
|
||||
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
|
||||
차단됩니다.
|
||||
</p>
|
||||
<div class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('white')">
|
||||
<Plus :size="15" />화이트리스트 추가</button
|
||||
><button
|
||||
type="button"
|
||||
class="button small"
|
||||
:disabled="busy || !selectedWhite.length"
|
||||
@click="remove('white')"
|
||||
>
|
||||
<Trash2 :size="15" />선택 항목 제거
|
||||
</button>
|
||||
</div>
|
||||
<div class="network-table-wrap">
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in white" :key="entry.id">
|
||||
<td>
|
||||
<input
|
||||
v-model="selectedWhite"
|
||||
type="checkbox"
|
||||
:value="entry.id"
|
||||
:aria-label="`${ruleName(entry)} 허용 규칙 선택`"
|
||||
:disabled="busy"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ entry.kind === 'country' ? '국가' : 'IP / 대역' }}</td>
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!white.length">
|
||||
<td colspan="3" class="network-empty">
|
||||
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-muted">
|
||||
현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다. 자동 블랙리스트가 켜져 있으면
|
||||
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
|
||||
</p>
|
||||
</section>
|
||||
<button class="button primary" :disabled="busy">네트워크 설정 저장</button>
|
||||
</form>
|
||||
<Teleport to="body"
|
||||
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
|
||||
<section
|
||||
ref="dialog"
|
||||
class="modal network-rule-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="network-rule-title"
|
||||
@keydown="modalKey"
|
||||
>
|
||||
<header class="modal-title">
|
||||
<h2 id="network-rule-title">
|
||||
{{ modal === 'black' ? '블랙리스트 추가' : '화이트리스트 추가' }}
|
||||
</h2>
|
||||
<button class="icon-button" aria-label="규칙 추가 닫기" :disabled="busy" @click="close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<form class="network-rule-form" @submit.prevent="add">
|
||||
<label
|
||||
>등록 대상<select v-model="kind" :disabled="busy" @change="value = ''">
|
||||
<option value="country">국가</option>
|
||||
<option value="ip">IP 주소 / IP 대역</option>
|
||||
</select></label
|
||||
><label
|
||||
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR'
|
||||
}}<input
|
||||
v-model="value"
|
||||
:maxlength="kind === 'country' ? 2 : 100"
|
||||
:placeholder="
|
||||
kind === 'country' ? '예: JP' : '예: 203.0.113.10 또는 198.51.100.0/24'
|
||||
"
|
||||
required
|
||||
:disabled="busy"
|
||||
autocapitalize="characters"
|
||||
/></label>
|
||||
<p v-if="kind === 'country' && /^[A-Za-z]{2}$/.test(value)" class="admin-muted">
|
||||
{{ country(value.toUpperCase()) }}
|
||||
</p>
|
||||
<p v-else class="admin-muted">
|
||||
국가, IPv4·IPv6 주소 또는 CIDR 대역을 한 항목씩 추가합니다.
|
||||
</p>
|
||||
<button class="button primary" :disabled="busy || !value.trim()">추가</button>
|
||||
</form>
|
||||
</section>
|
||||
</div></Teleport
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
@@ -4,6 +4,10 @@ export function formatSize(bytes) {
|
||||
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1).replace(/\.0$/, '')}KB`;
|
||||
return bytes === 0 ? '0KB' : '1KB 미만';
|
||||
}
|
||||
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
|
||||
export function formatCountry(code) {
|
||||
return code ? `${countryNames.of(code)}(${code})` : '확인 불가';
|
||||
}
|
||||
|
||||
export function formatAdminDate(value) {
|
||||
if (!value) return '확인 불가';
|
||||
@@ -22,3 +26,54 @@ export function formatAdminDate(value) {
|
||||
);
|
||||
return `${parts.year}년 ${parts.month}월 ${parts.day}일 ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
export function auditChangeLines(change) {
|
||||
const lists = {
|
||||
blockedCountries: '블랙리스트',
|
||||
blockedIps: '블랙리스트',
|
||||
allowedCountries: '화이트리스트',
|
||||
allowedIps: '화이트리스트',
|
||||
};
|
||||
if (lists[change.field]) {
|
||||
const values = (items) =>
|
||||
items
|
||||
.map((item) => (change.field.endsWith('Countries') ? formatCountry(item) : item))
|
||||
.join(', ');
|
||||
return [
|
||||
change.added?.length ? `${lists[change.field]} 추가: ${values(change.added)}` : null,
|
||||
change.removed?.length ? `${lists[change.field]} 제거: ${values(change.removed)}` : null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
const names = {
|
||||
uploadsPaused: '신규 업로드',
|
||||
maxConcurrentUploads: '동시에 처리할 업로드 수',
|
||||
totalStorageLimitMb: '전체 파일 저장 한도',
|
||||
blacklistEnabled: '블랙리스트 사용',
|
||||
autoBlockEnabled: '자동 블랙리스트 사용',
|
||||
autoWindowMinutes: '자동 감지 기간',
|
||||
autoLoginFailures: '로그인 실패 기준',
|
||||
autoRateViolations: '요청 제한 위반 기준',
|
||||
autoUploadRequests: '파일 전송 시도 기준',
|
||||
autoUploadBytes: '요청한 파일 용량 기준',
|
||||
autoBlockMinutes: '자동 차단 시간',
|
||||
};
|
||||
const value = (item) =>
|
||||
change.field === 'uploadsPaused'
|
||||
? item
|
||||
? '중지'
|
||||
: '허용'
|
||||
: typeof item === 'boolean'
|
||||
? item
|
||||
? '켜짐'
|
||||
: '꺼짐'
|
||||
: change.field === 'totalStorageLimitMb'
|
||||
? formatSize(item * 1048576)
|
||||
: change.field === 'autoUploadBytes'
|
||||
? formatSize(item)
|
||||
: change.field.endsWith('Minutes')
|
||||
? `${item}분`
|
||||
: item;
|
||||
return [
|
||||
`${names[change.field] || change.field} ${value(change.before)} > ${value(change.after)}`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4226,3 +4226,221 @@ a.button {
|
||||
.admin-activity span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.network-panel > form {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.network-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
.network-section > header,
|
||||
.network-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.network-section > header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.network-section h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.network-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 44px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.network-switch > span {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
padding: 3px;
|
||||
border-radius: 20px;
|
||||
background: var(--line);
|
||||
}
|
||||
.network-switch > span::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--muted);
|
||||
border-radius: 50%;
|
||||
transition: transform 160ms;
|
||||
}
|
||||
.network-switch[aria-checked='true'] > span {
|
||||
background: var(--accent);
|
||||
}
|
||||
.network-switch[aria-checked='true'] > span::after {
|
||||
background: var(--on-accent);
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.network-auto-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.network-auto-fields label,
|
||||
.network-rule-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.network-auto-fields input,
|
||||
.network-rule-form input,
|
||||
.network-rule-form select,
|
||||
.network-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.network-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
.network-search input {
|
||||
max-width: 180px;
|
||||
}
|
||||
.network-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.network-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.network-table th,
|
||||
.network-table td {
|
||||
padding: 13px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.network-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.network-table input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.network-table small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.network-empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.network-rule-modal .modal-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.network-rule-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.network-panel > form > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.admin-tabs {
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.network-section {
|
||||
padding: 16px;
|
||||
}
|
||||
.network-auto-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.network-search {
|
||||
margin-left: 0;
|
||||
}
|
||||
.network-panel > form > .button {
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-audit-panel,
|
||||
.admin-users {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.audit-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.audit-selection input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.audit-remove {
|
||||
grid-column: 1/-1;
|
||||
justify-self: end;
|
||||
}
|
||||
.admin-user-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
.admin-user-menu {
|
||||
position: fixed;
|
||||
width: 230px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 30px #0003;
|
||||
}
|
||||
.admin-user-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.admin-user-menu button:hover {
|
||||
background: var(--soft);
|
||||
}
|
||||
.admin-delete-email {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-delete-email input {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
22
deploy.md
22
deploy.md
@@ -481,11 +481,11 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
|
||||
|
||||
저장 파일의 업로드 주체·파일별 접속 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 각 파일의 최초 전송 요청 당시 전달된 IP 기반 추정 정보입니다. 이후 다른 파일을 올린 지역으로 덮어쓰지 않습니다. 같은 요청의 여러 파일에는 같은 정보를 사용합니다.
|
||||
|
||||
**접속 · 용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도를 변경합니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 기존 개인·기기 용량 제한, 파일당 크기 제한, 최소 5GiB 디스크 여유와 전송 요청 제한도 유지합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
|
||||
**용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도를 변경합니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 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`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
|
||||
**네트워크 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. 모달로 추가한 표를 확인하고 네트워크 설정을 저장하며, 제거할 때는 표의 항목을 선택합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
|
||||
|
||||
관리 설정은 DB의 `admin_settings`, 변경·원본 삭제 이력은 `admin_audit`에 보관합니다. 이력은 기본 30일이며 설정은 재시작 뒤 유지됩니다. 한 번 저장한 관리 설정은 `.env` 초기값보다 우선하고 이후에도 관리자 화면에서 수정합니다. 화면은 수동 새로고침만 사용하며 상시 목록 조회를 하지 않습니다.
|
||||
|
||||
@@ -507,3 +507,21 @@ sudo docker compose -f docker-compose.yaml up -d --build
|
||||
Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다.
|
||||
|
||||
관리자 한도 설명에는 실제 서버의 계정별 보관함·기기 세션별 업로드·기기 세션 전체 업로드·파일당 크기·NAS 최소 여유 공간을 각각 표시합니다. 전체 파일 한도는 모두가 공유하며 개인에게 같은 양을 배정하지 않습니다. 기본값 2GiB는 약 2.1GB, 최소 여유 5GiB는 약 5.4GB, 전체 20GiB는 약 21.5GB로 표시됩니다. 이 값은 각 NAS의 기존 `.env`와 저장된 관리 설정을 따르며 이번 표시 변경으로 값을 재설정하지 않습니다.
|
||||
|
||||
## 네트워크·사용자·관리 기록 업데이트
|
||||
|
||||
`0014_network_protection`은 `automatic_blocks`, `user_activity` 테이블만 추가합니다. 기존 차단 규칙·관리 설정·계정·자료와 Compose는 유지합니다. 새 설정이 없는 기존 DB에는 화이트리스트 빈 목록과 자동 차단 꺼짐 기본값을 적용합니다. 별도 `.env` 변경은 없습니다.
|
||||
|
||||
네트워크 탭의 세 영역에서 관리합니다. 수동 블랙리스트는 국가/IP/CIDR 차단, 화이트리스트는 등록 국가 또는 IP/CIDR만 허용하는 목록입니다. 비어 있으면 공개하고 블랙리스트·자동 차단이 화이트리스트보다 우선합니다. 국가 미제공 시 허용 IP와 일치하지 않으면 비공개입니다. 자동 차단을 켜면 원본 공인 IP별 기본 10분 창에서 로그인 실패 10회·요청 제한 위반 20회·파일 요청 시도 30회 초과·요청 용량 10GB 초과를 감지해 기본 30분 차단합니다. 기준과 차단 시간을 화면에서 조정합니다. 동일 IP의 로그인 보관함/기기 전송·여러 기기 세션을 함께 계산하며 같은 요청의 재시도는 제외합니다. 수락 전의 유효한 파일 요청 본문을 기준으로 세며 실제 업로드 성공 용량 통계는 아닙니다. 정상 조각 API·UPLOAD_BUSY·거절 대기는 위반 횟수에서 제외합니다. 공유 공인 IP/NAT의 다른 사람도 영향을 받을 수 있습니다.
|
||||
|
||||
자동 IP 차단은 DB에 보존하고 만료 후 약 1분 주기로 정리합니다. 감지 중인 횟수·용량 창과 중복 요청 키는 최대 각 10,000개 메모리 항목으로 제한하며 재시작 시 초기화됩니다. 자동 차단 목록은 최대 10,000개의 활성 IP까지 보관하고, 그 이후에도 기존 요청 제한·전체 용량·동시 작업 제한은 유지합니다. 자동 차단을 끄면 기록을 유지한 채 적용/감지를 중지하며, 기간이 끝나기 전에 다시 켜면 남은 차단도 적용됩니다. 표에서 선택 해제하면 해당 IP의 감지 누적도 초기화합니다. 알려지지 않은 프록시 주소는 자동 차단하지 않습니다. 기존 인증된 관리자 로그인은 자동 차단 상태를 복구할 수 있으며 수동 규칙은 우회하지 않습니다.
|
||||
|
||||
관리 기록은 전체 설정 반복 대신 실제 변경 필드의 전/후와 목록 추가/제거를 보관합니다. 변경 없는 저장은 기록하지 않으며 과거 전체 설정 기록은 이전 값이 있는 경우만 비교합니다. 기록은 기본 30일, 읽음 표시는 없고 개별/선택/전체 삭제가 가능하며 삭제해도 실제 설정과 자료는 유지됩니다.
|
||||
|
||||
사용자 탭은 로그인 계정의 이메일·상태와 마지막 로그인/인증/로그인 API 활동의 시간·국가·지역·IP를 관리자에게만 제공합니다. 같은 정보는 1분 안에 반복 기록하지 않으며 위치/IP는 `AUDIT_RETENTION_DAYS`(기본 30일) 후 지우고 마지막 시각만 남깁니다. 좌표·비밀번호·쿠키·토큰은 표시하거나 접속 메타데이터로 기록하지 않습니다. 기존 사용자의 과거 위치를 추정하지 않습니다.
|
||||
|
||||
사용자 더보기에서 비밀번호 재설정 메일을 보내거나 계정을 삭제합니다. 메일은 기존 Resend 설정과 발송 제한·일회용 토큰을 사용하며 메일 발송만으로 비밀번호를 바꾸지 않습니다. 수신자가 재설정을 완료하면 기존 로그인 세션을 폐기합니다. 계정 삭제는 대상 이메일 입력 확인 후 계정 개인정보·비밀번호를 비우고 세션/토큰·보관함 접근권을 무효화하며 진행 중인 계정 전송도 취소합니다. 공유받은 다른 사람의 파일은 마지막 유효 접근권까지 유지합니다. 계정은 참조 무결성을 위해 익명화한 삭제 상태로 남기고 사용자 목록에서 제외합니다. 관리자 자신과 `hello@noname.company`는 이 메뉴로 삭제하지 못합니다. 계정 지원 작업도 관리 기록에 남습니다.
|
||||
|
||||
평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다. 운영 NAS와 Cloudflare 설정은 자동 변경하지 않습니다.
|
||||
|
||||
국가 화이트리스트를 적용하더라도 컨테이너 내부 루프백에서 전달 헤더 없이 호출하는 `/api/health`는 상태 검사를 위해 허용합니다. 다른 API·관리 기능에는 이 예외를 적용하지 않고 외부 프록시에서 온 health 요청도 네트워크 규칙을 따릅니다.
|
||||
|
||||
@@ -199,3 +199,14 @@
|
||||
- 전송 선택·수신 요청·보관함·관리자·텍스트 한도 안내를 공통 KB/MB/GB(1,000 단위) 표시로 바꿨다. 1MB 미만은 KB, 1MB/1GB 경계에서만 단위를 바꾸며 `938KB`, `1.1MB`, `1.7GB`로 표시한다. 0은 0KB, 1KB 미만은 별도로 안내한다. 저장 한도와 환경변수의 기존 MiB 기준은 유지하고, 관리자 전체 용량 입력은 GB로 환산했다. 반올림된 표시를 수정하지 않고 다른 규칙만 저장해도 원래 설정 용량이 바뀌지 않는 브라우저 검증을 추가했다.
|
||||
- 관리자에게 계정별 보관함·기기 세션별 업로드·기기 전체 업로드·파일당 한도·NAS 최소 여유 공간의 실제 서버 설정값을 제공한다. 전체 한도는 사용자 모두의 원본·예약 합계이며 개인 배정량이 아님을 설명한다. 한도 부족 시 새 업로드 거절, 보관함이 가득 차면 수신/다운로드 후 기록 생략, 기존 자료 유지도 구분했다.
|
||||
- API 126개와 한도 API 검증을 포함한 관리자 9개, 전체 빌드·타입 검사·포맷 검사를 통과했다. 실제 Chromium의 관리자 PC/390px 모바일·이름 대괄호/만료일·수신 KB/MB/GB·보관함·12기기 레이아웃/푸터 회귀 검증을 통과했다. 운영 Docker에서 새 테이블 마이그레이션과 재시작 후 파일별 접속 기록, 관리자 설정, 파일/Unicode 텍스트·계정 세션·8MiB 조각 재개·SQLite integrity·바인드 파일 수동 삭제를 검증했다. 격리 테스트 자원만 사용했으며 NAS 운영 자료·Compose 파일은 변경하거나 재배포하지 않았다.
|
||||
|
||||
## 2026-10-15 네트워크 보호·관리 기록 정리·사용자 지원
|
||||
|
||||
- 국가를 `일본(JP)`처럼 코드와 함께 표시한다. 관리자 탭을 저장 파일/용량 설정/네트워크/사용자/관리 기록으로 나누고 네트워크에 자동 블랙리스트·수동 블랙리스트·화이트리스트를 배치했다. 국가/IP/IPv4·IPv6 CIDR은 모달 검증 후 한 항목씩 표에 추가하며 선택 제거와 명시적 저장을 제공한다. 기존 저장된 수동 차단 규칙과 리비전은 보존하고 새 자동 기능은 꺼짐·화이트리스트는 빈 값으로 시작한다.
|
||||
- 화이트리스트는 비어 있으면 공개, 등록된 국가 또는 IP/대역과 일치하면 허용하며 블랙리스트·자동 차단이 우선한다. 국가 미제공은 허용 IP와 일치하지 않으면 제외한다. IPv6·매핑 IPv4·원본 헤더 신뢰·관리자 자신을 차단하는 규칙 거절과 기존/새 WebSocket 접속의 제외를 확인했다. 국가만 허용해도 컨테이너 내부의 전달 헤더 없는 루프백 health 검사는 유지하고 외부 health 요청은 동일 정책으로 제한한다.
|
||||
- 자동 보호는 원본 공인 IP별 로그인 실패·일반 요청 제한 반복 위반·파일 전송 시도 횟수/선언 용량을 감지한다. 기본 10분 창, 실패 10회·위반 20회·파일 시도 30회 초과/10GB 초과·30분 차단이며 관리자에서 수정한다. 기기 쿠키 교체 및 로그인 보관함/기기 전송이 같은 IP 예산을 공유하고 동일 요청 키 재시도는 중복 계산하지 않음을 확인했다. 정상 조각 API·혼잡·거절 대기는 위반으로 세지 않으며 미확인 프록시를 자동 차단하지 않는다. 자동 목록의 재시작 유지·만료·수동 해제/누적 초기화·해제 뒤 접근·끄기/다시 켜기를 검증했다. 기존 검증 관리자 세션은 자동 차단 복구만 가능하고 수동 규칙은 우회하지 않는다.
|
||||
- 관리 기록은 전체 설정을 반복하지 않고 실제 전/후 값과 목록 추가/제거만 저장한다. 값이 같은 저장은 로그를 추가하지 않는다. 과거 기록은 비교 가능한 이전 정보가 있을 때만 차이를 표시한다. 읽음 상태는 추가하지 않았으며 개별/선택/전체 삭제와 전체 삭제 확인을 구현했다. 로그를 삭제해도 설정·자동 차단·파일에 영향이 없고 일반 계정은 조회/삭제할 수 없음을 확인했다.
|
||||
- 사용자 목록은 이메일/이름/상태·마지막 활동·국가/지역/IP만 관리자에게 제공하며 비밀번호 해시·세션·토큰을 반환하지 않는다. 같은 정보의 1분 내 쓰기 억제, 30일 뒤 위치/IP 제거와 마지막 시간 유지, 과거 지역 추정 없음·일반 사용자 접근 거절을 검증했다. 사용자 더보기에서 테스트 메일로 기존 일회용 재설정 링크를 발급하고 수신자의 완료 전 비밀번호 유지·완료 후 이전 세션 폐기·링크 재사용 거절을 확인했다.
|
||||
- 계정 삭제는 이메일 확인·관리자 보호·일반 계정 거절을 적용하고 개인정보/자격 증명 익명화·세션/토큰 폐기·보관함 접근권 제거·대기 중 계정 전송 취소를 수행한다. 다른 수신자가 참조하는 실제 파일은 삭제하지 않고 다운로드가 유지된다. 비밀번호 검증 중 계정 삭제와 겹쳐도 로그인 성공 세션을 발급하지 않도록 최신 상태를 다시 확인한다. 사용자 기능은 평소 더보기 안에만 표시한다.
|
||||
- API 전체 139개와 마지막 내부 health 예외를 포함한 네트워크 8개·전체 빌드/타입 검사·포맷 검사를 통과했다. 실제 Chromium에서 네트워크 모달/선택 제거·화이트리스트·로그인 실패 자동 차단 목록/해제·국가 코드 표시·변경 기록 선택/전체 삭제·사용자 더보기 재설정/삭제와 PC/390px 모바일을 확인했다. 기존 계정 가입/인증/보관함·일반 텍스트/링크/파일 전송의 브라우저 회귀도 통과했다.
|
||||
- 최종 운영 Docker에서 새 테이블 자동 마이그레이션, 화이트리스트·자동 차단 IP·사용자 접속·변경 필드 기록의 재시작 보존, 기존 계정 쿠키·자료·Unicode 텍스트·8MiB 부분 조각 이어 보내기·SQLite integrity·호스트 바인드 수동 삭제를 확인했다. 실제 Resend 메일과 NAS 운영 데이터·Cloudflare 설정은 사용하지 않고 격리 자원/메일 대역으로 검증했다. 운영 `docker-compose.yaml`은 변경하지 않았다.
|
||||
|
||||
@@ -115,10 +115,13 @@ 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()));`,
|
||||
`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'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
|
||||
),
|
||||
);
|
||||
assert.equal(saved.maxConcurrentUploads, 3);
|
||||
node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');db.prepare('INSERT INTO automatic_blocks (ip,countryCode,reason,createdAt,expiresAt) VALUES (?,?,?,?,?)').run('8.8.8.8','KR','login-failures',Date.now(),Date.now()+1800000);db.close();`,
|
||||
);
|
||||
}
|
||||
const fileState = JSON.parse(
|
||||
node(
|
||||
@@ -151,6 +154,23 @@ try {
|
||||
);
|
||||
assert.equal(settings.maxConcurrentUploads, 3);
|
||||
assert.deepEqual(settings.blockedCountries, ['CN']);
|
||||
assert.deepEqual(settings.allowedIps, ['127.0.0.1']);
|
||||
assert.equal(settings.autoBlockEnabled, true);
|
||||
const adminState = JSON.parse(
|
||||
node(
|
||||
`const headers={Cookie:${JSON.stringify(verifiedAccount.cookie)}};const base='http://127.0.0.1:${port}';const blocks=await(await fetch(base+'/api/admin/automatic-blocks',{headers})).json();const users=await(await fetch(base+'/api/admin/users',{headers})).json();const audit=await(await fetch(base+'/api/admin/audit',{headers})).json();console.log(JSON.stringify({blocks,users,audit}));`,
|
||||
),
|
||||
);
|
||||
assert.equal(adminState.blocks.items[0].ip, '8.8.8.8');
|
||||
assert.ok(
|
||||
adminState.users.users.find((user) => user.id === verifiedAccount.userId)?.lastSeenAt,
|
||||
);
|
||||
assert.ok(
|
||||
adminState.audit.events.some((event) =>
|
||||
event.details.changes?.some((change) => change.field === 'maxConcurrentUploads'),
|
||||
),
|
||||
);
|
||||
|
||||
const contexts = JSON.parse(
|
||||
node(
|
||||
`import Database from 'better-sqlite3'; const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify(db.prepare('SELECT count(*) AS count FROM file_upload_contexts').get()));db.close();`,
|
||||
|
||||
@@ -148,10 +148,10 @@ try {
|
||||
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(), /중국\(CN\) · Beijing/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
|
||||
assert.match(await admin.locator('.admin-file-info').first().innerText(), /^\[.+\]\s+guest-/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국 · Beijing/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국\(CN\) · Beijing/);
|
||||
assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/);
|
||||
assert.match(
|
||||
await admin.locator('.admin-file footer small').innerText(),
|
||||
@@ -162,7 +162,7 @@ try {
|
||||
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.getByRole('button', { name: '용량 설정', exact: true }).click();
|
||||
assert.match(
|
||||
await admin.locator('.admin-limit-details').innerText(),
|
||||
/로그인 보관함[\s\S]*계정당 2\.1GB/,
|
||||
@@ -180,12 +180,55 @@ try {
|
||||
);
|
||||
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
|
||||
await admin.getByLabel('전체 파일 저장 한도 (GB)').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, Math.round(16e9 / 1048576));
|
||||
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
async function addRule(page, list, kind, value) {
|
||||
await page.getByRole('button', { name: list + ' 추가', exact: true }).click();
|
||||
const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true });
|
||||
await modal.getByLabel('등록 대상').selectOption(kind);
|
||||
await modal
|
||||
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR', { exact: true })
|
||||
.fill(value);
|
||||
await modal.getByRole('button', { name: '추가', exact: true }).click();
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
await addRule(admin, '블랙리스트', 'country', 'cn');
|
||||
await addRule(admin, '블랙리스트', 'ip', '9.9.9.0/24');
|
||||
await addRule(admin, '화이트리스트', 'country', 'KR');
|
||||
await addRule(admin, '화이트리스트', 'ip', '8.8.8.0/24');
|
||||
await addRule(admin, '화이트리스트', 'ip', '4.4.4.4');
|
||||
await admin.getByRole('checkbox', { name: '4.4.4.4 허용 규칙 선택', exact: true }).check();
|
||||
await admin
|
||||
.locator('section[aria-labelledby="whitelist-title"]')
|
||||
.getByRole('button', { name: '선택 항목 제거', exact: true })
|
||||
.click();
|
||||
await admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }).click();
|
||||
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3');
|
||||
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click();
|
||||
await admin.getByText('네트워크 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']);
|
||||
assert.deepEqual(runtime.service.admin.state.allowedIps, ['8.8.8.0/24']);
|
||||
assert.equal(runtime.service.admin.state.autoBlockEnabled, true);
|
||||
const attacker = await page(390, '8.8.8.9', 'KR'),
|
||||
attackSession = await session(attacker);
|
||||
await attacker.evaluate(async (csrf) => {
|
||||
for (let i = 0; i < 3; i++)
|
||||
await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ email: 'missing@hop.test', password: 'incorrect' }),
|
||||
});
|
||||
}, attackSession.csrfToken);
|
||||
await admin.getByRole('button', { name: '자동 차단 목록 새로고침', exact: true }).click();
|
||||
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).waitFor();
|
||||
await admin.screenshot({ path: 'tests/e2e/network-desktop.png', fullPage: true });
|
||||
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).check();
|
||||
await admin.getByRole('button', { name: '선택 차단 해제', exact: true }).click();
|
||||
await admin.getByText('선택한 IP의 자동 차단을 해제했어요.', { exact: true }).waitFor();
|
||||
assert.equal(await attacker.evaluate(async () => (await fetch('/api/device/me')).status), 200);
|
||||
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();
|
||||
@@ -200,22 +243,75 @@ try {
|
||||
assert.equal(runtime.service.admin.summary().storedBytes, 0);
|
||||
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
|
||||
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
|
||||
const changedRow = admin
|
||||
.locator('.admin-audit article')
|
||||
.filter({ hasText: '동시에 처리할 업로드 수 4 > 3' });
|
||||
await changedRow.waitFor();
|
||||
await changedRow.getByRole('checkbox').check();
|
||||
await admin.getByRole('button', { name: '선택 기록 삭제', exact: true }).click();
|
||||
await admin.getByText('선택한 관리 기록을 삭제했어요.', { exact: true }).waitFor();
|
||||
assert.equal(await changedRow.count(), 0);
|
||||
await admin.getByRole('button', { name: '사용자', exact: true }).click();
|
||||
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).waitFor();
|
||||
assert.equal(await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).count(), 0);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-users.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click();
|
||||
await admin.getByRole('menuitem', { name: '비밀번호 재설정 메일 보내기', exact: true }).click();
|
||||
await admin
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: '메일 보내기', exact: true })
|
||||
.click();
|
||||
await admin.getByText('비밀번호 재설정 메일을 보냈어요.', { exact: true }).waitFor();
|
||||
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click();
|
||||
await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).click();
|
||||
const deletion = admin.getByRole('alertdialog', { name: '계정을 삭제할까요?', exact: true });
|
||||
assert.equal(
|
||||
await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await deletion.getByLabel('확인을 위해 대상 이메일 입력', { exact: true }).fill('other@hop.test');
|
||||
await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).click();
|
||||
await admin.getByText('계정을 삭제했어요.', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
|
||||
await admin.getByText('사용자 계정 삭제', { exact: true }).waitFor();
|
||||
await admin.getByText('비밀번호 재설정 메일 발송', { exact: true }).waitFor();
|
||||
await admin.getByRole('button', { name: '전체 기록 삭제', exact: true }).click();
|
||||
await admin
|
||||
.getByRole('alertdialog')
|
||||
.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();
|
||||
await mobile.getByRole('button', { name: '용량 설정', exact: true }).click();
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await mobile.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
assert.equal(
|
||||
await mobile
|
||||
.getByLabel('차단할 국가 코드')
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
|
||||
const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
|
||||
assert.equal(
|
||||
await ruleModal
|
||||
.getByLabel('국가 코드', { exact: true })
|
||||
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize) >= 16),
|
||||
true,
|
||||
);
|
||||
await ruleModal.getByLabel('국가 코드', { exact: true }).fill('JP');
|
||||
await mobile.screenshot({ path: 'tests/e2e/network-rule-mobile.png', fullPage: true });
|
||||
await ruleModal.getByRole('button', { name: '규칙 추가 닫기' }).click();
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user