From 471e2653d875b89ac23cee0e12a53bdc62348aea Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 15 Oct 2026 11:23:33 +0900 Subject: [PATCH] Clarify storage limits and unify file metadata displays --- README.md | 10 +- .../drizzle/0013_file_upload_contexts.sql | 8 ++ apps/server/drizzle/meta/_journal.json | 7 ++ apps/server/src/administration.js | 12 +- apps/server/src/db/schema.ts | 11 ++ apps/server/src/ownerInfo.js | 57 +++++++++- apps/server/test/admin.test.js | 7 ++ apps/server/test/display-format.test.js | 24 ++++ apps/server/test/owner-info.test.js | 103 ++++++++++++++++++ apps/web/src/AccountPanel.vue | 13 +-- apps/web/src/AdminPanel.vue | 77 ++++++++----- apps/web/src/App.vue | 25 +---- apps/web/src/SendComposer.vue | 10 +- apps/web/src/displayFormat.js | 24 ++++ apps/web/src/style.css | 18 +++ deploy.md | 16 ++- docs/VERIFICATION.md | 8 ++ tests/docker/persistence.mjs | 6 + tests/e2e/admin.mjs | 26 ++++- tests/e2e/layout.mjs | 17 ++- tests/e2e/phase3.mjs | 2 +- tests/e2e/receive-controls.mjs | 4 +- 22 files changed, 401 insertions(+), 84 deletions(-) create mode 100644 apps/server/drizzle/0013_file_upload_contexts.sql create mode 100644 apps/server/test/display-format.test.js create mode 100644 apps/web/src/displayFormat.js diff --git a/README.md b/README.md index d9ba9e2..6414eb4 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 이메일 인증을 완료한 **hello@noname.company** 계정으로 로그인하면 **더보기 → 관리자**가 표시됩니다. 서버도 매 요청에서 동일 계정의 인증·활성 상태를 확인합니다. 다른 계정이나 이메일 로컬 파트의 대소문자가 다른 계정에는 권한을 주지 않습니다. -저장 파일·업로드 주체·최근 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다. +저장 파일·업로드 주체·파일별 전송 요청 시점의 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다. 관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GiB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다. @@ -124,3 +124,11 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 기기 이름은 서버 DB에 저장되며 동일 브라우저의 기기 세션 쿠키가 유효한 동안 유지됩니다. 쿠키를 지우거나 다른 브라우저로 접속하면 새 기기로 인식됩니다. 전송 제한의 기본값은 상대 10회·기기 세션 30회·같은 IP 전체 120회/분입니다. 거절 후 5분 대기는 유지하며 전송 버튼에 재시도까지 남은 초를 표시합니다. 기존 NAS `.env` 한도 변경은 [deploy.md](deploy.md)의 전송 요청 제한 안내를 따릅니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하며 차단은 확인 후 실행합니다. 푸터의 내 기기 설정에서 차단한 기기를 관리·해제할 수 있습니다. `npm run test:e2e:receive`로 새 수신·제한·차단 흐름을 검증합니다. + +## 관리자 정보와 용량 표시 + +업로드 주체 이름은 `[iPhone]`처럼 대괄호로 구분하고 관리자·보관함의 만료일은 `[보관 만료] 2026년 10월 22일 11:02 까지`처럼 한국 시간의 연도·24시간으로 표시합니다. 푸터의 발견 허용/꺼짐 문구는 숨깁니다. + +전송·수신·보관함·관리자 화면은 같은 용량 표시 함수를 사용합니다. 1MB 미만은 KB, 1MB 이상은 MB, 1GB 이상은 GB이며 KB/MB/GB는 1,000 단위입니다. 예: `938KB`, `1.1MB`, `1.7GB`. 기존 서버 저장 한도와 환경변수의 MiB 기준은 유지합니다. 관리자 화면의 GB 설정을 바꾸지 않고 다른 규칙만 저장할 때는 반올림된 표시값 때문에 기존 용량이 바뀌지 않습니다. + +파일별 접속 정보는 DB에 최초 전송 요청 당시의 값을 저장하여 이후 같은 사람이 다른 지역에서 전송해도 바뀌지 않습니다. 같은 요청의 여러 파일은 같은 요청 당시 정보를 사용하며 원본 재전송은 원래 정보를 유지합니다. 기본 최대 7일 보관하고 실패·삭제한 파일의 기록은 주기 정리합니다. 기존 파일은 `owner-info.json`에 동일한 요청의 기록이 있을 때만 복원하고, 없으면 다른 파일의 최근 정보로 추정하지 않습니다. 파일 내용·GPS 좌표를 추가로 수집하지 않으며 관리자 외에는 제공하지 않습니다. diff --git a/apps/server/drizzle/0013_file_upload_contexts.sql b/apps/server/drizzle/0013_file_upload_contexts.sql new file mode 100644 index 0000000..850b72c --- /dev/null +++ b/apps/server/drizzle/0013_file_upload_contexts.sql @@ -0,0 +1,8 @@ +CREATE TABLE `file_upload_contexts` ( + `fileId` text PRIMARY KEY NOT NULL, + `context` text NOT NULL, + `expiresAt` integer NOT NULL, + FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `file_upload_context_expiry` ON `file_upload_contexts` (`expiresAt`); diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 527a74e..0140b50 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1791973081784, "tag": "0012_admin_controls", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1792030497381, + "tag": "0013_file_upload_contexts", + "breakpoints": true } ] } diff --git a/apps/server/src/administration.js b/apps/server/src/administration.js index ba8c977..a95e2e4 100644 --- a/apps/server/src/administration.js +++ b/apps/server/src/administration.js @@ -240,6 +240,13 @@ export class Administration { const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true }); 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, + }, storedFiles: Number(stored.count), storedBytes: Number(stored.bytes), reservedBytes: Number(reserved.bytes), @@ -285,8 +292,7 @@ export class Administration { const items = page .filter((file) => this.service.storage.availability.check(file)) .map((file) => { - const owner = this.service.storage.layout.ownerInfo.details(file.storageKey), - events = owner ? this.service.storage.layout.ownerInfo.read(owner).events : []; + const owner = this.service.storage.layout.ownerInfo.details(file.storageKey); const expiry = this.service.db .select({ at: sql`max(${s.items.expiresAt})` }) .from(s.items) @@ -307,7 +313,7 @@ export class Administration { name: this.service.storage.layout.ownerInfo.name(owner), } : null, - lastLocation: events.at(-1) ?? null, + lastLocation: this.service.storage.layout.ownerInfo.fileLocation(file), }; }); return { diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 9daf8a8..bb206f3 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -224,6 +224,17 @@ export const files = sqliteTable( ), ], ); +export const fileUploadContexts = sqliteTable( + 'file_upload_contexts', + { + fileId: text() + .primaryKey() + .references(() => files.id, { onDelete: 'cascade' }), + context: text().notNull(), + expiresAt: integer().notNull(), + }, + (t) => [index('file_upload_context_expiry').on(t.expiresAt)], +); export const reservations = sqliteTable( 'quota_reservations', { diff --git a/apps/server/src/ownerInfo.js b/apps/server/src/ownerInfo.js index de516f9..87ee10c 100644 --- a/apps/server/src/ownerInfo.js +++ b/apps/server/src/ownerInfo.js @@ -11,7 +11,7 @@ import { } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -import { and, eq, inArray, like } from 'drizzle-orm'; +import { and, eq, gt, inArray, like, lte, or } from 'drizzle-orm'; import * as s from './db/schema.js'; import { clientIp } from './security.js'; @@ -135,11 +135,62 @@ export class OwnerInfo { const owner = this.details(file.storageKey); if (!owner) return; const { events } = this.read(owner); - if (events.some((event) => event.requestId === requestId)) return; - events.push({ requestId, recordedAt: new Date().toISOString(), ...region }); + const existing = events.find((event) => event.requestId === requestId), + context = existing ?? { requestId, recordedAt: new Date().toISOString(), ...region }; + for (const row of this.service.storage.fileRows(requestId)) this.saveFileContext(row, context); + if (existing) return; + events.push(context); this.write(owner, events); } + saveFileContext(file, context) { + const expiresAt = + Date.parse(context.recordedAt) + this.service.cfg.fileRetentionDays * 86400000; + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return; + this.service.db + .insert(s.fileUploadContexts) + .values({ + fileId: file.id, + context: JSON.stringify(context), + expiresAt, + }) + .onConflictDoNothing() + .run(); + } + fileLocation(file) { + const stored = this.service.db + .select() + .from(s.fileUploadContexts) + .where( + and( + eq(s.fileUploadContexts.fileId, file.id), + gt(s.fileUploadContexts.expiresAt, Date.now()), + ), + ) + .get(); + if (stored) return JSON.parse(stored.context); + const owner = this.details(file.storageKey), + context = owner + ? this.read(owner).events.find((event) => event.requestId === file.requestId) + : null; + if (context) this.saveFileContext(file, context); + return context ?? null; + } async cleanup() { + this.service.db + .delete(s.fileUploadContexts) + .where( + or( + lte(s.fileUploadContexts.expiresAt, Date.now()), + inArray( + s.fileUploadContexts.fileId, + this.service.db + .select({ id: s.files.id }) + .from(s.files) + .where(eq(s.files.status, 'failed')), + ), + ), + ) + .run(); for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const owner = this.details(entry.name + '/file'); diff --git a/apps/server/test/admin.test.js b/apps/server/test/admin.test.js index e4ca3cb..c3ed85e 100644 --- a/apps/server/test/admin.test.js +++ b/apps/server/test/admin.test.js @@ -76,6 +76,13 @@ test('only the verified active hello account can view and mutate admin APIs; gue } assert.equal((await f.call(admin, '/auth/me')).json().account.administrator, true); assert.equal((await f.call(other, '/auth/me')).json().account.administrator, false); + assert.deepEqual((await f.call(admin, '/admin/summary')).json().limits, { + perFileBytes: f.cfg.maxFileBytes, + accountBytes: f.cfg.userQuota, + deviceBytes: f.cfg.guestQuota, + devicesTotalBytes: f.cfg.totalGuestQuota, + minDiskFreeBytes: f.cfg.minFreeBytes, + }); assert.equal((await f.call(caseAlias, '/auth/me')).json().account.administrator, false); assert.equal((await patch(f, other, { uploadsPaused: true })).statusCode, 403); assert.equal( diff --git a/apps/server/test/display-format.test.js b/apps/server/test/display-format.test.js new file mode 100644 index 0000000..de2eec9 --- /dev/null +++ b/apps/server/test/display-format.test.js @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatSize, formatAdminDate } from '../../web/src/displayFormat.js'; + +test('shared display sizes use decimal KB below 1MB and switch only at MB/GB boundaries', () => { + for (const [bytes, expected] of [ + [0, '0KB'], + [10, '1KB 미만'], + [1000, '1KB'], + [938000, '938KB'], + [999000, '999KB'], + [1000000, '1.0MB'], + [1100000, '1.1MB'], + [1000000000, '1.0GB'], + [1700000000, '1.7GB'], + ]) + assert.equal(formatSize(bytes), expected); +}); + +test('admin dates include year and unambiguous Korea 24-hour time independent of host timezone', () => { + assert.equal(formatAdminDate(Date.parse('2026-10-22T02:02:00Z')), '2026년 10월 22일 11:02'); + assert.equal(formatAdminDate(Date.parse('2026-10-21T15:02:00Z')), '2026년 10월 22일 00:02'); + assert.equal(formatAdminDate(null), '확인 불가'); +}); diff --git a/apps/server/test/owner-info.test.js b/apps/server/test/owner-info.test.js index 09d591d..4dbae64 100644 --- a/apps/server/test/owner-info.test.js +++ b/apps/server/test/owner-info.test.js @@ -85,6 +85,22 @@ test('region records are NAS-only, deduplicated by request, bounded and cleaned source: 'cloudflare', }); assert.equal(json().recentLocations.length, 20); + const info = f.service.storage.layout.ownerInfo; + assert.equal( + info.fileLocation(row).city, + 'Daejeon', + 'newer uploads cannot replace this file location', + ); + info.record(row, id, { ip: '1.1.1.1', countryCode: 'JP', city: 'Tokyo' }); + assert.equal( + info.fileLocation(row).city, + 'Daejeon', + 'idempotent retries cannot overwrite the original snapshot', + ); + assert.equal(info.fileLocation(row).ip, '8.8.8.8'); + const publicRequest = await f.call(b, '/transfer-requests'); + assert.equal(publicRequest.body.includes('Daejeon'), false); + assert.equal(publicRequest.body.includes('8.8.8.8'), false); const response = await f.app.inject({ url: '/uploads/originals/' + row.storageKey.split('/')[0] + '/owner-info.json', }); @@ -102,6 +118,93 @@ test('region records are NAS-only, deduplicated by request, bounded and cleaned f.service.db.update(s.files).set({ status: 'failed' }).where(eq(s.files.id, row.id)).run(); await f.service.storage.layout.ownerInfo.cleanup(); assert.equal(existsSync(owner.path), false); + assert.equal(f.service.db.select().from(s.fileUploadContexts).all().length, 0); + } finally { + await f.close(); + } +}); + +test('each file keeps its own request location; legacy lookup matches its request and expired snapshots are removed', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + f.cfg.clientIpSource = 'cloudflare'; + async function upload(country, city, ip) { + const request = await f.call( + a, + '/transfer-requests', + 'POST', + { + targetDeviceId: b.deviceId, + kind: 'file', + count: 1, + byteSize: 4, + files: [{ name: 'test.txt', size: 4, type: 'text/plain' }], + }, + randomUUID(), + { 'cf-connecting-ip': ip, 'cf-ipcountry': country, 'cf-ipcity': city }, + ); + assert.equal(request.statusCode, 200, request.body); + const id = request.json().requestId; + await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()); + const result = await f.call( + a, + `/uploads?requestId=${id}&fileIndex=0`, + 'POST', + Buffer.from( + '--loc\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--loc--\r\n', + ), + randomUUID(), + { 'content-type': 'multipart/form-data; boundary=loc' }, + ); + assert.equal(result.statusCode, 200, result.body); + return f.service.storage.fileRows(id)[0]; + } + const first = await upload('KR', 'Daejeon', '8.8.8.8'), + second = await upload('JP', 'Tokyo', '1.1.1.1'), + info = f.service.storage.layout.ownerInfo; + const locations = new Map( + f.service.admin.files({}).items.map((file) => [file.id, file.lastLocation]), + ); + assert.equal(locations.get(first.id).city, 'Daejeon'); + assert.equal(locations.get(second.id).city, 'Tokyo'); + f.service.db + .delete(s.fileUploadContexts) + .where(eq(s.fileUploadContexts.fileId, first.id)) + .run(); + assert.equal( + info.fileLocation(first).city, + 'Daejeon', + 'recover only the matching legacy request', + ); + f.service.db + .delete(s.fileUploadContexts) + .where(eq(s.fileUploadContexts.fileId, first.id)) + .run(); + const owner = info.details(first.storageKey); + info.write( + owner, + info.read(owner).events.filter((event) => event.requestId !== first.requestId), + ); + assert.equal( + info.fileLocation(first), + null, + 'a missing old record must not use the newest location', + ); + f.service.db + .update(s.fileUploadContexts) + .set({ expiresAt: Date.now() - 1 }) + .run(); + const old = info.read(owner).events.map((event) => ({ + ...event, + recordedAt: new Date(Date.now() - 8 * 86400000).toISOString(), + })); + info.write(owner, old); + await info.cleanup(); + assert.equal(f.service.db.select().from(s.fileUploadContexts).all().length, 0); + assert.equal(info.fileLocation(second), null); } finally { await f.close(); } diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index cb6245c..e459c16 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -1,4 +1,5 @@