Compare commits

..

2 Commits

Author SHA1 Message Date
83a5f92506 feat: implement comprehensive access control system
- Add authorizedGet function for secure resource access
- Implement ownership verification for all file/directory operations
- Use security through obscurity (not found vs access denied)
- Optimize bulk operations by removing redundant authorization checks
- Move generateFileUrl to filesystem.ts as fetchFileUrl with proper auth
- Ensure all database access goes through authorization layer

Co-authored-by: Ona <no-reply@ona.com>
2025-10-16 21:43:23 +00:00
b802cb5aec feat: add last access time to files 2025-10-16 20:56:16 +00:00
8 changed files with 101 additions and 28 deletions

View File

@@ -1,26 +1,16 @@
import { v } from "convex/values" import { v } from "convex/values"
import type { Id } from "./_generated/dataModel" import type { Id } from "./_generated/dataModel"
import { authenticatedMutation, authenticatedQuery } from "./functions" import { authenticatedMutation, authenticatedQuery, authorizedGet } 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 type { FileSystemItem } from "./model/filesystem" import type { FileSystemItem } from "./model/filesystem"
export const generateUploadUrl = authenticatedMutation({ export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => { handler: async (ctx) => {
// ctx.user and ctx.identity are automatically available
return await ctx.storage.generateUploadUrl() return await ctx.storage.generateUploadUrl()
}, },
}) })
export const generateFileUrl = authenticatedQuery({
args: {
storageId: v.id("_storage"),
},
handler: async (ctx, { storageId }) => {
return await ctx.storage.getUrl(storageId)
},
})
export const fetchFiles = authenticatedQuery({ export const fetchFiles = authenticatedQuery({
args: { args: {
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),
@@ -46,6 +36,10 @@ export const fetchDirectory = authenticatedQuery({
directoryId: v.id("directories"), directoryId: v.id("directories"),
}, },
handler: async (ctx, { directoryId }) => { handler: async (ctx, { directoryId }) => {
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw new Error("Directory not found")
}
return await Directories.fetch(ctx, { directoryId }) return await Directories.fetch(ctx, { directoryId })
}, },
}) })
@@ -56,6 +50,11 @@ export const createDirectory = authenticatedMutation({
directoryId: v.id("directories"), directoryId: v.id("directories"),
}, },
handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => { handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => {
const parentDirectory = await authorizedGet(ctx, directoryId)
if (!parentDirectory) {
throw new Error("Parent directory not found")
}
return await Directories.create(ctx, { return await Directories.create(ctx, {
name, name,
parentId: directoryId, parentId: directoryId,
@@ -72,6 +71,11 @@ export const saveFile = authenticatedMutation({
mimeType: v.optional(v.string()), mimeType: v.optional(v.string()),
}, },
handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => { handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => {
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw new Error("Directory not found")
}
const now = Date.now() const now = Date.now()
await ctx.db.insert("files", { await ctx.db.insert("files", {
@@ -94,6 +98,11 @@ export const renameFile = authenticatedMutation({
newName: v.string(), newName: v.string(),
}, },
handler: async (ctx, { directoryId, itemId, newName }) => { handler: async (ctx, { directoryId, itemId, newName }) => {
const file = await authorizedGet(ctx, itemId)
if (!file) {
throw new Error("File not found")
}
await Files.renameFile(ctx, { directoryId, itemId, newName }) await Files.renameFile(ctx, { directoryId, itemId, newName })
}, },
}) })

View File

@@ -1,5 +1,5 @@
import { v } from "convex/values" import { v } from "convex/values"
import { authenticatedMutation, authenticatedQuery } from "./functions" import { authenticatedMutation, authenticatedQuery, authorizedGet } from "./functions"
import * as Directories from "./model/directories" import * as Directories from "./model/directories"
import * as Err from "./model/error" import * as Err from "./model/error"
import * as Files from "./model/files" import * as Files from "./model/files"
@@ -22,10 +22,7 @@ export const moveItems = authenticatedMutation({
items: v.array(VFileSystemHandle), items: v.array(VFileSystemHandle),
}, },
handler: async (ctx, { targetDirectory: targetDirectoryHandle, items }) => { handler: async (ctx, { targetDirectory: targetDirectoryHandle, items }) => {
const targetDirectory = await Directories.fetchHandle( const targetDirectory = await authorizedGet(ctx, targetDirectoryHandle.id)
ctx,
targetDirectoryHandle,
)
if (!targetDirectory) { if (!targetDirectory) {
throw Err.create( throw Err.create(
Err.Code.DirectoryNotFound, Err.Code.DirectoryNotFound,
@@ -69,6 +66,16 @@ export const moveToTrash = authenticatedMutation({
handles: v.array(VFileSystemHandle), handles: v.array(VFileSystemHandle),
}, },
handler: async (ctx, { handles }) => { handler: async (ctx, { handles }) => {
for (const handle of handles) {
const item = await authorizedGet(ctx, handle.id)
if (!item) {
throw Err.create(
Err.Code.NotFound,
`Item ${handle.id} not found`,
)
}
}
// biome-ignore lint/suspicious/useIterableCallbackReturn: switch statement is exhaustive // biome-ignore lint/suspicious/useIterableCallbackReturn: switch statement is exhaustive
const promises = handles.map((handle) => { const promises = handles.map((handle) => {
switch (handle.kind) { switch (handle.kind) {
@@ -142,3 +149,17 @@ export const restoreItems = authenticatedMutation({
return await FileSystem.restoreItems(ctx, { handles }) return await FileSystem.restoreItems(ctx, { handles })
}, },
}) })
export const fetchFileUrl = authenticatedQuery({
args: {
fileId: v.id("files"),
},
handler: async (ctx, { fileId }) => {
const file = await authorizedGet(ctx, fileId)
if (!file) {
throw Err.create(Err.Code.NotFound, "File not found")
}
return await FileSystem.fetchFileUrl(ctx, { fileId })
},
})

View File

@@ -1,9 +1,15 @@
import type { UserIdentity } from "convex/server" import type {
DocumentByName,
TableNamesInDataModel,
UserIdentity,
} from "convex/server"
import type { GenericId } from "convex/values"
import { import {
customCtx, customCtx,
customMutation, customMutation,
customQuery, customQuery,
} from "convex-helpers/server/customFunctions" } from "convex-helpers/server/customFunctions"
import type { DataModel } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server" import type { MutationCtx, QueryCtx } from "./_generated/server"
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user" import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user"
@@ -43,3 +49,19 @@ export const authenticatedMutation = customMutation(
return { user, identity } return { user, identity }
}), }),
) )
/**
* Gets a document by its id and checks if the user is authorized to access it
*
* @returns The document associated with the id or null if the document is not found.
*/
export async function authorizedGet<T extends TableNamesInDataModel<DataModel>>(
ctx: AuthenticatedQueryCtx | AuthenticatedMutationCtx,
id: GenericId<T>,
): Promise<DocumentByName<DataModel, T> | null> {
const item = await ctx.db.get(id)
if (item && item.userId !== ctx.user._id) {
return null
}
return item
}

View File

@@ -3,6 +3,7 @@ import type {
AuthenticatedMutationCtx, AuthenticatedMutationCtx,
AuthenticatedQueryCtx, AuthenticatedQueryCtx,
} from "../functions" } from "../functions"
import { authorizedGet } from "../functions"
import * as Err from "./error" import * as Err from "./error"
import { import {
type DirectoryHandle, type DirectoryHandle,
@@ -27,8 +28,8 @@ export async function fetchHandle(
ctx: AuthenticatedQueryCtx, ctx: AuthenticatedQueryCtx,
handle: DirectoryHandle, handle: DirectoryHandle,
): Promise<Doc<"directories">> { ): Promise<Doc<"directories">> {
const directory = await ctx.db.get(handle.id) const directory = await authorizedGet(ctx, handle.id)
if (!directory || directory.userId !== ctx.user._id) { if (!directory) {
throw Err.create( throw Err.create(
Err.Code.DirectoryNotFound, Err.Code.DirectoryNotFound,
`Directory ${handle.id} not found`, `Directory ${handle.id} not found`,
@@ -41,7 +42,7 @@ export async function fetch(
ctx: AuthenticatedQueryCtx, ctx: AuthenticatedQueryCtx,
{ directoryId }: { directoryId: Id<"directories"> }, { directoryId }: { directoryId: Id<"directories"> },
): Promise<DirectoryInfo> { ): Promise<DirectoryInfo> {
const directory = await ctx.db.get(directoryId) const directory = await authorizedGet(ctx, directoryId)
if (!directory) { if (!directory) {
throw Err.create( throw Err.create(
Err.Code.DirectoryNotFound, Err.Code.DirectoryNotFound,
@@ -57,7 +58,7 @@ export async function fetch(
] ]
let parentDirId = directory.parentId let parentDirId = directory.parentId
while (parentDirId) { while (parentDirId) {
const parentDir = await ctx.db.get(parentDirId) const parentDir = await authorizedGet(ctx, parentDirId)
if (parentDir) { if (parentDir) {
path.push({ path.push({
handle: newDirectoryHandle(parentDir._id), handle: newDirectoryHandle(parentDir._id),
@@ -65,7 +66,7 @@ export async function fetch(
}) })
parentDirId = parentDir.parentId parentDirId = parentDir.parentId
} else { } else {
throw Err.create(Err.Code.Internal) throw Err.create(Err.Code.DirectoryNotFound, "Parent directory not found")
} }
} }
@@ -132,7 +133,7 @@ export async function create(
ctx: AuthenticatedMutationCtx, ctx: AuthenticatedMutationCtx,
{ name, parentId }: { name: string; parentId: Id<"directories"> }, { name, parentId }: { name: string; parentId: Id<"directories"> },
): Promise<Id<"directories">> { ): Promise<Id<"directories">> {
const parentDir = await ctx.db.get(parentId) const parentDir = await authorizedGet(ctx, parentId)
if (!parentDir) { if (!parentDir) {
throw Err.create( throw Err.create(
Err.Code.DirectoryNotFound, Err.Code.DirectoryNotFound,
@@ -180,7 +181,7 @@ export async function move(
) { ) {
const conflictCheckResults = await Promise.allSettled( const conflictCheckResults = await Promise.allSettled(
sourceDirectories.map((directory) => sourceDirectories.map((directory) =>
ctx.db.get(directory.id).then((d) => { authorizedGet(ctx, directory.id).then((d) => {
if (!d) { if (!d) {
throw Err.create( throw Err.create(
Err.Code.DirectoryNotFound, Err.Code.DirectoryNotFound,

View File

@@ -1,5 +1,5 @@
import type { Doc, Id } from "../_generated/dataModel" import type { Doc, Id } from "../_generated/dataModel"
import type { AuthenticatedMutationCtx } from "../functions" import { type AuthenticatedMutationCtx, authorizedGet } from "../functions"
import * as Err from "./error" import * as Err from "./error"
import type { DirectoryHandle, FileHandle } from "./filesystem" import type { DirectoryHandle, FileHandle } from "./filesystem"
@@ -48,7 +48,7 @@ export async function move(
) { ) {
const conflictCheckResults = await Promise.allSettled( const conflictCheckResults = await Promise.allSettled(
items.map((fileHandle) => items.map((fileHandle) =>
ctx.db.get(fileHandle.id).then((f) => { authorizedGet(ctx, fileHandle.id).then((f) => {
if (!f) { if (!f) {
throw Err.create( throw Err.create(
Err.Code.FileNotFound, Err.Code.FileNotFound,

View File

@@ -4,6 +4,7 @@ import type {
AuthenticatedMutationCtx, AuthenticatedMutationCtx,
AuthenticatedQueryCtx, AuthenticatedQueryCtx,
} from "../functions" } from "../functions"
import { authorizedGet } from "../functions"
import * as Directories from "./directories" import * as Directories from "./directories"
import * as Err from "./error" import * as Err from "./error"
import * as Files from "./files" import * as Files from "./files"
@@ -295,3 +296,20 @@ export async function emptyTrash(
], ],
}) })
} }
export async function fetchFileUrl(
ctx: AuthenticatedQueryCtx,
{ fileId }: { fileId: Id<"files"> },
): Promise<string> {
const file = await authorizedGet(ctx, fileId)
if (!file) {
throw Err.create(Err.Code.NotFound, "file not found")
}
const url = await ctx.storage.getUrl(file.storageId)
if (!url) {
throw Err.create(Err.Code.NotFound, "file not found")
}
return url
}

View File

@@ -12,10 +12,12 @@ const schema = defineSchema({
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
deletedAt: v.optional(v.number()), deletedAt: v.optional(v.number()),
lastAccessedAt: v.optional(v.number()),
}) })
.index("byDirectoryId", ["userId", "directoryId", "deletedAt"]) .index("byDirectoryId", ["userId", "directoryId", "deletedAt"])
.index("byUserId", ["userId", "deletedAt"]) .index("byUserId", ["userId", "deletedAt"])
.index("byDeletedAt", ["deletedAt"]) .index("byDeletedAt", ["deletedAt"])
.index("byLastAccessedAt", ["userId", "lastAccessedAt"])
.index("uniqueFileInDirectory", [ .index("uniqueFileInDirectory", [
"userId", "userId",
"directoryId", "directoryId",

View File

@@ -41,8 +41,8 @@ export function ImagePreviewDialog({
file: Doc<"files"> file: Doc<"files">
onClose: () => void onClose: () => void
}) { }) {
const fileUrl = useConvexQuery(api.files.generateFileUrl, { const fileUrl = useConvexQuery(api.filesystem.fetchFileUrl, {
storageId: file.storageId, fileId: file._id,
}) })
const setZoomLevel = useSetAtom(zoomLevelAtom) const setZoomLevel = useSetAtom(zoomLevelAtom)