Use visible NAS upload storage and reconcile missing files

This commit is contained in:
2026-10-13 15:54:27 +09:00
parent 50e54bc4c5
commit 9e0f493228
26 changed files with 1075 additions and 119 deletions

View File

@@ -1,17 +1,41 @@
import { execFileSync } from 'node:child_process';
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
const suffix = randomUUID().slice(0, 8),
name = `hop-check-${suffix}`,
volume = `hop-check-db-${suffix}`,
uploads = `hop-check-files-${suffix}`;
const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published.
const bindUploads = process.env.HOP_CHECK_BIND_UPLOADS === '1';
const folder = bindUploads ? mkdtempSync(resolve(tmpdir(), 'hop-bind-files-')) : null;
const runtimeUser = bindUploads ? '12001:12001' : '1000:1000';
const docker = (...args) =>
execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code);
try {
docker('volume', 'create', volume);
docker('volume', 'create', uploads);
if (bindUploads) {
docker(
'run',
'--rm',
'--network',
'none',
'--user',
'0:0',
'-v',
`${volume}:/database`,
'--mount',
`type=bind,source=${folder},target=/uploads`,
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
'sh',
'-eu',
'-c',
`chown 12001:12001 /database /uploads; chmod 700 /database /uploads`,
);
} else docker('volume', 'create', uploads);
docker(
'run',
'-d',
@@ -19,6 +43,8 @@ try {
name,
'--network',
'none',
'--user',
runtimeUser,
'-e',
'NODE_ENV=production',
'-e',
@@ -31,10 +57,12 @@ try {
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
'-e',
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
'-v',
`${volume}:/data/database`,
'-v',
`${uploads}:/data/uploads`,
...(bindUploads
? ['--mount', `type=volume,source=${volume},target=/data/database,volume-nocopy`]
: ['-v', `${volume}:/data/database`]),
...(bindUploads
? ['--mount', `type=bind,source=${folder},target=/data/uploads`]
: ['-v', `${uploads}:/data/uploads`]),
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
);
const wait = () =>
@@ -80,9 +108,41 @@ try {
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
);
assert.equal(integrity, 'ok');
if (bindUploads) {
assert.equal(docker('inspect', '--format', '{{.Config.User}}', name), runtimeUser);
const mode = JSON.parse(
node(
`import {statSync} from 'node:fs';const s=statSync('/data/uploads/originals');console.log(JSON.stringify({uid:s.uid,gid:s.gid,mode:s.mode&511}));`,
),
);
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
const originals = resolve(folder, 'originals');
const stored = readdirSync(originals).find(
(key) => readFileSync(resolve(originals, key)).toString() === 'Docker file persistence',
);
assert(stored, 'uploaded file is visible in the host project directory');
unlinkSync(resolve(originals, stored));
const missing = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify({status:r.status,body:await r.json()}));`,
),
);
assert.equal(missing.status, 404);
assert.equal(missing.body.code, 'FILE_NOT_FOUND');
const visible = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});console.log(JSON.stringify((await r.json()).items));`,
),
);
assert.equal(
visible.some((item) => item.accessId === fileState.accessId),
false,
);
assert(visible.some((item) => item.file?.name === 'resume.bin'));
}
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
console.log(
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.',
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`,
);
} catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
@@ -92,8 +152,9 @@ try {
try {
docker('rm', '-f', name);
} catch {}
for (const v of [volume, uploads])
for (const v of [volume, ...(bindUploads ? [] : [uploads])])
try {
docker('volume', 'rm', v);
} catch {}
if (folder) rmSync(folder, { recursive: true, force: true });
}