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

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

View 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 })
},
})

View File

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

View File

@@ -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.
*/

View 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),
})
}

View 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(),
})
}

View File

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

View File

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

View File

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