Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
@@ -515,15 +515,8 @@ export class Accounts {
|
||||
: null,
|
||||
};
|
||||
}
|
||||
used(userId: string) {
|
||||
const db = this.service.db;
|
||||
const owned = db
|
||||
.select()
|
||||
.from(s.files)
|
||||
.where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning'])))
|
||||
.all();
|
||||
const accounted = new Map(owned.map((f) => [f.id, f.size]));
|
||||
const preserved = db
|
||||
vaultFiles(userId: string) {
|
||||
const rows = this.service.db
|
||||
.select({ file: s.files })
|
||||
.from(s.access)
|
||||
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
|
||||
@@ -531,14 +524,31 @@ export class Accounts {
|
||||
.where(
|
||||
and(
|
||||
eq(s.access.userId, userId),
|
||||
or(eq(s.access.favorite, true), eq(s.access.role, 'self')),
|
||||
inArray(s.access.role, ['self', 'recipient']),
|
||||
isNull(s.access.deletedAt),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
eq(s.files.status, 'stored'),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const { file } of preserved) accounted.set(file.id, file.size);
|
||||
return [...accounted.values()].reduce((a, b) => a + b, 0);
|
||||
return new Map(rows.map(({ file }) => [file.id, file.size]));
|
||||
}
|
||||
used(userId: string) {
|
||||
return [...this.vaultFiles(userId).values()].reduce((sum, bytes) => sum + bytes, 0);
|
||||
}
|
||||
canStoreFile(userId: string, file: typeof s.files.$inferSelect) {
|
||||
const files = this.vaultFiles(userId);
|
||||
if (files.has(file.id)) return true;
|
||||
const reserved = this.service.db
|
||||
.select()
|
||||
.from(s.reservations)
|
||||
.where(and(eq(s.reservations.userId, userId), gt(s.reservations.expiresAt, Date.now())))
|
||||
.all()
|
||||
.reduce((sum, row) => sum + row.bytes, 0);
|
||||
return (
|
||||
[...files.values()].reduce((sum, bytes) => sum + bytes, 0) + reserved + file.size <=
|
||||
this.service.cfg.userQuota
|
||||
);
|
||||
}
|
||||
ensureQuota(userId: string, bytes: number) {
|
||||
if (this.used(userId) + bytes > this.service.cfg.userQuota)
|
||||
|
||||
@@ -30,7 +30,8 @@ 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 { ApiError, fail, clientIp } from './security.js';
|
||||
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
|
||||
import { CHUNK_BYTES } from './resumable.js';
|
||||
|
||||
export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) {
|
||||
const app = Fastify({
|
||||
@@ -49,6 +50,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 },
|
||||
});
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) =>
|
||||
done(null, body),
|
||||
);
|
||||
const { db, sqlite } = openDatabase(cfg.databasePath),
|
||||
service = new Service(db, cfg);
|
||||
let devOrigin: string | undefined;
|
||||
@@ -58,7 +62,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
(origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)),
|
||||
);
|
||||
const ip = (req: FastifyRequest) =>
|
||||
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies);
|
||||
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies, cfg.clientIpSource);
|
||||
const io = new Server(app.server, {
|
||||
serveClient: false,
|
||||
maxHttpBufferSize: 16384,
|
||||
@@ -93,6 +97,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
socket.request.socket.remoteAddress,
|
||||
socket.request.headers,
|
||||
cfg.trustedProxies,
|
||||
cfg.clientIpSource,
|
||||
);
|
||||
next();
|
||||
} catch {
|
||||
@@ -164,10 +169,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
);
|
||||
if (req.url.startsWith('/api/')) {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240);
|
||||
const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable');
|
||||
service.limiter.hit(
|
||||
`${resumable ? 'chunk' : 'api'}-ip:${ip(req) ?? req.raw.socket.remoteAddress}`,
|
||||
resumable ? 6000 : 240,
|
||||
);
|
||||
if (resumable) service.limiter.hit(`chunk-guest:${auth(req).id}`, 1200);
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
if (!allowed(req.headers.origin))
|
||||
fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.');
|
||||
fail(
|
||||
403,
|
||||
'ORIGIN_INVALID',
|
||||
`서비스 주소 ${cfg.appUrl}에서 접속해 주세요. 현재 접속 주소가 서버 설정과 다릅니다.`,
|
||||
);
|
||||
if (req.url.split('?')[0] !== '/api/guest/session') {
|
||||
auth(req);
|
||||
csrf(req);
|
||||
@@ -209,6 +223,20 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
expires: new Date(expiresAt),
|
||||
});
|
||||
app.get('/api/health', async () => ({ status: 'ok', phase: 1 }));
|
||||
app.get('/api/ops/proxy-diagnostic', async (req) => {
|
||||
if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.');
|
||||
const given = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
|
||||
if (!timingSafeEqual(Buffer.from(hash(given)), Buffer.from(hash(cfg.proxyDiagnosticToken!))))
|
||||
fail(403, 'OPERATOR_REQUIRED', '운영자 확인이 필요합니다.');
|
||||
const raw = req.headers['cf-connecting-ip'];
|
||||
const candidate = typeof raw === 'string' ? normalizeIp(raw.trim()) : null;
|
||||
return {
|
||||
peerIp: normalizeIp(req.raw.socket.remoteAddress ?? ''),
|
||||
cloudflareClientIpPresent: publicIp(candidate),
|
||||
source: cfg.clientIpSource,
|
||||
resolved: Boolean(ip(req)),
|
||||
};
|
||||
});
|
||||
app.post('/api/guest/session', async (req, reply) => {
|
||||
service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20);
|
||||
let token = req.cookies.hop_guest,
|
||||
@@ -409,6 +437,50 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
readAccess(accessSubject(req, id(req)), id(req), body.read);
|
||||
return { ok: true };
|
||||
});
|
||||
const resumableSubject = (req: FastifyRequest) => {
|
||||
const file = db
|
||||
.select()
|
||||
.from(tables.files)
|
||||
.where(eq(tables.files.id, id(req)))
|
||||
.get();
|
||||
if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.');
|
||||
return requestSubject(req, file!.requestId);
|
||||
};
|
||||
app.post('/api/uploads/resumable', async (req) => {
|
||||
const input = z
|
||||
.object({
|
||||
requestId: z.uuid(),
|
||||
fileIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(cfg.maxItems - 1),
|
||||
fingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
.strict()
|
||||
.parse(req.body);
|
||||
return service.resumable.start(
|
||||
requestSubject(req, input.requestId),
|
||||
input.requestId,
|
||||
input.fileIndex,
|
||||
input.fingerprint,
|
||||
);
|
||||
});
|
||||
app.get('/api/uploads/resumable/:id', async (req) =>
|
||||
service.resumable.status(resumableSubject(req), id(req)),
|
||||
);
|
||||
app.put('/api/uploads/resumable/:id', { bodyLimit: CHUNK_BYTES }, async (req) => {
|
||||
const offset = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(cfg.maxFileBytes)
|
||||
.parse(req.headers['upload-offset']);
|
||||
return service.resumable.append(resumableSubject(req), id(req), offset, req.body);
|
||||
});
|
||||
app.post('/api/uploads/resumable/:id/complete', async (req) =>
|
||||
service.resumable.complete(resumableSubject(req), id(req), req),
|
||||
);
|
||||
app.post('/api/uploads', async (req) => {
|
||||
const input = z
|
||||
.object({
|
||||
|
||||
18
apps/server/src/check-proxy.js
Normal file
18
apps/server/src/check-proxy.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { normalizeIp } from './security.js';
|
||||
const url = new URL('/api/ops/proxy-diagnostic', process.env.APP_URL);
|
||||
const token = process.env.PROXY_DIAGNOSTIC_TOKEN;
|
||||
if (!token) throw Error('먼저 configure-nas.sh를 실행하고 새 설정으로 재배포하세요.');
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw Error(`공개 서비스 주소를 통한 프록시 확인 실패: ${response.status}`);
|
||||
const result = await response.json();
|
||||
if (!result.cloudflareClientIpPresent || !normalizeIp(result.peerIp ?? ''))
|
||||
throw Error(
|
||||
'CF-Connecting-IP와 실제 인접 프록시 IP가 확인되지 않았습니다. NPM/Tunnel 설정을 확인하세요.',
|
||||
);
|
||||
console.log(`TRUSTED_PROXY_CIDRS=${result.peerIp}`);
|
||||
console.log('CLIENT_IP_SOURCE=cloudflare');
|
||||
console.log('SAME_NETWORK_DISCOVERY_ENABLED=true');
|
||||
@@ -8,6 +8,8 @@ export type Config = {
|
||||
discoverySecret: string;
|
||||
discoveryEnabled: boolean;
|
||||
trustedProxies: string[];
|
||||
clientIpSource: 'forwarded' | 'cloudflare';
|
||||
proxyDiagnosticToken?: string;
|
||||
port: number;
|
||||
host: string;
|
||||
guestDays: number;
|
||||
@@ -57,6 +59,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const appUrl = env.APP_URL ?? 'http://127.0.0.1';
|
||||
if (production && !appUrl.startsWith('https://'))
|
||||
throw Error('Production APP_URL must use HTTPS');
|
||||
if (env.CLIENT_IP_SOURCE && !['forwarded', 'cloudflare'].includes(env.CLIENT_IP_SOURCE))
|
||||
throw Error('Invalid CLIENT_IP_SOURCE');
|
||||
if (env.PROXY_DIAGNOSTIC_TOKEN && env.PROXY_DIAGNOSTIC_TOKEN.length < 32)
|
||||
throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters');
|
||||
return {
|
||||
production,
|
||||
appUrl,
|
||||
@@ -72,6 +78,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
clientIpSource: env.CLIENT_IP_SOURCE === 'cloudflare' ? 'cloudflare' : 'forwarded',
|
||||
proxyDiagnosticToken: env.PROXY_DIAGNOSTIC_TOKEN || undefined,
|
||||
port,
|
||||
host: env.HOST ?? '127.0.0.1',
|
||||
guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30),
|
||||
@@ -99,7 +107,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
setupToken: env.SETUP_TOKEN,
|
||||
resendApiKey: env.RESEND_API_KEY?.trim() || undefined,
|
||||
mailFrom: env.MAIL_FROM ?? 'hop. <hello@noname.company>',
|
||||
userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576,
|
||||
userQuota: num('USER_STORAGE_LIMIT_MB', 2048) * 1048576,
|
||||
emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7),
|
||||
webRoot: env.WEB_ROOT,
|
||||
};
|
||||
|
||||
252
apps/server/src/resumable.js
Normal file
252
apps/server/src/resumable.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { open, stat, truncate } from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as tables from './db/schema.js';
|
||||
import { fail } from './security.js';
|
||||
|
||||
export const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
export class ResumableUploads {
|
||||
constructor(service) {
|
||||
this.service = service;
|
||||
this.directory = resolve(service.cfg.uploadDir, 'chunks');
|
||||
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
||||
this.busy = new Set();
|
||||
}
|
||||
path(id, extension) {
|
||||
return resolve(this.directory, `${id}.${extension}`);
|
||||
}
|
||||
metadata(id) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.path(id, 'json'), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
save(id, value) {
|
||||
writeFileSync(this.path(id, 'next'), JSON.stringify(value), { mode: 0o600, flush: true });
|
||||
renameSync(this.path(id, 'next'), this.path(id, 'json'));
|
||||
}
|
||||
remove(id) {
|
||||
for (const extension of ['part', 'json', 'next'])
|
||||
rmSync(this.path(id, extension), { force: true });
|
||||
}
|
||||
manifest(subject, requestId, position) {
|
||||
return this.service.storage.uploadManifest(subject, requestId, position);
|
||||
}
|
||||
file(subject, id) {
|
||||
const row = this.service.db.select().from(tables.files).where(eq(tables.files.id, id)).get();
|
||||
if (!row) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.');
|
||||
return this.manifest(subject, row.requestId, row.position);
|
||||
}
|
||||
async lock(id, action) {
|
||||
if (this.busy.has(id))
|
||||
fail(409, 'UPLOAD_ACTIVE', '업로드 요청이 처리 중입니다. 잠시 후 다시 시도해 주세요.');
|
||||
this.busy.add(id);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
async state(subject, file, fingerprint) {
|
||||
if (file.status === 'stored')
|
||||
return {
|
||||
uploadId: file.id,
|
||||
offset: file.size,
|
||||
size: file.size,
|
||||
chunkSize: CHUNK_BYTES,
|
||||
status: 'stored',
|
||||
};
|
||||
let meta = this.metadata(file.id);
|
||||
if (!meta) {
|
||||
if (!fingerprint) fail(404, 'UPLOAD_NOT_STARTED', '먼저 업로드를 시작해 주세요.');
|
||||
meta = {
|
||||
requestId: file.requestId,
|
||||
subjectId: subject.id,
|
||||
sessionId: subject.sessionId,
|
||||
key: randomUUID(),
|
||||
fingerprint,
|
||||
hashes: [],
|
||||
offset: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
writeFileSync(this.path(file.id, 'part'), '', { flag: 'w', mode: 0o600 });
|
||||
this.save(file.id, meta);
|
||||
}
|
||||
if (meta.subjectId !== subject.id || meta.sessionId !== subject.sessionId)
|
||||
fail(409, 'UPLOAD_SESSION_CHANGED', '처음 업로드한 세션으로 다시 시도해 주세요.');
|
||||
if (fingerprint && meta.fingerprint !== fingerprint)
|
||||
fail(
|
||||
409,
|
||||
'FILE_CHANGED',
|
||||
'처음 선택한 파일과 내용이 다릅니다. 같은 파일로 다시 시도해 주세요.',
|
||||
);
|
||||
const length = (await stat(this.path(file.id, 'part'))).size;
|
||||
if (length < meta.offset) fail(409, 'UPLOAD_CORRUPTED', '업로드 자료를 다시 선택해 주세요.');
|
||||
// Recover a write interrupted between appending bytes and committing metadata.
|
||||
if (length > meta.offset) await truncate(this.path(file.id, 'part'), meta.offset);
|
||||
this.manifest(subject, file.requestId, file.position);
|
||||
this.service.storage.reserve(subject, file.requestId);
|
||||
return {
|
||||
uploadId: file.id,
|
||||
offset: meta.offset,
|
||||
size: file.size,
|
||||
chunkSize: CHUNK_BYTES,
|
||||
status: 'waiting',
|
||||
};
|
||||
}
|
||||
async start(subject, requestId, position, fingerprint) {
|
||||
const file = this.manifest(subject, requestId, position);
|
||||
return this.lock(file.id, () => this.state(subject, file, fingerprint));
|
||||
}
|
||||
async status(subject, id) {
|
||||
return this.lock(id, () => this.state(subject, this.file(subject, id)));
|
||||
}
|
||||
async append(subject, id, offset, bytes) {
|
||||
return this.lock(id, async () => {
|
||||
const file = this.file(subject, id);
|
||||
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
|
||||
const current = await this.state(subject, file),
|
||||
meta = this.metadata(id);
|
||||
if (
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
offset % CHUNK_BYTES !== 0 ||
|
||||
bytes.length !== Math.min(CHUNK_BYTES, file.size - offset) ||
|
||||
!bytes.length
|
||||
)
|
||||
fail(413, 'CHUNK_SIZE_INVALID', '업로드 조각 크기가 올바르지 않습니다.');
|
||||
const handle = await open(this.path(id, 'part'), 'r+');
|
||||
try {
|
||||
if (offset < current.offset && offset + bytes.length <= current.offset) {
|
||||
const previous = Buffer.alloc(bytes.length);
|
||||
const result = await handle.read(previous, 0, previous.length, offset);
|
||||
if (result.bytesRead !== bytes.length || !previous.equals(bytes))
|
||||
fail(409, 'CHUNK_CONFLICT', '이미 받은 조각과 내용이 다릅니다.');
|
||||
return current;
|
||||
}
|
||||
if (offset !== current.offset)
|
||||
fail(409, 'UPLOAD_OFFSET', '서버가 받은 위치부터 다시 보내 주세요.');
|
||||
let written = 0;
|
||||
while (written < bytes.length) {
|
||||
const result = await handle.write(
|
||||
bytes,
|
||||
written,
|
||||
bytes.length - written,
|
||||
offset + written,
|
||||
);
|
||||
if (!result.bytesWritten) throw Error('Unable to write upload chunk');
|
||||
written += result.bytesWritten;
|
||||
}
|
||||
await handle.sync();
|
||||
this.file(subject, id);
|
||||
if (!this.metadata(id)) fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었습니다.');
|
||||
meta.offset += bytes.length;
|
||||
meta.hashes.push(createHash('sha256').update(bytes).digest('hex'));
|
||||
meta.updatedAt = Date.now();
|
||||
this.save(id, meta);
|
||||
return { ...current, offset: meta.offset };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
async complete(subject, id, request) {
|
||||
return this.lock(id, async () => {
|
||||
const file = this.file(subject, id);
|
||||
if (file.status === 'stored') return { fileId: id, status: 'stored' };
|
||||
const current = await this.state(subject, file),
|
||||
meta = this.metadata(id);
|
||||
if (current.offset !== file.size)
|
||||
fail(409, 'UPLOAD_INCOMPLETE', '파일을 모두 보낸 후 완료할 수 있습니다.');
|
||||
if (
|
||||
createHash('sha256')
|
||||
.update(Buffer.from(meta.hashes.join(''), 'hex'))
|
||||
.digest('hex') !== meta.fingerprint
|
||||
)
|
||||
fail(409, 'FILE_CHECKSUM', '파일 내용 검증에 실패했습니다.');
|
||||
const path = this.path(id, 'part');
|
||||
const adapter = {
|
||||
raw: request.raw,
|
||||
async *parts() {
|
||||
yield { type: 'file', filename: file.name, file: createReadStream(path) };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await this.service.storage.upload(
|
||||
subject,
|
||||
file.requestId,
|
||||
file.position,
|
||||
adapter,
|
||||
meta.key,
|
||||
path,
|
||||
);
|
||||
this.remove(id);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!existsSync(path)) this.remove(id);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
abort(requestId) {
|
||||
for (const file of this.service.storage.fileRows(requestId)) this.remove(file.id);
|
||||
}
|
||||
abortSession(sessionId) {
|
||||
for (const name of readdirSync(this.directory)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
const id = name.slice(0, -5);
|
||||
if (this.metadata(id)?.sessionId === sessionId) this.remove(id);
|
||||
}
|
||||
}
|
||||
abortUser(userId) {
|
||||
for (const name of readdirSync(this.directory)) {
|
||||
if (name.endsWith('.json') && this.metadata(name.slice(0, -5))?.subjectId === userId)
|
||||
this.remove(name.slice(0, -5));
|
||||
}
|
||||
}
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const name of readdirSync(this.directory)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
const id = name.slice(0, -5),
|
||||
meta = this.metadata(id);
|
||||
const row = this.service.db
|
||||
.select()
|
||||
.from(tables.requests)
|
||||
.where(eq(tables.requests.id, meta.requestId))
|
||||
.get();
|
||||
if (
|
||||
!this.busy.has(id) &&
|
||||
(row?.status !== 'accepted' ||
|
||||
!row.startBy ||
|
||||
row.startBy <= now ||
|
||||
meta.updatedAt < now - this.service.cfg.orphanHours * 3600000)
|
||||
)
|
||||
this.remove(id);
|
||||
}
|
||||
for (const name of readdirSync(this.directory)) {
|
||||
if (!/^[0-9a-f-]{36}\.(part|next)$/.test(name)) continue;
|
||||
const id = name.split('.')[0];
|
||||
if (
|
||||
!this.busy.has(id) &&
|
||||
!existsSync(this.path(id, 'json')) &&
|
||||
statSync(resolve(this.directory, name)).mtimeMs <
|
||||
now - this.service.cfg.orphanHours * 3600000
|
||||
)
|
||||
this.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function clientIp(
|
||||
remote: string | undefined,
|
||||
headers: IncomingHttpHeaders,
|
||||
trusted: string[],
|
||||
source: 'forwarded' | 'cloudflare' = 'forwarded',
|
||||
): string | null {
|
||||
const first = remote ? normalizeIp(remote) : null;
|
||||
if (!first) return null;
|
||||
@@ -46,6 +47,13 @@ export function clientIp(
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (source === 'cloudflare') {
|
||||
// Trust this single edge-written header only through the configured adjacent relay.
|
||||
if (!trusts(first)) return null;
|
||||
const raw = headers['cf-connecting-ip'];
|
||||
const result = typeof raw === 'string' ? normalizeIp(raw.trim()) : null;
|
||||
return publicIp(result) ? result : null;
|
||||
}
|
||||
if (!trusts(first)) return first;
|
||||
const raw = headers['x-forwarded-for'];
|
||||
if (typeof raw !== 'string') return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Server } from 'socket.io';
|
||||
import type { Auth } from './auth.js';
|
||||
import type { Accounts } from './accounts.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { ResumableUploads } from './resumable.js';
|
||||
import { Vault } from './vault.js';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
|
||||
@@ -28,12 +29,14 @@ export class Service {
|
||||
accounts!: Accounts;
|
||||
io?: Server;
|
||||
storage: Storage;
|
||||
resumable: ResumableUploads;
|
||||
vault: Vault;
|
||||
constructor(
|
||||
readonly db: DB,
|
||||
readonly cfg: Config,
|
||||
) {
|
||||
this.storage = new Storage(this);
|
||||
this.resumable = new ResumableUploads(this);
|
||||
this.vault = new Vault(this);
|
||||
}
|
||||
guest(token: string | undefined) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdirSync, statfsSync, existsSync } from 'node:fs';
|
||||
import { createWriteStream, createReadStream } from 'node:fs';
|
||||
import { rename, unlink, readdir, stat, open } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { Transform } from 'node:stream';
|
||||
import { Transform, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm';
|
||||
import sharp from 'sharp';
|
||||
@@ -146,6 +146,7 @@ export class Storage {
|
||||
.run();
|
||||
}
|
||||
release(requestId: string) {
|
||||
this.service.resumable?.abort(requestId);
|
||||
const db = this.service.db;
|
||||
for (const file of this.fileRows(requestId)) {
|
||||
for (const [uploadId, upload] of this.active)
|
||||
@@ -161,12 +162,51 @@ export class Storage {
|
||||
.run();
|
||||
}
|
||||
}
|
||||
uploadManifest(g: StorageSubject, requestId: string, position: number) {
|
||||
if (g.kind === 'user') {
|
||||
const session = g.sessionId
|
||||
? this.service.db
|
||||
.select()
|
||||
.from(s.accountSessions)
|
||||
.where(eq(s.accountSessions.id, g.sessionId))
|
||||
.get()
|
||||
: null;
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== g.id ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= Date.now()
|
||||
)
|
||||
fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.');
|
||||
} else {
|
||||
const guest = this.service.db.select().from(s.guests).where(eq(s.guests.id, g.id)).get();
|
||||
if (!guest || guest.revokedAt || guest.expiresAt <= Date.now())
|
||||
fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.');
|
||||
}
|
||||
const row = this.request(g, requestId);
|
||||
if (row.kind !== 'file' || !this.isSender(g, row))
|
||||
fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.');
|
||||
if (this.service.vault.source(requestId))
|
||||
fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.');
|
||||
if (
|
||||
row.status !== 'accepted' ||
|
||||
!row.startBy ||
|
||||
row.startBy <= Date.now() ||
|
||||
this.blocked(g, row)
|
||||
)
|
||||
fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.');
|
||||
const file = this.fileRows(requestId).find((f) => f.position === position);
|
||||
if (!file) fail(404, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
|
||||
if (file!.status === 'uploading') fail(409, 'UPLOAD_ACTIVE', '파일이 처리 중입니다.');
|
||||
return file!;
|
||||
}
|
||||
async upload(
|
||||
g: StorageSubject,
|
||||
requestId: string,
|
||||
position: number,
|
||||
request: FastifyRequest,
|
||||
key: string,
|
||||
stagedPath?: string,
|
||||
) {
|
||||
const { db, cfg } = this.service;
|
||||
let file: typeof s.files.$inferSelect;
|
||||
@@ -230,7 +270,7 @@ export class Storage {
|
||||
.run();
|
||||
});
|
||||
const controller = new AbortController(),
|
||||
tempPath = resolve(this.temp, uploadId!);
|
||||
tempPath = stagedPath ?? resolve(this.temp, uploadId!);
|
||||
this.active.set(uploadId!, {
|
||||
guestId: `${g.kind ?? 'guest'}:${g.id}`,
|
||||
controller,
|
||||
@@ -265,7 +305,13 @@ export class Storage {
|
||||
await pipeline(
|
||||
part.file,
|
||||
counter,
|
||||
createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }),
|
||||
stagedPath
|
||||
? new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
})
|
||||
: createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (part.file.truncated || bytes !== file!.size)
|
||||
@@ -486,6 +532,11 @@ export class Storage {
|
||||
)
|
||||
.get();
|
||||
if (row) {
|
||||
if (g.kind === 'user' && row.item.fileId) {
|
||||
const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get();
|
||||
if (file && !this.service.accounts.canStoreFile(g.id, file))
|
||||
fail(413, 'ACCOUNT_QUOTA', '보관함 용량이 부족해 복원하지 못했습니다.');
|
||||
}
|
||||
db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run();
|
||||
count++;
|
||||
}
|
||||
@@ -505,6 +556,7 @@ export class Storage {
|
||||
if (this.cleaning) return;
|
||||
this.cleaning = true;
|
||||
try {
|
||||
this.service.resumable?.cleanup();
|
||||
const { db, cfg } = this.service,
|
||||
now = Date.now();
|
||||
const expired = db
|
||||
@@ -580,10 +632,12 @@ export class Storage {
|
||||
}
|
||||
}
|
||||
abortSession(id: string) {
|
||||
this.service.resumable?.abortSession(id);
|
||||
for (const upload of this.active.values())
|
||||
if (upload.sessionId === id) upload.controller.abort();
|
||||
}
|
||||
abortUser(id: string) {
|
||||
this.service.resumable?.abortUser(id);
|
||||
for (const upload of this.active.values())
|
||||
if (upload.guestId === `user:${id}`) upload.controller.abort();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ export class Vault {
|
||||
)
|
||||
return;
|
||||
if (!auth.serialize(auth.user(binding.userId)).verified) return;
|
||||
const item = db.select().from(s.items).where(eq(s.items.id, itemId)).get();
|
||||
const file = item?.fileId
|
||||
? db.select().from(s.files).where(eq(s.files.id, item.fileId)).get()
|
||||
: null;
|
||||
if (file && !this.service.accounts.canStoreFile(binding.userId, file)) {
|
||||
queueMicrotask(() =>
|
||||
this.service.io
|
||||
?.to(`guest:${request.recipientGuestId}`)
|
||||
.emit('receipt:skipped', { accountId: binding.userId, reason: 'quota' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
db.insert(s.access)
|
||||
.values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false })
|
||||
.onConflictDoNothing()
|
||||
|
||||
74
apps/server/test/cloudflare-discovery.test.js
Normal file
74
apps/server/test/cloudflare-discovery.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { io } from 'socket.io-client';
|
||||
import { fixture } from './helpers.js';
|
||||
import { clientIp } from '../src/security.js';
|
||||
|
||||
test('Cloudflare 원본 IP는 신뢰한 인접 프록시의 단일 공인 IP만 허용한다', () => {
|
||||
const resolve = (peer, headers) => clientIp(peer, headers, ['127.0.0.1'], 'cloudflare');
|
||||
assert.equal(
|
||||
resolve('127.0.0.1', { 'cf-connecting-ip': '8.8.8.8', 'x-forwarded-for': '1.1.1.1' }),
|
||||
'8.8.8.8',
|
||||
);
|
||||
assert.equal(resolve('8.8.4.4', { 'cf-connecting-ip': '8.8.8.8' }), null);
|
||||
for (const value of ['192.168.1.1', '127.0.0.1', 'bad', '8.8.8.8,1.1.1.1', ['8.8.8.8']])
|
||||
assert.equal(resolve('127.0.0.1', { 'cf-connecting-ip': value }), null);
|
||||
assert.equal(resolve('127.0.0.1', {}), null);
|
||||
});
|
||||
test('실제 소켓의 같은 원본 IP는 자동 발견하고 다른 네트워크·발견 꺼짐은 숨긴다', async () => {
|
||||
const f = await fixture(true),
|
||||
sockets = [];
|
||||
f.cfg.clientIpSource = 'cloudflare';
|
||||
try {
|
||||
const clients = [];
|
||||
for (const ip of ['8.8.8.8', '8.8.8.8', '1.1.1.1']) {
|
||||
const client = await f.create(ip, false);
|
||||
const socket = io(f.url, {
|
||||
transports: ['websocket'],
|
||||
auth: { csrfToken: client.csrf },
|
||||
extraHeaders: {
|
||||
Origin: f.cfg.appUrl,
|
||||
Cookie: client.cookie,
|
||||
'CF-Connecting-IP': ip,
|
||||
'X-Forwarded-For': '9.9.9.9',
|
||||
},
|
||||
});
|
||||
sockets.push(socket);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
});
|
||||
clients.push(client);
|
||||
}
|
||||
const list = (await f.call(clients[0], '/discovery/devices')).json().devices;
|
||||
assert.deepEqual(
|
||||
list.map((device) => device.id),
|
||||
[clients[1].deviceId],
|
||||
);
|
||||
await f.call(clients[1], '/device/me', 'PATCH', { discoverable: false });
|
||||
assert.equal((await f.call(clients[0], '/discovery/devices')).json().devices.length, 0);
|
||||
} finally {
|
||||
sockets.forEach((socket) => socket.disconnect());
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('프록시 진단은 운영자 토큰 없이 원본 정보를 노출하지 않는다', async () => {
|
||||
const f = await fixture();
|
||||
f.cfg.proxyDiagnosticToken = 'p'.repeat(40);
|
||||
try {
|
||||
const denied = await f.call(null, '/ops/proxy-diagnostic');
|
||||
assert.equal(denied.statusCode, 403);
|
||||
assert.equal(denied.json().peerIp, undefined);
|
||||
const allowed = await f.call(null, '/ops/proxy-diagnostic', 'GET', undefined, undefined, {
|
||||
authorization: `Bearer ${f.cfg.proxyDiagnosticToken}`,
|
||||
'cf-connecting-ip': '8.8.8.8',
|
||||
});
|
||||
assert.equal(allowed.statusCode, 200);
|
||||
assert.equal(allowed.json().peerIp, '127.0.0.1');
|
||||
assert.equal(allowed.json().cloudflareClientIpPresent, true);
|
||||
f.cfg.proxyDiagnosticToken = undefined;
|
||||
assert.equal((await f.call(null, '/ops/proxy-diagnostic')).statusCode, 404);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -418,14 +418,14 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
|
||||
const item = (await f.call(b, '/inbox')).json().items[0],
|
||||
download = await f.call(b, `/access/${item.accessId}/download`);
|
||||
assert.deepEqual(download.rawPayload, bytes);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length);
|
||||
assert.equal(
|
||||
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
|
||||
.statusCode,
|
||||
410,
|
||||
);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
||||
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
182
apps/server/test/resumable.test.js
Normal file
182
apps/server/test/resumable.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import { CHUNK_BYTES, ResumableUploads } from '../src/resumable.js';
|
||||
import * as schema from '../src/db/schema.js';
|
||||
|
||||
const digest = (bytes) => createHash('sha256').update(bytes).digest();
|
||||
function fingerprint(bytes) {
|
||||
const hashes = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES)
|
||||
hashes.push(digest(bytes.subarray(offset, offset + CHUNK_BYTES)));
|
||||
return digest(Buffer.concat(hashes)).toString('hex');
|
||||
}
|
||||
async function prepare(f, bytes, accept = true) {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const response = await f.call(
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: b.deviceId,
|
||||
kind: 'file',
|
||||
byteSize: bytes.length,
|
||||
count: 1,
|
||||
files: [{ name: 'resumable.bin', size: bytes.length, type: 'application/octet-stream' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const requestId = response.json().requestId;
|
||||
if (accept)
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${requestId}/accept`, 'POST', {}, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
const start = (sender = a, content = bytes) =>
|
||||
f.call(sender, '/uploads/resumable', 'POST', {
|
||||
requestId,
|
||||
fileIndex: 0,
|
||||
fingerprint: fingerprint(content),
|
||||
});
|
||||
return { a, b, requestId, start };
|
||||
}
|
||||
const put = (f, sender, id, offset, bytes) =>
|
||||
f.call(sender, `/uploads/resumable/${id}`, 'PUT', bytes, undefined, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'upload-offset': String(offset),
|
||||
});
|
||||
|
||||
test('분할 업로드는 수락·발신 권한·Origin·CSRF를 검사한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const p = await prepare(f, Buffer.from('hello'), false);
|
||||
assert.equal((await p.start()).statusCode, 409);
|
||||
await f.call(p.b, `/transfer-requests/${p.requestId}/accept`, 'POST', {}, randomUUID());
|
||||
assert.equal((await p.start(p.b)).statusCode, 403);
|
||||
const state = (await p.start()).json();
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
p.a,
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'PUT',
|
||||
Buffer.from('hello'),
|
||||
undefined,
|
||||
{
|
||||
'content-type': 'application/octet-stream',
|
||||
'upload-offset': '0',
|
||||
origin: 'https://evil.test',
|
||||
},
|
||||
)
|
||||
).statusCode,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(
|
||||
p.a,
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'PUT',
|
||||
Buffer.from('hello'),
|
||||
undefined,
|
||||
{
|
||||
'content-type': 'application/octet-stream',
|
||||
'upload-offset': '0',
|
||||
'x-csrf-token': 'wrong',
|
||||
},
|
||||
)
|
||||
).statusCode,
|
||||
403,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('받은 조각의 중복·다른 내용·순서를 검사하고 이어 받아 원본을 확정한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const bytes = Buffer.alloc(CHUNK_BYTES + 13, 71),
|
||||
p = await prepare(f, bytes);
|
||||
const state = (await p.start()).json(),
|
||||
path = `/uploads/resumable/${state.uploadId}`;
|
||||
const first = bytes.subarray(0, CHUNK_BYTES);
|
||||
assert.equal(
|
||||
(await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).json().code,
|
||||
'UPLOAD_OFFSET',
|
||||
);
|
||||
assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES);
|
||||
assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES);
|
||||
assert.equal(
|
||||
(await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES, 72))).json().code,
|
||||
'CHUNK_CONFLICT',
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(p.a, `${path}/complete`, 'POST', {})).json().code,
|
||||
'UPLOAD_INCOMPLETE',
|
||||
);
|
||||
f.service.resumable = new ResumableUploads(f.service);
|
||||
assert.equal((await p.start()).json().offset, CHUNK_BYTES);
|
||||
assert.equal((await p.start(p.a, Buffer.alloc(bytes.length, 73))).json().code, 'FILE_CHANGED');
|
||||
assert.equal(
|
||||
(await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200);
|
||||
assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200);
|
||||
const item = (await f.call(p.b, '/device/items')).json().items[0];
|
||||
const download = await f.call(p.b, `/access/${item.accessId}/download`);
|
||||
assert.deepEqual(download.rawPayload, bytes);
|
||||
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('8MiB 초과·전체 파일 검증 실패·취소는 자료 확정을 막고 임시 파일을 정리한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const bytes = Buffer.from('original'),
|
||||
p = await prepare(f, bytes);
|
||||
const state = (await p.start()).json();
|
||||
assert.equal(
|
||||
(await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES + 1))).statusCode,
|
||||
413,
|
||||
);
|
||||
assert.equal((await put(f, p.a, state.uploadId, 0, Buffer.from('tampered'))).statusCode, 200);
|
||||
assert.equal(
|
||||
(await f.call(p.a, `/uploads/resumable/${state.uploadId}/complete`, 'POST', {})).json().code,
|
||||
'FILE_CHECKSUM',
|
||||
);
|
||||
assert.equal((await f.call(p.b, '/device/items')).json().items.length, 0);
|
||||
await f.call(p.a, `/transfer-requests/${p.requestId}/cancel`, 'POST', {});
|
||||
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
|
||||
assert.equal((await put(f, p.a, state.uploadId, 0, bytes)).statusCode, 409);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
test('메타데이터 저장 전 중단된 쓰기는 마지막 확정 위치로 복구하고 만료 조각을 정리한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const p = await prepare(f, Buffer.from('crash')),
|
||||
state = (await p.start()).json();
|
||||
await writeFile(f.service.resumable.path(state.uploadId, 'part'), 'uncommitted');
|
||||
assert.equal((await p.start()).json().offset, 0);
|
||||
assert.equal((await readFile(f.service.resumable.path(state.uploadId, 'part'))).length, 0);
|
||||
f.service.db
|
||||
.update(schema.requests)
|
||||
.set({ startBy: Date.now() - 1 })
|
||||
.where(eq(schema.requests.id, p.requestId))
|
||||
.run();
|
||||
f.service.resumable.cleanup();
|
||||
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -45,6 +45,96 @@ function limits(f) {
|
||||
f.cfg.ipRate = 60;
|
||||
}
|
||||
|
||||
test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과 수신은 다운로드만 유지한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
f.cfg.userQuota = 7;
|
||||
const sender = await f.create(),
|
||||
receiver = await f.create(),
|
||||
outsider = await f.create();
|
||||
await f.space(sender, receiver);
|
||||
await signed(f, receiver, 'quota@hop.test');
|
||||
await signed(f, outsider, 'other-quota@hop.test');
|
||||
const saved = (
|
||||
await f.call(
|
||||
receiver,
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: 'file',
|
||||
files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
)
|
||||
).json();
|
||||
assert.equal((await upload(f, receiver, saved.requestId, Buffer.from('save'))).statusCode, 200);
|
||||
async function received(bytes) {
|
||||
const r = await f.call(
|
||||
sender,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: receiver.deviceId,
|
||||
kind: 'file',
|
||||
byteSize: bytes.length,
|
||||
count: 1,
|
||||
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
const id = r.json().requestId;
|
||||
assert.equal(
|
||||
(await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal((await upload(f, sender, id, bytes)).statusCode, 200);
|
||||
return id;
|
||||
}
|
||||
const skipped = await received(Buffer.from('full'));
|
||||
assert.equal((await f.call(receiver, '/inbox')).json().items.length, 0);
|
||||
const guestItem = (await f.call(receiver, '/device/items'))
|
||||
.json()
|
||||
.items.find((item) => item.file?.size === 4 && item.role === 'recipient');
|
||||
assert.deepEqual(
|
||||
(await f.call(receiver, `/access/${guestItem.accessId}/download`)).rawPayload,
|
||||
Buffer.from('full'),
|
||||
);
|
||||
await received(Buffer.from('fit'));
|
||||
assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 7);
|
||||
assert.equal((await f.call(outsider, '/account/storage')).json().usedBytes, 0);
|
||||
const own = (await f.call(receiver, '/vault')).json().items[0];
|
||||
const deleted = (
|
||||
await f.call(receiver, '/access/delete-batch', 'POST', { ids: [own.accessId] })
|
||||
).json();
|
||||
assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 3);
|
||||
const replacement = (
|
||||
await f.call(
|
||||
receiver,
|
||||
'/vault',
|
||||
'POST',
|
||||
{
|
||||
kind: 'file',
|
||||
files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
)
|
||||
).json();
|
||||
assert.equal(
|
||||
(await upload(f, receiver, replacement.requestId, Buffer.from('more'))).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(receiver, `/deletion-operations/${deleted.operationId}/undo`, 'POST', {}))
|
||||
.statusCode,
|
||||
413,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user