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 { sequence, type ServerInit } from "@sveltejs/kit/hooks";
|
||||||
import schedule from "node-schedule";
|
import schedule from "node-schedule";
|
||||||
import { ClientRepo, SessionRepo } from "#lib/server/db/index.ts";
|
|
||||||
import { migrateDB } from "#lib/server/db/kysely.ts";
|
import { migrateDB } from "#lib/server/db/kysely.ts";
|
||||||
import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.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 () => {
|
export const init: ServerInit = async () => {
|
||||||
await migrateDB();
|
await migrateDB();
|
||||||
@@ -11,9 +15,8 @@ export const init: ServerInit = async () => {
|
|||||||
schedule.scheduleJob("0 * * * *", async () => {
|
schedule.scheduleJob("0 * * * *", async () => {
|
||||||
// Failures are logged and retried at the next run, so they don't crash the server
|
// Failures are logged and retried at the next run, so they don't crash the server
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
ClientRepo.cleanupExpiredUserClientChallenges(),
|
AuthService.cleanupExpiredSessions(),
|
||||||
SessionRepo.cleanupExpiredSessions(),
|
ClientService.cleanupExpiredChallenges(),
|
||||||
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
|
|
||||||
UploadService.cleanupExpiredUploadSessions(),
|
UploadService.cleanupExpiredUploadSessions(),
|
||||||
]);
|
]);
|
||||||
results.forEach((result) => {
|
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) => {
|
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||||
if (width <= targetSize || height <= targetSize) {
|
if (width <= targetSize || height <= targetSize) {
|
||||||
return { width, height };
|
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.
|
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
|
||||||
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
|
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -125,8 +122,8 @@ export const generateThumbnailFromUrl = async (url: string, contentType: string)
|
|||||||
} catch {
|
} catch {
|
||||||
if (contentType !== "image/heic") return null;
|
if (contentType !== "image/heic") return null;
|
||||||
|
|
||||||
// Chromium can't decode HEIC images
|
const blob = await (await fetch(url)).blob();
|
||||||
const png = URL.createObjectURL(await convertHeicToPng(await (await fetch(url)).blob()));
|
const png = URL.createObjectURL(await convertHeic(blob, "image/png"));
|
||||||
try {
|
try {
|
||||||
return await generateImageThumbnail(png);
|
return await generateImageThumbnail(png);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
|
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts";
|
||||||
import {
|
import {
|
||||||
DECRYPTED_FILE_URL_PREFIX,
|
DECRYPTED_FILE_URL_PREFIX,
|
||||||
|
MAX_PREPARED_FILES,
|
||||||
type FileMetadata,
|
type FileMetadata,
|
||||||
type ServiceWorkerMessage,
|
type ServiceWorkerMessage,
|
||||||
type ServiceWorkerResponse,
|
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.
|
// 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.
|
// 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;
|
let isListening = false;
|
||||||
|
|
||||||
export const listenForServiceWorkerMessages = () => {
|
export const listenForServiceWorkerMessages = () => {
|
||||||
|
|||||||
@@ -20,43 +20,43 @@ import { getChunkCount } from "#lib/shared/format/index.ts";
|
|||||||
import { Scheduler } from "#lib/utils/index.ts";
|
import { Scheduler } from "#lib/utils/index.ts";
|
||||||
import { trpc } from "#trpc/client.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 {
|
export interface FileUploadState {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentId: DirectoryId;
|
parentId: DirectoryId;
|
||||||
status:
|
status: LiveStatus | "uploaded" | "canceled" | "error";
|
||||||
| "queued"
|
|
||||||
| "encryption-pending"
|
|
||||||
| "encrypting"
|
|
||||||
| "upload-pending"
|
|
||||||
| "uploading"
|
|
||||||
| "uploaded"
|
|
||||||
| "canceled"
|
|
||||||
| "error";
|
|
||||||
progress?: number;
|
progress?: number;
|
||||||
rate?: number;
|
rate?: number;
|
||||||
estimated?: number;
|
estimated?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LiveFileUploadState = FileUploadState & {
|
export type LiveFileUploadState = FileUploadState & { status: LiveStatus };
|
||||||
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
|
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
|
||||||
let uploadingFiles: FileUploadState[] = $state([]);
|
let uploadingFiles: FileUploadState[] = $state([]);
|
||||||
|
|
||||||
const isFileUploading = (status: FileUploadState["status"]) =>
|
const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => {
|
||||||
["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
return (LIVE_STATUSES as readonly string[]).includes(file.status);
|
||||||
|
};
|
||||||
|
|
||||||
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
||||||
return uploadingFiles.filter(
|
return uploadingFiles
|
||||||
(file) =>
|
.filter(isFileUploading)
|
||||||
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
|
.filter((file) => parentId === undefined || file.parentId === parentId);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearUploadedFiles = () => {
|
export const clearUploadedFiles = () => {
|
||||||
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
|
uploadingFiles = uploadingFiles.filter(isFileUploading);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestDuplicateFileScan = limitFunction(
|
const requestDuplicateFileScan = limitFunction(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
getDirectoryInfo,
|
getDirectoryInfo,
|
||||||
getFileInfo,
|
getFileInfo,
|
||||||
|
isExistingFileInfo,
|
||||||
|
isLocalDirectoryInfo,
|
||||||
setDirectoryFavorite,
|
setDirectoryFavorite,
|
||||||
setFileFavorite,
|
setFileFavorite,
|
||||||
type LocalDirectoryInfo,
|
type LocalDirectoryInfo,
|
||||||
@@ -29,18 +31,16 @@ export const getFavoriteEntries = async () => {
|
|||||||
]);
|
]);
|
||||||
return [
|
return [
|
||||||
...sortEntries(
|
...sortEntries(
|
||||||
directories.map((directory): FavoriteEntry => ({
|
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
|
||||||
type: "directory",
|
type: "directory",
|
||||||
name: directory.name!,
|
name: directory.name,
|
||||||
details: directory as LocalDirectoryInfo,
|
details: directory,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
...sortEntries(
|
...sortEntries(
|
||||||
files.map((file): FavoriteEntry => ({
|
files
|
||||||
type: "file",
|
.filter(isExistingFileInfo)
|
||||||
name: file.name!,
|
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
|
||||||
details: file as SummarizedFileInfo,
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
|||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
import { FilesystemCache } from "./cache.svelte.ts";
|
||||||
import { invalidateFile } from "./file.ts";
|
import { invalidateFile } from "./file.ts";
|
||||||
import {
|
import { toCategoryInfo, toFileInfo } from "./dto.ts";
|
||||||
decryptCategoryMetadata,
|
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
||||||
decryptFileMetadata,
|
|
||||||
encryptName,
|
|
||||||
generateEntryKeys,
|
|
||||||
} from "./metadata.ts";
|
|
||||||
import { useResource } from "./resource.svelte.ts";
|
import { useResource } from "./resource.svelte.ts";
|
||||||
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
|
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
|
||||||
|
|
||||||
@@ -60,38 +56,25 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
|||||||
try {
|
try {
|
||||||
const category = await trpc.category.get.query({ id, recurse: true });
|
const category = await trpc.category.get.query({ id, recurse: true });
|
||||||
const [subCategories, files, metadata] = await Promise.all([
|
const [subCategories, files, metadata] = await Promise.all([
|
||||||
|
Promise.all(category.subCategories.map(toCategoryInfo)),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
category.subCategories.map(async (category) => ({
|
(category.files ?? []).map(async (file) => ({
|
||||||
id: category.id,
|
...(await toFileInfo(file)),
|
||||||
parentId: id,
|
isRecursive: file.isRecursive,
|
||||||
...(await decryptCategoryMetadata(category)),
|
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
category.files &&
|
category.metadata && toCategoryInfo(category.metadata),
|
||||||
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),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return storeToIndexedDB(
|
return storeToIndexedDB(
|
||||||
id !== "root"
|
metadata
|
||||||
? {
|
? {
|
||||||
id,
|
...metadata,
|
||||||
parentId: category.metadata!.parent,
|
|
||||||
subCategories,
|
subCategories,
|
||||||
files: files!,
|
files,
|
||||||
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
|
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
|
||||||
...metadata!,
|
|
||||||
}
|
}
|
||||||
: { id, subCategories },
|
: { id: "root", subCategories },
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getFileThumbnail } from "#lib/client/cache/index.ts";
|
import { getFileThumbnail } from "#lib/client/cache/index.ts";
|
||||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/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";
|
import IconFavorite from "~icons/material-symbols/favorite";
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<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"
|
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
|
||||||
>
|
>
|
||||||
{#if thumbnail?.url}
|
{#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 * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
|
||||||
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
import { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
import { FilesystemCache } from "./cache.svelte.ts";
|
||||||
import {
|
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
|
||||||
decryptDirectoryMetadata,
|
import { encryptName, generateEntryKeys } from "./metadata.ts";
|
||||||
decryptFileMetadata,
|
|
||||||
encryptName,
|
|
||||||
generateEntryKeys,
|
|
||||||
} from "./metadata.ts";
|
|
||||||
import { useResource } from "./resource.svelte.ts";
|
import { useResource } from "./resource.svelte.ts";
|
||||||
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
|
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
|
||||||
|
|
||||||
@@ -43,37 +39,13 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
|||||||
try {
|
try {
|
||||||
const directory = await trpc.directory.get.query({ id });
|
const directory = await trpc.directory.get.query({ id });
|
||||||
const [subDirectories, files, metadata] = await Promise.all([
|
const [subDirectories, files, metadata] = await Promise.all([
|
||||||
Promise.all(
|
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
|
||||||
directory.subDirectories.map(async (directory) => ({
|
Promise.all(directory.files.map(toFileInfo)),
|
||||||
id: directory.id,
|
directory.metadata && toDirectoryInfo(directory.metadata),
|
||||||
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),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return storeToIndexedDB(
|
return storeToIndexedDB(
|
||||||
id !== "root"
|
metadata ? { ...metadata, subDirectories, files } : { id: "root", subDirectories, files },
|
||||||
? {
|
|
||||||
id,
|
|
||||||
parentId: directory.metadata!.parent,
|
|
||||||
subDirectories,
|
|
||||||
files,
|
|
||||||
isFavorite: directory.metadata!.isFavorite,
|
|
||||||
...metadata!,
|
|
||||||
}
|
|
||||||
: { id, subDirectories, files },
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
@@ -109,35 +81,19 @@ const storeToIndexedDB = (info: DirectoryInfo) => {
|
|||||||
return { ...info, exists: true as const };
|
return { ...info, exists: true as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDirectoryInfo = (
|
// Uses `serverResponse` instead of fetching the directory from the server if given
|
||||||
id: DirectoryId,
|
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
|
||||||
options?: {
|
const serverResponse = options?.serverResponse;
|
||||||
serverResponse?: {
|
|
||||||
parent: DirectoryId;
|
|
||||||
mekVersion: number;
|
|
||||||
dek: string;
|
|
||||||
dekVersion: Date;
|
|
||||||
name: string;
|
|
||||||
nameIv: string;
|
|
||||||
isFavorite: boolean;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
return cache.get(id, {
|
return cache.get(id, {
|
||||||
fetchFromServer:
|
fetchFromServer:
|
||||||
options?.serverResponse &&
|
serverResponse &&
|
||||||
(async (cachedValue) => {
|
(async (cachedValue) =>
|
||||||
const metadata = await decryptDirectoryMetadata(options!.serverResponse!);
|
storeToIndexedDB({
|
||||||
return storeToIndexedDB({
|
|
||||||
subDirectories: [],
|
subDirectories: [],
|
||||||
files: [],
|
files: [],
|
||||||
...cachedValue,
|
...cachedValue,
|
||||||
id: id as number,
|
...(await toDirectoryInfo(serverResponse)),
|
||||||
parentId: options!.serverResponse!.parent,
|
})),
|
||||||
isFavorite: options!.serverResponse!.isFavorite,
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { isTRPCClientError, trpc } from "#trpc/client.ts";
|
||||||
import { FilesystemCache } from "./cache.svelte.ts";
|
import { FilesystemCache } from "./cache.svelte.ts";
|
||||||
import { invalidateDirectory } from "./directory.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 { useResource } from "./resource.svelte.ts";
|
||||||
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.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>({
|
const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||||
async fetchFromIndexedDB(id) {
|
async fetchFromIndexedDB(id) {
|
||||||
const file = await IndexedDB.getFileInfo(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) {
|
if (file) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -30,7 +30,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
|||||||
name: file.name,
|
name: file.name,
|
||||||
createdAt: file.createdAt,
|
createdAt: file.createdAt,
|
||||||
lastModifiedAt: file.lastModifiedAt,
|
lastModifiedAt: file.lastModifiedAt,
|
||||||
categories: categories?.filter((category) => !!category) ?? [],
|
categories: await getFileCategoryInfos(file.categoryIds),
|
||||||
isFavorite: file.isFavorite,
|
isFavorite: file.isFavorite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -38,30 +38,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
|||||||
|
|
||||||
async fetchFromServer(id) {
|
async fetchFromServer(id) {
|
||||||
try {
|
try {
|
||||||
const file = await trpc.file.get.query({ id });
|
return storeToIndexedDB(await toFileWithCategoriesInfo(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,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
await IndexedDB.deleteFileInfo(id);
|
await IndexedDB.deleteFileInfo(id);
|
||||||
@@ -72,33 +49,21 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkFetchFromIndexedDB(ids) {
|
async bulkFetchFromIndexedDB(ids) {
|
||||||
const files = await IndexedDB.bulkGetFileInfos([...ids]);
|
const files = (await IndexedDB.bulkGetFileInfos([...ids])).filter((file) => !!file);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Map(
|
return new Map(
|
||||||
files
|
await Promise.all(
|
||||||
.filter((file) => !!file)
|
files.map(
|
||||||
.map((file, index) => [
|
async (file) =>
|
||||||
file.id,
|
[
|
||||||
{
|
file.id,
|
||||||
...file,
|
{
|
||||||
exists: true,
|
...file,
|
||||||
categories: categories[index]?.filter((category) => !!category) ?? [],
|
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 idsArray = [...ids.keys()];
|
||||||
|
|
||||||
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
|
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
|
||||||
const files = await Promise.all(
|
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
|
||||||
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 existingIds = new Set(filesRaw.map(({ id }) => id));
|
const existingIds = new Set(filesRaw.map(({ id }) => id));
|
||||||
const deletedIds = idsArray.filter((id) => !existingIds.has(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);
|
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileInfo = (
|
// Uses `serverResponse` instead of fetching the file from the server if given
|
||||||
id: number,
|
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
|
||||||
options?: {
|
const serverResponse = options?.serverResponse;
|
||||||
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;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
return cache.get(id, {
|
return cache.get(id, {
|
||||||
fetchFromServer:
|
fetchFromServer:
|
||||||
options?.serverResponse &&
|
serverResponse &&
|
||||||
(async (cachedValue) => {
|
(async (cachedValue) =>
|
||||||
const metadata = await decryptFileMetadata(options!.serverResponse!);
|
storeToIndexedDB({
|
||||||
return storeToIndexedDB({
|
|
||||||
categories: [],
|
categories: [],
|
||||||
...cachedValue,
|
...cachedValue,
|
||||||
id,
|
...(await toFileInfo(serverResponse)),
|
||||||
parentId: options!.serverResponse!.parent,
|
})),
|
||||||
contentType: options!.serverResponse!.contentType,
|
|
||||||
isFavorite: options!.serverResponse!.isFavorite,
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,3 +180,17 @@ export const setFileFavorite = async (
|
|||||||
invalidateDirectory(file.parentId);
|
invalidateDirectory(file.parentId);
|
||||||
invalidateFile(file.id);
|
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"
|
"subCategories" | "files" | "isFileRecursive"
|
||||||
>;
|
>;
|
||||||
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
|
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 {
|
import {
|
||||||
getDirectoryInfo,
|
getDirectoryInfo,
|
||||||
getFileInfo,
|
getFileInfo,
|
||||||
|
isExistingFileInfo,
|
||||||
|
isLocalDirectoryInfo,
|
||||||
type FileInfo,
|
type FileInfo,
|
||||||
type LocalCategoryInfo,
|
type LocalCategoryInfo,
|
||||||
type LocalDirectoryInfo,
|
type LocalDirectoryInfo,
|
||||||
@@ -37,5 +39,8 @@ export const search = async (filter: SearchFilter) => {
|
|||||||
),
|
),
|
||||||
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
|
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 env from "#lib/server/config.ts";
|
||||||
import { verifySessionId } from "#lib/server/crypto.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 {
|
export interface Session {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -39,19 +39,15 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
|
|||||||
throw new AuthenticationError(400, "Invalid session id");
|
throw new AuthenticationError(400, "Invalid session id");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const session = await SessionRepo.refreshSession(sessionId, ip, userAgent);
|
||||||
const { userId, clientId } = await SessionRepo.refreshSession(sessionId, ip, userAgent);
|
if (!session) {
|
||||||
return {
|
throw new AuthenticationError(401, "Invalid session id");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
userId: session.userId,
|
||||||
|
clientId: session.clientId ?? undefined,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureUserClientState = async (
|
const ensureUserClientState = async (
|
||||||
@@ -65,7 +61,7 @@ const ensureUserClientState = async (
|
|||||||
|
|
||||||
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
||||||
if (!userClient) {
|
if (!userClient) {
|
||||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
throw invalidSessionError();
|
||||||
} else if (userClient.state !== state) {
|
} else if (userClient.state !== state) {
|
||||||
throw new AppError("FORBIDDEN", "Forbidden");
|
throw new AppError("FORBIDDEN", "Forbidden");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const errorCodes = {
|
|||||||
"Inactive MEK version": "BAD_REQUEST",
|
"Inactive MEK version": "BAD_REQUEST",
|
||||||
// Session
|
// Session
|
||||||
"Session not found": "UNAUTHORIZED",
|
"Session not found": "UNAUTHORIZED",
|
||||||
"Session already exists": "CONFLICT",
|
"Already logged in": "CONFLICT",
|
||||||
// Upload
|
// Upload
|
||||||
"Upload session not found": "NOT_FOUND",
|
"Upload session not found": "NOT_FOUND",
|
||||||
} as const satisfies Record<string, ErrorCode>;
|
} 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))
|
.where("last_used_at", ">", new Date(now.getTime() - env.session.exp))
|
||||||
.returning(["user_id", "client_id"])
|
.returning(["user_id", "client_id"])
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!session) {
|
return session ? { userId: session.user_id, clientId: session.client_id } : null;
|
||||||
throw new IntegrityError("Session not found");
|
|
||||||
}
|
|
||||||
return { userId: session.user_id, clientId: session.client_id };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upgradeSession = async (
|
export const upgradeSession = async (
|
||||||
@@ -74,7 +71,7 @@ export const upgradeSession = async (
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof pg.DatabaseError && e.code === "23505") {
|
if (e instanceof pg.DatabaseError && e.code === "23505") {
|
||||||
throw new IntegrityError("Session already exists");
|
throw new IntegrityError("Already logged in");
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ export class AppError extends Error {
|
|||||||
return httpStatuses[this.code];
|
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";
|
import { AppError } from "#lib/server/errors.ts";
|
||||||
|
|
||||||
// Converts `AppError`s thrown by services into HTTP errors
|
// Converts `AppError`s thrown by services into HTTP errors
|
||||||
export const handleAppErrors = <Event extends RequestEvent>(
|
export const handleAppErrors = <Event>(
|
||||||
handler: (event: Event) => Promise<Response>,
|
handler: (event: Event) => Promise<Response>,
|
||||||
): ((event: Event) => Promise<Response>) => {
|
): ((event: Event) => Promise<Response>) => {
|
||||||
return async (event) => {
|
return async (event) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { error, redirect } from "@sveltejs/kit";
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
import type { Handle } from "@sveltejs/kit/hooks";
|
import type { Handle } from "@sveltejs/kit/hooks";
|
||||||
import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/auth.ts";
|
import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/auth.ts";
|
||||||
|
import { getRedirectPath, withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
|
|
||||||
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,16 +21,14 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
|||||||
} else if (pathname.startsWith("/api")) {
|
} else if (pathname.startsWith("/api")) {
|
||||||
error(e.status, e.message);
|
error(e.status, e.message);
|
||||||
} else {
|
} else {
|
||||||
redirect(302, "/auth/login?redirect=" + encodeURIComponent(pathname + search));
|
redirect(302, withRedirectPath("/auth/login", pathname + search));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.url.pathname === "/auth/login") {
|
if (event.url.pathname === "/auth/login") {
|
||||||
const redirectPath = event.url.searchParams.get("redirect");
|
redirect(302, getRedirectPath(event.url));
|
||||||
// Allow same-origin paths only
|
|
||||||
redirect(302, redirectPath?.match(/^\/(?![/\\])/) ? redirectPath : "/home");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await resolve(event);
|
return await resolve(event);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import argon2 from "argon2";
|
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 env from "#lib/server/config.ts";
|
||||||
import { issueSessionId } from "#lib/server/crypto.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";
|
import { createChallenge, verifyChallengeAnswer } from "./client.ts";
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
@@ -36,7 +36,7 @@ export const changePassword = async (
|
|||||||
|
|
||||||
const user = await UserRepo.getUser(userId);
|
const user = await UserRepo.getUser(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
throw invalidSessionError();
|
||||||
} else if (!(await argon2.verify(user.password, oldPassword))) {
|
} else if (!(await argon2.verify(user.password, oldPassword))) {
|
||||||
throw new AppError("FORBIDDEN", "Invalid password");
|
throw new AppError("FORBIDDEN", "Invalid password");
|
||||||
}
|
}
|
||||||
@@ -83,17 +83,12 @@ export const verifySessionUpgrade = async (
|
|||||||
) => {
|
) => {
|
||||||
const challenge = await SessionRepo.consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
|
const challenge = await SessionRepo.consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
|
||||||
const client = await verifyChallengeAnswer(challenge, answerSig);
|
const client = await verifyChallengeAnswer(challenge, answerSig);
|
||||||
|
await SessionRepo.upgradeSession(userId, sessionId, client.id, force);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
export const cleanupExpiredSessions = async () => {
|
||||||
await SessionRepo.upgradeSession(userId, sessionId, client.id, force);
|
await Promise.all([
|
||||||
} catch (e) {
|
SessionRepo.cleanupExpiredSessions(),
|
||||||
if (e instanceof IntegrityError) {
|
SessionRepo.cleanupExpiredSessionUpgradeChallenges(),
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -74,3 +74,7 @@ export const verifyClient = async (
|
|||||||
const client = await verifyChallengeAnswer(challenge, answerSig);
|
const client = await verifyChallengeAnswer(challenge, answerSig);
|
||||||
await ClientRepo.setUserClientStateToPending(userId, client.id);
|
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 { ClientRepo, HskRepo, MekRepo } from "#lib/server/db/index.ts";
|
||||||
import { verifySignature } from "#lib/server/crypto.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) => {
|
export const getClientMeks = async (userId: number, clientId: number) => {
|
||||||
return await MekRepo.getAllValidClientMeks(userId, clientId);
|
return await MekRepo.getAllValidClientMeks(userId, clientId);
|
||||||
@@ -15,10 +16,10 @@ const verifyClientEncMekSig = async (
|
|||||||
) => {
|
) => {
|
||||||
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
|
const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId);
|
||||||
if (!userClient) {
|
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);
|
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { UserRepo } from "#lib/server/db/index.ts";
|
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) => {
|
export const getUser = async (userId: number) => {
|
||||||
const user = await UserRepo.getUser(userId);
|
const user = await UserRepo.getUser(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
throw invalidSessionError();
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { AES_GCM_IV_SIZE } from "#lib/shared/format/index.ts";
|
|
||||||
import {
|
import {
|
||||||
encodeString,
|
encodeString,
|
||||||
decodeString,
|
decodeString,
|
||||||
@@ -7,6 +6,9 @@ import {
|
|||||||
concatenateBuffers,
|
concatenateBuffers,
|
||||||
} from "./encoding.ts";
|
} from "./encoding.ts";
|
||||||
|
|
||||||
|
export const AES_GCM_IV_SIZE = 12;
|
||||||
|
export const AES_GCM_TAG_SIZE = 16;
|
||||||
|
|
||||||
export const generateMasterKey = async () => {
|
export const generateMasterKey = async () => {
|
||||||
return {
|
return {
|
||||||
masterKey: await crypto.subtle.generateKey(
|
masterKey: await crypto.subtle.generateKey(
|
||||||
@@ -44,10 +46,12 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
|
const wrapKey = async (key: CryptoKey, masterKey: CryptoKey) => {
|
||||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
|
return encodeToBase64(await crypto.subtle.wrapKey("raw", key, masterKey, "AES-KW"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const wrapDataKey = wrapKey;
|
||||||
|
|
||||||
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
|
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
|
||||||
return {
|
return {
|
||||||
dataKey: await crypto.subtle.unwrapKey(
|
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) => {
|
export const wrapHmacSecret = wrapKey;
|
||||||
return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
|
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
|
||||||
return {
|
return {
|
||||||
@@ -84,7 +86,7 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
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(
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
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 (
|
export const signMasterKeyWrapped = async (
|
||||||
masterKeyWrapped: string,
|
masterKeyWrapped: string,
|
||||||
masterKeyVersion: number,
|
masterKeyVersion: number,
|
||||||
signKey: CryptoKey,
|
signKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
const serialized = JSON.stringify({
|
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
||||||
version: masterKeyVersion,
|
|
||||||
key: masterKeyWrapped,
|
|
||||||
});
|
|
||||||
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
return encodeToBase64(await signMessageRSA(encodeString(serialized), signKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,10 +190,7 @@ export const verifyMasterKeyWrapped = async (
|
|||||||
masterKeyWrappedSig: string,
|
masterKeyWrappedSig: string,
|
||||||
verifyKey: CryptoKey,
|
verifyKey: CryptoKey,
|
||||||
) => {
|
) => {
|
||||||
const serialized = JSON.stringify({
|
const serialized = serializeMasterKeyWrapped(masterKeyWrapped, masterKeyVersion);
|
||||||
version: masterKeyVersion,
|
|
||||||
key: masterKeyWrapped,
|
|
||||||
});
|
|
||||||
return await verifySignatureRSA(
|
return await verifySignatureRSA(
|
||||||
encodeString(serialized),
|
encodeString(serialized),
|
||||||
decodeFromBase64(masterKeyWrappedSig),
|
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
|
// 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.
|
// `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 ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
|
||||||
|
|
||||||
export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
|
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/";
|
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 {
|
export interface FileMetadata {
|
||||||
isLegacy: boolean;
|
isLegacy: boolean;
|
||||||
dataKey: CryptoKey;
|
dataKey: CryptoKey;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { afterPress } from "../press.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
actionButtonClass?: ClassValue;
|
actionButtonClass?: ClassValue;
|
||||||
@@ -23,11 +24,7 @@
|
|||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div data-entry-container onclick={afterPress(onclick)} class={["rounded-xl", className]}>
|
||||||
data-entry-container
|
|
||||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
|
||||||
class={["rounded-xl", className]}
|
|
||||||
>
|
|
||||||
<div data-entry-content class="flex h-full items-center gap-x-4 p-2 transition">
|
<div data-entry-content class="flex h-full items-center gap-x-4 p-2 transition">
|
||||||
<div class="grow overflow-x-hidden">
|
<div class="grow overflow-x-hidden">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -37,9 +34,7 @@
|
|||||||
data-action-button
|
data-action-button
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onActionButtonClick) {
|
afterPress(onActionButtonClick)?.();
|
||||||
setTimeout(onActionButtonClick, 100);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
class={["shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
|
class={["shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { afterPress } from "../press.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
onclick={afterPress(onclick)}
|
||||||
class={[
|
class={[
|
||||||
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
|
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
|
||||||
bgColor,
|
bgColor,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { afterPress } from "../press.ts";
|
||||||
|
|
||||||
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
||||||
|
|
||||||
@@ -13,10 +14,7 @@
|
|||||||
let { children, class: className, onclick }: Props = $props();
|
let { children, class: className, onclick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button onclick={afterPress(onclick)} class={["rounded-xl active:bg-gray-100", className]}>
|
||||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
|
||||||
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="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
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 {
|
interface Props {
|
||||||
class: ClassValue;
|
class: ClassValue;
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<div class="pointer-events-none fixed inset-0">
|
<div class="pointer-events-none fixed inset-0">
|
||||||
<AdaptiveDiv class="relative h-full">
|
<AdaptiveDiv class="relative h-full">
|
||||||
<button
|
<button
|
||||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
onclick={afterPress(onclick)}
|
||||||
class={[
|
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",
|
"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,
|
className,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
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 {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { afterPress } from "../press.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<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"
|
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">
|
<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 RenameModal } from "./overlays/RenameModal.svelte";
|
||||||
export { default as TextInputModal } from "./overlays/TextInputModal.svelte";
|
export { default as TextInputModal } from "./overlays/TextInputModal.svelte";
|
||||||
export * from "./overlays/toast.svelte.ts";
|
export * from "./overlays/toast.svelte.ts";
|
||||||
|
export * from "./press.ts";
|
||||||
export { default as Toasts } from "./overlays/Toasts.svelte";
|
export { default as Toasts } from "./overlays/Toasts.svelte";
|
||||||
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
|
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { afterPress } from "../press.ts";
|
||||||
|
|
||||||
import IconClose from "~icons/material-symbols/close";
|
import IconClose from "~icons/material-symbols/close";
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
onclick={onclick && (() => setTimeout(onclick, 100))}
|
onclick={afterPress(onclick)}
|
||||||
class={[
|
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",
|
"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
|
selected
|
||||||
@@ -42,9 +43,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onRemoveClick) {
|
afterPress(onRemoveClick)?.();
|
||||||
setTimeout(onRemoveClick, 100);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconClose />
|
<IconClose />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ClassValue } from "svelte/elements";
|
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";
|
import IconCategory from "~icons/material-symbols/category";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ClassValue } from "svelte/elements";
|
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 IconFolder from "~icons/material-symbols/folder";
|
||||||
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
|
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
import { TitleLabel } from "#lib/ui/index.ts";
|
import TitleLabel from "../labels/TitleLabel.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
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 {
|
interface Props {
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
import { AdaptiveDiv } from "#lib/ui/index.ts";
|
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import type { ClassValue } from "svelte/elements";
|
import type { ClassValue } from "svelte/elements";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { AdaptiveDiv } from "#lib/ui/index.ts";
|
import AdaptiveDiv from "../layout/AdaptiveDiv.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { TextInputModal } from "#lib/ui/index.ts";
|
import TextInputModal from "./TextInputModal.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 {
|
interface Props {
|
||||||
defaultValue?: string;
|
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";
|
import { defineParams } from "@sveltejs/kit/params";
|
||||||
|
|
||||||
const matchThumbnail = (param: string) => {
|
|
||||||
return param === "thumbnail";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const params = defineParams({
|
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 { login as requestLogin, logout as requestLogout } from "#lib/features/auth/index.ts";
|
||||||
import { showErrorToast } from "#lib/features/errors.ts";
|
import { showErrorToast } from "#lib/features/errors.ts";
|
||||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
||||||
|
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import {
|
import {
|
||||||
BottomDiv,
|
BottomDiv,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,10 +28,6 @@
|
|||||||
|
|
||||||
let isForceLoginModalOpen = $state(false);
|
let isForceLoginModalOpen = $state(false);
|
||||||
|
|
||||||
const redirect = async (url: string) => {
|
|
||||||
return await goto(`${url}?redirect=${encodeURIComponent(data.redirectPath)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const upgradeSession = async (force: boolean) => {
|
const upgradeSession = async (force: boolean) => {
|
||||||
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force);
|
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force);
|
||||||
if (!force && upgradeError === "Already logged in") {
|
if (!force && upgradeError === "Already logged in") {
|
||||||
@@ -47,7 +44,7 @@
|
|||||||
await cleanupDeletedFileCaches().catch(console.error);
|
await cleanupDeletedFileCaches().catch(console.error);
|
||||||
await goto(data.redirectPath);
|
await goto(data.redirectPath);
|
||||||
} else {
|
} else {
|
||||||
await redirect("/client/pending");
|
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +65,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!keyring.clientKeys) return await redirect("/key/generate");
|
if (!keyring.clientKeys)
|
||||||
|
return await goto(withRedirectPath("/key/generate", data.redirectPath));
|
||||||
|
|
||||||
await upgradeSession(false);
|
await upgradeSession(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ url }) => {
|
export const load: PageLoad = async ({ url }) => {
|
||||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
return { redirectPath: getRedirectPath(url) };
|
||||||
return { redirectPath };
|
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ url }) => {
|
export const load: PageLoad = async ({ url }) => {
|
||||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
return { redirectPath: getRedirectPath(url) };
|
||||||
return { redirectPath };
|
|
||||||
};
|
};
|
||||||
+2
-4
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { convertHeic } from "#lib/client/media/heic.ts";
|
||||||
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.ts";
|
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.ts";
|
||||||
import { saveDecryptedFile } from "#lib/client/service-worker.ts";
|
import { saveDecryptedFile } from "#lib/client/service-worker.ts";
|
||||||
import { getFileDownloadState } from "#lib/client/transfer/index.ts";
|
import { getFileDownloadState } from "#lib/client/transfer/index.ts";
|
||||||
@@ -48,10 +49,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await (await fetch(fileUrl)).blob();
|
const blob = await (await fetch(fileUrl)).blob();
|
||||||
const { default: heic2any } = await import("heic2any");
|
convertedImageUrl = URL.createObjectURL(await convertHeic(blob, "image/jpeg"));
|
||||||
convertedImageUrl = URL.createObjectURL(
|
|
||||||
(await heic2any({ blob, toType: "image/jpeg" })) as Blob,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showErrorToast(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">
|
<script lang="ts">
|
||||||
import { isFileDownloading, type FileDownloadState } from "#lib/client/transfer/index.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 {
|
interface Props {
|
||||||
state: FileDownloadState;
|
state: FileDownloadState;
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
<div class="w-full rounded-xl bg-gray-100 p-3">
|
<div class="w-full rounded-xl bg-gray-100 p-3">
|
||||||
<p class="font-medium">다운로드하는 중</p>
|
<p class="font-medium">다운로드하는 중</p>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
전송됨
|
<TransferProgress progress={state.progress} rate={state.rate} />
|
||||||
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
import type { FileDownloadState } from "#lib/client/transfer/index.ts";
|
import type { FileDownloadState } from "#lib/client/transfer/index.ts";
|
||||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/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 IconCloudDownload from "~icons/material-symbols/cloud-download";
|
||||||
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
||||||
@@ -31,9 +31,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-800">
|
<p class="text-xs text-gray-800">
|
||||||
{#if state.status === "downloading"}
|
{#if state.status === "downloading"}
|
||||||
전송됨
|
<TransferProgress progress={state.progress} rate={state.rate} />
|
||||||
{Math.floor((state.progress ?? 0) * 100)}% ·
|
|
||||||
{formatNetworkSpeed((state.rate ?? 0) * 8)}
|
|
||||||
{:else if state.status === "completed"}
|
{:else if state.status === "completed"}
|
||||||
다운로드 완료
|
다운로드 완료
|
||||||
{:else if state.status === "error"}
|
{:else if state.status === "error"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileUploadState } from "#lib/client/transfer/index.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 IconPending from "~icons/material-symbols/pending";
|
||||||
import IconLockClock from "~icons/material-symbols/lock-clock";
|
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||||
@@ -37,22 +37,7 @@
|
|||||||
{state.name}
|
{state.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-800">
|
<p class="text-xs text-gray-800">
|
||||||
{#if state.status === "queued"}
|
<UploadStatusText {state} />
|
||||||
대기 중
|
|
||||||
{: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}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
requestSessionUpgrade,
|
requestSessionUpgrade,
|
||||||
serializeClientKeys,
|
serializeClientKeys,
|
||||||
} from "#lib/client/keys/index.ts";
|
} from "#lib/client/keys/index.ts";
|
||||||
|
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import {
|
import {
|
||||||
BottomDiv,
|
BottomDiv,
|
||||||
Button,
|
Button,
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
} from "#lib/client/keys/index.ts";
|
} from "#lib/client/keys/index.ts";
|
||||||
import { ForceLoginModal } from "#lib/features/auth/components/index.ts";
|
import { ForceLoginModal } from "#lib/features/auth/components/index.ts";
|
||||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.ts";
|
||||||
|
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import {
|
import {
|
||||||
BottomDiv,
|
BottomDiv,
|
||||||
Button,
|
Button,
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await cleanupDeletedFileCaches().catch(console.error);
|
await cleanupDeletedFileCaches().catch(console.error);
|
||||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
await goto(withRedirectPath("/client/pending", data.redirectPath));
|
||||||
};
|
};
|
||||||
|
|
||||||
const importKeys = async () => {
|
const importKeys = async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { getRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ url }) => {
|
export const load: PageLoad = async ({ url }) => {
|
||||||
const redirectPath = url.searchParams.get("redirect") || "/home";
|
return { redirectPath: getRedirectPath(url) };
|
||||||
return { redirectPath };
|
|
||||||
};
|
};
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileCacheIndex } from "#lib/client/storage/indexed-db/index.ts";
|
import type { FileCacheIndex } from "#lib/client/storage/indexed-db/index.ts";
|
||||||
import type { MaybeFileInfo } from "#lib/features/filesystem/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 { formatDate, formatFileSize } from "#lib/utils/index.ts";
|
||||||
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => setTimeout(() => onDeleteClick(index.fileId), 100)}
|
onclick={afterPress(() => onDeleteClick(index.fileId))}
|
||||||
class="shrink-0 rounded-full p-1 active:bg-gray-100"
|
class="shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||||
>
|
>
|
||||||
<IconDelete class="text-lg text-gray-600" />
|
<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">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { getDownloadingFiles, getUploadingFiles } from "#lib/client/transfer/index.ts";
|
||||||
import { tryAction } from "#lib/features/errors.ts";
|
import { tryAction } from "#lib/features/errors.ts";
|
||||||
import { createDirectory, uploadFile, useDirectory } from "#lib/features/filesystem/index.ts";
|
import { createDirectory, uploadFile, useDirectory } from "#lib/features/filesystem/index.ts";
|
||||||
import { FloatingButton, OverlayGroup, TopBar } from "#lib/ui/index.ts";
|
import { FloatingButton, OverlayGroup, TopBar } from "#lib/ui/index.ts";
|
||||||
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
|
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
|
||||||
import DirectoryEntries from "./directory-entries/index.ts";
|
import DirectoryEntries from "./directory-entries/index.ts";
|
||||||
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
|
||||||
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||||
import EntryCreateBottomSheet from "./EntryCreateBottomSheet.svelte";
|
import EntryCreateBottomSheet from "./EntryCreateBottomSheet.svelte";
|
||||||
import EntryDeleteModal from "./EntryDeleteModal.svelte";
|
import EntryDeleteModal from "./EntryDeleteModal.svelte";
|
||||||
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
|
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
|
||||||
import EntryRenameModal from "./EntryRenameModal.svelte";
|
import EntryRenameModal from "./EntryRenameModal.svelte";
|
||||||
import UploadStatusCard from "./UploadStatusCard.svelte";
|
import TransferStatusCard from "./TransferStatusCard.svelte";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
@@ -89,8 +89,18 @@
|
|||||||
{#if info?.exists}
|
{#if info?.exists}
|
||||||
<div class="flex grow flex-col px-4 pb-4">
|
<div class="flex grow flex-col px-4 pb-4">
|
||||||
<div class="flex gap-x-2">
|
<div class="flex gap-x-2">
|
||||||
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
<TransferStatusCard
|
||||||
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
label="진행 중인 업로드"
|
||||||
|
count={getUploadingFiles().length}
|
||||||
|
color="blue"
|
||||||
|
onclick={() => goto("/file/uploads")}
|
||||||
|
/>
|
||||||
|
<TransferStatusCard
|
||||||
|
label="진행 중인 다운로드"
|
||||||
|
count={getDownloadingFiles().length}
|
||||||
|
color="green"
|
||||||
|
onclick={() => goto("/file/downloads")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#key info.id}
|
{#key info.id}
|
||||||
<DirectoryEntries
|
<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.subDirectories.map(toEntry("directory"))),
|
||||||
...sortEntries([
|
...sortEntries([
|
||||||
...info.files.map(toEntry("file")),
|
...info.files.map(toEntry("file")),
|
||||||
...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
|
...getUploadingFiles(info.id).map(toEntry("uploading-file")),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
+2
-13
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { LiveFileUploadState } from "#lib/client/transfer/index.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";
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
|
|
||||||
@@ -20,18 +20,7 @@
|
|||||||
{state.name}
|
{state.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
{#if state.status === "queued"}
|
<UploadStatusText {state} />
|
||||||
대기 중
|
|
||||||
{: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}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { FileThumbnailButton } from "#lib/features/filesystem/components/index.ts";
|
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 { EntryButton } from "#lib/ui/index.ts";
|
||||||
import { requestFreshMediaFilesRetrieval } from "./service.ts";
|
|
||||||
|
|
||||||
let mediaFiles: SummarizedFileInfo[] = $state([]);
|
let mediaFiles: SummarizedFileInfo[] = $state([]);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
mediaFiles = await requestFreshMediaFilesRetrieval();
|
mediaFiles = await getRecentMediaFileInfos(8);
|
||||||
});
|
});
|
||||||
</script>
|
</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 { redirect } from "@sveltejs/kit";
|
||||||
import { keyring } from "#lib/client/keys/index.ts";
|
import { keyring } from "#lib/client/keys/index.ts";
|
||||||
|
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
@@ -9,10 +10,10 @@ const KEYLESS_PATHS = ["/auth/login", "/key", "/client/pending"];
|
|||||||
export const load: LayoutLoad = ({ url }) => {
|
export const load: LayoutLoad = ({ url }) => {
|
||||||
if (KEYLESS_PATHS.some((path) => url.pathname.startsWith(path))) return;
|
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) {
|
if (!keyring.clientKeys) {
|
||||||
redirect(307, "/key/generate" + redirectQuery);
|
redirect(307, withRedirectPath("/key/generate", redirectPath));
|
||||||
} else if (!keyring.hasMasterKeys) {
|
} 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 { authorize } from "#lib/server/auth.ts";
|
||||||
import { handleAppErrors } from "#lib/server/http.ts";
|
import { handleAppErrors } from "#lib/server/http.ts";
|
||||||
import { FileService } from "#lib/server/services/index.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 downloadHandler = async ({ locals, params, request }: RequestEvent) => {
|
||||||
const { userId } = await authorize(locals, "activeClient");
|
const { userId } = await authorize(locals, "activeClient");
|
||||||
|
const { id } = params;
|
||||||
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 getStream = params.thumbnail
|
const getStream = params.thumbnail
|
||||||
? FileService.getFileThumbnailStream
|
? FileService.getFileThumbnailStream
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { FileMetadata } from "#lib/shared/service-worker.ts";
|
import { MAX_PREPARED_FILES, type FileMetadata } from "#lib/shared/service-worker.ts";
|
||||||
|
|
||||||
const MAX_FILE_METADATA_ENTRIES = 32;
|
|
||||||
|
|
||||||
const fileMetadata = new Map<number, FileMetadata>();
|
const fileMetadata = new Map<number, FileMetadata>();
|
||||||
|
|
||||||
@@ -18,7 +16,7 @@ export const fileMetadataStore = {
|
|||||||
fileMetadata.delete(fileId);
|
fileMetadata.delete(fileId);
|
||||||
fileMetadata.set(fileId, metadata);
|
fileMetadata.set(fileId, metadata);
|
||||||
|
|
||||||
while (fileMetadata.size > MAX_FILE_METADATA_ENTRIES) {
|
while (fileMetadata.size > MAX_PREPARED_FILES) {
|
||||||
fileMetadata.delete(fileMetadata.keys().next().value!);
|
fileMetadata.delete(fileMetadata.keys().next().value!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { navigating } from "$app/state";
|
import { navigating } from "$app/state";
|
||||||
|
import { withRedirectPath } from "#lib/shared/redirect.ts";
|
||||||
import type { AppRouter } from "./router.server.ts";
|
import type { AppRouter } from "./router.server.ts";
|
||||||
|
|
||||||
// Procedures that report invalid credentials as UNAUTHORIZED, not an expired session
|
// Procedures that report invalid credentials as UNAUTHORIZED, not an expired session
|
||||||
@@ -21,7 +22,7 @@ const sessionExpiryLink: TRPCLink<AppRouter> = () => {
|
|||||||
!CREDENTIAL_PROCEDURES.has(op.path) &&
|
!CREDENTIAL_PROCEDURES.has(op.path) &&
|
||||||
url.pathname !== "/auth/login"
|
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);
|
observer.error(err);
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-3
@@ -1,6 +1,8 @@
|
|||||||
import type { Category } from "#lib/server/db/repositories/category.ts";
|
import type { CategoryRepo, DirectoryRepo, FileRepo } from "#lib/server/db/index.ts";
|
||||||
import type { Directory } from "#lib/server/db/repositories/directory.ts";
|
|
||||||
import type { File } from "#lib/server/db/repositories/file.ts";
|
type Category = CategoryRepo.Category;
|
||||||
|
type Directory = DirectoryRepo.Directory;
|
||||||
|
type File = FileRepo.File;
|
||||||
|
|
||||||
// Shapes of filesystem entries exposed by the API
|
// Shapes of filesystem entries exposed by the API
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,9 @@ export const publicProcedure = t.procedure.use(async ({ next }) => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
// `authorize` also rejects unauthenticated requests
|
||||||
if (!ctx.locals.session) {
|
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
||||||
authedProcedure.use(async ({ ctx, next }) => {
|
publicProcedure.use(async ({ ctx, next }) => {
|
||||||
const session: Session = await authorize(ctx.locals, requiredPermission);
|
const session: Session = await authorize(ctx.locals, requiredPermission);
|
||||||
return next({ ctx: { session } });
|
return next({ ctx: { session } });
|
||||||
});
|
});
|
||||||
@@ -46,7 +40,7 @@ const sessionProcedure = (requiredPermission: "any" | "notClient") =>
|
|||||||
const clientSessionProcedure = (
|
const clientSessionProcedure = (
|
||||||
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
|
||||||
) =>
|
) =>
|
||||||
authedProcedure.use(async ({ ctx, next }) => {
|
publicProcedure.use(async ({ ctx, next }) => {
|
||||||
const session: ClientSession = await authorize(ctx.locals, requiredPermission);
|
const session: ClientSession = await authorize(ctx.locals, requiredPermission);
|
||||||
return next({ ctx: { session } });
|
return next({ ctx: { session } });
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const IdSchema = z.int().positive();
|
export const IdSchema = z.int().positive();
|
||||||
export const DirectoryIdSchema = z.union([z.literal("root"), IdSchema]);
|
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 Base64Schema = z.base64().nonempty();
|
||||||
|
|
||||||
export const EncryptedNameSchema = {
|
export const EncryptedNameSchema = {
|
||||||
|
|||||||
Reference in new issue
Block a user