diff --git a/README.md b/README.md index 922c834..f20020c 100644 --- a/README.md +++ b/README.md @@ -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와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다. diff --git a/apps/server/src/accounts.ts b/apps/server/src/accounts.ts index 008a669..47a84be 100644 --- a/apps/server/src/accounts.ts +++ b/apps/server/src/accounts.ts @@ -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) { diff --git a/apps/server/src/administration.js b/apps/server/src/administration.js index 4a1c058..f4ae5c8 100644 --- a/apps/server/src/administration.js +++ b/apps/server/src/administration.js @@ -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); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 26be1a1..23b2baf 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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)) { diff --git a/apps/server/src/fileLayout.js b/apps/server/src/fileLayout.js index 58391f3..398292b 100644 --- a/apps/server/src/fileLayout.js +++ b/apps/server/src/fileLayout.js @@ -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 { diff --git a/apps/server/src/ownerInfo.js b/apps/server/src/ownerInfo.js index 87ee10c..5c6cf15 100644 --- a/apps/server/src/ownerInfo.js +++ b/apps/server/src/ownerInfo.js @@ -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) { diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index c2fa2c4..3efef8a 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -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 diff --git a/apps/server/src/storage.ts b/apps/server/src/storage.ts index a7af1b6..7c3a25c 100644 --- a/apps/server/src/storage.ts +++ b/apps/server/src/storage.ts @@ -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`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', '파일 크기가 요청과 다릅니다.'), ); diff --git a/apps/server/test/admin-capacity.test.js b/apps/server/test/admin-capacity.test.js new file mode 100644 index 0000000..e336558 --- /dev/null +++ b/apps/server/test/admin-capacity.test.js @@ -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 }); + } +}); diff --git a/apps/server/test/file-layout.test.js b/apps/server/test/file-layout.test.js index fc5ebbc..5249db0 100644 --- a/apps/server/test/file-layout.test.js +++ b/apps/server/test/file-layout.test.js @@ -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(); } diff --git a/apps/server/test/helpers.ts b/apps/server/test/helpers.ts index a389bdb..7ef49e4 100644 --- a/apps/server/test/helpers.ts +++ b/apps/server/test/helpers.ts @@ -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 = {}, +) { 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' }); diff --git a/apps/server/test/network.test.js b/apps/server/test/network.test.js index 67f0643..2541e4c 100644 --- a/apps/server/test/network.test.js +++ b/apps/server/test/network.test.js @@ -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(); + } +}); diff --git a/apps/server/test/phase2.test.ts b/apps/server/test/phase2.test.ts index c53db3d..a67cb0b 100644 --- a/apps/server/test/phase2.test.ts +++ b/apps/server/test/phase2.test.ts @@ -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, diff --git a/apps/server/test/phase3.test.ts b/apps/server/test/phase3.test.ts index 91bebf8..246604c 100644 --- a/apps/server/test/phase3.test.ts +++ b/apps/server/test/phase3.test.ts @@ -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, diff --git a/apps/server/test/vault.test.js b/apps/server/test/vault.test.js index 8ff0aab..aa69ae3 100644 --- a/apps/server/test/vault.test.js +++ b/apps/server/test/vault.test.js @@ -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(); diff --git a/apps/web/src/AdminPanel.vue b/apps/web/src/AdminPanel.vue index 38fe17b..ff87b70 100644 --- a/apps/web/src/AdminPanel.vue +++ b/apps/web/src/AdminPanel.vue @@ -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 () => { {{ file.name }}{{ size(file.size) }} -
- [{{ - file.uploader?.name || - (file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기') - }}]{{ file.path.split('/')[0] }} +
+ {{ + file.uploader?.id || file.path.split('/')[0].replace(/^(user|guest)-/, '') + }} + {{ + file.uploader?.kind === 'user' ? file.uploader.email || '삭제된 계정' : '비로그인' + }}
접속 지역 · {{ region(file.lastLocation) }} { -
-
- -
-

- 전체 한도는 모든 사용자가 올린 원본 파일과 업로드 예약을 합친 용량입니다. 개인마다 이 - 용량을 배정하는 것은 아닙니다. -

-
-
-
로그인 보관함
-
계정당 {{ size(overview.limits.accountBytes) }} · 임시 저장과 받은 파일 합산
-
-
-
기기 전송 업로드
-
- 기기 세션당 {{ size(overview.limits.deviceBytes) }} · 로그인 여부와 관계없이 적용 -
-
-
-
기기 전송 업로드 전체
-
모든 기기 세션 합계 {{ size(overview.limits.devicesTotalBytes) }}
-
-
-
파일 한 개
-
최대 {{ size(overview.limits.perFileBytes) }}
-
-
-
NAS 최소 여유 공간
-
업로드 예약을 반영해 {{ size(overview.limits.minDiskFreeBytes) }} 이상 확보
-
-
-

- 전체·기기 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이 - 가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존 - 파일은 삭제하지 않습니다. -

- -
+ - +