DB 마이그레이션 스크립트 재생성 및 간단한 이미지/비디오 뷰어 구현

This commit is contained in:
static
2025-01-06 22:55:11 +09:00
parent 3168c441b9
commit 1c06a604c5
15 changed files with 111 additions and 16 deletions

View File

@@ -32,7 +32,7 @@ CREATE TABLE `directory` (
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`encrypted_at` integer NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
@@ -47,7 +47,9 @@ CREATE TABLE `file` (
`user_id` integer NOT NULL,
`master_encryption_key_version` integer NOT NULL,
`encrypted_data_encryption_key` text NOT NULL,
`encrypted_at` integer NOT NULL,
`data_encryption_key_version` integer NOT NULL,
`content_type` text NOT NULL,
`encrypted_content_iv` text NOT NULL,
`encrypted_name` text NOT NULL,
FOREIGN KEY (`parent_id`) REFERENCES `directory`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "901e84cd-f9eb-4329-a374-f71264675515",
"id": "929c6bca-d0c0-4899-afc6-a0a498226f28",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"client": {
@@ -262,8 +262,8 @@
"notNull": true,
"autoincrement": false
},
"encrypted_at": {
"name": "encrypted_at",
"data_encryption_key_version": {
"name": "data_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -384,13 +384,27 @@
"notNull": true,
"autoincrement": false
},
"encrypted_at": {
"name": "encrypted_at",
"data_encryption_key_version": {
"name": "data_encryption_key_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_content_iv": {
"name": "encrypted_content_iv",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_name": {
"name": "encrypted_name",
"type": "text",

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1735748192401,
"tag": "0000_lazy_scarecrow",
"when": 1736170919561,
"tag": "0000_handy_captain_marvel",
"breakpoints": true
}
]

View File

@@ -36,6 +36,7 @@
"eslint-plugin-tailwindcss": "^3.17.5",
"file-saver": "^2.0.5",
"globals": "^15.0.0",
"mime": "^4.0.6",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",

9
pnpm-lock.yaml generated
View File

@@ -88,6 +88,9 @@ devDependencies:
globals:
specifier: ^15.0.0
version: 15.14.0
mime:
specifier: ^4.0.6
version: 4.0.6
prettier:
specifier: ^3.3.2
version: 3.4.2
@@ -2689,6 +2692,12 @@ packages:
picomatch: 2.3.1
dev: true
/mime@4.0.6:
resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==}
engines: {node: '>=16'}
hasBin: true
dev: true
/mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}

View File

@@ -64,6 +64,7 @@ const fetchFileInfo = async (fileId: number, masterKey: CryptoKey) => {
id: fileId,
dataKey,
dataKeyVersion: metadata.dekVersion,
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
};

View File

@@ -21,6 +21,7 @@ export interface NewFileParams {
mekVersion: number;
encDek: string;
dekVersion: Date;
contentType: string;
encContentIv: string;
encName: string;
encNameIv: string;
@@ -137,6 +138,7 @@ export const registerNewFile = async (params: NewFileParams) => {
createdAt: now,
userId: params.userId,
mekVersion: params.mekVersion,
contentType: params.contentType,
encDek: params.encDek,
dekVersion: params.dekVersion,
encContentIv: params.encContentIv,

View File

@@ -47,6 +47,7 @@ export const file = sqliteTable(
mekVersion: integer("master_encryption_key_version").notNull(),
encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64
dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(),
contentType: text("content_type").notNull(),
encContentIv: text("encrypted_content_iv").notNull(), // Base64
encName: ciphertext("encrypted_name").notNull(),
},

View File

@@ -1,3 +1,4 @@
import mime from "mime";
import { z } from "zod";
export const fileRenameRequest = z.object({
@@ -12,6 +13,10 @@ export const fileInfoResponse = z.object({
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.date(),
contentType: z
.string()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
@@ -23,6 +28,10 @@ export const fileUploadRequest = z.object({
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.coerce.date(),
contentType: z
.string()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),

View File

@@ -83,6 +83,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
};

View File

@@ -22,6 +22,7 @@ export interface FileInfo {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
contentType: string;
contentIv: string;
name: string;
}

View File

@@ -1,26 +1,48 @@
<script lang="ts">
import FileSaver from "file-saver";
import { untrack } from "svelte";
import type { Writable } from "svelte/store";
import { TopBar } from "$lib/components";
import { getFileInfo } from "$lib/modules/file";
import { masterKeyStore, type FileInfo } from "$lib/stores";
import { requestFileDownload } from "./service";
type ContentType = "image" | "video";
let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state();
let isDownloaded = $state(false);
let content: ArrayBuffer | undefined = $state();
let contentType: ContentType | undefined = $state();
$effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloaded = false;
content = undefined;
contentType = undefined;
});
$effect(() => {
if (info && $info && !isDownloaded) {
isDownloaded = true;
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then((content) => {
FileSaver.saveAs(new Blob([content], { type: "application/octet-stream" }), $info.name);
if ($info && !isDownloaded) {
untrack(() => {
isDownloaded = true;
if ($info.contentType.startsWith("image/")) {
contentType = "image";
} else if ($info.contentType.startsWith("video/")) {
contentType = "video";
}
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then((res) => {
content = res;
if (!contentType) {
FileSaver.saveAs(new Blob([res], { type: $info.contentType }), $info.name);
}
});
});
}
});
@@ -30,4 +52,32 @@
<title>파일</title>
</svelte:head>
<TopBar title={$info?.name} />
<div class="flex h-full flex-col">
<div class="flex-shrink-0">
<TopBar title={$info?.name} />
</div>
<div class="flex w-full flex-grow flex-col items-center py-4">
{#snippet viewerLoading(message: string)}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">{message}</p>
</div>
{/snippet}
{#if contentType === "image"}
{#if $info && content}
{@const src = URL.createObjectURL(new Blob([content], { type: $info.contentType }))}
<img {src} alt={$info.name} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
{:else if contentType === "video"}
{#if $info && content}
{@const src = URL.createObjectURL(new Blob([content], { type: $info.contentType }))}
<!-- svelte-ignore a11y_media_has_caption -->
<video {src} controls></video>
{:else}
{@render viewerLoading("비디오를 불러오고 있어요.")}
{/if}
{/if}
</div>
</div>

View File

@@ -56,6 +56,7 @@ export const requestFileUpload = async (
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion,
contentType: file.type,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,

View File

@@ -16,7 +16,7 @@ export const GET: RequestHandler = async ({ cookies, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { createdAt, mekVersion, encDek, dekVersion, encContentIv, encName } =
const { createdAt, mekVersion, encDek, dekVersion, contentType, encContentIv, encName } =
await getFileInformation(userId, id);
return json(
fileInfoResponse.parse({
@@ -24,6 +24,7 @@ export const GET: RequestHandler = async ({ cookies, params }) => {
mekVersion,
dek: encDek,
dekVersion,
contentType: contentType,
contentIv: encContentIv,
name: encName.ciphertext,
nameIv: encName.iv,

View File

@@ -16,7 +16,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
if (!zodRes.success) error(400, "Invalid request body");
const { parentId, mekVersion, dek, dekVersion, contentIv, name, nameIv } = zodRes.data;
const { parentId, mekVersion, dek, dekVersion, contentType, contentIv, name, nameIv } =
zodRes.data;
await uploadFile(
{
@@ -25,6 +26,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
mekVersion,
encDek: dek,
dekVersion,
contentType,
encContentIv: contentIv,
encName: name,
encNameIv: nameIv,