Organize uploader storage and restore transfer readiness
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user