diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0d3e02f..37d56d3 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,13 @@ import { sequence, type ServerInit } from "@sveltejs/kit/hooks"; import schedule from "node-schedule"; -import { ClientRepo, SessionRepo } from "#lib/server/db/index.ts"; import { migrateDB } from "#lib/server/db/kysely.ts"; import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.ts"; -import { FileDeletionService, UploadService } from "#lib/server/services/index.ts"; +import { + AuthService, + ClientService, + FileDeletionService, + UploadService, +} from "#lib/server/services/index.ts"; export const init: ServerInit = async () => { await migrateDB(); @@ -11,9 +15,8 @@ export const init: ServerInit = async () => { schedule.scheduleJob("0 * * * *", async () => { // Failures are logged and retried at the next run, so they don't crash the server const results = await Promise.allSettled([ - ClientRepo.cleanupExpiredUserClientChallenges(), - SessionRepo.cleanupExpiredSessions(), - SessionRepo.cleanupExpiredSessionUpgradeChallenges(), + AuthService.cleanupExpiredSessions(), + ClientService.cleanupExpiredChallenges(), UploadService.cleanupExpiredUploadSessions(), ]); results.forEach((result) => { diff --git a/src/lib/client/media/heic.ts b/src/lib/client/media/heic.ts new file mode 100644 index 0000000..4201dd5 --- /dev/null +++ b/src/lib/client/media/heic.ts @@ -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; +}; diff --git a/src/lib/client/media/thumbnail.ts b/src/lib/client/media/thumbnail.ts index 07d3ce8..14ed9f8 100644 --- a/src/lib/client/media/thumbnail.ts +++ b/src/lib/client/media/thumbnail.ts @@ -1,3 +1,5 @@ +import { convertHeic } from "./heic.ts"; + const scaleSize = (width: number, height: number, targetSize: number) => { if (width <= targetSize || height <= targetSize) { return { width, height }; @@ -111,11 +113,6 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => { }); }; -const convertHeicToPng = async (blob: Blob) => { - const { default: heic2any } = await import("heic2any"); - return (await heic2any({ blob, toType: "image/png" })) as Blob; -}; - // Generates a thumbnail of the image or video at the URL. Returns `null` if not supported. export const generateThumbnailFromUrl = async (url: string, contentType: string) => { try { @@ -125,8 +122,8 @@ export const generateThumbnailFromUrl = async (url: string, contentType: string) } catch { if (contentType !== "image/heic") return null; - // Chromium can't decode HEIC images - const png = URL.createObjectURL(await convertHeicToPng(await (await fetch(url)).blob())); + const blob = await (await fetch(url)).blob(); + const png = URL.createObjectURL(await convertHeic(blob, "image/png")); try { return await generateImageThumbnail(png); } finally { diff --git a/src/lib/client/service-worker.ts b/src/lib/client/service-worker.ts index c3063ee..094a8ad 100644 --- a/src/lib/client/service-worker.ts +++ b/src/lib/client/service-worker.ts @@ -1,6 +1,8 @@ +import { LRUCache } from "lru-cache"; import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.ts"; import { DECRYPTED_FILE_URL_PREFIX, + MAX_PREPARED_FILES, type FileMetadata, type ServiceWorkerMessage, type ServiceWorkerResponse, @@ -10,7 +12,7 @@ const PREPARE_TIMEOUT_MS = 5000; // The service worker may be terminated at any time, losing its in-memory metadata. // Keep a copy here so that it can be re-requested on demand. -const preparedFiles = new Map(); +const preparedFiles = new LRUCache({ max: MAX_PREPARED_FILES }); let isListening = false; export const listenForServiceWorkerMessages = () => { diff --git a/src/lib/client/transfer/upload.svelte.ts b/src/lib/client/transfer/upload.svelte.ts index 4d51983..50d8c86 100644 --- a/src/lib/client/transfer/upload.svelte.ts +++ b/src/lib/client/transfer/upload.svelte.ts @@ -20,43 +20,43 @@ import { getChunkCount } from "#lib/shared/format/index.ts"; import { Scheduler } from "#lib/utils/index.ts"; import { trpc } from "#trpc/client.ts"; +const LIVE_STATUSES = [ + "queued", + "encryption-pending", + "encrypting", + "upload-pending", + "uploading", +] as const; + +type LiveStatus = (typeof LIVE_STATUSES)[number]; + export interface FileUploadState { id: string; name: string; parentId: DirectoryId; - status: - | "queued" - | "encryption-pending" - | "encrypting" - | "upload-pending" - | "uploading" - | "uploaded" - | "canceled" - | "error"; + status: LiveStatus | "uploaded" | "canceled" | "error"; progress?: number; rate?: number; estimated?: number; } -export type LiveFileUploadState = FileUploadState & { - status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; -}; +export type LiveFileUploadState = FileUploadState & { status: LiveStatus }; const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>(); let uploadingFiles: FileUploadState[] = $state([]); -const isFileUploading = (status: FileUploadState["status"]) => - ["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); +const isFileUploading = (file: FileUploadState): file is LiveFileUploadState => { + return (LIVE_STATUSES as readonly string[]).includes(file.status); +}; export const getUploadingFiles = (parentId?: DirectoryId) => { - return uploadingFiles.filter( - (file) => - (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), - ); + return uploadingFiles + .filter(isFileUploading) + .filter((file) => parentId === undefined || file.parentId === parentId); }; export const clearUploadedFiles = () => { - uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status)); + uploadingFiles = uploadingFiles.filter(isFileUploading); }; const requestDuplicateFileScan = limitFunction( diff --git a/src/lib/features/favorites/index.ts b/src/lib/features/favorites/index.ts index 3f1fafc..da0efa6 100644 --- a/src/lib/features/favorites/index.ts +++ b/src/lib/features/favorites/index.ts @@ -1,6 +1,8 @@ import { getDirectoryInfo, getFileInfo, + isExistingFileInfo, + isLocalDirectoryInfo, setDirectoryFavorite, setFileFavorite, type LocalDirectoryInfo, @@ -29,18 +31,16 @@ export const getFavoriteEntries = async () => { ]); return [ ...sortEntries( - directories.map((directory): FavoriteEntry => ({ + directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({ type: "directory", - name: directory.name!, - details: directory as LocalDirectoryInfo, + name: directory.name, + details: directory, })), ), ...sortEntries( - files.map((file): FavoriteEntry => ({ - type: "file", - name: file.name!, - details: file as SummarizedFileInfo, - })), + files + .filter(isExistingFileInfo) + .map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })), ), ]; }; diff --git a/src/lib/features/filesystem/category.ts b/src/lib/features/filesystem/category.ts index f4ec339..a98b2ad 100644 --- a/src/lib/features/filesystem/category.ts +++ b/src/lib/features/filesystem/category.ts @@ -2,12 +2,8 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts"; import { isTRPCClientError, trpc } from "#trpc/client.ts"; import { FilesystemCache } from "./cache.svelte.ts"; import { invalidateFile } from "./file.ts"; -import { - decryptCategoryMetadata, - decryptFileMetadata, - encryptName, - generateEntryKeys, -} from "./metadata.ts"; +import { toCategoryInfo, toFileInfo } from "./dto.ts"; +import { encryptName, generateEntryKeys } from "./metadata.ts"; import { useResource } from "./resource.svelte.ts"; import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts"; @@ -60,38 +56,25 @@ const cache = new FilesystemCache({ try { const category = await trpc.category.get.query({ id, recurse: true }); const [subCategories, files, metadata] = await Promise.all([ + Promise.all(category.subCategories.map(toCategoryInfo)), Promise.all( - category.subCategories.map(async (category) => ({ - id: category.id, - parentId: id, - ...(await decryptCategoryMetadata(category)), + (category.files ?? []).map(async (file) => ({ + ...(await toFileInfo(file)), + isRecursive: file.isRecursive, })), ), - category.files && - Promise.all( - category.files.map(async (file) => ({ - id: file.id, - parentId: file.parent, - contentType: file.contentType, - isFavorite: file.isFavorite, - isRecursive: file.isRecursive, - ...(await decryptFileMetadata(file)), - })), - ), - category.metadata && decryptCategoryMetadata(category.metadata), + category.metadata && toCategoryInfo(category.metadata), ]); return storeToIndexedDB( - id !== "root" + metadata ? { - id, - parentId: category.metadata!.parent, + ...metadata, subCategories, - files: files!, + files, isFileRecursive: cachedInfo?.isFileRecursive ?? false, - ...metadata!, } - : { id, subCategories }, + : { id: "root", subCategories }, ); } catch (e) { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { diff --git a/src/lib/features/filesystem/components/FileThumbnailButton.svelte b/src/lib/features/filesystem/components/FileThumbnailButton.svelte index ca19e4c..0e94419 100644 --- a/src/lib/features/filesystem/components/FileThumbnailButton.svelte +++ b/src/lib/features/filesystem/components/FileThumbnailButton.svelte @@ -1,6 +1,7 @@ +{/if} diff --git a/src/routes/(main)/directory/[[id]]/directory-entries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id=int]]/directory-entries/DirectoryEntries.svelte similarity index 97% rename from src/routes/(main)/directory/[[id]]/directory-entries/DirectoryEntries.svelte rename to src/routes/(main)/directory/[[id=int]]/directory-entries/DirectoryEntries.svelte index 7094bb5..a9a785f 100644 --- a/src/routes/(main)/directory/[[id]]/directory-entries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id=int]]/directory-entries/DirectoryEntries.svelte @@ -49,7 +49,7 @@ ...sortEntries(info.subDirectories.map(toEntry("directory"))), ...sortEntries([ ...info.files.map(toEntry("file")), - ...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")), + ...getUploadingFiles(info.id).map(toEntry("uploading-file")), ]), ]); diff --git a/src/routes/(main)/directory/[[id]]/directory-entries/UploadingFile.svelte b/src/routes/(main)/directory/[[id=int]]/directory-entries/UploadingFile.svelte similarity index 53% rename from src/routes/(main)/directory/[[id]]/directory-entries/UploadingFile.svelte rename to src/routes/(main)/directory/[[id=int]]/directory-entries/UploadingFile.svelte index b57403e..79b699c 100644 --- a/src/routes/(main)/directory/[[id]]/directory-entries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id=int]]/directory-entries/UploadingFile.svelte @@ -1,6 +1,6 @@ - -{#if downloadingFiles.length > 0} - -{/if} diff --git a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte deleted file mode 100644 index b356e77..0000000 --- a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -{#if uploadingFiles.length > 0} - -{/if} diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 66cc8ef..c1a68d5 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -2,14 +2,16 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { FileThumbnailButton } from "#lib/features/filesystem/components/index.ts"; - import { type SummarizedFileInfo } from "#lib/features/filesystem/index.ts"; + import { + getRecentMediaFileInfos, + type SummarizedFileInfo, + } from "#lib/features/filesystem/index.ts"; import { EntryButton } from "#lib/ui/index.ts"; - import { requestFreshMediaFilesRetrieval } from "./service.ts"; let mediaFiles: SummarizedFileInfo[] = $state([]); onMount(async () => { - mediaFiles = await requestFreshMediaFilesRetrieval(); + mediaFiles = await getRecentMediaFileInfos(8); }); diff --git a/src/routes/(main)/home/service.ts b/src/routes/(main)/home/service.ts deleted file mode 100644 index 6673323..0000000 --- a/src/routes/(main)/home/service.ts +++ /dev/null @@ -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); -}; diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 7ed68b6..f3d8e93 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,5 +1,6 @@ import { redirect } from "@sveltejs/kit"; import { keyring } from "#lib/client/keys/index.ts"; +import { withRedirectPath } from "#lib/shared/redirect.ts"; import type { LayoutLoad } from "./$types"; export const ssr = false; @@ -9,10 +10,10 @@ const KEYLESS_PATHS = ["/auth/login", "/key", "/client/pending"]; export const load: LayoutLoad = ({ url }) => { if (KEYLESS_PATHS.some((path) => url.pathname.startsWith(path))) return; - const redirectQuery = `?redirect=${encodeURIComponent(url.pathname + url.search)}`; + const redirectPath = url.pathname + url.search; if (!keyring.clientKeys) { - redirect(307, "/key/generate" + redirectQuery); + redirect(307, withRedirectPath("/key/generate", redirectPath)); } else if (!keyring.hasMasterKeys) { - redirect(307, "/client/pending" + redirectQuery); + redirect(307, withRedirectPath("/client/pending", redirectPath)); } }; diff --git a/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts b/src/routes/api/file/[id=int]/[[thumbnail=thumbnail]]/download/+server.ts similarity index 84% rename from src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts rename to src/routes/api/file/[id=int]/[[thumbnail=thumbnail]]/download/+server.ts index b5a1f2c..b4179d7 100644 --- a/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts +++ b/src/routes/api/file/[id=int]/[[thumbnail=thumbnail]]/download/+server.ts @@ -1,5 +1,3 @@ -import { error } from "@sveltejs/kit"; -import { z } from "zod"; import { authorize } from "#lib/server/auth.ts"; import { handleAppErrors } from "#lib/server/http.ts"; import { FileService } from "#lib/server/services/index.ts"; @@ -8,14 +6,7 @@ import type { RequestEvent, RequestHandler } from "./$types"; const downloadHandler = async ({ locals, params, request }: RequestEvent) => { const { userId } = await authorize(locals, "activeClient"); - - const zodRes = z - .object({ - id: z.coerce.number().int().positive(), - }) - .safeParse(params); - if (!zodRes.success) error(400, "Invalid path parameters"); - const { id } = zodRes.data; + const { id } = params; const getStream = params.thumbnail ? FileService.getFileThumbnailStream diff --git a/src/service-worker/stores.ts b/src/service-worker/stores.ts index 13e8e10..8ffa226 100644 --- a/src/service-worker/stores.ts +++ b/src/service-worker/stores.ts @@ -1,6 +1,4 @@ -import type { FileMetadata } from "#lib/shared/service-worker.ts"; - -const MAX_FILE_METADATA_ENTRIES = 32; +import { MAX_PREPARED_FILES, type FileMetadata } from "#lib/shared/service-worker.ts"; const fileMetadata = new Map(); @@ -18,7 +16,7 @@ export const fileMetadataStore = { fileMetadata.delete(fileId); fileMetadata.set(fileId, metadata); - while (fileMetadata.size > MAX_FILE_METADATA_ENTRIES) { + while (fileMetadata.size > MAX_PREPARED_FILES) { fileMetadata.delete(fileMetadata.keys().next().value!); } }, diff --git a/src/trpc/client.ts b/src/trpc/client.ts index 03dc738..c8837fa 100644 --- a/src/trpc/client.ts +++ b/src/trpc/client.ts @@ -3,6 +3,7 @@ import { observable } from "@trpc/server/observable"; import superjson from "superjson"; import { goto } from "$app/navigation"; import { navigating } from "$app/state"; +import { withRedirectPath } from "#lib/shared/redirect.ts"; import type { AppRouter } from "./router.server.ts"; // Procedures that report invalid credentials as UNAUTHORIZED, not an expired session @@ -21,7 +22,7 @@ const sessionExpiryLink: TRPCLink = () => { !CREDENTIAL_PROCEDURES.has(op.path) && url.pathname !== "/auth/login" ) { - void goto(`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`); + void goto(withRedirectPath("/auth/login", url.pathname + url.search)); } observer.error(err); }, diff --git a/src/trpc/dto.ts b/src/trpc/dto.ts index 84e278c..33eb55f 100644 --- a/src/trpc/dto.ts +++ b/src/trpc/dto.ts @@ -1,6 +1,8 @@ -import type { Category } from "#lib/server/db/repositories/category.ts"; -import type { Directory } from "#lib/server/db/repositories/directory.ts"; -import type { File } from "#lib/server/db/repositories/file.ts"; +import type { CategoryRepo, DirectoryRepo, FileRepo } from "#lib/server/db/index.ts"; + +type Category = CategoryRepo.Category; +type Directory = DirectoryRepo.Directory; +type File = FileRepo.File; // Shapes of filesystem entries exposed by the API diff --git a/src/trpc/init.server.ts b/src/trpc/init.server.ts index f9294bd..fd849b3 100644 --- a/src/trpc/init.server.ts +++ b/src/trpc/init.server.ts @@ -30,15 +30,9 @@ export const publicProcedure = t.procedure.use(async ({ next }) => { return result; }); -const authedProcedure = publicProcedure.use(async ({ ctx, next }) => { - if (!ctx.locals.session) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - return next(); -}); - +// `authorize` also rejects unauthenticated requests const sessionProcedure = (requiredPermission: "any" | "notClient") => - authedProcedure.use(async ({ ctx, next }) => { + publicProcedure.use(async ({ ctx, next }) => { const session: Session = await authorize(ctx.locals, requiredPermission); return next({ ctx: { session } }); }); @@ -46,7 +40,7 @@ const sessionProcedure = (requiredPermission: "any" | "notClient") => const clientSessionProcedure = ( requiredPermission: "anyClient" | "pendingClient" | "activeClient", ) => - authedProcedure.use(async ({ ctx, next }) => { + publicProcedure.use(async ({ ctx, next }) => { const session: ClientSession = await authorize(ctx.locals, requiredPermission); return next({ ctx: { session } }); }); diff --git a/src/trpc/schemas.ts b/src/trpc/schemas.ts index 5d6f3c2..3236f63 100644 --- a/src/trpc/schemas.ts +++ b/src/trpc/schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const IdSchema = z.int().positive(); export const DirectoryIdSchema = z.union([z.literal("root"), IdSchema]); -export const CategoryIdSchema = z.union([z.literal("root"), IdSchema]); +export const CategoryIdSchema = DirectoryIdSchema; export const Base64Schema = z.base64().nonempty(); export const EncryptedNameSchema = {