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:
@@ -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>
|
||||
)
|
||||
}
|
||||
33
packages/admin/src/components/ui/switch.tsx
Normal file
33
packages/admin/src/components/ui/switch.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface StorageFolder {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
breadcrumbs?: { id: string; name: string }[]
|
||||
accessLevel?: 'view' | 'edit' | 'admin' | null
|
||||
}
|
||||
|
||||
export interface StorageFolderPermission {
|
||||
|
||||
294
packages/backend/__tests__/routes/webdav/webdav.test.ts
Normal file
294
packages/backend/__tests__/routes/webdav/webdav.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
306
packages/backend/api-tests/suites/webdav.ts
Normal file
306
packages/backend/api-tests/suites/webdav.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
78
packages/backend/src/plugins/webdav-auth.ts
Normal file
78
packages/backend/src/plugins/webdav-auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
542
packages/backend/src/routes/webdav/index.ts
Normal file
542
packages/backend/src/routes/webdav/index.ts
Normal 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'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
121
packages/backend/src/services/webdav.service.ts
Normal file
121
packages/backend/src/services/webdav.service.ts
Normal 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 }
|
||||
},
|
||||
}
|
||||
104
packages/backend/src/utils/webdav-xml.ts
Normal file
104
packages/backend/src/utils/webdav-xml.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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>`
|
||||
}
|
||||
Reference in New Issue
Block a user