Make admin quotas editable and simplify uploader storage
This commit is contained in:
@@ -159,7 +159,7 @@ export class Accounts {
|
||||
input.count > cfg.maxItems ||
|
||||
(input.kind === 'url' && input.byteSize > cfg.maxUrlBytes) ||
|
||||
(input.kind === 'text' && input.byteSize > cfg.maxTextBytes) ||
|
||||
input.files?.some((f) => f.size > cfg.maxFileBytes)
|
||||
input.files?.some((f) => f.size > this.service.admin.state.perFileBytes)
|
||||
)
|
||||
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
|
||||
if (policy === 'request') {
|
||||
@@ -555,11 +555,11 @@ export class Accounts {
|
||||
.reduce((sum, row) => sum + row.bytes, 0);
|
||||
return (
|
||||
[...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <=
|
||||
this.service.cfg.userQuota
|
||||
this.service.admin.state.accountBytes
|
||||
);
|
||||
}
|
||||
ensureQuota(userId: string, bytes: number) {
|
||||
if (this.used(userId) + bytes > this.service.cfg.userQuota)
|
||||
if (this.used(userId) + bytes > this.service.admin.state.accountBytes)
|
||||
fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.');
|
||||
}
|
||||
favorite(userId: string, id: string, value: boolean) {
|
||||
|
||||
@@ -24,6 +24,18 @@ export function settingsChanges(before, after) {
|
||||
return changes;
|
||||
}
|
||||
function rule(value) {
|
||||
if (value.includes('*')) {
|
||||
// Only whole, trailing IPv4 octets may be wildcards.
|
||||
if (!/^(?:\d{1,3}\.){0,3}\*(?:\.\*){0,3}$/.test(value)) throw Error('Invalid IPv4 wildcard');
|
||||
const octets = value.split('.'),
|
||||
first = octets.indexOf('*');
|
||||
if (
|
||||
octets.length !== 4 ||
|
||||
octets.slice(0, first).some((part) => Number(part) > 255 || String(Number(part)) !== part)
|
||||
)
|
||||
throw Error('Invalid IPv4 wildcard');
|
||||
value = octets.map((part) => (part === '*' ? '0' : part)).join('.') + '/' + first * 8;
|
||||
}
|
||||
const [address, prefix] = value.includes('/')
|
||||
? ipaddr.parseCIDR(value)
|
||||
: [ipaddr.parse(value), undefined];
|
||||
@@ -56,6 +68,11 @@ const fields = {
|
||||
uploadsPaused: z.boolean(),
|
||||
maxConcurrentUploads: z.number().int().min(1).max(16),
|
||||
totalStorageLimitMb: z.number().int().min(1024).max(1048576),
|
||||
accountBytes: z.number().int().min(1).max(1e12),
|
||||
deviceBytes: z.number().int().min(1).max(1e12),
|
||||
devicesTotalBytes: z.number().int().min(1).max(1e12),
|
||||
perFileBytes: z.number().int().min(1).max(1e12),
|
||||
minDiskFreeBytes: z.number().int().min(0).max(1e12),
|
||||
blockedCountries: z
|
||||
.array(
|
||||
z
|
||||
@@ -115,7 +132,7 @@ export function normalizeNetworkRule(input) {
|
||||
const [address, prefix] = rule(input.value);
|
||||
return {
|
||||
...input,
|
||||
value: input.value.includes('/') ? `${address}/${prefix}` : address.toString(),
|
||||
value: /[/*]/.test(input.value) ? `${address}/${prefix}` : address.toString(),
|
||||
};
|
||||
}
|
||||
export const automaticBlockRemoval = z
|
||||
@@ -136,8 +153,15 @@ export class Administration {
|
||||
.from(s.adminSettings)
|
||||
.where(eq(s.adminSettings.name, 'controls'))
|
||||
.get();
|
||||
const capacityDefaults = {
|
||||
accountBytes: service.cfg.userQuota,
|
||||
deviceBytes: service.cfg.guestQuota,
|
||||
devicesTotalBytes: service.cfg.totalGuestQuota,
|
||||
perFileBytes: service.cfg.maxFileBytes,
|
||||
minDiskFreeBytes: service.cfg.minFreeBytes,
|
||||
};
|
||||
this.state = row
|
||||
? stateSchema.parse({ ...networkDefaults, ...JSON.parse(row.value) })
|
||||
? stateSchema.parse({ ...networkDefaults, ...capacityDefaults, ...JSON.parse(row.value) })
|
||||
: {
|
||||
revision: 0,
|
||||
uploadsPaused: false,
|
||||
@@ -146,6 +170,7 @@ export class Administration {
|
||||
blockedCountries: [],
|
||||
blockedIps: [],
|
||||
...networkDefaults,
|
||||
...capacityDefaults,
|
||||
};
|
||||
this.automatic = new AutomaticBlocks(service);
|
||||
this.compile();
|
||||
@@ -266,12 +291,7 @@ export class Administration {
|
||||
...new Set(next.allowedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
|
||||
].sort();
|
||||
next.blockedIps = [
|
||||
...new Set(
|
||||
next.blockedIps.map((value) => {
|
||||
const [address, prefix] = rule(value);
|
||||
return value.includes('/') ? address.toString() + '/' + prefix : address.toString();
|
||||
}),
|
||||
),
|
||||
...new Set(next.blockedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
|
||||
].sort();
|
||||
const context = visitorRegion(req, this.service.cfg),
|
||||
rules = next.blockedIps.map(rule);
|
||||
@@ -347,11 +367,11 @@ export class Administration {
|
||||
return {
|
||||
settings: { ...this.state },
|
||||
limits: {
|
||||
perFileBytes: this.service.cfg.maxFileBytes,
|
||||
accountBytes: this.service.cfg.userQuota,
|
||||
deviceBytes: this.service.cfg.guestQuota,
|
||||
devicesTotalBytes: this.service.cfg.totalGuestQuota,
|
||||
minDiskFreeBytes: this.service.cfg.minFreeBytes,
|
||||
perFileBytes: this.state.perFileBytes,
|
||||
accountBytes: this.state.accountBytes,
|
||||
deviceBytes: this.state.deviceBytes,
|
||||
devicesTotalBytes: this.state.devicesTotalBytes,
|
||||
minDiskFreeBytes: this.state.minDiskFreeBytes,
|
||||
},
|
||||
storedFiles: Number(stored.count),
|
||||
storedBytes: Number(stored.bytes),
|
||||
@@ -419,7 +439,14 @@ export class Administration {
|
||||
? {
|
||||
kind: owner.kind,
|
||||
id: owner.id,
|
||||
name: this.service.storage.layout.ownerInfo.name(owner),
|
||||
email:
|
||||
owner.kind === 'user'
|
||||
? (this.service.db
|
||||
.select({ email: s.users.email })
|
||||
.from(s.users)
|
||||
.where(eq(s.users.id, owner.id))
|
||||
.get()?.email ?? null)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
lastLocation: this.service.storage.layout.ownerInfo.fileLocation(file),
|
||||
@@ -659,7 +686,7 @@ export class Administration {
|
||||
this.service.storage.release(request.id);
|
||||
this.service.notifyTransfer(request.id);
|
||||
}
|
||||
const owner = this.service.storage.layout.ownerInfo.details(`user-${id}/file`);
|
||||
const owner = this.service.storage.layout.ownerInfo.details(`${id}/file`);
|
||||
try {
|
||||
const info = this.service.storage.layout.ownerInfo;
|
||||
if (owner) info.write(owner, info.read(owner).events);
|
||||
|
||||
@@ -719,11 +719,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
service.resumable.status(resumableSubject(req), id(req)),
|
||||
);
|
||||
app.put('/api/uploads/resumable/:id', { bodyLimit: CHUNK_BYTES }, async (req) => {
|
||||
// The resumable handler validates the offset against the accepted file manifest.
|
||||
// A later change to the per-file limit must not interrupt this upload.
|
||||
const offset = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(cfg.maxFileBytes)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.parse(req.headers['upload-offset']);
|
||||
return service.resumable.append(resumableSubject(req), id(req), offset, req.body);
|
||||
});
|
||||
@@ -1147,7 +1149,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
});
|
||||
app.get('/api/account/storage', async (req) => ({
|
||||
usedBytes: service.accounts.used(account(req, true).user.id),
|
||||
limitBytes: cfg.userQuota,
|
||||
limitBytes: service.admin.state.accountBytes,
|
||||
}));
|
||||
const webRoot = cfg.webRoot;
|
||||
if (webRoot && existsSync(webRoot)) {
|
||||
|
||||
@@ -13,15 +13,16 @@ import { readdir } from 'node:fs/promises';
|
||||
import { basename, dirname, extname, resolve } from 'node:path';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { OwnerInfo, OWNER_INFO_NAME } from './ownerInfo.js';
|
||||
import { OwnerInfo, OWNER_INFO_NAME, OWNER_FOLDER } from './ownerInfo.js';
|
||||
|
||||
export function fileExtension(name) {
|
||||
const extension = extname(name).toLowerCase();
|
||||
return /^\.[a-z0-9]{1,16}$/.test(extension) ? extension : '';
|
||||
}
|
||||
export function ownerDirectory(subject) {
|
||||
if (!/^[a-zA-Z0-9-]{1,80}$/.test(subject.id)) throw Error('Invalid storage owner');
|
||||
return `${subject.kind === 'user' ? 'user' : 'guest'}-${subject.id}`;
|
||||
const match = OWNER_FOLDER.exec(subject.id);
|
||||
if (!match || match[1]) throw Error('Invalid storage owner');
|
||||
return subject.id;
|
||||
}
|
||||
export function newStorageKey(name, subject) {
|
||||
return `${ownerDirectory(subject)}/${randomUUID()}${fileExtension(name)}`;
|
||||
@@ -51,7 +52,7 @@ export class FileLayout {
|
||||
const parts = (legacyCompleted ? key.slice('completed/'.length) : key).split('/');
|
||||
if (
|
||||
parts.length > (legacyCompleted ? 1 : 2) ||
|
||||
(parts.length === 2 && !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(parts[0])) ||
|
||||
(parts.length === 2 && !OWNER_FOLDER.test(parts[0])) ||
|
||||
parts.some((name) => !name || basename(name) !== name || ['.', '..'].includes(name))
|
||||
)
|
||||
throw Error('Invalid stored file key');
|
||||
@@ -188,7 +189,7 @@ export class FileLayout {
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (prefix || !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(entry.name)) continue;
|
||||
if (prefix || !OWNER_FOLDER.test(entry.name)) continue;
|
||||
for (const child of await readdir(resolve(root, entry.name), { withFileTypes: true }))
|
||||
if (!child.isDirectory() && child.name !== OWNER_INFO_NAME)
|
||||
yield {
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as s from './db/schema.js';
|
||||
import { clientIp } from './security.js';
|
||||
|
||||
export const OWNER_INFO_NAME = 'owner-info.json';
|
||||
export const OWNER_FOLDER = /^(?:(user|guest)-)?([a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12})$/;
|
||||
export function visitorRegion(req, cfg) {
|
||||
const ip = clientIp(
|
||||
req.raw.socket.remoteAddress,
|
||||
@@ -62,13 +63,20 @@ export class OwnerInfo {
|
||||
this.layout = layout;
|
||||
}
|
||||
details(key) {
|
||||
const match = /^((user|guest)-([a-zA-Z0-9-]{1,80}))\//.exec(key);
|
||||
if (!match) return null;
|
||||
const directory = key.split('/')[0],
|
||||
match = OWNER_FOLDER.exec(directory);
|
||||
if (!key.includes('/') || !match) return null;
|
||||
const id = match[2];
|
||||
const kind =
|
||||
match[1] ||
|
||||
(this.service.db.select({ id: s.users.id }).from(s.users).where(eq(s.users.id, id)).get()
|
||||
? 'user'
|
||||
: 'guest');
|
||||
return {
|
||||
directory: match[1],
|
||||
kind: match[2],
|
||||
id: match[3],
|
||||
path: resolve(this.layout.originals, match[1], OWNER_INFO_NAME),
|
||||
directory,
|
||||
kind,
|
||||
id,
|
||||
path: resolve(this.layout.originals, directory, OWNER_INFO_NAME),
|
||||
};
|
||||
}
|
||||
name(owner) {
|
||||
|
||||
@@ -613,7 +613,7 @@ export class Service {
|
||||
(input.kind === 'url' && input.byteSize > this.cfg.maxUrlBytes) ||
|
||||
(input.kind === 'text' && input.byteSize > this.cfg.maxTextBytes) ||
|
||||
input.count > this.cfg.maxItems ||
|
||||
input.files?.some((f) => f.size > this.cfg.maxFileBytes)
|
||||
input.files?.some((f) => f.size > this.admin.state.perFileBytes)
|
||||
)
|
||||
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
|
||||
const rejected = this.db
|
||||
|
||||
@@ -126,6 +126,8 @@ export class Storage {
|
||||
.where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now())))
|
||||
.get(),
|
||||
);
|
||||
// Honor space already reserved by accepted transfers when limits are reduced.
|
||||
if (!missing.length) return;
|
||||
const total = missing.reduce((sum, file) => sum + file.size, 0);
|
||||
if (g.kind === 'user') {
|
||||
const reserved = db
|
||||
@@ -135,9 +137,15 @@ export class Storage {
|
||||
.get()!.bytes;
|
||||
this.service.accounts.ensureQuota(g.id, reserved + total);
|
||||
}
|
||||
if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota)
|
||||
if (
|
||||
g.kind !== 'user' &&
|
||||
this.used(g.id) + this.reserved(g.id) + total > this.service.admin.state.deviceBytes
|
||||
)
|
||||
fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.');
|
||||
if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota)
|
||||
if (
|
||||
g.kind !== 'user' &&
|
||||
this.used() + this.reserved() + total > this.service.admin.state.devicesTotalBytes
|
||||
)
|
||||
fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.');
|
||||
const allReserved = db
|
||||
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
|
||||
@@ -152,7 +160,10 @@ export class Storage {
|
||||
if (storedBytes + allReserved + total > this.service.admin.state.totalStorageLimitMb * 1048576)
|
||||
fail(507, 'STORAGE_QUOTA', '서버의 전체 파일 저장 한도를 초과했습니다.');
|
||||
const disk = statfsSync(this.originals, { bigint: true });
|
||||
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
|
||||
if (
|
||||
BigInt(allReserved + total + this.service.admin.state.minDiskFreeBytes) >
|
||||
disk.bavail * disk.bsize
|
||||
)
|
||||
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
|
||||
for (const file of missing)
|
||||
db.insert(s.reservations)
|
||||
@@ -245,7 +256,7 @@ export class Storage {
|
||||
let bytes = 0,
|
||||
count = 0;
|
||||
for await (const part of request.parts({
|
||||
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
|
||||
limits: { files: 1, fields: 0, parts: 1, fileSize: existing.size + 1 },
|
||||
})) {
|
||||
if (part.type !== 'file')
|
||||
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
|
||||
@@ -317,7 +328,7 @@ export class Storage {
|
||||
let bytes = 0;
|
||||
const checksum = createHash('sha256');
|
||||
for await (const part of request.parts({
|
||||
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
|
||||
limits: { files: 1, fields: 0, parts: 1, fileSize: file!.size + 1 },
|
||||
})) {
|
||||
if (part.type !== 'file')
|
||||
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.');
|
||||
@@ -327,7 +338,7 @@ export class Storage {
|
||||
const counter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > file!.size || bytes > cfg.maxFileBytes)
|
||||
if (bytes > file!.size)
|
||||
return callback(
|
||||
new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'),
|
||||
);
|
||||
|
||||
366
apps/server/test/admin-capacity.test.js
Normal file
366
apps/server/test/admin-capacity.test.js
Normal file
@@ -0,0 +1,366 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { statfsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import Database from 'better-sqlite3';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
import { Administration } from '../src/administration.js';
|
||||
import { CHUNK_BYTES } from '../src/resumable.js';
|
||||
|
||||
async function signed(f, email) {
|
||||
const client = await f.create(),
|
||||
id = randomUUID();
|
||||
f.service.db
|
||||
.insert(s.users)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
displayName: email,
|
||||
passwordHash: await f.service.auth.password('12345678'),
|
||||
status: 'active',
|
||||
verifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
|
||||
return client;
|
||||
}
|
||||
async function patch(f, admin, values) {
|
||||
const response = await f.call(admin, '/admin/settings', 'PATCH', {
|
||||
revision: f.service.admin.state.revision,
|
||||
...values,
|
||||
});
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
return response.json();
|
||||
}
|
||||
async function request(f, sender, receiver, bytes = 8) {
|
||||
return f.call(
|
||||
sender,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: receiver.deviceId,
|
||||
kind: 'file',
|
||||
count: 1,
|
||||
byteSize: bytes,
|
||||
files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
}
|
||||
async function accept(f, receiver, response) {
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const id = response.json().requestId;
|
||||
const result = await f.call(
|
||||
receiver,
|
||||
`/transfer-requests/${id}/accept`,
|
||||
'POST',
|
||||
{},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(result.statusCode, 200, result.body);
|
||||
return id;
|
||||
}
|
||||
const own = (f, sender, bytes = 8) =>
|
||||
f.call(
|
||||
sender,
|
||||
'/vault',
|
||||
'POST',
|
||||
{ kind: 'file', files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }] },
|
||||
randomUUID(),
|
||||
);
|
||||
async function upload(f, sender, id, bytes) {
|
||||
return f.call(
|
||||
sender,
|
||||
`/uploads?requestId=${id}&fileIndex=0`,
|
||||
'POST',
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
'--quota\r\nContent-Disposition: form-data; name="file"; filename="quota.txt"\r\nContent-Type: text/plain\r\n\r\n',
|
||||
),
|
||||
bytes,
|
||||
Buffer.from('\r\n--quota--\r\n'),
|
||||
]),
|
||||
randomUUID(),
|
||||
{ 'content-type': 'multipart/form-data; boundary=quota' },
|
||||
);
|
||||
}
|
||||
|
||||
test('administrator limits persist, report to clients, reject invalid updates and enforce single-file/account quotas immediately', async () => {
|
||||
const f = await fixture(false, undefined, { maxFileBytes: 4 });
|
||||
try {
|
||||
const admin = await signed(f, 'hello@noname.company'),
|
||||
user = await signed(f, 'quota@hop.test'),
|
||||
a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT');
|
||||
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
|
||||
const updated = await patch(f, admin, {
|
||||
perFileBytes: 16,
|
||||
accountBytes: 12,
|
||||
deviceBytes: 24,
|
||||
devicesTotalBytes: 32,
|
||||
minDiskFreeBytes: 0,
|
||||
});
|
||||
const restored = new Administration(f.service);
|
||||
for (const key of [
|
||||
'perFileBytes',
|
||||
'accountBytes',
|
||||
'deviceBytes',
|
||||
'devicesTotalBytes',
|
||||
'minDiskFreeBytes',
|
||||
])
|
||||
assert.equal(restored.state[key], updated[key]);
|
||||
assert.equal((await f.call(user, '/account/storage')).json().limitBytes, 12);
|
||||
const id = await accept(f, b, await request(f, a, b));
|
||||
// Runtime increase must also override the original multipart plugin's 4-byte limit.
|
||||
assert.equal((await upload(f, a, id, Buffer.from('12345678'))).statusCode, 200);
|
||||
const saved = await own(f, user);
|
||||
assert.equal(saved.statusCode, 200, saved.body);
|
||||
assert.equal(
|
||||
(await own(f, user)).json().code,
|
||||
'ACCOUNT_QUOTA',
|
||||
'pending reservations count toward the account limit',
|
||||
);
|
||||
await patch(f, admin, { accountBytes: 24 });
|
||||
assert.equal((await own(f, user)).statusCode, 200);
|
||||
assert.equal(
|
||||
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
|
||||
200,
|
||||
);
|
||||
const list = (await f.call(admin, '/admin/files')).json().items;
|
||||
const accountFile = list.find((file) => file.uploader?.email === 'quota@hop.test');
|
||||
assert.ok(accountFile);
|
||||
assert.equal(accountFile.path.split('/')[0], accountFile.uploader.id);
|
||||
assert.equal(accountFile.uploader.kind, 'user');
|
||||
assert.equal('name' in accountFile.uploader, false);
|
||||
const guestFile = list.find((file) => file.uploader?.kind === 'guest');
|
||||
assert.equal(guestFile.uploader.email, null);
|
||||
assert.equal(guestFile.path.split('/')[0], guestFile.uploader.id);
|
||||
const before = f.service.admin.state.revision;
|
||||
for (const invalid of [
|
||||
{ perFileBytes: 0 },
|
||||
{ accountBytes: -1 },
|
||||
{ deviceBytes: 1.2 },
|
||||
{ devicesTotalBytes: 1e12 + 1 },
|
||||
{ minDiskFreeBytes: -1 },
|
||||
]) {
|
||||
assert.equal(
|
||||
(await f.call(admin, '/admin/settings', 'PATCH', { revision: before, ...invalid }))
|
||||
.statusCode,
|
||||
400,
|
||||
);
|
||||
assert.equal(f.service.admin.state.revision, before);
|
||||
}
|
||||
const audits = (await f.call(admin, '/admin/audit')).json().events;
|
||||
assert.ok(
|
||||
audits.some((event) =>
|
||||
event.details.changes?.some(
|
||||
(change) => change.field === 'perFileBytes' && change.before === 4 && change.after === 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('administrator device, combined device and minimum disk limits govern new reservations without changing existing files', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const admin = await signed(f, 'hello@noname.company'),
|
||||
a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
await patch(f, admin, { deviceBytes: 4 });
|
||||
let pending = await request(f, a, b);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
b,
|
||||
`/transfer-requests/${pending.json().requestId}/accept`,
|
||||
'POST',
|
||||
{},
|
||||
randomUUID(),
|
||||
)
|
||||
).json().code,
|
||||
'GUEST_QUOTA',
|
||||
);
|
||||
await patch(f, admin, { deviceBytes: 16, devicesTotalBytes: 4 });
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
b,
|
||||
`/transfer-requests/${pending.json().requestId}/accept`,
|
||||
'POST',
|
||||
{},
|
||||
randomUUID(),
|
||||
)
|
||||
).json().code,
|
||||
'TOTAL_QUOTA',
|
||||
);
|
||||
await patch(f, admin, { devicesTotalBytes: 24 });
|
||||
const disk = statfsSync(f.cfg.uploadDir),
|
||||
free = disk.bavail * disk.bsize;
|
||||
if (free + 1 <= 1e12) {
|
||||
await patch(f, admin, { minDiskFreeBytes: free + 1 });
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
b,
|
||||
`/transfer-requests/${pending.json().requestId}/accept`,
|
||||
'POST',
|
||||
{},
|
||||
randomUUID(),
|
||||
)
|
||||
).json().code,
|
||||
'DISK_FULL',
|
||||
);
|
||||
}
|
||||
await patch(f, admin, { minDiskFreeBytes: 0 });
|
||||
const id = await accept(f, b, pending);
|
||||
await patch(f, admin, { deviceBytes: 1, devicesTotalBytes: 1, perFileBytes: 1 });
|
||||
assert.equal(
|
||||
(await upload(f, a, id, Buffer.from('12345678'))).statusCode,
|
||||
200,
|
||||
'accepted reservation survives reduced limits',
|
||||
);
|
||||
const item = (await f.call(b, '/device/items'))
|
||||
.json()
|
||||
.items.find((item) => item.file?.name === 'quota.txt');
|
||||
assert.deepEqual(
|
||||
(await f.call(b, `/access/${item.accessId}/download`)).rawPayload,
|
||||
Buffer.from('12345678'),
|
||||
);
|
||||
assert.equal(f.service.admin.summary().storedFiles, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resumable offsets and completion honor the accepted manifest after reducing the file and account limits', async () => {
|
||||
const f = await fixture(false, undefined, { maxFileBytes: 4 });
|
||||
try {
|
||||
const admin = await signed(f, 'hello@noname.company'),
|
||||
user = await signed(f, 'resume@hop.test');
|
||||
await patch(f, admin, { perFileBytes: CHUNK_BYTES + 7, accountBytes: CHUNK_BYTES + 7 });
|
||||
const response = await own(f, user, CHUNK_BYTES + 7),
|
||||
bytes = Buffer.alloc(CHUNK_BYTES + 7, 120);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const requestId = response.json().requestId,
|
||||
hash = (value) => createHash('sha256').update(value).digest(),
|
||||
fingerprint = hash(
|
||||
Buffer.concat([hash(bytes.subarray(0, CHUNK_BYTES)), hash(bytes.subarray(CHUNK_BYTES))]),
|
||||
).toString('hex');
|
||||
const started = await f.call(user, '/uploads/resumable', 'POST', {
|
||||
requestId,
|
||||
fileIndex: 0,
|
||||
fingerprint,
|
||||
});
|
||||
assert.equal(started.statusCode, 200, started.body);
|
||||
const id = started.json().uploadId;
|
||||
const put = (offset, body) =>
|
||||
f.call(user, `/uploads/resumable/${id}`, 'PUT', body, undefined, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'upload-offset': String(offset),
|
||||
});
|
||||
assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200);
|
||||
await patch(f, admin, { perFileBytes: 1, accountBytes: 1 });
|
||||
assert.equal(
|
||||
(await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
|
||||
200,
|
||||
'offset is checked against manifest, not a stale startup limit',
|
||||
);
|
||||
const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {});
|
||||
assert.equal(completed.statusCode, 200, completed.body);
|
||||
assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length);
|
||||
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('offline transfer reset removes only transfer/guest data and keeps accounts, authentication, settings and user information', async () => {
|
||||
const f = await fixture(),
|
||||
folder = await mkdtemp(resolve(tmpdir(), 'hop-reset-test-'));
|
||||
let database;
|
||||
try {
|
||||
const admin = await signed(f, 'hello@noname.company'),
|
||||
user = await signed(f, 'keep@hop.test');
|
||||
const saved = await own(f, user);
|
||||
assert.equal(
|
||||
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
|
||||
200,
|
||||
);
|
||||
await patch(f, admin, { accountBytes: 3e9 });
|
||||
const path = resolve(folder, 'snapshot.sqlite');
|
||||
await f.sqlite.backup(path);
|
||||
database = new Database(path);
|
||||
const kept = [
|
||||
'users',
|
||||
'account_sessions',
|
||||
'account_tokens',
|
||||
'user_rules',
|
||||
'user_activity',
|
||||
'admin_settings',
|
||||
'admin_audit',
|
||||
'__drizzle_migrations',
|
||||
];
|
||||
const before = kept.map((table) =>
|
||||
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
|
||||
);
|
||||
const script = fileURLToPath(
|
||||
new URL('../../../scripts/reset-transfer-records.mjs', import.meta.url),
|
||||
);
|
||||
const denied = spawnSync(process.execPath, [script], {
|
||||
env: { ...process.env, DATABASE_PATH: path },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(denied.status, 0);
|
||||
assert.equal(database.prepare('SELECT count(*) AS count FROM files').get().count, 1);
|
||||
const cleared = spawnSync(process.execPath, [script, '--confirm-reset-transfer-records'], {
|
||||
env: { ...process.env, DATABASE_PATH: path },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(cleared.status, 0, cleared.stderr);
|
||||
for (const table of [
|
||||
'files',
|
||||
'items',
|
||||
'item_access',
|
||||
'transfer_requests',
|
||||
'transfer_batches',
|
||||
'quota_reservations',
|
||||
'file_upload_contexts',
|
||||
'guest_sessions',
|
||||
'browser_devices',
|
||||
'idempotency_records',
|
||||
])
|
||||
assert.equal(
|
||||
database.prepare(`SELECT count(*) AS count FROM "${table}"`).get().count,
|
||||
0,
|
||||
table,
|
||||
);
|
||||
kept.forEach((table, index) =>
|
||||
assert.equal(
|
||||
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
|
||||
before[index],
|
||||
table,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(database.pragma('foreign_key_check'), []);
|
||||
assert.equal(f.service.admin.summary().storedFiles, 1, 'only isolated DB copy is reset');
|
||||
} finally {
|
||||
database?.close();
|
||||
await f.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -89,7 +89,7 @@ test('signed-in uploads use the account folder; download retains the key, origin
|
||||
b = await f.create(),
|
||||
userId = await login(f, a);
|
||||
const value = await file(f, a, b);
|
||||
assert.match(value.row.storageKey, new RegExp(`^user-${userId}/[a-f0-9-]{36}\\.jpg$`));
|
||||
assert.match(value.row.storageKey, new RegExp(`^${userId}/[a-f0-9-]{36}\\.jpg$`));
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
||||
const expiry = f.service.db
|
||||
.select()
|
||||
@@ -119,7 +119,7 @@ test('anonymous upload uses its guest folder even when a signed-in recipient add
|
||||
b = await f.create();
|
||||
await login(f, b);
|
||||
const value = await file(f, a, b, 'received.mp4');
|
||||
assert.equal(value.row.storageKey.split('/')[0], `guest-${value.row.ownerGuestId}`);
|
||||
assert.equal(value.row.storageKey.split('/')[0], value.row.ownerGuestId);
|
||||
assert.match(value.row.storageKey, /\.mp4$/);
|
||||
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
|
||||
assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 200);
|
||||
@@ -149,8 +149,8 @@ test('the same account on different devices shares one upload folder with unique
|
||||
c.cookie += '; ' + String(loginResponse.headers['set-cookie']).split(';')[0];
|
||||
const one = await file(f, a, b),
|
||||
two = await file(f, c, d);
|
||||
assert.equal(one.row.storageKey.split('/')[0], `user-${id}`);
|
||||
assert.equal(two.row.storageKey.split('/')[0], `user-${id}`);
|
||||
assert.equal(one.row.storageKey.split('/')[0], id);
|
||||
assert.equal(two.row.storageKey.split('/')[0], id);
|
||||
assert.notEqual(one.row.storageKey, two.row.storageKey);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(one.row)), one.bytes);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(two.row)), two.bytes);
|
||||
@@ -176,7 +176,7 @@ test('old flat originals and completed files migrate to their recorded upload ow
|
||||
.run();
|
||||
f.service.storage.layout.prepare();
|
||||
const stored = current(f, value.row);
|
||||
assert.equal(stored.storageKey, `guest-${value.row.ownerGuestId}/${legacy}.png`);
|
||||
assert.equal(stored.storageKey, `${value.row.ownerGuestId}/${legacy}.png`);
|
||||
assert.equal(stored.id, value.row.id);
|
||||
assert.equal(stored.checksum, value.row.checksum);
|
||||
assert.equal(stored.updatedAt, value.row.updatedAt);
|
||||
@@ -218,7 +218,7 @@ test('migration waits for all active readers before moving a legacy file', async
|
||||
await once(second.stream, 'close');
|
||||
assert.deepEqual(Buffer.concat(chunks), value.bytes);
|
||||
f.service.storage.layout.prepare();
|
||||
assert.equal(current(f, value.row).storageKey, `guest-${value.row.ownerGuestId}/${key}.jpg`);
|
||||
assert.equal(current(f, value.row).storageKey, `${value.row.ownerGuestId}/${key}.jpg`);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ export function urlFor(value: string) {
|
||||
}
|
||||
const origin = 'http://hop.test';
|
||||
type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string };
|
||||
export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) {
|
||||
export async function fixture(
|
||||
discovery = false,
|
||||
mailer?: import('../src/auth.js').Mailer,
|
||||
overrides: Partial<import('../src/config.js').Config> = {},
|
||||
) {
|
||||
const folder = await mkdtemp(join(tmpdir(), 'hop-api-'));
|
||||
const cfg = {
|
||||
...loadConfig({
|
||||
@@ -33,6 +37,7 @@ export async function fixture(discovery = false, mailer?: import('../src/auth.js
|
||||
minFreeBytes: 0,
|
||||
trustedProxies: ['127.0.0.1', '::1'],
|
||||
offlineGraceMs: 20,
|
||||
...overrides,
|
||||
};
|
||||
const runtime = await createApp(cfg, { mailer });
|
||||
await runtime.app.listen({ port: 0, host: '127.0.0.1' });
|
||||
|
||||
@@ -485,3 +485,83 @@ test(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('IPv4 trailing wildcards normalize to CIDR for both lists, preserve range boundaries and reject invalid patterns or operator lockout', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const admin = await login(f),
|
||||
guest = await f.create();
|
||||
f.cfg.clientIpSource = 'cloudflare';
|
||||
for (const [value, expected] of [
|
||||
['203.0.113.*', '203.0.113.0/24'],
|
||||
['203.0.*.*', '203.0.0.0/16'],
|
||||
['203.*.*.*', '203.0.0.0/8'],
|
||||
['*.*.*.*', '0.0.0.0/0'],
|
||||
]) {
|
||||
const response = await f.call(
|
||||
admin,
|
||||
'/admin/network-rules/validate',
|
||||
'POST',
|
||||
{ kind: 'ip', value },
|
||||
undefined,
|
||||
actorHeaders,
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
assert.equal(response.json().value, expected);
|
||||
}
|
||||
for (const value of [
|
||||
'203.*.113.*',
|
||||
'203.0.113.1*',
|
||||
'203.0.113.* /24',
|
||||
'256.0.113.*',
|
||||
'203.0.*',
|
||||
'203.00.113.*',
|
||||
'2001:db8::*',
|
||||
])
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
admin,
|
||||
'/admin/network-rules/validate',
|
||||
'POST',
|
||||
{ kind: 'ip', value },
|
||||
undefined,
|
||||
actorHeaders,
|
||||
)
|
||||
).statusCode,
|
||||
400,
|
||||
value,
|
||||
);
|
||||
await patch(f, admin, {
|
||||
blockedIps: ['1.1.1.*', '1.1.1.0/24'],
|
||||
allowedIps: ['8.8.4.*', '1.1.*.*'],
|
||||
});
|
||||
assert.deepEqual(f.service.admin.state.blockedIps, ['1.1.1.0/24']);
|
||||
assert.deepEqual(new Administration(f.service).state.allowedIps, ['1.1.0.0/16', '8.8.4.0/24']);
|
||||
for (const [ip, status] of [
|
||||
['1.1.1.0', 403],
|
||||
['1.1.1.255', 403],
|
||||
['1.1.2.0', 200],
|
||||
['1.2.0.0', 403],
|
||||
['8.8.4.255', 200],
|
||||
['8.8.5.0', 403],
|
||||
])
|
||||
assert.equal(
|
||||
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers(ip))).statusCode,
|
||||
status,
|
||||
ip,
|
||||
);
|
||||
const lockout = await f.call(
|
||||
admin,
|
||||
'/admin/settings',
|
||||
'PATCH',
|
||||
{ revision: f.service.admin.state.revision, blockedIps: ['8.8.4.*'] },
|
||||
undefined,
|
||||
actorHeaders,
|
||||
);
|
||||
assert.equal(lockout.json().code, 'ADMIN_SELF_BLOCK');
|
||||
assert.deepEqual(f.service.admin.state.blockedIps, ['1.1.1.0/24']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
|
||||
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.totalGuestQuota = 4;
|
||||
f.service.admin.state.devicesTotalBytes = 4;
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
@@ -162,7 +162,7 @@ test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지
|
||||
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.guestQuota = 3;
|
||||
f.service.admin.state.deviceBytes = 3;
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
@@ -173,7 +173,7 @@ test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
413,
|
||||
);
|
||||
f.cfg.guestQuota = 4;
|
||||
f.service.admin.state.deviceBytes = 4;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
|
||||
@@ -395,7 +395,7 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
|
||||
try {
|
||||
const a = await register(f, 'alice@example.com'),
|
||||
b = await register(f, 'bob@example.com');
|
||||
f.cfg.totalGuestQuota = 1;
|
||||
f.service.admin.state.devicesTotalBytes = 1;
|
||||
const bytes = Buffer.from('account file');
|
||||
const response = await legacyRequest(f, a, {
|
||||
email: 'bob@example.com',
|
||||
@@ -407,12 +407,12 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
|
||||
assert.equal(response.statusCode, 200);
|
||||
const id = response.json().requestId;
|
||||
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409);
|
||||
f.cfg.userQuota = bytes.length - 1;
|
||||
f.service.admin.state.accountBytes = bytes.length - 1;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
413,
|
||||
);
|
||||
f.cfg.userQuota = bytes.length;
|
||||
f.service.admin.state.accountBytes = bytes.length;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
|
||||
@@ -49,7 +49,7 @@ test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
f.cfg.userQuota = 7;
|
||||
f.service.admin.state.accountBytes = 7;
|
||||
const sender = await f.create(),
|
||||
receiver = await f.create(),
|
||||
outsider = await f.create();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { api, showError } from './client';
|
||||
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
|
||||
import NetworkPanel from './NetworkPanel.vue';
|
||||
import CapacityPanel from './CapacityPanel.vue';
|
||||
import AdminAudit from './AdminAudit.vue';
|
||||
import AdminUsers from './AdminUsers.vue';
|
||||
const emit = defineEmits(['devices']);
|
||||
@@ -27,9 +28,7 @@ const overview = ref(null),
|
||||
notice = ref(''),
|
||||
confirmation = ref(null),
|
||||
dialog = ref(null);
|
||||
const maxConcurrent = ref(4),
|
||||
capacity = ref(20),
|
||||
pendingBlockedIp = ref(null);
|
||||
const pendingBlockedIp = ref(null);
|
||||
function region(location) {
|
||||
if (!location) return '확인 불가';
|
||||
return (
|
||||
@@ -41,16 +40,11 @@ function region(location) {
|
||||
.join(' · ') || '확인 불가'
|
||||
);
|
||||
}
|
||||
function form(settings) {
|
||||
maxConcurrent.value = settings.maxConcurrentUploads;
|
||||
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
|
||||
}
|
||||
async function refresh(resetForm = true) {
|
||||
async function refresh() {
|
||||
const [summary, list] = await Promise.all([api('/admin/summary'), api('/admin/files')]);
|
||||
overview.value = summary;
|
||||
files.value = list.items;
|
||||
cursor.value = list.nextCursor;
|
||||
if (resetForm) form(summary.settings);
|
||||
}
|
||||
async function act(fn) {
|
||||
if (busy.value) return;
|
||||
@@ -80,7 +74,7 @@ async function pauseUploads() {
|
||||
notice.value = settings.uploadsPaused
|
||||
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
|
||||
: '신규 업로드를 다시 허용했어요.';
|
||||
await refresh(false);
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
function addBlockedIp(ip) {
|
||||
@@ -89,24 +83,7 @@ function addBlockedIp(ip) {
|
||||
}
|
||||
async function networkSaved(settings) {
|
||||
overview.value.settings = settings;
|
||||
await refresh(false);
|
||||
}
|
||||
async function save() {
|
||||
await act(async () => {
|
||||
const settings = await api('/admin/settings', 'PATCH', {
|
||||
revision: overview.value.settings.revision,
|
||||
maxConcurrentUploads: Number(maxConcurrent.value),
|
||||
totalStorageLimitMb:
|
||||
Number(capacity.value) ===
|
||||
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
|
||||
? overview.value.settings.totalStorageLimitMb
|
||||
: Math.round((Number(capacity.value) * 1e9) / 1048576),
|
||||
});
|
||||
overview.value.settings = settings;
|
||||
form(settings);
|
||||
notice.value = '관리 설정을 저장했어요.';
|
||||
await refresh(false);
|
||||
});
|
||||
await refresh();
|
||||
}
|
||||
async function more() {
|
||||
await act(async () => {
|
||||
@@ -150,7 +127,7 @@ async function remove() {
|
||||
await api('/admin/files/' + file.id, 'DELETE');
|
||||
closeConfirmation();
|
||||
notice.value = '원본과 연결된 항목을 삭제했어요.';
|
||||
await refresh(false);
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
onMounted(async () => {
|
||||
@@ -254,13 +231,13 @@ onMounted(async () => {
|
||||
<strong>{{ file.name }}</strong
|
||||
><span>{{ size(file.size) }}</span>
|
||||
</div>
|
||||
<div class="admin-file-info">
|
||||
<span
|
||||
>[{{
|
||||
file.uploader?.name ||
|
||||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
|
||||
}}]</span
|
||||
><code>{{ file.path.split('/')[0] }}</code>
|
||||
<div class="admin-file-info admin-file-owner">
|
||||
<code>{{
|
||||
file.uploader?.id || file.path.split('/')[0].replace(/^(user|guest)-/, '')
|
||||
}}</code>
|
||||
<span>{{
|
||||
file.uploader?.kind === 'user' ? file.uploader.email || '삭제된 계정' : '비로그인'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="admin-file-info">
|
||||
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
|
||||
@@ -275,71 +252,20 @@ onMounted(async () => {
|
||||
</article>
|
||||
<button v-if="cursor" class="button small" :disabled="busy" @click="more">더 보기</button>
|
||||
</section>
|
||||
<form v-else-if="view === 'rules'" class="admin-settings" @submit.prevent="save">
|
||||
<div class="admin-settings-grid">
|
||||
<label
|
||||
>동시에 처리할 업로드 수<input
|
||||
v-model="maxConcurrent"
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
required
|
||||
:disabled="busy" /></label
|
||||
><label
|
||||
>전체 파일 저장 한도 (GB)<input
|
||||
v-model="capacity"
|
||||
type="number"
|
||||
min="1.1"
|
||||
max="1099.5"
|
||||
step="any"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
</div>
|
||||
<p class="admin-muted">
|
||||
전체 한도는 모든 사용자가 올린 원본 파일과 업로드 예약을 합친 용량입니다. 개인마다 이
|
||||
용량을 배정하는 것은 아닙니다.
|
||||
</p>
|
||||
<dl class="admin-limit-details">
|
||||
<div>
|
||||
<dt>로그인 보관함</dt>
|
||||
<dd>계정당 {{ size(overview.limits.accountBytes) }} · 임시 저장과 받은 파일 합산</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기기 전송 업로드</dt>
|
||||
<dd>
|
||||
기기 세션당 {{ size(overview.limits.deviceBytes) }} · 로그인 여부와 관계없이 적용
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기기 전송 업로드 전체</dt>
|
||||
<dd>모든 기기 세션 합계 {{ size(overview.limits.devicesTotalBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>파일 한 개</dt>
|
||||
<dd>최대 {{ size(overview.limits.perFileBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>NAS 최소 여유 공간</dt>
|
||||
<dd>업로드 예약을 반영해 {{ size(overview.limits.minDiskFreeBytes) }} 이상 확보</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="admin-muted">
|
||||
전체·기기 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이
|
||||
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
|
||||
파일은 삭제하지 않습니다.
|
||||
</p>
|
||||
<button class="button primary" :disabled="busy">설정 저장</button>
|
||||
</form>
|
||||
<CapacityPanel
|
||||
v-else-if="view === 'rules'"
|
||||
:settings="overview.settings"
|
||||
@saved="networkSaved"
|
||||
/>
|
||||
<NetworkPanel
|
||||
v-else-if="view === 'network'"
|
||||
:settings="overview.settings"
|
||||
:suggested-ip="pendingBlockedIp"
|
||||
@consumed="pendingBlockedIp = null"
|
||||
@saved="networkSaved"
|
||||
@activity="refresh(false)"
|
||||
@activity="refresh()"
|
||||
/>
|
||||
<AdminUsers v-else-if="view === 'users'" @changed="refresh(false)" />
|
||||
<AdminUsers v-else-if="view === 'users'" @changed="refresh()" />
|
||||
<AdminAudit v-else-if="view === 'audit'" />
|
||||
</template>
|
||||
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
|
||||
|
||||
151
apps/web/src/CapacityPanel.vue
Normal file
151
apps/web/src/CapacityPanel.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { Pencil } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatSize as size } from './displayFormat';
|
||||
const props = defineProps({ settings: { type: Object, required: true } });
|
||||
const emit = defineEmits(['saved']);
|
||||
const editing = ref(false),
|
||||
busy = ref(false),
|
||||
notice = ref(''),
|
||||
draft = reactive({});
|
||||
const limits = [
|
||||
{
|
||||
key: 'totalStorageLimitMb',
|
||||
label: '전체 파일 저장 한도',
|
||||
hint: '모든 사용자의 원본 파일과 업로드 예약 합계',
|
||||
max: 1099.511,
|
||||
},
|
||||
{
|
||||
key: 'accountBytes',
|
||||
label: '로그인 보관함 계정당 한도',
|
||||
hint: '각 계정에 공통 적용 · 임시 저장과 받은 파일 합산',
|
||||
},
|
||||
{
|
||||
key: 'deviceBytes',
|
||||
label: '기기 세션당 업로드 한도',
|
||||
hint: '로그인 여부와 관계없이 기기 전송에 적용',
|
||||
},
|
||||
{
|
||||
key: 'devicesTotalBytes',
|
||||
label: '기기 전송 업로드 전체 한도',
|
||||
hint: '모든 기기 세션이 올린 파일과 예약 합계',
|
||||
},
|
||||
{ key: 'perFileBytes', label: '파일 한 개 한도', hint: '새로 요청하는 파일 한 개의 최대 크기' },
|
||||
{
|
||||
key: 'minDiskFreeBytes',
|
||||
label: 'NAS 최소 여유 공간',
|
||||
hint: '업로드 예약을 반영한 뒤에도 남겨둘 공간',
|
||||
},
|
||||
];
|
||||
const bytes = (settings, key) =>
|
||||
key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key];
|
||||
const gb = (settings, key) => Number((bytes(settings, key) / 1e9).toFixed(3));
|
||||
let original;
|
||||
function edit() {
|
||||
original = { ...props.settings };
|
||||
draft.maxConcurrentUploads = original.maxConcurrentUploads;
|
||||
for (const { key } of limits) draft[key] = gb(original, key);
|
||||
notice.value = '';
|
||||
editing.value = true;
|
||||
}
|
||||
function cancel() {
|
||||
editing.value = false;
|
||||
notice.value = '변경을 취소했어요.';
|
||||
}
|
||||
async function save() {
|
||||
if (!editing.value || busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
const changes = {
|
||||
revision: original.revision,
|
||||
maxConcurrentUploads: Number(draft.maxConcurrentUploads),
|
||||
};
|
||||
for (const { key } of limits) {
|
||||
const value = Number(draft[key]);
|
||||
changes[key] =
|
||||
value === gb(original, key)
|
||||
? original[key]
|
||||
: Math.round((value * 1e9) / (key === 'totalStorageLimitMb' ? 1048576 : 1));
|
||||
}
|
||||
const settings = await api('/admin/settings', 'PATCH', changes);
|
||||
editing.value = false;
|
||||
emit('saved', settings);
|
||||
notice.value = '관리 설정을 저장했어요.';
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-settings" @submit.prevent="save">
|
||||
<header class="admin-edit-heading">
|
||||
<h2>업로드·보관 한도</h2>
|
||||
<button v-if="!editing" type="button" class="button small" :disabled="busy" @click="edit">
|
||||
<Pencil :size="15" />수정
|
||||
</button>
|
||||
<span v-else class="admin-muted">수정 중</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는
|
||||
공간입니다.
|
||||
</p>
|
||||
<div v-if="editing" class="admin-settings-grid">
|
||||
<label
|
||||
>동시에 처리할 업로드 수<input
|
||||
v-model.number="draft.maxConcurrentUploads"
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
<label v-for="limit in limits" :key="limit.key"
|
||||
>{{ limit.label }} (GB)<input
|
||||
v-model.number="draft[limit.key]"
|
||||
:aria-label="limit.label + ' (GB)'"
|
||||
type="number"
|
||||
:min="
|
||||
limit.key === 'minDiskFreeBytes'
|
||||
? 0
|
||||
: limit.key === 'totalStorageLimitMb'
|
||||
? 1.074
|
||||
: 0.001
|
||||
"
|
||||
:max="limit.max || 1000"
|
||||
step="any"
|
||||
required
|
||||
:disabled="busy"
|
||||
/><small>{{ limit.hint }}</small></label
|
||||
>
|
||||
</div>
|
||||
<dl v-else class="admin-limit-details admin-limit-values">
|
||||
<div>
|
||||
<dt>동시에 처리할 업로드 수</dt>
|
||||
<dd>
|
||||
<strong>{{ settings.maxConcurrentUploads }}개</strong>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-for="limit in limits" :key="limit.key">
|
||||
<dt>{{ limit.label }}</dt>
|
||||
<dd>
|
||||
<strong>{{ size(bytes(settings, limit.key)) }}</strong
|
||||
><small>{{ limit.hint }}</small>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="admin-muted">
|
||||
전체·개인 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이 가득
|
||||
차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존 파일은
|
||||
삭제하지 않으며 이미 예약한 업로드는 유지합니다.
|
||||
</p>
|
||||
<div v-if="editing" class="admin-edit-actions">
|
||||
<button class="button primary" :disabled="busy">설정 저장</button
|
||||
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
|
||||
</div>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,13 +1,26 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { Plus, Trash2, X, RefreshCw } from '@lucide/vue';
|
||||
import { Plus, Trash2, X, RefreshCw, Pencil } 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('');
|
||||
notice = ref(''),
|
||||
editing = ref(false);
|
||||
let editRevision;
|
||||
function edit() {
|
||||
fill(props.settings);
|
||||
editRevision = props.settings.revision;
|
||||
editing.value = true;
|
||||
notice.value = '';
|
||||
}
|
||||
function cancel() {
|
||||
fill(props.settings);
|
||||
editing.value = false;
|
||||
notice.value = '변경을 취소했어요.';
|
||||
}
|
||||
const selectedBlack = ref([]),
|
||||
selectedWhite = ref([]),
|
||||
selectedAuto = ref([]);
|
||||
@@ -48,7 +61,9 @@ function fill(settings) {
|
||||
}
|
||||
watch(
|
||||
() => props.settings.revision,
|
||||
() => fill(props.settings),
|
||||
() => {
|
||||
if (!editing.value) fill(props.settings);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const uploadGb = computed({
|
||||
@@ -95,18 +110,21 @@ async function loadAutomatic(more = false) {
|
||||
}
|
||||
loadAutomatic().catch(showError);
|
||||
async function save() {
|
||||
if (!editing.value) return;
|
||||
await act(async () => {
|
||||
const settings = await api('/admin/settings', 'PATCH', {
|
||||
revision: props.settings.revision,
|
||||
revision: editRevision,
|
||||
...draft,
|
||||
});
|
||||
fill(settings);
|
||||
editing.value = false;
|
||||
emit('saved', settings);
|
||||
notice.value = '네트워크 설정을 저장했어요.';
|
||||
});
|
||||
}
|
||||
let trigger;
|
||||
async function open(list) {
|
||||
if (!editing.value) return;
|
||||
trigger = document.activeElement;
|
||||
modal.value = list;
|
||||
kind.value = 'country';
|
||||
@@ -118,6 +136,7 @@ watch(
|
||||
() => props.suggestedIp,
|
||||
async (ip) => {
|
||||
if (!ip) return;
|
||||
if (!editing.value) edit();
|
||||
await open('black');
|
||||
kind.value = 'ip';
|
||||
value.value = ip;
|
||||
@@ -130,6 +149,7 @@ function close() {
|
||||
trigger?.focus();
|
||||
}
|
||||
async function add() {
|
||||
if (!editing.value) return;
|
||||
await act(async () => {
|
||||
const entry = await api('/admin/network-rules/validate', 'POST', {
|
||||
kind: kind.value,
|
||||
@@ -149,6 +169,7 @@ async function add() {
|
||||
});
|
||||
}
|
||||
function remove(list) {
|
||||
if (!editing.value) return;
|
||||
const selected = list === 'black' ? selectedBlack : selectedWhite;
|
||||
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
|
||||
ips = list === 'black' ? 'blockedIps' : 'allowedIps';
|
||||
@@ -197,9 +218,15 @@ function modalKey(event) {
|
||||
|
||||
<template>
|
||||
<section class="network-panel">
|
||||
<header class="admin-edit-heading">
|
||||
<h2>접속 보호 설정</h2>
|
||||
<button v-if="!editing" type="button" class="button small" :disabled="busy" @click="edit">
|
||||
<Pencil :size="15" />수정
|
||||
</button>
|
||||
<span v-else class="admin-muted">수정 중</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
목록과 감지 기준을 편집한 뒤 네트워크 설정을 저장하세요. 블랙리스트와 자동 차단이 우선
|
||||
적용됩니다.
|
||||
수정을 누르면 목록과 감지 기준을 변경할 수 있습니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
|
||||
</p>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
<form @submit.prevent="save">
|
||||
@@ -208,6 +235,7 @@ function modalKey(event) {
|
||||
<h2 id="automatic-title">자동 블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
v-if="editing"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.autoBlockEnabled"
|
||||
@@ -217,12 +245,15 @@ function modalKey(event) {
|
||||
>
|
||||
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
<span v-else class="admin-muted">{{
|
||||
draft.autoBlockEnabled ? '사용 중' : '사용 안 함'
|
||||
}}</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
|
||||
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
|
||||
</p>
|
||||
<div class="network-auto-fields">
|
||||
<div v-if="editing" class="network-auto-fields">
|
||||
<label
|
||||
>감지 기간 (분)<input
|
||||
v-model.number="draft.autoWindowMinutes"
|
||||
@@ -279,6 +310,32 @@ function modalKey(event) {
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
</div>
|
||||
<dl v-else class="admin-limit-details network-auto-summary">
|
||||
<div>
|
||||
<dt>감지 기간</dt>
|
||||
<dd>{{ draft.autoWindowMinutes }}분</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>로그인 실패</dt>
|
||||
<dd>{{ draft.autoLoginFailures }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청 제한 위반</dt>
|
||||
<dd>{{ draft.autoRateViolations }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>파일 전송 시도</dt>
|
||||
<dd>{{ draft.autoUploadRequests }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청한 파일 용량</dt>
|
||||
<dd>{{ uploadGb }}GB</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>자동 차단 시간</dt>
|
||||
<dd>{{ draft.autoBlockMinutes }}분</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="admin-muted">
|
||||
감지 기간 안에 로그인 실패·제한 위반이 설정 횟수에 도달하거나, 파일 전송 횟수·요청 용량을
|
||||
초과하면 해당 IP를 차단합니다. 수락 전 파일 전송 시도도 계산하며 같은 요청의 재시도는 중복
|
||||
@@ -359,6 +416,7 @@ function modalKey(event) {
|
||||
<h2 id="blacklist-title">블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
v-if="editing"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.blacklistEnabled"
|
||||
@@ -368,11 +426,14 @@ function modalKey(event) {
|
||||
>
|
||||
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
<span v-else class="admin-muted">{{
|
||||
draft.blacklistEnabled ? '사용 중' : '사용 안 함'
|
||||
}}</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
|
||||
</p>
|
||||
<div class="network-actions">
|
||||
<div v-if="editing" class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('black')">
|
||||
<Plus :size="15" />블랙리스트 추가</button
|
||||
><button
|
||||
@@ -388,14 +449,14 @@ function modalKey(event) {
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>선택</th>
|
||||
<th v-if="editing">선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in black" :key="entry.id">
|
||||
<td>
|
||||
<td v-if="editing">
|
||||
<input
|
||||
v-model="selectedBlack"
|
||||
type="checkbox"
|
||||
@@ -408,7 +469,9 @@ function modalKey(event) {
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!black.length">
|
||||
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
|
||||
<td :colspan="editing ? 3 : 2" class="network-empty">
|
||||
등록된 차단 규칙이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -424,7 +487,7 @@ function modalKey(event) {
|
||||
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
|
||||
차단됩니다.
|
||||
</p>
|
||||
<div class="network-actions">
|
||||
<div v-if="editing" class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('white')">
|
||||
<Plus :size="15" />화이트리스트 추가</button
|
||||
><button
|
||||
@@ -440,14 +503,14 @@ function modalKey(event) {
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>선택</th>
|
||||
<th v-if="editing">선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in white" :key="entry.id">
|
||||
<td>
|
||||
<td v-if="editing">
|
||||
<input
|
||||
v-model="selectedWhite"
|
||||
type="checkbox"
|
||||
@@ -460,7 +523,7 @@ function modalKey(event) {
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!white.length">
|
||||
<td colspan="3" class="network-empty">
|
||||
<td :colspan="editing ? 3 : 2" class="network-empty">
|
||||
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -472,7 +535,10 @@ function modalKey(event) {
|
||||
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
|
||||
</p>
|
||||
</section>
|
||||
<button class="button primary" :disabled="busy">네트워크 설정 저장</button>
|
||||
<div v-if="editing" class="admin-edit-actions">
|
||||
<button class="button primary" :disabled="busy">네트워크 설정 저장</button
|
||||
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
<Teleport to="body"
|
||||
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
|
||||
@@ -499,12 +565,12 @@ function modalKey(event) {
|
||||
<option value="ip">IP 주소 / IP 대역</option>
|
||||
</select></label
|
||||
><label
|
||||
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR'
|
||||
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역'
|
||||
}}<input
|
||||
v-model="value"
|
||||
:maxlength="kind === 'country' ? 2 : 100"
|
||||
:placeholder="
|
||||
kind === 'country' ? '예: JP' : '예: 203.0.113.10 또는 198.51.100.0/24'
|
||||
kind === 'country' ? '예: JP' : '예: 203.0.113.* 또는 198.51.100.0/24'
|
||||
"
|
||||
required
|
||||
:disabled="busy"
|
||||
@@ -514,7 +580,9 @@ function modalKey(event) {
|
||||
{{ country(value.toUpperCase()) }}
|
||||
</p>
|
||||
<p v-else class="admin-muted">
|
||||
국가, IPv4·IPv6 주소 또는 CIDR 대역을 한 항목씩 추가합니다.
|
||||
국가, IP 주소, CIDR 또는 IPv4 와일드카드를 한 항목씩 추가합니다. 203.0.113.*는
|
||||
203.0.113.0/24로 저장됩니다. *는 뒤쪽부터 전체 자리 단위로 사용하세요. IPv6 대역은
|
||||
CIDR로 입력하세요.
|
||||
</p>
|
||||
<button class="button primary" :disabled="busy || !value.trim()">추가</button>
|
||||
</form>
|
||||
|
||||
@@ -48,6 +48,11 @@ export function auditChangeLines(change) {
|
||||
uploadsPaused: '신규 업로드',
|
||||
maxConcurrentUploads: '동시에 처리할 업로드 수',
|
||||
totalStorageLimitMb: '전체 파일 저장 한도',
|
||||
accountBytes: '로그인 보관함 계정당 한도',
|
||||
deviceBytes: '기기 세션당 업로드 한도',
|
||||
devicesTotalBytes: '기기 전송 업로드 전체 한도',
|
||||
perFileBytes: '파일 한 개 한도',
|
||||
minDiskFreeBytes: 'NAS 최소 여유 공간',
|
||||
blacklistEnabled: '블랙리스트 사용',
|
||||
autoBlockEnabled: '자동 블랙리스트 사용',
|
||||
autoWindowMinutes: '자동 감지 기간',
|
||||
@@ -68,7 +73,7 @@ export function auditChangeLines(change) {
|
||||
: '꺼짐'
|
||||
: change.field === 'totalStorageLimitMb'
|
||||
? formatSize(item * 1048576)
|
||||
: change.field === 'autoUploadBytes'
|
||||
: change.field.endsWith('Bytes')
|
||||
? formatSize(item)
|
||||
: change.field.endsWith('Minutes')
|
||||
? `${item}분`
|
||||
|
||||
@@ -4444,3 +4444,43 @@ a.button {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-edit-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-edit-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
.admin-edit-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-limit-values {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
.admin-limit-values dd strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: 20px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.admin-limit-values dd small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.network-auto-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.admin-limit-values,
|
||||
.network-auto-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user