썸네일 업로드 구현

This commit is contained in:
static
2025-07-05 16:55:09 +09:00
parent c236242136
commit eaf2d7f202
6 changed files with 60 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ node_modules
/build /build
/data /data
/library /library
/thumbnails
# OS # OS
.DS_Store .DS_Store

View File

@@ -11,9 +11,11 @@ import {
digestMessage, digestMessage,
signMessageHmac, signMessageHmac,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { generateImageThumbnail, generateVideoThumbnail } from "$lib/modules/thumbnail";
import type { import type {
DuplicateFileScanRequest, DuplicateFileScanRequest,
DuplicateFileScanResponse, DuplicateFileScanResponse,
FileThumbnailUploadRequest,
FileUploadRequest, FileUploadRequest,
FileUploadResponse, FileUploadResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
@@ -76,6 +78,24 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
return new Date(utcDate - offsetMs); return new Date(utcDate - offsetMs);
}; };
const generateThumbnail = async (file: File, fileType: string) => {
let url;
try {
if (fileType.startsWith("image/")) {
url = URL.createObjectURL(file);
return await generateImageThumbnail(url);
} else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(file);
return await generateVideoThumbnail(url);
}
return null;
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
};
const encryptFile = limitFunction( const encryptFile = limitFunction(
async ( async (
status: Writable<FileUploadStatus>, status: Writable<FileUploadStatus>,
@@ -106,6 +126,11 @@ const encryptFile = limitFunction(
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey)); createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey); const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
const thumbnail = await generateThumbnail(file, fileType);
const thumbnailEncrypted = thumbnail
? await encryptData(await thumbnail.arrayBuffer(), dataKey)
: null;
status.update((value) => { status.update((value) => {
value.status = "upload-pending"; value.status = "upload-pending";
return value; return value;
@@ -120,13 +145,14 @@ const encryptFile = limitFunction(
nameEncrypted, nameEncrypted,
createdAtEncrypted, createdAtEncrypted,
lastModifiedAtEncrypted, lastModifiedAtEncrypted,
thumbnailEncrypted,
}; };
}, },
{ concurrency: 4 }, { concurrency: 4 },
); );
const requestFileUpload = limitFunction( const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData) => { async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => { status.update((value) => {
value.status = "uploading"; value.status = "uploading";
return value; return value;
@@ -144,6 +170,15 @@ const requestFileUpload = limitFunction(
}); });
const { file }: FileUploadResponse = res.data; const { file }: FileUploadResponse = res.data;
if (thumbnailForm) {
try {
await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm);
} catch (e) {
// TODO
console.error(e);
}
}
status.update((value) => { status.update((value) => {
value.status = "uploaded"; value.status = "uploaded";
return value; return value;
@@ -198,6 +233,7 @@ export const uploadFile = async (
nameEncrypted, nameEncrypted,
createdAtEncrypted, createdAtEncrypted,
lastModifiedAtEncrypted, lastModifiedAtEncrypted,
thumbnailEncrypted,
} = await encryptFile(status, file, fileBuffer, masterKey); } = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData(); const form = new FormData();
@@ -223,7 +259,20 @@ export const uploadFile = async (
form.set("content", new Blob([fileEncrypted.ciphertext])); form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash); form.set("checksum", fileEncryptedHash);
const { fileId } = await requestFileUpload(status, form); let thumbnailForm = null;
if (thumbnailEncrypted) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnailEncrypted.iv,
} as FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnailEncrypted.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer }; return { fileId, fileBuffer };
} catch (e) { } catch (e) {
status.update((value) => { status.update((value) => {

View File

@@ -32,13 +32,13 @@ export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
export const fileThumbnailInfoResponse = z.object({ export const fileThumbnailInfoResponse = z.object({
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
encContentIv: z.string().base64().nonempty(), contentIv: z.string().base64().nonempty(),
}); });
export type FileThumbnailInfoResponse = z.infer<typeof fileThumbnailInfoResponse>; export type FileThumbnailInfoResponse = z.infer<typeof fileThumbnailInfoResponse>;
export const fileThumbnailUploadRequest = z.object({ export const fileThumbnailUploadRequest = z.object({
dekVersion: z.string().datetime(), dekVersion: z.string().datetime(),
encContentIv: z.string().base64().nonempty(), contentIv: z.string().base64().nonempty(),
}); });
export type FileThumbnailUploadRequest = z.infer<typeof fileThumbnailUploadRequest>; export type FileThumbnailUploadRequest = z.infer<typeof fileThumbnailUploadRequest>;

View File

@@ -20,7 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
return json( return json(
fileThumbnailInfoResponse.parse({ fileThumbnailInfoResponse.parse({
updatedAt: updatedAt.toISOString(), updatedAt: updatedAt.toISOString(),
encContentIv, contentIv: encContentIv,
} satisfies FileThumbnailInfoResponse), } satisfies FileThumbnailInfoResponse),
); );
}; };

View File

@@ -39,7 +39,9 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
if (fieldname === "metadata") { if (fieldname === "metadata") {
// Ignore subsequent metadata fields // Ignore subsequent metadata fields
if (!metadata) { if (!metadata) {
metadata = fileThumbnailUploadRequest.parse(val); const zodRes = fileThumbnailUploadRequest.safeParse(JSON.parse(val));
if (!zodRes.success) error(400, "Invalid request body");
metadata = zodRes.data;
} }
} else { } else {
error(400, "Invalid request body"); error(400, "Invalid request body");
@@ -57,7 +59,7 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
userId, userId,
id, id,
new Date(metadata.dekVersion), new Date(metadata.dekVersion),
metadata.encContentIv, metadata.contentIv,
content, content,
); );
resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } })); resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } }));