Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

View File

@@ -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();
}