Use visible NAS upload storage and reconcile missing files
This commit is contained in:
@@ -401,6 +401,7 @@ export class Accounts {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.service.storage.availability.require(row!.item.fileId);
|
||||
return row!;
|
||||
}
|
||||
read(userId: string, id: string, value: boolean) {
|
||||
@@ -425,6 +426,7 @@ export class Accounts {
|
||||
query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean },
|
||||
) {
|
||||
this.verified(userId);
|
||||
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
|
||||
let after: { createdAt: number; id: string } | undefined;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
@@ -520,6 +522,7 @@ export class Accounts {
|
||||
};
|
||||
}
|
||||
vaultFiles(userId: string) {
|
||||
this.service.storage.availability.reconcile({ id: userId, kind: 'user' });
|
||||
const rows = this.service.db
|
||||
.select({ file: s.files })
|
||||
.from(s.access)
|
||||
|
||||
@@ -6,7 +6,6 @@ class PrivateLogs extends LogController {
|
||||
}
|
||||
import cookie from '@fastify/cookie';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import serveStatic from '@fastify/static';
|
||||
import { Server } from 'socket.io';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
@@ -602,7 +601,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, path } = service.storage.download(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(
|
||||
@@ -616,8 +615,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
)
|
||||
.header('Content-Length', file.size)
|
||||
.type('application/octet-stream');
|
||||
readAccess(g, accessId, true);
|
||||
return reply.send(createReadStream(path));
|
||||
try {
|
||||
readAccess(g, accessId, true);
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
throw error;
|
||||
}
|
||||
return reply.send(stream);
|
||||
});
|
||||
app.get('/api/access/:id/preview', async (req, reply) =>
|
||||
reply
|
||||
|
||||
115
apps/server/src/fileAvailability.js
Normal file
115
apps/server/src/fileAvailability.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, gt, inArray } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { fail } from './security.js';
|
||||
|
||||
export class FileAvailability {
|
||||
constructor(service, directory) {
|
||||
this.service = service;
|
||||
this.directory = directory;
|
||||
this.pending = new Set();
|
||||
}
|
||||
check(file) {
|
||||
try {
|
||||
if (statSync(resolve(this.directory, file.storageKey)).isFile()) return true;
|
||||
} catch (error) {
|
||||
if (!['ENOENT', 'ENOTDIR'].includes(error.code))
|
||||
fail(
|
||||
503,
|
||||
'STORAGE_UNAVAILABLE',
|
||||
'파일 저장소에 접근할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
);
|
||||
}
|
||||
this.missing(file.id);
|
||||
return false;
|
||||
}
|
||||
require(id) {
|
||||
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
|
||||
if (!file || file.status !== 'stored' || !this.check(file))
|
||||
fail(404, 'FILE_NOT_FOUND', '서버의 파일이 삭제되었거나 만료되었습니다. 다시 전송해 주세요.');
|
||||
return file;
|
||||
}
|
||||
reconcile(subject) {
|
||||
const rows = this.service.db
|
||||
.select({ file: s.files })
|
||||
.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'),
|
||||
subject.kind === 'user'
|
||||
? eq(s.access.userId, subject.id)
|
||||
: eq(s.access.guestId, subject.id),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const file of new Map(rows.map(({ file }) => [file.id, file])).values()) this.check(file);
|
||||
}
|
||||
invalidate(id) {
|
||||
const db = this.service.db;
|
||||
return db.transaction(() => {
|
||||
const refs = db
|
||||
.select({ access: s.access })
|
||||
.from(s.access)
|
||||
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
|
||||
.where(eq(s.items.fileId, id))
|
||||
.all();
|
||||
const transfers = db
|
||||
.select({ id: s.requests.id })
|
||||
.from(s.vaultTransfers)
|
||||
.innerJoin(s.access, eq(s.vaultTransfers.accessId, s.access.id))
|
||||
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
|
||||
.innerJoin(s.requests, eq(s.vaultTransfers.requestId, s.requests.id))
|
||||
.innerJoin(s.batches, eq(s.batches.requestId, s.requests.id))
|
||||
.where(
|
||||
and(
|
||||
eq(s.items.fileId, id),
|
||||
inArray(s.requests.status, ['pending', 'accepted']),
|
||||
eq(s.batches.status, 'waiting'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const now = Date.now();
|
||||
db.update(s.files)
|
||||
.set({ status: 'failed', checksum: null, updatedAt: now })
|
||||
.where(and(eq(s.files.id, id), eq(s.files.status, 'stored')))
|
||||
.run();
|
||||
db.update(s.items)
|
||||
.set({ expiresAt: now })
|
||||
.where(and(eq(s.items.fileId, id), gt(s.items.expiresAt, now)))
|
||||
.run();
|
||||
db.delete(s.reservations).where(eq(s.reservations.fileId, id)).run();
|
||||
for (const transfer of transfers) {
|
||||
db.update(s.requests)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq(s.requests.id, transfer.id))
|
||||
.run();
|
||||
db.update(s.batches)
|
||||
.set({ status: 'failed' })
|
||||
.where(eq(s.batches.requestId, transfer.id))
|
||||
.run();
|
||||
}
|
||||
return { refs, transfers };
|
||||
});
|
||||
}
|
||||
missing(id) {
|
||||
const affected = this.invalidate(id);
|
||||
if (this.pending.has(id)) return;
|
||||
this.pending.add(id);
|
||||
// A failed idempotent operation can roll back its outer transaction. Reapply afterward.
|
||||
queueMicrotask(() => {
|
||||
this.pending.delete(id);
|
||||
const file = this.service.db.select().from(s.files).where(eq(s.files.id, id)).get();
|
||||
const retried = file?.status === 'stored' ? this.invalidate(id) : { refs: [], transfers: [] };
|
||||
const refs = [...affected.refs, ...retried.refs].map(({ access }) => access);
|
||||
for (const user of new Set(refs.map((access) => access.userId).filter(Boolean)))
|
||||
this.service.accounts.notify(user);
|
||||
for (const guest of new Set(refs.map((access) => access.guestId).filter(Boolean)))
|
||||
this.service.presence.emit(guest, 'access:updated', {});
|
||||
for (const transfer of [...affected.transfers, ...retried.transfers])
|
||||
this.service.notifyTransfer(transfer.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -843,6 +843,7 @@ export class Service {
|
||||
}));
|
||||
}
|
||||
listItems(g: Guest, cursor?: string, limit = 30) {
|
||||
this.storage.availability.reconcile(g);
|
||||
let after: { createdAt: number; id: string } | undefined;
|
||||
if (cursor) {
|
||||
try {
|
||||
@@ -933,6 +934,7 @@ export class Service {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.storage.availability.require(row!.item.fileId);
|
||||
return row!;
|
||||
}
|
||||
read(g: Guest, id: string, value: boolean) {
|
||||
@@ -950,6 +952,7 @@ export class Service {
|
||||
)
|
||||
.get();
|
||||
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
|
||||
if (row!.item.fileId) this.storage.availability.require(row!.item.fileId);
|
||||
this.db
|
||||
.update(s.access)
|
||||
.set({ read: value })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, statfsSync, existsSync } from 'node:fs';
|
||||
import { createWriteStream, createReadStream } from 'node:fs';
|
||||
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 { Transform, Writable } from 'node:stream';
|
||||
@@ -11,6 +11,7 @@ import type { FastifyRequest } from 'fastify';
|
||||
import type { Guest, Service } from './service.js';
|
||||
import * as s from './db/schema.js';
|
||||
import { ApiError, fail } from './security.js';
|
||||
import { FileAvailability } from './fileAvailability.js';
|
||||
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
|
||||
export class Storage {
|
||||
private active = new Map<
|
||||
@@ -21,11 +22,13 @@ export class Storage {
|
||||
private cleanupPromise: Promise<void> | undefined;
|
||||
readonly temp: string;
|
||||
readonly originals: string;
|
||||
readonly availability: FileAvailability;
|
||||
constructor(readonly service: Service) {
|
||||
this.temp = resolve(service.cfg.uploadDir, 'temporary');
|
||||
this.originals = resolve(service.cfg.uploadDir, 'originals');
|
||||
mkdirSync(this.temp, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(this.originals, { recursive: true, mode: 0o700 });
|
||||
this.availability = new FileAvailability(service, this.originals);
|
||||
}
|
||||
private request(g: StorageSubject, id: string) {
|
||||
return g.kind === 'user'
|
||||
@@ -72,6 +75,7 @@ export class Storage {
|
||||
.run();
|
||||
}
|
||||
private used(guestId?: string) {
|
||||
if (guestId) this.availability.reconcile({ id: guestId });
|
||||
return this.service.db
|
||||
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
|
||||
.from(s.files)
|
||||
@@ -429,14 +433,34 @@ export class Storage {
|
||||
const row = this.access(g, accessId);
|
||||
if (row.item.kind !== 'file' || !row.item.fileId)
|
||||
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
|
||||
const file = this.service.db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored')))
|
||||
.get();
|
||||
if (!file || !existsSync(resolve(this.originals, file.storageKey)))
|
||||
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
|
||||
return { file: file!, path: resolve(this.originals, file!.storageKey) };
|
||||
const file = this.availability.require(row.item.fileId!);
|
||||
return { file, path: resolve(this.originals, file.storageKey) };
|
||||
}
|
||||
async openDownload(g: StorageSubject, accessId: string) {
|
||||
const { file, path } = this.download(g, accessId);
|
||||
try {
|
||||
const handle = await open(path, 'r');
|
||||
return { file, stream: handle.createReadStream() };
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
['ENOENT', 'ENOTDIR'].includes(String(error.code))
|
||||
) {
|
||||
this.availability.missing(file.id);
|
||||
throw new ApiError(
|
||||
404,
|
||||
'FILE_NOT_FOUND',
|
||||
'서버의 파일이 삭제되었습니다. 다시 전송해 주세요.',
|
||||
);
|
||||
}
|
||||
throw new ApiError(
|
||||
503,
|
||||
'STORAGE_UNAVAILABLE',
|
||||
'파일 저장소에 접근할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
);
|
||||
}
|
||||
}
|
||||
async preview(g: StorageSubject, id: string) {
|
||||
const { path } = this.download(g, id);
|
||||
@@ -582,6 +606,7 @@ export class Storage {
|
||||
.where(inArray(s.files.status, ['stored', 'cleaning']))
|
||||
.all();
|
||||
for (const file of all) {
|
||||
if (file.status === 'stored' && !this.availability.check(file)) continue;
|
||||
const refs = db
|
||||
.select({ id: s.items.id })
|
||||
.from(s.items)
|
||||
|
||||
271
apps/server/test/missing-files.test.js
Normal file
271
apps/server/test/missing-files.test.js
Normal file
@@ -0,0 +1,271 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { unlink, chmod } from 'node:fs/promises';
|
||||
import { unlinkSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
|
||||
async function signIn(f, client) {
|
||||
const id = randomUUID(),
|
||||
email = `${id}@hop.test`;
|
||||
f.service.db
|
||||
.insert(s.users)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
displayName: email,
|
||||
passwordHash: await f.service.auth.password('1234'),
|
||||
status: 'active',
|
||||
verifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
const response = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
|
||||
client.socket.disconnect();
|
||||
await f.connect(client);
|
||||
}
|
||||
async function save(f, client, bytes = Buffer.from('test')) {
|
||||
const request = await f.call(
|
||||
client,
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: 'file',
|
||||
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(request.statusCode, 200, request.body);
|
||||
const boundary = 'missing-file-test';
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`,
|
||||
),
|
||||
bytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
const response = await f.call(
|
||||
client,
|
||||
`/uploads?requestId=${request.json().requestId}&fileIndex=0`,
|
||||
'POST',
|
||||
body,
|
||||
randomUUID(),
|
||||
{ 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const row = f.service.storage.fileRows(request.json().requestId)[0];
|
||||
const item = (await f.call(client, '/vault'))
|
||||
.json()
|
||||
.items.find((item) => item.file?.id === row.id);
|
||||
return { file: row, item, path: resolve(f.service.storage.originals, row.storageKey) };
|
||||
}
|
||||
async function forward(f, sender, receiver, item, accept = true) {
|
||||
const response = await f.call(
|
||||
sender,
|
||||
`/vault/${item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: receiver.deviceId },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const id = response.json().requestId;
|
||||
if (accept)
|
||||
assert.equal(
|
||||
(await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
function limits(f) {
|
||||
f.cfg.sessionRate = 60;
|
||||
f.cfg.pairRate = 60;
|
||||
f.cfg.ipRate = 100;
|
||||
}
|
||||
|
||||
for (const trigger of ['download', 'list', 'quota', 'cleanup']) {
|
||||
test(`수동 삭제(${trigger}): 공유 원본의 모든 기록·개인 용량 정리와 정상 파일/링크·페이지 유지`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
outsider = await f.create();
|
||||
await f.space(a, b);
|
||||
await signIn(f, a);
|
||||
await signIn(f, b);
|
||||
const healthy = await save(f, a, Buffer.from('healthy'));
|
||||
const link = await f.call(
|
||||
a,
|
||||
'/vault',
|
||||
'POST',
|
||||
{ kind: 'url', urls: ['https://example.com/healthy'] },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(link.statusCode, 200, link.body);
|
||||
const missing = await save(f, a);
|
||||
await forward(f, a, b, missing.item);
|
||||
const received = (await f.call(b, '/inbox')).json().items[0];
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 11);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 4);
|
||||
await unlink(missing.path);
|
||||
const foreign = await f.call(outsider, `/access/${missing.item.accessId}/download`);
|
||||
assert(foreign.statusCode === 401 || foreign.statusCode === 404);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
||||
'stored',
|
||||
'무권한 접근으로 정리/존재 확인을 유발하지 않음',
|
||||
);
|
||||
if (trigger === 'download') {
|
||||
const response = await f.call(b, `/access/${received.accessId}/download`);
|
||||
assert.equal(response.statusCode, 404, response.body);
|
||||
assert.match(response.json().message, /다시 전송/);
|
||||
} else if (trigger === 'list') {
|
||||
await f.call(a, '/vault');
|
||||
} else if (trigger === 'quota') {
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
} else {
|
||||
const update = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(Error('missing notification')), 2000);
|
||||
b.socket.once('inbox:updated', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await f.service.storage.cleanup();
|
||||
await update;
|
||||
}
|
||||
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
assert.equal(
|
||||
(await f.call(a, '/vault/contents'))
|
||||
.json()
|
||||
.items.some((i) => i.file?.id === missing.file.id),
|
||||
false,
|
||||
);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 7);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
const page = (await f.call(a, '/vault?limit=1')).json();
|
||||
assert.equal(page.items.length, 1);
|
||||
assert(page.nextCursor);
|
||||
const next = (
|
||||
await f.call(a, `/vault?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`)
|
||||
).json();
|
||||
assert.equal(next.items.length, 1);
|
||||
assert.equal(next.nextCursor, null);
|
||||
const remaining = [...page.items, ...next.items];
|
||||
assert(remaining.some((i) => i.file?.id === healthy.file.id));
|
||||
assert(remaining.some((i) => i.url === 'https://example.com/healthy'));
|
||||
assert.deepEqual(
|
||||
(await f.call(a, `/access/${healthy.item.accessId}/download`)).rawPayload,
|
||||
Buffer.from('healthy'),
|
||||
);
|
||||
assert.equal((await f.call(a, `/access/${missing.item.accessId}/preview`)).statusCode, 404);
|
||||
f.service.cleanup();
|
||||
await f.service.storage.cleanup();
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 2);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ['send', 'accept']) {
|
||||
test(`수동 삭제 뒤 ${action} 실패: 외부 멱등 트랜잭션 롤백 후에도 삭제 감지와 대기 전송 취소 유지`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
await signIn(f, a);
|
||||
const missing = await save(f, a);
|
||||
const pending = action === 'accept' ? await forward(f, a, b, missing.item, false) : null;
|
||||
await unlink(missing.path);
|
||||
const result =
|
||||
action === 'accept'
|
||||
? await f.call(b, `/transfer-requests/${pending}/accept`, 'POST', {}, randomUUID())
|
||||
: await f.call(
|
||||
a,
|
||||
`/vault/${missing.item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: b.deviceId },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(result.statusCode, 404, result.body);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
||||
'failed',
|
||||
);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
||||
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
if (pending) {
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
|
||||
'cancelled',
|
||||
);
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.batches).where(eq(s.batches.requestId, pending)).get()
|
||||
.status,
|
||||
'failed',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('권한 오류는 삭제로 오인하지 않고 503 안내 후 권한 복구 시 원본·DB·용량 유지', async (t) => {
|
||||
if (process.getuid?.() === 0) return t.skip('root bypasses filesystem permissions');
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create();
|
||||
await signIn(f, a);
|
||||
const saved = await save(f, a);
|
||||
await chmod(f.service.storage.originals, 0);
|
||||
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
||||
assert.equal(response.statusCode, 503, response.body);
|
||||
assert.equal(response.json().code, 'STORAGE_UNAVAILABLE');
|
||||
assert.equal(
|
||||
f.service.db.select().from(s.files).where(eq(s.files.id, saved.file.id)).get().status,
|
||||
'stored',
|
||||
);
|
||||
await chmod(f.service.storage.originals, 0o700);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 4);
|
||||
assert.deepEqual(
|
||||
(await f.call(a, `/access/${saved.item.accessId}/download`)).rawPayload,
|
||||
Buffer.from('test'),
|
||||
);
|
||||
} finally {
|
||||
await chmod(f.service.storage.originals, 0o700);
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('메타데이터 확인 직후 원본이 사라져도 다운로드는 500 대신 명확한 404 반환', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create();
|
||||
await signIn(f, a);
|
||||
const saved = await save(f, a);
|
||||
const download = f.service.storage.download.bind(f.service.storage);
|
||||
f.service.storage.download = (...args) => {
|
||||
const result = download(...args);
|
||||
unlinkSync(saved.path);
|
||||
return result;
|
||||
};
|
||||
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
||||
assert.equal(response.statusCode, 404, response.body);
|
||||
assert.equal(response.json().code, 'FILE_NOT_FOUND');
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -65,8 +65,7 @@ const storage = ref({ usedBytes: 0, limitBytes: 0 });
|
||||
const securityAction = ref('none'),
|
||||
currentPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
newConfirmation = ref(''),
|
||||
newEmail = ref('');
|
||||
newConfirmation = ref('');
|
||||
const preferencesDialog = ref(null);
|
||||
let preferencesTrigger;
|
||||
watch(preferencesOpen, async (open) => {
|
||||
@@ -106,7 +105,6 @@ function selectSecurityAction(action = 'none') {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = action;
|
||||
}
|
||||
function openPreferences() {
|
||||
@@ -181,7 +179,6 @@ function clearPrivate() {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
}
|
||||
const refreshLists = createRefreshQueue(async () => {
|
||||
@@ -377,6 +374,12 @@ async function copy(item) {
|
||||
});
|
||||
}
|
||||
async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (error) {
|
||||
await refreshLists().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file.name;
|
||||
@@ -429,13 +432,6 @@ async function securitySubmit() {
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
} else {
|
||||
await api('/auth/change-email', 'POST', {
|
||||
email: newEmail.value,
|
||||
password: currentPassword.value,
|
||||
});
|
||||
selectSecurityAction();
|
||||
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -602,19 +598,19 @@ async function resendVerification() {
|
||||
</div>
|
||||
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'inbox'"
|
||||
:class="{ active: view === 'inbox' }"
|
||||
@click="view = 'inbox'"
|
||||
>
|
||||
받은 항목
|
||||
받은 항목</button
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장
|
||||
</button>
|
||||
</div>
|
||||
<form class="compact-search" @submit.prevent="act(refreshLists)">
|
||||
@@ -662,7 +658,7 @@ async function resendVerification() {
|
||||
><button type="button" class="text-button" @click="editing = null">취소</button>
|
||||
</form>
|
||||
<div class="item-footer">
|
||||
<span>{{ date(item.expiresAt) }} 삭제</span>
|
||||
<span>보관 만료 · {{ date(item.expiresAt) }}까지</span>
|
||||
<div>
|
||||
<button class="button small" @click="emit('send-item', item)">
|
||||
<Send :size="14" />기기로 보내기</button
|
||||
@@ -801,13 +797,7 @@ async function resendVerification() {
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
securityAction === 'password'
|
||||
? '비밀번호 변경'
|
||||
: securityAction === 'email'
|
||||
? '이메일 변경'
|
||||
: '계정 설정'
|
||||
}}
|
||||
{{ securityAction === 'password' ? '비밀번호 변경' : '계정 설정' }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="closePreferences" aria-label="계정 설정 닫기">
|
||||
@@ -820,13 +810,6 @@ async function resendVerification() {
|
||||
<strong>이메일</strong>
|
||||
<p>{{ account.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('email')"
|
||||
aria-label="이메일 변경"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
@@ -843,32 +826,26 @@ async function resendVerification() {
|
||||
</div>
|
||||
</div>
|
||||
<form v-else class="preferences-form" @submit.prevent="securitySubmit">
|
||||
<p v-if="securityAction === 'email'" class="preferences-hint">
|
||||
새 이메일로 받은 인증을 완료하면 변경됩니다.
|
||||
</p>
|
||||
<label
|
||||
>현재 비밀번호<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
required
|
||||
autocomplete="current-password" /></label
|
||||
><template v-if="securityAction === 'password'"
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label></template
|
||||
><label v-else
|
||||
>새 이메일<input type="email" v-model="newEmail" required autocomplete="email" /></label
|
||||
><label
|
||||
>새 비밀번호<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><label
|
||||
>새 비밀번호 확인<input
|
||||
type="password"
|
||||
v-model="newConfirmation"
|
||||
:minlength="passwordMinLength"
|
||||
required
|
||||
autocomplete="new-password" /></label
|
||||
><button class="button primary" :disabled="busy">{{ busy ? '변경 중…' : '변경' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { createNearbySearch } from './nearbySearch';
|
||||
import { useVisibleViewport } from './viewport';
|
||||
useVisibleViewport();
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
@@ -423,16 +424,31 @@ const vaultItems = ref([]),
|
||||
vaultCursor = ref(null),
|
||||
forwardedItem = ref(null),
|
||||
itemToSend = ref(null);
|
||||
async function loadVaultChoices() {
|
||||
const refreshVaultChoices = createRefreshQueue(async () => {
|
||||
if (!account.value) return;
|
||||
const owner = account.value.id;
|
||||
await act(async () => {
|
||||
const result = await api('/vault/contents');
|
||||
const wanted = Math.max(30, vaultItems.value.length);
|
||||
const refreshed = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: String(Math.min(100, wanted - refreshed.length)) });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const result = await api(`/vault/contents?${query}`);
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value = result.items;
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
refreshed.push(...result.items);
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor && refreshed.length < wanted);
|
||||
vaultItems.value = refreshed;
|
||||
vaultCursor.value = cursor;
|
||||
});
|
||||
async function loadVaultChoices() {
|
||||
await act(refreshVaultChoices);
|
||||
}
|
||||
const onVaultUpdated = () => {
|
||||
if (sendOpen.value && account.value && vaultItems.value.length)
|
||||
void refreshVaultChoices().catch(showError);
|
||||
};
|
||||
window.addEventListener('hop-account-updated', onVaultUpdated);
|
||||
async function moreVault() {
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
@@ -515,6 +531,7 @@ const onVisibility = () => {
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hop-account-updated', onVaultUpdated);
|
||||
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
@@ -831,7 +848,17 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
|
||||
<div
|
||||
v-if="
|
||||
receivedLinks.length &&
|
||||
!sendOpen &&
|
||||
!modal &&
|
||||
!incoming.length &&
|
||||
!receivedTexts.length
|
||||
"
|
||||
class="received-link-notice"
|
||||
role="status"
|
||||
>
|
||||
<Link :size="18" /><span>링크를 열었어요.</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
|
||||
><button
|
||||
@@ -1116,7 +1143,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<X :size="17" />
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
|
||||
@@ -146,8 +146,17 @@ function connect() {
|
||||
])
|
||||
socket.on(event, () => void refresh().catch(showError));
|
||||
}
|
||||
let errorTimer;
|
||||
export function showError(err) {
|
||||
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
clearTimeout(errorTimer);
|
||||
const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
: raw;
|
||||
error.value = message;
|
||||
errorTimer = setTimeout(() => {
|
||||
if (error.value === message) error.value = '';
|
||||
}, 5000);
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
@@ -523,6 +532,12 @@ export async function undoDelete(id) {
|
||||
await refresh();
|
||||
}
|
||||
export async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (err) {
|
||||
await refresh().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file?.name ?? '';
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
const blockedLinks = ref([]);
|
||||
const receivedTexts = ref([]);
|
||||
const receivedLinks = ref([]);
|
||||
let linkNoticeTimer;
|
||||
function reserve(request) {
|
||||
if (pending.has(request.id)) return;
|
||||
const tabs = [];
|
||||
@@ -57,6 +58,10 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
try {
|
||||
tab.location.replace(item.url);
|
||||
receivedLinks.value = [item];
|
||||
clearTimeout(linkNoticeTimer);
|
||||
linkNoticeTimer = setTimeout(() => {
|
||||
receivedLinks.value = [];
|
||||
}, 5000);
|
||||
void markRead(item).catch(onError);
|
||||
} catch {
|
||||
tab.close();
|
||||
@@ -73,6 +78,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
void markRead(item).catch(onError);
|
||||
}
|
||||
function dispose() {
|
||||
clearTimeout(linkNoticeTimer);
|
||||
for (const id of pending.keys()) abort(id);
|
||||
blockedLinks.value = [];
|
||||
receivedTexts.value = [];
|
||||
|
||||
@@ -1186,10 +1186,21 @@ h1 span {
|
||||
bottom: 90px;
|
||||
background: #8b4036;
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
padding: 4px 16px;
|
||||
gap: 0;
|
||||
}
|
||||
.error-toast button {
|
||||
.error-toast > span {
|
||||
min-width: 0;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-toast .icon-button {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin-right: -13px;
|
||||
}
|
||||
.inline-info {
|
||||
font-size: 11px;
|
||||
|
||||
Reference in New Issue
Block a user