feat: initial impl of file proxy

This commit is contained in:
2025-10-21 23:45:04 +00:00
parent 6eded27121
commit 6234c5efd3
24 changed files with 420 additions and 125 deletions

View File

@@ -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=
VITE_CONVEX_SITE_URL=
# this is the url to the file proxy
FILE_PROXY_URL=

View File

@@ -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 <ImagePreviewDialog file={file} onClose={onClose} />
return (
<ImagePreviewDialog openedFile={openedFile} onClose={onClose} />
)
default:
return null
}

View File

@@ -0,0 +1,3 @@
export function fileShareUrl(shareToken: string) {
return `${import.meta.env.VITE_FILE_PROXY_URL}/files/${shareToken}`
}

View File

@@ -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({
}
}}
>
<DialogOverlay className="flex items-center justify-center">
{!fileUrl ? (
<LoadingSpinner className="text-neutral-200 size-10" />
) : null}
</DialogOverlay>
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
<PreviewContent openedFile={openedFile} />
</Dialog>
)
}
function PreviewContent({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
function PreviewContent({ openedFile }: { openedFile: OpenedFile }) {
return (
<DialogContent
showCloseButton={false}
@@ -86,10 +69,10 @@ function PreviewContent({
>
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle className="truncate flex-1">
{file.name}
{openedFile.file.name}
</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar fileUrl={fileUrl} file={file} />
<Toolbar openedFile={openedFile} />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
@@ -99,13 +82,13 @@ function PreviewContent({
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview fileUrl={fileUrl} file={file} />
<ImagePreview openedFile={openedFile} />
</div>
</DialogContent>
)
}
function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
function Toolbar({ openedFile }: { openedFile: OpenedFile }) {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -159,8 +142,8 @@ function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
</Button>
<Button asChild>
<a
href={fileUrl}
download={file.name}
href={fileShareUrl(openedFile.shareToken)}
download={openedFile.file.name}
target="_blank"
className="flex flex-row items-center"
>
@@ -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 (
<img
src={fileUrl}
alt={file.name}
src={fileShareUrl(openedFile.shareToken)}
alt={openedFile.file.name}
className="object-contain"
style={{ transform: `scale(${zoomLevel})` }}
/>

View File

@@ -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<FileDragInfo | null>(null)
const optimisticDeletedItemsAtom = atom(
new Set<Id<"files"> | Id<"directories">>(),
)
const openedFileAtom = atom<Doc<"files"> | null>(null)
const openedFileAtom = atom<OpenedFile | null>(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<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
if (!directory || !directoryContent || !rootDirectory) {
return <DirectoryPageSkeleton />
}
@@ -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 */}
<DirectoryContentContextMenu>
<div className="w-full">
<WithAtom atom={optimisticDeletedItemsAtom}>
{(optimisticDeletedItems) => (
<DirectoryContentTable
hiddenItems={optimisticDeletedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={openFile}
/>
)}
</WithAtom>
<_DirectoryContentTable />
</div>
</DirectoryContentContextMenu>
@@ -228,7 +176,7 @@ function RouteComponent() {
if (!openedFile) return null
return (
<FilePreviewDialog
file={openedFile}
openedFile={openedFile}
onClose={() => 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<FileSystemItem>,
table: Table<FileSystemItem>,
) => {
if (row.getIsSelected()) {
setContextMenuTargetItems(
table.getSelectedRowModel().rows.map((row) => row.original),
)
} else {
setContextMenuTargetItems([row.original])
}
}
return (
<DirectoryContentTable
hiddenItems={optimisticDeletedItems}
directoryUrlFn={directoryUrlFn}
fileDragInfoAtom={fileDragInfoAtom}
onContextMenu={handleContextMenuRequest}
onOpenFile={onTableOpenFile}
/>
)
}
// ==================================
// MARK: ctx menu

View File

@@ -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=

14
apps/file-proxy/auth.ts Normal file
View File

@@ -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 }

View File

@@ -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()
})

6
apps/file-proxy/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "bun" {
interface Env {
CONVEX_URL: string
API_KEY: string
}
}

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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"
}

View File

@@ -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<Extract<K, string>> },
>(routes: R): R {
return routes
type ContextVariables = ConvexContextVariables & ApiKeyContextVariable
export function newRouter() {
return new Hono<{
Variables: ContextVariables
}>()
}