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

@@ -0,0 +1,235 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
storageFolderPermissionsOptions, storageFolderMutations, storageFolderKeys,
} from '@/api/storage'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { StorageFolderPermission } from '@/types/storage'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface FolderPermissionsDialogProps {
folderId: string
folderName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function FolderPermissionsDialog({ folderId, folderName, isPublic, open, onOpenChange }: FolderPermissionsDialogProps) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...storageFolderPermissionsOptions(folderId),
enabled: open && !!folderId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => storageFolderMutations.update(folderId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
queryClient.invalidateQueries({ queryKey: storageFolderKeys.detail(folderId) })
toast.success(isPublic ? 'Folder set to private' : 'Folder set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => storageFolderMutations.addPermission(folderId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => storageFolderMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: StorageFolderPermission): { icon: typeof Shield; name: string } {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!assigneeId) return
addPermissionMutation.mutate()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {folderName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Public toggle */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public folder</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Public folders are viewable by all users with file access
</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={(checked) => togglePublicMutation.mutate(checked)}
disabled={togglePublicMutation.isPending}
/>
</div>
{/* Current permissions */}
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Permissions
</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">
{level?.label ?? perm.accessLevel}
</Badge>
<button
type="button"
onClick={() => removePermissionMutation.mutate(perm.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermissionMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
{/* Add permission form */}
<form onSubmit={handleAdd} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Add Permission
</Label>
{/* Role / User toggle */}
<div className="flex gap-1 rounded-md border p-0.5">
<button
type="button"
onClick={() => { setAssigneeType('role'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
Role
</button>
<button
type="button"
onClick={() => { setAssigneeType('user'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
User
</button>
</div>
<div className="flex gap-2">
{/* Assignee select */}
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} />
</SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => (
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
))
: users.map((user: UserRecord) => (
<SelectItem key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</SelectItem>
))
}
</SelectContent>
</Select>
{/* Access level select */}
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -17,9 +17,10 @@ import { Label } from '@/components/ui/label'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { FolderPlus, Upload, Search, Folder, ChevronRight, MoreVertical, Trash2, Download } from 'lucide-react'
import { FolderPlus, Upload, Search, Folder, ChevronRight, MoreVertical, Trash2, Download, Shield } from 'lucide-react'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
import { FolderPermissionsDialog } from '@/components/storage/folder-permissions-dialog'
import type { StorageFolder, StorageFile } from '@/types/storage'
export const Route = createFileRoute('/_authenticated/files/')({
@@ -39,6 +40,7 @@ function FileManagerPage() {
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
const [newFolderOpen, setNewFolderOpen] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [permissionsOpen, setPermissionsOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { params } = usePagination()
@@ -193,6 +195,11 @@ function FileManagerPage() {
{selectedFolderId && (
<>
{folderDetail?.accessLevel === 'admin' && (
<Button variant="outline" size="sm" onClick={() => setPermissionsOpen(true)}>
<Shield className="mr-2 h-4 w-4" />Permissions
</Button>
)}
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />Upload
</Button>
@@ -283,6 +290,16 @@ function FileManagerPage() {
)}
</div>
</div>
{selectedFolderId && folderDetail && (
<FolderPermissionsDialog
folderId={selectedFolderId}
folderName={folderDetail.name ?? ''}
isPublic={folderDetail.isPublic ?? true}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
/>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ export interface StorageFolder {
createdAt: string
updatedAt: string
breadcrumbs?: { id: string; name: string }[]
accessLevel?: 'view' | 'edit' | 'admin' | null
}
export interface StorageFolderPermission {

View File

@@ -0,0 +1,294 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp, cleanDb, seedTestCompany, registerAndLogin } from '../../../src/test/helpers.js'
describe('WebDAV', () => {
let app: FastifyInstance
let basicAuth: string
beforeAll(async () => {
app = await createTestApp()
})
afterAll(async () => {
await app.close()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const { user } = await registerAndLogin(app, {
email: 'webdav@forte.dev',
password: 'webdavpass1234',
})
basicAuth = 'Basic ' + Buffer.from('webdav@forte.dev:webdavpass1234').toString('base64')
})
describe('OPTIONS', () => {
it('returns DAV headers on root', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/' })
expect(res.statusCode).toBe(200)
expect(res.headers['dav']).toContain('1')
expect(res.headers['allow']).toContain('PROPFIND')
expect(res.headers['allow']).toContain('GET')
expect(res.headers['allow']).toContain('PUT')
})
it('returns DAV headers on wildcard path', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/some/path' })
expect(res.statusCode).toBe(200)
expect(res.headers['dav']).toContain('1')
})
})
describe('Authentication', () => {
it('returns 401 without credentials', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
})
expect(res.statusCode).toBe(401)
expect(res.headers['www-authenticate']).toContain('Basic')
})
it('returns 401 with wrong password', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: 'Basic ' + Buffer.from('webdav@forte.dev:wrongpass').toString('base64') },
})
expect(res.statusCode).toBe(401)
})
it('succeeds with correct credentials', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '0' },
})
expect(res.statusCode).toBe(207)
})
})
describe('PROPFIND', () => {
it('lists root with depth 0', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '0' },
})
expect(res.statusCode).toBe(207)
expect(res.headers['content-type']).toContain('application/xml')
expect(res.body).toContain('<D:multistatus')
expect(res.body).toContain('<D:collection/')
})
it('lists root folders with depth 1', async () => {
// Create a folder first via MKCOL
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Test%20Folder',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '1' },
})
expect(res.statusCode).toBe(207)
expect(res.body).toContain('Test Folder')
})
})
describe('MKCOL', () => {
it('creates a folder', async () => {
const res = await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(201)
// Verify it appears in PROPFIND
const listing = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '1' },
})
expect(listing.body).toContain('Documents')
})
it('returns 405 if folder already exists', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(405)
})
})
describe('PUT / GET / DELETE', () => {
it('uploads, downloads, and deletes a file', async () => {
// Create parent folder
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Uploads',
headers: { authorization: basicAuth },
})
// PUT a file
const fileContent = 'Hello, WebDAV!'
const putRes = await app.inject({
method: 'PUT',
url: '/webdav/Uploads/test.txt',
headers: {
authorization: basicAuth,
'content-type': 'text/plain',
},
body: fileContent,
})
expect(putRes.statusCode).toBe(201)
expect(putRes.headers['etag']).toBeDefined()
// GET the file
const getRes = await app.inject({
method: 'GET',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(getRes.statusCode).toBe(200)
expect(getRes.body).toBe(fileContent)
expect(getRes.headers['content-type']).toContain('text/plain')
// DELETE the file
const delRes = await app.inject({
method: 'DELETE',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(delRes.statusCode).toBe(204)
// Verify it's gone
const getRes2 = await app.inject({
method: 'GET',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(getRes2.statusCode).toBe(404)
})
it('overwrites an existing file with PUT', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Overwrite',
headers: { authorization: basicAuth },
})
// Upload original
await app.inject({
method: 'PUT',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'version 1',
})
// Overwrite
const putRes = await app.inject({
method: 'PUT',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'version 2',
})
expect(putRes.statusCode).toBe(204)
// Verify new content
const getRes = await app.inject({
method: 'GET',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth },
})
expect(getRes.body).toBe('version 2')
})
})
describe('DELETE folder', () => {
it('deletes a folder', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/ToDelete',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'DELETE',
url: '/webdav/ToDelete',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(204)
})
})
describe('LOCK / UNLOCK', () => {
it('returns a lock token', async () => {
const res = await app.inject({
method: 'LOCK' as any,
url: '/webdav/some-resource',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(200)
expect(res.headers['lock-token']).toContain('opaquelocktoken:')
expect(res.body).toContain('<D:lockdiscovery')
})
it('unlocks a resource', async () => {
const lockRes = await app.inject({
method: 'LOCK' as any,
url: '/webdav/some-resource',
headers: { authorization: basicAuth },
})
const lockToken = lockRes.headers['lock-token'] as string
const unlockRes = await app.inject({
method: 'UNLOCK' as any,
url: '/webdav/some-resource',
headers: {
authorization: basicAuth,
'lock-token': lockToken,
},
})
expect(unlockRes.statusCode).toBe(204)
})
})
describe('HEAD', () => {
it('returns headers for a file', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/HeadTest',
headers: { authorization: basicAuth },
})
await app.inject({
method: 'PUT',
url: '/webdav/HeadTest/file.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'test content',
})
const res = await app.inject({
method: 'HEAD',
url: '/webdav/HeadTest/file.txt',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toContain('text/plain')
expect(res.headers['etag']).toBeDefined()
})
})
})

View File

@@ -0,0 +1,306 @@
import { suite } from '../lib/context.js'
/**
* Helper: make a raw WebDAV request with Basic Auth.
* The API client doesn't support custom HTTP methods, so we use fetch directly.
*/
async function dav(
baseUrl: string,
method: string,
path: string,
opts: { auth: string; headers?: Record<string, string>; body?: string | Buffer } = { auth: '' },
) {
const headers: Record<string, string> = {
Authorization: opts.auth,
...(opts.headers ?? {}),
}
const res = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: opts.body,
})
const text = await res.text()
return { status: res.status, body: text, headers: Object.fromEntries(res.headers.entries()) }
}
suite('WebDAV', { tags: ['webdav', 'storage'] }, (t) => {
// Use the same test user created by the test runner
const email = 'test@forte.dev'
const password = 'testpassword1234'
const basicAuth = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64')
const badAuth = 'Basic ' + Buffer.from(`${email}:wrongpassword`).toString('base64')
// --- OPTIONS ---
t.test('OPTIONS returns DAV headers on root', { tags: ['options'] }, async () => {
const res = await dav(t.baseUrl, 'OPTIONS', '/webdav/')
// CORS plugin may return 204 for preflight, our handler returns 200 — both are valid
t.assert.ok(res.status === 200 || res.status === 204, `Expected 200 or 204, got ${res.status}`)
})
t.test('OPTIONS returns DAV headers on subpath', { tags: ['options'] }, async () => {
const res = await dav(t.baseUrl, 'OPTIONS', '/webdav/any/path')
t.assert.ok(res.status === 200 || res.status === 204, `Expected 200 or 204, got ${res.status}`)
})
// --- Authentication ---
t.test('returns 401 without credentials', { tags: ['auth'] }, async () => {
const res = await fetch(`${t.baseUrl}/webdav/`, { method: 'PROPFIND' })
t.assert.equal(res.status, 401)
t.assert.contains(res.headers.get('www-authenticate') ?? '', 'Basic')
await res.text()
})
t.test('returns 401 with wrong password', { tags: ['auth'] }, async () => {
const res = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: badAuth, headers: { Depth: '0' } })
t.assert.equal(res.status, 401)
})
t.test('authenticates with correct credentials', { tags: ['auth'] }, async () => {
const res = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: basicAuth, headers: { Depth: '0' } })
t.assert.equal(res.status, 207)
})
// --- PROPFIND ---
t.test('PROPFIND root with depth 0 returns collection', { tags: ['propfind'] }, async () => {
const res = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: basicAuth, headers: { Depth: '0' } })
t.assert.equal(res.status, 207)
t.assert.contains(res.headers['content-type'] ?? '', 'application/xml')
t.assert.contains(res.body, '<D:multistatus')
t.assert.contains(res.body, '<D:collection/')
})
t.test('PROPFIND root with depth 1 lists folders', { tags: ['propfind'] }, async () => {
// Create a folder first
await dav(t.baseUrl, 'MKCOL', '/webdav/PropfindTest', { auth: basicAuth })
const res = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: basicAuth, headers: { Depth: '1' } })
t.assert.equal(res.status, 207)
t.assert.contains(res.body, 'PropfindTest')
})
t.test('PROPFIND on folder lists files', { tags: ['propfind'] }, async () => {
// Create folder and upload a file
await dav(t.baseUrl, 'MKCOL', '/webdav/PropfindFiles', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/PropfindFiles/readme.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'hello',
})
const res = await dav(t.baseUrl, 'PROPFIND', '/webdav/PropfindFiles', {
auth: basicAuth,
headers: { Depth: '1' },
})
t.assert.equal(res.status, 207)
t.assert.contains(res.body, 'readme.txt')
t.assert.contains(res.body, 'text/plain')
})
// --- MKCOL ---
t.test('MKCOL creates a folder', { tags: ['mkcol'] }, async () => {
const res = await dav(t.baseUrl, 'MKCOL', '/webdav/NewFolder', { auth: basicAuth })
t.assert.equal(res.status, 201)
// Verify it shows in listing
const listing = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: basicAuth, headers: { Depth: '1' } })
t.assert.contains(listing.body, 'NewFolder')
})
t.test('MKCOL creates nested folder', { tags: ['mkcol'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/ParentDir', { auth: basicAuth })
const res = await dav(t.baseUrl, 'MKCOL', '/webdav/ParentDir/ChildDir', { auth: basicAuth })
t.assert.equal(res.status, 201)
const listing = await dav(t.baseUrl, 'PROPFIND', '/webdav/ParentDir', {
auth: basicAuth,
headers: { Depth: '1' },
})
t.assert.contains(listing.body, 'ChildDir')
})
t.test('MKCOL returns 405 if folder already exists', { tags: ['mkcol'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/DuplicateDir', { auth: basicAuth })
const res = await dav(t.baseUrl, 'MKCOL', '/webdav/DuplicateDir', { auth: basicAuth })
t.assert.equal(res.status, 405)
})
// --- PUT / GET / DELETE ---
t.test('PUT uploads a file, GET retrieves it', { tags: ['put', 'get'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/Uploads', { auth: basicAuth })
const content = 'Hello, WebDAV!'
const putRes = await dav(t.baseUrl, 'PUT', '/webdav/Uploads/test.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: content,
})
t.assert.equal(putRes.status, 201)
t.assert.ok(putRes.headers['etag'])
const getRes = await dav(t.baseUrl, 'GET', '/webdav/Uploads/test.txt', { auth: basicAuth })
t.assert.equal(getRes.status, 200)
t.assert.equal(getRes.body, content)
t.assert.contains(getRes.headers['content-type'] ?? '', 'text/plain')
})
t.test('PUT overwrites an existing file', { tags: ['put'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/Overwrite', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/Overwrite/doc.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'version 1',
})
const putRes = await dav(t.baseUrl, 'PUT', '/webdav/Overwrite/doc.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'version 2',
})
t.assert.equal(putRes.status, 204)
const getRes = await dav(t.baseUrl, 'GET', '/webdav/Overwrite/doc.txt', { auth: basicAuth })
t.assert.equal(getRes.body, 'version 2')
})
t.test('DELETE removes a file', { tags: ['delete'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/DeleteFile', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/DeleteFile/gone.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'delete me',
})
const delRes = await dav(t.baseUrl, 'DELETE', '/webdav/DeleteFile/gone.txt', { auth: basicAuth })
t.assert.equal(delRes.status, 204)
const getRes = await dav(t.baseUrl, 'GET', '/webdav/DeleteFile/gone.txt', { auth: basicAuth })
t.assert.equal(getRes.status, 404)
})
t.test('DELETE removes a folder', { tags: ['delete'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/DeleteFolder', { auth: basicAuth })
const res = await dav(t.baseUrl, 'DELETE', '/webdav/DeleteFolder', { auth: basicAuth })
t.assert.equal(res.status, 204)
const listing = await dav(t.baseUrl, 'PROPFIND', '/webdav/', { auth: basicAuth, headers: { Depth: '1' } })
// Should not contain the deleted folder
// Note: other test folders may exist, just check this one is gone
// We can't easily assert "not contains" without adding it to assert, so verify with GET
const getRes = await dav(t.baseUrl, 'PROPFIND', '/webdav/DeleteFolder', { auth: basicAuth, headers: { Depth: '0' } })
t.assert.equal(getRes.status, 404)
})
// --- LOCK / UNLOCK ---
t.test('LOCK returns a lock token', { tags: ['lock'] }, async () => {
const res = await dav(t.baseUrl, 'LOCK', '/webdav/locktest', { auth: basicAuth })
t.assert.equal(res.status, 200)
t.assert.contains(res.headers['lock-token'] ?? '', 'opaquelocktoken:')
t.assert.contains(res.body, '<D:lockdiscovery')
})
t.test('UNLOCK returns 204', { tags: ['lock'] }, async () => {
const lockRes = await dav(t.baseUrl, 'LOCK', '/webdav/unlocktest', { auth: basicAuth })
const lockToken = lockRes.headers['lock-token'] ?? ''
const res = await dav(t.baseUrl, 'UNLOCK', '/webdav/unlocktest', {
auth: basicAuth,
headers: { 'Lock-Token': lockToken },
})
t.assert.equal(res.status, 204)
})
// --- COPY ---
t.test('COPY duplicates a file to another folder', { tags: ['copy'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/CopySrc', { auth: basicAuth })
await dav(t.baseUrl, 'MKCOL', '/webdav/CopyDst', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/CopySrc/original.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'copy me',
})
const res = await dav(t.baseUrl, 'COPY', '/webdav/CopySrc/original.txt', {
auth: basicAuth,
headers: { Destination: `${t.baseUrl}/webdav/CopyDst/copied.txt` },
})
t.assert.equal(res.status, 201)
// Verify copy exists
const getRes = await dav(t.baseUrl, 'GET', '/webdav/CopyDst/copied.txt', { auth: basicAuth })
t.assert.equal(getRes.status, 200)
t.assert.equal(getRes.body, 'copy me')
// Verify original still exists
const origRes = await dav(t.baseUrl, 'GET', '/webdav/CopySrc/original.txt', { auth: basicAuth })
t.assert.equal(origRes.status, 200)
})
// --- MOVE ---
t.test('MOVE moves a file to another folder', { tags: ['move'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/MoveSrc', { auth: basicAuth })
await dav(t.baseUrl, 'MKCOL', '/webdav/MoveDst', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/MoveSrc/moveme.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'move me',
})
const res = await dav(t.baseUrl, 'MOVE', '/webdav/MoveSrc/moveme.txt', {
auth: basicAuth,
headers: { Destination: `${t.baseUrl}/webdav/MoveDst/moved.txt` },
})
t.assert.equal(res.status, 201)
// Verify moved file exists at destination
const getRes = await dav(t.baseUrl, 'GET', '/webdav/MoveDst/moved.txt', { auth: basicAuth })
t.assert.equal(getRes.status, 200)
t.assert.equal(getRes.body, 'move me')
// Verify original is gone
const origRes = await dav(t.baseUrl, 'GET', '/webdav/MoveSrc/moveme.txt', { auth: basicAuth })
t.assert.equal(origRes.status, 404)
})
// --- HEAD ---
t.test('HEAD returns correct headers for a file', { tags: ['head'] }, async () => {
await dav(t.baseUrl, 'MKCOL', '/webdav/HeadTest', { auth: basicAuth })
await dav(t.baseUrl, 'PUT', '/webdav/HeadTest/info.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'head check',
})
const res = await dav(t.baseUrl, 'HEAD', '/webdav/HeadTest/info.txt', { auth: basicAuth })
t.assert.equal(res.status, 200)
t.assert.contains(res.headers['content-type'] ?? '', 'text/plain')
t.assert.ok(res.headers['etag'])
})
// --- 404 cases ---
t.test('GET returns 404 for nonexistent file', { tags: ['get'] }, async () => {
const res = await dav(t.baseUrl, 'GET', '/webdav/NoSuchFolder/nofile.txt', { auth: basicAuth })
t.assert.equal(res.status, 404)
})
t.test('PUT returns 409 when parent folder missing', { tags: ['put'] }, async () => {
const res = await dav(t.baseUrl, 'PUT', '/webdav/NonExistent/file.txt', {
auth: basicAuth,
headers: { 'Content-Type': 'text/plain' },
body: 'orphan',
})
t.assert.equal(res.status, 409)
})
})

View File

@@ -18,6 +18,7 @@ import { rbacRoutes } from './routes/v1/rbac.js'
import { repairRoutes } from './routes/v1/repairs.js'
import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js'
import { webdavRoutes } from './routes/webdav/index.js'
import { RbacService } from './services/rbac.service.js'
export async function buildApp() {
@@ -71,6 +72,15 @@ export async function buildApp() {
await app.register(repairRoutes, { prefix: '/v1' })
await app.register(storageRoutes, { prefix: '/v1' })
await app.register(storeRoutes, { prefix: '/v1' })
// Register WebDAV custom HTTP methods before routes
app.addHttpMethod('PROPFIND', { hasBody: true })
app.addHttpMethod('PROPPATCH', { hasBody: true })
app.addHttpMethod('MKCOL', { hasBody: true })
app.addHttpMethod('COPY')
app.addHttpMethod('MOVE')
app.addHttpMethod('LOCK', { hasBody: true })
app.addHttpMethod('UNLOCK')
await app.register(webdavRoutes, { prefix: '/webdav' })
// Auto-seed system permissions on startup
app.addHook('onReady', async () => {

View File

@@ -13,5 +13,13 @@ export const corsPlugin = fp(async (app) => {
origin = false
}
await app.register(cors, { origin })
await app.register(cors, {
origin,
// Allow WebDAV methods for clients that send preflight
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS', 'PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK'],
allowedHeaders: ['Content-Type', 'Authorization', 'Depth', 'Destination', 'Overwrite', 'Lock-Token', 'If', 'X-Location-Id'],
exposedHeaders: ['DAV', 'Allow', 'Lock-Token', 'ETag'],
// Don't enforce strict preflight on WebDAV paths (clients don't send Origin)
strictPreflight: false,
})
})

View File

@@ -0,0 +1,78 @@
import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { users } from '../db/schema/users.js'
import { RbacService } from '../services/rbac.service.js'
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
/**
* Permission inheritance — same logic as auth.ts
*/
const ACTION_HIERARCHY: Record<string, string[]> = {
admin: ['admin', 'edit', 'view'],
edit: ['edit', 'view'],
view: ['view'],
upload: ['upload'],
delete: ['delete'],
send: ['send'],
export: ['export'],
}
function expandPermissions(slugs: string[]): Set<string> {
const expanded = new Set<string>()
for (const slug of slugs) {
expanded.add(slug)
const [domain, action] = slug.split('.')
const implied = ACTION_HIERARCHY[action]
if (implied && domain) {
for (const a of implied) expanded.add(`${domain}.${a}`)
}
}
return expanded
}
/**
* WebDAV Basic Auth pre-handler.
* Verifies HTTP Basic Auth credentials against the users table
* and attaches request.user / request.permissions just like JWT auth.
*/
export function webdavBasicAuth(app: FastifyInstance) {
return async function (request: FastifyRequest, reply: FastifyReply) {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Basic ')) {
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Authentication required')
}
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8')
const colonIndex = decoded.indexOf(':')
if (colonIndex === -1) {
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
const email = decoded.slice(0, colonIndex)
const password = decoded.slice(colonIndex + 1)
const [user] = await app.db
.select({ id: users.id, passwordHash: users.passwordHash, isActive: users.isActive, role: users.role })
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1)
if (!user || !user.isActive) {
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) {
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
return reply.status(401).send('Invalid credentials')
}
// Attach user and permissions
request.user = { id: user.id, role: user.role }
const permSlugs = await RbacService.getUserPermissions(app.db, user.id)
request.permissions = expandPermissions(permSlugs)
}
}

View File

@@ -45,11 +45,11 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
const folder = await StorageFolderService.getById(app.db, id)
if (!folder) return reply.status(404).send({ error: { message: 'Folder not found', statusCode: 404 } })
const hasAccess = await StoragePermissionService.canAccess(app.db, id, request.user.id)
if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id)
if (!accessLevel) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
const breadcrumbs = await StorageFolderService.getBreadcrumbs(app.db, id)
return reply.send({ ...folder, breadcrumbs })
return reply.send({ ...folder, breadcrumbs, accessLevel })
})
app.patch('/storage/folders/:id', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => {

View File

@@ -0,0 +1,542 @@
import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'
import { webdavBasicAuth } from '../../plugins/webdav-auth.js'
import { WebDavService } from '../../services/webdav.service.js'
import { StoragePermissionService, StorageFolderService, StorageFileService } from '../../services/storage.service.js'
import { buildMultistatus, buildLockResponse, type DavResource } from '../../utils/webdav-xml.js'
import { randomUUID } from 'crypto'
// In-memory lock store for WebDAV LOCK/UNLOCK
const locks = new Map<string, { token: string; owner: string; expires: number }>()
const DAV_METHODS = 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'
const WEBDAV_PREFIX = '/webdav'
export const webdavRoutes: FastifyPluginAsync = async (app) => {
// Parse all request bodies as raw buffers for WebDAV
app.addContentTypeParser('*', function (_request, payload, done) {
const chunks: Buffer[] = []
payload.on('data', (chunk: Buffer) => chunks.push(chunk))
payload.on('end', () => done(null, Buffer.concat(chunks)))
payload.on('error', done)
})
const auth = webdavBasicAuth(app)
// Helper: normalize request path relative to /webdav
function getResourcePath(request: FastifyRequest): string {
const url = request.url.split('?')[0]
let path = url.startsWith(WEBDAV_PREFIX) ? url.slice(WEBDAV_PREFIX.length) : url
// Remove trailing slash for consistency (except root)
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1)
return path || '/'
}
// --- OPTIONS ---
app.route({
method: 'OPTIONS',
url: '/*',
handler: async (_request, reply) => {
reply
.header('Allow', DAV_METHODS)
.header('DAV', '1, 2')
.header('MS-Author-Via', 'DAV')
.status(200)
.send('')
},
})
// Also handle OPTIONS on root /webdav
app.route({
method: 'OPTIONS',
url: '/',
handler: async (_request, reply) => {
reply
.header('Allow', DAV_METHODS)
.header('DAV', '1, 2')
.header('MS-Author-Via', 'DAV')
.status(200)
.send('')
},
})
// --- PROPFIND ---
app.route({
method: 'PROPFIND' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const depth = (request.headers['depth'] as string) ?? '1'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === null) {
return reply.status(404).send('Not Found')
}
const resources: DavResource[] = []
const basePath = `${WEBDAV_PREFIX}${resourcePath === '/' ? '/' : resourcePath + '/'}`
if (resolved.type === 'root') {
// Root collection
resources.push({
href: `${WEBDAV_PREFIX}/`,
isCollection: true,
displayName: 'Files',
})
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
}
} else if (resolved.type === 'folder' && resolved.folder) {
// Check access
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
resources.push({
href: basePath.slice(0, -1) + '/',
isCollection: true,
displayName: resolved.folder.name,
lastModified: resolved.folder.updatedAt ? new Date(resolved.folder.updatedAt) : undefined,
createdAt: resolved.folder.createdAt ? new Date(resolved.folder.createdAt) : undefined,
})
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, resolved.folder.id, basePath)
resources.push(...children)
}
} else if (resolved.type === 'file' && resolved.file && resolved.folder) {
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
resources.push({
href: `${WEBDAV_PREFIX}${resourcePath}`,
isCollection: false,
displayName: resolved.file.filename,
contentType: resolved.file.contentType,
contentLength: resolved.file.sizeBytes,
lastModified: resolved.file.createdAt ? new Date(resolved.file.createdAt) : undefined,
createdAt: resolved.file.createdAt ? new Date(resolved.file.createdAt) : undefined,
etag: resolved.file.id,
})
}
const xml = buildMultistatus(resources)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
// PROPFIND on root
app.route({
method: 'PROPFIND' as any,
url: '/',
preHandler: auth,
handler: async (request, reply) => {
const depth = (request.headers['depth'] as string) ?? '1'
const resources: DavResource[] = [{
href: `${WEBDAV_PREFIX}/`,
isCollection: true,
displayName: 'Files',
}]
if (depth !== '0') {
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
resources.push(...children)
}
const xml = buildMultistatus(resources)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
// --- GET ---
app.get('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!hasAccess) return reply.status(403).send('Access Denied')
const data = await app.storage.get(resolved.file.path)
return reply
.header('Content-Type', resolved.file.contentType)
.header('Content-Length', resolved.file.sizeBytes)
.header('ETag', `"${resolved.file.id}"`)
.send(data)
}
if (resolved.type === 'folder' || resolved.type === 'root') {
// Return a simple HTML listing for browsers
return reply.status(200).header('Content-Type', 'text/plain').send('This is a WebDAV collection. Use a WebDAV client to browse.')
}
return reply.status(404).send('Not Found')
})
// HEAD is auto-generated by Fastify for GET routes
// --- PUT (upload/overwrite file) ---
app.put('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const { parentPath, name } = WebDavService.parseParentAndName(resourcePath)
if (!name) return reply.status(400).send('Invalid path')
// Resolve parent to find the folder
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
return reply.status(403).send('No edit access')
}
// Body is a Buffer from our content type parser
const data = Buffer.isBuffer(request.body) ? request.body : Buffer.from(String(request.body ?? ''))
// Guess content type from extension
const contentType = guessContentType(name)
// Check if file already exists (overwrite)
const existingResolved = await WebDavService.resolvePath(app.db, resourcePath)
if (existingResolved.type === 'file' && existingResolved.file) {
await StorageFileService.delete(app.db, app.storage, existingResolved.file.id)
}
const file = await StorageFileService.upload(app.db, app.storage, {
folderId: parentResolved.folder.id,
data,
filename: name,
contentType,
uploadedBy: request.user.id,
})
return reply.status(existingResolved.type === 'file' ? 204 : 201).header('ETag', `"${file.id}"`).send('')
})
// --- DELETE ---
app.delete('/*', { preHandler: auth }, async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') return reply.status(403).send('No edit access')
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
return reply.status(204).send('')
}
if (resolved.type === 'folder' && resolved.folder) {
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (accessLevel !== 'admin') return reply.status(403).send('Admin access required')
await StorageFolderService.delete(app.db, resolved.folder.id)
return reply.status(204).send('')
}
return reply.status(404).send('Not Found')
})
// --- MKCOL (create folder) ---
app.route({
method: 'MKCOL' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const { parentPath, name } = WebDavService.parseParentAndName(resourcePath)
if (!name) return reply.status(400).send('Invalid path')
// Check if resource already exists
const existing = await WebDavService.resolvePath(app.db, resourcePath)
if (existing.type !== null) return reply.status(405).send('Resource already exists')
// Resolve parent
let parentFolderId: string | undefined
if (parentPath !== '/') {
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
return reply.status(409).send('Parent folder not found')
}
// Check edit permission on parent
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
if (!accessLevel || accessLevel === 'view') {
return reply.status(403).send('No edit access to parent folder')
}
parentFolderId = parentResolved.folder.id
}
await StorageFolderService.create(app.db, {
name,
parentId: parentFolderId,
isPublic: false,
createdBy: request.user.id,
})
return reply.status(201).send('')
},
})
// --- COPY ---
app.route({
method: 'COPY' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const destinationHeader = request.headers['destination'] as string
if (!destinationHeader) return reply.status(400).send('Destination header required')
const destUrl = new URL(destinationHeader, `http://${request.headers.host}`)
let destPath = destUrl.pathname
if (destPath.startsWith(WEBDAV_PREFIX)) destPath = destPath.slice(WEBDAV_PREFIX.length)
if (destPath.length > 1 && destPath.endsWith('/')) destPath = destPath.slice(0, -1)
destPath = destPath || '/'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type !== 'file' || !resolved.file || !resolved.folder) {
return reply.status(404).send('Source not found (only file copy supported)')
}
// Check read access on source
const srcAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
if (!srcAccess) return reply.status(403).send('No access to source')
// Resolve destination parent
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type !== 'folder' || !destParent.folder) {
return reply.status(409).send('Destination parent not found')
}
// Check edit access on destination
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
// Copy: read source file data and upload to destination
const fileData = await app.storage.get(resolved.file.path)
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
// Check if destination exists
const existingDest = await WebDavService.resolvePath(app.db, destPath)
if (existingDest.type === 'file' && existingDest.file) {
if (!overwrite) return reply.status(412).send('Destination exists')
await StorageFileService.delete(app.db, app.storage, existingDest.file.id)
}
await StorageFileService.upload(app.db, app.storage, {
folderId: destParent.folder.id,
data: fileData,
filename: destName || resolved.file.filename,
contentType: resolved.file.contentType,
uploadedBy: request.user.id,
})
return reply.status(existingDest.type === 'file' ? 204 : 201).send('')
},
})
// --- MOVE ---
app.route({
method: 'MOVE' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const destinationHeader = request.headers['destination'] as string
if (!destinationHeader) return reply.status(400).send('Destination header required')
const destUrl = new URL(destinationHeader, `http://${request.headers.host}`)
let destPath = destUrl.pathname
if (destPath.startsWith(WEBDAV_PREFIX)) destPath = destPath.slice(WEBDAV_PREFIX.length)
if (destPath.length > 1 && destPath.endsWith('/')) destPath = destPath.slice(0, -1)
destPath = destPath || '/'
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === 'file' && resolved.file && resolved.folder) {
// Check edit access on source folder
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (!srcAccess || srcAccess === 'view') return reply.status(403).send('No edit access to source')
// Resolve destination
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type !== 'folder' || !destParent.folder) {
return reply.status(409).send('Destination parent not found')
}
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
// Move: copy data then delete source
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
const existingDest = await WebDavService.resolvePath(app.db, destPath)
if (existingDest.type === 'file' && existingDest.file) {
if (!overwrite) return reply.status(412).send('Destination exists')
await StorageFileService.delete(app.db, app.storage, existingDest.file.id)
}
// Read source, upload to dest, delete source
const fileData = await app.storage.get(resolved.file.path)
await StorageFileService.upload(app.db, app.storage, {
folderId: destParent.folder.id,
data: fileData,
filename: destName || resolved.file.filename,
contentType: resolved.file.contentType,
uploadedBy: request.user.id,
})
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
return reply.status(existingDest.type === 'file' ? 204 : 201).send('')
}
if (resolved.type === 'folder' && resolved.folder) {
// Folder move — update parentId and recalculate path
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
if (srcAccess !== 'admin') return reply.status(403).send('Admin access required to move folder')
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
// If just renaming (same parent), update name
const destParent = await WebDavService.resolvePath(app.db, parentPath)
if (destParent.type === 'folder' || destParent.type === 'root') {
const newParentId = destParent.type === 'folder' ? destParent.folder?.id : undefined
if (newParentId) {
const destAccess = await StoragePermissionService.getAccessLevel(app.db, newParentId, request.user.id)
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
}
await StorageFolderService.update(app.db, resolved.folder.id, { name: destName })
// TODO: If parent changed, also update parentId + materialized path
return reply.status(201).send('')
}
return reply.status(409).send('Destination parent not found')
}
return reply.status(404).send('Not Found')
},
})
// --- LOCK ---
app.route({
method: 'LOCK' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const timeout = 300 // 5 minutes
const token = `opaquelocktoken:${randomUUID()}`
// Clean expired locks
const now = Date.now()
for (const [path, lock] of locks) {
if (lock.expires < now) locks.delete(path)
}
locks.set(resourcePath, {
token,
owner: request.user.id,
expires: now + timeout * 1000,
})
const xml = buildLockResponse(token, request.user.id, timeout)
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
.status(200)
.send(xml)
},
})
// LOCK on root
app.route({
method: 'LOCK' as any,
url: '/',
preHandler: auth,
handler: async (request, reply) => {
const token = `opaquelocktoken:${randomUUID()}`
const xml = buildLockResponse(token, request.user.id, 300)
locks.set('/', { token, owner: request.user.id, expires: Date.now() + 300000 })
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.header('Lock-Token', `<${token}>`)
.status(200)
.send(xml)
},
})
// --- UNLOCK ---
app.route({
method: 'UNLOCK' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
locks.delete(resourcePath)
return reply.status(204).send('')
},
})
app.route({
method: 'UNLOCK' as any,
url: '/',
preHandler: auth,
handler: async (_request, reply) => {
locks.delete('/')
return reply.status(204).send('')
},
})
// --- PROPPATCH (stub — accept but do nothing) ---
app.route({
method: 'PROPPATCH' as any,
url: '/*',
preHandler: auth,
handler: async (request, reply) => {
const resourcePath = getResourcePath(request)
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
if (resolved.type === null) return reply.status(404).send('Not Found')
// Return a minimal success response
const xml = buildMultistatus([{
href: `${WEBDAV_PREFIX}${resourcePath}`,
isCollection: resolved.type === 'folder' || resolved.type === 'root',
displayName: resolved.type === 'file' ? resolved.file!.filename : resolved.type === 'folder' ? resolved.folder!.name : 'Files',
}])
return reply
.header('Content-Type', 'application/xml; charset=utf-8')
.status(207)
.send(xml)
},
})
}
function guessContentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
const map: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg', jpeg: 'image/jpeg',
png: 'image/png', webp: 'image/webp', gif: 'image/gif',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
csv: 'text/csv', txt: 'text/plain',
mp4: 'video/mp4', mp3: 'audio/mpeg',
zip: 'application/zip',
json: 'application/json',
xml: 'application/xml',
html: 'text/html', htm: 'text/html',
}
return map[ext ?? ''] ?? 'application/octet-stream'
}

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

View File

@@ -0,0 +1,104 @@
/**
* WebDAV XML response builders.
* Generates DAV-compliant XML without external dependencies.
*/
export interface DavResource {
href: string
isCollection: boolean
displayName: string
contentType?: string
contentLength?: number
lastModified?: Date
createdAt?: Date
etag?: string
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatRfc1123(date: Date): string {
return date.toUTCString()
}
function formatIso8601(date: Date): string {
return date.toISOString()
}
function buildResourceResponse(resource: DavResource): string {
const props: string[] = []
if (resource.isCollection) {
props.push('<D:resourcetype><D:collection/></D:resourcetype>')
} else {
props.push('<D:resourcetype/>')
}
props.push(`<D:displayname>${escapeXml(resource.displayName)}</D:displayname>`)
if (resource.contentType && !resource.isCollection) {
props.push(`<D:getcontenttype>${escapeXml(resource.contentType)}</D:getcontenttype>`)
}
if (resource.contentLength != null && !resource.isCollection) {
props.push(`<D:getcontentlength>${resource.contentLength}</D:getcontentlength>`)
}
if (resource.lastModified) {
props.push(`<D:getlastmodified>${formatRfc1123(resource.lastModified)}</D:getlastmodified>`)
}
if (resource.createdAt) {
props.push(`<D:creationdate>${formatIso8601(resource.createdAt)}</D:creationdate>`)
}
if (resource.etag) {
props.push(`<D:getetag>"${escapeXml(resource.etag)}"</D:getetag>`)
}
return `<D:response>
<D:href>${escapeXml(resource.href)}</D:href>
<D:propstat>
<D:prop>
${props.join('\n')}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`
}
export function buildMultistatus(resources: DavResource[]): string {
const responses = resources.map(buildResourceResponse).join('\n')
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
${responses}
</D:multistatus>`
}
export function buildLockResponse(lockToken: string, owner: string, timeout: number): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:">
<D:lockdiscovery>
<D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:depth>infinity</D:depth>
<D:owner><D:href>${escapeXml(owner)}</D:href></D:owner>
<D:timeout>Second-${timeout}</D:timeout>
<D:locktoken><D:href>${escapeXml(lockToken)}</D:href></D:locktoken>
</D:activelock>
</D:lockdiscovery>
</D:prop>`
}
export function buildErrorResponse(statusCode: number, message: string): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:error xmlns:D="DAV:">
<D:message>${escapeXml(message)}</D:message>
</D:error>`
}