mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
Compare commits
1 Commits
a4912c8952
...
122970a6ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122970a6ea |
@@ -9,7 +9,6 @@ services:
|
||||
volumes:
|
||||
- ./data/library:/app/data/library
|
||||
- ./data/thumbnails:/app/data/thumbnails
|
||||
- ./data/uploads:/app/data/uploads
|
||||
environment:
|
||||
# ArkVault
|
||||
- DATABASE_HOST=database
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
cleanupExpiredSessions,
|
||||
cleanupExpiredSessionUpgradeChallenges,
|
||||
} from "$lib/server/db/session";
|
||||
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
||||
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
|
||||
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
||||
|
||||
export const init: ServerInit = async () => {
|
||||
await migrateDB();
|
||||
|
||||
30
src/lib/modules/crypto/hmac.worker.ts
Normal file
30
src/lib/modules/crypto/hmac.worker.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { hmac } from "@noble/hashes/hmac.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
|
||||
interface ComputeMessage {
|
||||
type: "compute";
|
||||
file: File;
|
||||
hmacSecret: ArrayBuffer;
|
||||
}
|
||||
|
||||
type WorkerMessage = ComputeMessage;
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const { type } = event.data;
|
||||
|
||||
if (type === "compute") {
|
||||
const { file, hmacSecret } = event.data;
|
||||
|
||||
const h = hmac.create(sha256, new Uint8Array(hmacSecret));
|
||||
const reader = file.stream().getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
h.update(value);
|
||||
}
|
||||
|
||||
const result = h.digest();
|
||||
self.postMessage({ type: "result", hmac: result }, { transfer: [result.buffer] });
|
||||
}
|
||||
};
|
||||
25
src/lib/modules/crypto/hmacWorker.ts
Normal file
25
src/lib/modules/crypto/hmacWorker.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import HmacWorker from "./hmac.worker?worker";
|
||||
|
||||
export const computeFileHmac = async (file: File, hmacSecret: CryptoKey): Promise<Uint8Array> => {
|
||||
const worker = new HmacWorker();
|
||||
const hmacSecretRaw = await crypto.subtle.exportKey("raw", hmacSecret);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.onmessage = (event: MessageEvent<{ type: "result"; hmac: Uint8Array }>) => {
|
||||
if (event.data.type === "result") {
|
||||
resolve(event.data.hmac);
|
||||
worker.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
reject(error);
|
||||
worker.terminate();
|
||||
};
|
||||
|
||||
worker.postMessage(
|
||||
{ type: "compute", file, hmacSecret: hmacSecretRaw },
|
||||
{ transfer: [hmacSecretRaw] },
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import HmacWorker from "$workers/hmac?worker";
|
||||
import type { ComputeMessage, ResultMessage } from "$workers/hmac";
|
||||
import { hmac } from "@noble/hashes/hmac.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
|
||||
export const digestMessage = async (message: BufferSource) => {
|
||||
return await crypto.subtle.digest("SHA-256", message);
|
||||
@@ -18,24 +18,10 @@ export const generateHmacSecret = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
||||
const stream = message.stream();
|
||||
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
||||
const worker = new HmacWorker();
|
||||
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
|
||||
resolve(data.result);
|
||||
worker.terminate();
|
||||
export const createHmacStream = async (hmacSecret: CryptoKey) => {
|
||||
const h = hmac.create(sha256, new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret)));
|
||||
return {
|
||||
update: (data: Uint8Array) => h.update(data),
|
||||
digest: () => h.digest(),
|
||||
};
|
||||
|
||||
worker.onerror = ({ error }) => {
|
||||
reject(error);
|
||||
worker.terminate();
|
||||
};
|
||||
|
||||
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
|
||||
transfer: [stream, hmacSecretRaw.buffer],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { ENCRYPTED_CHUNK_SIZE } from "$lib/constants";
|
||||
import { CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "$lib/constants";
|
||||
import { decryptChunk, concatenateBuffers } from "$lib/modules/crypto";
|
||||
|
||||
export interface FileDownloadState {
|
||||
@@ -100,7 +100,7 @@ export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boo
|
||||
return await decryptFile(
|
||||
state,
|
||||
fileEncrypted,
|
||||
isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE,
|
||||
isLegacy ? fileEncrypted.byteLength : CHUNK_SIZE + ENCRYPTION_OVERHEAD,
|
||||
dataKey,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
import { decryptChunk } from "$lib/modules/crypto";
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||
@@ -20,7 +20,12 @@ const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
|
||||
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
|
||||
const thumbnailEncrypted = await res.arrayBuffer();
|
||||
const thumbnailBuffer = await decryptData(
|
||||
thumbnailEncrypted.slice(12),
|
||||
thumbnailEncrypted.slice(0, 12),
|
||||
dataKey,
|
||||
);
|
||||
|
||||
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||
return getThumbnailUrl(thumbnailBuffer);
|
||||
|
||||
@@ -2,7 +2,7 @@ import ExifReader from "exifreader";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { CHUNK_SIZE } from "$lib/constants";
|
||||
import { encodeToBase64, generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
|
||||
import { signMessageHmac } from "$lib/modules/crypto";
|
||||
import { computeFileHmac } from "$lib/modules/crypto/hmacWorker";
|
||||
import { Scheduler } from "$lib/modules/scheduler";
|
||||
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||
import { uploadBlob } from "$lib/modules/upload";
|
||||
@@ -50,15 +50,9 @@ export const clearUploadedFiles = () => {
|
||||
};
|
||||
|
||||
const requestDuplicateFileScan = limitFunction(
|
||||
async (
|
||||
state: FileUploadState,
|
||||
file: File,
|
||||
hmacSecret: HmacSecret,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
) => {
|
||||
state.status = "encryption-pending";
|
||||
|
||||
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
|
||||
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
|
||||
const hmacResult = await computeFileHmac(file, hmacSecret.secret);
|
||||
const fileSigned = encodeToBase64(hmacResult);
|
||||
const files = await trpc().file.listByHash.query({
|
||||
hskVersion: hmacSecret.version,
|
||||
contentHmac: fileSigned,
|
||||
@@ -104,21 +98,19 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
|
||||
interface FileMetadata {
|
||||
const requestFileUpload2 = async (
|
||||
state: FileUploadState,
|
||||
file: Blob,
|
||||
fileSigned: string,
|
||||
fileMetadata: {
|
||||
parentId: "root" | number;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
}
|
||||
|
||||
const requestFileMetadataEncryption = limitFunction(
|
||||
async (
|
||||
state: FileUploadState,
|
||||
file: Blob,
|
||||
fileMetadata: FileMetadata,
|
||||
},
|
||||
masterKey: MasterKey,
|
||||
hmacSecret: HmacSecret,
|
||||
) => {
|
||||
) => {
|
||||
state.status = "encrypting";
|
||||
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
@@ -127,8 +119,7 @@ const requestFileMetadataEncryption = limitFunction(
|
||||
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
|
||||
await Promise.all([
|
||||
encryptString(fileMetadata.name, dataKey),
|
||||
fileMetadata.createdAt &&
|
||||
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
|
||||
fileMetadata.createdAt && encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
|
||||
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
|
||||
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
|
||||
]);
|
||||
@@ -149,28 +140,12 @@ const requestFileMetadataEncryption = limitFunction(
|
||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||
});
|
||||
|
||||
state.status = "upload-pending";
|
||||
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
const requestFileUpload = limitFunction(
|
||||
async (
|
||||
state: FileUploadState,
|
||||
uploadId: string,
|
||||
file: Blob,
|
||||
fileSigned: string,
|
||||
thumbnailBuffer: ArrayBuffer | undefined,
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
) => {
|
||||
state.status = "uploading";
|
||||
|
||||
await uploadBlob(uploadId, file, dataKey, {
|
||||
onProgress(s) {
|
||||
state.progress = s.progress;
|
||||
state.rate = s.rate;
|
||||
state.rate = s.rateBps;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,7 +155,6 @@ const requestFileUpload = limitFunction(
|
||||
});
|
||||
|
||||
if (thumbnailBuffer) {
|
||||
try {
|
||||
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
||||
file: fileId,
|
||||
dekVersion: dataKeyVersion,
|
||||
@@ -189,16 +163,12 @@ const requestFileUpload = limitFunction(
|
||||
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
|
||||
|
||||
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
state.status = "uploaded";
|
||||
return { fileId };
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
return { fileId, thumbnailBuffer };
|
||||
};
|
||||
|
||||
export const uploadFile = async (
|
||||
file: File,
|
||||
@@ -215,44 +185,51 @@ export const uploadFile = async (
|
||||
const state = uploadingFiles.at(-1)!;
|
||||
|
||||
return await scheduler.schedule(file.size, async () => {
|
||||
try {
|
||||
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
|
||||
state.status = "encryption-pending";
|
||||
|
||||
try {
|
||||
const { fileSigned } = await requestDuplicateFileScan(file, hmacSecret, onDuplicate);
|
||||
if (!fileSigned) {
|
||||
state.status = "canceled";
|
||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileBuffer;
|
||||
const fileType = getFileType(file);
|
||||
const fileMetadata: FileMetadata = {
|
||||
if (fileType.startsWith("image/")) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const fileCreatedAt = extractExifDateTime(fileBuffer);
|
||||
|
||||
const { fileId, thumbnailBuffer } = await requestFileUpload2(
|
||||
state,
|
||||
new Blob([fileBuffer], { type: fileType }),
|
||||
fileSigned,
|
||||
{
|
||||
parentId,
|
||||
name: file.name,
|
||||
createdAt: fileCreatedAt,
|
||||
lastModifiedAt: new Date(file.lastModified),
|
||||
};
|
||||
|
||||
if (fileType.startsWith("image/")) {
|
||||
fileBuffer = await file.arrayBuffer();
|
||||
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
|
||||
}
|
||||
|
||||
const blob = new Blob([file], { type: fileType });
|
||||
|
||||
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
|
||||
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
|
||||
|
||||
const { fileId } = await requestFileUpload(
|
||||
state,
|
||||
uploadId,
|
||||
blob,
|
||||
fileSigned,
|
||||
thumbnailBuffer,
|
||||
dataKey,
|
||||
dataKeyVersion,
|
||||
},
|
||||
masterKey,
|
||||
hmacSecret,
|
||||
);
|
||||
|
||||
return { fileId, fileBuffer, thumbnailBuffer };
|
||||
} else {
|
||||
const { fileId, thumbnailBuffer } = await requestFileUpload2(
|
||||
state,
|
||||
file,
|
||||
fileSigned,
|
||||
{
|
||||
parentId,
|
||||
name: file.name,
|
||||
lastModifiedAt: new Date(file.lastModified),
|
||||
},
|
||||
masterKey,
|
||||
hmacSecret,
|
||||
);
|
||||
return { fileId, thumbnailBuffer };
|
||||
}
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
|
||||
@@ -3,32 +3,27 @@ import pLimit from "p-limit";
|
||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
||||
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
|
||||
|
||||
interface UploadStats {
|
||||
progress: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
const createSpeedMeter = (timeWindow = 1500) => {
|
||||
const samples: { t: number; b: number }[] = [];
|
||||
let lastSpeed = 0;
|
||||
|
||||
return (bytesNow?: number) => {
|
||||
if (!bytesNow) return lastSpeed;
|
||||
type UploadStats = {
|
||||
progress: number; // 0..1 (암호화 후 기준)
|
||||
rateBps: number; // bytes/sec
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function createSpeedMeter(windowMs = 1500) {
|
||||
const samples: Array<{ t: number; b: number }> = [];
|
||||
return (bytesNow: number) => {
|
||||
const now = performance.now();
|
||||
samples.push({ t: now, b: bytesNow });
|
||||
|
||||
const cutoff = now - timeWindow;
|
||||
const cutoff = now - windowMs;
|
||||
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
|
||||
|
||||
const first = samples[0]!;
|
||||
const dt = now - first.t;
|
||||
const db = bytesNow - first.b;
|
||||
|
||||
lastSpeed = dt > 0 ? (db / dt) * 1000 : 0;
|
||||
return lastSpeed;
|
||||
return dt > 0 ? (db / dt) * 1000 : 0;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const uploadChunk = async (
|
||||
uploadId: string,
|
||||
@@ -71,10 +66,10 @@ export const uploadBlob = async (
|
||||
if (!onProgress) return;
|
||||
|
||||
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
|
||||
const rate = speedMeter(uploadedBytes);
|
||||
const rateBps = speedMeter(uploadedBytes);
|
||||
const progress = Math.min(1, uploadedBytes / totalBytes);
|
||||
|
||||
onProgress({ progress, rate });
|
||||
onProgress({ progress, rateBps, uploadedBytes, totalBytes });
|
||||
};
|
||||
|
||||
const onChunkProgress = (idx: number, loaded: number) => {
|
||||
@@ -85,12 +80,12 @@ export const uploadBlob = async (
|
||||
const limit = pLimit(options?.concurrency ?? 4);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: totalChunks }, (_, i) =>
|
||||
Array.from({ length: totalChunks }, (_, chunkIndex) =>
|
||||
limit(() =>
|
||||
uploadChunk(
|
||||
uploadId,
|
||||
i + 1,
|
||||
blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
|
||||
chunkIndex,
|
||||
blob.slice(chunkIndex * CHUNK_SIZE, (chunkIndex + 1) * CHUNK_SIZE),
|
||||
dataKey,
|
||||
onChunkProgress,
|
||||
),
|
||||
@@ -98,5 +93,11 @@ export const uploadBlob = async (
|
||||
),
|
||||
);
|
||||
|
||||
onProgress?.({ progress: 1, rate: speedMeter() });
|
||||
// 완료 보정
|
||||
onProgress?.({
|
||||
progress: 1,
|
||||
rateBps: 0,
|
||||
uploadedBytes: totalBytes,
|
||||
totalBytes,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -497,22 +497,21 @@ export const migrateFileContent = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
newPath: string,
|
||||
dekVersion: Date,
|
||||
encContentHash: string,
|
||||
) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.select(["path", "data_encryption_key_version", "encrypted_content_iv"])
|
||||
.select(["path", "encrypted_content_iv"])
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||
throw new IntegrityError("Invalid DEK version");
|
||||
} else if (!file.encrypted_content_iv) {
|
||||
}
|
||||
if (!file.encrypted_content_iv) {
|
||||
throw new IntegrityError("File is not legacy");
|
||||
}
|
||||
|
||||
@@ -526,6 +525,7 @@ export const migrateFileContent = async (
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.insertInto("file_log")
|
||||
.values({
|
||||
@@ -534,7 +534,8 @@ export const migrateFileContent = async (
|
||||
action: "migrate",
|
||||
})
|
||||
.execute();
|
||||
return { oldPath: file.path };
|
||||
|
||||
return file.path;
|
||||
};
|
||||
|
||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||
|
||||
@@ -21,14 +21,8 @@ export const up = async (db: Kysely<any>) => {
|
||||
.addColumn("type", "text", (col) => col.notNull())
|
||||
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
|
||||
.addColumn("path", "text", (col) => col.notNull())
|
||||
.addColumn("bitmap", "bytea", (col) => col.notNull())
|
||||
.addColumn("total_chunks", "integer", (col) => col.notNull())
|
||||
.addColumn("uploaded_chunks", "integer", (col) =>
|
||||
col
|
||||
.generatedAlwaysAs(sql`bit_count(bitmap)`)
|
||||
.stored()
|
||||
.notNull(),
|
||||
)
|
||||
.addColumn("uploaded_chunks", sql`integer[]`, (col) => col.notNull().defaultTo(sql`'{}'`))
|
||||
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
|
||||
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
|
||||
.addColumn("master_encryption_key_version", "integer")
|
||||
@@ -52,11 +46,6 @@ export const up = async (db: Kysely<any>) => {
|
||||
"hmac_secret_key",
|
||||
["user_id", "version"],
|
||||
)
|
||||
.addCheckConstraint(
|
||||
"upload_session_ck01",
|
||||
sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`,
|
||||
)
|
||||
.addCheckConstraint("upload_session_ck02", sql`uploaded_chunks <= total_chunks`)
|
||||
.execute();
|
||||
};
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ interface UploadSessionTable {
|
||||
type: "file" | "thumbnail" | "migration";
|
||||
user_id: number;
|
||||
path: string;
|
||||
bitmap: Buffer;
|
||||
total_chunks: number;
|
||||
uploaded_chunks: Generated<number>;
|
||||
uploaded_chunks: Generated<number[]>;
|
||||
expires_at: Date;
|
||||
|
||||
// For file uploads
|
||||
parent_id: number | null;
|
||||
master_encryption_key_version: number | null;
|
||||
encrypted_data_encryption_key: string | null; // Base64
|
||||
@@ -20,6 +20,8 @@ interface UploadSessionTable {
|
||||
encrypted_name: Ciphertext | null;
|
||||
encrypted_created_at: Ciphertext | null;
|
||||
encrypted_last_modified_at: Ciphertext | null;
|
||||
|
||||
// For thumbnail uploads
|
||||
file_id: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ interface BaseUploadSession {
|
||||
id: string;
|
||||
userId: number;
|
||||
path: string;
|
||||
bitmap: Buffer;
|
||||
totalChunks: number;
|
||||
uploadedChunks: number;
|
||||
uploadedChunks: number[];
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
@@ -26,14 +25,19 @@ interface FileUploadSession extends BaseUploadSession {
|
||||
encLastModifiedAt: Ciphertext;
|
||||
}
|
||||
|
||||
interface ThumbnailOrMigrationUploadSession extends BaseUploadSession {
|
||||
type: "thumbnail" | "migration";
|
||||
interface ThumbnailUploadSession extends BaseUploadSession {
|
||||
type: "thumbnail";
|
||||
fileId: number;
|
||||
dekVersion: Date;
|
||||
}
|
||||
|
||||
interface MigrationUploadSession extends BaseUploadSession {
|
||||
type: "migration";
|
||||
fileId: number;
|
||||
}
|
||||
|
||||
export const createFileUploadSession = async (
|
||||
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
||||
params: Omit<FileUploadSession, "type" | "uploadedChunks">,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
@@ -69,7 +73,6 @@ export const createFileUploadSession = async (
|
||||
type: "file",
|
||||
user_id: params.userId,
|
||||
path: params.path,
|
||||
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
||||
total_chunks: params.totalChunks,
|
||||
expires_at: params.expiresAt,
|
||||
parent_id: params.parentId !== "root" ? params.parentId : null,
|
||||
@@ -86,8 +89,8 @@ export const createFileUploadSession = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createThumbnailOrMigrationUploadSession = async (
|
||||
params: Omit<ThumbnailOrMigrationUploadSession, "bitmap" | "uploadedChunks">,
|
||||
export const createThumbnailUploadSession = async (
|
||||
params: Omit<ThumbnailUploadSession, "type" | "uploadedChunks">,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
@@ -108,10 +111,9 @@ export const createThumbnailOrMigrationUploadSession = async (
|
||||
.insertInto("upload_session")
|
||||
.values({
|
||||
id: params.id,
|
||||
type: params.type,
|
||||
type: "thumbnail",
|
||||
user_id: params.userId,
|
||||
path: params.path,
|
||||
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
||||
total_chunks: params.totalChunks,
|
||||
expires_at: params.expiresAt,
|
||||
file_id: params.fileId,
|
||||
@@ -121,6 +123,39 @@ export const createThumbnailOrMigrationUploadSession = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createMigrationUploadSession = async (
|
||||
params: Omit<MigrationUploadSession, "type" | "uploadedChunks">,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.select("encrypted_content_iv")
|
||||
.where("id", "=", params.fileId)
|
||||
.where("user_id", "=", params.userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
} else if (!file.encrypted_content_iv) {
|
||||
throw new IntegrityError("File is not legacy");
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("upload_session")
|
||||
.values({
|
||||
id: params.id,
|
||||
type: "migration",
|
||||
user_id: params.userId,
|
||||
path: params.path,
|
||||
total_chunks: params.totalChunks,
|
||||
expires_at: params.expiresAt,
|
||||
file_id: params.fileId,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
const session = await db
|
||||
.selectFrom("upload_session")
|
||||
@@ -138,7 +173,6 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
id: session.id,
|
||||
userId: session.user_id,
|
||||
path: session.path,
|
||||
bitmap: session.bitmap,
|
||||
totalChunks: session.total_chunks,
|
||||
uploadedChunks: session.uploaded_chunks,
|
||||
expiresAt: session.expires_at,
|
||||
@@ -152,28 +186,36 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
encCreatedAt: session.encrypted_created_at,
|
||||
encLastModifiedAt: session.encrypted_last_modified_at!,
|
||||
} satisfies FileUploadSession;
|
||||
} else {
|
||||
} else if (session.type === "thumbnail") {
|
||||
return {
|
||||
type: session.type,
|
||||
type: "thumbnail",
|
||||
id: session.id,
|
||||
userId: session.user_id,
|
||||
path: session.path,
|
||||
bitmap: session.bitmap,
|
||||
totalChunks: session.total_chunks,
|
||||
uploadedChunks: session.uploaded_chunks,
|
||||
expiresAt: session.expires_at,
|
||||
fileId: session.file_id!,
|
||||
dekVersion: session.data_encryption_key_version!,
|
||||
} satisfies ThumbnailOrMigrationUploadSession;
|
||||
} satisfies ThumbnailUploadSession;
|
||||
} else {
|
||||
return {
|
||||
type: "migration",
|
||||
id: session.id,
|
||||
userId: session.user_id,
|
||||
path: session.path,
|
||||
totalChunks: session.total_chunks,
|
||||
uploadedChunks: session.uploaded_chunks,
|
||||
expiresAt: session.expires_at,
|
||||
fileId: session.file_id!,
|
||||
} satisfies MigrationUploadSession;
|
||||
}
|
||||
};
|
||||
|
||||
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
|
||||
await db
|
||||
.updateTable("upload_session")
|
||||
.set({
|
||||
bitmap: sql`set_bit(${sql.ref("bitmap")}, ${chunkIndex - 1}, 1)`,
|
||||
})
|
||||
.set({ uploaded_chunks: sql`array_append(uploaded_chunks, ${chunkIndex})` })
|
||||
.where("id", "=", sessionId)
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -8,12 +8,6 @@ import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
||||
|
||||
const chunkLocks = new Set<string>();
|
||||
|
||||
const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
|
||||
chunkIndex -= 1;
|
||||
const byte = bitmap[Math.floor(chunkIndex / 8)];
|
||||
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
|
||||
};
|
||||
|
||||
export const uploadChunk = async (
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
@@ -34,13 +28,13 @@ export const uploadChunk = async (
|
||||
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
||||
if (!session) {
|
||||
error(404, "Invalid upload id");
|
||||
} else if (chunkIndex > session.totalChunks) {
|
||||
} else if (chunkIndex >= session.totalChunks) {
|
||||
error(400, "Invalid chunk index");
|
||||
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
|
||||
} else if (session.uploadedChunks.includes(chunkIndex)) {
|
||||
error(409, "Chunk already uploaded");
|
||||
}
|
||||
|
||||
const isLastChunk = chunkIndex === session.totalChunks;
|
||||
const isLastChunk = chunkIndex === session.totalChunks - 1;
|
||||
filePath = `${session.path}/${chunkIndex}`;
|
||||
|
||||
const hashStream = createHash("sha256");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
||||
import { encodeToBase64, digestMessage } from "$lib/modules/crypto";
|
||||
import {
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
downloadFile,
|
||||
deleteFileThumbnailCache,
|
||||
} from "$lib/modules/file";
|
||||
import { uploadBlob } from "$lib/modules/upload";
|
||||
import { trpc } from "$trpc/client";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
@@ -24,24 +24,41 @@ export const requestFileDownload = async (
|
||||
|
||||
export const requestFileThumbnailUpload = async (
|
||||
fileId: number,
|
||||
thumbnail: Blob,
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
thumbnailEncrypted: { ciphertext: ArrayBuffer; iv: ArrayBuffer },
|
||||
) => {
|
||||
try {
|
||||
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
||||
file: fileId,
|
||||
dekVersion: dataKeyVersion,
|
||||
});
|
||||
|
||||
await uploadBlob(uploadId, thumbnail, dataKey);
|
||||
// Prepend IV to ciphertext (consistent with file download format)
|
||||
const ivAndCiphertext = new Uint8Array(
|
||||
thumbnailEncrypted.iv.byteLength + thumbnailEncrypted.ciphertext.byteLength,
|
||||
);
|
||||
ivAndCiphertext.set(new Uint8Array(thumbnailEncrypted.iv), 0);
|
||||
ivAndCiphertext.set(
|
||||
new Uint8Array(thumbnailEncrypted.ciphertext),
|
||||
thumbnailEncrypted.iv.byteLength,
|
||||
);
|
||||
|
||||
const chunkHash = encodeToBase64(await digestMessage(ivAndCiphertext));
|
||||
|
||||
const response = await fetch(`/api/upload/${uploadId}/chunks/0`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Digest": `sha-256=:${chunkHash}:`,
|
||||
},
|
||||
body: ivAndCiphertext,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Thumbnail upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const requestDeletedFilesCleanup = async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { encryptData } from "$lib/modules/crypto";
|
||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
|
||||
import { requestFileThumbnailUpload } from "$lib/services/file";
|
||||
@@ -32,10 +33,12 @@ export const requestThumbnailUpload = async (
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
) => {
|
||||
const res = await requestFileThumbnailUpload(fileId, thumbnail, dataKey, dataKeyVersion);
|
||||
if (!res) return false;
|
||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
||||
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnailEncrypted);
|
||||
if (!res.ok) return false;
|
||||
|
||||
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileId, buffer));
|
||||
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
info,
|
||||
state: getMigrationState(info.id),
|
||||
}))
|
||||
.filter((file) => file.state?.status !== "uploaded"),
|
||||
.filter((file) => file.state?.status !== "completed"),
|
||||
);
|
||||
|
||||
const migrateAllFiles = () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script module lang="ts">
|
||||
const subtexts = {
|
||||
queued: "대기 중",
|
||||
"download-pending": "다운로드를 기다리는 중",
|
||||
downloading: "다운로드하는 중",
|
||||
"encryption-pending": "암호화를 기다리는 중",
|
||||
encrypting: "암호화하는 중",
|
||||
"upload-pending": "업로드를 기다리는 중",
|
||||
uploaded: "",
|
||||
completed: "완료",
|
||||
error: "실패",
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,13 @@ import { trpc } from "$trpc/client";
|
||||
|
||||
export type MigrationStatus =
|
||||
| "queued"
|
||||
| "download-pending"
|
||||
| "downloading"
|
||||
| "encryption-pending"
|
||||
| "encrypting"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "completed"
|
||||
| "error";
|
||||
|
||||
export interface MigrationState {
|
||||
@@ -35,37 +38,29 @@ export const getMigrationState = (fileId: number) => {
|
||||
|
||||
export const clearMigrationStates = () => {
|
||||
for (const [id, state] of states) {
|
||||
if (state.status === "uploaded" || state.status === "error") {
|
||||
if (state.status === "completed" || state.status === "error") {
|
||||
states.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestFileUpload = limitFunction(
|
||||
async (
|
||||
state: MigrationState,
|
||||
fileId: number,
|
||||
fileBuffer: ArrayBuffer,
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
) => {
|
||||
const uploadMigrationChunks = limitFunction(
|
||||
async (state: MigrationState, fileId: number, fileBuffer: ArrayBuffer, dataKey: CryptoKey) => {
|
||||
state.status = "uploading";
|
||||
|
||||
const { uploadId } = await trpc().upload.startMigrationUpload.mutate({
|
||||
file: fileId,
|
||||
chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE),
|
||||
dekVersion: dataKeyVersion,
|
||||
});
|
||||
|
||||
await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, {
|
||||
onProgress(s) {
|
||||
state.progress = s.progress;
|
||||
state.rate = s.rate;
|
||||
state.rate = s.rateBps;
|
||||
},
|
||||
});
|
||||
|
||||
await trpc().upload.completeMigrationUpload.mutate({ uploadId });
|
||||
state.status = "uploaded";
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
@@ -83,7 +78,7 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const dataKey = fileInfo.dataKey;
|
||||
const dataKey = fileInfo.dataKey?.key;
|
||||
if (!dataKey) {
|
||||
throw new Error("Data key not available");
|
||||
}
|
||||
@@ -92,11 +87,18 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
|
||||
|
||||
await scheduler.schedule(
|
||||
async () => {
|
||||
state.status = "download-pending";
|
||||
state.status = "downloading";
|
||||
fileBuffer = await requestFileDownload(fileInfo.id, dataKey.key, true);
|
||||
fileBuffer = await requestFileDownload(fileInfo.id, dataKey, true);
|
||||
return fileBuffer.byteLength;
|
||||
},
|
||||
() => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey.key, dataKey.version),
|
||||
async () => {
|
||||
state.status = "encryption-pending";
|
||||
|
||||
await uploadMigrationChunks(state, fileInfo.id, fileBuffer!, dataKey);
|
||||
|
||||
state.status = "completed";
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
queued: "대기 중",
|
||||
"generation-pending": "준비 중",
|
||||
generating: "생성하는 중",
|
||||
"upload-pending": "업로드를 기다리는 중",
|
||||
uploading: "업로드하는 중",
|
||||
error: "실패",
|
||||
} as const;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { encryptData } from "$lib/modules/crypto";
|
||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { Scheduler } from "$lib/modules/scheduler";
|
||||
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
|
||||
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
|
||||
|
||||
export type GenerationStatus =
|
||||
| "queued"
|
||||
| "generation-pending"
|
||||
| "generating"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "error";
|
||||
@@ -29,27 +31,33 @@ export const clearThumbnailGenerationStatuses = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const generateThumbnail = limitFunction(
|
||||
async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => {
|
||||
statuses.set(fileId, "generating");
|
||||
|
||||
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
|
||||
if (!thumbnail) return null;
|
||||
|
||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
||||
statuses.set(fileId, "upload-pending");
|
||||
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
const requestThumbnailUpload = limitFunction(
|
||||
async (fileInfo: FileInfo, fileBuffer: ArrayBuffer) => {
|
||||
statuses.set(fileInfo.id, "generating");
|
||||
async (
|
||||
fileId: number,
|
||||
dataKeyVersion: Date,
|
||||
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: ArrayBuffer },
|
||||
) => {
|
||||
statuses.set(fileId, "uploading");
|
||||
|
||||
const thumbnail = await generateThumbnail(
|
||||
new Blob([fileBuffer], { type: fileInfo.contentType }),
|
||||
);
|
||||
if (!thumbnail) return false;
|
||||
|
||||
statuses.set(fileInfo.id, "uploading");
|
||||
|
||||
const res = await requestFileThumbnailUpload(
|
||||
fileInfo.id,
|
||||
thumbnail,
|
||||
fileInfo.dataKey?.key!,
|
||||
fileInfo.dataKey?.version!,
|
||||
);
|
||||
if (!res) return false;
|
||||
|
||||
statuses.set(fileInfo.id, "uploaded");
|
||||
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileInfo.id, buffer));
|
||||
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
|
||||
if (!res.ok) return false;
|
||||
statuses.set(fileId, "uploaded");
|
||||
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
|
||||
return true;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
@@ -73,7 +81,16 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
||||
return file.byteLength;
|
||||
},
|
||||
async () => {
|
||||
if (!(await requestThumbnailUpload(fileInfo, file!))) {
|
||||
const thumbnail = await generateThumbnail(
|
||||
fileInfo.id,
|
||||
file!,
|
||||
fileInfo.contentType,
|
||||
fileInfo.dataKey?.key!,
|
||||
);
|
||||
if (
|
||||
!thumbnail ||
|
||||
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
|
||||
) {
|
||||
statuses.set(fileInfo.id, "error");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { Readable } from "stream";
|
||||
import type { ReadableStream } from "stream/web";
|
||||
import { ReadableStream } from "stream/web";
|
||||
import { z } from "zod";
|
||||
import { parseContentDigestHeader } from "$lib/modules/http";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
@@ -13,7 +13,7 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.uuidv4(),
|
||||
index: z.coerce.number().int().positive(),
|
||||
index: z.coerce.number().int().nonnegative(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../lib/constants";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
|
||||
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../modules/constants";
|
||||
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
|
||||
import { getFile } from "../modules/opfs";
|
||||
@@ -15,13 +15,10 @@ const createResponse = (
|
||||
const headers: Record<string, string> = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(range.end - range.start + 1),
|
||||
"Content-Type": contentType ?? "application/octet-stream",
|
||||
...(isRangeRequest ? getContentRangeHeader(range) : {}),
|
||||
};
|
||||
|
||||
if (contentType) {
|
||||
headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
if (downloadFilename) {
|
||||
headers["Content-Disposition"] =
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "./constants";
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants";
|
||||
import { decryptFile } from "./handlers";
|
||||
import { fileMetadataStore } from "./stores";
|
||||
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
|
||||
|
||||
1
src/service-worker/modules/constants.ts
Normal file
1
src/service-worker/modules/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../lib/constants";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
|
||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "./constants";
|
||||
|
||||
export * from "../../lib/modules/crypto";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, createWriteStream } from "fs";
|
||||
import { copyFile, mkdir } from "fs/promises";
|
||||
import { mkdir, rename } from "fs/promises";
|
||||
import mime from "mime";
|
||||
import { dirname } from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -13,8 +13,6 @@ import env from "$lib/server/loadenv";
|
||||
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
|
||||
|
||||
const sessionLocks = new Set<string>();
|
||||
|
||||
const generateSessionId = async () => {
|
||||
@@ -62,7 +60,7 @@ const uploadRouter = router({
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: input.chunks,
|
||||
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
parentId: input.parent,
|
||||
mekVersion: input.mekVersion,
|
||||
encDek: input.dek,
|
||||
@@ -91,6 +89,41 @@ const uploadRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
startFileThumbnailUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
file: z.int().positive(),
|
||||
dekVersion: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, path } = await generateSessionId();
|
||||
|
||||
try {
|
||||
await UploadRepo.createThumbnailUploadSession({
|
||||
id,
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: 1, // Up to 4 MiB
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
fileId: input.file,
|
||||
dekVersion: input.dekVersion,
|
||||
});
|
||||
return { uploadId: id };
|
||||
} catch (e) {
|
||||
await safeRecursiveRm(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
completeFileUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
@@ -110,14 +143,14 @@ const uploadRouter = router({
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||
if (session?.type !== "file") {
|
||||
if (!session || session.type !== "file") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||
} else if (
|
||||
(session.hskVersion && !input.contentHmac) ||
|
||||
(!session.hskVersion && input.contentHmac)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" });
|
||||
} else if (session.uploadedChunks < session.totalChunks) {
|
||||
} else if (session.uploadedChunks.length < session.totalChunks) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||
}
|
||||
|
||||
@@ -127,7 +160,7 @@ const uploadRouter = router({
|
||||
const hashStream = createHash("sha256");
|
||||
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
||||
|
||||
for (let i = 1; i <= session.totalChunks; i++) {
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
for await (const chunk of createReadStream(`${session.path}/${i}`)) {
|
||||
hashStream.update(chunk);
|
||||
writeStream.write(chunk);
|
||||
@@ -162,42 +195,6 @@ const uploadRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
startFileThumbnailUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
file: z.int().positive(),
|
||||
dekVersion: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, path } = await generateSessionId();
|
||||
|
||||
try {
|
||||
await UploadRepo.createThumbnailOrMigrationUploadSession({
|
||||
id,
|
||||
type: "thumbnail",
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: 1, // Up to 4 MiB
|
||||
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||
fileId: input.file,
|
||||
dekVersion: input.dekVersion,
|
||||
});
|
||||
return { uploadId: id };
|
||||
} catch (e) {
|
||||
await safeRecursiveRm(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
completeFileThumbnailUpload: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
@@ -216,15 +213,15 @@ const uploadRouter = router({
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||
if (session?.type !== "thumbnail") {
|
||||
if (!session || session.type !== "thumbnail") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||
} else if (session.uploadedChunks < session.totalChunks) {
|
||||
} else if (session.uploadedChunks.length < session.totalChunks) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||
}
|
||||
|
||||
thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`;
|
||||
await mkdir(dirname(thumbnailPath), { recursive: true });
|
||||
await copyFile(`${session.path}/1`, thumbnailPath);
|
||||
await rename(`${session.path}/0`, thumbnailPath);
|
||||
|
||||
const oldThumbnailPath = await db.transaction().execute(async (trx) => {
|
||||
const oldPath = await MediaRepo.updateFileThumbnail(
|
||||
@@ -241,10 +238,12 @@ const uploadRouter = router({
|
||||
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
|
||||
} catch (e) {
|
||||
await safeUnlink(thumbnailPath);
|
||||
|
||||
if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
|
||||
// DEK rotated after this upload started
|
||||
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -257,22 +256,19 @@ const uploadRouter = router({
|
||||
z.object({
|
||||
file: z.int().positive(),
|
||||
chunks: z.int().positive(),
|
||||
dekVersion: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, path } = await generateSessionId();
|
||||
|
||||
try {
|
||||
await UploadRepo.createThumbnailOrMigrationUploadSession({
|
||||
await UploadRepo.createMigrationUploadSession({
|
||||
id,
|
||||
type: "migration",
|
||||
userId: ctx.session.userId,
|
||||
path,
|
||||
totalChunks: input.chunks,
|
||||
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
fileId: input.file,
|
||||
dekVersion: input.dekVersion,
|
||||
});
|
||||
return { uploadId: id };
|
||||
} catch (e) {
|
||||
@@ -280,9 +276,9 @@ const uploadRouter = router({
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
||||
} else if (e.message === "File is not legacy") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
@@ -307,9 +303,9 @@ const uploadRouter = router({
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||
if (session?.type !== "migration") {
|
||||
if (!session || session.type !== "migration") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||
} else if (session.uploadedChunks < session.totalChunks) {
|
||||
} else if (session.uploadedChunks.length < session.totalChunks) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||
}
|
||||
|
||||
@@ -319,7 +315,7 @@ const uploadRouter = router({
|
||||
const hashStream = createHash("sha256");
|
||||
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
||||
|
||||
for (let i = 1; i <= session.totalChunks; i++) {
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
for await (const chunk of createReadStream(`${session.path}/${i}`)) {
|
||||
hashStream.update(chunk);
|
||||
writeStream.write(chunk);
|
||||
@@ -332,12 +328,11 @@ const uploadRouter = router({
|
||||
|
||||
const hash = hashStream.digest("base64");
|
||||
const oldPath = await db.transaction().execute(async (trx) => {
|
||||
const { oldPath } = await FileRepo.migrateFileContent(
|
||||
const oldPath = await FileRepo.migrateFileContent(
|
||||
trx,
|
||||
ctx.session.userId,
|
||||
session.fileId,
|
||||
filePath,
|
||||
session.dekVersion!,
|
||||
hash,
|
||||
);
|
||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||
@@ -347,10 +342,12 @@ const uploadRouter = router({
|
||||
await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]);
|
||||
} catch (e) {
|
||||
await safeUnlink(filePath);
|
||||
|
||||
if (e instanceof IntegrityError && e.message === "File is not legacy") {
|
||||
// File migrated after this upload started
|
||||
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
||||
} else if (e.message === "File is not legacy") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { hmac } from "@noble/hashes/hmac.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
|
||||
export interface ComputeMessage {
|
||||
stream: ReadableStream;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
result: Uint8Array;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ComputeMessage>) => {
|
||||
const h = hmac.create(sha256, event.data.key);
|
||||
const reader = event.data.stream.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
h.update(value);
|
||||
}
|
||||
|
||||
const result = h.digest();
|
||||
self.postMessage({ result } satisfies ResultMessage, { transfer: [result.buffer] });
|
||||
};
|
||||
@@ -8,7 +8,6 @@ const config = {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$trpc: "./src/trpc",
|
||||
$workers: "./src/workers",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user