diff --git a/AGENTS.md b/AGENTS.md index 7f74c84..c0770c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ backend: convex This project uses npm workspaces. - `packages/convex` - convex functions and models - `apps/drive-web` - frontend dashboard +- `apps/file-proxy` - proxies uploaded files via opaque share tokens - `packages/path` - path utils # General Guidelines diff --git a/apps/drive-web/.env.sample b/apps/drive-web/.env.sample index 644cb2c..837fc12 100644 --- a/apps/drive-web/.env.sample +++ b/apps/drive-web/.env.sample @@ -1,4 +1,6 @@ # this is the url to the convex instance (NOT THE DASHBOARD) VITE_CONVEX_URL= # this is the convex url for invoking http actions -VITE_CONVEX_SITE_URL= \ No newline at end of file +VITE_CONVEX_SITE_URL= +# this is the url to the file proxy +FILE_PROXY_URL= \ No newline at end of file diff --git a/apps/drive-web/src/files/file-preview-dialog.tsx b/apps/drive-web/src/files/file-preview-dialog.tsx index 905eb95..b6ba23a 100644 --- a/apps/drive-web/src/files/file-preview-dialog.tsx +++ b/apps/drive-web/src/files/file-preview-dialog.tsx @@ -1,20 +1,20 @@ -import type { Doc } from "@fileone/convex/dataModel" +import type { OpenedFile } from "@fileone/convex/filesystem" import { ImagePreviewDialog } from "./image-preview-dialog" export function FilePreviewDialog({ - file, + openedFile, onClose, }: { - file: Doc<"files"> + openedFile: OpenedFile onClose: () => void }) { - if (!file) return null - - switch (file.mimeType) { + switch (openedFile.file.mimeType) { case "image/jpeg": case "image/png": case "image/gif": - return + return ( + + ) default: return null } diff --git a/apps/drive-web/src/files/file-share.ts b/apps/drive-web/src/files/file-share.ts new file mode 100644 index 0000000..c81b7ac --- /dev/null +++ b/apps/drive-web/src/files/file-share.ts @@ -0,0 +1,3 @@ +export function fileShareUrl(shareToken: string) { + return `${import.meta.env.VITE_FILE_PROXY_URL}/files/${shareToken}` +} diff --git a/apps/drive-web/src/files/image-preview-dialog.tsx b/apps/drive-web/src/files/image-preview-dialog.tsx index 998faee..dfe89b0 100644 --- a/apps/drive-web/src/files/image-preview-dialog.tsx +++ b/apps/drive-web/src/files/image-preview-dialog.tsx @@ -1,7 +1,5 @@ -import { api } from "@fileone/convex/api" -import type { Doc } from "@fileone/convex/dataModel" +import type { OpenedFile } from "@fileone/convex/filesystem" import { DialogTitle } from "@radix-ui/react-dialog" -import { useQuery as useConvexQuery } from "convex/react" import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import { DownloadIcon, @@ -18,9 +16,8 @@ import { DialogClose, DialogContent, DialogHeader, - DialogOverlay, } from "@/components/ui/dialog" -import { LoadingSpinner } from "@/components/ui/loading-spinner" +import { fileShareUrl } from "./file-share" const zoomLevelAtom = atom( 1, @@ -35,15 +32,12 @@ const zoomLevelAtom = atom( ) export function ImagePreviewDialog({ - file, + openedFile, onClose, }: { - file: Doc<"files"> + openedFile: OpenedFile onClose: () => void }) { - const fileUrl = useConvexQuery(api.filesystem.fetchFileUrl, { - fileId: file._id, - }) const setZoomLevel = useSetAtom(zoomLevelAtom) useEffect( @@ -62,23 +56,12 @@ export function ImagePreviewDialog({ } }} > - - {!fileUrl ? ( - - ) : null} - - {fileUrl ? : null} + ) } -function PreviewContent({ - fileUrl, - file, -}: { - fileUrl: string - file: Doc<"files"> -}) { +function PreviewContent({ openedFile }: { openedFile: OpenedFile }) { return ( - {file.name} + {openedFile.file.name} - + @@ -99,13 +82,13 @@ function PreviewContent({ - + ) } -function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) { +function Toolbar({ openedFile }: { openedFile: OpenedFile }) { const setZoomLevel = useSetAtom(zoomLevelAtom) const zoomInterval = useRef | null>(null) @@ -159,8 +142,8 @@ function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) { @@ -191,18 +174,12 @@ function ResetZoomButton() { ) } -function ImagePreview({ - fileUrl, - file, -}: { - fileUrl: string - file: Doc<"files"> -}) { +function ImagePreview({ openedFile }: { openedFile: OpenedFile }) { const zoomLevel = useAtomValue(zoomLevelAtom) return ( diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 9068b02..be776eb 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -3,12 +3,14 @@ import type { Doc, Id } from "@fileone/convex/dataModel" import { type FileSystemItem, newFileSystemHandle, + type OpenedFile, } from "@fileone/convex/filesystem" import { useMutation } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import type { Row, Table } from "@tanstack/react-table" import { useMutation as useContextMutation, + useMutation as useConvexMutation, useQuery as useConvexQuery, } from "convex/react" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" @@ -77,22 +79,12 @@ const fileDragInfoAtom = atom(null) const optimisticDeletedItemsAtom = atom( new Set | Id<"directories">>(), ) -const openedFileAtom = atom | null>(null) +const openedFileAtom = atom(null) const itemBeingRenamedAtom = atom<{ originalItem: FileSystemItem name: string } | null>(null) -const tableFilterAtom = atom((get) => { - const optimisticDeletedItems = get(optimisticDeletedItemsAtom) - console.log("optimisticDeletedItems", optimisticDeletedItems) - return (item: FileSystemItem) => { - const test = !optimisticDeletedItems.has(item.doc._id) - console.log("test", test) - return test - } -}) - // MARK: page entry function RouteComponent() { const { directoryId } = Route.useParams() @@ -100,7 +92,6 @@ function RouteComponent() { const directory = useConvexQuery(api.files.fetchDirectory, { directoryId, }) - const store = useStore() const directoryContent = useConvexQuery( api.filesystem.fetchDirectoryContent, { @@ -108,45 +99,12 @@ function RouteComponent() { trashed: false, }, ) - const setOpenedFile = useSetAtom(openedFileAtom) - const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) - - const tableFilter = useCallback( - (item: FileSystemItem) => - store.get(optimisticDeletedItemsAtom).has(item.doc._id), - [store], - ) - - const openFile = useCallback( - (file: Doc<"files">) => { - setOpenedFile(file) - }, - [setOpenedFile], - ) - - const directoryUrlFn = useCallback( - (directory: Doc<"directories">) => `/directories/${directory._id}`, - [], - ) const directoryUrlById = useCallback( (directoryId: Id<"directories">) => `/directories/${directoryId}`, [], ) - const handleContextMenuRequest = ( - row: Row, - table: Table, - ) => { - if (row.getIsSelected()) { - setContextMenuTargetItems( - table.getSelectedRowModel().rows.map((row) => row.original), - ) - } else { - setContextMenuTargetItems([row.original]) - } - } - if (!directory || !directoryContent || !rootDirectory) { return } @@ -171,17 +129,7 @@ function RouteComponent() { {/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */} - - {(optimisticDeletedItems) => ( - - )} - + <_DirectoryContentTable /> @@ -228,7 +176,7 @@ function RouteComponent() { if (!openedFile) return null return ( setOpenedFile(null)} /> ) @@ -238,6 +186,58 @@ function RouteComponent() { ) } +// MARK: directory table + +function _DirectoryContentTable() { + const optimisticDeletedItems = useAtomValue(optimisticDeletedItemsAtom) + const setOpenedFile = useSetAtom(openedFileAtom) + const setContextMenuTargetItems = useSetAtom(contextMenuTargetItemsAtom) + + const { mutate: openFile } = useMutation({ + mutationFn: useConvexMutation(api.filesystem.openFile), + onSuccess: (openedFile: OpenedFile) => { + console.log("openedFile", openedFile) + setOpenedFile(openedFile) + }, + onError: (error) => { + console.error(error) + toast.error("Failed to open file") + }, + }) + + const onTableOpenFile = (file: Doc<"files">) => { + openFile({ fileId: file._id }) + } + + const directoryUrlFn = useCallback( + (directory: Doc<"directories">) => `/directories/${directory._id}`, + [], + ) + + const handleContextMenuRequest = ( + row: Row, + table: Table, + ) => { + if (row.getIsSelected()) { + setContextMenuTargetItems( + table.getSelectedRowModel().rows.map((row) => row.original), + ) + } else { + setContextMenuTargetItems([row.original]) + } + } + + return ( + + ) +} + // ================================== // MARK: ctx menu diff --git a/apps/file-proxy/.env.sample b/apps/file-proxy/.env.sample new file mode 100644 index 0000000..0485654 --- /dev/null +++ b/apps/file-proxy/.env.sample @@ -0,0 +1,4 @@ +CONVEX_URL= +# api key used to auth with the convex backend +# use the drexa cli to generate an api key, then add the api key to the api key table via the convex dashboard +API_KEY= \ No newline at end of file diff --git a/apps/file-proxy/auth.ts b/apps/file-proxy/auth.ts new file mode 100644 index 0000000..50500d2 --- /dev/null +++ b/apps/file-proxy/auth.ts @@ -0,0 +1,14 @@ +import { createMiddleware } from "hono/factory" + +export type ApiKeyContextVariable = { + apiKey: string +} + +const apiKeyMiddleware = createMiddleware<{ Variables: ApiKeyContextVariable }>( + async (c, next) => { + c.set("apiKey", process.env.API_KEY) + await next() + }, +) + +export { apiKeyMiddleware } diff --git a/apps/file-proxy/convex.ts b/apps/file-proxy/convex.ts index e69de29..22e28e6 100644 --- a/apps/file-proxy/convex.ts +++ b/apps/file-proxy/convex.ts @@ -0,0 +1,16 @@ +import { ConvexHttpClient } from "convex/browser" +import { createMiddleware } from "hono/factory" + +const _client = new ConvexHttpClient(process.env.CONVEX_URL) + +export type ConvexContextVariables = { + convex: ConvexHttpClient +} + +export const convexMiddleware = createMiddleware<{ + Variables: ConvexContextVariables +}>(async (c, next) => { + c.var + c.set("convex", _client) + await next() +}) diff --git a/apps/file-proxy/env.d.ts b/apps/file-proxy/env.d.ts new file mode 100644 index 0000000..bcb5da7 --- /dev/null +++ b/apps/file-proxy/env.d.ts @@ -0,0 +1,6 @@ +declare module "bun" { + interface Env { + CONVEX_URL: string + API_KEY: string + } +} diff --git a/apps/file-proxy/files.ts b/apps/file-proxy/files.ts index 9f95da9..afee343 100644 --- a/apps/file-proxy/files.ts +++ b/apps/file-proxy/files.ts @@ -1,12 +1,39 @@ -import { Hono } from "hono" +import { api } from "@fileone/convex/api" +import { newRouter } from "./router" -const h = new Hono().basePath("/files") +const r = newRouter().basePath("/files") -h.get("/:fileId", async (c) => { - const fileId = c.req.param("fileId") - if (!fileId) { - return c.json({ error: "File ID is required" }, 400) +r.get(":shareToken", async (c) => { + const shareToken = c.req.param("shareToken") + if (!shareToken) { + return c.json({ error: "not found" }, 404) } + + const fileShare = await c.var.convex.query(api.fileshare.findFileShare, { + apiKey: c.var.apiKey, + shareToken, + }) + if (!fileShare) { + return c.json({ error: "not found" }, 404) + } + + const fileUrl = await c.var.convex.query(api.filesystem.getStorageUrl, { + apiKey: c.var.apiKey, + storageId: fileShare.storageId, + }) + if (!fileUrl) { + return c.json({ error: "not found" }, 404) + } + + const fileResponse = await fetch(fileUrl) + if (!fileResponse.ok) { + return c.json({ error: "not found" }, 404) + } + + return new Response(fileResponse.body, { + status: fileResponse.status, + headers: fileResponse.headers, + }) }) -export { h as files } +export { r as files } diff --git a/apps/file-proxy/index.ts b/apps/file-proxy/index.ts index 6be95ea..be00554 100644 --- a/apps/file-proxy/index.ts +++ b/apps/file-proxy/index.ts @@ -1,10 +1,16 @@ import { Hono } from "hono" -import { handleFileRequest } from "./files" +import { apiKeyMiddleware } from "./auth" +import { convexMiddleware } from "./convex" +import { files } from "./files" -Bun.serve({ - routes: { - "/files/:fileId": { - GET: handleFileRequest, - }, - }, -}) +const app = new Hono() + +app.use(convexMiddleware) +app.use(apiKeyMiddleware) + +app.route("/", files) + +export default { + port: 8081, + fetch: app.fetch, +} diff --git a/apps/file-proxy/package.json b/apps/file-proxy/package.json index f4a9f31..73fedb8 100644 --- a/apps/file-proxy/package.json +++ b/apps/file-proxy/package.json @@ -3,6 +3,9 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "dev": "bun --hot run index.ts" + }, "devDependencies": { "@types/bun": "latest" }, @@ -11,6 +14,7 @@ }, "dependencies": { "@fileone/convex": "workspace:*", + "arktype": "^2.1.23", "convex": "^1.28.0", "hono": "^4.10.1" } diff --git a/apps/file-proxy/router.ts b/apps/file-proxy/router.ts index bf79c29..acfd5da 100644 --- a/apps/file-proxy/router.ts +++ b/apps/file-proxy/router.ts @@ -1,7 +1,11 @@ -import type { RouterTypes } from "bun" +import { Hono } from "hono" +import type { ApiKeyContextVariable } from "./auth" +import type { ConvexContextVariables } from "./convex" -function router< - R extends { [K in keyof R]: RouterTypes.RouteValue> }, ->(routes: R): R { - return routes +type ContextVariables = ConvexContextVariables & ApiKeyContextVariable + +export function newRouter() { + return new Hono<{ + Variables: ContextVariables + }>() } diff --git a/bun.lock b/bun.lock index 3f84113..bd70f52 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,7 @@ "name": "@drexa/file-proxy", "dependencies": { "@fileone/convex": "workspace:*", + "arktype": "^2.1.23", "convex": "^1.28.0", "hono": "^4.10.1", }, @@ -128,6 +129,12 @@ "convex": "1.28.0", }, "packages": { + "@ark/regex": ["@ark/regex@0.0.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg=="], + + "@ark/schema": ["@ark/schema@0.50.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-hfmP82GltBZDadIOeR3argKNlYYyB2wyzHp0eeAqAOFBQguglMV/S7Ip2q007bRtKxIMLDqFY6tfPie1dtssaQ=="], + + "@ark/util": ["@ark/util@0.50.0", "", {}, "sha512-tIkgIMVRpkfXRQIEf0G2CJryZVtHVrqcWHMDa5QKo0OEEBu0tHkRSIMm4Ln8cd8Bn9TPZtvc/kE2Gma8RESPSg=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="], @@ -542,6 +549,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "arktype": ["arktype@2.1.23", "", { "dependencies": { "@ark/regex": "0.0.0", "@ark/schema": "0.50.0", "@ark/util": "0.50.0" } }, "sha512-tyxNWX6xJVMb2EPJJ3OjgQS1G/vIeQRrZuY4DeBNQmh8n7geS+czgbauQWB6Pr+RXiOO8ChEey44XdmxsqGmfQ=="], + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], diff --git a/packages/convex/_generated/api.d.ts b/packages/convex/_generated/api.d.ts index b468048..7e6fd61 100644 --- a/packages/convex/_generated/api.d.ts +++ b/packages/convex/_generated/api.d.ts @@ -17,12 +17,15 @@ import type * as betterauth_auth from "../betterauth/auth.js"; import type * as convex__generated_api from "../convex/_generated/api.js"; import type * as convex__generated_server from "../convex/_generated/server.js"; import type * as files from "../files.js"; +import type * as fileshare from "../fileshare.js"; import type * as filesystem from "../filesystem.js"; import type * as functions from "../functions.js"; import type * as http from "../http.js"; import type * as model_apikey from "../model/apikey.js"; import type * as model_directories from "../model/directories.js"; +import type * as model_filepreview from "../model/filepreview.js"; import type * as model_files from "../model/files.js"; +import type * as model_fileshare from "../model/fileshare.js"; import type * as model_filesystem from "../model/filesystem.js"; import type * as model_user from "../model/user.js"; import type * as shared_error from "../shared/error.js"; @@ -54,12 +57,15 @@ declare const fullApi: ApiFromModules<{ "convex/_generated/api": typeof convex__generated_api; "convex/_generated/server": typeof convex__generated_server; files: typeof files; + fileshare: typeof fileshare; filesystem: typeof filesystem; functions: typeof functions; http: typeof http; "model/apikey": typeof model_apikey; "model/directories": typeof model_directories; + "model/filepreview": typeof model_filepreview; "model/files": typeof model_files; + "model/fileshare": typeof model_fileshare; "model/filesystem": typeof model_filesystem; "model/user": typeof model_user; "shared/error": typeof shared_error; diff --git a/packages/convex/fileshare.ts b/packages/convex/fileshare.ts new file mode 100644 index 0000000..daffb41 --- /dev/null +++ b/packages/convex/fileshare.ts @@ -0,0 +1,12 @@ +import { v } from "convex/values" +import { apiKeyAuthenticatedQuery } from "./functions" +import * as FileShare from "./model/fileshare" + +export const findFileShare = apiKeyAuthenticatedQuery({ + args: { + shareToken: v.string(), + }, + handler: async (ctx, { shareToken }) => { + return await FileShare.find(ctx, { shareToken }) + }, +}) diff --git a/packages/convex/filesystem.ts b/packages/convex/filesystem.ts index cbf5be8..9420b04 100644 --- a/packages/convex/filesystem.ts +++ b/packages/convex/filesystem.ts @@ -1,11 +1,13 @@ import { v } from "convex/values" import { + apiKeyAuthenticatedQuery, authenticatedMutation, authenticatedQuery, authorizedGet, } from "./functions" import * as Directories from "./model/directories" import * as Files from "./model/files" +import * as FileSystem from "./model/filesystem" import { deleteItemsPermanently, emptyTrash as emptyTrashImpl, @@ -160,6 +162,15 @@ export const restoreItems = authenticatedMutation({ }, }) +export const getStorageUrl = apiKeyAuthenticatedQuery({ + args: { + storageId: v.id("_storage"), + }, + handler: async (ctx, { storageId }) => { + return await ctx.storage.getUrl(storageId) + }, +}) + export const fetchFileUrl = authenticatedQuery({ args: { fileId: v.id("files"), @@ -168,3 +179,12 @@ export const fetchFileUrl = authenticatedQuery({ return await fetchFileUrlImpl(ctx, { fileId }) }, }) + +export const openFile = authenticatedMutation({ + args: { + fileId: v.id("files"), + }, + handler: async (ctx, { fileId }) => { + return await FileSystem.openFile(ctx, { fileId }) + }, +}) diff --git a/packages/convex/functions.ts b/packages/convex/functions.ts index 0f4e0aa..1fb2127 100644 --- a/packages/convex/functions.ts +++ b/packages/convex/functions.ts @@ -26,6 +26,10 @@ export type AuthenticatedMutationCtx = MutationCtx & { identity: UserIdentity } +export type ApiKeyAuthenticatedQueryCtx = QueryCtx & { + __branded: "ApiKeyAuthenticatedQueryCtx" +} + /** * Custom query that automatically provides authenticated user context * Throws an error if the user is not authenticated @@ -52,6 +56,21 @@ export const authenticatedMutation = customMutation( }), ) +/** + * Custom query that requires api key authentication for a query. + */ +export const apiKeyAuthenticatedQuery = customQuery(query, { + args: { + apiKey: v.string(), + }, + input: async (ctx, args) => { + if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) { + throw Err.create(Err.Code.Unauthenticated, "Invalid API key") + } + return { ctx: ctx as ApiKeyAuthenticatedQueryCtx, args } + }, +}) + /** * Custom mutation that requires api key authentication for a mutation. */ diff --git a/packages/convex/model/filepreview.ts b/packages/convex/model/filepreview.ts new file mode 100644 index 0000000..4e2ba6e --- /dev/null +++ b/packages/convex/model/filepreview.ts @@ -0,0 +1,36 @@ +import type { Doc, Id } from "../_generated/dataModel" +import type { + AuthenticatedMutationCtx, + AuthenticatedQueryCtx, +} from "../functions" +import * as FileShare from "./fileshare" + +const PREVIEW_FILE_SHARE_VALID_FOR_MS = 1000 * 60 * 10 // 10 minutes + +export async function find( + ctx: AuthenticatedMutationCtx | AuthenticatedQueryCtx, + { storageId }: { storageId: Id<"_storage"> }, +) { + return await FileShare.findByStorageId(ctx, { storageId }) +} + +export async function create( + ctx: AuthenticatedMutationCtx, + { storageId }: { storageId: Id<"_storage"> }, +) { + return await FileShare.create(ctx, { + shareToken: crypto.randomUUID(), + storageId, + expiresAt: new Date(Date.now() + PREVIEW_FILE_SHARE_VALID_FOR_MS), + }) +} + +export async function extend( + ctx: AuthenticatedMutationCtx, + { doc }: { doc: Doc<"fileShares"> }, +) { + return await FileShare.updateExpiry(ctx, { + doc, + expiresAt: new Date(Date.now() + PREVIEW_FILE_SHARE_VALID_FOR_MS), + }) +} diff --git a/packages/convex/model/fileshare.ts b/packages/convex/model/fileshare.ts new file mode 100644 index 0000000..c54b606 --- /dev/null +++ b/packages/convex/model/fileshare.ts @@ -0,0 +1,83 @@ +import type { Doc, Id } from "../_generated/dataModel" +import type { MutationCtx } from "../_generated/server" +import type { + ApiKeyAuthenticatedQueryCtx, + AuthenticatedMutationCtx, + AuthenticatedQueryCtx, +} from "../functions" +import * as Err from "../shared/error" + +export async function create( + ctx: MutationCtx, + { + shareToken, + storageId, + expiresAt, + }: { shareToken: string; storageId: Id<"_storage">; expiresAt?: Date }, +) { + const id = await ctx.db.insert("fileShares", { + shareToken, + storageId, + expiresAt: expiresAt?.getTime(), + }) + const doc = await ctx.db.get(id) + if (!doc) { + throw Err.create(Err.Code.Internal, "Failed to create file share") + } + return doc +} + +export async function remove( + ctx: AuthenticatedMutationCtx, + { doc }: { doc: Doc<"fileShares"> }, +) { + return await ctx.db.delete(doc._id) +} + +export async function find( + ctx: + | AuthenticatedMutationCtx + | AuthenticatedQueryCtx + | ApiKeyAuthenticatedQueryCtx, + { shareToken }: { shareToken: string }, +) { + const doc = await ctx.db + .query("fileShares") + .withIndex("byShareToken", (q) => q.eq("shareToken", shareToken)) + .first() + if (!doc) { + throw Err.create(Err.Code.NotFound, "File share not found") + } + + if (hasExpired(doc)) { + throw Err.create(Err.Code.NotFound, "File share not found") + } + + return doc +} + +export async function findByStorageId( + ctx: AuthenticatedMutationCtx | AuthenticatedQueryCtx, + { storageId }: { storageId: Id<"_storage"> }, +) { + return await ctx.db + .query("fileShares") + .withIndex("byStorageId", (q) => q.eq("storageId", storageId)) + .first() +} + +export function hasExpired(doc: Doc<"fileShares">) { + if (!doc.expiresAt) { + return false + } + return doc.expiresAt < Date.now() +} + +export async function updateExpiry( + ctx: AuthenticatedMutationCtx, + { doc, expiresAt }: { doc: Doc<"fileShares">; expiresAt: Date }, +) { + return await ctx.db.patch(doc._id, { + expiresAt: expiresAt.getTime(), + }) +} diff --git a/packages/convex/model/filesystem.ts b/packages/convex/model/filesystem.ts index a2aa1e0..f76e25c 100644 --- a/packages/convex/model/filesystem.ts +++ b/packages/convex/model/filesystem.ts @@ -17,7 +17,9 @@ import { newFileHandle, } from "../shared/filesystem" import * as Directories from "./directories" +import * as FilePreview from "./filepreview" import * as Files from "./files" +import * as FileShare from "./fileshare" export const VDirectoryHandle = v.object({ kind: v.literal(FileType.Directory), @@ -228,3 +230,33 @@ export async function fetchFileUrl( return url } + +export async function openFile( + ctx: AuthenticatedMutationCtx, + { fileId }: { fileId: Id<"files"> }, +) { + const file = await authorizedGet(ctx, fileId) + if (!file) { + throw Err.create(Err.Code.NotFound, "file not found") + } + + const fileShare = await FilePreview.find(ctx, { + storageId: file.storageId, + }) + if (fileShare && !FileShare.hasExpired(fileShare)) { + await FilePreview.extend(ctx, { doc: fileShare }) + return { + file, + shareToken: fileShare.shareToken, + } + } + + const newFileShare = await FilePreview.create(ctx, { + storageId: file.storageId, + }) + + return { + file, + shareToken: newFileShare.shareToken, + } +} diff --git a/packages/convex/schema.ts b/packages/convex/schema.ts index 072d5cb..a626c2f 100644 --- a/packages/convex/schema.ts +++ b/packages/convex/schema.ts @@ -24,6 +24,7 @@ const schema = defineSchema({ "name", "deletedAt", ]), + directories: defineTable({ name: v.string(), userId: v.string(), // BetterAuth user IDs are strings, not Convex Ids @@ -40,13 +41,21 @@ const schema = defineSchema({ "name", "deletedAt", ]), + apiKeys: defineTable({ publicId: v.string(), hashedKey: v.string(), - createdAt: v.number(), - updatedAt: v.number(), expiresAt: v.optional(v.number()), }).index("byPublicId", ["publicId"]), + + fileShares: defineTable({ + shareToken: v.string(), + storageId: v.id("_storage"), + expiresAt: v.optional(v.number()), + }) + .index("byShareToken", ["shareToken"]) + .index("byExpiredAt", ["expiresAt"]) + .index("byStorageId", ["storageId"]), }) export default schema diff --git a/packages/convex/shared/filesystem.ts b/packages/convex/shared/filesystem.ts index 9a456cd..0e0b32b 100644 --- a/packages/convex/shared/filesystem.ts +++ b/packages/convex/shared/filesystem.ts @@ -57,6 +57,11 @@ export type FileHandle = { export type FileSystemHandle = DirectoryHandle | FileHandle +export type OpenedFile = { + file: Doc<"files"> + shareToken: string +} + export type DeleteResult = { deleted: { files: number