Separate completed transfers and retain original file extensions
This commit is contained in:
@@ -55,7 +55,9 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
|
||||
|
||||
## 파일 저장 위치
|
||||
|
||||
NAS의 파일 원본은 `/volume1/docker/projects/apps/hop/uploads/originals`에 저장합니다. 앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 원본 수동 삭제는 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 단일 Compose 파일로 전환하는 순서는 [deploy.md](deploy.md)의 7-0단계를 따르며 기존 계정·DB·파일은 유지합니다.
|
||||
NAS 파일은 `/volume1/docker/projects/apps/hop/uploads`에 저장합니다. `originals`에는 보관함에 연결된 파일과 아직 다운로드를 완료하지 않은 파일을 두고, 보관함에 연결되지 않은 파일은 수신자에게 다운로드 응답을 끝낸 뒤 `completed`로 이동합니다. 기본 7일 만료일과 용량 계산은 그대로 유지하며 이동으로 만료일을 연장하지 않습니다. 물리 파일명은 `무작위 식별자.jpg`처럼 원래 확장자를 붙이고, 다운로드 시에는 원래 파일명을 제공합니다. 확장자 없는 기존 파일도 앱 시작 시 가능한 경우 자동 반영합니다. 이전 버전의 다운로드 완료 여부는 소급 확인할 수 없어 기존 파일은 다시 다운로드할 때 분류합니다.
|
||||
|
||||
앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 두 폴더 모두 수동 삭제를 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 단일 Compose 파일로 전환하는 순서는 [deploy.md](deploy.md)의 7-0단계를 따르며 기존 계정·DB·파일은 유지합니다.
|
||||
|
||||
## 업데이트와 백업
|
||||
|
||||
|
||||
@@ -623,7 +623,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
app.get('/api/access/:id/download', async (req, reply) => {
|
||||
const accessId = id(req),
|
||||
g = accessSubject(req, accessId);
|
||||
const { file, stream } = await service.storage.openDownload(g, accessId);
|
||||
const { file, stream, access } = await service.storage.openDownload(g, accessId);
|
||||
const fallback =
|
||||
file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download';
|
||||
const encoded = encodeURIComponent(file.name).replace(
|
||||
@@ -643,6 +643,15 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
stream.destroy();
|
||||
throw error;
|
||||
}
|
||||
reply.raw.once('finish', () => {
|
||||
if (reply.statusCode === 200 && stream.readableEnded && access.role === 'recipient') {
|
||||
try {
|
||||
service.storage.layout.downloaded(file.id);
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'Completed file relocation will be retried');
|
||||
}
|
||||
}
|
||||
});
|
||||
return reply.send(stream);
|
||||
});
|
||||
app.get('/api/access/:id/preview', async (req, reply) =>
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, gt, inArray } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { fail } from './security.js';
|
||||
|
||||
export class FileAvailability {
|
||||
constructor(service, directory) {
|
||||
constructor(service, filePath) {
|
||||
this.service = service;
|
||||
this.directory = directory;
|
||||
this.filePath = filePath;
|
||||
this.pending = new Set();
|
||||
}
|
||||
check(file) {
|
||||
try {
|
||||
if (statSync(resolve(this.directory, file.storageKey)).isFile()) return true;
|
||||
if (statSync(this.filePath(file)).isFile()) return true;
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ENOTDIR'].includes(error.code)) {
|
||||
try {
|
||||
if (this.service.storage.layout.recover(file)) return this.check(file);
|
||||
} catch {
|
||||
fail(
|
||||
503,
|
||||
'STORAGE_UNAVAILABLE',
|
||||
'파일 저장소에 접근할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!['ENOENT', 'ENOTDIR'].includes(error.code))
|
||||
fail(
|
||||
503,
|
||||
|
||||
213
apps/server/src/fileLayout.js
Normal file
213
apps/server/src/fileLayout.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { basename, extname, resolve } from 'node:path';
|
||||
import { and, eq, gt, inArray, isNotNull, sql } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
|
||||
export function fileExtension(name) {
|
||||
const extension = extname(name).toLowerCase();
|
||||
return /^\.[a-z0-9]{1,16}$/.test(extension) ? extension : '';
|
||||
}
|
||||
export function newStorageKey(name) {
|
||||
return randomUUID() + fileExtension(name);
|
||||
}
|
||||
|
||||
export class FileLayout {
|
||||
constructor(service, canMove) {
|
||||
this.service = service;
|
||||
this.canMove = canMove;
|
||||
this.originals = resolve(service.cfg.uploadDir, 'originals');
|
||||
this.completed = resolve(service.cfg.uploadDir, 'completed');
|
||||
this.journal = resolve(service.cfg.uploadDir, '.file-moves');
|
||||
this.pending = new Set();
|
||||
for (const directory of [this.originals, this.completed, this.journal])
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
path(file) {
|
||||
const current = this.service.db
|
||||
.select({ key: s.files.storageKey })
|
||||
.from(s.files)
|
||||
.where(eq(s.files.id, file.id))
|
||||
.get();
|
||||
return this.keyPath(current?.key ?? file.storageKey);
|
||||
}
|
||||
keyPath(key) {
|
||||
const name = key.startsWith('completed/') ? key.slice('completed/'.length) : key;
|
||||
if (!name || basename(name) !== name || ['.', '..'].includes(name))
|
||||
throw Error('Invalid stored file key');
|
||||
return resolve(key.startsWith('completed/') ? this.completed : this.originals, name);
|
||||
}
|
||||
hasAccountReference(id) {
|
||||
const { db, cfg } = this.service;
|
||||
return Boolean(
|
||||
db
|
||||
.select({ id: s.access.id })
|
||||
.from(s.items)
|
||||
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
|
||||
.where(
|
||||
and(
|
||||
eq(s.items.fileId, id),
|
||||
isNotNull(s.access.userId),
|
||||
inArray(s.access.role, ['self', 'recipient']),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${Date.now() - cfg.undoSeconds * 1000})`,
|
||||
),
|
||||
)
|
||||
.get(),
|
||||
);
|
||||
}
|
||||
recover(file) {
|
||||
const journalPath = resolve(this.journal, file.id + '.json');
|
||||
if (!existsSync(journalPath)) return false;
|
||||
const entry = JSON.parse(readFileSync(journalPath, 'utf8'));
|
||||
if (entry.fileId !== file.id || entry.checksum !== file.checksum || entry.size !== file.size)
|
||||
throw Error('Invalid file relocation journal');
|
||||
const source = this.keyPath(entry.from),
|
||||
target = this.keyPath(entry.to);
|
||||
if (![entry.from, entry.to].includes(file.storageKey))
|
||||
throw Error('File relocation key changed');
|
||||
if (existsSync(file.storageKey === entry.from ? source : target)) {
|
||||
unlinkSync(journalPath);
|
||||
return false;
|
||||
}
|
||||
const recoveredKey = file.storageKey === entry.from ? entry.to : entry.from;
|
||||
const recoveredPath = this.keyPath(recoveredKey);
|
||||
if (existsSync(recoveredPath)) {
|
||||
const metadata = statSync(recoveredPath);
|
||||
if (!metadata.isFile() || metadata.size !== file.size)
|
||||
throw Error('Incomplete file relocation');
|
||||
this.service.db
|
||||
.update(s.files)
|
||||
.set({ storageKey: recoveredKey })
|
||||
.where(and(eq(s.files.id, file.id), eq(s.files.storageKey, file.storageKey)))
|
||||
.run();
|
||||
unlinkSync(journalPath);
|
||||
return true;
|
||||
}
|
||||
unlinkSync(journalPath);
|
||||
return false;
|
||||
}
|
||||
move(file, key) {
|
||||
if (key === file.storageKey || !this.canMove(file.id)) return;
|
||||
const destination = key;
|
||||
if (existsSync(this.keyPath(destination)))
|
||||
throw Error('File relocation destination already exists');
|
||||
const source = this.keyPath(file.storageKey),
|
||||
target = this.keyPath(destination);
|
||||
let moved = false;
|
||||
const journalPath = resolve(this.journal, file.id + '.json');
|
||||
writeFileSync(
|
||||
journalPath,
|
||||
JSON.stringify({
|
||||
fileId: file.id,
|
||||
checksum: file.checksum,
|
||||
size: file.size,
|
||||
from: file.storageKey,
|
||||
to: destination,
|
||||
}),
|
||||
{ mode: 0o600, flag: 'wx', flush: true },
|
||||
);
|
||||
try {
|
||||
this.service.db.transaction(() => {
|
||||
renameSync(source, target);
|
||||
moved = true;
|
||||
const updated = this.service.db
|
||||
.update(s.files)
|
||||
.set({ storageKey: destination })
|
||||
.where(
|
||||
and(
|
||||
eq(s.files.id, file.id),
|
||||
eq(s.files.storageKey, file.storageKey),
|
||||
inArray(s.files.status, ['stored', 'cleaning']),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
if (!updated.changes) throw Error('Stored file changed during relocation');
|
||||
});
|
||||
} catch (error) {
|
||||
if (moved && !existsSync(source)) {
|
||||
try {
|
||||
renameSync(target, source);
|
||||
} catch {}
|
||||
}
|
||||
if (existsSync(source)) {
|
||||
try {
|
||||
unlinkSync(journalPath);
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// A journal cleanup failure must never roll back a committed database/file move.
|
||||
try {
|
||||
unlinkSync(journalPath);
|
||||
} catch {}
|
||||
}
|
||||
prepare() {
|
||||
for (const file of this.service.db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(inArray(s.files.status, ['stored', 'cleaning']))
|
||||
.all()) {
|
||||
try {
|
||||
this.recover(file);
|
||||
const current = this.service.db.select().from(s.files).where(eq(s.files.id, file.id)).get();
|
||||
const name = basename(current.storageKey),
|
||||
extension = fileExtension(current.name);
|
||||
if (extension && !extname(name) && existsSync(this.keyPath(current.storageKey)))
|
||||
this.move(
|
||||
current,
|
||||
(current.storageKey.startsWith('completed/') ? 'completed/' : '') + name + extension,
|
||||
);
|
||||
} catch {
|
||||
// Naming is cosmetic: keep the old key and let availability checks report storage errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
downloaded(id) {
|
||||
this.pending.add(id);
|
||||
this.flush(id);
|
||||
}
|
||||
flush(id) {
|
||||
if (!this.pending.has(id) || !this.canMove(id)) return;
|
||||
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
|
||||
if (!file || file.status !== 'stored') {
|
||||
this.pending.delete(id);
|
||||
return;
|
||||
}
|
||||
const directory = this.hasAccountReference(id) ? '' : 'completed/';
|
||||
this.move(file, directory + basename(file.storageKey));
|
||||
this.pending.delete(id);
|
||||
}
|
||||
reconcile() {
|
||||
for (const file of this.service.db
|
||||
.selectDistinct({ id: s.files.id })
|
||||
.from(s.files)
|
||||
.innerJoin(s.items, eq(s.items.fileId, s.files.id))
|
||||
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
|
||||
.where(
|
||||
and(
|
||||
eq(s.files.status, 'stored'),
|
||||
sql`${s.files.storageKey} LIKE 'completed/%'`,
|
||||
isNotNull(s.access.userId),
|
||||
inArray(s.access.role, ['self', 'recipient']),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${Date.now() - this.service.cfg.undoSeconds * 1000})`,
|
||||
),
|
||||
)
|
||||
.all())
|
||||
this.pending.add(file.id);
|
||||
for (const id of this.pending) {
|
||||
try {
|
||||
this.flush(id);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { Guest, Service } from './service.js';
|
||||
import * as s from './db/schema.js';
|
||||
import { ApiError, fail } from './security.js';
|
||||
import { FileAvailability } from './fileAvailability.js';
|
||||
import { FileLayout, newStorageKey } from './fileLayout.js';
|
||||
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
|
||||
export class Storage {
|
||||
private active = new Map<
|
||||
@@ -20,6 +21,8 @@ export class Storage {
|
||||
>();
|
||||
private cleaning = false;
|
||||
private cleanupPromise: Promise<void> | undefined;
|
||||
private readers = new Map<string, number>();
|
||||
readonly layout: FileLayout;
|
||||
readonly temp: string;
|
||||
readonly originals: string;
|
||||
readonly availability: FileAvailability;
|
||||
@@ -28,7 +31,11 @@ export class Storage {
|
||||
this.originals = resolve(service.cfg.uploadDir, 'originals');
|
||||
mkdirSync(this.temp, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(this.originals, { recursive: true, mode: 0o700 });
|
||||
this.availability = new FileAvailability(service, this.originals);
|
||||
this.layout = new FileLayout(service, (id: string) => !this.readers.has(id));
|
||||
this.layout.prepare();
|
||||
this.availability = new FileAvailability(service, (file: typeof s.files.$inferSelect) =>
|
||||
this.layout.path(file),
|
||||
);
|
||||
}
|
||||
private request(g: StorageSubject, id: string) {
|
||||
return g.kind === 'user'
|
||||
@@ -68,7 +75,7 @@ export class Storage {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
storageKey: randomUUID(),
|
||||
storageKey: newStorageKey(file.name),
|
||||
status: 'pending',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
@@ -434,14 +441,29 @@ export class Storage {
|
||||
if (row.item.kind !== 'file' || !row.item.fileId)
|
||||
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
|
||||
const file = this.availability.require(row.item.fileId!);
|
||||
return { file, path: resolve(this.originals, file.storageKey) };
|
||||
return { file, path: this.layout.path(file), access: row.access };
|
||||
}
|
||||
private beginRead(id: string) {
|
||||
this.readers.set(id, (this.readers.get(id) ?? 0) + 1);
|
||||
}
|
||||
private endRead(id: string) {
|
||||
const remaining = (this.readers.get(id) ?? 1) - 1;
|
||||
if (remaining) this.readers.set(id, remaining);
|
||||
else this.readers.delete(id);
|
||||
try {
|
||||
this.layout.flush(id);
|
||||
} catch {}
|
||||
}
|
||||
async openDownload(g: StorageSubject, accessId: string) {
|
||||
const { file, path } = this.download(g, accessId);
|
||||
const { file, path, access } = this.download(g, accessId);
|
||||
this.beginRead(file.id);
|
||||
try {
|
||||
const handle = await open(path, 'r');
|
||||
return { file, stream: handle.createReadStream() };
|
||||
const stream = handle.createReadStream();
|
||||
stream.once('close', () => this.endRead(file.id));
|
||||
return { file, stream, access };
|
||||
} catch (error) {
|
||||
this.endRead(file.id);
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
@@ -463,7 +485,8 @@ export class Storage {
|
||||
}
|
||||
}
|
||||
async preview(g: StorageSubject, id: string) {
|
||||
const { path } = this.download(g, id);
|
||||
const { file, path } = this.download(g, id);
|
||||
this.beginRead(file.id);
|
||||
try {
|
||||
const handle = await open(path, 'r');
|
||||
const magic = Buffer.alloc(16);
|
||||
@@ -491,6 +514,8 @@ export class Storage {
|
||||
.toBuffer();
|
||||
} catch {
|
||||
fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.');
|
||||
} finally {
|
||||
this.endRead(file.id);
|
||||
}
|
||||
}
|
||||
deleteAccess(g: StorageSubject, ids: string[]) {
|
||||
@@ -583,6 +608,7 @@ export class Storage {
|
||||
this.service.resumable?.cleanup();
|
||||
const { db, cfg } = this.service,
|
||||
now = Date.now();
|
||||
this.layout.reconcile();
|
||||
const expired = db
|
||||
.select()
|
||||
.from(s.reservations)
|
||||
@@ -622,7 +648,7 @@ export class Storage {
|
||||
if (refs.length) continue;
|
||||
db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run();
|
||||
try {
|
||||
await unlink(resolve(this.originals, file.storageKey));
|
||||
await unlink(this.layout.path(file));
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
|
||||
continue;
|
||||
@@ -640,11 +666,38 @@ export class Storage {
|
||||
.all()
|
||||
.map((f) => f.storageKey),
|
||||
);
|
||||
for (const name of await readdir(this.originals)) {
|
||||
if (validKeys.has(name)) continue;
|
||||
const path = resolve(this.originals, name),
|
||||
metadata = await stat(path);
|
||||
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
|
||||
for (const [directory, prefix] of [
|
||||
[this.originals, ''],
|
||||
[this.layout.completed, 'completed/'],
|
||||
]) {
|
||||
for (const name of await readdir(directory)) {
|
||||
const key = prefix + name;
|
||||
if (validKeys.has(key)) continue;
|
||||
const path = resolve(directory, name);
|
||||
try {
|
||||
const metadata = await stat(path);
|
||||
if (!metadata.isFile() || metadata.mtimeMs >= now - cfg.orphanHours * 3600000) continue;
|
||||
if (
|
||||
db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(
|
||||
and(eq(s.files.storageKey, key), inArray(s.files.status, ['stored', 'cleaning'])),
|
||||
)
|
||||
.get()
|
||||
)
|
||||
continue;
|
||||
await unlink(path).catch(() => {});
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
!['ENOENT', 'ENOTDIR'].includes(String(error.code))
|
||||
)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of await readdir(this.temp)) {
|
||||
if (this.active.has(name)) continue;
|
||||
|
||||
393
apps/server/test/file-layout.test.js
Normal file
393
apps/server/test/file-layout.test.js
Normal file
@@ -0,0 +1,393 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { get } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
|
||||
async function login(f, c) {
|
||||
const id = randomUUID(),
|
||||
email = `${id}@hop.test`;
|
||||
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 r = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
c.cookie += '; ' + String(r.headers['set-cookie']).split(';')[0];
|
||||
return id;
|
||||
}
|
||||
async function file(f, a, b, name = '사진.JPG') {
|
||||
const bytes = Buffer.from('unchanged file bytes');
|
||||
await f.space(a, b);
|
||||
const r = await f.call(
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: b.deviceId,
|
||||
kind: 'file',
|
||||
count: 1,
|
||||
byteSize: bytes.length,
|
||||
files: [{ name, size: bytes.length, type: 'application/octet-stream' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(r.statusCode, 200, r.body);
|
||||
const id = r.json().requestId;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
);
|
||||
const boundary = 'layout-upload',
|
||||
body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
|
||||
),
|
||||
bytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
const upload = await f.call(
|
||||
a,
|
||||
`/uploads?requestId=${id}&fileIndex=0`,
|
||||
'POST',
|
||||
body,
|
||||
randomUUID(),
|
||||
{ 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
);
|
||||
assert.equal(upload.statusCode, 200, upload.body);
|
||||
const row = f.service.storage.fileRows(id)[0];
|
||||
const item = (await f.call(b, '/device/items')).json().items.find((i) => i.file?.id === row.id);
|
||||
return { row, item, bytes, id };
|
||||
}
|
||||
function current(f, row) {
|
||||
return f.service.db.select().from(s.files).where(eq(s.files.id, row.id)).get();
|
||||
}
|
||||
async function archived(f, row) {
|
||||
// The HTTP response can finish before the file descriptor's asynchronous close.
|
||||
for (let i = 0; i < 100 && !current(f, row).storageKey.startsWith('completed/'); i++)
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(current(f, row).storageKey, `completed/${row.storageKey}`);
|
||||
}
|
||||
|
||||
test('logged-in sender to anonymous recipient: extension kept, completed after download, seven-day expiry and re-download unchanged', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await login(f, a);
|
||||
const value = await file(f, a, b),
|
||||
row = value.row;
|
||||
assert.match(row.storageKey, /^[a-f0-9-]{36}\.jpg$/);
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
||||
const expiry = f.service.db
|
||||
.select()
|
||||
.from(s.items)
|
||||
.where(eq(s.items.fileId, row.id))
|
||||
.get().expiresAt;
|
||||
assert.equal(existsSync(resolve(f.service.storage.originals, row.storageKey)), true);
|
||||
const downloaded = await f.call(b, `/access/${value.item.accessId}/download`);
|
||||
assert.equal(downloaded.statusCode, 200);
|
||||
assert.deepEqual(downloaded.rawPayload, value.bytes);
|
||||
await archived(f, row);
|
||||
const stored = current(f, row);
|
||||
assert.equal(stored.storageKey, `completed/${row.storageKey}`);
|
||||
assert.equal(existsSync(resolve(f.service.storage.originals, row.storageKey)), false);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes);
|
||||
assert.equal(stored.checksum, row.checksum);
|
||||
assert.equal(stored.updatedAt, row.updatedAt);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.items).where(eq(s.items.fileId, row.id)).get().expiresAt,
|
||||
expiry,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload,
|
||||
value.bytes,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('anonymous sender to signed-in recipient: received-account reference keeps the file in originals', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await login(f, b);
|
||||
const value = await file(f, a, b, 'received.pdf');
|
||||
const downloaded = await f.call(b, `/access/${value.item.accessId}/download`);
|
||||
assert.equal(downloaded.statusCode, 200);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
assert(existsSync(f.service.storage.layout.path(value.row)));
|
||||
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('a sender read or unauthorized read does not classify a transfer as recipient-completed', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
assert.equal((await f.call(c, `/access/${value.item.accessId}/download`)).statusCode, 404);
|
||||
const sent = (await f.call(a, '/device/items')).json().items[0];
|
||||
assert.equal((await f.call(a, `/access/${sent.accessId}/download`)).statusCode, 200);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('relocation waits for all active readers and preserves concurrent download bytes', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
const guest = f.service.db
|
||||
.select()
|
||||
.from(s.guests)
|
||||
.where(eq(s.guests.id, f.service.deviceGuest(b.deviceId).guestId))
|
||||
.get();
|
||||
const first = await f.service.storage.openDownload(guest, value.item.accessId),
|
||||
second = await f.service.storage.openDownload(guest, value.item.accessId);
|
||||
f.service.storage.layout.downloaded(value.row.id);
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
const firstClosed = once(first.stream, 'close');
|
||||
first.stream.resume();
|
||||
await firstClosed;
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
const chunks = [];
|
||||
second.stream.on('data', (chunk) => chunks.push(chunk));
|
||||
await once(second.stream, 'close');
|
||||
assert.deepEqual(Buffer.concat(chunks), value.bytes);
|
||||
assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('legacy extension-less files migrate without changing IDs, checksums, content or expiry', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b, 'old.png');
|
||||
const legacy = randomUUID();
|
||||
renameSync(
|
||||
f.service.storage.layout.path(value.row),
|
||||
resolve(f.service.storage.originals, legacy),
|
||||
);
|
||||
f.service.db
|
||||
.update(s.files)
|
||||
.set({ storageKey: legacy })
|
||||
.where(eq(s.files.id, value.row.id))
|
||||
.run();
|
||||
f.service.storage.layout.prepare();
|
||||
const stored = current(f, value.row);
|
||||
assert.equal(stored.storageKey, legacy + '.png');
|
||||
assert.equal(stored.id, value.row.id);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes);
|
||||
assert.equal(stored.checksum, value.row.checksum);
|
||||
const before = stored.storageKey;
|
||||
f.service.storage.layout.prepare();
|
||||
assert.equal(current(f, value.row).storageKey, before);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('database relocation failure rolls back the filename and preserves the original bytes', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
f.sqlite.exec(
|
||||
"CREATE TRIGGER reject_move BEFORE UPDATE OF storageKey ON files BEGIN SELECT RAISE(ABORT, 'test relocation failure'); END",
|
||||
);
|
||||
assert.throws(() =>
|
||||
f.service.storage.layout.move(value.row, `completed/${value.row.storageKey}`),
|
||||
);
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(value.row)), value.bytes);
|
||||
assert.equal(
|
||||
existsSync(f.service.storage.layout.keyPath(`completed/${value.row.storageKey}`)),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(resolve(f.service.storage.layout.journal, value.row.id + '.json')),
|
||||
false,
|
||||
);
|
||||
f.sqlite.exec('DROP TRIGGER reject_move');
|
||||
f.service.storage.layout.downloaded(value.row.id);
|
||||
assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('account deletion undo remains protected; restoring an account reference returns an archive to originals', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await login(f, b);
|
||||
const value = await file(f, a, b);
|
||||
const reference = f.service.db
|
||||
.select()
|
||||
.from(s.access)
|
||||
.where(and(eq(s.access.itemId, value.item.id), isNotNull(s.access.userId)))
|
||||
.get();
|
||||
assert(reference);
|
||||
f.service.db
|
||||
.update(s.access)
|
||||
.set({ deletedAt: Date.now() })
|
||||
.where(eq(s.access.id, reference.id))
|
||||
.run();
|
||||
f.service.storage.layout.downloaded(value.row.id);
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
f.service.db
|
||||
.update(s.access)
|
||||
.set({ deletedAt: Date.now() - f.cfg.undoSeconds * 1000 - 1 })
|
||||
.where(eq(s.access.id, reference.id))
|
||||
.run();
|
||||
f.service.storage.layout.downloaded(value.row.id);
|
||||
assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`);
|
||||
f.service.db
|
||||
.update(s.access)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(s.access.id, reference.id))
|
||||
.run();
|
||||
f.service.storage.layout.reconcile();
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
assert.deepEqual(readFileSync(f.service.storage.layout.path(value.row)), value.bytes);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('aborting an HTTP download before all bytes arrive leaves the file available in originals', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
const openDownload = f.service.storage.openDownload.bind(f.service.storage);
|
||||
let slow;
|
||||
f.service.storage.openDownload = async (...args) => {
|
||||
const result = await openDownload(...args);
|
||||
const closed = once(result.stream, 'close');
|
||||
result.stream.destroy();
|
||||
await closed;
|
||||
slow = Readable.from(
|
||||
(async function* () {
|
||||
yield value.bytes.subarray(0, 1);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
yield value.bytes.subarray(1);
|
||||
})(),
|
||||
);
|
||||
return { ...result, stream: slow };
|
||||
};
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = get(
|
||||
`${f.url}/api/access/${value.item.accessId}/download`,
|
||||
{ headers: { cookie: b.cookie, origin: 'http://hop.test' } },
|
||||
(response) => {
|
||||
assert.equal(response.statusCode, 200);
|
||||
response.once('data', () => {
|
||||
response.destroy();
|
||||
request.destroy();
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
request.once('error', reject);
|
||||
});
|
||||
if (!slow.closed) await once(slow, 'close');
|
||||
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
|
||||
f.service.storage.openDownload = openDownload;
|
||||
assert.deepEqual(
|
||||
(await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload,
|
||||
value.bytes,
|
||||
);
|
||||
await archived(f, value.row);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('journal recovers a restart between file rename and DB commit; unrelated files are not adopted', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
const key = `completed/${value.row.storageKey}`,
|
||||
target = f.service.storage.layout.keyPath(key);
|
||||
renameSync(f.service.storage.layout.path(value.row), target);
|
||||
writeFileSync(
|
||||
resolve(f.service.storage.layout.journal, value.row.id + '.json'),
|
||||
JSON.stringify({
|
||||
fileId: value.row.id,
|
||||
checksum: value.row.checksum,
|
||||
size: value.row.size,
|
||||
from: value.row.storageKey,
|
||||
to: key,
|
||||
}),
|
||||
);
|
||||
f.service.storage.layout.prepare();
|
||||
assert.equal(current(f, value.row).storageKey, key);
|
||||
assert.deepEqual(
|
||||
(await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload,
|
||||
value.bytes,
|
||||
);
|
||||
unlinkSync(target);
|
||||
writeFileSync(
|
||||
resolve(f.service.storage.originals, value.row.storageKey),
|
||||
Buffer.alloc(value.row.size, 120),
|
||||
);
|
||||
const missing = await f.call(b, `/access/${value.item.accessId}/download`);
|
||||
assert.equal(missing.statusCode, 404, 'no journal: do not adopt an unrelated same-size file');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('completed manual deletion and natural expiry clear references and remove bytes in either directory', async () => {
|
||||
for (const manual of [true, false]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
const value = await file(f, a, b);
|
||||
await f.call(b, `/access/${value.item.accessId}/download`);
|
||||
await archived(f, value.row);
|
||||
const path = f.service.storage.layout.path(current(f, value.row));
|
||||
if (manual) {
|
||||
unlinkSync(path);
|
||||
assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 404);
|
||||
} else
|
||||
f.service.db
|
||||
.update(s.items)
|
||||
.set({ expiresAt: Date.now() - 1 })
|
||||
.where(eq(s.items.fileId, value.row.id))
|
||||
.run();
|
||||
await f.service.storage.cleanup();
|
||||
assert.equal(existsSync(path), false);
|
||||
assert.equal(current(f, value.row).status, 'failed');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
17
deploy.md
17
deploy.md
@@ -213,13 +213,18 @@ https://hop.noname.company
|
||||
|
||||
현재 흐름은 **수락 → 서버로 업로드 → NAS에 원본 저장 → 수신자가 다운로드 → 보관 기간 종료 후 정리**입니다. 다운로드 완료 직후 삭제하는 방식이 아닙니다. 임시 저장과 받은 항목도 같은 파일 저장소를 사용하며 DB의 접근 권한으로 사용자별 목록을 구분합니다.
|
||||
|
||||
| 데이터 | NAS 저장 위치 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------- |
|
||||
| 업로드 완료된 파일 원본 | `hop/uploads/originals/` | `/data/uploads/originals/` |
|
||||
| 업로드 중인 파일·조각 | `hop/uploads/temporary/`, `hop/uploads/chunks/` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` Docker 볼륨 | `/data/database/hop.sqlite` |
|
||||
| 데이터 | NAS 저장 위치 | 컨테이너 내부 위치 |
|
||||
| ------------------------------------- | ----------------------------------------------- | --------------------------------------------------- |
|
||||
| 보관함 연결 또는 다운로드 미완료 파일 | `hop/uploads/originals/` | `/data/uploads/originals/` |
|
||||
| 다운로드 완료·보관함 미연결 파일 | `hop/uploads/completed/` | `/data/uploads/completed/` |
|
||||
| 업로드 중인 파일·조각 | `hop/uploads/temporary/`, `hop/uploads/chunks/` | `/data/uploads/temporary/`, `/data/uploads/chunks/` |
|
||||
| 계정·파일명·소유자·만료·접근 권한 | `hop_hop_database` Docker 볼륨 | `/data/database/hop.sqlite` |
|
||||
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads → originals**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads/originals`입니다. 원본은 무작위 식별자로 저장하고 원래 파일명과 사용자 연결 정보는 DB에 둡니다. 업로드 폴더는 Git과 Docker 이미지에서 제외합니다. NAS 공유 폴더를 컨테이너에 연결하는 방식은 [Docker 공식 설명](https://docs.docker.com/engine/storage/bind-mounts/)과 [UGREEN 공식 안내](https://support.ugnas.com/detail/article/en-US/611)를 참고했습니다.
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads`입니다. 파일명은 `무작위 식별자.jpg`처럼 원래 확장자를 붙여 저장하고, 원래 전체 파일명과 사용자 연결 정보는 DB에 둡니다. 확장자가 없거나 안전한 확장자로 판별할 수 없는 파일에는 확장자를 임의로 붙이지 않습니다. 업로드 폴더는 Git과 Docker 이미지에서 제외합니다. NAS 공유 폴더를 컨테이너에 연결하는 방식은 [Docker 공식 설명](https://docs.docker.com/engine/storage/bind-mounts/)과 [UGREEN 공식 안내](https://support.ugnas.com/detail/article/en-US/611)를 참고했습니다.
|
||||
|
||||
로그인한 A가 비로그인 B에게 새 파일을 보내도 A의 보관함에 자동 저장하지 않습니다. B에게 다운로드 응답을 끝낸 파일에 유효한 계정 보관함 참조가 없으면 `completed`로 옮기며, 로그인한 수신자의 받은 항목이나 임시 저장에 연결된 파일은 `originals`에 유지합니다. 다른 다운로드·미리보기가 진행 중이면 이동을 기다립니다. 폴더 분류만 변경하며 기존 접근권·재다운로드·용량 계산·기본 7일 만료일은 유지합니다. 계정 참조가 복원되면 다음 정리 시 `originals`로 돌려놓습니다. 서버의 응답 완료는 브라우저가 기기에 저장했다는 보증은 아닙니다.
|
||||
|
||||
앱 시작 시 확장자 없는 기존 파일은 DB와 함께 이름을 변경합니다. 이전 버전의 다운로드 완료 여부는 소급 확인할 수 없으므로 기존 파일은 수신자가 다시 다운로드할 때 `completed`로 분류합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 복구 기록으로 파일 경로와 DB를 맞춥니다. 새 배포에 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
|
||||
|
||||
파일 관리자로 원본을 수동 삭제하면 다음 보관함·용량 조회, 다운로드·재전송 시 확인하거나 약 1분 간격의 정리 작업에서 감지합니다. 같은 원본의 모든 계정·기기 항목은 만료 처리해 목록과 전송 선택에서 제외하고 사용량을 회수합니다. 열린 화면에는 갱신 이벤트를 보내며 이미 열린 다운로드·전송 화면에서는 삭제 안내를 표시합니다. 권한 오류는 503 안내를 반환하고 DB 기록을 삭제하지 않습니다. 원본을 수동으로 넣거나 이름을 바꿔도 새 보관함 항목을 자동 생성하지 않습니다. `temporary`·`chunks`는 진행 중 업로드에 사용하는 폴더입니다.
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
받은 항목과 임시 저장은 각각 저장 시점부터 최대 7일이다. 기존 자료도 마이그레이션 시점부터 최대 7일로 제한하며, 더 가까운 기존 만료는 유지한다. 즐겨찾기로 만료를 우회할 수 없다. 만료 항목과 접근권을 정리하고 마지막 유효한 파일 참조가 사라지면 원본 파일을 제거한다. 새 전송의 수신 기록은 새 저장 시점부터 7일이며 원본 기록의 만료를 연장하지 않는다.
|
||||
|
||||
물리 파일은 `uploads/originals`에 저장하고 안전한 원래 확장자를 무작위 식별자에 붙인다. 수신자의 다운로드 응답이 끝난 뒤 유효한 계정 보관함 참조가 없으면 `uploads/completed`로 이동한다. 삭제 실행 취소 시간 안의 계정 참조도 보호한다. 진행 중인 다운로드·이미지 미리보기가 모두 종료될 때까지 이동을 미루며, 계정 참조가 생기거나 복원되면 정리 시 `originals`로 돌아온다. 폴더 이동으로 접근권·용량·만료일을 바꾸지 않는다. 두 폴더 모두 수동 삭제 감지와 만료 정리를 적용한다. 이동 기록으로 중단 후 DB·물리 경로를 복구하고, 확장자 없는 기존 파일은 시작 시 이름을 변경한다. 이전 다운로드 완료 여부는 추정하지 않는다.
|
||||
|
||||
## 주변 발견과 방
|
||||
|
||||
주변 기기는 검증된 접속 IP 그룹으로 찾는다. 운영에서는 신뢰할 프록시 경로 검증 후 같은 네트워크 발견을 켠다. 로컬 개발에서는 실제 loopback 접속을 같은 기기의 다른 브라우저로 인식해 자동 발견을 제공한다. 이 방식은 Bluetooth나 물리적 거리 측정이 아니다.
|
||||
|
||||
@@ -167,3 +167,10 @@
|
||||
- 수신 카드에 유형별 아이콘·제목, 수락/거절 2:1 버튼, 오른쪽 하단 차단 확인을 적용했다. 현재 기기만 자신의 차단 목록을 조회·해제할 수 있으며 해제해도 이전 요청은 되살리지 않는다. 수신 파일 목록의 기본 들여쓰기를 제거하고 0.1MB 이상은 MB·1GB 이상은 GB로 표시하며 한 파일의 용량을 중복 표시하지 않는다. 기기 이름 관련 안내 문구는 제거했다.
|
||||
- API 102개 통과. 새 검증에서 실제 제한 응답의 종료 시간·헤더, 반복 재시도의 다른 한도 보존, 거절 대기, 차단 목록 비공개·다른 기기의 해제 불가·해제 후 재전송과 기존 요청 재수락 불가를 확인했다. 전체 빌드·타입 검사와 기존 파일/링크·클립보드·반응형 레이아웃 브라우저 검증도 통과했다.
|
||||
- 새 실제 Chromium 검증에서 모바일/PC 버튼 비율, 파일 용량 0.1/1.3MB·1.7GB, 파일 목록 여백·가로 넘침 없음, 초안 유지·카운트 종료 후 재활성화, 차단 확인 취소·실행·관리 화면 해제를 확인하고 스크린샷을 확인했다. 브라우저 카운트 검사는 3초 제한 응답을 주입했고 실제 60초 제한·서버 권한은 API 테스트로 검증했다. NAS 재배포와 실물 Safari 검증은 수행하지 않았다.
|
||||
|
||||
## 2026-10-14 완료 파일 분류와 원래 확장자
|
||||
|
||||
- 사용자가 선택한 대로 보관함에 연결되지 않은 파일은 수신자에게 다운로드 응답을 끝낸 뒤 `uploads/completed`로 옮기고 기존 7일 만료일을 유지한다. 보관함·실행 취소 가능한 계정 참조가 있으면 `originals`에 유지하며 복원된 참조는 정리 시 원래 폴더로 돌린다. 파일 ID·접근권·원래 다운로드 이름·개인 및 익명 용량 계산은 그대로 사용한다.
|
||||
- 물리 파일명에 안전한 원래 확장자를 붙이며 확장자 없는 기존 파일은 앱 시작 시 DB와 함께 변경한다. 이동은 진행 중 다운로드·이미지 미리보기가 종료될 때까지 미룬다. DB 변경 실패 시 파일을 원래 경로로 돌리고, 이동 기록으로 재시작 중 경로 불일치를 복구한다. 기존 다운로드 완료 여부는 소급 추정하지 않는다. 두 폴더에 수동 삭제 감지·기존 만료 및 고아 파일 정리를 적용했다.
|
||||
- API 총 112개 통과. 새 실제 파일 검사 10개로 로그인 발신→익명 수신 완료 분류·익명 발신→로그인 수신 보관함 유지·재다운로드·만료일과 체크섬 보존·권한 없는 읽기 및 발신자 읽기의 분류 제외·동시 읽기·HTTP 중도 종료·기존 확장자 이전·DB 실패 롤백·계정 삭제 실행 취소와 복원·재시작 복구·수동 삭제·자연 만료를 확인했다. 전체 빌드·타입 검사·포맷 및 셸 문법 검사도 통과했다.
|
||||
- 기존 익명 파일/링크 전송 및 PC/모바일 보관함·파일 재전송·수동 원본 삭제 브라우저 검증을 통과했다. 새 운영 Docker 이미지와 격리한 임시 DB 볼륨·바인드 업로드에서 완료 폴더와 `.txt` 확장자, 재시작 후 기존 주소 다운로드, 8MiB 조각 이어 보내기, Unicode 텍스트·세션 보존, SQLite integrity, 완료 폴더의 호스트 수동 삭제를 확인했다. NAS 설정 검사도 통과했고 `docker-compose.yaml`은 변경하지 않았다. 실제 NAS 재배포는 수행하지 않았다.
|
||||
|
||||
@@ -29,7 +29,7 @@ docker run --rm --network none --user 0:0 \
|
||||
alpine:3.22 sh -eu -c '
|
||||
chown -R "$1:$2" /database /uploads
|
||||
chmod 700 /database /uploads
|
||||
for folder in originals temporary chunks; do
|
||||
for folder in originals completed temporary chunks; do
|
||||
mkdir -p "/uploads/$folder"
|
||||
chmod 700 "/uploads/$folder"
|
||||
done
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
const suffix = randomUUID().slice(0, 8),
|
||||
@@ -151,6 +151,15 @@ try {
|
||||
`import {createHash} from 'node:crypto';const state=${JSON.stringify(fileState.pending)},base='http://127.0.0.1:${port}',path='/api/uploads/resumable/'+state.uploadId;const headers={Origin:'https://hop.test',Cookie:state.cookie,'X-CSRF-Token':state.csrf};const r=await fetch(base+path,{headers});const progress=await r.json();if(!r.ok||progress.offset!==8*1024*1024)throw Error('partial offset not preserved');const last=await fetch(base+path,{method:'PUT',headers:{...headers,'Content-Type':'application/octet-stream','Upload-Offset':String(progress.offset)},body:Buffer.alloc(7,120)});if(!last.ok)throw Error(await last.text());const complete=await fetch(base+path+'/complete',{method:'POST',headers:{...headers,'Content-Type':'application/json'},body:'{}'});if(!complete.ok)throw Error(await complete.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const item=(await list.json()).items.find(i=>i.file?.name==='resume.bin');const downloaded=await fetch(base+'/api/access/'+item.accessId+'/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});const content=Buffer.from(await downloaded.arrayBuffer());if(!downloaded.ok||content.length!==8*1024*1024+7||!content.equals(Buffer.alloc(content.length,120)))throw Error('resumed content mismatch');console.log('resumed');`,
|
||||
);
|
||||
assert.equal(resumed, 'resumed');
|
||||
// Completed physical paths and the original download URL survive another restart.
|
||||
docker('restart', name);
|
||||
wait();
|
||||
assert.equal(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('completed download failed after restart');console.log(await r.text());`,
|
||||
),
|
||||
'Docker file persistence',
|
||||
);
|
||||
const integrity = node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
|
||||
);
|
||||
@@ -163,12 +172,14 @@ try {
|
||||
),
|
||||
);
|
||||
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',
|
||||
const completed = resolve(folder, 'completed');
|
||||
const stored = readdirSync(completed).find(
|
||||
(key) => readFileSync(resolve(completed, key)).toString() === 'Docker file persistence',
|
||||
);
|
||||
assert(stored, 'uploaded file is visible in the host project directory');
|
||||
unlinkSync(resolve(originals, stored));
|
||||
assert(stored, 'downloaded anonymous file is visible in the host completed directory');
|
||||
assert.match(stored, /^[a-f0-9-]{36}\.txt$/);
|
||||
assert.equal(existsSync(resolve(folder, 'originals', stored)), false);
|
||||
unlinkSync(resolve(completed, 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()}));`,
|
||||
|
||||
Reference in New Issue
Block a user