diff --git a/packages/admin/src/components/storage/folder-permissions-dialog.tsx b/packages/admin/src/components/storage/folder-permissions-dialog.tsx new file mode 100644 index 0000000..095a61a --- /dev/null +++ b/packages/admin/src/components/storage/folder-permissions-dialog.tsx @@ -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 ( + + + + + + Permissions — {folderName} + + + +
+ {/* Public toggle */} +
+
+ +

+ Public folders are viewable by all users with file access +

+
+ togglePublicMutation.mutate(checked)} + disabled={togglePublicMutation.isPending} + /> +
+ + {/* Current permissions */} +
+ +
+ {permsLoading ? ( +

Loading...

+ ) : permissions.length === 0 ? ( +

No specific permissions assigned

+ ) : ( + permissions.map((perm) => { + const { icon: Icon, name } = getPermissionLabel(perm) + const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel) + return ( +
+
+ + {name} +
+
+ + {level?.label ?? perm.accessLevel} + + +
+
+ ) + }) + )} +
+
+ + {/* Add permission form */} +
+ + + {/* Role / User toggle */} +
+ + +
+ +
+ {/* Assignee select */} + + + {/* Access level select */} + +
+ + +
+
+
+
+ ) +} diff --git a/packages/admin/src/components/ui/switch.tsx b/packages/admin/src/components/ui/switch.tsx new file mode 100644 index 0000000..dd2a617 --- /dev/null +++ b/packages/admin/src/components/ui/switch.tsx @@ -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 & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/packages/admin/src/routes/_authenticated/files/index.tsx b/packages/admin/src/routes/_authenticated/files/index.tsx index d8a3a3d..b28310f 100644 --- a/packages/admin/src/routes/_authenticated/files/index.tsx +++ b/packages/admin/src/routes/_authenticated/files/index.tsx @@ -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(null) const [newFolderOpen, setNewFolderOpen] = useState(false) const [newFolderName, setNewFolderName] = useState('') + const [permissionsOpen, setPermissionsOpen] = useState(false) const fileInputRef = useRef(null) const { params } = usePagination() @@ -193,6 +195,11 @@ function FileManagerPage() { {selectedFolderId && ( <> + {folderDetail?.accessLevel === 'admin' && ( + + )} @@ -283,6 +290,16 @@ function FileManagerPage() { )} + + {selectedFolderId && folderDetail && ( + + )} ) } diff --git a/packages/admin/src/types/storage.ts b/packages/admin/src/types/storage.ts index 9b03402..8543966 100644 --- a/packages/admin/src/types/storage.ts +++ b/packages/admin/src/types/storage.ts @@ -8,6 +8,7 @@ export interface StorageFolder { createdAt: string updatedAt: string breadcrumbs?: { id: string; name: string }[] + accessLevel?: 'view' | 'edit' | 'admin' | null } export interface StorageFolderPermission { diff --git a/packages/backend/__tests__/routes/webdav/webdav.test.ts b/packages/backend/__tests__/routes/webdav/webdav.test.ts new file mode 100644 index 0000000..361bf5d --- /dev/null +++ b/packages/backend/__tests__/routes/webdav/webdav.test.ts @@ -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(' { + // 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(' { + 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() + }) + }) +}) diff --git a/packages/backend/api-tests/suites/webdav.ts b/packages/backend/api-tests/suites/webdav.ts new file mode 100644 index 0000000..75d98c5 --- /dev/null +++ b/packages/backend/api-tests/suites/webdav.ts @@ -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; body?: string | Buffer } = { auth: '' }, +) { + const headers: Record = { + 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, ' { + // 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, ' { + 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) + }) +}) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 1c87296..52f1577 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -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 () => { diff --git a/packages/backend/src/plugins/cors.ts b/packages/backend/src/plugins/cors.ts index 8b332b2..1779b01 100644 --- a/packages/backend/src/plugins/cors.ts +++ b/packages/backend/src/plugins/cors.ts @@ -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, + }) }) diff --git a/packages/backend/src/plugins/webdav-auth.ts b/packages/backend/src/plugins/webdav-auth.ts new file mode 100644 index 0000000..333fba6 --- /dev/null +++ b/packages/backend/src/plugins/webdav-auth.ts @@ -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 = { + admin: ['admin', 'edit', 'view'], + edit: ['edit', 'view'], + view: ['view'], + upload: ['upload'], + delete: ['delete'], + send: ['send'], + export: ['export'], +} + +function expandPermissions(slugs: string[]): Set { + const expanded = new Set() + 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) + } +} diff --git a/packages/backend/src/routes/v1/storage.ts b/packages/backend/src/routes/v1/storage.ts index 0aeb6b6..7d3cfd4 100644 --- a/packages/backend/src/routes/v1/storage.ts +++ b/packages/backend/src/routes/v1/storage.ts @@ -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) => { diff --git a/packages/backend/src/routes/webdav/index.ts b/packages/backend/src/routes/webdav/index.ts new file mode 100644 index 0000000..cf4b3bc --- /dev/null +++ b/packages/backend/src/routes/webdav/index.ts @@ -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() + +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 = { + 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' +} diff --git a/packages/backend/src/services/storage.service.ts b/packages/backend/src/services/storage.service.ts index a489780..c3e2aee 100644 --- a/packages/backend/src/services/storage.service.ts +++ b/packages/backend/src/services/storage.service.ts @@ -23,10 +23,11 @@ function getExtension(contentType: string): string { export const StoragePermissionService = { async canAccess(db: PostgresJsDatabase, folderId: string, userId: string): Promise { - // 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) diff --git a/packages/backend/src/services/webdav.service.ts b/packages/backend/src/services/webdav.service.ts new file mode 100644 index 0000000..3cd5bd3 --- /dev/null +++ b/packages/backend/src/services/webdav.service.ts @@ -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, urlPath: string): Promise { + 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, folderId: string | null, basePath: string): Promise { + 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 } + }, +} diff --git a/packages/backend/src/utils/webdav-xml.ts b/packages/backend/src/utils/webdav-xml.ts new file mode 100644 index 0000000..12188d7 --- /dev/null +++ b/packages/backend/src/utils/webdav-xml.ts @@ -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, '"') +} + +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('') + } else { + props.push('') + } + + props.push(`${escapeXml(resource.displayName)}`) + + if (resource.contentType && !resource.isCollection) { + props.push(`${escapeXml(resource.contentType)}`) + } + + if (resource.contentLength != null && !resource.isCollection) { + props.push(`${resource.contentLength}`) + } + + if (resource.lastModified) { + props.push(`${formatRfc1123(resource.lastModified)}`) + } + + if (resource.createdAt) { + props.push(`${formatIso8601(resource.createdAt)}`) + } + + if (resource.etag) { + props.push(`"${escapeXml(resource.etag)}"`) + } + + return ` +${escapeXml(resource.href)} + + +${props.join('\n')} + +HTTP/1.1 200 OK + +` +} + +export function buildMultistatus(resources: DavResource[]): string { + const responses = resources.map(buildResourceResponse).join('\n') + return ` + +${responses} +` +} + +export function buildLockResponse(lockToken: string, owner: string, timeout: number): string { + return ` + + + + + +infinity +${escapeXml(owner)} +Second-${timeout} +${escapeXml(lockToken)} + + +` +} + +export function buildErrorResponse(statusCode: number, message: string): string { + return ` + +${escapeXml(message)} +` +}