Clarify storage limits and unify file metadata displays

This commit is contained in:
2026-10-15 11:23:33 +09:00
parent 3786baf60c
commit 471e2653d8
22 changed files with 401 additions and 84 deletions

View File

@@ -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`);

View File

@@ -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
}
]
}

View File

@@ -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 {

View File

@@ -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',
{

View File

@@ -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');

View File

@@ -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(

View File

@@ -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), '확인 불가');
});

View File

@@ -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();
}