mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
사소한 리팩토링
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ export const generateHmacSecret = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
||||||
const worker = new HmacWorker();
|
|
||||||
const stream = message.stream();
|
const stream = message.stream();
|
||||||
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
||||||
|
const worker = new HmacWorker();
|
||||||
|
|
||||||
return new Promise<Uint8Array>((resolve, reject) => {
|
return new Promise<Uint8Array>((resolve, reject) => {
|
||||||
worker.onmessage = (event: MessageEvent<ResultMessage>) => {
|
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
|
||||||
resolve(event.data.result);
|
resolve(data.result);
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ const requestDuplicateFileScan = limitFunction(
|
|||||||
) => {
|
) => {
|
||||||
state.status = "encryption-pending";
|
state.status = "encryption-pending";
|
||||||
|
|
||||||
const hmacResult = await signMessageHmac(file, hmacSecret.secret);
|
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
|
||||||
const fileSigned = encodeToBase64(hmacResult);
|
|
||||||
const files = await trpc().file.listByHash.query({
|
const files = await trpc().file.listByHash.query({
|
||||||
hskVersion: hmacSecret.version,
|
hskVersion: hmacSecret.version,
|
||||||
contentHmac: fileSigned,
|
contentHmac: fileSigned,
|
||||||
@@ -171,7 +170,7 @@ const requestFileUpload = limitFunction(
|
|||||||
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.rateBps;
|
state.rate = s.rate;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,32 @@ import pLimit from "p-limit";
|
|||||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
|
||||||
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
|
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
|
||||||
|
|
||||||
type UploadStats = {
|
interface UploadStats {
|
||||||
progress: number; // 0..1 (암호화 후 기준)
|
progress: number;
|
||||||
rateBps: number; // bytes/sec
|
rate: number;
|
||||||
uploadedBytes: number;
|
}
|
||||||
totalBytes: number;
|
|
||||||
};
|
const createSpeedMeter = (timeWindow = 1500) => {
|
||||||
|
const samples: { t: number; b: number }[] = [];
|
||||||
|
let lastSpeed = 0;
|
||||||
|
|
||||||
|
return (bytesNow?: number) => {
|
||||||
|
if (!bytesNow) return lastSpeed;
|
||||||
|
|
||||||
function createSpeedMeter(windowMs = 1500) {
|
|
||||||
const samples: Array<{ t: number; b: number }> = [];
|
|
||||||
return (bytesNow: number) => {
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
samples.push({ t: now, b: bytesNow });
|
samples.push({ t: now, b: bytesNow });
|
||||||
const cutoff = now - windowMs;
|
|
||||||
|
const cutoff = now - timeWindow;
|
||||||
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
|
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
|
||||||
|
|
||||||
const first = samples[0]!;
|
const first = samples[0]!;
|
||||||
const dt = now - first.t;
|
const dt = now - first.t;
|
||||||
const db = bytesNow - first.b;
|
const db = bytesNow - first.b;
|
||||||
return dt > 0 ? (db / dt) * 1000 : 0;
|
|
||||||
|
lastSpeed = dt > 0 ? (db / dt) * 1000 : 0;
|
||||||
|
return lastSpeed;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const uploadChunk = async (
|
const uploadChunk = async (
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
@@ -66,10 +71,10 @@ export const uploadBlob = async (
|
|||||||
if (!onProgress) return;
|
if (!onProgress) return;
|
||||||
|
|
||||||
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
|
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
|
||||||
const rateBps = speedMeter(uploadedBytes);
|
const rate = speedMeter(uploadedBytes);
|
||||||
const progress = Math.min(1, uploadedBytes / totalBytes);
|
const progress = Math.min(1, uploadedBytes / totalBytes);
|
||||||
|
|
||||||
onProgress({ progress, rateBps, uploadedBytes, totalBytes });
|
onProgress({ progress, rate });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChunkProgress = (idx: number, loaded: number) => {
|
const onChunkProgress = (idx: number, loaded: number) => {
|
||||||
@@ -84,7 +89,7 @@ export const uploadBlob = async (
|
|||||||
limit(() =>
|
limit(() =>
|
||||||
uploadChunk(
|
uploadChunk(
|
||||||
uploadId,
|
uploadId,
|
||||||
i + 1, // 1-based chunk index
|
i + 1,
|
||||||
blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
|
blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
|
||||||
dataKey,
|
dataKey,
|
||||||
onChunkProgress,
|
onChunkProgress,
|
||||||
@@ -93,11 +98,5 @@ export const uploadBlob = async (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 완료 보정
|
onProgress?.({ progress: 1, rate: speedMeter() });
|
||||||
onProgress?.({
|
|
||||||
progress: 1,
|
|
||||||
rateBps: 0,
|
|
||||||
uploadedBytes: totalBytes,
|
|
||||||
totalBytes,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -497,21 +497,22 @@ export const migrateFileContent = async (
|
|||||||
userId: number,
|
userId: number,
|
||||||
fileId: number,
|
fileId: number,
|
||||||
newPath: string,
|
newPath: string,
|
||||||
|
dekVersion: Date,
|
||||||
encContentHash: string,
|
encContentHash: string,
|
||||||
) => {
|
) => {
|
||||||
const file = await trx
|
const file = await trx
|
||||||
.selectFrom("file")
|
.selectFrom("file")
|
||||||
.select(["path", "encrypted_content_iv"])
|
.select(["path", "data_encryption_key_version", "encrypted_content_iv"])
|
||||||
.where("id", "=", fileId)
|
.where("id", "=", fileId)
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.forUpdate()
|
.forUpdate()
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new IntegrityError("File not found");
|
throw new IntegrityError("File not found");
|
||||||
}
|
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||||
if (!file.encrypted_content_iv) {
|
throw new IntegrityError("Invalid DEK version");
|
||||||
|
} else if (!file.encrypted_content_iv) {
|
||||||
throw new IntegrityError("File is not legacy");
|
throw new IntegrityError("File is not legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +526,6 @@ export const migrateFileContent = async (
|
|||||||
.where("id", "=", fileId)
|
.where("id", "=", fileId)
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insertInto("file_log")
|
.insertInto("file_log")
|
||||||
.values({
|
.values({
|
||||||
@@ -534,8 +534,7 @@ export const migrateFileContent = async (
|
|||||||
action: "migrate",
|
action: "migrate",
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
return { oldPath: file.path };
|
||||||
return file.path;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||||
|
|||||||
@@ -52,11 +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`uploaded_chunks <= total_chunks`)
|
|
||||||
.addCheckConstraint(
|
.addCheckConstraint(
|
||||||
"upload_session_ck02",
|
"upload_session_ck01",
|
||||||
sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`,
|
sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`,
|
||||||
)
|
)
|
||||||
|
.addCheckConstraint("upload_session_ck02", sql`uploaded_chunks <= total_chunks`)
|
||||||
.execute();
|
.execute();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface UploadSessionTable {
|
|||||||
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
|
||||||
@@ -21,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,17 +26,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MigrationUploadSession extends BaseUploadSession {
|
|
||||||
type: "migration";
|
|
||||||
fileId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createFileUploadSession = async (
|
export const createFileUploadSession = async (
|
||||||
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
||||||
) => {
|
) => {
|
||||||
@@ -91,8 +86,8 @@ export const createFileUploadSession = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createThumbnailUploadSession = async (
|
export const createThumbnailOrMigrationUploadSession = async (
|
||||||
params: Omit<ThumbnailUploadSession, "type" | "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
|
||||||
@@ -113,7 +108,7 @@ 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)),
|
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
||||||
@@ -126,40 +121,6 @@ export const createThumbnailUploadSession = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMigrationUploadSession = async (
|
|
||||||
params: Omit<MigrationUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
|
||||||
) => {
|
|
||||||
await db.transaction().execute(async (trx) => {
|
|
||||||
const file = await trx
|
|
||||||
.selectFrom("file")
|
|
||||||
.select("encrypted_content_iv")
|
|
||||||
.where("id", "=", params.fileId)
|
|
||||||
.where("user_id", "=", params.userId)
|
|
||||||
.limit(1)
|
|
||||||
.forUpdate()
|
|
||||||
.executeTakeFirst();
|
|
||||||
if (!file) {
|
|
||||||
throw new IntegrityError("File not found");
|
|
||||||
} else if (!file.encrypted_content_iv) {
|
|
||||||
throw new IntegrityError("File is not legacy");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.insertInto("upload_session")
|
|
||||||
.values({
|
|
||||||
id: params.id,
|
|
||||||
type: "migration",
|
|
||||||
user_id: params.userId,
|
|
||||||
path: params.path,
|
|
||||||
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
|
|
||||||
total_chunks: params.totalChunks,
|
|
||||||
expires_at: params.expiresAt,
|
|
||||||
file_id: params.fileId,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUploadSession = async (sessionId: string, userId: number) => {
|
export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||||
const session = await db
|
const session = await db
|
||||||
.selectFrom("upload_session")
|
.selectFrom("upload_session")
|
||||||
@@ -191,9 +152,9 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
|||||||
encCreatedAt: session.encrypted_created_at,
|
encCreatedAt: session.encrypted_created_at,
|
||||||
encLastModifiedAt: session.encrypted_last_modified_at!,
|
encLastModifiedAt: session.encrypted_last_modified_at!,
|
||||||
} satisfies FileUploadSession;
|
} satisfies FileUploadSession;
|
||||||
} else if (session.type === "thumbnail") {
|
} 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,
|
||||||
@@ -203,19 +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;
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
type: "migration",
|
|
||||||
id: session.id,
|
|
||||||
userId: session.user_id,
|
|
||||||
path: session.path,
|
|
||||||
bitmap: session.bitmap,
|
|
||||||
totalChunks: session.total_chunks,
|
|
||||||
uploadedChunks: session.uploaded_chunks,
|
|
||||||
expiresAt: session.expires_at,
|
|
||||||
fileId: session.file_id!,
|
|
||||||
} satisfies MigrationUploadSession;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,18 +42,25 @@ export const clearMigrationStates = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestFileUpload = limitFunction(
|
const requestFileUpload = limitFunction(
|
||||||
async (state: MigrationState, fileId: number, fileBuffer: ArrayBuffer, dataKey: CryptoKey) => {
|
async (
|
||||||
|
state: MigrationState,
|
||||||
|
fileId: number,
|
||||||
|
fileBuffer: ArrayBuffer,
|
||||||
|
dataKey: CryptoKey,
|
||||||
|
dataKeyVersion: Date,
|
||||||
|
) => {
|
||||||
state.status = "uploading";
|
state.status = "uploading";
|
||||||
|
|
||||||
const { uploadId } = await trpc().upload.startMigrationUpload.mutate({
|
const { uploadId } = await trpc().upload.startMigrationUpload.mutate({
|
||||||
file: fileId,
|
file: fileId,
|
||||||
chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE),
|
chunks: Math.ceil(fileBuffer.byteLength / CHUNK_SIZE),
|
||||||
|
dekVersion: dataKeyVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, {
|
await uploadBlob(uploadId, new Blob([fileBuffer]), dataKey, {
|
||||||
onProgress(s) {
|
onProgress(s) {
|
||||||
state.progress = s.progress;
|
state.progress = s.progress;
|
||||||
state.rate = s.rateBps;
|
state.rate = s.rate;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +83,7 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dataKey = fileInfo.dataKey?.key;
|
const dataKey = fileInfo.dataKey;
|
||||||
if (!dataKey) {
|
if (!dataKey) {
|
||||||
throw new Error("Data key not available");
|
throw new Error("Data key not available");
|
||||||
}
|
}
|
||||||
@@ -86,10 +93,10 @@ export const requestFileMigration = async (fileInfo: FileInfo) => {
|
|||||||
await scheduler.schedule(
|
await scheduler.schedule(
|
||||||
async () => {
|
async () => {
|
||||||
state.status = "downloading";
|
state.status = "downloading";
|
||||||
fileBuffer = await requestFileDownload(fileInfo.id, dataKey, true);
|
fileBuffer = await requestFileDownload(fileInfo.id, dataKey.key, true);
|
||||||
return fileBuffer.byteLength;
|
return fileBuffer.byteLength;
|
||||||
},
|
},
|
||||||
() => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey),
|
() => requestFileUpload(state, fileInfo.id, fileBuffer!, dataKey.key, dataKey.version),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.status = "error";
|
state.status = "error";
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
queued: "대기 중",
|
queued: "대기 중",
|
||||||
"generation-pending": "준비 중",
|
"generation-pending": "준비 중",
|
||||||
generating: "생성하는 중",
|
generating: "생성하는 중",
|
||||||
"upload-pending": "업로드를 기다리는 중",
|
|
||||||
uploading: "업로드하는 중",
|
uploading: "업로드하는 중",
|
||||||
error: "실패",
|
error: "실패",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export type GenerationStatus =
|
|||||||
| "queued"
|
| "queued"
|
||||||
| "generation-pending"
|
| "generation-pending"
|
||||||
| "generating"
|
| "generating"
|
||||||
| "upload-pending"
|
|
||||||
| "uploading"
|
| "uploading"
|
||||||
| "uploaded"
|
| "uploaded"
|
||||||
| "error";
|
| "error";
|
||||||
@@ -39,6 +38,8 @@ const requestThumbnailUpload = limitFunction(
|
|||||||
);
|
);
|
||||||
if (!thumbnail) return false;
|
if (!thumbnail) return false;
|
||||||
|
|
||||||
|
statuses.set(fileInfo.id, "uploading");
|
||||||
|
|
||||||
const res = await requestFileThumbnailUpload(
|
const res = await requestFileThumbnailUpload(
|
||||||
fileInfo.id,
|
fileInfo.id,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, text } from "@sveltejs/kit";
|
import { error, text } from "@sveltejs/kit";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { 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 { parseContentDigestHeader } from "$lib/modules/http";
|
||||||
import { authorize } from "$lib/server/modules/auth";
|
import { authorize } from "$lib/server/modules/auth";
|
||||||
|
|||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +110,7 @@ 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) ||
|
||||||
@@ -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,7 +216,7 @@ 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 < 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" });
|
||||||
@@ -221,7 +224,7 @@ const uploadRouter = router({
|
|||||||
|
|
||||||
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}/1`, 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,12 +241,10 @@ 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) {
|
|
||||||
if (e.message === "File not found") {
|
if (e instanceof IntegrityError && e.message === "Invalid DEK version") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
// DEK rotated after this upload started
|
||||||
} else if (e.message === "Invalid DEK version") {
|
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Mismatched DEK version" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,19 +257,22 @@ const uploadRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
file: z.int().positive(),
|
file: z.int().positive(),
|
||||||
chunks: z.int().positive(),
|
chunks: z.int().positive(),
|
||||||
|
dekVersion: z.date(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, path } = await generateSessionId();
|
const { id, path } = await generateSessionId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await UploadRepo.createMigrationUploadSession({
|
await UploadRepo.createThumbnailOrMigrationUploadSession({
|
||||||
id,
|
id,
|
||||||
|
type: "migration",
|
||||||
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),
|
||||||
fileId: input.file,
|
fileId: input.file,
|
||||||
|
dekVersion: input.dekVersion,
|
||||||
});
|
});
|
||||||
return { uploadId: id };
|
return { uploadId: id };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -276,9 +280,9 @@ const uploadRouter = router({
|
|||||||
|
|
||||||
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 === "File is not legacy") {
|
} else if (e.message === "File is not legacy") {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
@@ -303,7 +307,7 @@ 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 !== "migration") {
|
if (session?.type !== "migration") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" });
|
||||||
} else if (session.uploadedChunks < 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" });
|
||||||
@@ -328,11 +332,12 @@ const uploadRouter = router({
|
|||||||
|
|
||||||
const hash = hashStream.digest("base64");
|
const hash = hashStream.digest("base64");
|
||||||
const oldPath = await db.transaction().execute(async (trx) => {
|
const oldPath = await db.transaction().execute(async (trx) => {
|
||||||
const oldPath = await FileRepo.migrateFileContent(
|
const { oldPath } = await FileRepo.migrateFileContent(
|
||||||
trx,
|
trx,
|
||||||
ctx.session.userId,
|
ctx.session.userId,
|
||||||
session.fileId,
|
session.fileId,
|
||||||
filePath,
|
filePath,
|
||||||
|
session.dekVersion!,
|
||||||
hash,
|
hash,
|
||||||
);
|
);
|
||||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||||
@@ -342,12 +347,10 @@ const uploadRouter = router({
|
|||||||
await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]);
|
await Promise.all([safeUnlink(oldPath), safeRecursiveRm(session.path)]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await safeUnlink(filePath);
|
await safeUnlink(filePath);
|
||||||
if (e instanceof IntegrityError) {
|
|
||||||
if (e.message === "File not found") {
|
if (e instanceof IntegrityError && e.message === "File is not legacy") {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
|
// File migrated after this upload started
|
||||||
} else if (e.message === "File is not legacy") {
|
throw new TRPCError({ code: "CONFLICT", message: e.message });
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "File is not legacy" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user