mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-03-22 23:36:55 +09:00
Compare commits
11 Commits
7f68f6d580
...
v0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385404ece2 | ||
|
|
ac6aaa18ca | ||
|
|
7b621d6e98 | ||
|
|
3906ec4371 | ||
|
|
90ac5ba4c3 | ||
|
|
dfffa004ac | ||
|
|
0cd55a413d | ||
|
|
361d966a59 | ||
|
|
aef43b8bfa | ||
|
|
7f128cccf6 | ||
|
|
a198e5f6dc |
@@ -12,3 +12,4 @@ USER_CLIENT_CHALLENGE_EXPIRES=
|
|||||||
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
||||||
LIBRARY_PATH=
|
LIBRARY_PATH=
|
||||||
THUMBNAILS_PATH=
|
THUMBNAILS_PATH=
|
||||||
|
UPLOADS_PATH=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,6 +21,7 @@ services:
|
|||||||
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
||||||
- LIBRARY_PATH=/app/data/library
|
- LIBRARY_PATH=/app/data/library
|
||||||
- THUMBNAILS_PATH=/app/data/thumbnails
|
- THUMBNAILS_PATH=/app/data/thumbnails
|
||||||
|
- UPLOADS_PATH=/app/data/uploads
|
||||||
# SvelteKit
|
# SvelteKit
|
||||||
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
||||||
- XFF_DEPTH=${TRUST_PROXY:-}
|
- XFF_DEPTH=${TRUST_PROXY:-}
|
||||||
|
|||||||
54
package.json
54
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "arkvault",
|
"name": "arkvault",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.1",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -16,56 +16,56 @@
|
|||||||
"db:migrate": "kysely migrate"
|
"db:migrate": "kysely migrate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.3",
|
"@eslint/compat": "^2.0.1",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.2",
|
||||||
"@iconify-json/material-symbols": "^1.2.61",
|
"@iconify-json/material-symbols": "^1.2.51",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.1",
|
||||||
"@sveltejs/kit": "^2.53.4",
|
"@sveltejs/kit": "^2.49.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tanstack/svelte-virtual": "^3.13.21",
|
"@tanstack/svelte-virtual": "^3.13.18",
|
||||||
"@trpc/client": "^11.12.0",
|
"@trpc/client": "^11.8.1",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/ms": "^0.7.34",
|
"@types/ms": "^0.7.34",
|
||||||
"@types/node-schedule": "^2.1.8",
|
"@types/node-schedule": "^2.1.8",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.16.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.23",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.2",
|
||||||
"dexie": "^4.3.0",
|
"dexie": "^4.2.1",
|
||||||
"es-hangul": "^2.3.8",
|
"es-hangul": "^2.3.8",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.15.2",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||||
"exifreader": "^4.36.2",
|
"exifreader": "^4.36.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.0.0",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"kysely-ctl": "^0.20.0",
|
"kysely-ctl": "^0.20.0",
|
||||||
"lru-cache": "^11.2.6",
|
"lru-cache": "^11.2.4",
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.2.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.0",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"svelte": "^5.53.9",
|
"svelte": "^5.46.4",
|
||||||
"svelte-check": "^4.4.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.53.0",
|
||||||
"unplugin-icons": "^23.0.1",
|
"unplugin-icons": "^23.0.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trpc/server": "^11.12.0",
|
"@trpc/server": "^11.8.1",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"kysely": "^0.28.11",
|
"kysely": "^0.28.9",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.17.1",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
|
|||||||
1386
pnpm-lock.yaml
generated
1386
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -168,7 +168,7 @@ const requestFileUpload = limitFunction(
|
|||||||
) => {
|
) => {
|
||||||
state.status = "uploading";
|
state.status = "uploading";
|
||||||
|
|
||||||
const { encContentHash } = await uploadBlob(uploadId, file, dataKey, {
|
await uploadBlob(uploadId, file, dataKey, {
|
||||||
onProgress(s) {
|
onProgress(s) {
|
||||||
state.progress = s.progress;
|
state.progress = s.progress;
|
||||||
state.rate = s.rate;
|
state.rate = s.rate;
|
||||||
@@ -178,7 +178,6 @@ const requestFileUpload = limitFunction(
|
|||||||
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
|
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
|
||||||
uploadId,
|
uploadId,
|
||||||
contentHmac: fileSigned,
|
contentHmac: fileSigned,
|
||||||
encContentHash,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (thumbnailBuffer) {
|
if (thumbnailBuffer) {
|
||||||
|
|||||||
@@ -12,3 +12,11 @@ export const parseRangeHeader = (value: 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];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { sha256 } from "@noble/hashes/sha2.js";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
||||||
import { encodeToBase64, encryptChunk } from "$lib/modules/crypto";
|
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
|
||||||
import { BoundedQueue } from "$lib/utils";
|
import { BoundedQueue } from "$lib/utils";
|
||||||
|
|
||||||
interface UploadStats {
|
interface UploadStats {
|
||||||
@@ -13,6 +12,7 @@ interface UploadStats {
|
|||||||
interface EncryptedChunk {
|
interface EncryptedChunk {
|
||||||
index: number;
|
index: number;
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
|
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
|
||||||
@@ -68,18 +68,27 @@ const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 5
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const encryptChunkData = async (chunk: Blob, dataKey: CryptoKey): Promise<ArrayBuffer> => {
|
const encryptChunkData = async (
|
||||||
return await encryptChunk(await chunk.arrayBuffer(), dataKey);
|
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 (
|
const uploadEncryptedChunk = async (
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
chunkIndex: number,
|
chunkIndex: number,
|
||||||
encrypted: ArrayBuffer,
|
encrypted: ArrayBuffer,
|
||||||
|
hash: string,
|
||||||
onChunkProgress: (chunkIndex: number, loaded: number) => void,
|
onChunkProgress: (chunkIndex: number, loaded: number) => void,
|
||||||
) => {
|
) => {
|
||||||
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
|
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Digest": `sha-256=:${hash}:`,
|
||||||
|
},
|
||||||
onUploadProgress(e) {
|
onUploadProgress(e) {
|
||||||
onChunkProgress(chunkIndex, e.loaded ?? 0);
|
onChunkProgress(chunkIndex, e.loaded ?? 0);
|
||||||
},
|
},
|
||||||
@@ -103,7 +112,6 @@ export const uploadBlob = async (
|
|||||||
|
|
||||||
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
|
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
|
||||||
const speedMeter = createSpeedMeter(3000, 200);
|
const speedMeter = createSpeedMeter(3000, 200);
|
||||||
const hash = sha256.create();
|
|
||||||
|
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
if (!onProgress) return;
|
if (!onProgress) return;
|
||||||
@@ -128,9 +136,8 @@ export const uploadBlob = async (
|
|||||||
try {
|
try {
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
const chunk = blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
const chunk = blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
||||||
const data = await encryptChunkData(chunk, dataKey);
|
const { data, hash } = await encryptChunkData(chunk, dataKey);
|
||||||
hash.update(new Uint8Array(data));
|
await queue.push({ index: i, data, hash });
|
||||||
await queue.push({ index: i, data });
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
encryptionError = e instanceof Error ? e : new Error(String(e));
|
encryptionError = e instanceof Error ? e : new Error(String(e));
|
||||||
@@ -151,7 +158,7 @@ export const uploadBlob = async (
|
|||||||
|
|
||||||
const task = limit(async () => {
|
const task = limit(async () => {
|
||||||
try {
|
try {
|
||||||
await uploadEncryptedChunk(uploadId, item.index, item.data, onChunkProgress);
|
await uploadEncryptedChunk(uploadId, item.index, item.data, item.hash, onChunkProgress);
|
||||||
} finally {
|
} finally {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
item.data = null;
|
item.data = null;
|
||||||
@@ -173,5 +180,4 @@ export const uploadBlob = async (
|
|||||||
await Promise.all([encryptionProducer(), uploadConsumer()]);
|
await Promise.all([encryptionProducer(), uploadConsumer()]);
|
||||||
|
|
||||||
onProgress?.({ progress: 1, rate: speedMeter() });
|
onProgress?.({ progress: 1, rate: speedMeter() });
|
||||||
return { encContentHash: encodeToBase64(hash.digest()) };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type IntegrityErrorMessages =
|
|||||||
| "Directory already favorited"
|
| "Directory already favorited"
|
||||||
| "Directory not favorited"
|
| "Directory not favorited"
|
||||||
| "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"
|
||||||
| "File already favorited"
|
| "File already favorited"
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ export const getAllFileIds = async (userId: number) => {
|
|||||||
return files.map(({ id }) => id);
|
return files.map(({ id }) => id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLegacyFiles = async (userId: number, limit: number = 100) => {
|
||||||
|
const files = await db
|
||||||
|
.selectFrom("file")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("encrypted_content_iv", "is not", null)
|
||||||
|
.limit(limit)
|
||||||
|
.execute();
|
||||||
|
return files.map(toFile);
|
||||||
|
};
|
||||||
|
|
||||||
export const getFilesWithoutThumbnail = async (userId: number, limit: number = 100) => {
|
export const getFilesWithoutThumbnail = async (userId: number, limit: number = 100) => {
|
||||||
const files = await db
|
const files = await db
|
||||||
.selectFrom("file")
|
.selectFrom("file")
|
||||||
@@ -405,6 +416,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 {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Ciphertext } from "./utils";
|
|||||||
|
|
||||||
export interface UploadSessionTable {
|
export 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;
|
bitmap: Buffer;
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -86,8 +86,8 @@ export const createFileUploadSession = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThumbnailUploadSession = async (
|
export const createThumbnailOrMigrationUploadSession = async (
|
||||||
params: Omit<ThumbnailUploadSession, "bitmap" | "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
|
||||||
@@ -164,7 +164,7 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryPath: env.LIBRARY_PATH || "library",
|
libraryPath: env.LIBRARY_PATH || "library",
|
||||||
thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails",
|
thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails",
|
||||||
|
uploadsPath: env.UPLOADS_PATH || "uploads",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { unlink } from "fs/promises";
|
import { rm, unlink } from "fs/promises";
|
||||||
|
|
||||||
|
export const safeRecursiveRm = async (path: string | null | undefined) => {
|
||||||
|
if (path) {
|
||||||
|
await rm(path, { recursive: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const safeUnlink = async (path: string | null | undefined) => {
|
export const safeUnlink = async (path: string | null | undefined) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
import { open } from "fs/promises";
|
import { createHash } from "crypto";
|
||||||
|
import { createWriteStream } from "fs";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "$lib/constants";
|
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "$lib/constants";
|
||||||
import { UploadRepo } from "$lib/server/db";
|
import { UploadRepo } from "$lib/server/db";
|
||||||
import { safeUnlink } from "$lib/server/modules/filesystem";
|
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
|
||||||
|
|
||||||
const chunkLocks = new Set<string>();
|
const chunkLocks = new Set<string>();
|
||||||
|
|
||||||
@@ -13,61 +14,12 @@ const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
|
|||||||
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
|
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeChunkAtOffset = async (
|
|
||||||
path: string,
|
|
||||||
encChunkStream: Readable,
|
|
||||||
chunkIndex: number,
|
|
||||||
isLastChunk: boolean,
|
|
||||||
) => {
|
|
||||||
const offset = (chunkIndex - 1) * ENCRYPTED_CHUNK_SIZE;
|
|
||||||
const file = await open(path, "r+");
|
|
||||||
let written = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const chunk of encChunkStream) {
|
|
||||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
written += buffer.length;
|
|
||||||
if (written > ENCRYPTED_CHUNK_SIZE) {
|
|
||||||
throw new Error("Invalid chunk size");
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunkOffset = 0;
|
|
||||||
while (chunkOffset < buffer.length) {
|
|
||||||
const { bytesWritten } = await file.write(
|
|
||||||
buffer,
|
|
||||||
chunkOffset,
|
|
||||||
buffer.length - chunkOffset,
|
|
||||||
offset + written - buffer.length + chunkOffset,
|
|
||||||
);
|
|
||||||
if (bytesWritten <= 0) {
|
|
||||||
throw new Error("Failed to write chunk");
|
|
||||||
}
|
|
||||||
chunkOffset += bytesWritten;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!isLastChunk && written !== ENCRYPTED_CHUNK_SIZE) ||
|
|
||||||
(isLastChunk && (written <= ENCRYPTION_OVERHEAD || written > ENCRYPTED_CHUNK_SIZE))
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid chunk size");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLastChunk) {
|
|
||||||
await file.truncate(offset + written);
|
|
||||||
}
|
|
||||||
|
|
||||||
return written;
|
|
||||||
} finally {
|
|
||||||
await file.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadChunk = async (
|
export const uploadChunk = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
chunkIndex: number,
|
chunkIndex: number,
|
||||||
encChunkStream: Readable,
|
encChunkStream: Readable,
|
||||||
|
encChunkHash: string,
|
||||||
) => {
|
) => {
|
||||||
const lockKey = `${sessionId}/${chunkIndex}`;
|
const lockKey = `${sessionId}/${chunkIndex}`;
|
||||||
if (chunkLocks.has(lockKey)) {
|
if (chunkLocks.has(lockKey)) {
|
||||||
@@ -76,6 +28,8 @@ export const uploadChunk = async (
|
|||||||
chunkLocks.add(lockKey);
|
chunkLocks.add(lockKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -87,10 +41,39 @@ export const uploadChunk = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLastChunk = chunkIndex === session.totalChunks;
|
const isLastChunk = chunkIndex === session.totalChunks;
|
||||||
await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk);
|
filePath = `${session.path}/${chunkIndex}`;
|
||||||
|
|
||||||
|
const hashStream = createHash("sha256");
|
||||||
|
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
|
||||||
|
let writtenBytes = 0;
|
||||||
|
|
||||||
|
for await (const chunk of encChunkStream) {
|
||||||
|
hashStream.update(chunk);
|
||||||
|
writeStream.write(chunk);
|
||||||
|
writtenBytes += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeStream.end((e: any) => (e ? reject(e) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hashStream.digest("base64") !== encChunkHash) {
|
||||||
|
throw new Error("Invalid checksum");
|
||||||
|
} else if (
|
||||||
|
(!isLastChunk && writtenBytes !== ENCRYPTED_CHUNK_SIZE) ||
|
||||||
|
(isLastChunk && (writtenBytes <= ENCRYPTION_OVERHEAD || writtenBytes > ENCRYPTED_CHUNK_SIZE))
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid chunk size");
|
||||||
|
}
|
||||||
|
|
||||||
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
|
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message === "Invalid chunk size") {
|
await safeUnlink(filePath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
(e.message === "Invalid checksum" || e.message === "Invalid chunk size")
|
||||||
|
) {
|
||||||
error(400, "Invalid request body");
|
error(400, "Invalid request body");
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
@@ -101,5 +84,5 @@ export const uploadChunk = async (
|
|||||||
|
|
||||||
export const cleanupExpiredUploadSessions = async () => {
|
export const cleanupExpiredUploadSessions = async () => {
|
||||||
const paths = await UploadRepo.cleanupExpiredUploadSessions();
|
const paths = await UploadRepo.cleanupExpiredUploadSessions();
|
||||||
await Promise.all(paths.map(safeUnlink));
|
await Promise.all(paths.map(safeRecursiveRm));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
82
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
82
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<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 type { MaybeFileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
import { sortEntries } from "$lib/utils";
|
||||||
|
import File from "./File.svelte";
|
||||||
|
import {
|
||||||
|
getMigrationState,
|
||||||
|
clearMigrationStates,
|
||||||
|
requestLegacyFiles,
|
||||||
|
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(await requestLegacyFiles(data.files, $masterKeyStore?.get(1)?.key!));
|
||||||
|
});
|
||||||
|
|
||||||
|
$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>
|
||||||
116
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
116
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { limitFunction } from "p-limit";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import { CHUNK_SIZE } from "$lib/constants";
|
||||||
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { uploadBlob } from "$lib/modules/upload";
|
||||||
|
import { requestFileDownload } from "$lib/services/file";
|
||||||
|
import { HybridPromise, Scheduler } from "$lib/utils";
|
||||||
|
import { trpc } from "$trpc/client";
|
||||||
|
import type { RouterOutputs } from "$trpc/router.server";
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
export const requestLegacyFiles = async (
|
||||||
|
filesRaw: RouterOutputs["file"]["listLegacy"],
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
const files = await HybridPromise.all(
|
||||||
|
filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })),
|
||||||
|
);
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,6 +2,7 @@ import { error, text } from "@sveltejs/kit";
|
|||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import type { ReadableStream } from "stream/web";
|
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";
|
||||||
@@ -18,7 +19,10 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
|
|||||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||||
const { id: sessionId, index: chunkIndex } = zodRes.data;
|
const { id: sessionId, index: chunkIndex } = zodRes.data;
|
||||||
|
|
||||||
if (!request.body) {
|
const encContentHash = parseContentDigestHeader(request.headers.get("Content-Digest"));
|
||||||
|
if (!encContentHash) {
|
||||||
|
error(400, "Invalid request headers");
|
||||||
|
} else if (!request.body) {
|
||||||
error(400, "Invalid request body");
|
error(400, "Invalid request body");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
chunkIndex,
|
chunkIndex,
|
||||||
Readable.fromWeb(request.body as ReadableStream),
|
Readable.fromWeb(request.body as ReadableStream),
|
||||||
|
encContentHash,
|
||||||
);
|
);
|
||||||
return text("Chunk uploaded", { headers: { "Content-Type": "text/plain" } });
|
return text("Chunk uploaded", { headers: { "Content-Type": "text/plain" } });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ const fileRouter = router({
|
|||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listLegacy: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||||
|
const files = await FileRepo.getLegacyFiles(ctx.session.userId);
|
||||||
|
return files.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
isLegacy: true,
|
||||||
|
parent: file.parentId,
|
||||||
|
mekVersion: file.mekVersion,
|
||||||
|
dek: file.encDek,
|
||||||
|
dekVersion: file.dekVersion,
|
||||||
|
contentType: file.contentType,
|
||||||
|
name: file.encName.ciphertext,
|
||||||
|
nameIv: file.encName.iv,
|
||||||
|
createdAt: file.encCreatedAt?.ciphertext,
|
||||||
|
createdAtIv: file.encCreatedAt?.iv,
|
||||||
|
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||||
|
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||||
|
isFavorite: file.isFavorite,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
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 } from "fs";
|
import { createReadStream, createWriteStream } from "fs";
|
||||||
import { mkdir, open } 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";
|
||||||
@@ -10,30 +10,17 @@ import { DirectoryIdSchema } from "$lib/schemas";
|
|||||||
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "$lib/server/db";
|
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "$lib/server/db";
|
||||||
import db from "$lib/server/db/kysely";
|
import db from "$lib/server/db/kysely";
|
||||||
import env from "$lib/server/loadenv";
|
import env from "$lib/server/loadenv";
|
||||||
import { 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 UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
|
||||||
|
|
||||||
const sessionLocks = new Set<string>();
|
const sessionLocks = new Set<string>();
|
||||||
|
|
||||||
const reserveUploadPath = async (path: string) => {
|
const generateSessionId = async () => {
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
const file = await open(path, "wx", 0o600);
|
|
||||||
await file.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateFileUploadSession = async (userId: number) => {
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const path = `${env.libraryPath}/${userId}/${uuidv4()}`;
|
const path = `${env.uploadsPath}/${id}`;
|
||||||
await reserveUploadPath(path);
|
await mkdir(path, { recursive: true });
|
||||||
return { id, path };
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateThumbnailUploadSession = async (userId: number) => {
|
|
||||||
const id = uuidv4();
|
|
||||||
const path = `${env.thumbnailsPath}/${userId}/${id}`;
|
|
||||||
await reserveUploadPath(path);
|
|
||||||
return { id, path };
|
return { id, path };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +54,7 @@ const uploadRouter = router({
|
|||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, path } = await generateFileUploadSession(ctx.session.userId);
|
const { id, path } = await generateSessionId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await UploadRepo.createFileUploadSession({
|
await UploadRepo.createFileUploadSession({
|
||||||
@@ -91,7 +78,7 @@ const uploadRouter = router({
|
|||||||
});
|
});
|
||||||
return { uploadId: id };
|
return { uploadId: id };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(path);
|
await safeRecursiveRm(path);
|
||||||
|
|
||||||
if (e instanceof IntegrityError) {
|
if (e instanceof IntegrityError) {
|
||||||
if (e.message === "Inactive MEK version") {
|
if (e.message === "Inactive MEK version") {
|
||||||
@@ -109,7 +96,6 @@ const uploadRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
uploadId: z.uuidv4(),
|
uploadId: z.uuidv4(),
|
||||||
contentHmac: z.base64().nonempty().optional(),
|
contentHmac: z.base64().nonempty().optional(),
|
||||||
encContentHash: z.base64().nonempty(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -120,6 +106,8 @@ const uploadRouter = router({
|
|||||||
sessionLocks.add(uploadId);
|
sessionLocks.add(uploadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let filePath = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||||
if (session?.type !== "file") {
|
if (session?.type !== "file") {
|
||||||
@@ -133,24 +121,29 @@ const uploadRouter = router({
|
|||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashStream = createHash("sha256");
|
filePath = `${env.libraryPath}/${ctx.session.userId}/${uuidv4()}`;
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
for await (const chunk of createReadStream(session.path)) {
|
const hashStream = createHash("sha256");
|
||||||
hashStream.update(chunk);
|
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 hash = hashStream.digest("base64");
|
||||||
if (hash !== input.encContentHash) {
|
|
||||||
await UploadRepo.deleteUploadSession(db, uploadId);
|
|
||||||
await safeUnlink(session.path);
|
|
||||||
throw new TRPCError({ code: "CONFLICT", message: "Uploaded file corrupted" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileId = await db.transaction().execute(async (trx) => {
|
const fileId = await db.transaction().execute(async (trx) => {
|
||||||
const { id: fileId } = await FileRepo.registerFile(trx, {
|
const { id: fileId } = await FileRepo.registerFile(trx, {
|
||||||
...session,
|
...session,
|
||||||
userId: ctx.session.userId,
|
userId: ctx.session.userId,
|
||||||
path: session.path,
|
path: filePath,
|
||||||
contentHmac: input.contentHmac ?? null,
|
contentHmac: input.contentHmac ?? null,
|
||||||
encContentHash: hash,
|
encContentHash: hash,
|
||||||
encContentIv: null,
|
encContentIv: null,
|
||||||
@@ -159,7 +152,11 @@ const uploadRouter = router({
|
|||||||
return fileId;
|
return fileId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await safeRecursiveRm(session.path);
|
||||||
return { file: fileId };
|
return { file: fileId };
|
||||||
|
} catch (e) {
|
||||||
|
await safeUnlink(filePath);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
sessionLocks.delete(uploadId);
|
sessionLocks.delete(uploadId);
|
||||||
}
|
}
|
||||||
@@ -173,10 +170,10 @@ const uploadRouter = router({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, path } = await generateThumbnailUploadSession(ctx.session.userId);
|
const { id, path } = await generateSessionId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await UploadRepo.createThumbnailUploadSession({
|
await UploadRepo.createThumbnailOrMigrationUploadSession({
|
||||||
id,
|
id,
|
||||||
type: "thumbnail",
|
type: "thumbnail",
|
||||||
userId: ctx.session.userId,
|
userId: ctx.session.userId,
|
||||||
@@ -188,7 +185,7 @@ const uploadRouter = router({
|
|||||||
});
|
});
|
||||||
return { uploadId: id };
|
return { uploadId: id };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(path);
|
await safeRecursiveRm(path);
|
||||||
|
|
||||||
if (e instanceof IntegrityError) {
|
if (e instanceof IntegrityError) {
|
||||||
if (e.message === "File not found") {
|
if (e.message === "File not found") {
|
||||||
@@ -215,6 +212,8 @@ const uploadRouter = router({
|
|||||||
sessionLocks.add(uploadId);
|
sessionLocks.add(uploadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let thumbnailPath = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId);
|
||||||
if (session?.type !== "thumbnail") {
|
if (session?.type !== "thumbnail") {
|
||||||
@@ -223,20 +222,26 @@ const uploadRouter = router({
|
|||||||
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}`;
|
||||||
|
await mkdir(dirname(thumbnailPath), { recursive: true });
|
||||||
|
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(
|
||||||
trx,
|
trx,
|
||||||
ctx.session.userId,
|
ctx.session.userId,
|
||||||
session.fileId,
|
session.fileId,
|
||||||
session.dekVersion,
|
session.dekVersion,
|
||||||
session.path,
|
thumbnailPath,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||||
return oldPath;
|
return oldPath;
|
||||||
});
|
});
|
||||||
await safeUnlink(oldThumbnailPath);
|
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
await safeUnlink(thumbnailPath);
|
||||||
|
|
||||||
if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
|
if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
|
||||||
// DEK rotated after this upload started
|
// DEK rotated after this upload started
|
||||||
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||||
@@ -246,6 +251,112 @@ const uploadRouter = router({
|
|||||||
sessionLocks.delete(uploadId);
|
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.message === "File not found") {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||||
|
} else if (e.message === "File is not legacy") {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
sessionLocks.delete(uploadId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default uploadRouter;
|
export default uploadRouter;
|
||||||
|
|||||||
Reference in New Issue
Block a user