mirror of
https://github.com/get-drexa/drive.git
synced 2025-11-30 21:41:39 +00:00
feat: initial impl of file proxy
This commit is contained in:
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_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;
|
||||
|
||||
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 {
|
||||
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 })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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,
|
||||
} 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user