mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 16:16:55 +00:00
Compare commits
8 Commits
b9e6f17b0c
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3906ec4371 | ||
|
|
90ac5ba4c3 | ||
|
|
dfffa004ac | ||
|
|
0cd55a413d | ||
|
|
361d966a59 | ||
|
|
aef43b8bfa | ||
|
|
7f128cccf6 | ||
|
|
a198e5f6dc |
@@ -89,15 +89,11 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
||||
return { ciphertext, iv: encodeToBase64(iv.buffer) };
|
||||
};
|
||||
|
||||
export const decryptData = async (
|
||||
ciphertext: BufferSource,
|
||||
iv: string | BufferSource,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
|
||||
return await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,
|
||||
iv: decodeFromBase64(iv),
|
||||
} satisfies AesGcmParams,
|
||||
dataKey,
|
||||
ciphertext,
|
||||
|
||||
@@ -62,14 +62,15 @@ const requestFileDownload = limitFunction(
|
||||
);
|
||||
|
||||
const decryptFile = limitFunction(
|
||||
async (state: FileDownloadState, fileEncrypted: ArrayBuffer, dataKey: CryptoKey) => {
|
||||
async (
|
||||
state: FileDownloadState,
|
||||
fileEncrypted: ArrayBuffer,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
state.status = "decrypting";
|
||||
|
||||
const fileBuffer = await decryptData(
|
||||
fileEncrypted.slice(12),
|
||||
fileEncrypted.slice(0, 12),
|
||||
dataKey,
|
||||
);
|
||||
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
|
||||
|
||||
state.status = "decrypted";
|
||||
state.result = fileBuffer;
|
||||
@@ -78,7 +79,7 @@ const decryptFile = limitFunction(
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const downloadFile = async (id: number, dataKey: CryptoKey) => {
|
||||
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
|
||||
downloadingFiles.push({
|
||||
id,
|
||||
status: "download-pending",
|
||||
@@ -86,7 +87,7 @@ export const downloadFile = async (id: number, dataKey: CryptoKey) => {
|
||||
const state = downloadingFiles.at(-1)!;
|
||||
|
||||
try {
|
||||
return await decryptFile(state, await requestFileDownload(state, id), dataKey);
|
||||
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { isTRPCClientError, trpc } from "$trpc/client";
|
||||
|
||||
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
|
||||
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
|
||||
@@ -17,18 +18,25 @@ const fetchFromOpfs = async (fileId: number) => {
|
||||
};
|
||||
|
||||
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
|
||||
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const thumbnailEncrypted = await res.arrayBuffer();
|
||||
try {
|
||||
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
|
||||
fetch(`/api/file/${fileId}/thumbnail/download`),
|
||||
trpc().file.thumbnail.query({ id: fileId }),
|
||||
]);
|
||||
const thumbnailBuffer = await decryptData(
|
||||
thumbnailEncrypted.slice(12),
|
||||
thumbnailEncrypted.slice(0, 12),
|
||||
await thumbnailEncrypted.arrayBuffer(),
|
||||
thumbnailEncryptedIv,
|
||||
dataKey,
|
||||
);
|
||||
|
||||
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||
return getThumbnailUrl(thumbnailBuffer);
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileThumbnail = (file: SummarizedFileInfo) => {
|
||||
|
||||
@@ -50,6 +50,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
parentId: file.parent,
|
||||
dataKey: metadata.dataKey,
|
||||
contentType: file.contentType,
|
||||
contentIv: file.contentIv,
|
||||
name: metadata.name,
|
||||
createdAt: metadata.createdAt,
|
||||
lastModifiedAt: metadata.lastModifiedAt,
|
||||
@@ -117,6 +118,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
exists: true as const,
|
||||
parentId: metadataRaw.parent,
|
||||
contentType: metadataRaw.contentType,
|
||||
contentIv: metadataRaw.contentIv,
|
||||
categories,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface FileInfo {
|
||||
parentId: DirectoryId;
|
||||
dataKey?: DataKey;
|
||||
contentType: string;
|
||||
contentIv?: string;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
@@ -41,7 +42,7 @@ export type MaybeFileInfo =
|
||||
| (FileInfo & { exists: true })
|
||||
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
|
||||
|
||||
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
|
||||
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">;
|
||||
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
|
||||
|
||||
interface LocalCategoryInfo {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const parseRangeHeader = (rangeHeader: string | null) => {
|
||||
if (!rangeHeader) return undefined;
|
||||
|
||||
const firstRange = rangeHeader.split(",")[0]!.trim();
|
||||
const parts = firstRange.replace(/bytes=/, "").split("-");
|
||||
return {
|
||||
start: parts[0] ? parseInt(parts[0], 10) : undefined,
|
||||
end: parts[1] ? parseInt(parts[1], 10) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
|
||||
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
|
||||
};
|
||||
@@ -10,69 +10,30 @@ import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
|
||||
import env from "$lib/server/loadenv";
|
||||
import { safeUnlink } from "$lib/server/modules/filesystem";
|
||||
|
||||
const createEncContentStream = async (
|
||||
path: string,
|
||||
iv: Buffer,
|
||||
range?: { start?: number; end?: number },
|
||||
) => {
|
||||
const { size: fileSize } = await stat(path);
|
||||
const ivSize = iv.byteLength;
|
||||
const totalSize = fileSize + ivSize;
|
||||
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? totalSize - 1;
|
||||
if (start > end || start < 0 || end >= totalSize) {
|
||||
error(416, "Invalid range");
|
||||
}
|
||||
|
||||
return {
|
||||
encContentStream: Readable.toWeb(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
if (start < ivSize) {
|
||||
yield iv.subarray(start, Math.min(end + 1, ivSize));
|
||||
}
|
||||
if (end >= ivSize) {
|
||||
yield* createReadStream(path, {
|
||||
start: Math.max(0, start - ivSize),
|
||||
end: end - ivSize,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
),
|
||||
),
|
||||
range: { start, end, total: totalSize },
|
||||
};
|
||||
};
|
||||
|
||||
export const getFileStream = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
range?: { start?: number; end?: number },
|
||||
) => {
|
||||
export const getFileStream = async (userId: number, fileId: number) => {
|
||||
const file = await FileRepo.getFile(userId, fileId);
|
||||
if (!file) {
|
||||
error(404, "Invalid file id");
|
||||
}
|
||||
|
||||
return createEncContentStream(file.path, Buffer.from(file.encContentIv, "base64"), range);
|
||||
const { size } = await stat(file.path);
|
||||
return {
|
||||
encContentStream: Readable.toWeb(createReadStream(file.path)),
|
||||
encContentSize: size,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFileThumbnailStream = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
range?: { start?: number; end?: number },
|
||||
) => {
|
||||
export const getFileThumbnailStream = async (userId: number, fileId: number) => {
|
||||
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
|
||||
if (!thumbnail) {
|
||||
error(404, "File or its thumbnail not found");
|
||||
}
|
||||
|
||||
return createEncContentStream(
|
||||
thumbnail.path,
|
||||
Buffer.from(thumbnail.encContentIv, "base64"),
|
||||
range,
|
||||
);
|
||||
const { size } = await stat(thumbnail.path);
|
||||
return {
|
||||
encContentStream: Readable.toWeb(createReadStream(thumbnail.path)),
|
||||
encContentSize: size,
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadFileThumbnail = async (
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||
import { trpc } from "$trpc/client";
|
||||
|
||||
export const requestFileDownload = async (fileId: number, dataKey: CryptoKey) => {
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
const fileBuffer = await downloadFile(fileId, dataKey);
|
||||
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { page } from "$app/state";
|
||||
import { FullscreenDiv } from "$lib/components/atoms";
|
||||
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
|
||||
import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
|
||||
import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
|
||||
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
|
||||
import { getFileDownloadState } from "$lib/modules/file";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
@@ -95,12 +95,14 @@
|
||||
untrack(() => {
|
||||
if (!downloadState && !isDownloadRequested) {
|
||||
isDownloadRequested = true;
|
||||
requestFileDownload(data.id, info!.dataKey!.key).then(async (buffer) => {
|
||||
requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
|
||||
async (buffer) => {
|
||||
const blob = await updateViewer(buffer, contentType);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, info!.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -108,9 +110,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (info?.exists && downloadState?.status === "decrypted") {
|
||||
untrack(
|
||||
() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType!),
|
||||
);
|
||||
untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
||||
await scheduler.schedule(
|
||||
async () => {
|
||||
statuses.set(fileInfo.id, "generation-pending");
|
||||
file = await requestFileDownload(fileInfo.id, fileInfo.dataKey?.key!);
|
||||
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!);
|
||||
return file.byteLength;
|
||||
},
|
||||
async () => {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
|
||||
import { getFileStream } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
const downloadHandler = async (
|
||||
locals: App.Locals,
|
||||
params: Record<string, string>,
|
||||
request: Request,
|
||||
) => {
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
@@ -20,29 +15,11 @@ const downloadHandler = async (
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const { encContentStream, range } = await getFileStream(
|
||||
userId,
|
||||
id,
|
||||
parseRangeHeader(request.headers.get("Range")),
|
||||
);
|
||||
return {
|
||||
stream: encContentStream,
|
||||
const { encContentStream, encContentSize } = await getFileStream(userId, id);
|
||||
return new Response(encContentStream as ReadableStream, {
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": (range.end - range.start + 1).toString(),
|
||||
"Content-Type": "application/octet-stream",
|
||||
...getContentRangeHeader(range),
|
||||
"Content-Length": encContentSize.toString(),
|
||||
},
|
||||
isRangeRequest: !!range,
|
||||
};
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params, request }) => {
|
||||
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
||||
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
|
||||
};
|
||||
|
||||
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
|
||||
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
||||
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
|
||||
import { getFileThumbnailStream } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
const downloadHandler = async (
|
||||
locals: App.Locals,
|
||||
params: Record<string, string>,
|
||||
request: Request,
|
||||
) => {
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
@@ -20,29 +15,11 @@ const downloadHandler = async (
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const { encContentStream, range } = await getFileThumbnailStream(
|
||||
userId,
|
||||
id,
|
||||
parseRangeHeader(request.headers.get("Range")),
|
||||
);
|
||||
return {
|
||||
stream: encContentStream,
|
||||
const { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id);
|
||||
return new Response(encContentStream as ReadableStream, {
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": (range.end - range.start + 1).toString(),
|
||||
"Content-Type": "application/octet-stream",
|
||||
...getContentRangeHeader(range),
|
||||
"Content-Length": encContentSize.toString(),
|
||||
},
|
||||
isRangeRequest: !!range,
|
||||
};
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params, request }) => {
|
||||
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
||||
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
|
||||
};
|
||||
|
||||
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
|
||||
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
||||
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ const fileRouter = router({
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
contentIv: file.encContentIv,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
@@ -57,6 +58,7 @@ const fileRouter = router({
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
contentIv: file.encContentIv,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
@@ -156,7 +158,7 @@ const fileRouter = router({
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" });
|
||||
}
|
||||
|
||||
return { updatedAt: thumbnail.updatedAt };
|
||||
return { updatedAt: thumbnail.updatedAt, contentIv: thumbnail.encContentIv };
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user