Organize uploader storage and restore transfer readiness
This commit is contained in:
@@ -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·자료를 초기화하지 않습니다.
|
||||
|
||||
## 업데이트와 백업
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
166
apps/server/src/ownerInfo.js
Normal file
166
apps/server/src/ownerInfo.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
108
apps/server/test/owner-info.test.js
Normal file
108
apps/server/test/owner-info.test.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -450,6 +450,7 @@ async function logout() {
|
||||
await api('/auth/logout', 'POST', {});
|
||||
clearPrivate();
|
||||
await refreshAccount();
|
||||
emit('devices');
|
||||
reconnect();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -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
|
||||
? '저장하기'
|
||||
: '전송하기'
|
||||
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="retryable" class="content-switches">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readDeviceInfo } from './deviceInfo';
|
||||
import { PASSWORD_MIN_LENGTH } from '@hop/shared';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { io } from 'socket.io-client';
|
||||
export const account = ref(null),
|
||||
registrationOpen = ref(false),
|
||||
@@ -27,6 +27,7 @@ export const device = ref(null),
|
||||
error = ref('');
|
||||
export const pendingPeers = ref([]);
|
||||
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
|
||||
export const transferConnecting = ref(false);
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
@@ -46,6 +47,46 @@ export function reserveLocationAcceptance(requestId) {
|
||||
}
|
||||
let socket;
|
||||
let reauthorizing;
|
||||
export async function waitForConnection() {
|
||||
if (socket?.connected) return;
|
||||
if (!socket) throw Error('서버 연결을 확인해 주세요.');
|
||||
await new Promise((resolve, reject) => {
|
||||
const stop = watch(connected, (ready) => {
|
||||
if (!ready) return;
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
resolve();
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
stop();
|
||||
reject(Error('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'));
|
||||
}, 5000);
|
||||
socket.connect();
|
||||
});
|
||||
}
|
||||
export async function requestTransfer(path, input) {
|
||||
const key = crypto.randomUUID(),
|
||||
owner = device.value?.id;
|
||||
try {
|
||||
transferConnecting.value = !socket?.connected;
|
||||
await waitForConnection();
|
||||
const deadline = Date.now() + 5000;
|
||||
while (true) {
|
||||
if (device.value?.id !== owner)
|
||||
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
|
||||
try {
|
||||
return await api(path, 'POST', input, key);
|
||||
} catch (error) {
|
||||
// Only a known, still-authorized peer in its reconnect grace period is retried.
|
||||
if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error;
|
||||
transferConnecting.value = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
transferConnecting.value = false;
|
||||
}
|
||||
}
|
||||
const drafts = new Map();
|
||||
const completing = new Set();
|
||||
export async function api(path, method = 'GET', body, key, signal) {
|
||||
@@ -222,17 +263,12 @@ export const refresh = createRefreshQueue(async () => {
|
||||
});
|
||||
export async function send(targetDeviceId, kind, values) {
|
||||
const content = kind === 'text' ? values.text : values.urls.join('\n');
|
||||
const { requestId } = await api(
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId,
|
||||
kind,
|
||||
byteSize: new TextEncoder().encode(content).length,
|
||||
count: kind === 'text' ? 1 : values.urls.length,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
const { requestId } = await requestTransfer('/transfer-requests', {
|
||||
targetDeviceId,
|
||||
kind,
|
||||
byteSize: new TextEncoder().encode(content).length,
|
||||
count: kind === 'text' ? 1 : values.urls.length,
|
||||
});
|
||||
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
|
||||
await refresh();
|
||||
return requestId;
|
||||
@@ -317,7 +353,7 @@ export async function sendFiles(targetDeviceId, files) {
|
||||
type: f.type || 'application/octet-stream',
|
||||
})),
|
||||
};
|
||||
const { requestId } = await api('/transfer-requests', 'POST', input, crypto.randomUUID());
|
||||
const { requestId } = await requestTransfer('/transfer-requests', input);
|
||||
fileDrafts.set(requestId, files);
|
||||
await refresh();
|
||||
return requestId;
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
|
||||
timer,
|
||||
permission,
|
||||
permissionQuery,
|
||||
locationRequest,
|
||||
disposed = false,
|
||||
blocked = false;
|
||||
const ready = () =>
|
||||
@@ -47,8 +48,13 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
|
||||
});
|
||||
return permissionQuery;
|
||||
}
|
||||
async function locate() {
|
||||
if (!ready() || blocked || locating.value || disposed) return;
|
||||
function locate() {
|
||||
if (!ready() || blocked || disposed) return;
|
||||
if (locating.value) return locationRequest;
|
||||
locationRequest = readLocation();
|
||||
return locationRequest;
|
||||
}
|
||||
async function readLocation() {
|
||||
const current = ++revision;
|
||||
locating.value = true;
|
||||
clearTimeout(timer);
|
||||
@@ -84,7 +90,8 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
|
||||
}
|
||||
}
|
||||
async function resume() {
|
||||
if (!ready() || blocked || active.value || locating.value || disposed) return;
|
||||
if (!ready() || blocked || active.value || disposed) return;
|
||||
if (locating.value) return locationRequest;
|
||||
const current = revision;
|
||||
const state = await readPermission();
|
||||
if (disposed || current !== revision || !ready()) return;
|
||||
@@ -92,7 +99,7 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
|
||||
blocked = true;
|
||||
return;
|
||||
}
|
||||
void locate();
|
||||
return locate();
|
||||
}
|
||||
function visibility() {
|
||||
visible = document.visibilityState === 'visible';
|
||||
@@ -119,5 +126,9 @@ export function createNearbySearch({ connected, device, spaces, settings, publis
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
if (permission) permission.onchange = null;
|
||||
});
|
||||
return { active, locating };
|
||||
async function ensureReady() {
|
||||
await resume();
|
||||
return ready() && active.value && !blocked && !disposed;
|
||||
}
|
||||
return { active, locating, ensureReady };
|
||||
}
|
||||
|
||||
24
deploy.md
24
deploy.md
@@ -213,18 +213,24 @@ https://hop.noname.company
|
||||
|
||||
현재 흐름은 **수락 → 서버로 업로드 → NAS에 원본 저장 → 수신자가 다운로드 → 보관 기간 종료 후 정리**입니다. 다운로드 완료 직후 삭제하는 방식이 아닙니다. 임시 저장과 받은 항목도 같은 파일 저장소를 사용하며 DB의 접근 권한으로 사용자별 목록을 구분합니다.
|
||||
|
||||
| 데이터 | 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 저장 위치 | 컨테이너 내부 위치 |
|
||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------- |
|
||||
| 로그인 중 업로드한 새 파일 | `hop/uploads/originals/user-계정ID/` | `/data/uploads/originals/user-계정ID/` |
|
||||
| 비로그인 업로드 파일 | `hop/uploads/originals/guest-기기세션ID/` | `/data/uploads/originals/guest-기기세션ID/` |
|
||||
| 업로드 중인 파일·조각 | `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**로 들어갑니다. 전체 경로는 `/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)를 참고했습니다.
|
||||
NAS 파일 관리자에서 **공유 폴더 → docker → projects → apps → hop → uploads → originals**로 들어갑니다. 전체 경로는 `/volume1/docker/projects/apps/hop/uploads/originals`입니다. **업로드한 사람** 기준으로 로그인 계정은 `user-계정ID`, 비로그인 기기는 `guest-기기세션ID` 폴더를 사용합니다. 같은 로그인 계정은 기기가 달라도 같은 폴더를 사용합니다. 받은 사람의 보관함은 원본을 참조하므로 여러 명이 받아도 복제하지 않습니다. 계정 ID와 이메일은 DB의 `users`에 있으며 파일명·접근권도 DB에서 연결합니다. 폴더명이 다운로드 권한을 부여하지는 않습니다.
|
||||
|
||||
로그인한 A가 비로그인 B에게 새 파일을 보내도 A의 보관함에 자동 저장하지 않습니다. B에게 다운로드 응답을 끝낸 파일에 유효한 계정 보관함 참조가 없으면 `completed`로 옮기며, 로그인한 수신자의 받은 항목이나 임시 저장에 연결된 파일은 `originals`에 유지합니다. 다른 다운로드·미리보기가 진행 중이면 이동을 기다립니다. 폴더 분류만 변경하며 기존 접근권·재다운로드·용량 계산·기본 7일 만료일은 유지합니다. 계정 참조가 복원되면 다음 정리 시 `originals`로 돌려놓습니다. 서버의 응답 완료는 브라우저가 기기에 저장했다는 보증은 아닙니다.
|
||||
파일명은 `무작위 식별자.jpg`처럼 안전한 원래 확장자를 붙여 저장하고 다운로드 시 원래 전체 파일명을 제공합니다. 확장자가 없거나 안전한 확장자로 판별할 수 없는 파일에는 임의 확장자를 붙이지 않습니다. 업로드 폴더는 Git과 Docker 이미지에서 제외합니다. NAS 공유 폴더를 컨테이너에 연결하는 방식은 [Docker 공식 설명](https://docs.docker.com/engine/storage/bind-mounts/)과 [UGREEN 공식 안내](https://support.ugnas.com/detail/article/en-US/611)를 참고했습니다.
|
||||
|
||||
앱 시작 시 확장자 없는 기존 파일은 DB와 함께 이름을 변경합니다. 이전 버전의 다운로드 완료 여부는 소급 확인할 수 없으므로 기존 파일은 수신자가 다시 다운로드할 때 `completed`로 분류합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 복구 기록으로 파일 경로와 DB를 맞춥니다. 새 배포에 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
|
||||
로그인한 A가 비로그인 B에게 새 파일을 보내도 A의 보관함에 자동 저장하지 않습니다. 원본은 업로드 소유자의 폴더에서 기존 접근권과 만료일에 따라 유지합니다. `completed`로 옮기는 방식은 사용하지 않습니다. 이전 방식에서는 계정 보관함에 연결된 파일은 다운로드가 끝나도 `originals`에 두었지만, 현재는 다운로드 완료 여부로 폴더를 나누지 않습니다.
|
||||
|
||||
앱 시작 시 기존 평면 `originals` 및 `completed`의 파일을 DB에 기록된 업로드 소유자 폴더로 이동하고 확장자도 적용합니다. 과거 기기 전송의 업로드 소유자가 기기 세션으로만 기록되어 있다면 해당 `guest-기기세션ID` 폴더로 이동하며 로그인 계정을 추정하지 않습니다. 파일 ID·다운로드 주소·만료일·사용량과 기존 자료는 유지합니다. 진행 중인 파일 읽기가 있으면 이동을 미루고 DB 변경 실패 시 원래 경로로 복원합니다. 이동 중 종료되면 숨김 폴더 `.file-moves`의 기록으로 경로와 DB를 맞춥니다. 빈 기존 `completed` 폴더만 제거하며 새 `completed` 폴더는 만들지 않습니다. 저장소 초기화·Compose 수정·폴더 수동 생성은 필요하지 않습니다.
|
||||
|
||||
각 사용자 폴더의 `owner-info.json`에서 업로드 요청 시 확인한 국가·지역·도시·접속 IP와 업로드 주체를 확인합니다. 최대 최근 20개·기본 7일의 기록이며 마지막 원본과 대기 업로드가 모두 사라지면 정보 파일도 정리합니다. 좌표·세션 쿠키·CSRF·파일 본문은 저장하지 않습니다. 일반 사용자의 API·보관함·공개 URL로 제공하지 않습니다. 외부 GeoIP 조회 없이 기본 제공 값만 사용하며 미제공 지역과 과거 자료는 확인 불가입니다.
|
||||
|
||||
지역은 `CLIENT_IP_SOURCE=cloudflare`일 때 검증된 인접 프록시에서 온 Cloudflare 헤더만 사용합니다. 국가 정보는 IP geolocation, 도시 정보는 **Cloudflare → Rules → Settings/Managed Transforms → Add visitor location headers**를 활성화하면 전달받을 수 있습니다. 현재 대시보드 메뉴가 다르면 [Cloudflare 공식 설정 문서](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers)를 참고하세요. CF 좌표·우편번호는 무시합니다. VPN·통신사망 등으로 실제 거주지와 다를 수 있습니다. 악용 대응용 관리자 화면·국가/IP 차단은 별도로 관리하며 실제 규칙은 운영자가 지정합니다.
|
||||
|
||||
파일 관리자로 원본을 수동 삭제하면 다음 보관함·용량 조회, 다운로드·재전송 시 확인하거나 약 1분 간격의 정리 작업에서 감지합니다. 같은 원본의 모든 계정·기기 항목은 만료 처리해 목록과 전송 선택에서 제외하고 사용량을 회수합니다. 열린 화면에는 갱신 이벤트를 보내며 이미 열린 다운로드·전송 화면에서는 삭제 안내를 표시합니다. 권한 오류는 503 안내를 반환하고 DB 기록을 삭제하지 않습니다. 원본을 수동으로 넣거나 이름을 바꿔도 새 보관함 항목을 자동 생성하지 않습니다. `temporary`·`chunks`는 진행 중 업로드에 사용하는 폴더입니다.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
받은 항목과 임시 저장은 각각 저장 시점부터 최대 7일이다. 기존 자료도 마이그레이션 시점부터 최대 7일로 제한하며, 더 가까운 기존 만료는 유지한다. 즐겨찾기로 만료를 우회할 수 없다. 만료 항목과 접근권을 정리하고 마지막 유효한 파일 참조가 사라지면 원본 파일을 제거한다. 새 전송의 수신 기록은 새 저장 시점부터 7일이며 원본 기록의 만료를 연장하지 않는다.
|
||||
|
||||
물리 파일은 `uploads/originals`에 저장하고 안전한 원래 확장자를 무작위 식별자에 붙인다. 수신자의 다운로드 응답이 끝난 뒤 유효한 계정 보관함 참조가 없으면 `uploads/completed`로 이동한다. 삭제 실행 취소 시간 안의 계정 참조도 보호한다. 진행 중인 다운로드·이미지 미리보기가 모두 종료될 때까지 이동을 미루며, 계정 참조가 생기거나 복원되면 정리 시 `originals`로 돌아온다. 폴더 이동으로 접근권·용량·만료일을 바꾸지 않는다. 두 폴더 모두 수동 삭제 감지와 만료 정리를 적용한다. 이동 기록으로 중단 후 DB·물리 경로를 복구하고, 확장자 없는 기존 파일은 시작 시 이름을 변경한다. 이전 다운로드 완료 여부는 추정하지 않는다.
|
||||
물리 파일은 `uploads/originals/user-계정ID` 또는 `uploads/originals/guest-기기세션ID`에 업로드한 사람 기준으로 저장하고 안전한 원래 확장자를 무작위 식별자에 붙인다. 로그인 중 기기 전송은 계정 폴더를 선택하되 기존 기기 세션의 업로드 권한·용량 정책을 유지한다. 수신 보관함은 같은 원본을 참조하며 다른 수신자별로 복제하지 않는다. 다운로드 완료로 폴더를 바꾸지 않으며 `completed`는 사용하지 않는다. 기존 평면 및 `completed` 파일은 시작 시 기록된 업로드 소유자 폴더로 이동한다. 계정 정보가 기록되지 않은 과거 파일은 기기 세션 소유자를 사용하며 추정하지 않는다. 진행 중인 파일 읽기는 보호하며 이동 기록으로 중단 후 DB·물리 경로를 복구한다. 사용자 폴더 안에도 수동 삭제 감지와 기존 만료·고아 정리를 적용한다.
|
||||
|
||||
## 주변 발견과 방
|
||||
|
||||
|
||||
@@ -174,3 +174,10 @@
|
||||
- 물리 파일명에 안전한 원래 확장자를 붙이며 확장자 없는 기존 파일은 앱 시작 시 DB와 함께 변경한다. 이동은 진행 중 다운로드·이미지 미리보기가 종료될 때까지 미룬다. DB 변경 실패 시 파일을 원래 경로로 돌리고, 이동 기록으로 재시작 중 경로 불일치를 복구한다. 기존 다운로드 완료 여부는 소급 추정하지 않는다. 두 폴더에 수동 삭제 감지·기존 만료 및 고아 파일 정리를 적용했다.
|
||||
- API 총 112개 통과. 새 실제 파일 검사 10개로 로그인 발신→익명 수신 완료 분류·익명 발신→로그인 수신 보관함 유지·재다운로드·만료일과 체크섬 보존·권한 없는 읽기 및 발신자 읽기의 분류 제외·동시 읽기·HTTP 중도 종료·기존 확장자 이전·DB 실패 롤백·계정 삭제 실행 취소와 복원·재시작 복구·수동 삭제·자연 만료를 확인했다. 전체 빌드·타입 검사·포맷 및 셸 문법 검사도 통과했다.
|
||||
- 기존 익명 파일/링크 전송 및 PC/모바일 보관함·파일 재전송·수동 원본 삭제 브라우저 검증을 통과했다. 새 운영 Docker 이미지와 격리한 임시 DB 볼륨·바인드 업로드에서 완료 폴더와 `.txt` 확장자, 재시작 후 기존 주소 다운로드, 8MiB 조각 이어 보내기, Unicode 텍스트·세션 보존, SQLite integrity, 완료 폴더의 호스트 수동 삭제를 확인했다. NAS 설정 검사도 통과했고 `docker-compose.yaml`은 변경하지 않았다. 실제 NAS 재배포는 수행하지 않았다.
|
||||
|
||||
## 2026-10-14 업로드 주체별 저장과 첫 전송 복구
|
||||
|
||||
- `completed` 분류를 업로드한 사람 기준의 `originals/user-계정ID`·`originals/guest-기기세션ID`로 변경했다. 로그인 기기 전송은 계정 폴더를 사용하되 기존 기기 업로드 권한·용량 정책은 유지한다. 기존 평면 및 completed 파일은 기록된 업로드 소유자를 기준으로 이전하며 ID·만료·원래 이름·다운로드 주소와 공유 원본을 유지한다. 원본 확장자, DB 실패 롤백과 재시작 복구, 읽기 보호, 사용자 폴더 안의 수동 삭제·만료·고아 정리를 검증했다.
|
||||
- 네이티브 파일 선택창의 숨김/복귀 때 위치 재등록보다 POST가 먼저 나가던 상황을 수정 전 브라우저에서 재현했다. 위치 기반 대상은 위치 수신 확인까지 기다리며 같은 네트워크의 잠깐 끊긴 대상은 서버가 재접속 상태로 구분한다. 현재 연결을 최대 5초 기다리고 같은 요청 키로만 재시도해 첫 클릭의 초안·파일을 유지한다. 권한 없음·차단·다른 공간은 재시도하지 않는다. 연결 확인 중 버튼 안내를 적용했다. 개인정보 보호를 위해 숨김 시 좌표 제거는 유지한다.
|
||||
- 로그아웃 후 메인 기기 화면을 표시한다. 사용자 폴더의 owner-info.json에는 검증된 기본 접속 IP·국가·지역·도시만 업로드 요청 시 기록하며 원문·좌표·인증 값은 기록하지 않는다. 제공되지 않은 지역과 과거 파일은 추정하지 않는다. 최대 20개·기본 7일의 기록만 유지하고 마지막 원본/대기 업로드가 사라지면 제거한다. 공개 URL·일반 보관함에서는 제공하지 않는다.
|
||||
- API 114개·전체 빌드·타입 검사 통과. 실제 Chromium에서 파일 선택창 위치 복귀 대기와 단일 요청, 같은 네트워크의 재접속 중 자동 재시도·동일 키·파일 유지·모바일 수락 다운로드, 보관함 원본 재전송·수동 삭제·로그아웃 후 메인 화면을 확인했다. 운영 Docker의 사용자 폴더·확장자·호스트 파일 가시성·재시작 후 재다운로드·수동 삭제·8MiB 조각 재개·Unicode 텍스트와 인증 세션 보존·SQLite integrity도 통과했다. 실제 NAS 배포는 수행하지 않았다.
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs",
|
||||
"test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs",
|
||||
"test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs",
|
||||
"test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs"
|
||||
"test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs",
|
||||
"test:e2e:reconnect": "node --import tsx tests/e2e/transfer-reconnect.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
|
||||
@@ -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 completed temporary chunks; do
|
||||
for folder in originals temporary chunks; do
|
||||
mkdir -p "/uploads/$folder"
|
||||
chmod 700 "/uploads/$folder"
|
||||
done
|
||||
|
||||
@@ -151,12 +151,12 @@ 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.
|
||||
// Per-uploader 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());`,
|
||||
`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('owner folder download failed after restart');console.log(await r.text());`,
|
||||
),
|
||||
'Docker file persistence',
|
||||
);
|
||||
@@ -172,14 +172,22 @@ try {
|
||||
),
|
||||
);
|
||||
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
||||
const completed = resolve(folder, 'completed');
|
||||
const stored = readdirSync(completed).find(
|
||||
(key) => readFileSync(resolve(completed, key)).toString() === 'Docker file persistence',
|
||||
);
|
||||
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 originals = resolve(folder, 'originals');
|
||||
const stored = readdirSync(originals)
|
||||
.filter((key) => key.startsWith('guest-'))
|
||||
.flatMap((owner) =>
|
||||
readdirSync(resolve(originals, owner)).map((key) => ({
|
||||
owner,
|
||||
key,
|
||||
path: resolve(originals, owner, key),
|
||||
})),
|
||||
)
|
||||
.find(({ path }) => readFileSync(path).toString() === 'Docker file persistence');
|
||||
assert(stored, 'uploaded file is visible under its guest owner in the host project');
|
||||
assert.match(stored.owner, /^guest-[a-f0-9-]{36}$/);
|
||||
assert.match(stored.key, /^[a-f0-9-]{36}\.txt$/);
|
||||
assert.equal(existsSync(resolve(folder, 'completed')), false);
|
||||
unlinkSync(stored.path);
|
||||
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()}));`,
|
||||
|
||||
@@ -52,6 +52,16 @@ try {
|
||||
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
|
||||
navigator.geolocation.getCurrentPosition = (...args) => {
|
||||
window.__geoCalls++;
|
||||
if (window.__holdGeo) {
|
||||
const [success, failure, options] = args;
|
||||
return original(
|
||||
(position) => {
|
||||
window.__releaseGeo = () => success(position);
|
||||
},
|
||||
failure,
|
||||
options,
|
||||
);
|
||||
}
|
||||
return original(...args);
|
||||
};
|
||||
});
|
||||
@@ -104,6 +114,50 @@ try {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
// A native file picker hides the page. Sending immediately on return must wait for
|
||||
// the fresh location acknowledgement, without a failed first request or lost files.
|
||||
await sender.locator('.peer-card').first().click();
|
||||
await sender.evaluate(() => {
|
||||
window.__holdGeo = true;
|
||||
window.__geoVisible = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await receiver.locator('button.peer-card').waitFor({ state: 'hidden' });
|
||||
await sender
|
||||
.locator('.send-dialog input[type="file"]')
|
||||
.setInputFiles({
|
||||
name: 'picker.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('file picker return'),
|
||||
});
|
||||
await sender.evaluate(() => {
|
||||
window.__geoVisible = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await sender.waitForFunction(() => Boolean(window.__releaseGeo));
|
||||
let transferPosts = 0;
|
||||
const countTransfer = (request) => {
|
||||
if (request.method() === 'POST' && new URL(request.url()).pathname === '/api/transfer-requests')
|
||||
transferPosts++;
|
||||
};
|
||||
sender.on('request', countTransfer);
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await sender.waitForTimeout(250);
|
||||
assert.equal(transferPosts, 0, '위치 복귀 전 요청을 보내지 않음');
|
||||
assert.equal(await sender.locator('.error-toast').count(), 0, '첫 전송 오류 없음');
|
||||
assert.match(await sender.locator('.selected-files').innerText(), /picker.txt/);
|
||||
await sender.evaluate(() => {
|
||||
window.__holdGeo = false;
|
||||
window.__releaseGeo();
|
||||
delete window.__releaseGeo;
|
||||
});
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
const pickedDownload = receiver.waitForEvent('download');
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
assert.equal((await pickedDownload).suggestedFilename(), 'picker.txt');
|
||||
assert.equal(transferPosts, 1, '파일 요청은 한 번만 생성');
|
||||
sender.off('request', countTransfer);
|
||||
await sender.locator('.send-dialog').waitFor({ state: 'hidden' });
|
||||
await b.route('https://example.com/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Nearby link</title>' }),
|
||||
);
|
||||
|
||||
@@ -336,8 +336,7 @@ try {
|
||||
await receiver.locator('.current-vault').waitFor();
|
||||
assert.equal(await receiver.locator('.content-submit').isEnabled(), true);
|
||||
let removed = false;
|
||||
for (const key of await readdir(runtime.service.storage.originals)) {
|
||||
const path = resolve(runtime.service.storage.originals, key);
|
||||
for await (const { path } of runtime.service.storage.layout.entries()) {
|
||||
if ((await readFile(path)).equals(bytes)) {
|
||||
await unlink(path);
|
||||
removed = true;
|
||||
@@ -392,6 +391,13 @@ try {
|
||||
);
|
||||
await sender.locator('.error-toast').waitFor({ state: 'hidden', timeout: 8000 });
|
||||
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
|
||||
await receiver.locator('.nearby-section').waitFor();
|
||||
assert.equal(
|
||||
await receiver.locator('.account-auth').count(),
|
||||
0,
|
||||
'로그아웃 후 로그인 화면 대신 메인 기기 화면',
|
||||
);
|
||||
await receiver.locator('.login-button').click();
|
||||
await receiver.locator('.account-auth').waitFor();
|
||||
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
|
||||
const signup = receiver.locator('.account-auth');
|
||||
|
||||
102
tests/e2e/transfer-reconnect.mjs
Normal file
102
tests/e2e/transfer-reconnect.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { createApp } from '../../apps/server/src/app.ts';
|
||||
import { loadConfig } from '../../apps/server/src/config.ts';
|
||||
import { requests as table } from '../../apps/server/src/db/schema.ts';
|
||||
|
||||
const folder = await mkdtemp(resolve(tmpdir(), 'hop-reconnect-'));
|
||||
const runtime = await createApp({
|
||||
...loadConfig({
|
||||
APP_URL: 'http://127.0.0.1',
|
||||
DATABASE_PATH: resolve(folder, 'db.sqlite'),
|
||||
UPLOAD_DIR: resolve(folder, 'uploads'),
|
||||
MIN_FREE_DISK_MB: '0',
|
||||
SAME_NETWORK_DISCOVERY_ENABLED: 'true',
|
||||
LOCATION_DISCOVERY_ENABLED: 'false',
|
||||
SESSION_SECRET: 's'.repeat(40),
|
||||
DISCOVERY_SECRET: 'd'.repeat(40),
|
||||
}),
|
||||
webRoot: resolve('apps/web/dist'),
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
|
||||
runtime.setDevOrigin(url);
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.HOP_BROWSER_EXECUTABLE
|
||||
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
|
||||
: {}),
|
||||
});
|
||||
const a = await browser.newContext({ viewport: { width: 1280, height: 900 } }),
|
||||
b = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const sender = await a.newPage(),
|
||||
receiver = await b.newPage(),
|
||||
errors = [];
|
||||
for (const page of [sender, receiver])
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await Promise.all([sender.goto(url), receiver.goto(url)]);
|
||||
await sender.locator('.peer-card').first().waitFor();
|
||||
const receiverState = await receiver.evaluate(
|
||||
async () => (await (await fetch('/api/device/me')).json()).device,
|
||||
);
|
||||
const guestId = runtime.service.deviceGuest(receiverState.id).guestId;
|
||||
await sender.locator('.peer-card').first().click();
|
||||
const bytes = Buffer.from('first-click transfer');
|
||||
await sender
|
||||
.locator('.send-dialog input[type="file"]')
|
||||
.setInputFiles({ name: 'first.txt', mimeType: 'text/plain', buffer: bytes });
|
||||
let hold = true;
|
||||
runtime.io.use((socket, next) => {
|
||||
if (hold && socket.data.guest.id === guestId) setTimeout(() => next(), 1200);
|
||||
else next();
|
||||
});
|
||||
const serverSocket = [...runtime.io.sockets.sockets.values()].find(
|
||||
(socket) => socket.data.guest.id === guestId,
|
||||
);
|
||||
serverSocket.conn.close();
|
||||
await receiver.locator('.app-shell[data-online="false"]').waitFor();
|
||||
const responses = [],
|
||||
keys = [];
|
||||
sender.on('response', (response) => {
|
||||
if (
|
||||
new URL(response.url()).pathname === '/api/transfer-requests' &&
|
||||
response.request().method() === 'POST'
|
||||
) {
|
||||
responses.push(response.status());
|
||||
keys.push(response.request().headers()['idempotency-key']);
|
||||
}
|
||||
});
|
||||
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await sender.getByRole('button', { name: '연결 확인 중', exact: true }).waitFor();
|
||||
assert.equal(await sender.locator('.error-toast').count(), 0);
|
||||
assert.match(await sender.locator('.selected-files').innerText(), /first.txt/);
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
|
||||
hold = false;
|
||||
assert.equal(responses[0], 409);
|
||||
assert.equal(responses.at(-1), 200);
|
||||
assert.equal(new Set(keys).size, 1, 'reconnect retry reuses one idempotency key');
|
||||
assert.equal(runtime.service.db.select().from(table).all().length, 1, 'only one logical request');
|
||||
const downloaded = receiver.waitForEvent('download');
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
const download = await downloaded;
|
||||
assert.equal(download.suggestedFilename(), 'first.txt');
|
||||
assert.deepEqual(await readFile(await download.path()), bytes);
|
||||
assert.equal(await sender.locator('.error-toast').count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: same-network peer reconnect on first file send, connection feedback, file draft retained, stable retry key, one request, accepted download on mobile.',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await runtime.app.close();
|
||||
await rm(folder, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user