Make admin quotas editable and simplify uploader storage
This commit is contained in:
@@ -55,7 +55,7 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
|
||||
|
||||
## 파일 저장 위치
|
||||
|
||||
NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **업로드한 사람** 기준으로 구분합니다. 로그인 중 올린 새 파일은 `user-계정ID/`, 비로그인 파일은 `guest-기기세션ID/`에 저장합니다. 파일명은 `무작위 식별자.jpg`처럼 안전한 원래 확장자를 붙이고 다운로드 시에는 원래 파일명을 제공합니다. 받은 사람의 보관함은 같은 원본을 참조하며 중복 저장하지 않습니다. 기본 보관 기간은 항목별 7일이고 마지막 유효 참조가 끝나면 약 1분 간격 정리에서 파일을 지웁니다. 각 업로드 폴더의 `owner-info.json`에는 업로드 요청 시 확인한 국가·지역·도시·접속 IP를 관리자용으로 기록합니다. 좌표·파일 본문·인증 비밀값은 기록하지 않고, 기본 7일 내 최근 20개 기록만 유지합니다. 지역 정보가 제공되지 않으면 확인 불가로 남깁니다.
|
||||
NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **업로드한 사람** 기준으로 구분합니다. 로그인 중 올린 새 파일은 `계정ID/`, 비로그인 파일은 `기기세션ID/`에 저장합니다. 파일명은 `무작위 식별자.jpg`처럼 안전한 원래 확장자를 붙이고 다운로드 시에는 원래 파일명을 제공합니다. 받은 사람의 보관함은 같은 원본을 참조하며 중복 저장하지 않습니다. 기본 보관 기간은 항목별 7일이고 마지막 유효 참조가 끝나면 약 1분 간격 정리에서 파일을 지웁니다. 각 업로드 폴더의 `owner-info.json`에는 업로드 요청 시 확인한 국가·지역·도시·접속 IP를 관리자용으로 기록합니다. 좌표·파일 본문·인증 비밀값은 기록하지 않고, 기본 7일 내 최근 20개 기록만 유지합니다. 지역 정보가 제공되지 않으면 확인 불가로 남깁니다.
|
||||
|
||||
기존 평면 `originals` 및 `completed` 파일은 앱 시작 시 DB에 기록된 업로드 소유자의 폴더로 이동하며 만료일·ID·접근권은 유지합니다. 새 `completed` 폴더는 만들지 않습니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 기기 세션 폴더를 사용하고 계정을 추정하지 않습니다. 앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 수동 삭제는 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 기존 계정·DB·자료를 초기화하지 않습니다.
|
||||
|
||||
@@ -146,3 +146,5 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
|
||||
사용자 탭은 이메일·계정 상태·최근 접속 시각·국가/지역/IP를 관리자에게만 보여 줍니다. 로그인·인증·로그인 상태 API 이용부터 기록하며, 같은 접속 정보는 약 1분에 한 번만 갱신합니다. 마지막 접속 시각은 유지하고 위치/IP는 기본 30일 이후 제거합니다. 과거 지역은 추정하지 않습니다. **더보기**에서 재설정 메일 발송과 계정 삭제를 사용합니다. 재설정은 기존 일회용 메일 절차를 사용하며 사용자가 완료해야 비밀번호와 로그인 상태가 바뀝니다. 삭제는 이메일 확인 후 로그인·토큰·보관함 접근권을 폐기하고 계정을 익명화합니다. 다른 수신자의 파일은 유지하며 관리자 자신은 삭제할 수 없습니다.
|
||||
|
||||
관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다.
|
||||
|
||||
관리자 용량·네트워크 설정은 기본적으로 값만 표시하며 **수정 → 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 관리자 화면에서 설정하며 저장한 값은 DB에 보존됩니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
32
deploy.md
32
deploy.md
@@ -218,18 +218,18 @@ https://hop.noname.company
|
||||
|
||||
| 데이터 | NAS 저장 위치 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------- |
|
||||
| 로그인 중 업로드한 새 파일 | `hop/uploads/originals/user-계정ID/` | `/data/uploads/originals/user-계정ID/` |
|
||||
| 비로그인 업로드 파일 | `hop/uploads/originals/guest-기기세션ID/` | `/data/uploads/originals/guest-기기세션ID/` |
|
||||
| 로그인 중 업로드한 새 파일 | `hop/uploads/originals/계정ID/` | `/data/uploads/originals/계정ID/` |
|
||||
| 비로그인 업로드 파일 | `hop/uploads/originals/기기세션ID/` | `/data/uploads/originals/기기세션ID/` |
|
||||
| 업로드 중인 파일·조각 | `hop/uploads/temporary/`, `hop/uploads/chunks/` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` Docker 볼륨 | `/data/database/hop.sqlite` |
|
||||
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads → originals**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads/originals`입니다. **업로드한 사람** 기준으로 로그인 계정은 `user-계정ID`, 비로그인 기기는 `guest-기기세션ID` 폴더를 사용합니다. 같은 로그인 계정은 기기가 달라도 같은 폴더를 사용합니다. 받은 사람의 보관함은 원본을 참조하므로 여러 명이 받아도 복제하지 않습니다. 계정 ID와 이메일은 DB의 `users`에 있으며 파일명·접근권도 DB에서 연결합니다. 폴더명이 다운로드 권한을 부여하지는 않습니다.
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads → originals**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads/originals`입니다. **업로드한 사람** 기준으로 로그인 계정은 `계정ID`, 비로그인 기기는 `기기세션ID` 폴더를 사용합니다. 같은 로그인 계정은 기기가 달라도 같은 폴더를 사용합니다. 받은 사람의 보관함은 원본을 참조하므로 여러 명이 받아도 복제하지 않습니다. 계정 ID와 이메일은 DB의 `users`에 있으며 파일명·접근권도 DB에서 연결합니다. 폴더명이 다운로드 권한을 부여하지는 않습니다.
|
||||
|
||||
파일명은 `무작위 식별자.jpg`처럼 안전한 원래 확장자를 붙여 저장하고 다운로드 시 원래 전체 파일명을 제공합니다. 확장자가 없거나 안전한 확장자로 판별할 수 없는 파일에는 임의 확장자를 붙이지 않습니다. 업로드 폴더는 Git과 Docker 이미지에서 제외합니다. NAS 공유 폴더를 컨테이너에 연결하는 방식은 [Docker 공식 설명](https://docs.docker.com/engine/storage/bind-mounts/)과 [UGREEN 공식 안내](https://support.ugnas.com/detail/article/en-US/611)를 참고했습니다.
|
||||
|
||||
로그인한 A가 비로그인 B에게 새 파일을 보내도 A의 보관함에 자동 저장하지 않습니다. 원본은 업로드 소유자의 폴더에서 기존 접근권과 만료일에 따라 유지합니다. `completed`로 옮기는 방식은 사용하지 않습니다. 이전 방식에서는 계정 보관함에 연결된 파일은 다운로드가 끝나도 `originals`에 두었지만, 현재는 다운로드 완료 여부로 폴더를 나누지 않습니다.
|
||||
|
||||
앱 시작 시 기존 평면 `originals` 및 `completed`의 파일을 DB에 기록된 업로드 소유자 폴더로 이동하고 확장자도 적용합니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 `guest-기기세션ID` 폴더로 이동하며 로그인 계정을 추정하지 않습니다. 파일 ID·다운로드 주소·만료일·사용량과 기존 자료는 유지합니다. 진행 중인 파일 읽기가 있으면 이동을 미루고 DB 변경 실패 시 원래 경로로 복원합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 기록으로 경로와 DB를 맞춥니다. 빈 기존 `completed` 폴더만 제거하며 새 `completed` 폴더는 만들지 않습니다. 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
|
||||
앱 시작 시 기존 평면 `originals` 및 `completed`의 파일을 DB에 기록된 업로드 소유자 폴더로 이동하고 확장자도 적용합니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 `기기세션ID` 폴더로 이동하며 로그인 계정을 추정하지 않습니다. 파일 ID·다운로드 주소·만료일·사용량과 기존 자료는 유지합니다. 진행 중인 파일 읽기가 있으면 이동을 미루고 DB 변경 실패 시 원래 경로로 복원합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 기록으로 경로와 DB를 맞춥니다. 빈 기존 `completed` 폴더만 제거하며 새 `completed` 폴더는 만들지 않습니다. 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
|
||||
|
||||
각 사용자 폴더의 `owner-info.json`에서 업로드 요청 시 확인한 국가·지역·도시·접속 IP와 업로드 주체를 확인합니다. 최대 최근 20개·기본 7일의 기록이며 마지막 원본과 대기 업로드가 모두 사라지면 정보 파일과 빈 사용자 폴더도 정리합니다. 좌표·세션 쿠키·CSRF·파일 본문은 저장하지 않습니다. 일반 사용자의 API·보관함·공개 URL로 제공하지 않습니다. 외부 GeoIP 조회 없이 기본 제공 값만 사용하며 미제공 지역과 과거 자료는 확인 불가입니다.
|
||||
|
||||
@@ -481,11 +481,11 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
|
||||
|
||||
저장 파일의 업로드 주체·파일별 접속 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 각 파일의 최초 전송 요청 당시 전달된 IP 기반 추정 정보입니다. 이후 다른 파일을 올린 지역으로 덮어쓰지 않습니다. 같은 요청의 여러 파일에는 같은 정보를 사용합니다.
|
||||
|
||||
**용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도를 변경합니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 기존 개인·기기 용량 제한, 파일당 크기 제한, 최소 5GiB 디스크 여유와 전송 요청 제한도 유지합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
|
||||
**용량 설정 → 수정**에서 동시 업로드 1~16개, 전체 파일 저장 한도, 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간을 변경합니다. 평소에는 값만 표시하고 수정 중에만 입력창과 저장/취소 버튼을 표시합니다. 개인 한도는 모든 계정·기기에 같은 기준으로 적용하며 계정별 예외 설정은 아닙니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 새 용량 항목의 초기값은 기존 `.env`를 따릅니다. 파일/개인/기기 한도와 NAS 최소 여유는 관리자 설정을 저장한 뒤 재시작 없이 적용하고 전송 횟수 제한은 유지합니다. 이미 수락해 공간을 예약한 업로드는 한도를 낮춰도 계속되며, 이후 새 공간 예약에는 변경된 한도를 적용합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
|
||||
|
||||
**신규 업로드 중지**는 새 파일 전송·임시 저장을 거절합니다. 중지 전에 수락한 파일, 이미 저장된 원본의 재전송·다운로드, 링크·텍스트 전송은 계속됩니다. 한도를 낮춰도 기존 자료를 자동 삭제하지 않습니다.
|
||||
|
||||
**네트워크 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. 모달로 추가한 표를 확인하고 네트워크 설정을 저장하며, 제거할 때는 표의 항목을 선택합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
|
||||
**네트워크 → 수정 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. IPv4는 `203.0.113.*`, `203.0.*.*`처럼 뒤쪽 전체 자리의 와일드카드도 지원하며 각각 `/24`, `/16` CIDR로 저장합니다. `203.*.113.*`나 `113*` 같은 불연속·부분 자리 패턴은 지원하지 않습니다. IPv6는 CIDR을 사용합니다. 모달로 추가한 표를 확인하고 네트워크 설정을 저장하며, 제거할 때는 표의 항목을 선택합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
|
||||
|
||||
관리 설정은 DB의 `admin_settings`, 변경·원본 삭제 이력은 `admin_audit`에 보관합니다. 이력은 기본 30일이며 설정은 재시작 뒤 유지됩니다. 한 번 저장한 관리 설정은 `.env` 초기값보다 우선하고 이후에도 관리자 화면에서 수정합니다. 화면은 수동 새로고침만 사용하며 상시 목록 조회를 하지 않습니다.
|
||||
|
||||
@@ -525,3 +525,23 @@ Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker
|
||||
평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다. 운영 NAS와 Cloudflare 설정은 자동 변경하지 않습니다.
|
||||
|
||||
국가 화이트리스트를 적용하더라도 컨테이너 내부 루프백에서 전달 헤더 없이 호출하는 `/api/health`는 상태 검사를 위해 허용합니다. 다른 API·관리 기능에는 이 예외를 적용하지 않고 외부 프록시에서 온 health 요청도 네트워크 규칙을 따릅니다.
|
||||
|
||||
## 출시 전 전송 자료 초기화 (계정 보존)
|
||||
|
||||
현재 운영 NAS의 전송/보관 자료를 비워도 된다는 운영자 결정에 따라 사용할 일회성 작업입니다. 앱 시작 때 자동 초기화하거나 새 폴더로 이관하는 코드는 추가하지 않습니다. 새 업로드는 접두어 없는 `uploads/originals/업로더UUID/`에 저장합니다. 관리자 파일 목록은 기기명을 생략하고 ID와 로그인 이메일 또는 ‘비로그인’을 표시합니다. 과거 비로그인 기록을 현재 로그인 계정으로 추정하지 않습니다.
|
||||
|
||||
프로젝트 디렉터리에서 최신 소스를 받은 뒤 진행합니다.
|
||||
|
||||
```sh
|
||||
git pull --ff-only origin main
|
||||
sudo docker compose -f docker-compose.yaml stop hop
|
||||
sudo sh scripts/reset-nas-transfer-data.sh
|
||||
```
|
||||
|
||||
초기화 성공 문구를 확인한 다음 **서비스를 중지한 상태에서** NAS 파일 관리자로 `hop/uploads` **안의 내용**을 모두 지웁니다. `uploads` 폴더 자체, `.env`, DB 볼륨은 유지합니다. 숨김 `.file-moves`도 초기화 대상입니다. 마지막으로 빌드·배포합니다.
|
||||
|
||||
```sh
|
||||
sudo docker compose -f docker-compose.yaml up -d --build
|
||||
```
|
||||
|
||||
스크립트는 `hop` 컨테이너가 중지됐는지 확인하고 해당 컨테이너의 DB 볼륨·실제 이미지 ID·실행 UID를 그대로 사용합니다. 외부 네트워크나 앱 프로세스 없이 전송·보관함·예약·기기 세션·재시도 기록만 트랜잭션으로 비우고 외래 키를 검사합니다. 계정·비밀번호·이메일 인증 상태·계정 로그인 세션·계정 설정·접속 기록과 관리자 설정/기록은 보존합니다. 파일은 자동 삭제하지 않습니다. 계정별 보관함의 파일·링크·텍스트와 기기 연결 공간은 모두 초기화됩니다. NAS에서 직접 실행하기 전에는 데이터가 바뀌지 않습니다.
|
||||
|
||||
@@ -210,3 +210,12 @@
|
||||
- 계정 삭제는 이메일 확인·관리자 보호·일반 계정 거절을 적용하고 개인정보/자격 증명 익명화·세션/토큰 폐기·보관함 접근권 제거·대기 중 계정 전송 취소를 수행한다. 다른 수신자가 참조하는 실제 파일은 삭제하지 않고 다운로드가 유지된다. 비밀번호 검증 중 계정 삭제와 겹쳐도 로그인 성공 세션을 발급하지 않도록 최신 상태를 다시 확인한다. 사용자 기능은 평소 더보기 안에만 표시한다.
|
||||
- API 전체 139개와 마지막 내부 health 예외를 포함한 네트워크 8개·전체 빌드/타입 검사·포맷 검사를 통과했다. 실제 Chromium에서 네트워크 모달/선택 제거·화이트리스트·로그인 실패 자동 차단 목록/해제·국가 코드 표시·변경 기록 선택/전체 삭제·사용자 더보기 재설정/삭제와 PC/390px 모바일을 확인했다. 기존 계정 가입/인증/보관함·일반 텍스트/링크/파일 전송의 브라우저 회귀도 통과했다.
|
||||
- 최종 운영 Docker에서 새 테이블 자동 마이그레이션, 화이트리스트·자동 차단 IP·사용자 접속·변경 필드 기록의 재시작 보존, 기존 계정 쿠키·자료·Unicode 텍스트·8MiB 부분 조각 이어 보내기·SQLite integrity·호스트 바인드 수동 삭제를 확인했다. 실제 Resend 메일과 NAS 운영 데이터·Cloudflare 설정은 사용하지 않고 격리 자원/메일 대역으로 검증했다. 운영 `docker-compose.yaml`은 변경하지 않았다.
|
||||
|
||||
## 2026-10-15 관리자 수정 모드·실시간 용량 정책·업로더 표시
|
||||
|
||||
- 용량/네트워크는 기본적으로 값과 표만 보여준다. 수정 버튼으로 입력/토글/규칙 추가·제거를 열고 저장/취소로 종료한다. 취소가 서버를 변경하지 않고 반올림된 GB를 수정하지 않으면 기존 바이트 값을 유지함을 실제 Chromium PC/390px 모바일에서 확인했다. 설정 리비전 충돌 검사는 유지한다. 목록 검색·명시적 자동 차단 해제·사용자 더보기 동작은 그대로 제공한다.
|
||||
- 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 관리자 설정으로 저장한다. `.env`는 새 필드의 최초 기본값에만 사용하고 기존 관리 설정과 계정은 보존한다. 실제 파일 요청·공간 예약·보관함 수신 정책·사용량 응답에 즉시 적용하며 변경분만 관리 기록에 남긴다. 수락해 예약한 업로드는 한도를 낮춘 뒤에도 직접 업로드와 8MiB 조각 이어 보내기를 완료한다. 요청한 파일 크기를 넘기는 본문과 잘못된 수치는 거절한다.
|
||||
- IPv4 뒤쪽 전체 자리 와일드카드를 양쪽 목록에서 지원하고 `203.0.113.*`를 `203.0.113.0/24`로 저장한다. `/8`·`/16`·`/24`·전체 대역 정규화, 중복 제거, 경계 IP의 허용/거절, 불연속/부분 자리/IPv6 와일드카드 거절과 관리자 자신 차단 방지를 검증했다.
|
||||
- 새 파일 폴더는 `originals/업로더UUID/`이다. 관리 목록은 기기명을 생략하고 UUID + 계정 이메일 또는 비로그인을 표시한다. 계정 이메일은 관리자 API에서 DB의 업로더 계정으로 조회하며 일반 전송 API에 추가하지 않는다. 기존 prefix 폴더를 강제로 옮기는 새 이관 기능은 추가하지 않았다.
|
||||
- 출시 전 자료를 비워도 된다는 운영자 요청에 맞춰 별도 오프라인 초기화 도구를 준비했다. 서비스 실행 중 실행을 막는 NAS 래퍼를 사용하며 전송/보관함/기기 세션만 DB 트랜잭션으로 비운다. 격리 DB와 실제 Docker DB 볼륨에서 계정·비밀번호·인증/로그인 세션·사용자 정보·관리 설정을 그대로 보존하고 외래 키/SQLite integrity를 확인했다. uploads의 내용은 운영자가 서비스 중지 상태에서 직접 삭제한다. NAS 운영 DB나 파일은 여기서 초기화하지 않았다.
|
||||
- 전체 API 144개, 관리자 PC/모바일 브라우저, 타입 검사와 빌드를 통과했다. 운영 이미지의 격리 Docker에서 설정/파일/텍스트/인증 재시작 보존, 8MiB 조각 재개, UUID 경로·NAS UID·호스트 수동 삭제 대응, 오프라인 기록 초기화 후 계정/관리 설정 보존과 재시작을 통과했다. 테스트 자원은 종료 후 제거했고 운영 Compose 설정은 변경하지 않았다.
|
||||
|
||||
15
scripts/reset-nas-transfer-data.sh
Normal file
15
scripts/reset-nas-transfer-data.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# One-time reset for prelaunch data; preserves registered accounts and admin settings.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' hop)" != false ]; then
|
||||
printf '%s\n' 'hop 프로젝트를 먼저 중지하세요. 실행 중에는 초기화할 수 없습니다.' >&2
|
||||
exit 1
|
||||
fi
|
||||
hop_reset_image=$(docker inspect --format '{{.Image}}' hop)
|
||||
hop_reset_user=$(docker inspect --format '{{.Config.User}}' hop)
|
||||
# Use the stopped container's exact database volume, image and user, with no network or app process.
|
||||
docker run --rm --network none --volumes-from hop --user "$hop_reset_user" \
|
||||
--mount "type=bind,source=$PWD/scripts/reset-transfer-records.mjs,target=/app/scripts/reset-transfer-records.mjs,readonly" \
|
||||
--env DATABASE_PATH=/data/database/hop.sqlite \
|
||||
--entrypoint node "$hop_reset_image" /app/scripts/reset-transfer-records.mjs --confirm-reset-transfer-records
|
||||
51
scripts/reset-transfer-records.mjs
Normal file
51
scripts/reset-transfer-records.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Offline maintenance only. The NAS wrapper checks that hop is stopped first.
|
||||
import Database from 'better-sqlite3';
|
||||
if (!process.argv.includes('--confirm-reset-transfer-records')) {
|
||||
throw Error('전송·보관 기록 초기화에는 --confirm-reset-transfer-records가 필요합니다.');
|
||||
}
|
||||
const path = process.env.DATABASE_PATH;
|
||||
if (!path || path === ':memory:') throw Error('기존 DATABASE_PATH를 지정하세요.');
|
||||
const database = new Database(path, { fileMustExist: true });
|
||||
try {
|
||||
database.pragma('foreign_keys = ON');
|
||||
database.pragma('busy_timeout = 5000');
|
||||
const tables = [
|
||||
'vault_transfers',
|
||||
'request_receipt_accounts',
|
||||
'deletion_targets',
|
||||
'deletion_operations',
|
||||
'item_access',
|
||||
'items',
|
||||
'quota_reservations',
|
||||
'file_upload_contexts',
|
||||
'files',
|
||||
'transfer_batches',
|
||||
'transfer_requests',
|
||||
'idempotency_records',
|
||||
'guest_blocks',
|
||||
'space_members',
|
||||
'connection_spaces',
|
||||
'browser_devices',
|
||||
'guest_sessions',
|
||||
];
|
||||
const existing = new Set(
|
||||
database
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all()
|
||||
.map((row) => row.name),
|
||||
);
|
||||
const users = database.prepare('SELECT count(*) AS count FROM users').get().count;
|
||||
database.transaction(() => {
|
||||
for (const table of tables) if (existing.has(table)) database.exec(`DELETE FROM "${table}"`);
|
||||
if (database.pragma('foreign_key_check').length) throw Error('외래 키 검사 실패');
|
||||
if (database.prepare('SELECT count(*) AS count FROM users').get().count !== users)
|
||||
throw Error('계정 수 검사 실패');
|
||||
})();
|
||||
database.pragma('wal_checkpoint(TRUNCATE)');
|
||||
console.log(`전송·보관 기록 초기화 완료. 계정 ${users}개와 인증·관리 설정은 유지했습니다.`);
|
||||
console.log(
|
||||
'uploads 폴더의 내용은 삭제하지 않았습니다. 서비스가 중지된 동안 폴더 안의 내용을 비운 뒤 재배포하세요.',
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
@@ -115,10 +115,11 @@ try {
|
||||
if (verifiedAccount) {
|
||||
const saved = JSON.parse(
|
||||
node(
|
||||
`const base='http://127.0.0.1:${port}', cookie=${JSON.stringify(verifiedAccount.cookie)};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'},body:'{}'})).json();const r=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({revision:0,maxConcurrentUploads:3,blockedCountries:['CN'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
|
||||
`const base='http://127.0.0.1:${port}', cookie=${JSON.stringify(verifiedAccount.cookie)};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'},body:'{}'})).json();const r=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({revision:0,maxConcurrentUploads:3,accountBytes:3e9,deviceBytes:4e9,devicesTotalBytes:12e9,perFileBytes:1e9,minDiskFreeBytes:0,blockedCountries:['CN'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
|
||||
),
|
||||
);
|
||||
assert.equal(saved.maxConcurrentUploads, 3);
|
||||
assert.equal(saved.accountBytes, 3e9);
|
||||
node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');db.prepare('INSERT INTO automatic_blocks (ip,countryCode,reason,createdAt,expiresAt) VALUES (?,?,?,?,?)').run('8.8.8.8','KR','login-failures',Date.now(),Date.now()+1800000);db.close();`,
|
||||
);
|
||||
@@ -153,6 +154,14 @@ try {
|
||||
),
|
||||
);
|
||||
assert.equal(settings.maxConcurrentUploads, 3);
|
||||
for (const [key, value] of Object.entries({
|
||||
accountBytes: 3e9,
|
||||
deviceBytes: 4e9,
|
||||
devicesTotalBytes: 12e9,
|
||||
perFileBytes: 1e9,
|
||||
minDiskFreeBytes: 0,
|
||||
}))
|
||||
assert.equal(settings[key], value, key);
|
||||
assert.deepEqual(settings.blockedCountries, ['CN']);
|
||||
assert.deepEqual(settings.allowedIps, ['127.0.0.1']);
|
||||
assert.equal(settings.autoBlockEnabled, true);
|
||||
@@ -216,7 +225,7 @@ try {
|
||||
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
||||
const originals = resolve(folder, 'originals');
|
||||
const stored = readdirSync(originals)
|
||||
.filter((key) => key.startsWith('guest-'))
|
||||
.filter((key) => /^[a-f0-9-]{36}$/.test(key))
|
||||
.flatMap((owner) =>
|
||||
readdirSync(resolve(originals, owner)).map((key) => ({
|
||||
owner,
|
||||
@@ -226,7 +235,7 @@ try {
|
||||
)
|
||||
.find(({ path }) => readFileSync(path).toString() === 'Docker file persistence');
|
||||
assert(stored, 'uploaded file is visible under its guest owner in the host project');
|
||||
assert.match(stored.owner, /^guest-[a-f0-9-]{36}$/);
|
||||
assert.match(stored.owner, /^[a-f0-9-]{36}$/);
|
||||
assert.match(stored.key, /^[a-f0-9-]{36}\.txt$/);
|
||||
assert.equal(existsSync(resolve(folder, 'completed')), false);
|
||||
unlinkSync(stored.path);
|
||||
@@ -249,8 +258,46 @@ try {
|
||||
assert(visible.some((item) => item.file?.name === 'resume.bin'));
|
||||
}
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
if (process.env.HOP_CHECK_RESET === '1') {
|
||||
const protectedState = `import Database from 'better-sqlite3';import {createHash} from 'node:crypto';const db=new Database('/data/database/hop.sqlite');const tables=['users','account_sessions','account_tokens','user_rules','admin_settings','user_activity'];const rows=tables.map(t=>db.prepare('SELECT * FROM '+t).all());console.log(createHash('sha256').update(JSON.stringify(rows)).digest('hex'));db.close();`;
|
||||
const beforeReset = node(protectedState);
|
||||
docker('stop', name);
|
||||
const reset = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
'none',
|
||||
'--volumes-from',
|
||||
name,
|
||||
'--user',
|
||||
runtimeUser,
|
||||
'--mount',
|
||||
`type=bind,source=${resolve('scripts/reset-transfer-records.mjs')},target=/app/scripts/reset-transfer-records.mjs,readonly`,
|
||||
'--env',
|
||||
'DATABASE_PATH=/data/database/hop.sqlite',
|
||||
'--entrypoint',
|
||||
'node',
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
'/app/scripts/reset-transfer-records.mjs',
|
||||
'--confirm-reset-transfer-records',
|
||||
);
|
||||
assert.match(reset, /초기화 완료/);
|
||||
docker('start', name);
|
||||
wait();
|
||||
assert.equal(
|
||||
node(protectedState),
|
||||
beforeReset,
|
||||
'offline reset preserves user/auth/admin data exactly',
|
||||
);
|
||||
const emptied = JSON.parse(
|
||||
node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify({files:db.prepare('SELECT count(*) AS n FROM files').get().n,requests:db.prepare('SELECT count(*) AS n FROM transfer_requests').get().n,guests:db.prepare('SELECT count(*) AS n FROM guest_sessions').get().n,integrity:db.pragma('integrity_check',{simple:true})}));db.close();`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(emptied, { files: 0, requests: 0, guests: 0, integrity: 'ok' });
|
||||
}
|
||||
console.log(
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
|
||||
@@ -113,19 +113,22 @@ try {
|
||||
files: [{ name: 'sample.mp4', size: 4, type: 'video/mp4' }],
|
||||
});
|
||||
await call(receiver, b.csrfToken, '/transfer-requests/' + transfer.requestId + '/accept', {});
|
||||
await sender.evaluate(
|
||||
async ({ csrf, id }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', new Blob(['test'], { type: 'video/mp4' }), 'sample.mp4');
|
||||
const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) throw Error(await response.text());
|
||||
},
|
||||
{ csrf: a.csrfToken, id: transfer.requestId },
|
||||
);
|
||||
async function uploadFile(page, csrf, id, name, type) {
|
||||
await page.evaluate(
|
||||
async ({ csrf, id, name, type }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', new Blob(['test'], { type }), name);
|
||||
const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) throw Error(await response.text());
|
||||
},
|
||||
{ csrf, id, name, type },
|
||||
);
|
||||
}
|
||||
await uploadFile(sender, a.csrfToken, transfer.requestId, 'sample.mp4', 'video/mp4');
|
||||
async function login(page, email) {
|
||||
await page.locator('.login-button').click();
|
||||
await page.locator('.account-auth').waitFor();
|
||||
@@ -150,7 +153,8 @@ try {
|
||||
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /중국\(CN\) · Beijing/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
|
||||
assert.match(await admin.locator('.admin-file-info').first().innerText(), /^\[.+\]\s+guest-/);
|
||||
assert.match(await admin.locator('.admin-file-owner').innerText(), /^[a-f0-9-]{36}\s+비로그인$/);
|
||||
assert.doesNotMatch(await admin.locator('.admin-file-owner').innerText(), /guest-|user-/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국\(CN\) · Beijing/);
|
||||
assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/);
|
||||
assert.match(
|
||||
@@ -163,42 +167,67 @@ try {
|
||||
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
|
||||
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
|
||||
await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
|
||||
assert.match(
|
||||
await admin.locator('.admin-limit-details').innerText(),
|
||||
/로그인 보관함[\s\S]*계정당 2\.1GB/,
|
||||
);
|
||||
assert.match(
|
||||
await admin.locator('.admin-limit-details').innerText(),
|
||||
/기기 전송 업로드[\s\S]*기기 세션당 2\.1GB/,
|
||||
);
|
||||
const capacityForm = admin.locator('.admin-settings');
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.1GB/);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
|
||||
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
runtime.service.admin.state.totalStorageLimitMb,
|
||||
20480,
|
||||
'rounded GB presentation must not change the stored quota',
|
||||
'unmodified rounded GB keeps exact stored quota',
|
||||
);
|
||||
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
|
||||
await admin.getByLabel('전체 파일 저장 한도 (GB)').fill('16');
|
||||
assert.equal(runtime.service.admin.state.accountBytes, 2147483648);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('8');
|
||||
await admin.getByRole('button', { name: '취소', exact: true }).click();
|
||||
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 4);
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('3');
|
||||
await admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true }).fill('16');
|
||||
await admin.getByLabel('로그인 보관함 계정당 한도 (GB)', { exact: true }).fill('3');
|
||||
await admin.getByLabel('기기 세션당 업로드 한도 (GB)', { exact: true }).fill('4');
|
||||
await admin.getByLabel('기기 전송 업로드 전체 한도 (GB)', { exact: true }).fill('12');
|
||||
await admin.getByLabel('파일 한 개 한도 (GB)', { exact: true }).fill('1');
|
||||
await admin.getByLabel('NAS 최소 여유 공간 (GB)', { exact: true }).fill('0');
|
||||
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
|
||||
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3);
|
||||
assert.equal(runtime.service.admin.state.totalStorageLimitMb, Math.round(16e9 / 1048576));
|
||||
for (const [key, expected] of Object.entries({
|
||||
accountBytes: 3e9,
|
||||
deviceBytes: 4e9,
|
||||
devicesTotalBytes: 12e9,
|
||||
perFileBytes: 1e9,
|
||||
minDiskFreeBytes: 0,
|
||||
}))
|
||||
assert.equal(runtime.service.admin.state[key], expected);
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('50');
|
||||
await admin.getByRole('button', { name: '취소', exact: true }).click();
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
async function addRule(page, list, kind, value) {
|
||||
await page.getByRole('button', { name: list + ' 추가', exact: true }).click();
|
||||
const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true });
|
||||
await modal.getByLabel('등록 대상').selectOption(kind);
|
||||
await modal
|
||||
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR', { exact: true })
|
||||
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역', { exact: true })
|
||||
.fill(value);
|
||||
await modal.getByRole('button', { name: '추가', exact: true }).click();
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
await addRule(admin, '블랙리스트', 'country', 'cn');
|
||||
await addRule(admin, '블랙리스트', 'ip', '9.9.9.0/24');
|
||||
await addRule(admin, '블랙리스트', 'ip', '9.9.9.*');
|
||||
await addRule(admin, '화이트리스트', 'country', 'KR');
|
||||
await addRule(admin, '화이트리스트', 'ip', '8.8.8.0/24');
|
||||
await addRule(admin, '화이트리스트', 'ip', '8.8.8.*');
|
||||
await addRule(admin, '화이트리스트', 'ip', '4.4.4.4');
|
||||
await admin.getByRole('checkbox', { name: '4.4.4.4 허용 규칙 선택', exact: true }).check();
|
||||
await admin
|
||||
@@ -209,6 +238,9 @@ try {
|
||||
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3');
|
||||
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click();
|
||||
await admin.getByText('네트워크 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
assert.equal(runtime.service.admin.state.autoLoginFailures, 3);
|
||||
assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']);
|
||||
assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']);
|
||||
assert.deepEqual(runtime.service.admin.state.allowedIps, ['8.8.8.0/24']);
|
||||
assert.equal(runtime.service.admin.state.autoBlockEnabled, true);
|
||||
@@ -241,6 +273,25 @@ try {
|
||||
await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click();
|
||||
await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor();
|
||||
assert.equal(runtime.service.admin.summary().storedBytes, 0);
|
||||
const normalSession = await session(normal);
|
||||
const owned = await call(normal, normalSession.csrfToken, '/vault', {
|
||||
kind: 'file',
|
||||
files: [{ name: 'account-owner.txt', size: 4, type: 'text/plain' }],
|
||||
});
|
||||
await uploadFile(
|
||||
normal,
|
||||
normalSession.csrfToken,
|
||||
owned.requestId,
|
||||
'account-owner.txt',
|
||||
'text/plain',
|
||||
);
|
||||
await admin.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||
const ownedCard = admin.locator('.admin-file').filter({ hasText: 'account-owner.txt' });
|
||||
await ownedCard.waitFor();
|
||||
const ownerLabel = await ownedCard.locator('.admin-file-owner').innerText();
|
||||
assert.match(ownerLabel, /^[a-f0-9-]{36}\s+other@hop\.test$/);
|
||||
assert.doesNotMatch(ownerLabel, /user-|guest-|\[/);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-file-account.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
|
||||
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
|
||||
const changedRow = admin
|
||||
@@ -296,11 +347,22 @@ try {
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.equal(await mobile.locator('.admin-settings input').count(), 0);
|
||||
await mobile.getByRole('button', { name: '수정', exact: true }).click();
|
||||
assert.equal(await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(), '1');
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await mobile.screenshot({ path: 'tests/e2e/admin-capacity-mobile.png', fullPage: true });
|
||||
await mobile.getByRole('button', { name: '취소', exact: true }).click();
|
||||
await mobile.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.equal(await mobile.getByRole('switch').count(), 0);
|
||||
await mobile.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
|
||||
const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user