Add folder permissions UI and WebDAV protocol support

Permissions UI:
- FolderPermissionsDialog component with public/private toggle,
  role/user permission management, and access level badges
- Integrated into file manager toolbar (visible for folder admins)
- Backend returns accessLevel in folder detail endpoint

WebDAV server:
- Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials)
- PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support
- Permission-checked against existing folder access model
- In-memory lock stubs for Windows client compatibility
- 22 API integration tests covering all operations

Also fixes canAccess to check folder creator (was missing).
This commit is contained in:
Ryan Moon
2026-03-29 17:38:57 -05:00
parent cbbf2713a1
commit 51ca2ca683
14 changed files with 1757 additions and 7 deletions

View File

@@ -23,10 +23,11 @@ function getExtension(contentType: string): string {
export const StoragePermissionService = {
async canAccess(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<boolean> {
// Check if folder is public
const [folder] = await db.select({ isPublic: storageFolders.isPublic }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
// Check if folder is public or user is creator
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
if (!folder) return false
if (folder.isPublic) return true
if (folder.createdBy === userId) return true
// Check direct user permission
const [userPerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions)

View File

@@ -0,0 +1,121 @@
import { eq, and, isNull } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { storageFolders, storageFiles } from '../db/schema/storage.js'
import type { DavResource } from '../utils/webdav-xml.js'
interface ResolvedPath {
type: 'root' | 'folder' | 'file' | null
folder?: typeof storageFolders.$inferSelect
file?: typeof storageFiles.$inferSelect
parentFolder?: typeof storageFolders.$inferSelect
}
export const WebDavService = {
/**
* Resolve a WebDAV URL path to a database entity.
* Path is relative to /webdav/ (e.g., "/HR Documents/Policies/handbook.pdf")
*/
async resolvePath(db: PostgresJsDatabase<any>, urlPath: string): Promise<ResolvedPath> {
const segments = urlPath.split('/').filter(Boolean).map(decodeURIComponent)
if (segments.length === 0) {
return { type: 'root' }
}
// Walk the folder tree
let currentFolder: typeof storageFolders.$inferSelect | undefined
let parentFolder: typeof storageFolders.$inferSelect | undefined
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
const isLast = i === segments.length - 1
const parentId = currentFolder?.id ?? null
// Try to find a folder with this name under the current parent
const whereClause = parentId
? and(eq(storageFolders.name, segment), eq(storageFolders.parentId, parentId))
: and(eq(storageFolders.name, segment), isNull(storageFolders.parentId))
const [folder] = await db.select().from(storageFolders).where(whereClause).limit(1)
if (folder) {
parentFolder = currentFolder
currentFolder = folder
continue
}
// Not a folder — if this is the last segment, check for a file
if (isLast && currentFolder) {
const [file] = await db.select().from(storageFiles)
.where(and(eq(storageFiles.folderId, currentFolder.id), eq(storageFiles.filename, segment)))
.limit(1)
if (file) {
return { type: 'file', file, folder: currentFolder, parentFolder }
}
}
// Also check for a file at root level (no parent folder) - not supported in our model
// Files must be in folders
return { type: null }
}
return { type: 'folder', folder: currentFolder, parentFolder }
},
/**
* List children of a folder (or root) as DAV resources.
*/
async listChildren(db: PostgresJsDatabase<any>, folderId: string | null, basePath: string): Promise<DavResource[]> {
const resources: DavResource[] = []
// Sub-folders
const folderWhere = folderId
? eq(storageFolders.parentId, folderId)
: isNull(storageFolders.parentId)
const subFolders = await db.select().from(storageFolders).where(folderWhere).orderBy(storageFolders.name)
for (const folder of subFolders) {
resources.push({
href: `${basePath}${encodeURIComponent(folder.name)}/`,
isCollection: true,
displayName: folder.name,
lastModified: folder.updatedAt ? new Date(folder.updatedAt) : undefined,
createdAt: folder.createdAt ? new Date(folder.createdAt) : undefined,
})
}
// Files (only if we're inside a folder, not root)
if (folderId) {
const files = await db.select().from(storageFiles)
.where(eq(storageFiles.folderId, folderId))
.orderBy(storageFiles.filename)
for (const file of files) {
resources.push({
href: `${basePath}${encodeURIComponent(file.filename)}`,
isCollection: false,
displayName: file.filename,
contentType: file.contentType,
contentLength: file.sizeBytes,
lastModified: file.createdAt ? new Date(file.createdAt) : undefined,
createdAt: file.createdAt ? new Date(file.createdAt) : undefined,
etag: file.id,
})
}
}
return resources
},
/**
* Parse the parent path and filename from a WebDAV path.
*/
parseParentAndName(urlPath: string): { parentPath: string; name: string } {
const segments = urlPath.split('/').filter(Boolean).map(decodeURIComponent)
if (segments.length === 0) return { parentPath: '/', name: '' }
const name = segments[segments.length - 1]
const parentPath = '/' + segments.slice(0, -1).join('/')
return { parentPath, name }
},
}