diff --git a/README.md b/README.md
index 6f8fe42..b602807 100644
--- a/README.md
+++ b/README.md
@@ -55,9 +55,9 @@ QR·4자리 코드는 기본 10분의 임시 공간 초대입니다. 초대 코
## 파일 저장 위치
-NAS 파일은 `/volume1/docker/projects/apps/hop/uploads`에 저장합니다. `originals`에는 보관함에 연결된 파일과 아직 다운로드를 완료하지 않은 파일을 두고, 보관함에 연결되지 않은 파일은 수신자에게 다운로드 응답을 끝낸 뒤 `completed`로 이동합니다. 기본 7일 만료일과 용량 계산은 그대로 유지하며 이동으로 만료일을 연장하지 않습니다. 물리 파일명은 `무작위 식별자.jpg`처럼 원래 확장자를 붙이고, 다운로드 시에는 원래 파일명을 제공합니다. 확장자 없는 기존 파일도 앱 시작 시 가능한 경우 자동 반영합니다. 이전 버전의 다운로드 완료 여부는 소급 확인할 수 없어 기존 파일은 다시 다운로드할 때 분류합니다.
+NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **업로드한 사람** 기준으로 구분합니다. 로그인 중 올린 새 파일은 `user-계정ID/`, 비로그인 파일은 `guest-기기세션ID/`에 저장합니다. 파일명은 `무작위 식별자.jpg`처럼 안전한 원래 확장자를 붙이고 다운로드 시에는 원래 파일명을 제공합니다. 받은 사람의 보관함은 같은 원본을 참조하며 중복 저장하지 않습니다. 기본 보관 기간은 항목별 7일이고 마지막 유효 참조가 끝나면 약 1분 간격 정리에서 파일을 지웁니다. 각 업로드 폴더의 `owner-info.json`에는 업로드 요청 시 확인한 국가·지역·도시·접속 IP를 관리자용으로 기록합니다. 좌표·파일 본문·인증 비밀값은 기록하지 않고, 기본 7일 내 최근 20개 기록만 유지합니다. 지역 정보가 제공되지 않으면 확인 불가로 남깁니다.
-앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 두 폴더 모두 수동 삭제를 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 단일 Compose 파일로 전환하는 순서는 [deploy.md](deploy.md)의 7-0단계를 따르며 기존 계정·DB·파일은 유지합니다.
+기존 평면 `originals` 및 `completed` 파일은 앱 시작 시 DB에 기록된 업로드 소유자의 폴더로 이동하며 만료일·ID·접근권은 유지합니다. 새 `completed` 폴더는 만들지 않습니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 기기 세션 폴더를 사용하고 계정을 추정하지 않습니다. 앱은 NAS 사용자의 UID/GID로 실행하며 업로드 폴더는 Git·Docker 빌드에서 제외합니다. DB는 `hop_hop_database` 영구 볼륨에 있습니다. 수동 삭제는 조회·다운로드·전송과 1분 주기 정리 시 감지해 공유된 모든 항목과 용량 계산에 반영합니다. 권한 오류는 삭제로 오인하지 않습니다. 기존 계정·DB·자료를 초기화하지 않습니다.
## 업데이트와 백업
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index fb46e57..903f284 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -30,6 +30,7 @@ import type { Config } from './config.js';
import { openDatabase } from './db/index.js';
import { Service, type Guest } from './service.js';
import { Presence } from './presence.js';
+import { visitorRegion } from './ownerInfo.js';
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
import { CHUNK_BYTES } from './resumable.js';
@@ -222,6 +223,15 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
};
const key = (req: FastifyRequest) =>
typeof req.headers['idempotency-key'] === 'string' ? req.headers['idempotency-key'] : undefined;
+ const recordUploadRegion = (req: FastifyRequest, requestId: string) => {
+ const file = service.storage.fileRows(requestId)[0];
+ if (!file) return;
+ try {
+ service.storage.layout.ownerInfo.record(file, requestId, visitorRegion(req, cfg));
+ } catch {
+ req.log.warn('Upload region metadata could not be recorded');
+ }
+ };
const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id;
app.addHook('onRequest', async (req, reply) => {
reply
@@ -430,14 +440,22 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
const result = service.accounts.idem(user.id, 'request-create', key(req), input, () =>
service.accounts.create(user.id, input),
);
+ recordUploadRegion(req, result.requestId);
service.notifyTransfer(result.requestId);
return result;
}
const g = auth(req),
input = requestInput.parse(req.body);
const result = service.idem(g, 'request-create', key(req), input, () =>
- service.createRequest(g, input, ip(req) ?? 'unknown'),
+ service.createRequest(
+ g,
+ input,
+ ip(req) ?? 'unknown',
+ false,
+ service.auth.session(req.cookies.hop_account)?.user.id,
+ ),
);
+ recordUploadRegion(req, result.requestId);
service.notifyTransfer(result.requestId);
return result;
});
@@ -623,7 +641,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, access } = await service.storage.openDownload(g, accessId);
+ const { file, stream } = 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,15 +661,6 @@ 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) =>
@@ -901,6 +910,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
);
return created;
});
+ recordUploadRegion(req, result.requestId);
service.notifyTransfer(result.requestId);
return result;
});
diff --git a/apps/server/src/fileLayout.js b/apps/server/src/fileLayout.js
index 707a723..58391f3 100644
--- a/apps/server/src/fileLayout.js
+++ b/apps/server/src/fileLayout.js
@@ -7,17 +7,24 @@ import {
readFileSync,
writeFileSync,
unlinkSync,
+ rmdirSync,
} from 'node:fs';
-import { basename, extname, resolve } from 'node:path';
-import { and, eq, gt, inArray, isNotNull, sql } from 'drizzle-orm';
+import { readdir } from 'node:fs/promises';
+import { basename, dirname, extname, resolve } from 'node:path';
+import { and, eq, inArray } from 'drizzle-orm';
import * as s from './db/schema.js';
+import { OwnerInfo, OWNER_INFO_NAME } from './ownerInfo.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 function ownerDirectory(subject) {
+ if (!/^[a-zA-Z0-9-]{1,80}$/.test(subject.id)) throw Error('Invalid storage owner');
+ return `${subject.kind === 'user' ? 'user' : 'guest'}-${subject.id}`;
+}
+export function newStorageKey(name, subject) {
+ return `${ownerDirectory(subject)}/${randomUUID()}${fileExtension(name)}`;
}
export class FileLayout {
@@ -27,9 +34,9 @@ export class FileLayout {
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])
+ for (const directory of [this.originals, this.journal])
mkdirSync(directory, { recursive: true, mode: 0o700 });
+ this.ownerInfo = new OwnerInfo(service, this);
}
path(file) {
const current = this.service.db
@@ -40,29 +47,15 @@ export class FileLayout {
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))
+ const legacyCompleted = key.startsWith('completed/');
+ const parts = (legacyCompleted ? key.slice('completed/'.length) : key).split('/');
+ if (
+ parts.length > (legacyCompleted ? 1 : 2) ||
+ (parts.length === 2 && !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(parts[0])) ||
+ parts.some((name) => !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(),
- );
+ return resolve(legacyCompleted ? this.completed : this.originals, ...parts);
}
recover(file) {
const journalPath = resolve(this.journal, file.id + '.json');
@@ -102,6 +95,7 @@ export class FileLayout {
throw Error('File relocation destination already exists');
const source = this.keyPath(file.storageKey),
target = this.keyPath(destination);
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
let moved = false;
const journalPath = resolve(this.journal, file.id + '.json');
writeFileSync(
@@ -161,53 +155,48 @@ export class FileLayout {
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,
- );
+ const directory =
+ current.storageKey.includes('/') && !current.storageKey.startsWith('completed/')
+ ? current.storageKey.split('/')[0]
+ : ownerDirectory({
+ id: current.ownerUserId ?? current.ownerGuestId,
+ kind: current.ownerUserId ? 'user' : undefined,
+ });
+ if (existsSync(this.keyPath(current.storageKey)))
+ this.move(current, `${directory}/${name}${extname(name) ? '' : extension}`);
+ const stored = this.service.db.select().from(s.files).where(eq(s.files.id, file.id)).get();
+ if (existsSync(this.keyPath(stored.storageKey))) this.ownerInfo.ensure(stored);
} catch {
- // Naming is cosmetic: keep the old key and let availability checks report storage errors.
+ // Keep the existing usable path if migration cannot finish; checks report storage errors.
+ }
+ }
+ try {
+ rmdirSync(this.completed);
+ } catch {} // Remove only an empty legacy folder.
+ }
+ async *entries() {
+ for (const [root, prefix] of [
+ [this.originals, ''],
+ [this.completed, 'completed/'],
+ ]) {
+ let entries;
+ try {
+ entries = await readdir(root, { withFileTypes: true });
+ } catch (error) {
+ if (error.code === 'ENOENT') continue;
+ throw error;
+ }
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ if (prefix || !/^(user|guest)-[a-zA-Z0-9-]{1,80}$/.test(entry.name)) continue;
+ for (const child of await readdir(resolve(root, entry.name), { withFileTypes: true }))
+ if (!child.isDirectory() && child.name !== OWNER_INFO_NAME)
+ yield {
+ key: `${entry.name}/${child.name}`,
+ path: resolve(root, entry.name, child.name),
+ };
+ } else yield { key: prefix + entry.name, path: resolve(root, entry.name) };
}
}
}
- 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 {}
- }
- }
}
diff --git a/apps/server/src/ownerInfo.js b/apps/server/src/ownerInfo.js
new file mode 100644
index 0000000..2132606
--- /dev/null
+++ b/apps/server/src/ownerInfo.js
@@ -0,0 +1,166 @@
+import { randomUUID } from 'node:crypto';
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+ renameSync,
+ unlinkSync,
+ statSync,
+} from 'node:fs';
+import { readdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { and, eq, inArray, like } from 'drizzle-orm';
+import * as s from './db/schema.js';
+import { clientIp } from './security.js';
+
+export const OWNER_INFO_NAME = 'owner-info.json';
+export function visitorRegion(req, cfg) {
+ const ip = clientIp(
+ req.raw.socket.remoteAddress,
+ req.headers,
+ cfg.trustedProxies,
+ cfg.clientIpSource,
+ );
+ const empty = { ip, countryCode: null, region: null, city: null, source: 'unavailable' };
+ if (
+ cfg.clientIpSource !== 'cloudflare' ||
+ !clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies, 'cloudflare')
+ )
+ return empty;
+ const clean = (name) => {
+ const raw = req.headers[name];
+ if (typeof raw !== 'string' || raw.length > 512) return null;
+ let text = raw;
+ // Node's HTTP parser represents raw UTF-8 header bytes as Latin-1.
+ if (/^[\u0000-\u00ff]*$/.test(raw)) {
+ try {
+ text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(raw, 'latin1'));
+ } catch {}
+ }
+ return (
+ text
+ .replace(/[\u0000-\u001f\u007f]/g, '')
+ .trim()
+ .slice(0, 80) || null
+ );
+ };
+ const country = clean('cf-ipcountry')?.toUpperCase();
+ return {
+ ip,
+ countryCode: country && /^[A-Z]{2}$/.test(country) && country !== 'XX' ? country : null,
+ region: clean('cf-region'),
+ city: clean('cf-ipcity'),
+ source: 'cloudflare',
+ };
+}
+
+export class OwnerInfo {
+ constructor(service, layout) {
+ this.service = service;
+ this.layout = layout;
+ }
+ details(key) {
+ const match = /^((user|guest)-([a-zA-Z0-9-]{1,80}))\//.exec(key);
+ if (!match) return null;
+ return {
+ directory: match[1],
+ kind: match[2],
+ id: match[3],
+ path: resolve(this.layout.originals, match[1], OWNER_INFO_NAME),
+ };
+ }
+ name(owner) {
+ const table = owner.kind === 'user' ? s.users : s.devices;
+ const row = this.service.db
+ .select()
+ .from(table)
+ .where(eq(owner.kind === 'user' ? s.users.id : s.devices.guestId, owner.id))
+ .get();
+ return (owner.kind === 'user' ? row?.displayName : row?.name) ?? null;
+ }
+ read(owner) {
+ try {
+ if (statSync(owner.path).size > 65536) return { events: [], changed: true };
+ const data = JSON.parse(readFileSync(owner.path, 'utf8'));
+ if (
+ data.uploader?.id !== owner.id ||
+ data.uploader?.kind !== owner.kind ||
+ !Array.isArray(data.recentLocations)
+ )
+ return { events: [], changed: true };
+ const events = data.recentLocations
+ .filter(
+ (event) =>
+ typeof event.recordedAt === 'string' &&
+ Date.parse(event.recordedAt) >
+ Date.now() - this.service.cfg.fileRetentionDays * 86400000,
+ )
+ .slice(-20);
+ return { events, changed: events.length !== data.recentLocations.length };
+ } catch {
+ return { events: [], changed: true };
+ }
+ }
+ write(owner, events) {
+ mkdirSync(dirname(owner.path), { recursive: true, mode: 0o700 });
+ const temporary = owner.path + '.' + randomUUID() + '.tmp';
+ try {
+ writeFileSync(
+ temporary,
+ JSON.stringify(
+ {
+ version: 1,
+ uploader: { kind: owner.kind, id: owner.id, name: this.name(owner) },
+ recentLocations: events.slice(-20),
+ },
+ null,
+ 2,
+ ) + '\n',
+ { mode: 0o600, flag: 'wx' },
+ );
+ renameSync(temporary, owner.path);
+ } finally {
+ try {
+ unlinkSync(temporary);
+ } catch {}
+ }
+ }
+ ensure(file) {
+ const owner = this.details(file.storageKey);
+ if (owner && !existsSync(owner.path)) this.write(owner, []);
+ }
+ record(file, requestId, region) {
+ 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 });
+ this.write(owner, events);
+ }
+ async cleanup() {
+ for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const owner = this.details(entry.name + '/file');
+ if (!owner || !existsSync(owner.path)) continue;
+ const live = this.service.db
+ .select({ id: s.files.id })
+ .from(s.files)
+ .where(
+ and(
+ like(s.files.storageKey, entry.name + '/%'),
+ inArray(s.files.status, ['pending', 'uploading', 'stored', 'cleaning']),
+ ),
+ )
+ .get();
+ if (!live) {
+ try {
+ unlinkSync(owner.path);
+ } catch {}
+ continue;
+ }
+ const { events, changed } = this.read(owner);
+ if (changed) this.write(owner, events);
+ }
+ }
+}
diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts
index 2bfdae2..03a4fdb 100644
--- a/apps/server/src/service.ts
+++ b/apps/server/src/service.ts
@@ -582,9 +582,22 @@ export class Service {
},
ip: string,
storedSource = false,
+ storageUserId?: string,
) {
this.cleanup();
const target = this.deviceGuest(input.targetDeviceId);
+ if (
+ target &&
+ target.guestId !== g.id &&
+ this.device(g.id).discoverable &&
+ !this.presence.active(target.guestId) &&
+ this.canReceive(g.id, target.guestId, false)
+ )
+ fail(
+ 409,
+ 'TARGET_RECONNECTING',
+ '상대 기기의 연결을 확인하고 있어요. 잠시 후 다시 시도해 주세요.',
+ );
if (
!target ||
target.guestId === g.id ||
@@ -656,7 +669,8 @@ export class Service {
})
.run();
this.db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run();
- if (input.files && !storedSource) this.storage.createManifest(g, id, input.files);
+ if (input.files && !storedSource)
+ this.storage.createManifest(g, id, input.files, storageUserId);
return { requestId: id };
}
request(g: Guest, id: string) {
diff --git a/apps/server/src/storage.ts b/apps/server/src/storage.ts
index 3f18de2..b609272 100644
--- a/apps/server/src/storage.ts
+++ b/apps/server/src/storage.ts
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, statfsSync } from 'node:fs';
import { createWriteStream } from 'node:fs';
import { rename, unlink, readdir, stat, open } from 'node:fs/promises';
-import { resolve } from 'node:path';
+import { dirname, resolve } from 'node:path';
import { Transform, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm';
@@ -62,6 +62,7 @@ export class Storage {
g: StorageSubject,
requestId: string,
manifest: { name: string; size: number; type: string }[],
+ storageUserId?: string,
) {
for (const [position, file] of manifest.entries())
this.service.db
@@ -75,7 +76,10 @@ export class Storage {
name: file.name,
size: file.size,
type: file.type,
- storageKey: newStorageKey(file.name),
+ storageKey: newStorageKey(
+ file.name,
+ storageUserId ? { id: storageUserId, kind: 'user' } : g,
+ ),
status: 'pending',
updatedAt: Date.now(),
})
@@ -329,7 +333,8 @@ export class Storage {
fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.');
}
if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
- const originalPath = resolve(this.originals, file!.storageKey);
+ const originalPath = this.layout.keyPath(file!.storageKey);
+ mkdirSync(dirname(originalPath), { recursive: true, mode: 0o700 });
await rename(tempPath, originalPath);
try {
db.transaction(() => {
@@ -450,9 +455,6 @@ export class Storage {
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, access } = this.download(g, accessId);
@@ -608,7 +610,6 @@ 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)
@@ -666,39 +667,33 @@ export class Storage {
.all()
.map((f) => f.storageKey),
);
- 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 await (const { key, path } of this.layout.entries()) {
+ if (validKeys.has(key)) continue;
+ 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;
}
}
+ await this.layout.ownerInfo.cleanup();
for (const name of await readdir(this.temp)) {
if (this.active.has(name)) continue;
const path = resolve(this.temp, name);
diff --git a/apps/server/test/file-layout.test.js b/apps/server/test/file-layout.test.js
index e1527eb..fc5ebbc 100644
--- a/apps/server/test/file-layout.test.js
+++ b/apps/server/test/file-layout.test.js
@@ -2,11 +2,17 @@ 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 {
+ existsSync,
+ readFileSync,
+ renameSync,
+ writeFileSync,
+ unlinkSync,
+ mkdirSync,
+ utimesSync,
+} from 'node:fs';
import { resolve } from 'node:path';
-import { and, eq, isNotNull } from 'drizzle-orm';
+import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
@@ -76,265 +82,176 @@ async function file(f, a, b, name = '사진.JPG') {
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 () => {
+test('signed-in uploads use the account folder; download retains the key, original filename and seven-day expiry', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
- c = await f.create();
+ userId = await login(f, a);
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
+ assert.match(value.row.storageKey, new RegExp(`^user-${userId}/[a-f0-9-]{36}\\.jpg$`));
+ assert.equal((await f.call(a, '/vault')).json().items.length, 0);
+ const expiry = 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);
+ .from(s.items)
+ .where(eq(s.items.fileId, value.row.id))
+ .get().expiresAt;
+ for (let i = 0; i < 2; i++) {
+ const response = await f.call(b, `/access/${value.item.accessId}/download`);
+ assert.equal(response.statusCode, 200);
+ assert.deepEqual(response.rawPayload, value.bytes);
+ assert.match(response.headers['content-disposition'], /filename\*=UTF-8/);
+ }
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,
+ f.service.db.select().from(s.items).where(eq(s.items.fileId, value.row.id)).get().expiresAt,
+ expiry,
);
- 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}`);
+ assert.equal(existsSync(f.service.storage.layout.completed), false);
} finally {
await f.close();
}
});
-test('account deletion undo remains protected; restoring an account reference returns an archive to originals', async () => {
+test('anonymous upload uses its guest folder even when a signed-in recipient adds a received reference', 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);
+ const value = await file(f, a, b, 'received.mp4');
+ assert.equal(value.row.storageKey.split('/')[0], `guest-${value.row.ownerGuestId}`);
+ assert.match(value.row.storageKey, /\.mp4$/);
+ assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
+ assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 200);
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(
+ (await f.call(await f.create(), `/access/${value.item.accessId}/download`)).statusCode,
+ 404,
+ );
+ } finally {
+ await f.close();
+ }
+});
+test('the same account on different devices shares one upload folder with unique physical filenames', async () => {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create(),
+ c = await f.create(),
+ d = await f.create();
+ const id = await login(f, a),
+ user = f.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
+ const loginResponse = await f.call(c, '/auth/login', 'POST', {
+ email: user.email,
+ password: '12345678',
+ });
+ assert.equal(loginResponse.statusCode, 200);
+ c.cookie += '; ' + String(loginResponse.headers['set-cookie']).split(';')[0];
+ const one = await file(f, a, b),
+ two = await file(f, c, d);
+ assert.equal(one.row.storageKey.split('/')[0], `user-${id}`);
+ assert.equal(two.row.storageKey.split('/')[0], `user-${id}`);
+ assert.notEqual(one.row.storageKey, two.row.storageKey);
+ assert.deepEqual(readFileSync(f.service.storage.layout.path(one.row)), one.bytes);
+ assert.deepEqual(readFileSync(f.service.storage.layout.path(two.row)), two.bytes);
+ } finally {
+ await f.close();
+ }
+});
+test('old flat originals and completed files migrate to their recorded upload owner without changing IDs or expiry', async () => {
+ for (const completed of [false, true]) {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create(),
+ value = await file(f, a, b, 'old.png'),
+ legacy = randomUUID();
+ if (completed) mkdirSync(f.service.storage.layout.completed, { recursive: true });
+ const key = completed ? `completed/${legacy}` : legacy;
+ renameSync(f.service.storage.layout.path(value.row), f.service.storage.layout.keyPath(key));
+ f.service.db
+ .update(s.files)
+ .set({ storageKey: key })
+ .where(eq(s.files.id, value.row.id))
+ .run();
+ f.service.storage.layout.prepare();
+ const stored = current(f, value.row);
+ assert.equal(stored.storageKey, `guest-${value.row.ownerGuestId}/${legacy}.png`);
+ assert.equal(stored.id, value.row.id);
+ assert.equal(stored.checksum, value.row.checksum);
+ assert.equal(stored.updatedAt, value.row.updatedAt);
+ assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes);
+ assert.equal(existsSync(f.service.storage.layout.completed), false);
+ f.service.storage.layout.prepare();
+ assert.equal(current(f, value.row).storageKey, stored.storageKey);
+ assert.deepEqual(
+ (await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload,
+ value.bytes,
+ );
+ } finally {
+ await f.close();
+ }
+ }
+});
+test('migration waits for all active readers before moving a legacy file', async () => {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create(),
+ value = await file(f, a, b),
+ key = randomUUID();
+ renameSync(f.service.storage.layout.path(value.row), f.service.storage.layout.keyPath(key));
+ f.service.db.update(s.files).set({ storageKey: key }).where(eq(s.files.id, value.row.id)).run();
+ const guest = f.service.guest(a.cookie.split('=')[1]);
+ const sent = (await f.call(a, '/device/items')).json().items[0];
+ const first = await f.service.storage.openDownload(guest, sent.accessId),
+ second = await f.service.storage.openDownload(guest, sent.accessId);
+ f.service.storage.layout.prepare();
+ assert.equal(current(f, value.row).storageKey, key);
+ const closed = once(first.stream, 'close');
+ first.stream.resume();
+ await closed;
+ f.service.storage.layout.prepare();
+ assert.equal(current(f, value.row).storageKey, key);
+ const chunks = [];
+ second.stream.on('data', (chunk) => chunks.push(chunk));
+ await once(second.stream, 'close');
+ assert.deepEqual(Buffer.concat(chunks), value.bytes);
+ f.service.storage.layout.prepare();
+ assert.equal(current(f, value.row).storageKey, `guest-${value.row.ownerGuestId}/${key}.jpg`);
+ } finally {
+ await f.close();
+ }
+});
+test('database move failure rolls back the filename and leaves bytes and old key available', async () => {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create(),
+ value = await file(f, a, b);
+ const destination = value.row.storageKey.split('/')[0] + '/' + randomUUID() + '.jpg';
+ 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, destination));
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,
+ assert.equal(existsSync(f.service.storage.layout.keyPath(destination)), false);
+ assert.equal(
+ existsSync(resolve(f.service.storage.layout.journal, value.row.id + '.json')),
+ false,
);
- 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 () => {
+test('move journal restores a rename interrupted before DB commit and never adopts unrelated replacement files', 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}`,
+ b = await f.create(),
+ value = await file(f, a, b);
+ const key = value.row.storageKey.split('/')[0] + '/' + randomUUID() + '.jpg',
target = f.service.storage.layout.keyPath(key);
renameSync(f.service.storage.layout.path(value.row), target);
writeFileSync(
@@ -355,39 +272,62 @@ test('journal recovers a restart between file rename and DB commit; unrelated fi
);
unlinkSync(target);
writeFileSync(
- resolve(f.service.storage.originals, value.row.storageKey),
+ f.service.storage.layout.keyPath(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');
+ assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 404);
} 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();
+test('manual deletion and seven-day expiry remove personal and guest folder files and reconcile references', async () => {
+ for (const signedIn of [false, true])
+ for (const manual of [false, true]) {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create();
+ if (signedIn) await login(f, a);
+ const value = await file(f, a, b),
+ path = f.service.storage.layout.path(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();
+ }
}
+});
+test('orphan cleanup scans nested owner folders and preserves valid old files', async () => {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create(),
+ value = await file(f, a, b);
+ const valid = f.service.storage.layout.path(value.row),
+ orphan = resolve(valid, '..', randomUUID() + '.txt'),
+ old = new Date(Date.now() - 25 * 3600000);
+ writeFileSync(orphan, 'orphan');
+ utimesSync(orphan, old, old);
+ utimesSync(valid, old, old);
+ await f.service.storage.cleanup();
+ assert.equal(existsSync(orphan), false);
+ assert.equal(existsSync(valid), true);
+ assert.throws(() => f.service.storage.layout.keyPath('../outside'));
+ assert.throws(() => f.service.storage.layout.keyPath('user-x/../../outside'));
+ } finally {
+ await f.close();
}
});
diff --git a/apps/server/test/owner-info.test.js b/apps/server/test/owner-info.test.js
new file mode 100644
index 0000000..09d591d
--- /dev/null
+++ b/apps/server/test/owner-info.test.js
@@ -0,0 +1,108 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { randomUUID } from 'node:crypto';
+import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { eq } from 'drizzle-orm';
+import { fixture } from './helpers.js';
+import { visitorRegion } from '../src/ownerInfo.js';
+import { loadConfig } from '../src/config.js';
+import * as s from '../src/db/schema.js';
+
+test('basic country and city use only trusted Cloudflare headers and ignore precise coordinates', () => {
+ const cfg = { ...loadConfig(), clientIpSource: 'cloudflare', trustedProxies: ['127.0.0.1'] };
+ const req = {
+ raw: { socket: { remoteAddress: '127.0.0.1' } },
+ headers: {
+ 'cf-connecting-ip': '8.8.8.8',
+ 'cf-ipcountry': 'KR',
+ 'cf-region': 'Daejeon',
+ 'cf-ipcity': Buffer.from('대전', 'utf8').toString('latin1'),
+ 'cf-iplatitude': '37.123456',
+ 'cf-iplongitude': '127.123456',
+ },
+ };
+ assert.deepEqual(visitorRegion(req, cfg), {
+ ip: '8.8.8.8',
+ countryCode: 'KR',
+ region: 'Daejeon',
+ city: '대전',
+ source: 'cloudflare',
+ });
+ for (const change of [{ trustedProxies: [] }, { clientIpSource: 'forwarded' }])
+ assert.deepEqual(visitorRegion(req, { ...cfg, ...change }), {
+ ip: null,
+ countryCode: null,
+ region: null,
+ city: null,
+ source: 'unavailable',
+ });
+ assert.equal(
+ visitorRegion({ ...req, headers: { ...req.headers, 'cf-ipcountry': 'XX' } }, cfg).countryCode,
+ null,
+ );
+});
+test('region records are NAS-only, deduplicated by request, bounded and cleaned with owner files', async () => {
+ const f = await fixture();
+ try {
+ const a = await f.create(),
+ b = await f.create();
+ await f.space(a, b);
+ f.cfg.clientIpSource = 'cloudflare';
+ const key = randomUUID(),
+ input = {
+ targetDeviceId: b.deviceId,
+ kind: 'file',
+ byteSize: 4,
+ count: 1,
+ files: [{ name: 'test.mp4', size: 4, type: 'video/mp4' }],
+ },
+ headers = {
+ 'cf-connecting-ip': '8.8.8.8',
+ 'cf-ipcountry': 'KR',
+ 'cf-region': 'Daejeon',
+ 'cf-ipcity': 'Daejeon',
+ 'cf-iplatitude': '37.123456',
+ };
+ const result = await f.call(a, '/transfer-requests', 'POST', input, key, headers);
+ assert.equal(result.statusCode, 200);
+ const id = result.json().requestId,
+ row = f.service.storage.fileRows(id)[0],
+ owner = f.service.storage.layout.ownerInfo.details(row.storageKey);
+ const json = () => JSON.parse(readFileSync(owner.path, 'utf8'));
+ assert.equal(json().uploader.id, row.ownerGuestId);
+ assert.equal(json().recentLocations[0].city, 'Daejeon');
+ const serialized = readFileSync(owner.path, 'utf8');
+ assert.equal(json().recentLocations[0].ip, '8.8.8.8');
+ for (const secret of ['37.123456', a.csrf, a.cookie])
+ assert.equal(serialized.includes(secret), false);
+ await f.call(a, '/transfer-requests', 'POST', input, key, headers);
+ assert.equal(json().recentLocations.length, 1);
+ for (let i = 0; i < 30; i++)
+ f.service.storage.layout.ownerInfo.record(row, randomUUID(), {
+ countryCode: 'CN',
+ region: null,
+ city: null,
+ source: 'cloudflare',
+ });
+ assert.equal(json().recentLocations.length, 20);
+ const response = await f.app.inject({
+ url: '/uploads/originals/' + row.storageKey.split('/')[0] + '/owner-info.json',
+ });
+ assert.equal(
+ response.body.includes('recentLocations'),
+ false,
+ 'metadata is not publicly served',
+ );
+ const expired = json();
+ for (const event of expired.recentLocations)
+ event.recordedAt = new Date(Date.now() - 8 * 86400000).toISOString();
+ writeFileSync(owner.path, JSON.stringify(expired));
+ await f.service.storage.layout.ownerInfo.cleanup();
+ assert.equal(json().recentLocations.length, 0);
+ 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);
+ } finally {
+ await f.close();
+ }
+});
diff --git a/apps/server/test/phase2.test.ts b/apps/server/test/phase2.test.ts
index 506561d..c53db3d 100644
--- a/apps/server/test/phase2.test.ts
+++ b/apps/server/test/phase2.test.ts
@@ -66,7 +66,7 @@ test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장
{ name: '파일.txt', size: bytes.length, type: 'text/plain' },
]);
assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409);
- assert.equal((await readdir(f.service.storage.originals)).length, 0);
+ assert.equal((await Array.fromAsync(f.service.storage.layout.entries())).length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
@@ -267,7 +267,7 @@ test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤
await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] })
).json();
await f.service.storage.cleanup();
- assert.equal((await readdir(f.service.storage.originals)).length, 1);
+ assert.equal((await Array.fromAsync(f.service.storage.layout.entries())).length, 1);
f.service.db
.update(s.deletions)
.set({ undoUntil: Date.now() - 1 })
@@ -283,7 +283,7 @@ test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤
.run();
f.service.cleanup();
await f.service.storage.cleanup();
- assert.equal((await readdir(f.service.storage.originals)).length, 0);
+ assert.equal((await Array.fromAsync(f.service.storage.layout.entries())).length, 0);
} finally {
await f.close();
}
diff --git a/apps/server/test/transfer-controls.test.js b/apps/server/test/transfer-controls.test.js
index 914af87..738cc06 100644
--- a/apps/server/test/transfer-controls.test.js
+++ b/apps/server/test/transfer-controls.test.js
@@ -2,6 +2,41 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './helpers.js';
import { Limiter, ApiError } from '../src/security.js';
+import { randomUUID } from 'node:crypto';
+import { once } from 'node:events';
+
+test('a known reconnecting peer returns a transient code without creating a request or charging quotas', async () => {
+ const f = await fixture(true);
+ try {
+ f.cfg.offlineGraceMs = 1000;
+ f.cfg.pairRate = 1;
+ const a = await f.create('8.8.8.8'),
+ b = await f.create('8.8.8.8');
+ const disconnected = once(f.service.io.sockets.sockets.get(b.socket.id), 'disconnect');
+ b.socket.disconnect();
+ await disconnected;
+ const key = randomUUID();
+ for (let i = 0; i < 3; i++) {
+ const response = await f.request(a, b, 'test', 'url', 1, key);
+ assert.equal(response.statusCode, 409);
+ assert.equal(response.json().code, 'TARGET_RECONNECTING');
+ assert.equal((await f.call(a, '/transfer-requests')).json().requests.length, 0);
+ }
+ await f.call(b, '/device/me', 'PATCH', { discoverable: false });
+ assert.equal((await f.request(a, b, 'test', 'url', 1, key)).json().code, 'TARGET_UNAVAILABLE');
+ await f.call(b, '/device/me', 'PATCH', { discoverable: true });
+ await f.connect(b);
+ const response = await f.request(a, b, 'test', 'url', 1, key);
+ assert.equal(response.statusCode, 200);
+ assert.equal(
+ (await f.request(a, b, 'test', 'url', 1, key)).json().requestId,
+ response.json().requestId,
+ );
+ assert.equal((await f.call(a, '/transfer-requests')).json().requests.length, 1);
+ } finally {
+ await f.close();
+ }
+});
test('rate windows report their deadline; denied retries do not charge other quotas', (t) => {
let now = 1000;
diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue
index 48050d9..cb6245c 100644
--- a/apps/web/src/AccountPanel.vue
+++ b/apps/web/src/AccountPanel.vue
@@ -450,6 +450,7 @@ async function logout() {
await api('/auth/logout', 'POST', {});
clearPrivate();
await refreshAccount();
+ emit('devices');
reconnect();
await refresh();
});
diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue
index b77f381..eea42e1 100644
--- a/apps/web/src/App.vue
+++ b/apps/web/src/App.vue
@@ -66,6 +66,9 @@ import {
spaces,
error,
transferCooldown,
+ transferConnecting,
+ waitForConnection,
+ requestTransfer,
bootstrap,
api,
refresh,
@@ -168,7 +171,7 @@ const receiving = computed(() =>
r.transferStatus !== 'failed',
),
);
-createNearbySearch({
+const nearbySearch = createNearbySearch({
connected,
device,
spaces,
@@ -176,6 +179,24 @@ createNearbySearch({
publish: publishLocation,
clear: clearLocation,
});
+const preparingTransfer = ref(false);
+async function prepareTransfer(target) {
+ preparingTransfer.value = true;
+ try {
+ await waitForConnection();
+ if (
+ target.sources?.includes('nearby') &&
+ !target.sources.some((source) => ['network', 'space'].includes(source))
+ ) {
+ if (!(await nearbySearch.ensureReady()))
+ throw Error(
+ '기기 연결을 복구하지 못했어요. 다시 시도하거나 다른 기기 연결을 이용해 주세요.',
+ );
+ }
+ } finally {
+ preparingTransfer.value = false;
+ }
+}
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
const receivedTexts = receiver.receivedTexts;
@@ -509,12 +530,9 @@ async function forwardVault(item) {
}
const owner = account.value?.id;
await act(async () => {
- await api(
- `/vault/${item.accessId}/send`,
- 'POST',
- { targetDeviceId: selected.value.id },
- crypto.randomUUID(),
- );
+ const target = selected.value;
+ await prepareTransfer(target);
+ await requestTransfer(`/vault/${item.accessId}/send`, { targetDeviceId: target.id });
if (account.value?.id !== owner) return;
forwardedItem.value = item;
sendOpen.value = true;
@@ -629,10 +647,12 @@ function selectPeer(peer) {
async function submitClipboard(content) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
+ const target = selected.value;
+ await prepareTransfer(target);
forwardedItem.value = null;
itemToSend.value = null;
await send(
- selected.value.id,
+ target.id,
content.kind,
content.kind === 'text' ? { text: content.text } : { urls: content.urls },
);
@@ -657,11 +677,15 @@ async function closeReceivedText(item) {
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
+ const target = selected.value;
forwardedItem.value = null;
itemToSend.value = null;
if (currentTransfer.value?.kind === 'file' && currentTransfer.value.status === 'accepted')
await retryFiles(currentTransfer.value.id, files);
- else await sendFiles(selected.value.id, files);
+ else {
+ await prepareTransfer(target);
+ await sendFiles(target.id, files);
+ }
});
}
async function createSpace() {
@@ -1195,6 +1219,7 @@ async function spaceAction(space, action, memberId) {
:clipboard-key="selectionEpoch"
:read-clipboard-on-selection="true"
:busy="busy"
+ :preparing="preparingTransfer || transferConnecting"
:retry-seconds="retrySeconds"
:transfer="currentTransfer"
:progress="
diff --git a/apps/web/src/SendComposer.vue b/apps/web/src/SendComposer.vue
index 3ec2fcd..5090b64 100644
--- a/apps/web/src/SendComposer.vue
+++ b/apps/web/src/SendComposer.vue
@@ -19,6 +19,7 @@ const props = defineProps({
targetKey: String,
clipboardKey: Number,
busy: Boolean,
+ preparing: Boolean,
retrySeconds: { type: Number, default: 0 },
disabled: Boolean,
transfer: Object,
@@ -521,15 +522,17 @@ function size(value) {
@click="submit"
>
{{
- !transfer && retrySeconds > 0
- ? `${retrySeconds}초 후 전송 가능`
- : retryable
- ? '다시 전송'
- : transfer
- ? '전송 취소'
- : saveMode
- ? '저장하기'
- : '전송하기'
+ preparing
+ ? '연결 확인 중'
+ : !transfer && retrySeconds > 0
+ ? `${retrySeconds}초 후 전송 가능`
+ : retryable
+ ? '다시 전송'
+ : transfer
+ ? '전송 취소'
+ : saveMode
+ ? '저장하기'
+ : '전송하기'
}}