리다이렉트 경로 처리, 파일 정보 변환, 상태 표시 UI 등 중복된 코드를 통합하고 모듈 간 순환 의존성 및 계층 위반 해소

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 08:51:07 +09:00
1 parent 9119c21881
commit 5b8621e524
96 files changed
+452 -548

No files matched your search

+8 -5
View File
@@ -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) => {
+5
View File
@@ -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;
};
+4 -7
View File
@@ -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 {
+3 -1
View File
@@ -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 = () => {
+19 -19
View File
@@ -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(
+8 -8
View File
@@ -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 })),
),
];
};
+11 -28
View 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}
+14 -58
View File
@@ -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)),
})),
});
};
+46
View File
@@ -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 };
};
+51 -118
View File
@@ -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);
};
+7
View File
@@ -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;
+6 -1
View File
@@ -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
View File
@@ -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");
}
+1 -1
View File
@@ -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>;
+2 -5
View File
@@ -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;
}
+4
View File
@@ -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");
+2 -2
View File
@@ -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) => {
+3 -4
View File
@@ -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);
+10 -15
View File
@@ -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(),
]);
};
+4
View File
@@ -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();
};
+4 -3
View File
@@ -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);
};
+2 -2
View File
@@ -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;
};
+9 -7
View File
@@ -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",
+11 -8
View File
@@ -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),
+2 -2
View File
@@ -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
+12
View File
@@ -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)}`;
};
+3
View File
@@ -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;
+3 -8
View File
@@ -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]}
>
+2 -1
View File
@@ -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,
+2 -4
View File
@@ -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()}
+3 -2
View File
@@ -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,
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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">
+1
View File
@@ -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";
+3 -4
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { TextInputModal } from "#lib/ui/index.ts";
import TextInputModal from "./TextInputModal.svelte";
interface Props {
isOpen: boolean;
+2 -1
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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);
};
+2 -2
View File
@@ -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,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 };
};
@@ -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}
@@ -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
View File
@@ -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" />
@@ -0,0 +1,5 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ params }) => {
return { id: params.id ?? ("root" as const) };
};
@@ -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,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) };
};
@@ -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}
@@ -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>
@@ -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>
@@ -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}
+5 -3
View File
@@ -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>
-14
View File
@@ -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);
};
+4 -3
View File
@@ -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,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
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -9
View File
@@ -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
View File
@@ -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 = {