mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 00:00:28 +09:00
리다이렉트 경로 처리, 파일 정보 변환, 상태 표시 UI 등 중복된 코드를 통합하고 모듈 간 순환 의존성 및 계층 위반 해소
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9119c21881
commit
5b8621e524
96 files changed
+452
-548
No files matched your search
+8
-5
@@ -1,9 +1,13 @@
|
||||
import { sequence, type ServerInit } from "@sveltejs/kit/hooks";
|
||||
import schedule from "node-schedule";
|
||||
import { ClientRepo, SessionRepo } from "#lib/server/db/index.ts";
|
||||
import { migrateDB } from "#lib/server/db/kysely.ts";
|
||||
import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.ts";
|
||||
import { FileDeletionService, UploadService } from "#lib/server/services/index.ts";
|
||||
import {
|
||||
AuthService,
|
||||
ClientService,
|
||||
FileDeletionService,
|
||||
UploadService,
|
||||
} from "#lib/server/services/index.ts";
|
||||
|
||||
export const init: ServerInit = async () => {
|
||||
await migrateDB();
|
||||
@@ -11,9 +15,8 @@ export const init: ServerInit = async () => {
|
||||
schedule.scheduleJob("0 * * * *", async () => {
|
||||
// Failures are logged and retried at the next run, so they don't crash the server
|
||||
const results = await Promise.allSettled([
|
||||
ClientRepo.cleanupExpiredUserClientChallenges(),
|
||||
SessionRepo.cleanupExpiredSessions(),
|
||||
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
|
||||
AuthService.cleanupExpiredSessions(),
|
||||
ClientService.cleanupExpiredChallenges(),
|
||||
UploadService.cleanupExpiredUploadSessions(),
|
||||
]);
|
||||
results.forEach((result) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Chromium can't decode HEIC images, so they are converted to be displayed
|
||||
export const convertHeic = async (blob: Blob, toType: "image/png" | "image/jpeg") => {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
return (await heic2any({ blob, toType })) as Blob;
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { convertHeic } from "./heic.ts";
|
||||
|
||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||
if (width <= targetSize || height <= targetSize) {
|
||||
return { width, height };
|
||||
@@ -111,11 +113,6 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
});
|
||||
};
|
||||
|
||||
const convertHeicToPng = async (blob: Blob) => {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
return (await heic2any({ blob, toType: "image/png" })) as Blob;
|
||||
};
|
||||
|
||||
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
|
||||
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
|
||||
try {
|
||||
@@ -125,8 +122,8 @@ export const generateThumbnailFromUrl = async (url: string, contentType: string)
|
||||
} catch {
|
||||
if (contentType !== "image/heic") return null;
|
||||
|
||||
// Chromium can't decode HEIC images
|
||||
const png = URL.createObjectURL(await convertHeicToPng(await (await fetch(url)).blob()));
|
||||
const blob = await (await fetch(url)).blob();
|
||||
const png = URL.createObjectURL(await convertHeic(blob, "image/png"));
|
||||
try {
|
||||
return await generateImageThumbnail(png);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
|
||||
import {
|
||||
DECRYPTED_FILE_URL_PREFIX,
|
||||
MAX_PREPARED_FILES,
|
||||
type FileMetadata,
|
||||
type ServiceWorkerMessage,
|
||||
type ServiceWorkerResponse,
|
||||
@@ -10,7 +12,7 @@ const PREPARE_TIMEOUT_MS = 5000;
|
||||
|
||||
// The service worker may be terminated at any time, losing its in-memory metadata.
|
||||
// Keep a copy here so that it can be re-requested on demand.
|
||||
const preparedFiles = new Map<number, FileMetadata>();
|
||||
const preparedFiles = new LRUCache<number, FileMetadata>({ max: MAX_PREPARED_FILES });
|
||||
let isListening = false;
|
||||
|
||||
export const listenForServiceWorkerMessages = () => {
|
||||
|
||||
@@ -20,43 +20,43 @@ import { getChunkCount } from "#lib/shared/format/index.ts";
|
||||
import { Scheduler } from "#lib/utils/index.ts";
|
||||
import { trpc } from "#trpc/client.ts";
|
||||
|
||||
const LIVE_STATUSES = [
|
||||
"queued",
|
||||
"encryption-pending",
|
||||
"encrypting",
|
||||
"upload-pending",
|
||||
"uploading",
|
||||
] as const;
|
||||
|
||||
type LiveStatus = (typeof LIVE_STATUSES)[number];
|
||||
|
||||
export interface FileUploadState {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: DirectoryId;
|
||||
status:
|
||||
| "queued"
|
||||
| "encryption-pending"
|
||||
| "encrypting"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "canceled"
|
||||
| "error";
|
||||
status: LiveStatus | "uploaded" | "canceled" | "error";
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
estimated?: number;
|
||||
}
|
||||
|
||||
export type LiveFileUploadState = FileUploadState & {
|
||||
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
|
||||
};
|
||||
export type LiveFileUploadState = FileUploadState & { status: LiveStatus };
|
||||
|
||||
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
|
||||
let uploadingFiles: FileUploadState[] = $state([]);
|
||||
|
||||
const isFileUploading = (status: FileUploadState["status"]) =>
|
||||
["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
||||
const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => {
|
||||
return (LIVE_STATUSES as readonly string[]).includes(file.status);
|
||||
};
|
||||
|
||||
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
||||
return uploadingFiles.filter(
|
||||
(file) =>
|
||||
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
|
||||
);
|
||||
return uploadingFiles
|
||||
.filter(isFileUploading)
|
||||
.filter((file) => parentId === undefined || file.parentId === parentId);
|
||||
};
|
||||
|
||||
export const clearUploadedFiles = () => {
|
||||
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
|
||||
uploadingFiles = uploadingFiles.filter(isFileUploading);
|
||||
};
|
||||
|
||||
const requestDuplicateFileScan = limitFunction(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
isExistingFileInfo,
|
||||
isLocalDirectoryInfo,
|
||||
setDirectoryFavorite,
|
||||
setFileFavorite,
|
||||
type LocalDirectoryInfo,
|
||||
@@ -29,18 +31,16 @@ export const getFavoriteEntries = async () => {
|
||||
]);
|
||||
return [
|
||||
...sortEntries(
|
||||
directories.map((directory): FavoriteEntry => ({
|
||||
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
|
||||
type: "directory",
|
||||
name: directory.name!,
|
||||
details: directory as LocalDirectoryInfo,
|
||||
name: directory.name,
|
||||
details: directory,
|
||||
})),
|
||||
),
|
||||
...sortEntries(
|
||||
files.map((file): FavoriteEntry => ({
|
||||
type: "file",
|
||||
name: file.name!,
|
||||
details: file as SummarizedFileInfo,
|
||||
})),
|
||||
files
|
||||
.filter(isExistingFileInfo)
|
||||
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2,12 +2,8 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||
import { FilesystemCache } from "./cache.svelte.ts";
|
||||
import { invalidateFile } from "./file.ts";
|
||||
import {
|
||||
decryptCategoryMetadata,
|
||||
decryptFileMetadata,
|
||||
encryptName,
|
||||
generateEntryKeys,
|
||||
} from "./metadata.ts";
|
||||
import { toCategoryInfo, toFileInfo } from "./dto.ts";
|
||||
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
||||
import { useResource } from "./resource.svelte.ts";
|
||||
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
|
||||
|
||||
@@ -60,38 +56,25 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
||||
try {
|
||||
const category = await trpc.category.get.query({ id, recurse: true });
|
||||
const [subCategories, files, metadata] = await Promise.all([
|
||||
Promise.all(category.subCategories.map(toCategoryInfo)),
|
||||
Promise.all(
|
||||
category.subCategories.map(async (category) => ({
|
||||
id: category.id,
|
||||
parentId: id,
|
||||
...(await decryptCategoryMetadata(category)),
|
||||
(category.files ?? []).map(async (file) => ({
|
||||
...(await toFileInfo(file)),
|
||||
isRecursive: file.isRecursive,
|
||||
})),
|
||||
),
|
||||
category.files &&
|
||||
Promise.all(
|
||||
category.files.map(async (file) => ({
|
||||
id: file.id,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
isFavorite: file.isFavorite,
|
||||
isRecursive: file.isRecursive,
|
||||
...(await decryptFileMetadata(file)),
|
||||
})),
|
||||
),
|
||||
category.metadata && decryptCategoryMetadata(category.metadata),
|
||||
category.metadata && toCategoryInfo(category.metadata),
|
||||
]);
|
||||
|
||||
return storeToIndexedDB(
|
||||
id !== "root"
|
||||
metadata
|
||||
? {
|
||||
id,
|
||||
parentId: category.metadata!.parent,
|
||||
...metadata,
|
||||
subCategories,
|
||||
files: files!,
|
||||
files,
|
||||
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
|
||||
...metadata!,
|
||||
}
|
||||
: { id, subCategories },
|
||||
: { id: "root", subCategories },
|
||||
);
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.ts";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
|
||||
import { afterPress } from "#lib/ui/index.ts";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
|
||||
onclick={afterPress(onclick && (() => onclick(info)))}
|
||||
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
|
||||
>
|
||||
{#if thumbnail?.url}
|
||||
|
||||
@@ -2,12 +2,8 @@ import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/ind
|
||||
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||
import { FilesystemCache } from "./cache.svelte.ts";
|
||||
import {
|
||||
decryptDirectoryMetadata,
|
||||
decryptFileMetadata,
|
||||
encryptName,
|
||||
generateEntryKeys,
|
||||
} from "./metadata.ts";
|
||||
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
|
||||
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
||||
import { useResource } from "./resource.svelte.ts";
|
||||
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
|
||||
|
||||
@@ -43,37 +39,13 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
try {
|
||||
const directory = await trpc.directory.get.query({ id });
|
||||
const [subDirectories, files, metadata] = await Promise.all([
|
||||
Promise.all(
|
||||
directory.subDirectories.map(async (directory) => ({
|
||||
id: directory.id,
|
||||
parentId: id,
|
||||
isFavorite: directory.isFavorite,
|
||||
...(await decryptDirectoryMetadata(directory)),
|
||||
})),
|
||||
),
|
||||
Promise.all(
|
||||
directory.files.map(async (file) => ({
|
||||
id: file.id,
|
||||
parentId: id,
|
||||
contentType: file.contentType,
|
||||
isFavorite: file.isFavorite,
|
||||
...(await decryptFileMetadata(file)),
|
||||
})),
|
||||
),
|
||||
directory.metadata && decryptDirectoryMetadata(directory.metadata),
|
||||
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
|
||||
Promise.all(directory.files.map(toFileInfo)),
|
||||
directory.metadata && toDirectoryInfo(directory.metadata),
|
||||
]);
|
||||
|
||||
return storeToIndexedDB(
|
||||
id !== "root"
|
||||
? {
|
||||
id,
|
||||
parentId: directory.metadata!.parent,
|
||||
subDirectories,
|
||||
files,
|
||||
isFavorite: directory.metadata!.isFavorite,
|
||||
...metadata!,
|
||||
}
|
||||
: { id, subDirectories, files },
|
||||
metadata ? { ...metadata, subDirectories, files } : { id: "root", subDirectories, files },
|
||||
);
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
@@ -109,35 +81,19 @@ const storeToIndexedDB = (info: DirectoryInfo) => {
|
||||
return { ...info, exists: true as const };
|
||||
};
|
||||
|
||||
export const getDirectoryInfo = (
|
||||
id: DirectoryId,
|
||||
options?: {
|
||||
serverResponse?: {
|
||||
parent: DirectoryId;
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
// Uses `serverResponse` instead of fetching the directory from the server if given
|
||||
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
|
||||
const serverResponse = options?.serverResponse;
|
||||
return cache.get(id, {
|
||||
fetchFromServer:
|
||||
options?.serverResponse &&
|
||||
(async (cachedValue) => {
|
||||
const metadata = await decryptDirectoryMetadata(options!.serverResponse!);
|
||||
return storeToIndexedDB({
|
||||
serverResponse &&
|
||||
(async (cachedValue) =>
|
||||
storeToIndexedDB({
|
||||
subDirectories: [],
|
||||
files: [],
|
||||
...cachedValue,
|
||||
id: id as number,
|
||||
parentId: options!.serverResponse!.parent,
|
||||
isFavorite: options!.serverResponse!.isFavorite,
|
||||
...metadata,
|
||||
});
|
||||
}),
|
||||
...(await toDirectoryInfo(serverResponse)),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RouterOutputs } from "#trpc/router.server.ts";
|
||||
import {
|
||||
decryptCategoryMetadata,
|
||||
decryptDirectoryMetadata,
|
||||
decryptFileMetadata,
|
||||
} from "./metadata.ts";
|
||||
import type { FileInfo, SubCategoryInfo, SubDirectoryInfo, SummarizedFileInfo } from "./types.ts";
|
||||
|
||||
// Shapes of filesystem entries returned by the server
|
||||
export type DirectoryDto = RouterOutputs["directory"]["get"]["subDirectories"][number];
|
||||
export type FileDto = RouterOutputs["directory"]["get"]["files"][number];
|
||||
export type CategoryDto = RouterOutputs["category"]["get"]["subCategories"][number];
|
||||
export type FileWithCategoriesDto = RouterOutputs["file"]["get"];
|
||||
|
||||
export const toDirectoryInfo = async (directory: DirectoryDto): Promise<SubDirectoryInfo> => ({
|
||||
id: directory.id,
|
||||
parentId: directory.parent,
|
||||
isFavorite: directory.isFavorite,
|
||||
...(await decryptDirectoryMetadata(directory)),
|
||||
});
|
||||
|
||||
export const toFileInfo = async (file: FileDto): Promise<SummarizedFileInfo> => ({
|
||||
id: file.id,
|
||||
isLegacy: file.isLegacy,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
isFavorite: file.isFavorite,
|
||||
...(await decryptFileMetadata(file)),
|
||||
});
|
||||
|
||||
export const toCategoryInfo = async (category: CategoryDto): Promise<SubCategoryInfo> => ({
|
||||
id: category.id,
|
||||
parentId: category.parent,
|
||||
...(await decryptCategoryMetadata(category)),
|
||||
});
|
||||
|
||||
export const toFileWithCategoriesInfo = async ({
|
||||
categories,
|
||||
...file
|
||||
}: FileWithCategoriesDto): Promise<FileInfo> => {
|
||||
const [fileInfo, categoryInfos] = await Promise.all([
|
||||
toFileInfo(file),
|
||||
Promise.all(categories.map(toCategoryInfo)),
|
||||
]);
|
||||
return { ...fileInfo, categories: categoryInfos };
|
||||
};
|
||||
@@ -3,24 +3,24 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||
import { FilesystemCache } from "./cache.svelte.ts";
|
||||
import { invalidateDirectory } from "./directory.ts";
|
||||
import { decryptCategoryMetadata, decryptFileMetadata, encryptName } from "./metadata.ts";
|
||||
import { toFileInfo, toFileWithCategoriesInfo, type FileDto } from "./dto.ts";
|
||||
import { encryptName } from "./metadata.ts";
|
||||
import { useResource } from "./resource.svelte.ts";
|
||||
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.ts";
|
||||
|
||||
// Categories of files stored in IndexedDB, which may have been deleted
|
||||
const getFileCategoryInfos = async (categoryIds: number[] | undefined) => {
|
||||
const categories = await Promise.all(
|
||||
(categoryIds ?? []).map((id) => IndexedDB.getCategoryInfo(id)),
|
||||
);
|
||||
return categories
|
||||
.filter((category) => !!category)
|
||||
.map(({ id, parentId, name }) => ({ id, parentId, name }));
|
||||
};
|
||||
|
||||
const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
async fetchFromIndexedDB(id) {
|
||||
const file = await IndexedDB.getFileInfo(id);
|
||||
const categories = file?.categoryIds
|
||||
? await Promise.all(
|
||||
file.categoryIds.map(async (categoryId) => {
|
||||
const category = await IndexedDB.getCategoryInfo(categoryId);
|
||||
return category
|
||||
? { id: category.id, parentId: category.parentId, name: category.name }
|
||||
: undefined;
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (file) {
|
||||
return {
|
||||
id,
|
||||
@@ -30,7 +30,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
name: file.name,
|
||||
createdAt: file.createdAt,
|
||||
lastModifiedAt: file.lastModifiedAt,
|
||||
categories: categories?.filter((category) => !!category) ?? [],
|
||||
categories: await getFileCategoryInfos(file.categoryIds),
|
||||
isFavorite: file.isFavorite,
|
||||
};
|
||||
}
|
||||
@@ -38,30 +38,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
|
||||
async fetchFromServer(id) {
|
||||
try {
|
||||
const file = await trpc.file.get.query({ id });
|
||||
const [categories, metadata] = await Promise.all([
|
||||
Promise.all(
|
||||
file.categories.map(async (category) => ({
|
||||
id: category.id,
|
||||
parentId: category.parent,
|
||||
...(await decryptCategoryMetadata(category)),
|
||||
})),
|
||||
),
|
||||
decryptFileMetadata(file),
|
||||
]);
|
||||
|
||||
return storeToIndexedDB({
|
||||
id,
|
||||
isLegacy: file.isLegacy,
|
||||
parentId: file.parent,
|
||||
dataKey: metadata.dataKey,
|
||||
contentType: file.contentType,
|
||||
name: metadata.name,
|
||||
createdAt: metadata.createdAt,
|
||||
lastModifiedAt: metadata.lastModifiedAt,
|
||||
categories,
|
||||
isFavorite: file.isFavorite,
|
||||
});
|
||||
return storeToIndexedDB(await toFileWithCategoriesInfo(await trpc.file.get.query({ id })));
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
await IndexedDB.deleteFileInfo(id);
|
||||
@@ -72,33 +49,21 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
},
|
||||
|
||||
async bulkFetchFromIndexedDB(ids) {
|
||||
const files = await IndexedDB.bulkGetFileInfos([...ids]);
|
||||
const categories = await Promise.all(
|
||||
files.map(async (file) =>
|
||||
file?.categoryIds
|
||||
? await Promise.all(
|
||||
file.categoryIds.map(async (categoryId) => {
|
||||
const category = await IndexedDB.getCategoryInfo(categoryId);
|
||||
return category
|
||||
? { id: category.id, parentId: category.parentId, name: category.name }
|
||||
: undefined;
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const files = (await IndexedDB.bulkGetFileInfos([...ids])).filter((file) => !!file);
|
||||
return new Map(
|
||||
files
|
||||
.filter((file) => !!file)
|
||||
.map((file, index) => [
|
||||
file.id,
|
||||
{
|
||||
...file,
|
||||
exists: true,
|
||||
categories: categories[index]?.filter((category) => !!category) ?? [],
|
||||
},
|
||||
]),
|
||||
await Promise.all(
|
||||
files.map(
|
||||
async (file) =>
|
||||
[
|
||||
file.id,
|
||||
{
|
||||
...file,
|
||||
exists: true as const,
|
||||
categories: await getFileCategoryInfos(file.categoryIds),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -106,31 +71,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
const idsArray = [...ids.keys()];
|
||||
|
||||
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
|
||||
const files = await Promise.all(
|
||||
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
|
||||
const [categories, metadata] = await Promise.all([
|
||||
Promise.all(
|
||||
categoriesRaw.map(async (category) => ({
|
||||
id: category.id,
|
||||
parentId: category.parent,
|
||||
...(await decryptCategoryMetadata(category)),
|
||||
})),
|
||||
),
|
||||
decryptFileMetadata(metadataRaw),
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
exists: true as const,
|
||||
isLegacy: metadataRaw.isLegacy,
|
||||
parentId: metadataRaw.parent,
|
||||
contentType: metadataRaw.contentType,
|
||||
categories,
|
||||
isFavorite: metadataRaw.isFavorite,
|
||||
...metadata,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
|
||||
|
||||
const existingIds = new Set(filesRaw.map(({ id }) => id));
|
||||
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
|
||||
@@ -175,40 +116,18 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
|
||||
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
|
||||
};
|
||||
|
||||
export const getFileInfo = (
|
||||
id: number,
|
||||
options?: {
|
||||
serverResponse?: {
|
||||
parent: DirectoryId;
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
contentType: string;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
// Uses `serverResponse` instead of fetching the file from the server if given
|
||||
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
|
||||
const serverResponse = options?.serverResponse;
|
||||
return cache.get(id, {
|
||||
fetchFromServer:
|
||||
options?.serverResponse &&
|
||||
(async (cachedValue) => {
|
||||
const metadata = await decryptFileMetadata(options!.serverResponse!);
|
||||
return storeToIndexedDB({
|
||||
serverResponse &&
|
||||
(async (cachedValue) =>
|
||||
storeToIndexedDB({
|
||||
categories: [],
|
||||
...cachedValue,
|
||||
id,
|
||||
parentId: options!.serverResponse!.parent,
|
||||
contentType: options!.serverResponse!.contentType,
|
||||
isFavorite: options!.serverResponse!.isFavorite,
|
||||
...metadata,
|
||||
});
|
||||
}),
|
||||
...(await toFileInfo(serverResponse)),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -261,3 +180,17 @@ export const setFileFavorite = async (
|
||||
invalidateDirectory(file.parentId);
|
||||
invalidateFile(file.id);
|
||||
};
|
||||
|
||||
// The most recently created media files among the files stored locally
|
||||
export const getRecentMediaFileInfos = async (limit: number) => {
|
||||
const files = await IndexedDB.getAllFileInfos();
|
||||
return files
|
||||
.filter(
|
||||
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
|
||||
)
|
||||
.slice(0, limit);
|
||||
};
|
||||
@@ -77,3 +77,10 @@ export type SubCategoryInfo = Omit<
|
||||
"subCategories" | "files" | "isFileRecursive"
|
||||
>;
|
||||
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
|
||||
|
||||
export const isLocalDirectoryInfo = (
|
||||
info: MaybeDirectoryInfo,
|
||||
): info is LocalDirectoryInfo & { exists: true } => info.exists && info.id !== "root";
|
||||
|
||||
export const isExistingFileInfo = (info: MaybeFileInfo): info is FileInfo & { exists: true } =>
|
||||
info.exists;
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
isExistingFileInfo,
|
||||
isLocalDirectoryInfo,
|
||||
type FileInfo,
|
||||
type LocalCategoryInfo,
|
||||
type LocalDirectoryInfo,
|
||||
@@ -37,5 +39,8 @@ export const search = async (filter: SearchFilter) => {
|
||||
),
|
||||
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
|
||||
]);
|
||||
return { directories, files } as SearchResult;
|
||||
return {
|
||||
directories: directories.filter(isLocalDirectoryInfo),
|
||||
files: files.filter(isExistingFileInfo),
|
||||
} satisfies SearchResult;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
||||
|
||||
interface Props {
|
||||
progress?: number;
|
||||
rate?: number; // Bytes per second
|
||||
}
|
||||
|
||||
let { progress, rate }: Props = $props();
|
||||
</script>
|
||||
|
||||
전송됨 {Math.floor((progress ?? 0) * 100)}% · {formatNetworkSpeed((rate ?? 0) * 8)}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { FileUploadState } from "#lib/client/transfer/index.ts";
|
||||
import TransferProgress from "./TransferProgress.svelte";
|
||||
|
||||
interface Props {
|
||||
state: FileUploadState;
|
||||
}
|
||||
|
||||
let { state }: Props = $props();
|
||||
|
||||
const statusTexts = {
|
||||
queued: "대기 중",
|
||||
"encryption-pending": "준비 중",
|
||||
encrypting: "암호화하는 중",
|
||||
"upload-pending": "업로드를 기다리는 중",
|
||||
uploaded: "업로드 완료",
|
||||
canceled: "",
|
||||
error: "업로드 실패",
|
||||
} satisfies Record<Exclude<FileUploadState["status"], "uploading">, string>;
|
||||
</script>
|
||||
|
||||
{#if state.status === "uploading"}
|
||||
<TransferProgress progress={state.progress} rate={state.rate} />
|
||||
{:else}
|
||||
{statusTexts[state.status]}
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TransferProgress } from "./TransferProgress.svelte";
|
||||
export { default as UploadStatusText } from "./UploadStatusText.svelte";
|
||||
+11
-15
@@ -1,7 +1,7 @@
|
||||
import { ClientRepo, SessionRepo, IntegrityError } from "#lib/server/db/index.ts";
|
||||
import { ClientRepo, SessionRepo } from "#lib/server/db/index.ts";
|
||||
import env from "#lib/server/config.ts";
|
||||
import { verifySessionId } from "#lib/server/crypto.ts";
|
||||
import { AppError } from "#lib/server/errors.ts";
|
||||
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
|
||||
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
@@ -39,19 +39,15 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
|
||||
throw new AuthenticationError(400, "Invalid session id");
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId, clientId } = await SessionRepo.refreshSession(sessionId, ip, userAgent);
|
||||
return {
|
||||
id: sessionId,
|
||||
userId,
|
||||
clientId: clientId ?? undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Session not found") {
|
||||
throw new AuthenticationError(401, "Invalid session id");
|
||||
}
|
||||
throw e;
|
||||
const session = await SessionRepo.refreshSession(sessionId, ip, userAgent);
|
||||
if (!session) {
|
||||
throw new AuthenticationError(401, "Invalid session id");
|
||||
}
|
||||
return {
|
||||
id: sessionId,
|
||||
userId: session.userId,
|
||||
clientId: session.clientId ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const ensureUserClientState = async (
|
||||
@@ -65,7 +61,7 @@ const ensureUserClientState = async (
|
||||
|
||||
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
throw invalidSessionError();
|
||||
} else if (userClient.state !== state) {
|
||||
throw new AppError("FORBIDDEN", "Forbidden");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const errorCodes = {
|
||||
"Inactive MEK version": "BAD_REQUEST",
|
||||
// Session
|
||||
"Session not found": "UNAUTHORIZED",
|
||||
"Session already exists": "CONFLICT",
|
||||
"Already logged in": "CONFLICT",
|
||||
// Upload
|
||||
"Upload session not found": "NOT_FOUND",
|
||||
} as const satisfies Record<string, ErrorCode>;
|
||||
|
||||
@@ -40,10 +40,7 @@ export const refreshSession = async (
|
||||
.where("last_used_at", ">", new Date(now.getTime() - env.session.exp))
|
||||
.returning(["user_id", "client_id"])
|
||||
.executeTakeFirst();
|
||||
if (!session) {
|
||||
throw new IntegrityError("Session not found");
|
||||
}
|
||||
return { userId: session.user_id, clientId: session.client_id };
|
||||
return session ? { userId: session.user_id, clientId: session.client_id } : null;
|
||||
};
|
||||
|
||||
export const upgradeSession = async (
|
||||
@@ -74,7 +71,7 @@ export const upgradeSession = async (
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof pg.DatabaseError && e.code === "23505") {
|
||||
throw new IntegrityError("Session already exists");
|
||||
throw new IntegrityError("Already logged in");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,7 @@ export class AppError extends Error {
|
||||
return httpStatuses[this.code];
|
||||
}
|
||||
}
|
||||
|
||||
// The user or the client of an authenticated session doesn't exist, which shouldn't happen
|
||||
export const invalidSessionError = () =>
|
||||
new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
@@ -1,8 +1,8 @@
|
||||
import { error, type RequestEvent } from "@sveltejs/kit";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { AppError } from "#lib/server/errors.ts";
|
||||
|
||||
// Converts `AppError`s thrown by services into HTTP errors
|
||||
export const handleAppErrors = <Event extends RequestEvent>(
|
||||
export const handleAppErrors = <Event>(
|
||||
handler: (event: Event) => Promise<Response>,
|
||||
): ((event: Event) => Promise<Response>) => {
|
||||
return async (event) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error, redirect } from "@sveltejs/kit";
|
||||
import type { Handle } from "@sveltejs/kit/hooks";
|
||||
import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/auth.ts";
|
||||
import { getRedirectPath, withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
|
||||
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
||||
try {
|
||||
@@ -20,16 +21,14 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
||||
} else if (pathname.startsWith("/api")) {
|
||||
error(e.status, e.message);
|
||||
} else {
|
||||
redirect(302, "/auth/login?redirect=" + encodeURIComponent(pathname + search));
|
||||
redirect(302, withRedirectPath("/auth/login", pathname + search));
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (event.url.pathname === "/auth/login") {
|
||||
const redirectPath = event.url.searchParams.get("redirect");
|
||||
// Allow same-origin paths only
|
||||
redirect(302, redirectPath?.match(/^\/(?![/\\])/) ? redirectPath : "/home");
|
||||
redirect(302, getRedirectPath(event.url));
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import argon2 from "argon2";
|
||||
import { ClientRepo, IntegrityError, SessionRepo, UserRepo } from "#lib/server/db/index.ts";
|
||||
import { ClientRepo, SessionRepo, UserRepo } from "#lib/server/db/index.ts";
|
||||
import env from "#lib/server/config.ts";
|
||||
import { issueSessionId } from "#lib/server/crypto.ts";
|
||||
import { AppError } from "#lib/server/errors.ts";
|
||||
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
|
||||
import { createChallenge, verifyChallengeAnswer } from "./client.ts";
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
@@ -36,7 +36,7 @@ export const changePassword = async (
|
||||
|
||||
const user = await UserRepo.getUser(userId);
|
||||
if (!user) {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
throw invalidSessionError();
|
||||
} else if (!(await argon2.verify(user.password, oldPassword))) {
|
||||
throw new AppError("FORBIDDEN", "Invalid password");
|
||||
}
|
||||
@@ -83,17 +83,12 @@ export const verifySessionUpgrade = async (
|
||||
) => {
|
||||
const challenge = await SessionRepo.consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
|
||||
const client = await verifyChallengeAnswer(challenge, answerSig);
|
||||
await SessionRepo.upgradeSession(userId, sessionId, client.id, force);
|
||||
};
|
||||
|
||||
try {
|
||||
await SessionRepo.upgradeSession(userId, sessionId, client.id, force);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Session not found") {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid challenge answer");
|
||||
} else if (e.message === "Session already exists") {
|
||||
throw new AppError("CONFLICT", "Already logged in");
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
export const cleanupExpiredSessions = async () => {
|
||||
await Promise.all([
|
||||
SessionRepo.cleanupExpiredSessions(),
|
||||
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
|
||||
]);
|
||||
};
|
||||
@@ -74,3 +74,7 @@ export const verifyClient = async (
|
||||
const client = await verifyChallengeAnswer(challenge, answerSig);
|
||||
await ClientRepo.setUserClientStateToPending(userId, client.id);
|
||||
};
|
||||
|
||||
export const cleanupExpiredChallenges = async () => {
|
||||
await ClientRepo.cleanupExpiredUserClientChallenges();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ClientRepo, HskRepo, MekRepo } from "#lib/server/db/index.ts";
|
||||
import { verifySignature } from "#lib/server/crypto.ts";
|
||||
import { AppError } from "#lib/server/errors.ts";
|
||||
import { AppError, invalidSessionError } from "#lib/server/errors.ts";
|
||||
import { serializeMasterKeyWrapped } from "#lib/shared/crypto/index.ts";
|
||||
|
||||
export const getClientMeks = async (userId: number, clientId: number) => {
|
||||
return await MekRepo.getAllValidClientMeks(userId, clientId);
|
||||
@@ -15,10 +16,10 @@ const verifyClientEncMekSig = async (
|
||||
) => {
|
||||
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
throw invalidSessionError();
|
||||
}
|
||||
|
||||
const data = JSON.stringify({ version, key: encMek });
|
||||
const data = serializeMasterKeyWrapped(encMek, version);
|
||||
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UserRepo } from "#lib/server/db/index.ts";
|
||||
import { AppError } from "#lib/server/errors.ts";
|
||||
import { invalidSessionError } from "#lib/server/errors.ts";
|
||||
|
||||
export const getUser = async (userId: number) => {
|
||||
const user = await UserRepo.getUser(userId);
|
||||
if (!user) {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
throw invalidSessionError();
|
||||
}
|
||||
return user;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AES_GCM_IV_SIZE } from "#lib/shared/format/index.ts";
|
||||
import {
|
||||
encodeString,
|
||||
decodeString,
|
||||
@@ -7,6 +6,9 @@ import {
|
||||
concatenateBuffers,
|
||||
} from "./encoding.ts";
|
||||
|
||||
export const AES_GCM_IV_SIZE = 12;
|
||||
export const AES_GCM_TAG_SIZE = 16;
|
||||
|
||||
export const generateMasterKey = async () => {
|
||||
return {
|
||||
masterKey: await crypto.subtle.generateKey(
|
||||
@@ -44,10 +46,12 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
|
||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
|
||||
const wrapKey = async (key: CryptoKey, masterKey: CryptoKey) => {
|
||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", key, masterKey, "AES-KW"));
|
||||
};
|
||||
|
||||
export const wrapDataKey = wrapKey;
|
||||
|
||||
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
|
||||
return {
|
||||
dataKey: await crypto.subtle.unwrapKey(
|
||||
@@ -62,9 +66,7 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
|
||||
};
|
||||
};
|
||||
|
||||
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
|
||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
|
||||
};
|
||||
export const wrapHmacSecret = wrapKey;
|
||||
|
||||
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
|
||||
return {
|
||||
@@ -84,7 +86,7 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
|
||||
};
|
||||
|
||||
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
|
||||
@@ -166,15 +166,21 @@ export const verifySignatureRSA = async (
|
||||
);
|
||||
};
|
||||
|
||||
// The message signed by clients to prove the integrity of the wrapped master key. Also verified
|
||||
// by the server, so it must not be changed.
|
||||
export const serializeMasterKeyWrapped = (masterKeyWrapped: string, masterKeyVersion: number) => {
|
||||
return JSON.stringify({
|
||||
version: masterKeyVersion,
|
||||
key: masterKeyWrapped,
|
||||
});
|
||||
};
|
||||
|
||||
export const signMasterKeyWrapped = async (
|
||||
masterKeyWrapped: string,
|
||||
masterKeyVersion: number,
|
||||
signKey: CryptoKey,
|
||||
) => {
|
||||
const serialized = JSON.stringify({
|
||||
version: masterKeyVersion,
|
||||
key: masterKeyWrapped,
|
||||
});
|
||||
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
||||
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
||||
};
|
||||
|
||||
@@ -184,10 +190,7 @@ export const verifyMasterKeyWrapped = async (
|
||||
masterKeyWrappedSig: string,
|
||||
verifyKey: CryptoKey,
|
||||
) => {
|
||||
const serialized = JSON.stringify({
|
||||
version: masterKeyVersion,
|
||||
key: masterKeyWrapped,
|
||||
});
|
||||
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
||||
return await verifySignatureRSA(
|
||||
encodeString(serialized),
|
||||
decodeFromBase64(masterKeyWrappedSig),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AES_GCM_IV_SIZE, AES_GCM_TAG_SIZE } from "#lib/shared/crypto/index.ts";
|
||||
|
||||
// Files are encrypted in chunks of `CHUNK_SIZE` bytes, each of which is stored as
|
||||
// `IV || ciphertext || tag`. Legacy files are encrypted as a single chunk.
|
||||
|
||||
export const AES_GCM_IV_SIZE = 12;
|
||||
export const AES_GCM_TAG_SIZE = 16;
|
||||
export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
|
||||
|
||||
export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const DEFAULT_REDIRECT_PATH = "/home";
|
||||
|
||||
// Returns the path to return to after the flow at the URL (e.g. logging in) is done. Only
|
||||
// same-origin paths are allowed to prevent open redirects.
|
||||
export const getRedirectPath = (url: URL) => {
|
||||
const path = url.searchParams.get("redirect");
|
||||
return path && /^\/(?![/\\])/.test(path) ? path : DEFAULT_REDIRECT_PATH;
|
||||
};
|
||||
|
||||
export const withRedirectPath = (path: string, redirectPath: string) => {
|
||||
return `${path}?redirect=${encodeURIComponent(redirectPath)}`;
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decrypted-file/";
|
||||
|
||||
// Both the clients and the service worker keep the metadata of recently prepared files only
|
||||
export const MAX_PREPARED_FILES = 32;
|
||||
|
||||
export interface FileMetadata {
|
||||
isLegacy: boolean;
|
||||
dataKey: CryptoKey;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
interface Props {
|
||||
actionButtonClass?: ClassValue;
|
||||
@@ -23,11 +24,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
data-entry-container
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
class={["rounded-xl", className]}
|
||||
>
|
||||
<div data-entry-container onclick={afterPress(onclick)} class={["rounded-xl", className]}>
|
||||
<div data-entry-content class="flex h-full items-center gap-x-4 p-2 transition">
|
||||
<div class="grow overflow-x-hidden">
|
||||
{@render children()}
|
||||
@@ -37,9 +34,7 @@
|
||||
data-action-button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onActionButtonClick) {
|
||||
setTimeout(onActionButtonClick, 100);
|
||||
}
|
||||
afterPress(onActionButtonClick)?.();
|
||||
}}
|
||||
class={["shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -26,7 +27,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
onclick={afterPress(onclick)}
|
||||
class={[
|
||||
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
|
||||
bgColor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
||||
|
||||
@@ -13,10 +14,7 @@
|
||||
let { children, class: className, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
class={["rounded-xl active:bg-gray-100", className]}
|
||||
>
|
||||
<button onclick={afterPress(onclick)} class={["rounded-xl active:bg-gray-100", className]}>
|
||||
<div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
|
||||
<div class="grow">
|
||||
{@render children()}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||
import { AdaptiveDiv } from "#lib/ui/index.ts";
|
||||
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
interface Props {
|
||||
class: ClassValue;
|
||||
@@ -15,7 +16,7 @@
|
||||
<div class="pointer-events-none fixed inset-0">
|
||||
<AdaptiveDiv class="relative h-full">
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
onclick={afterPress(onclick)}
|
||||
class={[
|
||||
"pointer-events-auto absolute flex size-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400",
|
||||
className,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||
import { EntryButton, IconLabel } from "#lib/ui/index.ts";
|
||||
import IconLabel from "../labels/IconLabel.svelte";
|
||||
import EntryButton from "./EntryButton.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -10,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
onclick={afterPress(onclick)}
|
||||
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
|
||||
>
|
||||
<div class="h-full p-1 transition active:scale-95">
|
||||
|
||||
@@ -24,5 +24,6 @@ export * from "./overlays/overlay-group.svelte.ts";
|
||||
export { default as RenameModal } from "./overlays/RenameModal.svelte";
|
||||
export { default as TextInputModal } from "./overlays/TextInputModal.svelte";
|
||||
export * from "./overlays/toast.svelte.ts";
|
||||
export * from "./press.ts";
|
||||
export { default as Toasts } from "./overlays/Toasts.svelte";
|
||||
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { afterPress } from "../press.ts";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||
onclick={afterPress(onclick)}
|
||||
class={[
|
||||
"inline-flex cursor-pointer items-center gap-x-1 rounded-lg px-3 py-1.5 text-sm font-medium transition active:scale-95",
|
||||
selected
|
||||
@@ -42,9 +43,7 @@
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onRemoveClick) {
|
||||
setTimeout(onRemoveClick, 100);
|
||||
}
|
||||
afterPress(onRemoveClick)?.();
|
||||
}}
|
||||
>
|
||||
<IconClose />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { IconLabel } from "#lib/ui/index.ts";
|
||||
import IconLabel from "./IconLabel.svelte";
|
||||
|
||||
import IconCategory from "~icons/material-symbols/category";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { IconLabel } from "#lib/ui/index.ts";
|
||||
import IconLabel from "./IconLabel.svelte";
|
||||
|
||||
import IconFolder from "~icons/material-symbols/folder";
|
||||
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||
import { TitleLabel } from "#lib/ui/index.ts";
|
||||
import TitleLabel from "../labels/TitleLabel.svelte";
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { Button, Modal } from "#lib/ui/index.ts";
|
||||
import Button from "../buttons/Button.svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
cancelText?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { AdaptiveDiv } from "#lib/ui/index.ts";
|
||||
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
import { fade } from "svelte/transition";
|
||||
import { AdaptiveDiv } from "#lib/ui/index.ts";
|
||||
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { TextInputModal } from "#lib/ui/index.ts";
|
||||
import TextInputModal from "./TextInputModal.svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { TextInput, ActionModal, type ConfirmHandler } from "#lib/ui/index.ts";
|
||||
import TextInput from "../inputs/TextInput.svelte";
|
||||
import ActionModal, { type ConfirmHandler } from "./ActionModal.svelte";
|
||||
|
||||
interface Props {
|
||||
defaultValue?: string;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const PRESS_FEEDBACK_DELAY = 100;
|
||||
|
||||
// Delays the handler so that the press animation is visible before the screen changes
|
||||
export const afterPress = (handler: (() => void) | undefined) => {
|
||||
return handler && (() => void setTimeout(handler, PRESS_FEEDBACK_DELAY));
|
||||
};
|
||||
+6
-5
@@ -1,9 +1,10 @@
|
||||
import { defineParams } from "@sveltejs/kit/params";
|
||||
|
||||
const matchThumbnail = (param: string) => {
|
||||
return param === "thumbnail";
|
||||
};
|
||||
|
||||
export const params = defineParams({
|
||||
thumbnail: (param) => (matchThumbnail(param) ? param : undefined),
|
||||
// Positive integers such as the ids of files, directories, and categories
|
||||
int: (param) => {
|
||||
const value = Number(param);
|
||||
return /^[1-9]\d*$/.test(param) && Number.isSafeInteger(value) ? value : undefined;
|
||||
},
|
||||
thumbnail: (param) => (param === "thumbnail" ? param : undefined),
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import { login as requestLogin, logout as requestLogout } from "#lib/features/auth/index.ts";
|
||||
import { showErrorToast } from "#lib/features/errors.ts";
|
||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
||||
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import {
|
||||
BottomDiv,
|
||||
Button,
|
||||
@@ -27,10 +28,6 @@
|
||||
|
||||
let isForceLoginModalOpen = $state(false);
|
||||
|
||||
const redirect = async (url: string) => {
|
||||
return await goto(`${url}?redirect=${encodeURIComponent(data.redirectPath)}`);
|
||||
};
|
||||
|
||||
const upgradeSession = async (force: boolean) => {
|
||||
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force);
|
||||
if (!force && upgradeError === "Already logged in") {
|
||||
@@ -47,7 +44,7 @@
|
||||
await cleanupDeletedFileCaches().catch(console.error);
|
||||
await goto(data.redirectPath);
|
||||
} else {
|
||||
await redirect("/client/pending");
|
||||
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +65,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keyring.clientKeys) return await redirect("/key/generate");
|
||||
if (!keyring.clientKeys)
|
||||
return await goto(withRedirectPath("/key/generate", data.redirectPath));
|
||||
|
||||
await upgradeSession(false);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
||||
return { redirectPath };
|
||||
return { redirectPath: getRedirectPath(url) };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
||||
return { redirectPath };
|
||||
return { redirectPath: getRedirectPath(url) };
|
||||
};
|
||||
+2
-4
@@ -2,6 +2,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { convertHeic } from "#lib/client/media/heic.ts";
|
||||
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.ts";
|
||||
import { saveDecryptedFile } from "#lib/client/service-worker.ts";
|
||||
import { getFileDownloadState } from "#lib/client/transfer/index.ts";
|
||||
@@ -48,10 +49,7 @@
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(fileUrl)).blob();
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
convertedImageUrl = URL.createObjectURL(
|
||||
(await heic2any({ blob, toType: "image/jpeg" })) as Blob,
|
||||
);
|
||||
convertedImageUrl = URL.createObjectURL(await convertHeic(blob, "image/jpeg"));
|
||||
} catch (e) {
|
||||
showErrorToast(e, "이미지를 표시하지 못했어요.");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return { id: params.id };
|
||||
};
|
||||
File renamed without changes.
+2
-3
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { isFileDownloading, type FileDownloadState } from "#lib/client/transfer/index.ts";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
||||
import { TransferProgress } from "#lib/features/transfer/components/index.ts";
|
||||
|
||||
interface Props {
|
||||
state: FileDownloadState;
|
||||
@@ -13,8 +13,7 @@
|
||||
<div class="w-full rounded-xl bg-gray-100 p-3">
|
||||
<p class="font-medium">다운로드하는 중</p>
|
||||
<p class="text-xs">
|
||||
전송됨
|
||||
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
|
||||
<TransferProgress progress={state.progress} rate={state.rate} />
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
File renamed without changes.
@@ -1,15 +0,0 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(404, "Not found");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
return { id };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { FileDownloadState } from "#lib/client/transfer/index.ts";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
||||
import { TransferProgress } from "#lib/features/transfer/components/index.ts";
|
||||
|
||||
import IconCloudDownload from "~icons/material-symbols/cloud-download";
|
||||
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
||||
@@ -31,9 +31,7 @@
|
||||
</p>
|
||||
<p class="text-xs text-gray-800">
|
||||
{#if state.status === "downloading"}
|
||||
전송됨
|
||||
{Math.floor((state.progress ?? 0) * 100)}% ·
|
||||
{formatNetworkSpeed((state.rate ?? 0) * 8)}
|
||||
<TransferProgress progress={state.progress} rate={state.rate} />
|
||||
{:else if state.status === "completed"}
|
||||
다운로드 완료
|
||||
{:else if state.status === "error"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { FileUploadState } from "#lib/client/transfer/index.ts";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
||||
import { UploadStatusText } from "#lib/features/transfer/components/index.ts";
|
||||
|
||||
import IconPending from "~icons/material-symbols/pending";
|
||||
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||
@@ -37,22 +37,7 @@
|
||||
{state.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-800">
|
||||
{#if state.status === "queued"}
|
||||
대기 중
|
||||
{:else if state.status === "encryption-pending"}
|
||||
준비 중
|
||||
{:else if state.status === "encrypting"}
|
||||
암호화하는 중
|
||||
{:else if state.status === "upload-pending"}
|
||||
업로드를 기다리는 중
|
||||
{:else if state.status === "uploading"}
|
||||
전송됨
|
||||
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
|
||||
{:else if state.status === "uploaded"}
|
||||
업로드 완료
|
||||
{:else if state.status === "error"}
|
||||
업로드 실패
|
||||
{/if}
|
||||
<UploadStatusText {state} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@
|
||||
requestSessionUpgrade,
|
||||
serializeClientKeys,
|
||||
} from "#lib/client/keys/index.ts";
|
||||
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import {
|
||||
BottomDiv,
|
||||
Button,
|
||||
@@ -58,7 +59,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
} from "#lib/client/keys/index.ts";
|
||||
import { ForceLoginModal } from "#lib/features/auth/components/index.ts";
|
||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
||||
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import {
|
||||
BottomDiv,
|
||||
Button,
|
||||
@@ -87,7 +88,7 @@
|
||||
}
|
||||
|
||||
await cleanupDeletedFileCaches().catch(console.error);
|
||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||
};
|
||||
|
||||
const importKeys = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
||||
return { redirectPath };
|
||||
return { redirectPath: getRedirectPath(url) };
|
||||
};
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { FileCacheIndex } from "#lib/client/storage/indexed-db/index.ts";
|
||||
import type { MaybeFileInfo } from "#lib/features/filesystem/index.ts";
|
||||
import { afterPress } from "#lib/ui/index.ts";
|
||||
import { formatDate, formatFileSize } from "#lib/utils/index.ts";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
@@ -40,7 +41,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => setTimeout(() => onDeleteClick(index.fileId), 100)}
|
||||
onclick={afterPress(() => onDeleteClick(index.fileId))}
|
||||
class="shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||
>
|
||||
<IconDelete class="text-lg text-gray-600" />
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return { id: params.id ?? ("root" as const) };
|
||||
};
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,17 +0,0 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(404, "Not found");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
return {
|
||||
id: id ? id : ("root" as const),
|
||||
};
|
||||
};
|
||||
+14
-4
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { getDownloadingFiles, getUploadingFiles } from "#lib/client/transfer/index.ts";
|
||||
import { tryAction } from "#lib/features/errors.ts";
|
||||
import { createDirectory, uploadFile, useDirectory } from "#lib/features/filesystem/index.ts";
|
||||
import { FloatingButton, OverlayGroup, TopBar } from "#lib/ui/index.ts";
|
||||
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
|
||||
import DirectoryEntries from "./directory-entries/index.ts";
|
||||
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||
import EntryCreateBottomSheet from "./EntryCreateBottomSheet.svelte";
|
||||
import EntryDeleteModal from "./EntryDeleteModal.svelte";
|
||||
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
|
||||
import EntryRenameModal from "./EntryRenameModal.svelte";
|
||||
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||
import TransferStatusCard from "./TransferStatusCard.svelte";
|
||||
import {
|
||||
createContext,
|
||||
deleteEntry,
|
||||
@@ -89,8 +89,18 @@
|
||||
{#if info?.exists}
|
||||
<div class="flex grow flex-col px-4 pb-4">
|
||||
<div class="flex gap-x-2">
|
||||
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
||||
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
||||
<TransferStatusCard
|
||||
label="진행 중인 업로드"
|
||||
count={getUploadingFiles().length}
|
||||
color="blue"
|
||||
onclick={() => goto("/file/uploads")}
|
||||
/>
|
||||
<TransferStatusCard
|
||||
label="진행 중인 다운로드"
|
||||
count={getDownloadingFiles().length}
|
||||
color="green"
|
||||
onclick={() => goto("/file/downloads")}
|
||||
/>
|
||||
</div>
|
||||
{#key info.id}
|
||||
<DirectoryEntries
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return { id: params.id ?? ("root" as const) };
|
||||
};
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { afterPress } from "#lib/ui/index.ts";
|
||||
|
||||
interface Props {
|
||||
color: "blue" | "green";
|
||||
count: number;
|
||||
label: string;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { color, count, label, onclick }: Props = $props();
|
||||
|
||||
const colorClasses = {
|
||||
blue: { button: "bg-blue-100 active:bg-blue-200", count: "text-blue-800" },
|
||||
green: { button: "bg-green-100 active:bg-green-200", count: "text-green-800" },
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<button
|
||||
onclick={afterPress(onclick)}
|
||||
class={["mb-4 max-w-[50%] flex-1 rounded-xl p-3", colorClasses[color].button]}
|
||||
>
|
||||
<div class="text-left transition active:scale-95">
|
||||
<p class="text-xs text-gray-800">{label}</p>
|
||||
<p class={["font-medium", colorClasses[color].count]}>{count}개</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
+1
-1
@@ -49,7 +49,7 @@
|
||||
...sortEntries(info.subDirectories.map(toEntry("directory"))),
|
||||
...sortEntries([
|
||||
...info.files.map(toEntry("file")),
|
||||
...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
|
||||
...getUploadingFiles(info.id).map(toEntry("uploading-file")),
|
||||
]),
|
||||
]);
|
||||
</script>
|
||||
+2
-13
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { LiveFileUploadState } from "#lib/client/transfer/index.ts";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.ts";
|
||||
import { UploadStatusText } from "#lib/features/transfer/components/index.ts";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
|
||||
@@ -20,18 +20,7 @@
|
||||
{state.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{#if state.status === "queued"}
|
||||
대기 중
|
||||
{:else if state.status === "encryption-pending"}
|
||||
준비 중
|
||||
{:else if state.status === "encrypting"}
|
||||
암호화하는 중
|
||||
{:else if state.status === "upload-pending"}
|
||||
업로드를 기다리는 중
|
||||
{:else if state.status === "uploading"}
|
||||
전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
|
||||
{formatNetworkSpeed((state.rate ?? 0) * 8)}
|
||||
{/if}
|
||||
<UploadStatusText {state} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,17 +0,0 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(404, "Not found");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
return {
|
||||
id: id ? id : ("root" as const),
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getDownloadingFiles } from "#lib/client/transfer/index.ts";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { onclick }: Props = $props();
|
||||
|
||||
let downloadingFiles = $derived(getDownloadingFiles());
|
||||
</script>
|
||||
|
||||
{#if downloadingFiles.length > 0}
|
||||
<button
|
||||
onclick={() => setTimeout(onclick, 100)}
|
||||
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-green-100 p-3 active:bg-green-200"
|
||||
>
|
||||
<div class="text-left transition active:scale-95">
|
||||
<p class="text-xs text-gray-800">진행 중인 다운로드</p>
|
||||
<p class="font-medium text-green-800">{downloadingFiles.length}개</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getUploadingFiles } from "#lib/client/transfer/index.ts";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { onclick }: Props = $props();
|
||||
|
||||
let uploadingFiles = $derived(getUploadingFiles());
|
||||
</script>
|
||||
|
||||
{#if uploadingFiles.length > 0}
|
||||
<button
|
||||
onclick={() => setTimeout(onclick, 100)}
|
||||
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-blue-100 p-3 active:bg-blue-200"
|
||||
>
|
||||
<div class="text-left transition active:scale-95">
|
||||
<p class="text-xs text-gray-800">진행 중인 업로드</p>
|
||||
<p class="font-medium text-blue-800">{uploadingFiles.length}개</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -2,14 +2,16 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { FileThumbnailButton } from "#lib/features/filesystem/components/index.ts";
|
||||
import { type SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
|
||||
import {
|
||||
getRecentMediaFileInfos,
|
||||
type SummarizedFileInfo,
|
||||
} from "#lib/features/filesystem/index.ts";
|
||||
import { EntryButton } from "#lib/ui/index.ts";
|
||||
import { requestFreshMediaFilesRetrieval } from "./service.ts";
|
||||
|
||||
let mediaFiles: SummarizedFileInfo[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
mediaFiles = await requestFreshMediaFilesRetrieval();
|
||||
mediaFiles = await getRecentMediaFileInfos(8);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getAllFileInfos } from "#lib/client/storage/indexed-db/index.ts";
|
||||
|
||||
export const requestFreshMediaFilesRetrieval = async (limit = 8) => {
|
||||
const files = await getAllFileInfos();
|
||||
files.sort(
|
||||
(a, b) =>
|
||||
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
|
||||
);
|
||||
return files
|
||||
.filter(
|
||||
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
|
||||
)
|
||||
.slice(0, limit);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { keyring } from "#lib/client/keys/index.ts";
|
||||
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const ssr = false;
|
||||
@@ -9,10 +10,10 @@ const KEYLESS_PATHS = ["/auth/login", "/key", "/client/pending"];
|
||||
export const load: LayoutLoad = ({ url }) => {
|
||||
if (KEYLESS_PATHS.some((path) => url.pathname.startsWith(path))) return;
|
||||
|
||||
const redirectQuery = `?redirect=${encodeURIComponent(url.pathname + url.search)}`;
|
||||
const redirectPath = url.pathname + url.search;
|
||||
if (!keyring.clientKeys) {
|
||||
redirect(307, "/key/generate" + redirectQuery);
|
||||
redirect(307, withRedirectPath("/key/generate", redirectPath));
|
||||
} else if (!keyring.hasMasterKeys) {
|
||||
redirect(307, "/client/pending" + redirectQuery);
|
||||
redirect(307, withRedirectPath("/client/pending", redirectPath));
|
||||
}
|
||||
};
|
||||
+1
-10
@@ -1,5 +1,3 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "#lib/server/auth.ts";
|
||||
import { handleAppErrors } from "#lib/server/http.ts";
|
||||
import { FileService } from "#lib/server/services/index.ts";
|
||||
@@ -8,14 +6,7 @@ import type { RequestEvent, RequestHandler } from "./$types";
|
||||
|
||||
const downloadHandler = async ({ locals, params, request }: RequestEvent) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
const { id } = params;
|
||||
|
||||
const getStream = params.thumbnail
|
||||
? FileService.getFileThumbnailStream
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { FileMetadata } from "#lib/shared/service-worker.ts";
|
||||
|
||||
const MAX_FILE_METADATA_ENTRIES = 32;
|
||||
import { MAX_PREPARED_FILES, type FileMetadata } from "#lib/shared/service-worker.ts";
|
||||
|
||||
const fileMetadata = new Map<number, FileMetadata>();
|
||||
|
||||
@@ -18,7 +16,7 @@ export const fileMetadataStore = {
|
||||
fileMetadata.delete(fileId);
|
||||
fileMetadata.set(fileId, metadata);
|
||||
|
||||
while (fileMetadata.size > MAX_FILE_METADATA_ENTRIES) {
|
||||
while (fileMetadata.size > MAX_PREPARED_FILES) {
|
||||
fileMetadata.delete(fileMetadata.keys().next().value!);
|
||||
}
|
||||
},
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { observable } from "@trpc/server/observable";
|
||||
import superjson from "superjson";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigating } from "$app/state";
|
||||
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||
import type { AppRouter } from "./router.server.ts";
|
||||
|
||||
// Procedures that report invalid credentials as UNAUTHORIZED, not an expired session
|
||||
@@ -21,7 +22,7 @@ const sessionExpiryLink: TRPCLink<AppRouter> = () => {
|
||||
!CREDENTIAL_PROCEDURES.has(op.path) &&
|
||||
url.pathname !== "/auth/login"
|
||||
) {
|
||||
void goto(`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
void goto(withRedirectPath("/auth/login", url.pathname + url.search));
|
||||
}
|
||||
observer.error(err);
|
||||
},
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
import type { Category } from "#lib/server/db/repositories/category.ts";
|
||||
import type { Directory } from "#lib/server/db/repositories/directory.ts";
|
||||
import type { File } from "#lib/server/db/repositories/file.ts";
|
||||
import type { CategoryRepo, DirectoryRepo, FileRepo } from "#lib/server/db/index.ts";
|
||||
|
||||
type Category = CategoryRepo.Category;
|
||||
type Directory = DirectoryRepo.Directory;
|
||||
type File = FileRepo.File;
|
||||
|
||||
// Shapes of filesystem entries exposed by the API
|
||||
|
||||
|
||||
@@ -30,15 +30,9 @@ export const publicProcedure = t.procedure.use(async ({ next }) => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.locals.session) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
// `authorize` also rejects unauthenticated requests
|
||||
const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
||||
authedProcedure.use(async ({ ctx, next }) => {
|
||||
publicProcedure.use(async ({ ctx, next }) => {
|
||||
const session: Session = await authorize(ctx.locals, requiredPermission);
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
@@ -46,7 +40,7 @@ const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
||||
const clientSessionProcedure = (
|
||||
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
||||
) =>
|
||||
authedProcedure.use(async ({ ctx, next }) => {
|
||||
publicProcedure.use(async ({ ctx, next }) => {
|
||||
const session: ClientSession = await authorize(ctx.locals, requiredPermission);
|
||||
return next({ ctx: { session } });
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const IdSchema = z.int().positive();
|
||||
export const DirectoryIdSchema = z.union([z.literal("root"), IdSchema]);
|
||||
export const CategoryIdSchema = z.union([z.literal("root"), IdSchema]);
|
||||
export const CategoryIdSchema = DirectoryIdSchema;
|
||||
export const Base64Schema = z.base64().nonempty();
|
||||
|
||||
export const EncryptedNameSchema = {
|
||||
|
||||
Reference in new issue
Block a user