mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 16:16:55 +00:00
Compare commits
18 Commits
614d0e74b4
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b621d6e98 | ||
|
|
b952bfae86 | ||
|
|
4cdf2b342f | ||
|
|
a4912c8952 | ||
|
|
00b9858db7 | ||
|
|
c778a4fb9e | ||
|
|
e7dc96bb47 | ||
|
|
b636d75ea0 | ||
|
|
27e90ef4d7 | ||
|
|
594c3654c9 | ||
|
|
3906ec4371 | ||
|
|
90ac5ba4c3 | ||
|
|
dfffa004ac | ||
|
|
0cd55a413d | ||
|
|
361d966a59 | ||
|
|
aef43b8bfa | ||
|
|
7f128cccf6 | ||
|
|
a198e5f6dc |
45
.github/workflows/docker.yaml
vendored
Normal file
45
.github/workflows/docker.yaml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Docker Image Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,value={{version}}
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/library:/app/data/library
|
- ./data/library:/app/data/library
|
||||||
- ./data/thumbnails:/app/data/thumbnails
|
- ./data/thumbnails:/app/data/thumbnails
|
||||||
|
- ./data/uploads:/app/data/uploads
|
||||||
environment:
|
environment:
|
||||||
# ArkVault
|
# ArkVault
|
||||||
- DATABASE_HOST=database
|
- DATABASE_HOST=database
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
cleanupExpiredSessions,
|
cleanupExpiredSessions,
|
||||||
cleanupExpiredSessionUpgradeChallenges,
|
cleanupExpiredSessionUpgradeChallenges,
|
||||||
} from "$lib/server/db/session";
|
} from "$lib/server/db/session";
|
||||||
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
|
|
||||||
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
import { authenticate, setAgentInfo } from "$lib/server/middlewares";
|
||||||
|
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
|
||||||
|
|
||||||
export const init: ServerInit = async () => {
|
export const init: ServerInit = async () => {
|
||||||
await migrateDB();
|
await migrateDB();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { hmac } from "@noble/hashes/hmac.js";
|
import HmacWorker from "$workers/hmac?worker";
|
||||||
import { sha256 } from "@noble/hashes/sha2.js";
|
import type { ComputeMessage, ResultMessage } from "$workers/hmac";
|
||||||
|
|
||||||
export const digestMessage = async (message: BufferSource) => {
|
export const digestMessage = async (message: BufferSource) => {
|
||||||
return await crypto.subtle.digest("SHA-256", message);
|
return await crypto.subtle.digest("SHA-256", message);
|
||||||
@@ -18,10 +18,24 @@ export const generateHmacSecret = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createHmacStream = async (hmacSecret: CryptoKey) => {
|
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
||||||
const h = hmac.create(sha256, new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret)));
|
const stream = message.stream();
|
||||||
return {
|
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
||||||
update: (data: Uint8Array) => h.update(data),
|
const worker = new HmacWorker();
|
||||||
digest: () => h.digest(),
|
|
||||||
};
|
return new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
|
||||||
|
resolve(data.result);
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 axios from "axios";
|
||||||
import { limitFunction } from "p-limit";
|
import { limitFunction } from "p-limit";
|
||||||
import { CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "$lib/constants";
|
import { ENCRYPTED_CHUNK_SIZE } from "$lib/constants";
|
||||||
import { decryptChunk, concatenateBuffers } from "$lib/modules/crypto";
|
import { decryptChunk, concatenateBuffers } from "$lib/modules/crypto";
|
||||||
|
|
||||||
export interface FileDownloadState {
|
export interface FileDownloadState {
|
||||||
@@ -100,7 +100,7 @@ export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boo
|
|||||||
return await decryptFile(
|
return await decryptFile(
|
||||||
state,
|
state,
|
||||||
fileEncrypted,
|
fileEncrypted,
|
||||||
isLegacy ? fileEncrypted.byteLength : CHUNK_SIZE + ENCRYPTION_OVERHEAD,
|
isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE,
|
||||||
dataKey,
|
dataKey,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { writable, type Writable } from "svelte/store";
|
import { writable, type Writable } from "svelte/store";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { decryptData } from "$lib/modules/crypto";
|
import { decryptChunk } from "$lib/modules/crypto";
|
||||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
||||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||||
@@ -20,12 +20,7 @@ const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
|
|||||||
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
const thumbnailEncrypted = await res.arrayBuffer();
|
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
|
||||||
const thumbnailBuffer = await decryptData(
|
|
||||||
thumbnailEncrypted.slice(12),
|
|
||||||
thumbnailEncrypted.slice(0, 12),
|
|
||||||
dataKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||||
return getThumbnailUrl(thumbnailBuffer);
|
return getThumbnailUrl(thumbnailBuffer);
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import ExifReader from "exifreader";
|
import ExifReader from "exifreader";
|
||||||
import pLimit, { limitFunction } from "p-limit";
|
import { limitFunction } from "p-limit";
|
||||||
import { CHUNK_SIZE } from "$lib/constants";
|
import { CHUNK_SIZE } from "$lib/constants";
|
||||||
import {
|
import { encodeToBase64, generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
|
||||||
encodeToBase64,
|
import { signMessageHmac } from "$lib/modules/crypto";
|
||||||
generateDataKey,
|
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||||
wrapDataKey,
|
import { uploadBlob } from "$lib/modules/upload";
|
||||||
encryptData,
|
|
||||||
encryptString,
|
|
||||||
encryptChunk,
|
|
||||||
digestMessage,
|
|
||||||
createHmacStream,
|
|
||||||
} from "$lib/modules/crypto";
|
|
||||||
import { Scheduler } from "$lib/modules/scheduler";
|
|
||||||
import { generateThumbnail, generateThumbnailFromFile } from "$lib/modules/thumbnail";
|
|
||||||
import type { MasterKey, HmacSecret } from "$lib/stores";
|
import type { MasterKey, HmacSecret } from "$lib/stores";
|
||||||
|
import { Scheduler } from "$lib/utils";
|
||||||
import { trpc } from "$trpc/client";
|
import { trpc } from "$trpc/client";
|
||||||
import type { RouterInputs } from "$trpc/router.server";
|
|
||||||
|
|
||||||
export interface FileUploadState {
|
export interface FileUploadState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,17 +50,15 @@ export const clearUploadedFiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestDuplicateFileScan = limitFunction(
|
const requestDuplicateFileScan = limitFunction(
|
||||||
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
|
async (
|
||||||
const hmacStream = await createHmacStream(hmacSecret.secret);
|
state: FileUploadState,
|
||||||
const reader = file.stream().getReader();
|
file: File,
|
||||||
|
hmacSecret: HmacSecret,
|
||||||
|
onDuplicate: () => Promise<boolean>,
|
||||||
|
) => {
|
||||||
|
state.status = "encryption-pending";
|
||||||
|
|
||||||
while (true) {
|
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
hmacStream.update(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileSigned = encodeToBase64(hmacStream.digest());
|
|
||||||
const files = await trpc().file.listByHash.query({
|
const files = await trpc().file.listByHash.query({
|
||||||
hskVersion: hmacSecret.version,
|
hskVersion: hmacSecret.version,
|
||||||
contentHmac: fileSigned,
|
contentHmac: fileSigned,
|
||||||
@@ -114,295 +104,107 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
|||||||
return new Date(utcDate - offsetMs);
|
return new Date(utcDate - offsetMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const encryptChunks = async (fileBuffer: ArrayBuffer, dataKey: CryptoKey) => {
|
interface FileMetadata {
|
||||||
const chunksEncrypted: { chunkEncrypted: ArrayBuffer; chunkEncryptedHash: string }[] = [];
|
parentId: "root" | number;
|
||||||
let offset = 0;
|
name: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
lastModifiedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
while (offset < fileBuffer.byteLength) {
|
const requestFileMetadataEncryption = limitFunction(
|
||||||
const nextOffset = Math.min(offset + CHUNK_SIZE, fileBuffer.byteLength);
|
async (
|
||||||
const chunkEncrypted = await encryptChunk(fileBuffer.slice(offset, nextOffset), dataKey);
|
state: FileUploadState,
|
||||||
chunksEncrypted.push({
|
file: Blob,
|
||||||
chunkEncrypted: chunkEncrypted,
|
fileMetadata: FileMetadata,
|
||||||
chunkEncryptedHash: encodeToBase64(await digestMessage(chunkEncrypted)),
|
masterKey: MasterKey,
|
||||||
});
|
hmacSecret: HmacSecret,
|
||||||
offset = nextOffset;
|
) => {
|
||||||
}
|
|
||||||
|
|
||||||
return chunksEncrypted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const encryptImageFile = limitFunction(
|
|
||||||
async (state: FileUploadState, file: File, masterKey: MasterKey) => {
|
|
||||||
state.status = "encrypting";
|
state.status = "encrypting";
|
||||||
|
|
||||||
const fileBuffer = await file.arrayBuffer();
|
|
||||||
const createdAt = extractExifDateTime(fileBuffer);
|
|
||||||
|
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||||
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
||||||
const chunksEncrypted = await encryptChunks(fileBuffer, dataKey);
|
|
||||||
|
|
||||||
const nameEncrypted = await encryptString(file.name, dataKey);
|
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
|
||||||
const createdAtEncrypted =
|
await Promise.all([
|
||||||
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
encryptString(fileMetadata.name, dataKey),
|
||||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
fileMetadata.createdAt &&
|
||||||
|
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
|
||||||
|
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
|
||||||
|
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
|
||||||
|
]);
|
||||||
|
|
||||||
const thumbnail = await generateThumbnail(fileBuffer, getFileType(file));
|
const { uploadId } = await trpc().upload.startFileUpload.mutate({
|
||||||
const thumbnailBuffer = await thumbnail?.arrayBuffer();
|
chunks: Math.ceil(file.size / CHUNK_SIZE),
|
||||||
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
|
parent: fileMetadata.parentId,
|
||||||
|
mekVersion: masterKey.version,
|
||||||
|
dek: dataKeyWrapped,
|
||||||
|
dekVersion: dataKeyVersion,
|
||||||
|
hskVersion: hmacSecret.version,
|
||||||
|
contentType: file.type,
|
||||||
|
name: nameEncrypted.ciphertext,
|
||||||
|
nameIv: nameEncrypted.iv,
|
||||||
|
createdAt: createdAtEncrypted?.ciphertext,
|
||||||
|
createdAtIv: createdAtEncrypted?.iv,
|
||||||
|
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
||||||
|
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||||
|
});
|
||||||
|
|
||||||
state.status = "upload-pending";
|
state.status = "upload-pending";
|
||||||
|
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
|
||||||
return {
|
|
||||||
dataKeyWrapped,
|
|
||||||
dataKeyVersion,
|
|
||||||
chunksEncrypted,
|
|
||||||
nameEncrypted,
|
|
||||||
createdAtEncrypted,
|
|
||||||
lastModifiedAtEncrypted,
|
|
||||||
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
{ concurrency: 4 },
|
{ concurrency: 4 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadThumbnail = async (
|
const requestFileUpload = limitFunction(
|
||||||
fileId: number,
|
|
||||||
thumbnailEncrypted: { ciphertext: ArrayBuffer; iv: ArrayBuffer },
|
|
||||||
dataKeyVersion: Date,
|
|
||||||
) => {
|
|
||||||
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
|
||||||
file: fileId,
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestImageFileUpload = limitFunction(
|
|
||||||
async (
|
async (
|
||||||
state: FileUploadState,
|
state: FileUploadState,
|
||||||
metadata: RouterInputs["upload"]["startFileUpload"],
|
uploadId: string,
|
||||||
chunksEncrypted: { chunkEncrypted: ArrayBuffer; chunkEncryptedHash: string }[],
|
file: Blob,
|
||||||
fileSigned: string | undefined,
|
fileSigned: string,
|
||||||
thumbnailData: { ciphertext: ArrayBuffer; iv: ArrayBuffer; plaintext: ArrayBuffer } | null,
|
thumbnailBuffer: ArrayBuffer | undefined,
|
||||||
|
dataKey: CryptoKey,
|
||||||
dataKeyVersion: Date,
|
dataKeyVersion: Date,
|
||||||
) => {
|
) => {
|
||||||
state.status = "uploading";
|
state.status = "uploading";
|
||||||
|
|
||||||
const { uploadId } = await trpc().upload.startFileUpload.mutate(metadata);
|
await uploadBlob(uploadId, file, dataKey, {
|
||||||
|
onProgress(s) {
|
||||||
const totalBytes = chunksEncrypted.reduce((sum, c) => sum + c.chunkEncrypted.byteLength, 0);
|
state.progress = s.progress;
|
||||||
let uploadedBytes = 0;
|
state.rate = s.rate;
|
||||||
const startTime = Date.now();
|
},
|
||||||
|
});
|
||||||
for (let i = 0; i < chunksEncrypted.length; i++) {
|
|
||||||
const { chunkEncrypted, chunkEncryptedHash } = chunksEncrypted[i]!;
|
|
||||||
|
|
||||||
const response = await fetch(`/api/upload/${uploadId}/chunks/${i}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Digest": `sha-256=:${chunkEncryptedHash}:`,
|
|
||||||
},
|
|
||||||
body: chunkEncrypted,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Chunk upload failed: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedBytes += chunkEncrypted.byteLength;
|
|
||||||
|
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
|
||||||
const rate = uploadedBytes / elapsed;
|
|
||||||
const remaining = totalBytes - uploadedBytes;
|
|
||||||
const estimated = rate > 0 ? remaining / rate : undefined;
|
|
||||||
|
|
||||||
state.progress = uploadedBytes / totalBytes;
|
|
||||||
state.rate = rate;
|
|
||||||
state.estimated = estimated;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
|
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
|
||||||
uploadId,
|
uploadId,
|
||||||
contentHmac: fileSigned,
|
contentHmac: fileSigned,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (thumbnailData) {
|
if (thumbnailBuffer) {
|
||||||
try {
|
try {
|
||||||
await uploadThumbnail(fileId, thumbnailData, dataKeyVersion);
|
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
||||||
|
file: fileId,
|
||||||
|
dekVersion: dataKeyVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
|
||||||
|
|
||||||
|
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// TODO: Error handling for thumbnail upload
|
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.status = "uploaded";
|
state.status = "uploaded";
|
||||||
|
return { fileId };
|
||||||
return { fileId, thumbnailBuffer: thumbnailData?.plaintext };
|
|
||||||
},
|
},
|
||||||
{ concurrency: 1 },
|
{ concurrency: 1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestFileUpload = async (
|
|
||||||
state: FileUploadState,
|
|
||||||
file: File,
|
|
||||||
masterKey: MasterKey,
|
|
||||||
hmacSecret: HmacSecret,
|
|
||||||
fileSigned: string,
|
|
||||||
parentId: DirectoryId,
|
|
||||||
) => {
|
|
||||||
state.status = "uploading";
|
|
||||||
|
|
||||||
const fileType = getFileType(file);
|
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
|
||||||
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
|
||||||
|
|
||||||
const nameEncrypted = await encryptString(file.name, dataKey);
|
|
||||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
|
||||||
|
|
||||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
|
||||||
const metadata = {
|
|
||||||
chunks: totalChunks,
|
|
||||||
parent: parentId,
|
|
||||||
mekVersion: masterKey.version,
|
|
||||||
dek: dataKeyWrapped,
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentType: fileType,
|
|
||||||
name: nameEncrypted.ciphertext,
|
|
||||||
nameIv: nameEncrypted.iv,
|
|
||||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
|
||||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { uploadId } = await trpc().upload.startFileUpload.mutate(metadata);
|
|
||||||
|
|
||||||
const reader = file.stream().getReader();
|
|
||||||
const limit = pLimit(4);
|
|
||||||
let buffer = new Uint8Array(0);
|
|
||||||
let chunkIndex = 0;
|
|
||||||
const uploadPromises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
const totalBytes = file.size;
|
|
||||||
let uploadedBytes = 0;
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const uploadChunk = async (
|
|
||||||
index: number,
|
|
||||||
encryptedChunk: ArrayBuffer,
|
|
||||||
chunkHash: string,
|
|
||||||
originalChunkSize: number,
|
|
||||||
) => {
|
|
||||||
const response = await fetch(`/api/upload/${uploadId}/chunks/${index}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Digest": `sha-256=:${chunkHash}:`,
|
|
||||||
},
|
|
||||||
body: encryptedChunk,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Chunk upload failed: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedBytes += originalChunkSize;
|
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
|
||||||
const rate = uploadedBytes / elapsed;
|
|
||||||
const remaining = totalBytes - uploadedBytes;
|
|
||||||
const estimated = rate > 0 ? remaining / rate : undefined;
|
|
||||||
|
|
||||||
state.progress = uploadedBytes / totalBytes;
|
|
||||||
state.rate = rate;
|
|
||||||
state.estimated = estimated;
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done && buffer.length === 0) break;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
const newBuffer = new Uint8Array(buffer.length + value.length);
|
|
||||||
newBuffer.set(buffer);
|
|
||||||
newBuffer.set(value, buffer.length);
|
|
||||||
buffer = newBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (buffer.length >= CHUNK_SIZE || (done && buffer.length > 0)) {
|
|
||||||
const chunkSize = Math.min(CHUNK_SIZE, buffer.length);
|
|
||||||
const chunk = buffer.slice(0, chunkSize);
|
|
||||||
buffer = buffer.slice(chunkSize);
|
|
||||||
|
|
||||||
const encryptedChunk = await encryptChunk(chunk.buffer.slice(0, chunk.byteLength), dataKey);
|
|
||||||
const chunkHash = encodeToBase64(await digestMessage(encryptedChunk));
|
|
||||||
const currentIndex = chunkIndex++;
|
|
||||||
|
|
||||||
uploadPromises.push(
|
|
||||||
limit(() => uploadChunk(currentIndex, encryptedChunk, chunkHash, chunkSize)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (done) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
|
|
||||||
uploadId,
|
|
||||||
contentHmac: fileSigned,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileType.startsWith("video/")) {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailFromFile(file);
|
|
||||||
if (thumbnail) {
|
|
||||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
|
||||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
|
||||||
|
|
||||||
await uploadThumbnail(fileId, thumbnailEncrypted, dataKeyVersion);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Thumbnail upload failure is not critical
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.status = "uploaded";
|
|
||||||
|
|
||||||
return { fileId };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadFile = async (
|
export const uploadFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
parentId: "root" | number,
|
parentId: "root" | number,
|
||||||
hmacSecret: HmacSecret,
|
|
||||||
masterKey: MasterKey,
|
masterKey: MasterKey,
|
||||||
|
hmacSecret: HmacSecret,
|
||||||
onDuplicate: () => Promise<boolean>,
|
onDuplicate: () => Promise<boolean>,
|
||||||
) => {
|
) => {
|
||||||
uploadingFiles.push({
|
uploadingFiles.push({
|
||||||
@@ -413,65 +215,44 @@ export const uploadFile = async (
|
|||||||
const state = uploadingFiles.at(-1)!;
|
const state = uploadingFiles.at(-1)!;
|
||||||
|
|
||||||
return await scheduler.schedule(file.size, async () => {
|
return await scheduler.schedule(file.size, async () => {
|
||||||
state.status = "encryption-pending";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileSigned } = await requestDuplicateFileScan(file, hmacSecret, onDuplicate);
|
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
|
||||||
|
|
||||||
if (!fileSigned) {
|
if (!fileSigned) {
|
||||||
state.status = "canceled";
|
state.status = "canceled";
|
||||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fileBuffer;
|
||||||
const fileType = getFileType(file);
|
const fileType = getFileType(file);
|
||||||
|
const fileMetadata: FileMetadata = {
|
||||||
|
parentId,
|
||||||
|
name: file.name,
|
||||||
|
lastModifiedAt: new Date(file.lastModified),
|
||||||
|
};
|
||||||
|
|
||||||
if (fileType.startsWith("image/")) {
|
if (fileType.startsWith("image/")) {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
fileBuffer = await file.arrayBuffer();
|
||||||
const {
|
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
|
||||||
dataKeyWrapped,
|
|
||||||
dataKeyVersion,
|
|
||||||
chunksEncrypted,
|
|
||||||
nameEncrypted,
|
|
||||||
createdAtEncrypted,
|
|
||||||
lastModifiedAtEncrypted,
|
|
||||||
thumbnail,
|
|
||||||
} = await encryptImageFile(state, file, masterKey);
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
chunks: chunksEncrypted.length,
|
|
||||||
parent: parentId,
|
|
||||||
mekVersion: masterKey.version,
|
|
||||||
dek: dataKeyWrapped,
|
|
||||||
dekVersion: dataKeyVersion,
|
|
||||||
hskVersion: hmacSecret.version,
|
|
||||||
contentType: fileType,
|
|
||||||
name: nameEncrypted.ciphertext,
|
|
||||||
nameIv: nameEncrypted.iv,
|
|
||||||
createdAt: createdAtEncrypted?.ciphertext,
|
|
||||||
createdAtIv: createdAtEncrypted?.iv,
|
|
||||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
|
||||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { fileId, thumbnailBuffer } = await requestImageFileUpload(
|
|
||||||
state,
|
|
||||||
metadata,
|
|
||||||
chunksEncrypted,
|
|
||||||
fileSigned,
|
|
||||||
thumbnail ?? null,
|
|
||||||
dataKeyVersion,
|
|
||||||
);
|
|
||||||
return { fileId, fileBuffer, thumbnailBuffer };
|
|
||||||
} else {
|
|
||||||
const { fileId } = await requestFileUpload(
|
|
||||||
state,
|
|
||||||
file,
|
|
||||||
masterKey,
|
|
||||||
hmacSecret,
|
|
||||||
fileSigned,
|
|
||||||
parentId,
|
|
||||||
);
|
|
||||||
return { fileId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { fileId, fileBuffer, thumbnailBuffer };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.status = "error";
|
state.status = "error";
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const parseRangeHeader = (rangeHeader: string | null) => {
|
export const parseRangeHeader = (value: string | null) => {
|
||||||
if (!rangeHeader) return undefined;
|
if (!value) return undefined;
|
||||||
|
|
||||||
const firstRange = rangeHeader.split(",")[0]!.trim();
|
const firstRange = value.split(",")[0]!.trim();
|
||||||
const parts = firstRange.replace(/bytes=/, "").split("-");
|
const parts = firstRange.replace(/bytes=/, "").split("-");
|
||||||
return {
|
return {
|
||||||
start: parts[0] ? parseInt(parts[0], 10) : undefined,
|
start: parts[0] ? parseInt(parts[0], 10) : undefined,
|
||||||
@@ -12,3 +12,11 @@ export const parseRangeHeader = (rangeHeader: string | null) => {
|
|||||||
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
|
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
|
||||||
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
|
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseContentDigestHeader = (value: string | null) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const firstDigest = value.split(",")[0]!.trim();
|
||||||
|
const match = firstDigest.match(/^sha-256=:([A-Za-z0-9+/=]+):$/);
|
||||||
|
return match?.[1];
|
||||||
|
};
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ const generateImageThumbnail = (imageUrl: string) => {
|
|||||||
.catch(reject);
|
.catch(reject);
|
||||||
};
|
};
|
||||||
image.onerror = reject;
|
image.onerror = reject;
|
||||||
|
|
||||||
image.src = imageUrl;
|
image.src = imageUrl;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -85,31 +84,27 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
|
export const generateThumbnail = async (blob: Blob) => {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
if (fileType.startsWith("image/")) {
|
if (blob.type.startsWith("image/")) {
|
||||||
const fileBlob = new Blob([fileBuffer], { type: fileType });
|
url = URL.createObjectURL(blob);
|
||||||
url = URL.createObjectURL(fileBlob);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await generateImageThumbnail(url);
|
return await generateImageThumbnail(url);
|
||||||
} catch {
|
} catch {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
url = undefined;
|
url = undefined;
|
||||||
|
|
||||||
if (fileType === "image/heic") {
|
if (blob.type === "image/heic") {
|
||||||
const { default: heic2any } = await import("heic2any");
|
const { default: heic2any } = await import("heic2any");
|
||||||
url = URL.createObjectURL(
|
url = URL.createObjectURL((await heic2any({ blob, toType: "image/png" })) as Blob);
|
||||||
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
|
|
||||||
);
|
|
||||||
return await generateImageThumbnail(url);
|
return await generateImageThumbnail(url);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (fileType.startsWith("video/")) {
|
} else if (blob.type.startsWith("video/")) {
|
||||||
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
|
url = URL.createObjectURL(blob);
|
||||||
return await generateVideoThumbnail(url);
|
return await generateVideoThumbnail(url);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -122,22 +117,6 @@ export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: strin
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateThumbnailFromFile = async (file: File) => {
|
|
||||||
if (!file.type.startsWith("video/")) return null;
|
|
||||||
|
|
||||||
let url;
|
|
||||||
try {
|
|
||||||
url = URL.createObjectURL(file);
|
|
||||||
return await generateVideoThumbnail(url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
if (url) {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
||||||
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
183
src/lib/modules/upload.ts
Normal file
183
src/lib/modules/upload.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import pLimit from "p-limit";
|
||||||
|
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
||||||
|
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
|
||||||
|
import { BoundedQueue } from "$lib/utils";
|
||||||
|
|
||||||
|
interface UploadStats {
|
||||||
|
progress: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncryptedChunk {
|
||||||
|
index: number;
|
||||||
|
data: ArrayBuffer;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
|
||||||
|
const samples: { t: number; b: number }[] = [];
|
||||||
|
let lastSpeed = 0;
|
||||||
|
let startTime: number | null = null;
|
||||||
|
|
||||||
|
return (bytesNow?: number) => {
|
||||||
|
if (bytesNow === undefined) return lastSpeed;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Initialize start time on first call
|
||||||
|
if (startTime === null) {
|
||||||
|
startTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed since the last sample
|
||||||
|
const lastSample = samples[samples.length - 1];
|
||||||
|
if (lastSample && now - lastSample.t < minInterval) {
|
||||||
|
return lastSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
samples.push({ t: now, b: bytesNow });
|
||||||
|
|
||||||
|
// Remove old samples outside the time window
|
||||||
|
const cutoff = now - timeWindow;
|
||||||
|
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
|
||||||
|
|
||||||
|
// Need at least 2 samples to calculate speed
|
||||||
|
if (samples.length < 2) {
|
||||||
|
return lastSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = samples[0]!;
|
||||||
|
const dt = now - first.t;
|
||||||
|
const db = bytesNow - first.b;
|
||||||
|
|
||||||
|
if (dt >= minInterval) {
|
||||||
|
const instantSpeed = (db / dt) * 1000;
|
||||||
|
// Apply EMA for smoother speed transitions
|
||||||
|
const alpha = 0.3;
|
||||||
|
const rawSpeed =
|
||||||
|
lastSpeed === 0 ? instantSpeed : alpha * instantSpeed + (1 - alpha) * lastSpeed;
|
||||||
|
|
||||||
|
// Apply warmup ramp to prevent initial overestimation
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const warmupWeight = Math.min(1, elapsed / warmupPeriod);
|
||||||
|
lastSpeed = rawSpeed * warmupWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSpeed;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptChunkData = async (
|
||||||
|
chunk: Blob,
|
||||||
|
dataKey: CryptoKey,
|
||||||
|
): Promise<{ data: ArrayBuffer; hash: string }> => {
|
||||||
|
const encrypted = await encryptChunk(await chunk.arrayBuffer(), dataKey);
|
||||||
|
const hash = encodeToBase64(await digestMessage(encrypted));
|
||||||
|
return { data: encrypted, hash };
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadEncryptedChunk = async (
|
||||||
|
uploadId: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
encrypted: ArrayBuffer,
|
||||||
|
hash: string,
|
||||||
|
onChunkProgress: (chunkIndex: number, loaded: number) => void,
|
||||||
|
) => {
|
||||||
|
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Digest": `sha-256=:${hash}:`,
|
||||||
|
},
|
||||||
|
onUploadProgress(e) {
|
||||||
|
onChunkProgress(chunkIndex, e.loaded ?? 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onChunkProgress(chunkIndex, encrypted.byteLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadBlob = async (
|
||||||
|
uploadId: string,
|
||||||
|
blob: Blob,
|
||||||
|
dataKey: CryptoKey,
|
||||||
|
options?: { concurrency?: number; onProgress?: (s: UploadStats) => void },
|
||||||
|
) => {
|
||||||
|
const onProgress = options?.onProgress;
|
||||||
|
const networkConcurrency = options?.concurrency ?? 4;
|
||||||
|
const maxQueueSize = 8;
|
||||||
|
|
||||||
|
const totalChunks = Math.ceil(blob.size / CHUNK_SIZE);
|
||||||
|
const totalBytes = blob.size + totalChunks * ENCRYPTION_OVERHEAD;
|
||||||
|
|
||||||
|
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
|
||||||
|
const speedMeter = createSpeedMeter(3000, 200);
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
if (!onProgress) return;
|
||||||
|
|
||||||
|
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
|
||||||
|
const rate = speedMeter(uploadedBytes);
|
||||||
|
const progress = Math.min(1, uploadedBytes / totalBytes);
|
||||||
|
|
||||||
|
onProgress({ progress, rate });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChunkProgress = (idx: number, loaded: number) => {
|
||||||
|
uploadedByChunk[idx] = loaded;
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
|
||||||
|
let encryptionError: Error | null = null;
|
||||||
|
|
||||||
|
// Producer: encrypt chunks and push to queue
|
||||||
|
const encryptionProducer = async () => {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
const chunk = blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
||||||
|
const { data, hash } = await encryptChunkData(chunk, dataKey);
|
||||||
|
await queue.push({ index: i, data, hash });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
encryptionError = e instanceof Error ? e : new Error(String(e));
|
||||||
|
} finally {
|
||||||
|
queue.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Consumer: upload chunks from queue with concurrency limit
|
||||||
|
const uploadConsumer = async () => {
|
||||||
|
const limit = pLimit(networkConcurrency);
|
||||||
|
const activeTasks = new Set<Promise<void>>();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const item = await queue.pop();
|
||||||
|
if (item === null) break;
|
||||||
|
if (encryptionError) throw encryptionError;
|
||||||
|
|
||||||
|
const task = limit(async () => {
|
||||||
|
try {
|
||||||
|
await uploadEncryptedChunk(uploadId, item.index, item.data, item.hash, onChunkProgress);
|
||||||
|
} finally {
|
||||||
|
// @ts-ignore
|
||||||
|
item.data = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
activeTasks.add(task);
|
||||||
|
task.finally(() => activeTasks.delete(task));
|
||||||
|
|
||||||
|
if (activeTasks.size >= networkConcurrency) {
|
||||||
|
await Promise.race(activeTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(activeTasks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run producer and consumer concurrently
|
||||||
|
await Promise.all([encryptionProducer(), uploadConsumer()]);
|
||||||
|
|
||||||
|
onProgress?.({ progress: 1, rate: speedMeter() });
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ type IntegrityErrorMessages =
|
|||||||
// File
|
// File
|
||||||
| "Directory not found"
|
| "Directory not found"
|
||||||
| "File not found"
|
| "File not found"
|
||||||
|
| "File is not legacy"
|
||||||
| "File not found in category"
|
| "File not found in category"
|
||||||
| "File already added to category"
|
| "File already added to category"
|
||||||
| "Invalid DEK version"
|
| "Invalid DEK version"
|
||||||
|
|||||||
@@ -334,6 +334,16 @@ export const getAllFileIds = async (userId: number) => {
|
|||||||
return files.map(({ id }) => id);
|
return files.map(({ id }) => id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLegacyFileIds = async (userId: number) => {
|
||||||
|
const files = await db
|
||||||
|
.selectFrom("file")
|
||||||
|
.select("id")
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("encrypted_content_iv", "is not", null)
|
||||||
|
.execute();
|
||||||
|
return files.map(({ id }) => id);
|
||||||
|
};
|
||||||
|
|
||||||
export const getAllFileIdsByContentHmac = async (
|
export const getAllFileIdsByContentHmac = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
hskVersion: number,
|
hskVersion: number,
|
||||||
@@ -482,6 +492,51 @@ export const unregisterFile = async (userId: number, fileId: number) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const migrateFileContent = async (
|
||||||
|
trx: typeof db,
|
||||||
|
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"])
|
||||||
|
.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) {
|
||||||
|
throw new IntegrityError("File is not legacy");
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.updateTable("file")
|
||||||
|
.set({
|
||||||
|
path: newPath,
|
||||||
|
encrypted_content_iv: null,
|
||||||
|
encrypted_content_hash: encContentHash,
|
||||||
|
})
|
||||||
|
.where("id", "=", fileId)
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.insertInto("file_log")
|
||||||
|
.values({
|
||||||
|
file_id: fileId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: "migrate",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return { oldPath: file.path };
|
||||||
|
};
|
||||||
|
|
||||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||||
await db.transaction().execute(async (trx) => {
|
await db.transaction().execute(async (trx) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ export const up = async (db: Kysely<any>) => {
|
|||||||
.addColumn("type", "text", (col) => col.notNull())
|
.addColumn("type", "text", (col) => col.notNull())
|
||||||
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
|
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
|
||||||
.addColumn("path", "text", (col) => col.notNull())
|
.addColumn("path", "text", (col) => col.notNull())
|
||||||
|
.addColumn("bitmap", "bytea", (col) => col.notNull())
|
||||||
.addColumn("total_chunks", "integer", (col) => col.notNull())
|
.addColumn("total_chunks", "integer", (col) => col.notNull())
|
||||||
.addColumn("uploaded_chunks", sql`integer[]`, (col) => col.notNull().defaultTo(sql`'{}'`))
|
.addColumn("uploaded_chunks", "integer", (col) =>
|
||||||
|
col
|
||||||
|
.generatedAlwaysAs(sql`bit_count(bitmap)`)
|
||||||
|
.stored()
|
||||||
|
.notNull(),
|
||||||
|
)
|
||||||
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
|
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
|
||||||
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
|
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
|
||||||
.addColumn("master_encryption_key_version", "integer")
|
.addColumn("master_encryption_key_version", "integer")
|
||||||
@@ -46,6 +52,11 @@ export const up = async (db: Kysely<any>) => {
|
|||||||
"hmac_secret_key",
|
"hmac_secret_key",
|
||||||
["user_id", "version"],
|
["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();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface FileLogTable {
|
|||||||
id: Generated<number>;
|
id: Generated<number>;
|
||||||
file_id: number;
|
file_id: number;
|
||||||
timestamp: ColumnType<Date, Date, never>;
|
timestamp: ColumnType<Date, Date, never>;
|
||||||
action: "create" | "rename" | "add-to-category" | "remove-from-category";
|
action: "create" | "rename" | "migrate" | "add-to-category" | "remove-from-category";
|
||||||
new_name: Ciphertext | null;
|
new_name: Ciphertext | null;
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { Ciphertext } from "./utils";
|
|||||||
|
|
||||||
interface UploadSessionTable {
|
interface UploadSessionTable {
|
||||||
id: string;
|
id: string;
|
||||||
type: "file" | "thumbnail";
|
type: "file" | "thumbnail" | "migration";
|
||||||
user_id: number;
|
user_id: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
bitmap: Buffer;
|
||||||
total_chunks: number;
|
total_chunks: number;
|
||||||
uploaded_chunks: Generated<number[]>;
|
uploaded_chunks: Generated<number>;
|
||||||
expires_at: Date;
|
expires_at: Date;
|
||||||
|
|
||||||
// For file uploads
|
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
master_encryption_key_version: number | null;
|
master_encryption_key_version: number | null;
|
||||||
encrypted_data_encryption_key: string | null; // Base64
|
encrypted_data_encryption_key: string | null; // Base64
|
||||||
@@ -20,8 +20,6 @@ interface UploadSessionTable {
|
|||||||
encrypted_name: Ciphertext | null;
|
encrypted_name: Ciphertext | null;
|
||||||
encrypted_created_at: Ciphertext | null;
|
encrypted_created_at: Ciphertext | null;
|
||||||
encrypted_last_modified_at: Ciphertext | null;
|
encrypted_last_modified_at: Ciphertext | null;
|
||||||
|
|
||||||
// For thumbnail uploads
|
|
||||||
file_id: number | null;
|
file_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ interface BaseUploadSession {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
bitmap: Buffer;
|
||||||
totalChunks: number;
|
totalChunks: number;
|
||||||
uploadedChunks: number[];
|
uploadedChunks: number;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +26,14 @@ interface FileUploadSession extends BaseUploadSession {
|
|||||||
encLastModifiedAt: Ciphertext;
|
encLastModifiedAt: Ciphertext;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThumbnailUploadSession extends BaseUploadSession {
|
interface ThumbnailOrMigrationUploadSession extends BaseUploadSession {
|
||||||
type: "thumbnail";
|
type: "thumbnail" | "migration";
|
||||||
fileId: number;
|
fileId: number;
|
||||||
dekVersion: Date;
|
dekVersion: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createFileUploadSession = async (
|
export const createFileUploadSession = async (
|
||||||
params: Omit<FileUploadSession, "type" | "uploadedChunks">,
|
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction().execute(async (trx) => {
|
await db.transaction().execute(async (trx) => {
|
||||||
const mek = await trx
|
const mek = await trx
|
||||||
@@ -68,6 +69,7 @@ export const createFileUploadSession = async (
|
|||||||
type: "file",
|
type: "file",
|
||||||
user_id: params.userId,
|
user_id: params.userId,
|
||||||
path: params.path,
|
path: params.path,
|
||||||
|
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
||||||
total_chunks: params.totalChunks,
|
total_chunks: params.totalChunks,
|
||||||
expires_at: params.expiresAt,
|
expires_at: params.expiresAt,
|
||||||
parent_id: params.parentId !== "root" ? params.parentId : null,
|
parent_id: params.parentId !== "root" ? params.parentId : null,
|
||||||
@@ -84,8 +86,8 @@ export const createFileUploadSession = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThumbnailUploadSession = async (
|
export const createThumbnailOrMigrationUploadSession = async (
|
||||||
params: Omit<ThumbnailUploadSession, "type" | "uploadedChunks">,
|
params: Omit<ThumbnailOrMigrationUploadSession, "bitmap" | "uploadedChunks">,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction().execute(async (trx) => {
|
await db.transaction().execute(async (trx) => {
|
||||||
const file = await trx
|
const file = await trx
|
||||||
@@ -106,9 +108,10 @@ export const createThumbnailUploadSession = async (
|
|||||||
.insertInto("upload_session")
|
.insertInto("upload_session")
|
||||||
.values({
|
.values({
|
||||||
id: params.id,
|
id: params.id,
|
||||||
type: "thumbnail",
|
type: params.type,
|
||||||
user_id: params.userId,
|
user_id: params.userId,
|
||||||
path: params.path,
|
path: params.path,
|
||||||
|
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
||||||
total_chunks: params.totalChunks,
|
total_chunks: params.totalChunks,
|
||||||
expires_at: params.expiresAt,
|
expires_at: params.expiresAt,
|
||||||
file_id: params.fileId,
|
file_id: params.fileId,
|
||||||
@@ -135,6 +138,7 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
userId: session.user_id,
|
userId: session.user_id,
|
||||||
path: session.path,
|
path: session.path,
|
||||||
|
bitmap: session.bitmap,
|
||||||
totalChunks: session.total_chunks,
|
totalChunks: session.total_chunks,
|
||||||
uploadedChunks: session.uploaded_chunks,
|
uploadedChunks: session.uploaded_chunks,
|
||||||
expiresAt: session.expires_at,
|
expiresAt: session.expires_at,
|
||||||
@@ -150,23 +154,26 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
|||||||
} satisfies FileUploadSession;
|
} satisfies FileUploadSession;
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
type: "thumbnail",
|
type: session.type,
|
||||||
id: session.id,
|
id: session.id,
|
||||||
userId: session.user_id,
|
userId: session.user_id,
|
||||||
path: session.path,
|
path: session.path,
|
||||||
|
bitmap: session.bitmap,
|
||||||
totalChunks: session.total_chunks,
|
totalChunks: session.total_chunks,
|
||||||
uploadedChunks: session.uploaded_chunks,
|
uploadedChunks: session.uploaded_chunks,
|
||||||
expiresAt: session.expires_at,
|
expiresAt: session.expires_at,
|
||||||
fileId: session.file_id!,
|
fileId: session.file_id!,
|
||||||
dekVersion: session.data_encryption_key_version!,
|
dekVersion: session.data_encryption_key_version!,
|
||||||
} satisfies ThumbnailUploadSession;
|
} satisfies ThumbnailOrMigrationUploadSession;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
|
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
|
||||||
await db
|
await db
|
||||||
.updateTable("upload_session")
|
.updateTable("upload_session")
|
||||||
.set({ uploaded_chunks: sql`array_append(uploaded_chunks, ${chunkIndex})` })
|
.set({
|
||||||
|
bitmap: sql`set_bit(${sql.ref("bitmap")}, ${chunkIndex - 1}, 1)`,
|
||||||
|
})
|
||||||
.where("id", "=", sessionId)
|
.where("id", "=", sessionId)
|
||||||
.execute();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
|||||||
|
|
||||||
const chunkLocks = new Set<string>();
|
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 (
|
export const uploadChunk = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -28,13 +34,13 @@ export const uploadChunk = async (
|
|||||||
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
error(404, "Invalid upload id");
|
error(404, "Invalid upload id");
|
||||||
} else if (chunkIndex >= session.totalChunks) {
|
} else if (chunkIndex > session.totalChunks) {
|
||||||
error(400, "Invalid chunk index");
|
error(400, "Invalid chunk index");
|
||||||
} else if (session.uploadedChunks.includes(chunkIndex)) {
|
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
|
||||||
error(409, "Chunk already uploaded");
|
error(409, "Chunk already uploaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLastChunk = chunkIndex === session.totalChunks - 1;
|
const isLastChunk = chunkIndex === session.totalChunks;
|
||||||
filePath = `${session.path}/${chunkIndex}`;
|
filePath = `${session.path}/${chunkIndex}`;
|
||||||
|
|
||||||
const hashStream = createHash("sha256");
|
const hashStream = createHash("sha256");
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
||||||
import { encodeToBase64, digestMessage } from "$lib/modules/crypto";
|
|
||||||
import {
|
import {
|
||||||
getFileCache,
|
getFileCache,
|
||||||
storeFileCache,
|
storeFileCache,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
deleteFileThumbnailCache,
|
deleteFileThumbnailCache,
|
||||||
} from "$lib/modules/file";
|
} from "$lib/modules/file";
|
||||||
|
import { uploadBlob } from "$lib/modules/upload";
|
||||||
import { trpc } from "$trpc/client";
|
import { trpc } from "$trpc/client";
|
||||||
|
|
||||||
export const requestFileDownload = async (
|
export const requestFileDownload = async (
|
||||||
@@ -24,41 +24,24 @@ export const requestFileDownload = async (
|
|||||||
|
|
||||||
export const requestFileThumbnailUpload = async (
|
export const requestFileThumbnailUpload = async (
|
||||||
fileId: number,
|
fileId: number,
|
||||||
|
thumbnail: Blob,
|
||||||
|
dataKey: CryptoKey,
|
||||||
dataKeyVersion: Date,
|
dataKeyVersion: Date,
|
||||||
thumbnailEncrypted: { ciphertext: ArrayBuffer; iv: ArrayBuffer },
|
|
||||||
) => {
|
) => {
|
||||||
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
try {
|
||||||
file: fileId,
|
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
|
||||||
dekVersion: dataKeyVersion,
|
file: fileId,
|
||||||
});
|
dekVersion: dataKeyVersion,
|
||||||
|
});
|
||||||
|
|
||||||
// Prepend IV to ciphertext (consistent with file download format)
|
await uploadBlob(uploadId, thumbnail, dataKey);
|
||||||
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));
|
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||||
|
return true;
|
||||||
const response = await fetch(`/api/upload/${uploadId}/chunks/0`, {
|
} catch {
|
||||||
method: "POST",
|
// TODO: Error Handling
|
||||||
headers: {
|
return false;
|
||||||
"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 response;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDeletedFilesCleanup = async () => {
|
export const requestDeletedFilesCleanup = async () => {
|
||||||
|
|||||||
44
src/lib/utils/concurrency/BoundedQueue.ts
Normal file
44
src/lib/utils/concurrency/BoundedQueue.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export class BoundedQueue<T> {
|
||||||
|
private isClosed = false;
|
||||||
|
private reservedCount = 0;
|
||||||
|
private items: T[] = [];
|
||||||
|
|
||||||
|
private waitersNotFull: (() => void)[] = [];
|
||||||
|
private waitersNotEmpty: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly maxSize: number) {}
|
||||||
|
|
||||||
|
async push(item: T) {
|
||||||
|
if (this.isClosed) {
|
||||||
|
throw new Error("Queue closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.reservedCount >= this.maxSize) {
|
||||||
|
await new Promise<void>((resolve) => this.waitersNotFull.push(resolve));
|
||||||
|
if (this.isClosed) throw new Error("Queue closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reservedCount++;
|
||||||
|
this.items.push(item);
|
||||||
|
this.waitersNotEmpty.shift()?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pop() {
|
||||||
|
while (this.items.length === 0) {
|
||||||
|
if (this.isClosed) return null;
|
||||||
|
await new Promise<void>((resolve) => this.waitersNotEmpty.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = this.items.shift()!;
|
||||||
|
this.reservedCount--;
|
||||||
|
this.waitersNotFull.shift()?.();
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isClosed = true;
|
||||||
|
while (this.waitersNotEmpty.length > 0) this.waitersNotEmpty.shift()!();
|
||||||
|
while (this.waitersNotFull.length > 0) this.waitersNotFull.shift()!();
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/lib/utils/concurrency/index.ts
Normal file
3
src/lib/utils/concurrency/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./BoundedQueue";
|
||||||
|
export * from "./HybridPromise";
|
||||||
|
export * from "./Scheduler";
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
|
export * from "./concurrency";
|
||||||
export * from "./format";
|
export * from "./format";
|
||||||
export * from "./gotoStateful";
|
export * from "./gotoStateful";
|
||||||
export * from "./HybridPromise";
|
|
||||||
export * from "./sort";
|
export * from "./sort";
|
||||||
|
|||||||
5
src/params/thumbnail.ts
Normal file
5
src/params/thumbnail.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ParamMatcher } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const match: ParamMatcher = (param) => {
|
||||||
|
return param === "thumbnail";
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { encryptData } from "$lib/modules/crypto";
|
|
||||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||||
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
|
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
|
||||||
import { requestFileThumbnailUpload } from "$lib/services/file";
|
import { requestFileThumbnailUpload } from "$lib/services/file";
|
||||||
@@ -33,12 +32,10 @@ export const requestThumbnailUpload = async (
|
|||||||
dataKey: CryptoKey,
|
dataKey: CryptoKey,
|
||||||
dataKeyVersion: Date,
|
dataKeyVersion: Date,
|
||||||
) => {
|
) => {
|
||||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
const res = await requestFileThumbnailUpload(fileId, thumbnail, dataKey, dataKeyVersion);
|
||||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
if (!res) return false;
|
||||||
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnailEncrypted);
|
|
||||||
if (!res.ok) return false;
|
|
||||||
|
|
||||||
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
|
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileId, buffer));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createCaller } from "$trpc/router.server";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const files = await createCaller(event).file.listLegacy();
|
||||||
|
return { files };
|
||||||
|
};
|
||||||
79
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
79
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||||
|
import { TopBar } from "$lib/components/molecules";
|
||||||
|
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
import { sortEntries } from "$lib/utils";
|
||||||
|
import File from "./File.svelte";
|
||||||
|
import { getMigrationState, clearMigrationStates, requestFileMigration } from "./service.svelte";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let fileInfos: MaybeFileInfo[] = $state([]);
|
||||||
|
let files = $derived(
|
||||||
|
fileInfos
|
||||||
|
.map((info) => ({
|
||||||
|
info,
|
||||||
|
state: getMigrationState(info.id),
|
||||||
|
}))
|
||||||
|
.filter((file) => file.state?.status !== "uploaded"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const migrateAllFiles = () => {
|
||||||
|
files.forEach(({ info }) => {
|
||||||
|
if (info.exists) {
|
||||||
|
requestFileMigration(info);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
fileInfos = sortEntries(
|
||||||
|
Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => clearMigrationStates);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>암호화 마이그레이션</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<TopBar title="암호화 마이그레이션" />
|
||||||
|
<FullscreenDiv>
|
||||||
|
{#if files.length > 0}
|
||||||
|
<div class="space-y-4 pb-4">
|
||||||
|
<p class="break-keep text-gray-800">
|
||||||
|
이전 버전의 ArkVault에서 업로드된 {files.length}개 파일을 다시 암호화할 수 있어요.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each files as { info, state } (info.id)}
|
||||||
|
{#if info.exists}
|
||||||
|
<File
|
||||||
|
{info}
|
||||||
|
{state}
|
||||||
|
onclick={({ id }) => goto(`/file/${id}`)}
|
||||||
|
onMigrateClick={requestFileMigration}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BottomDiv>
|
||||||
|
<Button onclick={migrateAllFiles} class="w-full">모두 다시 암호화하기</Button>
|
||||||
|
</BottomDiv>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-grow items-center justify-center">
|
||||||
|
<p class="text-gray-500">
|
||||||
|
{#if data.files.length === 0}
|
||||||
|
마이그레이션할 파일이 없어요.
|
||||||
|
{:else}
|
||||||
|
파일 목록을 불러오고 있어요.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</FullscreenDiv>
|
||||||
52
src/routes/(fullscreen)/settings/migration/File.svelte
Normal file
52
src/routes/(fullscreen)/settings/migration/File.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
const subtexts = {
|
||||||
|
queued: "대기 중",
|
||||||
|
downloading: "다운로드하는 중",
|
||||||
|
"upload-pending": "업로드를 기다리는 중",
|
||||||
|
uploaded: "",
|
||||||
|
error: "실패",
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { formatDateTime, formatNetworkSpeed } from "$lib/utils";
|
||||||
|
import type { MigrationState } from "./service.svelte";
|
||||||
|
|
||||||
|
import IconSync from "~icons/material-symbols/sync";
|
||||||
|
|
||||||
|
type FileInfoWithExists = FileInfo & { exists: true };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
info: FileInfoWithExists;
|
||||||
|
onclick: (file: FileInfo) => void;
|
||||||
|
onMigrateClick: (file: FileInfoWithExists) => void;
|
||||||
|
state: MigrationState | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { info, onclick, onMigrateClick, state }: Props = $props();
|
||||||
|
|
||||||
|
let subtext = $derived.by(() => {
|
||||||
|
if (!state) {
|
||||||
|
return formatDateTime(info.createdAt ?? info.lastModifiedAt);
|
||||||
|
}
|
||||||
|
if (state.status === "uploading") {
|
||||||
|
const progress = Math.floor((state.progress ?? 0) * 100);
|
||||||
|
const speed = formatNetworkSpeed((state.rate ?? 0) * 8);
|
||||||
|
return `전송됨 ${progress}% · ${speed}`;
|
||||||
|
}
|
||||||
|
return subtexts[state.status] ?? state.status;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionEntryButton
|
||||||
|
class="h-14"
|
||||||
|
onclick={() => onclick(info)}
|
||||||
|
actionButtonIcon={!state || state.status === "error" ? IconSync : undefined}
|
||||||
|
onActionButtonClick={() => onMigrateClick(info)}
|
||||||
|
actionButtonClass="text-gray-800"
|
||||||
|
>
|
||||||
|
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
|
||||||
|
</ActionEntryButton>
|
||||||
105
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
105
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { limitFunction } from "p-limit";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import { CHUNK_SIZE } from "$lib/constants";
|
||||||
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { uploadBlob } from "$lib/modules/upload";
|
||||||
|
import { requestFileDownload } from "$lib/services/file";
|
||||||
|
import { Scheduler } from "$lib/utils";
|
||||||
|
import { trpc } from "$trpc/client";
|
||||||
|
|
||||||
|
export type MigrationStatus =
|
||||||
|
| "queued"
|
||||||
|
| "downloading"
|
||||||
|
| "upload-pending"
|
||||||
|
| "uploading"
|
||||||
|
| "uploaded"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface MigrationState {
|
||||||
|
status: MigrationStatus;
|
||||||
|
progress?: number;
|
||||||
|
rate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduler = new Scheduler();
|
||||||
|
const states = new SvelteMap<number, MigrationState>();
|
||||||
|
|
||||||
|
const createState = (status: MigrationStatus): MigrationState => {
|
||||||
|
const state = $state({ status });
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMigrationState = (fileId: number) => {
|
||||||
|
return states.get(fileId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearMigrationStates = () => {
|
||||||
|
for (const [id, state] of states) {
|
||||||
|
if (state.status === "uploaded" || state.status === "error") {
|
||||||
|
states.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestFileUpload = limitFunction(
|
||||||
|
async (
|
||||||
|
state: MigrationState,
|
||||||
|
fileId: number,
|
||||||
|
fileBuffer: ArrayBuffer,
|
||||||
|
dataKey: CryptoKey,
|
||||||
|
dataKeyVersion: Date,
|
||||||
|
) => {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await trpc().upload.completeMigrationUpload.mutate({ uploadId });
|
||||||
|
state.status = "uploaded";
|
||||||
|
},
|
||||||
|
{ concurrency: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const requestFileMigration = async (fileInfo: FileInfo) => {
|
||||||
|
let state = states.get(fileInfo.id);
|
||||||
|
if (state) {
|
||||||
|
if (state.status !== "error") return;
|
||||||
|
state.status = "queued";
|
||||||
|
state.progress = undefined;
|
||||||
|
state.rate = undefined;
|
||||||
|
} else {
|
||||||
|
state = createState("queued");
|
||||||
|
states.set(fileInfo.id, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataKey = fileInfo.dataKey;
|
||||||
|
if (!dataKey) {
|
||||||
|
throw new Error("Data key not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileBuffer: ArrayBuffer | undefined;
|
||||||
|
|
||||||
|
await scheduler.schedule(
|
||||||
|
async () => {
|
||||||
|
state.status = "downloading";
|
||||||
|
fileBuffer = await requestFileDownload(fileInfo.id, dataKey.key, true);
|
||||||
|
return fileBuffer.byteLength;
|
||||||
|
},
|
||||||
|
() => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey.key, dataKey.version),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state.status = "error";
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
queued: "대기 중",
|
queued: "대기 중",
|
||||||
"generation-pending": "준비 중",
|
"generation-pending": "준비 중",
|
||||||
generating: "생성하는 중",
|
generating: "생성하는 중",
|
||||||
"upload-pending": "업로드를 기다리는 중",
|
|
||||||
uploading: "업로드하는 중",
|
uploading: "업로드하는 중",
|
||||||
error: "실패",
|
error: "실패",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { limitFunction } from "p-limit";
|
import { limitFunction } from "p-limit";
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
import { encryptData } from "$lib/modules/crypto";
|
|
||||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||||
import type { FileInfo } from "$lib/modules/filesystem";
|
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";
|
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
|
||||||
|
import { Scheduler } from "$lib/utils";
|
||||||
|
|
||||||
export type GenerationStatus =
|
export type GenerationStatus =
|
||||||
| "queued"
|
| "queued"
|
||||||
| "generation-pending"
|
| "generation-pending"
|
||||||
| "generating"
|
| "generating"
|
||||||
| "upload-pending"
|
|
||||||
| "uploading"
|
| "uploading"
|
||||||
| "uploaded"
|
| "uploaded"
|
||||||
| "error";
|
| "error";
|
||||||
@@ -31,33 +29,27 @@ 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(
|
const requestThumbnailUpload = limitFunction(
|
||||||
async (
|
async (fileInfo: FileInfo, fileBuffer: ArrayBuffer) => {
|
||||||
fileId: number,
|
statuses.set(fileInfo.id, "generating");
|
||||||
dataKeyVersion: Date,
|
|
||||||
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: ArrayBuffer },
|
|
||||||
) => {
|
|
||||||
statuses.set(fileId, "uploading");
|
|
||||||
|
|
||||||
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
|
const thumbnail = await generateThumbnail(
|
||||||
if (!res.ok) return false;
|
new Blob([fileBuffer], { type: fileInfo.contentType }),
|
||||||
statuses.set(fileId, "uploaded");
|
);
|
||||||
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
|
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));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ concurrency: 4 },
|
{ concurrency: 4 },
|
||||||
@@ -81,16 +73,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
|||||||
return file.byteLength;
|
return file.byteLength;
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const thumbnail = await generateThumbnail(
|
if (!(await requestThumbnailUpload(fileInfo, file!))) {
|
||||||
fileInfo.id,
|
|
||||||
file!,
|
|
||||||
fileInfo.contentType,
|
|
||||||
fileInfo.dataKey?.key!,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!thumbnail ||
|
|
||||||
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
|
|
||||||
) {
|
|
||||||
statuses.set(fileInfo.id, "error");
|
statuses.set(fileInfo.id, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
|
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!, () => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
duplicatedFile = file;
|
duplicatedFile = file;
|
||||||
resolveForDuplicateFileModal = resolve;
|
resolveForDuplicateFileModal = resolve;
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ export const requestDirectoryCreation = async (
|
|||||||
export const requestFileUpload = async (
|
export const requestFileUpload = async (
|
||||||
file: File,
|
file: File,
|
||||||
parentId: "root" | number,
|
parentId: "root" | number,
|
||||||
hmacSecret: HmacSecret,
|
|
||||||
masterKey: MasterKey,
|
masterKey: MasterKey,
|
||||||
|
hmacSecret: HmacSecret,
|
||||||
onDuplicate: () => Promise<boolean>,
|
onDuplicate: () => Promise<boolean>,
|
||||||
) => {
|
) => {
|
||||||
const res = await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
|
const res = await uploadFile(file, parentId, masterKey, hmacSecret, onDuplicate);
|
||||||
if (!res) return false;
|
if (!res) return false;
|
||||||
|
|
||||||
if (res.fileBuffer) {
|
if (res.fileBuffer) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import IconStorage from "~icons/material-symbols/storage";
|
import IconStorage from "~icons/material-symbols/storage";
|
||||||
import IconImage from "~icons/material-symbols/image";
|
import IconImage from "~icons/material-symbols/image";
|
||||||
|
import IconLockReset from "~icons/material-symbols/lock-reset";
|
||||||
import IconPassword from "~icons/material-symbols/password";
|
import IconPassword from "~icons/material-symbols/password";
|
||||||
import IconLogout from "~icons/material-symbols/logout";
|
import IconLogout from "~icons/material-symbols/logout";
|
||||||
|
|
||||||
@@ -41,6 +42,13 @@
|
|||||||
>
|
>
|
||||||
썸네일
|
썸네일
|
||||||
</MenuEntryButton>
|
</MenuEntryButton>
|
||||||
|
<MenuEntryButton
|
||||||
|
onclick={() => goto("/settings/migration")}
|
||||||
|
icon={IconLockReset}
|
||||||
|
iconColor="text-teal-500"
|
||||||
|
>
|
||||||
|
암호화 마이그레이션
|
||||||
|
</MenuEntryButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="font-semibold">보안</p>
|
<p class="font-semibold">보안</p>
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { error } from "@sveltejs/kit";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { parseRangeHeader, getContentRangeHeader } from "$lib/modules/http";
|
import { parseRangeHeader, getContentRangeHeader } from "$lib/modules/http";
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
import { authorize } from "$lib/server/modules/auth";
|
||||||
import { getFileStream } from "$lib/server/services/file";
|
import { getFileStream, getFileThumbnailStream } from "$lib/server/services/file";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler, RouteParams } from "./$types";
|
||||||
|
|
||||||
const downloadHandler = async (
|
const downloadHandler = async (locals: App.Locals, params: RouteParams, request: Request) => {
|
||||||
locals: App.Locals,
|
|
||||||
params: Record<string, string>,
|
|
||||||
request: Request,
|
|
||||||
) => {
|
|
||||||
const { userId } = await authorize(locals, "activeClient");
|
const { userId } = await authorize(locals, "activeClient");
|
||||||
|
|
||||||
const zodRes = z
|
const zodRes = z
|
||||||
@@ -20,29 +16,29 @@ const downloadHandler = async (
|
|||||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||||
const { id } = zodRes.data;
|
const { id } = zodRes.data;
|
||||||
|
|
||||||
const { encContentStream, range } = await getFileStream(
|
const getStream = params.thumbnail ? getFileThumbnailStream : getFileStream;
|
||||||
|
const { encContentStream, range } = await getStream(
|
||||||
userId,
|
userId,
|
||||||
id,
|
id,
|
||||||
parseRangeHeader(request.headers.get("Range")),
|
parseRangeHeader(request.headers.get("Range")),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
stream: encContentStream,
|
stream: encContentStream,
|
||||||
|
status: range ? 206 : 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Length": (range.end - range.start + 1).toString(),
|
"Content-Length": String(range.end - range.start + 1),
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
...getContentRangeHeader(range),
|
...getContentRangeHeader(range),
|
||||||
},
|
},
|
||||||
isRangeRequest: !!range,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals, params, request }) => {
|
export const GET: RequestHandler = async ({ locals, params, request }) => {
|
||||||
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
const { stream, ...init } = await downloadHandler(locals, params, request);
|
||||||
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
|
return new Response(stream as ReadableStream, init);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
|
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
|
||||||
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
|
return new Response(null, await downloadHandler(locals, params, request));
|
||||||
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
|
|
||||||
};
|
};
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { parseRangeHeader, getContentRangeHeader } from "$lib/modules/http";
|
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
|
||||||
import { getFileThumbnailStream } from "$lib/server/services/file";
|
|
||||||
import type { RequestHandler } from "./$types";
|
|
||||||
|
|
||||||
const downloadHandler = async (
|
|
||||||
locals: App.Locals,
|
|
||||||
params: Record<string, string>,
|
|
||||||
request: Request,
|
|
||||||
) => {
|
|
||||||
const { userId } = await authorize(locals, "activeClient");
|
|
||||||
|
|
||||||
const zodRes = z
|
|
||||||
.object({
|
|
||||||
id: z.coerce.number().int().positive(),
|
|
||||||
})
|
|
||||||
.safeParse(params);
|
|
||||||
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,
|
|
||||||
headers: {
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
"Content-Length": (range.end - range.start + 1).toString(),
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
...getContentRangeHeader(range),
|
|
||||||
},
|
|
||||||
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,6 +1,8 @@
|
|||||||
import { error, text } from "@sveltejs/kit";
|
import { error, text } from "@sveltejs/kit";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
import type { ReadableStream } from "stream/web";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { parseContentDigestHeader } from "$lib/modules/http";
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
import { authorize } from "$lib/server/modules/auth";
|
||||||
import { uploadChunk } from "$lib/server/services/upload";
|
import { uploadChunk } from "$lib/server/services/upload";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
@@ -11,33 +13,25 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
|
|||||||
const zodRes = z
|
const zodRes = z
|
||||||
.object({
|
.object({
|
||||||
id: z.uuidv4(),
|
id: z.uuidv4(),
|
||||||
index: z.coerce.number().int().nonnegative(),
|
index: z.coerce.number().int().positive(),
|
||||||
})
|
})
|
||||||
.safeParse(params);
|
.safeParse(params);
|
||||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||||
const { id: uploadId, index: chunkIndex } = zodRes.data;
|
const { id: sessionId, index: chunkIndex } = zodRes.data;
|
||||||
|
|
||||||
// Parse Content-Digest header (RFC 9530)
|
const encContentHash = parseContentDigestHeader(request.headers.get("Content-Digest"));
|
||||||
// Expected format: sha-256=:base64hash:
|
if (!encContentHash) {
|
||||||
const contentDigest = request.headers.get("Content-Digest");
|
error(400, "Invalid request headers");
|
||||||
if (!contentDigest) error(400, "Missing Content-Digest header");
|
} else if (!request.body) {
|
||||||
|
|
||||||
const digestMatch = contentDigest.match(/^sha-256=:([A-Za-z0-9+/=]+):$/);
|
|
||||||
if (!digestMatch || !digestMatch[1])
|
|
||||||
error(400, "Invalid Content-Digest format, must be sha-256=:base64:");
|
|
||||||
const encChunkHash = digestMatch[1];
|
|
||||||
|
|
||||||
const contentType = request.headers.get("Content-Type");
|
|
||||||
if (contentType !== "application/octet-stream" || !request.body) {
|
|
||||||
error(400, "Invalid request body");
|
error(400, "Invalid request body");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert web ReadableStream to Node Readable
|
await uploadChunk(
|
||||||
const nodeReadable = Readable.fromWeb(
|
userId,
|
||||||
request.body as unknown as Parameters<typeof Readable.fromWeb>[0],
|
sessionId,
|
||||||
|
chunkIndex,
|
||||||
|
Readable.fromWeb(request.body as ReadableStream),
|
||||||
|
encContentHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
await uploadChunk(userId, uploadId, chunkIndex, nodeReadable, encChunkHash);
|
|
||||||
|
|
||||||
return text("Chunk uploaded", { headers: { "Content-Type": "text/plain" } });
|
return text("Chunk uploaded", { headers: { "Content-Type": "text/plain" } });
|
||||||
};
|
};
|
||||||
|
|||||||
1
src/service-worker/constants.ts
Normal file
1
src/service-worker/constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "../lib/constants";
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../modules/constants";
|
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
|
||||||
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
|
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
|
||||||
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
|
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
|
||||||
import { getFile } from "../modules/opfs";
|
import { getFile } from "../modules/opfs";
|
||||||
@@ -15,10 +15,13 @@ const createResponse = (
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Length": String(range.end - range.start + 1),
|
"Content-Length": String(range.end - range.start + 1),
|
||||||
"Content-Type": contentType ?? "application/octet-stream",
|
|
||||||
...(isRangeRequest ? getContentRangeHeader(range) : {}),
|
...(isRangeRequest ? getContentRangeHeader(range) : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (contentType) {
|
||||||
|
headers["Content-Type"] = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadFilename) {
|
if (downloadFilename) {
|
||||||
headers["Content-Disposition"] =
|
headers["Content-Disposition"] =
|
||||||
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;
|
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
/// <reference types="@sveltejs/kit" />
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
|
||||||
import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants";
|
import { DECRYPTED_FILE_URL_PREFIX } from "./constants";
|
||||||
import { decryptFile } from "./handlers";
|
import { decryptFile } from "./handlers";
|
||||||
import { fileMetadataStore } from "./stores";
|
import { fileMetadataStore } from "./stores";
|
||||||
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
|
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
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";
|
export * from "../../lib/modules/crypto";
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ const fileRouter = router({
|
|||||||
return await MediaRepo.getMissingFileThumbnails(ctx.session.userId);
|
return await MediaRepo.getMissingFileThumbnails(ctx.session.userId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listLegacy: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||||
|
return await FileRepo.getLegacyFileIds(ctx.session.userId);
|
||||||
|
}),
|
||||||
|
|
||||||
rename: roleProcedure["activeClient"]
|
rename: roleProcedure["activeClient"]
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { createReadStream, createWriteStream } from "fs";
|
import { createReadStream, createWriteStream } from "fs";
|
||||||
import { mkdir, rename } from "fs/promises";
|
import { copyFile, mkdir } from "fs/promises";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
@@ -13,6 +13,8 @@ import env from "$lib/server/loadenv";
|
|||||||
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
||||||
import { router, roleProcedure } from "../init.server";
|
import { router, roleProcedure } from "../init.server";
|
||||||
|
|
||||||
|
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
|
||||||
|
|
||||||
const sessionLocks = new Set<string>();
|
const sessionLocks = new Set<string>();
|
||||||
|
|
||||||
const generateSessionId = async () => {
|
const generateSessionId = async () => {
|
||||||
@@ -60,7 +62,7 @@ const uploadRouter = router({
|
|||||||
userId: ctx.session.userId,
|
userId: ctx.session.userId,
|
||||||
path,
|
path,
|
||||||
totalChunks: input.chunks,
|
totalChunks: input.chunks,
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES),
|
||||||
parentId: input.parent,
|
parentId: input.parent,
|
||||||
mekVersion: input.mekVersion,
|
mekVersion: input.mekVersion,
|
||||||
encDek: input.dek,
|
encDek: input.dek,
|
||||||
@@ -89,41 +91,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.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"]
|
completeFileUpload: roleProcedure["activeClient"]
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -143,14 +110,14 @@ const uploadRouter = router({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||||
if (!session || session.type !== "file") {
|
if (session?.type !== "file") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||||
} else if (
|
} else if (
|
||||||
(session.hskVersion && !input.contentHmac) ||
|
(session.hskVersion && !input.contentHmac) ||
|
||||||
(!session.hskVersion && input.contentHmac)
|
(!session.hskVersion && input.contentHmac)
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" });
|
||||||
} else if (session.uploadedChunks.length < session.totalChunks) {
|
} else if (session.uploadedChunks < session.totalChunks) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +127,7 @@ const uploadRouter = router({
|
|||||||
const hashStream = createHash("sha256");
|
const hashStream = createHash("sha256");
|
||||||
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
||||||
|
|
||||||
for (let i = 0; i < session.totalChunks; i++) {
|
for (let i = 1; i <= session.totalChunks; i++) {
|
||||||
for await (const chunk of createReadStream(`${session.path}/${i}`)) {
|
for await (const chunk of createReadStream(`${session.path}/${i}`)) {
|
||||||
hashStream.update(chunk);
|
hashStream.update(chunk);
|
||||||
writeStream.write(chunk);
|
writeStream.write(chunk);
|
||||||
@@ -195,6 +162,42 @@ 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"]
|
completeFileThumbnailUpload: roleProcedure["activeClient"]
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -213,15 +216,15 @@ const uploadRouter = router({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||||
if (!session || session.type !== "thumbnail") {
|
if (session?.type !== "thumbnail") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||||
} else if (session.uploadedChunks.length < session.totalChunks) {
|
} else if (session.uploadedChunks < session.totalChunks) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`;
|
thumbnailPath = `${env.thumbnailsPath}/${ctx.session.userId}/${uploadId}`;
|
||||||
await mkdir(dirname(thumbnailPath), { recursive: true });
|
await mkdir(dirname(thumbnailPath), { recursive: true });
|
||||||
await rename(`${session.path}/0`, thumbnailPath);
|
await copyFile(`${session.path}/1`, thumbnailPath);
|
||||||
|
|
||||||
const oldThumbnailPath = await db.transaction().execute(async (trx) => {
|
const oldThumbnailPath = await db.transaction().execute(async (trx) => {
|
||||||
const oldPath = await MediaRepo.updateFileThumbnail(
|
const oldPath = await MediaRepo.updateFileThumbnail(
|
||||||
@@ -238,14 +241,118 @@ const uploadRouter = router({
|
|||||||
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
|
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(thumbnailPath);
|
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 });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
sessionLocks.delete(uploadId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
startMigrationUpload: roleProcedure["activeClient"]
|
||||||
|
.input(
|
||||||
|
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({
|
||||||
|
id,
|
||||||
|
type: "migration",
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
path,
|
||||||
|
totalChunks: input.chunks,
|
||||||
|
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 instanceof IntegrityError) {
|
||||||
if (e.message === "File not found") {
|
if (e.message === "File not found") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||||
} else if (e.message === "Invalid DEK version") {
|
} else if (e.message === "File is not legacy") {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
completeMigrationUpload: roleProcedure["activeClient"]
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
uploadId: z.uuidv4(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { uploadId } = input;
|
||||||
|
if (sessionLocks.has(uploadId)) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" });
|
||||||
|
} else {
|
||||||
|
sessionLocks.add(uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||||
|
if (session?.type !== "migration") {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||||
|
} else if (session.uploadedChunks < session.totalChunks) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath = `${env.libraryPath}/${ctx.session.userId}/${uuidv4()}`;
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
const hashStream = createHash("sha256");
|
||||||
|
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
||||||
|
|
||||||
|
for (let i = 1; i <= session.totalChunks; i++) {
|
||||||
|
for await (const chunk of createReadStream(`${session.path}/${i}`)) {
|
||||||
|
hashStream.update(chunk);
|
||||||
|
writeStream.write(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeStream.end((e: any) => (e ? reject(e) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = hashStream.digest("base64");
|
||||||
|
const oldPath = await db.transaction().execute(async (trx) => {
|
||||||
|
const { oldPath } = await FileRepo.migrateFileContent(
|
||||||
|
trx,
|
||||||
|
ctx.session.userId,
|
||||||
|
session.fileId,
|
||||||
|
filePath,
|
||||||
|
session.dekVersion!,
|
||||||
|
hash,
|
||||||
|
);
|
||||||
|
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||||
|
return oldPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
sessionLocks.delete(uploadId);
|
sessionLocks.delete(uploadId);
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/workers/hmac.ts
Normal file
25
src/workers/hmac.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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,6 +8,7 @@ const config = {
|
|||||||
adapter: adapter(),
|
adapter: adapter(),
|
||||||
alias: {
|
alias: {
|
||||||
$trpc: "./src/trpc",
|
$trpc: "./src/trpc",
|
||||||
|
$workers: "./src/workers",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user