Add network protection and administrator account support
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user