Add network protection and administrator account support

This commit is contained in:
2026-10-15 12:05:52 +09:00
parent 471e2653d8
commit 4d527f74e6
20 changed files with 2913 additions and 106 deletions

View 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
);

View File

@@ -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
}
]
}

View File

@@ -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))

View File

@@ -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!,

View File

@@ -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;
}

View 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 };
}
}

View File

@@ -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',
{

View 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();
}
},
);

View 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
View 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>

View File

@@ -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&#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
View 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>

View 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>

View File

@@ -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)}`,
];
}

View File

@@ -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;
}