mirror of
https://github.com/get-drexa/drive.git
synced 2025-11-30 21:41:39 +00:00
Compare commits
2 Commits
6eded27121
...
a862442979
| Author | SHA1 | Date | |
|---|---|---|---|
|
a862442979
|
|||
|
6234c5efd3
|
@@ -6,6 +6,7 @@ backend: convex
|
|||||||
This project uses npm workspaces.
|
This project uses npm workspaces.
|
||||||
- `packages/convex` - convex functions and models
|
- `packages/convex` - convex functions and models
|
||||||
- `apps/drive-web` - frontend dashboard
|
- `apps/drive-web` - frontend dashboard
|
||||||
|
- `apps/file-proxy` - proxies uploaded files via opaque share tokens
|
||||||
- `packages/path` - path utils
|
- `packages/path` - path utils
|
||||||
|
|
||||||
# General Guidelines
|
# General Guidelines
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
VITE_CONVEX_URL=
|
VITE_CONVEX_URL=
|
||||||
# this is the convex url for invoking http actions
|
# 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=
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { Doc } from "@fileone/convex/dataModel"
|
import type { OpenedFile } from "@fileone/convex/filesystem"
|
||||||
import { ImagePreviewDialog } from "./image-preview-dialog"
|
import { ImagePreviewDialog } from "./image-preview-dialog"
|
||||||
|
|
||||||
export function FilePreviewDialog({
|
export function FilePreviewDialog({
|
||||||
file,
|
openedFile,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
file: Doc<"files">
|
openedFile: OpenedFile
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
if (!file) return null
|
switch (openedFile.file.mimeType) {
|
||||||
|
|
||||||
switch (file.mimeType) {
|
|
||||||
case "image/jpeg":
|
case "image/jpeg":
|
||||||
case "image/png":
|
case "image/png":
|
||||||
case "image/gif":
|
case "image/gif":
|
||||||
return <ImagePreviewDialog file={file} onClose={onClose} />
|
return (
|
||||||
|
<ImagePreviewDialog openedFile={openedFile} onClose={onClose} />
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
3
apps/drive-web/src/files/file-share.ts
Normal file
3
apps/drive-web/src/files/file-share.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function fileShareUrl(shareToken: string) {
|
||||||
|
return `${import.meta.env.VITE_FILE_PROXY_URL}/files/${shareToken}`
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { api } from "@fileone/convex/api"
|
import type { OpenedFile } from "@fileone/convex/filesystem"
|
||||||
import type { Doc } from "@fileone/convex/dataModel"
|
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog"
|
import { DialogTitle } from "@radix-ui/react-dialog"
|
||||||
import { useQuery as useConvexQuery } from "convex/react"
|
|
||||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
|
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
@@ -18,9 +16,8 @@ import {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogOverlay,
|
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
import { fileShareUrl } from "./file-share"
|
||||||
|
|
||||||
const zoomLevelAtom = atom(
|
const zoomLevelAtom = atom(
|
||||||
1,
|
1,
|
||||||
@@ -35,15 +32,12 @@ const zoomLevelAtom = atom(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export function ImagePreviewDialog({
|
export function ImagePreviewDialog({
|
||||||
file,
|
openedFile,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
file: Doc<"files">
|
openedFile: OpenedFile
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const fileUrl = useConvexQuery(api.filesystem.fetchFileUrl, {
|
|
||||||
fileId: file._id,
|
|
||||||
})
|
|
||||||
const setZoomLevel = useSetAtom(zoomLevelAtom)
|
const setZoomLevel = useSetAtom(zoomLevelAtom)
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -62,23 +56,12 @@ export function ImagePreviewDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogOverlay className="flex items-center justify-center">
|
<PreviewContent openedFile={openedFile} />
|
||||||
{!fileUrl ? (
|
|
||||||
<LoadingSpinner className="text-neutral-200 size-10" />
|
|
||||||
) : null}
|
|
||||||
</DialogOverlay>
|
|
||||||
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewContent({
|
function PreviewContent({ openedFile }: { openedFile: OpenedFile }) {
|
||||||
fileUrl,
|
|
||||||
file,
|
|
||||||
}: {
|
|
||||||
fileUrl: string
|
|
||||||
file: Doc<"files">
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
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">
|
<DialogHeader className="overflow-auto border-b border-b-border p-4 flex flex-row items-center justify-between">
|
||||||
<DialogTitle className="truncate flex-1">
|
<DialogTitle className="truncate flex-1">
|
||||||
{file.name}
|
{openedFile.file.name}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<div className="flex flex-row items-center space-x-2">
|
||||||
<Toolbar fileUrl={fileUrl} file={file} />
|
<Toolbar openedFile={openedFile} />
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<DialogClose>
|
<DialogClose>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
@@ -99,13 +82,13 @@ function PreviewContent({
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
|
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
|
function Toolbar({ openedFile }: { openedFile: OpenedFile }) {
|
||||||
const setZoomLevel = useSetAtom(zoomLevelAtom)
|
const setZoomLevel = useSetAtom(zoomLevelAtom)
|
||||||
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
|
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
@@ -159,8 +142,8 @@ function Toolbar({ fileUrl, file }: { fileUrl: string; file: Doc<"files"> }) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a
|
<a
|
||||||
href={fileUrl}
|
href={fileShareUrl(openedFile.shareToken)}
|
||||||
download={file.name}
|
download={openedFile.file.name}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
@@ -191,18 +174,12 @@ function ResetZoomButton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImagePreview({
|
function ImagePreview({ openedFile }: { openedFile: OpenedFile }) {
|
||||||
fileUrl,
|
|
||||||
file,
|
|
||||||
}: {
|
|
||||||
fileUrl: string
|
|
||||||
file: Doc<"files">
|
|
||||||
}) {
|
|
||||||
const zoomLevel = useAtomValue(zoomLevelAtom)
|
const zoomLevel = useAtomValue(zoomLevelAtom)
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={fileShareUrl(openedFile.shareToken)}
|
||||||
alt={file.name}
|
alt={openedFile.file.name}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
style={{ transform: `scale(${zoomLevel})` }}
|
style={{ transform: `scale(${zoomLevel})` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type { Doc, Id } from "@fileone/convex/dataModel"
|
|||||||
import {
|
import {
|
||||||
type FileSystemItem,
|
type FileSystemItem,
|
||||||
newFileSystemHandle,
|
newFileSystemHandle,
|
||||||
|
type OpenedFile,
|
||||||
} from "@fileone/convex/filesystem"
|
} from "@fileone/convex/filesystem"
|
||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import type { Row, Table } from "@tanstack/react-table"
|
import type { Row, Table } from "@tanstack/react-table"
|
||||||
import {
|
import {
|
||||||
useMutation as useContextMutation,
|
useMutation as useContextMutation,
|
||||||
|
useMutation as useConvexMutation,
|
||||||
useQuery as useConvexQuery,
|
useQuery as useConvexQuery,
|
||||||
} from "convex/react"
|
} from "convex/react"
|
||||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
||||||
@@ -77,22 +79,12 @@ const fileDragInfoAtom = atom<FileDragInfo | null>(null)
|
|||||||
const optimisticDeletedItemsAtom = atom(
|
const optimisticDeletedItemsAtom = atom(
|
||||||
new Set<Id<"files"> | Id<"directories">>(),
|
new Set<Id<"files"> | Id<"directories">>(),
|
||||||
)
|
)
|
||||||
const openedFileAtom = atom<Doc<"files"> | null>(null)
|
const openedFileAtom = atom<OpenedFile | null>(null)
|
||||||
const itemBeingRenamedAtom = atom<{
|
const itemBeingRenamedAtom = atom<{
|
||||||
originalItem: FileSystemItem
|
originalItem: FileSystemItem
|
||||||
name: string
|
name: string
|
||||||
} | null>(null)
|
} | 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
|
// MARK: page entry
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { directoryId } = Route.useParams()
|
const { directoryId } = Route.useParams()
|
||||||
@@ -100,7 +92,6 @@ function RouteComponent() {
|
|||||||
const directory = useConvexQuery(api.files.fetchDirectory, {
|
const directory = useConvexQuery(api.files.fetchDirectory, {
|
||||||
directoryId,
|
directoryId,
|
||||||
})
|
})
|
||||||
const store = useStore()
|
|
||||||
const directoryContent = useConvexQuery(
|
const directoryContent = useConvexQuery(
|
||||||
api.filesystem.fetchDirectoryContent,
|
api.filesystem.fetchDirectoryContent,
|
||||||
{
|
{
|
||||||
@@ -108,45 +99,12 @@ function RouteComponent() {
|
|||||||
trashed: false,
|
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(
|
const directoryUrlById = useCallback(
|
||||||
(directoryId: Id<"directories">) => `/directories/${directoryId}`,
|
(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) {
|
if (!directory || !directoryContent || !rootDirectory) {
|
||||||
return <DirectoryPageSkeleton />
|
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 must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
|
||||||
<DirectoryContentContextMenu>
|
<DirectoryContentContextMenu>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<WithAtom atom={optimisticDeletedItemsAtom}>
|
<_DirectoryContentTable />
|
||||||
{(optimisticDeletedItems) => (
|
|
||||||
<DirectoryContentTable
|
|
||||||
hiddenItems={optimisticDeletedItems}
|
|
||||||
directoryUrlFn={directoryUrlFn}
|
|
||||||
fileDragInfoAtom={fileDragInfoAtom}
|
|
||||||
onContextMenu={handleContextMenuRequest}
|
|
||||||
onOpenFile={openFile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</WithAtom>
|
|
||||||
</div>
|
</div>
|
||||||
</DirectoryContentContextMenu>
|
</DirectoryContentContextMenu>
|
||||||
|
|
||||||
@@ -228,7 +176,7 @@ function RouteComponent() {
|
|||||||
if (!openedFile) return null
|
if (!openedFile) return null
|
||||||
return (
|
return (
|
||||||
<FilePreviewDialog
|
<FilePreviewDialog
|
||||||
file={openedFile}
|
openedFile={openedFile}
|
||||||
onClose={() => setOpenedFile(null)}
|
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
|
// MARK: ctx menu
|
||||||
|
|
||||||
|
|||||||
4
apps/file-proxy/.env.sample
Normal file
4
apps/file-proxy/.env.sample
Normal 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
14
apps/file-proxy/auth.ts
Normal 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 }
|
||||||
@@ -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
6
apps/file-proxy/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "bun" {
|
||||||
|
interface Env {
|
||||||
|
CONVEX_URL: string
|
||||||
|
API_KEY: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => {
|
r.get(":shareToken", async (c) => {
|
||||||
const fileId = c.req.param("fileId")
|
const shareToken = c.req.param("shareToken")
|
||||||
if (!fileId) {
|
if (!shareToken) {
|
||||||
return c.json({ error: "File ID is required" }, 400)
|
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 }
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { handleFileRequest } from "./files"
|
import { apiKeyMiddleware } from "./auth"
|
||||||
|
import { convexMiddleware } from "./convex"
|
||||||
|
import { files } from "./files"
|
||||||
|
|
||||||
Bun.serve({
|
const app = new Hono()
|
||||||
routes: {
|
|
||||||
"/files/:fileId": {
|
app.use(convexMiddleware)
|
||||||
GET: handleFileRequest,
|
app.use(apiKeyMiddleware)
|
||||||
},
|
|
||||||
},
|
app.route("/", files)
|
||||||
})
|
|
||||||
|
export default {
|
||||||
|
port: 8081,
|
||||||
|
fetch: app.fetch,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --hot run index.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
@@ -11,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fileone/convex": "workspace:*",
|
"@fileone/convex": "workspace:*",
|
||||||
|
"arktype": "^2.1.23",
|
||||||
"convex": "^1.28.0",
|
"convex": "^1.28.0",
|
||||||
"hono": "^4.10.1"
|
"hono": "^4.10.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<
|
type ContextVariables = ConvexContextVariables & ApiKeyContextVariable
|
||||||
R extends { [K in keyof R]: RouterTypes.RouteValue<Extract<K, string>> },
|
|
||||||
>(routes: R): R {
|
export function newRouter() {
|
||||||
return routes
|
return new Hono<{
|
||||||
|
Variables: ContextVariables
|
||||||
|
}>()
|
||||||
}
|
}
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -83,6 +83,7 @@
|
|||||||
"name": "@drexa/file-proxy",
|
"name": "@drexa/file-proxy",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fileone/convex": "workspace:*",
|
"@fileone/convex": "workspace:*",
|
||||||
|
"arktype": "^2.1.23",
|
||||||
"convex": "^1.28.0",
|
"convex": "^1.28.0",
|
||||||
"hono": "^4.10.1",
|
"hono": "^4.10.1",
|
||||||
},
|
},
|
||||||
@@ -128,6 +129,12 @@
|
|||||||
"convex": "1.28.0",
|
"convex": "1.28.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --filter=@fileone/web dev",
|
"dev": "bun run --elide-lines=0 --filter './apps/*' dev",
|
||||||
"build": "bun run --filter=@fileone/web build",
|
"build": "bun run --filter=@fileone/web build",
|
||||||
"preview": "bun run --filter=@fileone/web preview",
|
"preview": "bun run --filter=@fileone/web preview",
|
||||||
"drexa": "bun run apps/cli/index.ts"
|
"drexa": "bun run apps/cli/index.ts"
|
||||||
|
|||||||
6
packages/convex/_generated/api.d.ts
vendored
6
packages/convex/_generated/api.d.ts
vendored
@@ -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_api from "../convex/_generated/api.js";
|
||||||
import type * as convex__generated_server from "../convex/_generated/server.js";
|
import type * as convex__generated_server from "../convex/_generated/server.js";
|
||||||
import type * as files from "../files.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 filesystem from "../filesystem.js";
|
||||||
import type * as functions from "../functions.js";
|
import type * as functions from "../functions.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
import type * as model_apikey from "../model/apikey.js";
|
import type * as model_apikey from "../model/apikey.js";
|
||||||
import type * as model_directories from "../model/directories.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_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_filesystem from "../model/filesystem.js";
|
||||||
import type * as model_user from "../model/user.js";
|
import type * as model_user from "../model/user.js";
|
||||||
import type * as shared_error from "../shared/error.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/api": typeof convex__generated_api;
|
||||||
"convex/_generated/server": typeof convex__generated_server;
|
"convex/_generated/server": typeof convex__generated_server;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
|
fileshare: typeof fileshare;
|
||||||
filesystem: typeof filesystem;
|
filesystem: typeof filesystem;
|
||||||
functions: typeof functions;
|
functions: typeof functions;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
"model/apikey": typeof model_apikey;
|
"model/apikey": typeof model_apikey;
|
||||||
"model/directories": typeof model_directories;
|
"model/directories": typeof model_directories;
|
||||||
|
"model/filepreview": typeof model_filepreview;
|
||||||
"model/files": typeof model_files;
|
"model/files": typeof model_files;
|
||||||
|
"model/fileshare": typeof model_fileshare;
|
||||||
"model/filesystem": typeof model_filesystem;
|
"model/filesystem": typeof model_filesystem;
|
||||||
"model/user": typeof model_user;
|
"model/user": typeof model_user;
|
||||||
"shared/error": typeof shared_error;
|
"shared/error": typeof shared_error;
|
||||||
|
|||||||
12
packages/convex/fileshare.ts
Normal file
12
packages/convex/fileshare.ts
Normal file
@@ -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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { v } from "convex/values"
|
import { v } from "convex/values"
|
||||||
import {
|
import {
|
||||||
|
apiKeyAuthenticatedQuery,
|
||||||
authenticatedMutation,
|
authenticatedMutation,
|
||||||
authenticatedQuery,
|
authenticatedQuery,
|
||||||
authorizedGet,
|
authorizedGet,
|
||||||
} from "./functions"
|
} from "./functions"
|
||||||
import * as Directories from "./model/directories"
|
import * as Directories from "./model/directories"
|
||||||
import * as Files from "./model/files"
|
import * as Files from "./model/files"
|
||||||
|
import * as FileSystem from "./model/filesystem"
|
||||||
import {
|
import {
|
||||||
deleteItemsPermanently,
|
deleteItemsPermanently,
|
||||||
emptyTrash as emptyTrashImpl,
|
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({
|
export const fetchFileUrl = authenticatedQuery({
|
||||||
args: {
|
args: {
|
||||||
fileId: v.id("files"),
|
fileId: v.id("files"),
|
||||||
@@ -168,3 +179,12 @@ export const fetchFileUrl = authenticatedQuery({
|
|||||||
return await fetchFileUrlImpl(ctx, { fileId })
|
return await fetchFileUrlImpl(ctx, { fileId })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const openFile = authenticatedMutation({
|
||||||
|
args: {
|
||||||
|
fileId: v.id("files"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { fileId }) => {
|
||||||
|
return await FileSystem.openFile(ctx, { fileId })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export type AuthenticatedMutationCtx = MutationCtx & {
|
|||||||
identity: UserIdentity
|
identity: UserIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiKeyAuthenticatedQueryCtx = QueryCtx & {
|
||||||
|
__branded: "ApiKeyAuthenticatedQueryCtx"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom query that automatically provides authenticated user context
|
* Custom query that automatically provides authenticated user context
|
||||||
* Throws an error if the user is not authenticated
|
* 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.
|
* Custom mutation that requires api key authentication for a mutation.
|
||||||
*/
|
*/
|
||||||
|
|||||||
36
packages/convex/model/filepreview.ts
Normal file
36
packages/convex/model/filepreview.ts
Normal file
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
83
packages/convex/model/fileshare.ts
Normal file
83
packages/convex/model/fileshare.ts
Normal file
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
newFileHandle,
|
newFileHandle,
|
||||||
} from "../shared/filesystem"
|
} from "../shared/filesystem"
|
||||||
import * as Directories from "./directories"
|
import * as Directories from "./directories"
|
||||||
|
import * as FilePreview from "./filepreview"
|
||||||
import * as Files from "./files"
|
import * as Files from "./files"
|
||||||
|
import * as FileShare from "./fileshare"
|
||||||
|
|
||||||
export const VDirectoryHandle = v.object({
|
export const VDirectoryHandle = v.object({
|
||||||
kind: v.literal(FileType.Directory),
|
kind: v.literal(FileType.Directory),
|
||||||
@@ -228,3 +230,33 @@ export async function fetchFileUrl(
|
|||||||
|
|
||||||
return url
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const schema = defineSchema({
|
|||||||
"name",
|
"name",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
directories: defineTable({
|
directories: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
userId: v.string(), // BetterAuth user IDs are strings, not Convex Ids
|
userId: v.string(), // BetterAuth user IDs are strings, not Convex Ids
|
||||||
@@ -40,13 +41,21 @@ const schema = defineSchema({
|
|||||||
"name",
|
"name",
|
||||||
"deletedAt",
|
"deletedAt",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
apiKeys: defineTable({
|
apiKeys: defineTable({
|
||||||
publicId: v.string(),
|
publicId: v.string(),
|
||||||
hashedKey: v.string(),
|
hashedKey: v.string(),
|
||||||
createdAt: v.number(),
|
|
||||||
updatedAt: v.number(),
|
|
||||||
expiresAt: v.optional(v.number()),
|
expiresAt: v.optional(v.number()),
|
||||||
}).index("byPublicId", ["publicId"]),
|
}).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
|
export default schema
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export type FileHandle = {
|
|||||||
|
|
||||||
export type FileSystemHandle = DirectoryHandle | FileHandle
|
export type FileSystemHandle = DirectoryHandle | FileHandle
|
||||||
|
|
||||||
|
export type OpenedFile = {
|
||||||
|
file: Doc<"files">
|
||||||
|
shareToken: string
|
||||||
|
}
|
||||||
|
|
||||||
export type DeleteResult = {
|
export type DeleteResult = {
|
||||||
deleted: {
|
deleted: {
|
||||||
files: number
|
files: number
|
||||||
|
|||||||
Reference in New Issue
Block a user