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 @@
- 텍스트는 최대 {{ Math.floor(textLimitBytes / 1024) }}KiB까지 보낼 수 있어요.
+ 텍스트는 최대 {{ size(textLimitBytes) }}까지 보낼 수 있어요.
diff --git a/apps/web/src/displayFormat.js b/apps/web/src/displayFormat.js
new file mode 100644
index 0000000..71ef8bb
--- /dev/null
+++ b/apps/web/src/displayFormat.js
@@ -0,0 +1,24 @@
+export function formatSize(bytes) {
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`;
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`;
+ if (bytes >= 1000) return `${(bytes / 1000).toFixed(1).replace(/\.0$/, '')}KB`;
+ return bytes === 0 ? '0KB' : '1KB 미만';
+}
+
+export function formatAdminDate(value) {
+ if (!value) return '확인 불가';
+ const parts = Object.fromEntries(
+ new Intl.DateTimeFormat('ko-KR', {
+ timeZone: 'Asia/Seoul',
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ })
+ .formatToParts(new Date(value))
+ .map(({ type, value }) => [type, value]),
+ );
+ return `${parts.year}년 ${parts.month}월 ${parts.day}일 ${parts.hour}:${parts.minute}`;
+}
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 4edf64e..3133208 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -4090,6 +4090,24 @@ a.button {
grid-template-columns: 1fr 1fr;
gap: 18px;
}
+.admin-limit-details {
+ display: grid;
+ gap: 12px;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.6;
+}
+.admin-limit-details > div {
+ display: grid;
+ gap: 3px;
+}
+.admin-limit-details dt {
+ font-weight: 550;
+}
+.admin-limit-details dd {
+ margin: 0;
+ color: var(--muted);
+}
.admin-muted {
font-size: 14px;
line-height: 1.6;
diff --git a/deploy.md b/deploy.md
index 4048917..cec6992 100644
--- a/deploy.md
+++ b/deploy.md
@@ -240,7 +240,7 @@ NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps →
- **다운로드 실패·수신자 이탈:** 이미 저장된 원본은 바로 삭제되지 않습니다. 받은 항목에 저장된 파일은 로그인 후 만료 전에 다시 다운로드할 수 있습니다. 개인 용량 초과로 받은 항목에 저장되지 않았거나 비로그인으로 받은 경우, 사이트를 떠난 뒤 이전 다운로드를 다시 찾는 UI는 제공하지 않습니다. 이때는 다시 전송하는 방법을 사용합니다. 다운로드 시작 표시는 기기에 저장이 끝났다는 확인을 의미하지 않습니다.
- **업로드 도중 이탈:** 완료되지 않은 업로드는 받은 항목에 생성되지 않습니다. 취소된 조각은 제거하며, 남은 미완료·고아 업로드는 기본 24시간 후 정리합니다. 브라우저를 떠난 뒤 자동 전송·자동 다운로드 재개는 보장하지 않습니다.
- **만료·삭제:** 기본 보관 기간은 항목별 7일이며, 정리 작업은 약 1분 간격입니다. 같은 원본을 사용하는 유효 접근 권한이 하나라도 남아 있으면 유지합니다. 모두 만료·삭제되면 삭제 실행 취소 시간 이후 원본을 제거합니다. 재배포·재시작만으로 파일이 삭제되지는 않습니다.
-- **관리자 접근:** NAS·Docker 볼륨과 DB를 읽을 권한이 있는 관리자는 파일과 연결 정보를 확인할 수 있습니다. 종단 간 암호화나 파일 자체 암호화는 적용하지 않습니다. 앱의 서비스 관리 화면에서는 인증된 `hello@noname.company`만 파일 메타데이터와 최근 지역/IP를 조회·원본 삭제할 수 있습니다. 파일 내용을 내려받는 관리자 API는 제공하지 않으며 일반 계정은 허용된 본인 항목만 조회할 수 있습니다.
+- **관리자 접근:** NAS·Docker 볼륨과 DB를 읽을 권한이 있는 관리자는 파일과 연결 정보를 확인할 수 있습니다. 종단 간 암호화나 파일 자체 암호화는 적용하지 않습니다. 앱의 서비스 관리 화면에서는 인증된 `hello@noname.company`만 파일 메타데이터와 파일별 접속 지역/IP를 조회·원본 삭제할 수 있습니다. 파일 내용을 내려받는 관리자 API는 제공하지 않으며 일반 계정은 허용된 본인 항목만 조회할 수 있습니다.
개인 한도가 차도 **수신·다운로드는 계속**합니다. 새로 받은 파일은 받은 항목에 저장하지 않고 짧은 안내를 표시합니다. 직접 저장은 용량 부족 시 거절합니다. 링크·메모는 파일 용량 합계에 포함하지 않습니다. 기존 10GiB 기본값은 설정 스크립트가 2GiB로 바꾸며, 다른 값으로 조정한 설정과 기존 자료는 유지합니다.
@@ -479,9 +479,9 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
이메일 인증을 완료한 `hello@noname.company` 계정으로 로그인한 뒤 **더보기 → 관리자**를 선택합니다. 별도의 관리자 비밀번호나 환경변수 없이 이 정확한 활성·인증 계정만 권한을 갖습니다. 서버에서도 권한을 검사하며 다른 일반 계정에는 메뉴와 API를 제공하지 않습니다.
-저장 파일의 업로드 주체·최근 제공 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 해당 업로드 주체의 최근 기록이며 모든 파일마다 정확한 위치를 추적한 정보는 아닙니다.
+저장 파일의 업로드 주체·파일별 접속 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 각 파일의 최초 전송 요청 당시 전달된 IP 기반 추정 정보입니다. 이후 다른 파일을 올린 지역으로 덮어쓰지 않습니다. 같은 요청의 여러 파일에는 같은 정보를 사용합니다.
-**접속 · 용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도 1~1024GiB를 변경합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 기존 개인·기기 용량 제한, 파일당 크기 제한, 최소 5GiB 디스크 여유와 전송 요청 제한도 유지합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
+**접속 · 용량 설정**에서 동시 업로드 1~16개와 전체 파일 저장 한도를 변경합니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 기존 개인·기기 용량 제한, 파일당 크기 제한, 최소 5GiB 디스크 여유와 전송 요청 제한도 유지합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
**신규 업로드 중지**는 새 파일 전송·임시 저장을 거절합니다. 중지 전에 수락한 파일, 이미 저장된 원본의 재전송·다운로드, 링크·텍스트 전송은 계속됩니다. 한도를 낮춰도 기존 자료를 자동 삭제하지 않습니다.
@@ -497,3 +497,13 @@ sudo docker compose -f docker-compose.yaml up -d --build
```
앱의 업로드 제한과 차단은 저장 공간·동시 작업을 보호하지만 앱에 도달하기 전의 대규모 트래픽 공격까지 해결하지는 않습니다. 필요하면 Cloudflare 등 앞단의 접근·요청 제한도 함께 적용합니다.
+
+## 파일별 접속 정보와 공통 표시 업데이트
+
+`file_upload_contexts` 테이블을 자동 추가하여 각 원본 파일의 최초 전송 요청 정보를 보관합니다. 새 요청을 다른 IP/지역에서 보내거나 재시도해도 기존 파일의 정보는 바뀌지 않습니다. 기존 파일은 소유자 정보의 동일 요청 ID 기록이 있을 때만 복구하며 다른 요청의 최신 지역은 사용하지 않습니다. 기록은 기본 최대 7일이고 파일 실패·삭제 시에도 약 1분 주기 정리에서 제거합니다. 관리자 외의 전송·보관함 API에는 노출하지 않습니다.
+
+관리자 이름은 대괄호로 감싸고, 만료일은 관리자·보관함 모두 `[보관 만료] 2026년 10월 22일 11:02 까지` 형식의 한국 시간으로 표시합니다. 푸터의 발견 허용 문구는 숨깁니다. 용량은 모든 화면에서 KB/MB/GB(1,000 단위)로 통일하며 실제 파일 한도·개인별 한도·서버 전체 한도는 그대로입니다. `.env` 용량 값의 MiB 기준은 유지하고 관리자 화면의 전체 용량 입력은 GB로 환산합니다. 반올림된 GB를 수정하지 않았다면 다른 설정을 저장해도 원래 한도를 보존합니다.
+
+Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다.
+
+관리자 한도 설명에는 실제 서버의 계정별 보관함·기기 세션별 업로드·기기 세션 전체 업로드·파일당 크기·NAS 최소 여유 공간을 각각 표시합니다. 전체 파일 한도는 모두가 공유하며 개인에게 같은 양을 배정하지 않습니다. 기본값 2GiB는 약 2.1GB, 최소 여유 5GiB는 약 5.4GB, 전체 20GiB는 약 21.5GB로 표시됩니다. 이 값은 각 NAS의 기존 `.env`와 저장된 관리 설정을 따르며 이번 표시 변경으로 값을 재설정하지 않습니다.
diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md
index 7ba8637..7d04f09 100644
--- a/docs/VERIFICATION.md
+++ b/docs/VERIFICATION.md
@@ -191,3 +191,11 @@
- 설정 변경은 DB 트랜잭션과 감사 이력으로 저장하고 변경 버전 충돌을 거절한다. DB 오류 롤백 뒤 적용 중 정책 보존과 별도 인스턴스 재로드, 관리 기록 30일 정리를 확인했다. 업로드 주체 정보는 마지막 원본/대기 업로드가 사라지면 제거하고 빈 사용자 폴더도 정리한다.
- API 123개·추가 페이지 이동/별도 계정 격리를 포함한 관리자 9개·전체 타입 검사와 빌드·포맷 검사를 통과했다. 실제 Chromium에서 관리자 전용 메뉴·일반 계정 API 403·지역/IP 표시·일시 중지/재개·규칙 저장·파일 삭제 확인과 이력·390px 모바일 배치/16px 입력·로그아웃 후 권한 제거를 확인했다. 기존 파일/링크 전송·보관함·주변 위치 검색·첫 파일 전송 재접속 브라우저 회귀도 통과했다.
- 최종 운영 Docker 이미지에서 추가 관리 테이블의 자동 마이그레이션, 관리자 인증과 설정 재시작 보존, 기존 파일/텍스트·세션·인증·8MiB 조각 재개·SQLite integrity·사용자별 경로·호스트 파일 수동 삭제를 확인했다. 격리 자원만 사용하고 종료 후 제거했다. `docker-compose.yaml`과 실제 NAS 컨테이너·볼륨·자료·Cloudflare 규칙은 변경하거나 재배포하지 않았다. 도시 정보는 Cloudflare 전달 설정이 있어야 제공되며 대규모 네트워크 공격은 별도 앞단 대응이 필요하다.
+
+## 2026-10-15 관리자 표시·파일별 접속 기록·용량 표기 통일
+
+- 관리자 업로드 주체 이름을 `[iPhone]`처럼 감싸고 관리자·보관함 만료일을 `[보관 만료] 2026년 10월 22일 11:02 까지` 형식으로 통일했다. 한국 시간·연도·24시간을 명시하며 자정의 00시와 실행 호스트 시간대 독립성을 검증했다. 푸터의 발견 허용/꺼짐 문구를 제거하고 자동 발견과 기기 이름·코드는 유지했다.
+- 이전에 소유자 최신 지역을 표시하던 동작을 파일별 최초 요청의 지역/IP로 수정했다. 추가 테이블 `file_upload_contexts`에 파일별 스냅샷을 저장한다. 동일 기기의 대전/도쿄 두 실제 업로드가 각자 다른 정보를 유지하며, 재시도나 최근 20개 요약 교체로 이전 파일이 변경되지 않음을 확인했다. 동일 요청의 여러 파일은 같은 요청 정보를 사용한다. 기존 기록은 요청 ID가 일치할 때만 복원하고 없으면 확인 불가다. 기록의 기본 7일 만료·실패 파일 정리·일반 수신 API에 지역/IP 미노출을 확인했다.
+- 전송 선택·수신 요청·보관함·관리자·텍스트 한도 안내를 공통 KB/MB/GB(1,000 단위) 표시로 바꿨다. 1MB 미만은 KB, 1MB/1GB 경계에서만 단위를 바꾸며 `938KB`, `1.1MB`, `1.7GB`로 표시한다. 0은 0KB, 1KB 미만은 별도로 안내한다. 저장 한도와 환경변수의 기존 MiB 기준은 유지하고, 관리자 전체 용량 입력은 GB로 환산했다. 반올림된 표시를 수정하지 않고 다른 규칙만 저장해도 원래 설정 용량이 바뀌지 않는 브라우저 검증을 추가했다.
+- 관리자에게 계정별 보관함·기기 세션별 업로드·기기 전체 업로드·파일당 한도·NAS 최소 여유 공간의 실제 서버 설정값을 제공한다. 전체 한도는 사용자 모두의 원본·예약 합계이며 개인 배정량이 아님을 설명한다. 한도 부족 시 새 업로드 거절, 보관함이 가득 차면 수신/다운로드 후 기록 생략, 기존 자료 유지도 구분했다.
+- API 126개와 한도 API 검증을 포함한 관리자 9개, 전체 빌드·타입 검사·포맷 검사를 통과했다. 실제 Chromium의 관리자 PC/390px 모바일·이름 대괄호/만료일·수신 KB/MB/GB·보관함·12기기 레이아웃/푸터 회귀 검증을 통과했다. 운영 Docker에서 새 테이블 마이그레이션과 재시작 후 파일별 접속 기록, 관리자 설정, 파일/Unicode 텍스트·계정 세션·8MiB 조각 재개·SQLite integrity·바인드 파일 수동 삭제를 검증했다. 격리 테스트 자원만 사용했으며 NAS 운영 자료·Compose 파일은 변경하거나 재배포하지 않았다.
diff --git a/tests/docker/persistence.mjs b/tests/docker/persistence.mjs
index 376b9c5..d9f9f1b 100644
--- a/tests/docker/persistence.mjs
+++ b/tests/docker/persistence.mjs
@@ -151,6 +151,12 @@ try {
);
assert.equal(settings.maxConcurrentUploads, 3);
assert.deepEqual(settings.blockedCountries, ['CN']);
+ const contexts = JSON.parse(
+ node(
+ `import Database from 'better-sqlite3'; const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify(db.prepare('SELECT count(*) AS count FROM file_upload_contexts').get()));db.close();`,
+ ),
+ );
+ assert.ok(contexts.count > 0, 'per-file upload location snapshots survive restart');
}
const preservedText = JSON.parse(
node(
diff --git a/tests/e2e/admin.mjs b/tests/e2e/admin.mjs
index 47760c7..fdf1a5c 100644
--- a/tests/e2e/admin.mjs
+++ b/tests/e2e/admin.mjs
@@ -150,20 +150,42 @@ try {
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
assert.match(await admin.locator('.admin-file').innerText(), /중국 · 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').innerText(), /접속 지역 · 중국 · Beijing/);
+ assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/);
+ assert.match(
+ await admin.locator('.admin-file footer small').innerText(),
+ /^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/,
+ );
await admin.getByRole('button', { name: '신규 업로드 중지', exact: true }).click();
await admin.getByText('신규 업로드 중지됨', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.uploadsPaused, true);
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/,
+ );
+ 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',
+ );
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
- await admin.getByLabel('전체 파일 저장 한도 (GiB)').fill('16');
+ await admin.getByLabel('전체 파일 저장 한도 (GB)').fill('16');
await admin.getByLabel('차단할 국가 코드').fill('CN');
await admin.getByLabel('차단할 IP 또는 IP 대역').fill('9.9.9.0/24');
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, 16384);
+ assert.equal(runtime.service.admin.state.totalStorageLimitMb, Math.round(16e9 / 1048576));
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await sender.evaluate(async () => (await fetch('/api/device/me')).status), 403);
await admin.getByRole('button', { name: '저장 파일', exact: true }).click();
diff --git a/tests/e2e/layout.mjs b/tests/e2e/layout.mjs
index 70a19fc..b37fd83 100644
--- a/tests/e2e/layout.mjs
+++ b/tests/e2e/layout.mjs
@@ -414,9 +414,22 @@ try {
return { deviceId: session.device.id, ok: response.ok };
});
assert.equal(oldSession.ok, true);
- await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor();
+ await page.waitForFunction(
+ () =>
+ !document
+ .querySelector('.footer-device-summary .device-state-dot')
+ ?.classList.contains('enabled'),
+ );
await page.reload();
- await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
+ await page.waitForFunction(() =>
+ document
+ .querySelector('.footer-device-summary .device-state-dot')
+ ?.classList.contains('enabled'),
+ );
+ assert.doesNotMatch(
+ await page.locator('.footer-device-summary').innerText(),
+ /발견 허용|발견 꺼짐/,
+ );
assert.equal(await page.locator('.footer-device-name').innerText(), '나의 테스트 기기');
assert.equal(
await page.evaluate(async () => (await (await fetch('/api/device/snapshot')).json()).device.id),
diff --git a/tests/e2e/phase3.mjs b/tests/e2e/phase3.mjs
index ac532ce..c718440 100644
--- a/tests/e2e/phase3.mjs
+++ b/tests/e2e/phase3.mjs
@@ -247,7 +247,7 @@ try {
await sender.locator('.item-card').filter({ hasText: '나중에 읽을 글' }).waitFor();
assert.match(
await sender.locator('.item-footer > span').first().innerText(),
- /^보관 만료 · .+까지$/,
+ /^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/,
);
await sender.getByRole('button', { name: '메모 수정', exact: true }).click();
await sender.getByLabel('한 줄 메모 수정').fill('업무 링크');
diff --git a/tests/e2e/receive-controls.mjs b/tests/e2e/receive-controls.mjs
index fdac5a4..b117b02 100644
--- a/tests/e2e/receive-controls.mjs
+++ b/tests/e2e/receive-controls.mjs
@@ -112,7 +112,7 @@ try {
assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0);
if (kind === 'file') {
await expect(card.locator('.request-file-size')).toHaveText(
- size === 1700000000 ? '1.7 GB' : size === 100000 ? '0.1 MB' : '1.3 MB',
+ size === 1700000000 ? '1.7GB' : size === 100000 ? '100KB' : '1.3MB',
);
const spacing = await card.locator('.request-files').evaluate((el) => ({
padding: getComputedStyle(el).paddingLeft,
@@ -196,7 +196,7 @@ try {
assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0);
assert.deepEqual(errors, []);
console.log(
- 'PASS: content icons/titles, desktop/mobile 2:1 buttons, single file sizes 0.1/1.3MB and 1.7GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.',
+ 'PASS: content icons/titles, desktop/mobile 2:1 buttons, consistent single file sizes 100KB/1.3MB/1.7GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.',
);
} finally {
await browser?.close();