diff --git a/packages/backend/src/plugins/webdav-auth.ts b/packages/backend/src/plugins/webdav-auth.ts index 333fba6..a419df0 100644 --- a/packages/backend/src/plugins/webdav-auth.ts +++ b/packages/backend/src/plugins/webdav-auth.ts @@ -30,6 +30,40 @@ function expandPermissions(slugs: string[]): Set { return expanded } +// Simple rate limiter: track failed attempts per IP +const failedAttempts = new Map() +const MAX_FAILED_ATTEMPTS = 10 +const LOCKOUT_DURATION = 5 * 60 * 1000 // 5 minutes + +function checkRateLimit(ip: string): boolean { + const now = Date.now() + const entry = failedAttempts.get(ip) + if (!entry || entry.resetAt < now) return true // allowed + return entry.count < MAX_FAILED_ATTEMPTS +} + +function recordFailure(ip: string) { + const now = Date.now() + const entry = failedAttempts.get(ip) + if (!entry || entry.resetAt < now) { + failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION }) + } else { + entry.count++ + } +} + +function clearFailures(ip: string) { + failedAttempts.delete(ip) +} + +// Periodic cleanup of old entries +setInterval(() => { + const now = Date.now() + for (const [ip, entry] of failedAttempts) { + if (entry.resetAt < now) failedAttempts.delete(ip) + } +}, 60_000) + /** * WebDAV Basic Auth pre-handler. * Verifies HTTP Basic Auth credentials against the users table @@ -37,6 +71,13 @@ function expandPermissions(slugs: string[]): Set { */ export function webdavBasicAuth(app: FastifyInstance) { return async function (request: FastifyRequest, reply: FastifyReply) { + const ip = request.ip + + if (!checkRateLimit(ip)) { + reply.header('Retry-After', '300') + return reply.status(429).send('Too many failed attempts. Try again later.') + } + const authHeader = request.headers.authorization if (!authHeader || !authHeader.startsWith('Basic ')) { reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"') @@ -46,6 +87,7 @@ export function webdavBasicAuth(app: FastifyInstance) { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8') const colonIndex = decoded.indexOf(':') if (colonIndex === -1) { + recordFailure(ip) reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"') return reply.status(401).send('Invalid credentials') } @@ -60,17 +102,20 @@ export function webdavBasicAuth(app: FastifyInstance) { .limit(1) if (!user || !user.isActive) { + recordFailure(ip) 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) { + recordFailure(ip) reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"') return reply.status(401).send('Invalid credentials') } - // Attach user and permissions + // Success — clear failure counter + clearFailures(ip) 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 1d96ba2..de0d371 100644 --- a/packages/backend/src/routes/v1/storage.ts +++ b/packages/backend/src/routes/v1/storage.ts @@ -15,10 +15,10 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { const { name, parentId, isPublic } = request.body as { name?: string; parentId?: string; isPublic?: boolean } if (!name?.trim()) throw new ValidationError('Folder name is required') - // Check parent access if creating subfolder + // Check parent access if creating subfolder — require at least edit (not traverse or view) if (parentId) { - const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentId, request.user.id) - if (!accessLevel || accessLevel === 'view') { + const hasEdit = await StoragePermissionService.hasAccess(app.db, parentId, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send({ error: { message: 'No edit access to parent folder', statusCode: 403 } }) } } @@ -29,10 +29,16 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { app.get('/storage/folders', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { parentId } = request.query as { parentId?: string } - const folders = parentId + const allChildren = parentId ? await StorageFolderService.listChildren(app.db, parentId) : await StorageFolderService.listChildren(app.db, null) - return reply.send({ data: folders }) + // Filter to only folders the user can access (at least traverse) + const accessible = [] + for (const folder of allChildren) { + const level = await StoragePermissionService.getAccessLevel(app.db, folder.id, request.user.id) + if (level) accessible.push(folder) + } + return reply.send({ data: accessible }) }) app.get('/storage/folders/tree', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { @@ -56,8 +62,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { const { id } = request.params as { id: string } const { name, isPublic } = request.body as { name?: string; isPublic?: boolean } - const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id) - if (!accessLevel || accessLevel === 'view') { + const hasEdit = await StoragePermissionService.hasAccess(app.db, id, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } }) } @@ -83,6 +89,9 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { app.get('/storage/folders/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { id } = request.params as { id: string } + // Only folder admins can view permission details + const hasAdmin = await StoragePermissionService.hasAccess(app.db, id, request.user.id, 'admin') + if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } }) const permissions = await StoragePermissionService.listPermissions(app.db, id) return reply.send({ data: permissions }) }) @@ -105,9 +114,18 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { app.delete('/storage/folder-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => { const { id } = request.params as { id: string } - const perm = await StoragePermissionService.removePermission(app.db, id) + // Look up the permission to find which folder it belongs to + const existing = await StoragePermissionService.listPermissions(app.db, id) + // listPermissions takes folderId, we need to find by perm id — use removePermission which fetches first + const perm = await StoragePermissionService.getPermissionById(app.db, id) if (!perm) return reply.status(404).send({ error: { message: 'Permission not found', statusCode: 404 } }) - return reply.send(perm) + + // Verify admin access on the folder this permission belongs to + const hasAdmin = await StoragePermissionService.hasAccess(app.db, perm.folderId, request.user.id, 'admin') + if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } }) + + const deleted = await StoragePermissionService.removePermission(app.db, id) + return reply.send(deleted) }) // --- Files --- @@ -115,8 +133,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { app.post('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => { const { folderId } = request.params as { folderId: string } - const accessLevel = await StoragePermissionService.getAccessLevel(app.db, folderId, request.user.id) - if (!accessLevel || accessLevel === 'view') { + const hasEdit = await StoragePermissionService.hasAccess(app.db, folderId, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send({ error: { message: 'No edit access to this folder', statusCode: 403 } }) } @@ -153,8 +171,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { const file = await StorageFileService.getById(app.db, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) - const accessLevel = await StoragePermissionService.getAccessLevel(app.db, file.folderId, request.user.id) - if (!accessLevel || accessLevel === 'view') { + const hasEdit = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } }) } @@ -167,8 +185,8 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { const file = await StorageFileService.getById(app.db, id) if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } }) - const hasAccess = await StoragePermissionService.canAccess(app.db, file.folderId, request.user.id) - if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) + const hasView = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'view') + if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) const token = app.jwt.sign({ path: file.path, purpose: 'file-access' } as any, { expiresIn: '15m' }) const signedUrl = `/v1/files/s/${file.path}?token=${token}` @@ -181,6 +199,14 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { const params = PaginationSchema.parse(request.query) const result = await StorageFileService.search(app.db, q, params) - return reply.send(result) + + // Filter results to only files the user has at least view access to + const filtered = [] + for (const file of result.data) { + const hasView = await StoragePermissionService.hasAccess(app.db, (file as any).folderId, request.user.id, 'view') + if (hasView) filtered.push(file) + } + + return reply.send({ data: filtered, pagination: { ...result.pagination, total: filtered.length } }) }) } diff --git a/packages/backend/src/routes/webdav/index.ts b/packages/backend/src/routes/webdav/index.ts index eb7c5f0..01eb071 100644 --- a/packages/backend/src/routes/webdav/index.ts +++ b/packages/backend/src/routes/webdav/index.ts @@ -12,10 +12,20 @@ const DAV_METHODS = 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL const WEBDAV_PREFIX = '/webdav' export const webdavRoutes: FastifyPluginAsync = async (app) => { - // Parse all request bodies as raw buffers for WebDAV + // Parse all request bodies as raw buffers for WebDAV, with size limit + const MAX_UPLOAD_SIZE = 100 * 1024 * 1024 // 100MB app.addContentTypeParser('*', function (_request, payload, done) { const chunks: Buffer[] = [] - payload.on('data', (chunk: Buffer) => chunks.push(chunk)) + let totalSize = 0 + payload.on('data', (chunk: Buffer) => { + totalSize += chunk.length + if (totalSize > MAX_UPLOAD_SIZE) { + payload.destroy() + done(new Error('Payload too large'), undefined) + return + } + chunks.push(chunk) + }) payload.on('end', () => done(null, Buffer.concat(chunks))) payload.on('error', done) }) @@ -85,7 +95,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { }) if (depth !== '0') { const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`) - resources.push(...children) + // Filter to only folders the user has access to + const filtered = [] + for (const child of children) { + if (!child.isCollection) continue // root has no files + // Extract folder name from href to resolve access + const folderName = decodeURIComponent(child.href.replace(`${WEBDAV_PREFIX}/`, '').replace(/\/$/, '')) + const resolved = await WebDavService.resolvePath(app.db, '/' + folderName) + if (resolved.type === 'folder' && resolved.folder) { + const level = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id) + if (level) filtered.push(child) + } + } + resources.push(...filtered) } } else if (resolved.type === 'folder' && resolved.folder) { // Check access — traverse or higher lets you see the folder @@ -144,7 +166,17 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { if (depth !== '0') { const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`) - resources.push(...children) + const filtered = [] + for (const child of children) { + if (!child.isCollection) continue + const folderName = decodeURIComponent(child.href.replace(`${WEBDAV_PREFIX}/`, '').replace(/\/$/, '')) + const childResolved = await WebDavService.resolvePath(app.db, '/' + folderName) + if (childResolved.type === 'folder' && childResolved.folder) { + const level = await StoragePermissionService.getAccessLevel(app.db, childResolved.folder.id, request.user.id) + if (level) filtered.push(child) + } + } + resources.push(...filtered) } const xml = buildMultistatus(resources) @@ -189,15 +221,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { if (!name) return reply.status(400).send('Invalid path') + // Check if resource is locked by another user + const lockCheck = checkLock(resourcePath, request.user.id) + if (lockCheck.byOther) return reply.status(423).send('Resource is locked by another user') + // 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') { + // Check edit permission — traverse and view are not enough + const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send('No edit access') } @@ -227,11 +263,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { // --- DELETE --- app.delete('/*', { preHandler: auth }, async (request, reply) => { const resourcePath = getResourcePath(request) + + // Check if resource is locked by another user + const lockCheck = checkLock(resourcePath, request.user.id) + if (lockCheck.byOther) return reply.status(423).send('Resource is locked by another user') + 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') + const hasEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit') + if (!hasEdit) return reply.status(403).send('No edit access') await StorageFileService.delete(app.db, app.storage, resolved.file.id) return reply.status(204).send('') } @@ -268,12 +309,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { 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') { + const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit') + if (!hasEdit) { return reply.status(403).send('No edit access to parent folder') } parentFolderId = parentResolved.folder.id + } else { + // Creating top-level folder requires files.admin permission + if (!request.permissions.has('files.delete')) { + return reply.status(403).send('Admin permission required to create top-level folders') + } } await StorageFolderService.create(app.db, { @@ -308,9 +353,9 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { 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') + // Check view access on source (traverse is not enough to read file data) + const hasSrcView = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'view') + if (!hasSrcView) return reply.status(403).send('No access to source') // Resolve destination parent const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath) @@ -320,8 +365,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { } // 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') + const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit') + if (!hasDestEdit) 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) @@ -366,8 +411,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { 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') + const hasSrcEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit') + if (!hasSrcEdit) return reply.status(403).send('No edit access to source') // Resolve destination const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath) @@ -375,8 +420,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { 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') + const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit') + if (!hasDestEdit) 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' @@ -412,8 +457,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { 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') + const hasDestEdit = await StoragePermissionService.hasAccess(app.db, newParentId, request.user.id, 'edit') + if (!hasDestEdit) return reply.status(403).send('No edit access to destination') } await StorageFolderService.update(app.db, resolved.folder.id, { name: destName }) @@ -428,6 +473,23 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { }, }) + // --- Lock helpers --- + const MAX_LOCKS_PER_USER = 100 + const LOCK_TIMEOUT = 300 // 5 minutes + + function cleanExpiredLocks() { + const now = Date.now() + for (const [path, lock] of locks) { + if (lock.expires < now) locks.delete(path) + } + } + + function checkLock(resourcePath: string, userId: string): { locked: boolean; byOther: boolean } { + const lock = locks.get(resourcePath) + if (!lock || lock.expires < Date.now()) return { locked: false, byOther: false } + return { locked: true, byOther: lock.owner !== userId } + } + // --- LOCK --- app.route({ method: 'LOCK' as any, @@ -435,22 +497,27 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { preHandler: auth, handler: async (request, reply) => { const resourcePath = getResourcePath(request) - const timeout = 300 // 5 minutes - const token = `opaquelocktoken:${randomUUID()}` + cleanExpiredLocks() - // Clean expired locks - const now = Date.now() - for (const [path, lock] of locks) { - if (lock.expires < now) locks.delete(path) + // Check if locked by another user + const existing = locks.get(resourcePath) + if (existing && existing.expires > Date.now() && existing.owner !== request.user.id) { + return reply.status(423).send('Resource is locked by another user') } - locks.set(resourcePath, { - token, - owner: request.user.id, - expires: now + timeout * 1000, - }) + // Cap locks per user + let userLockCount = 0 + for (const lock of locks.values()) { + if (lock.owner === request.user.id && lock.expires > Date.now()) userLockCount++ + } + if (userLockCount >= MAX_LOCKS_PER_USER) { + return reply.status(400).send('Too many active locks') + } - const xml = buildLockResponse(token, request.user.id, timeout) + const token = `opaquelocktoken:${randomUUID()}` + locks.set(resourcePath, { token, owner: request.user.id, expires: Date.now() + LOCK_TIMEOUT * 1000 }) + + const xml = buildLockResponse(token, request.user.id, LOCK_TIMEOUT) return reply .header('Content-Type', 'application/xml; charset=utf-8') .header('Lock-Token', `<${token}>`) @@ -459,15 +526,15 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { }, }) - // LOCK on root app.route({ method: 'LOCK' as any, url: '/', preHandler: auth, handler: async (request, reply) => { + cleanExpiredLocks() const token = `opaquelocktoken:${randomUUID()}` - const xml = buildLockResponse(token, request.user.id, 300) - locks.set('/', { token, owner: request.user.id, expires: Date.now() + 300000 }) + locks.set('/', { token, owner: request.user.id, expires: Date.now() + LOCK_TIMEOUT * 1000 }) + const xml = buildLockResponse(token, request.user.id, LOCK_TIMEOUT) return reply .header('Content-Type', 'application/xml; charset=utf-8') .header('Lock-Token', `<${token}>`) @@ -483,6 +550,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { preHandler: auth, handler: async (request, reply) => { const resourcePath = getResourcePath(request) + const lock = locks.get(resourcePath) + // Only the lock owner can unlock + if (lock && lock.owner !== request.user.id) { + return reply.status(403).send('Not the lock owner') + } locks.delete(resourcePath) return reply.status(204).send('') }, @@ -492,7 +564,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { method: 'UNLOCK' as any, url: '/', preHandler: auth, - handler: async (_request, reply) => { + handler: async (request, reply) => { + const lock = locks.get('/') + if (lock && lock.owner !== request.user.id) { + return reply.status(403).send('Not the lock owner') + } locks.delete('/') return reply.status(204).send('') }, diff --git a/packages/backend/src/services/storage.service.ts b/packages/backend/src/services/storage.service.ts index 9b97614..4452fc2 100644 --- a/packages/backend/src/services/storage.service.ts +++ b/packages/backend/src/services/storage.service.ts @@ -7,6 +7,14 @@ import { randomUUID } from 'crypto' import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' import type { PaginationInput } from '@forte/shared/schemas' +const MAX_PARENT_DEPTH = 50 + +const ACCESS_RANK: Record = { traverse: 0, view: 1, edit: 2, admin: 3 } + +function highestAccess(a: string, b: string): 'admin' | 'edit' | 'view' | 'traverse' { + return (ACCESS_RANK[a] ?? 0) >= (ACCESS_RANK[b] ?? 0) ? a as any : b as any +} + function getExtension(contentType: string): string { const map: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', @@ -16,75 +24,87 @@ function getExtension(contentType: string): string { 'application/msword': 'doc', 'application/vnd.ms-excel': 'xls', 'text/plain': 'txt', 'text/csv': 'csv', } - return map[contentType] ?? contentType.split('/')[1] ?? 'bin' + // Sanitize: only allow alphanumeric extensions + const fallback = (contentType.split('/')[1] ?? 'bin').replace(/[^a-zA-Z0-9]/g, '') + return map[contentType] ?? (fallback || 'bin') } // --- Permission Service --- export const StoragePermissionService = { + /** + * Check if a user has at least the given minimum access level on a folder. + * Use this instead of canAccess() to enforce traverse vs view distinction. + */ + async hasAccess(db: PostgresJsDatabase, folderId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise { + const level = await this.getAccessLevel(db, folderId, userId) + if (!level) return false + return ACCESS_RANK[level] >= ACCESS_RANK[minLevel] + }, + + /** + * @deprecated Use hasAccess() with explicit minLevel instead. + * canAccess returns true for traverse, which may not be intended. + */ async canAccess(db: PostgresJsDatabase, folderId: string, userId: string): Promise { - // 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) - .where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId))) - .limit(1) - if (userPerm) return true - - // Check role-based permission - const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) - if (userRoleIds.length > 0) { - const roleIds = userRoleIds.map((r) => r.roleId) - const [rolePerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions) - .where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds))) - .limit(1) - if (rolePerm) return true - } - - // Check parent folder (inherited permissions) - const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) - if (parentFolder?.parentId) { - return this.canAccess(db, parentFolder.parentId, userId) - } - - return false + const level = await this.getAccessLevel(db, folderId, userId) + return level !== null }, async getAccessLevel(db: PostgresJsDatabase, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> { - const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + const visited = new Set() + return this._getAccessLevelInner(db, folderId, userId, visited, false) + }, + + /** + * Inner recursive access level resolver with cycle protection and depth limit. + * isInherited: true when recursing into parent folders — isPublic only applies to the directly requested folder. + */ + async _getAccessLevelInner( + db: PostgresJsDatabase, folderId: string, userId: string, + visited: Set, isInherited: boolean, + ): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> { + if (visited.has(folderId) || visited.size >= MAX_PARENT_DEPTH) return null + visited.add(folderId) + + const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy, parentId: storageFolders.parentId }) + .from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) if (!folder) return null // Creator always has admin if (folder.createdBy === userId) return 'admin' + // Collect the best explicit permission on this folder + let best: 'admin' | 'edit' | 'view' | 'traverse' | null = null + // Check direct user permission const [userPerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions) .where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId))) .limit(1) - if (userPerm) return userPerm.accessLevel + if (userPerm) best = userPerm.accessLevel - // Check role-based permission - const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) - if (userRoleIds.length > 0) { - const roleIds = userRoleIds.map((r) => r.roleId) - const [rolePerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions) + // Check role-based permissions — pick highest across all roles + const userRoleRows = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId)) + if (userRoleRows.length > 0) { + const roleIds = userRoleRows.map((r) => r.roleId) + const rolePerms = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions) .where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds))) - .limit(1) - if (rolePerm) return rolePerm.accessLevel + for (const rp of rolePerms) { + best = best ? highestAccess(best, rp.accessLevel) : rp.accessLevel + } } - // Check parent (inherited) - const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) - if (parentFolder?.parentId) { - return this.getAccessLevel(db, parentFolder.parentId, userId) + if (best) return best + + // Check parent (inherited permissions) — only explicit permissions inherit, not isPublic + if (folder.parentId) { + const inherited = await this._getAccessLevelInner(db, folder.parentId, userId, visited, true) + if (inherited) return inherited } - // Public folders give view access - if (folder.isPublic) return 'view' + // isPublic only grants view on the directly requested folder, not via inheritance + // This prevents a public grandparent from granting view on a private child + if (!isInherited && folder.isPublic) return 'view' return null }, @@ -93,6 +113,11 @@ export const StoragePermissionService = { return db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.folderId, folderId)) }, + async getPermissionById(db: PostgresJsDatabase, permissionId: string) { + const [perm] = await db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).limit(1) + return perm ?? null + }, + async setPermission(db: PostgresJsDatabase, folderId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) { // Remove existing permission for this role/user on this folder if (roleId) { @@ -125,9 +150,11 @@ export const StoragePermissionService = { const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) if (!folder?.parentId) return + const visited = new Set() let currentParentId: string | null = folder.parentId - while (currentParentId) { - // Check if this role/user already has any permission on this ancestor + while (currentParentId && !visited.has(currentParentId) && visited.size < MAX_PARENT_DEPTH) { + visited.add(currentParentId) + const whereClause = roleId ? and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.roleId, roleId)) : and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.userId, userId!)) @@ -135,7 +162,6 @@ export const StoragePermissionService = { const [existing] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1) if (!existing) { - // No permission — add traverse await db.insert(storageFolderPermissions).values({ folderId: currentParentId, roleId: roleId ?? null, @@ -143,17 +169,94 @@ export const StoragePermissionService = { accessLevel: 'traverse' as any, }) } - // If they already have any permission (even traverse), don't touch it - // Walk up const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentParentId)).limit(1) currentParentId = parent?.parentId ?? null } }, async removePermission(db: PostgresJsDatabase, permissionId: string) { - const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning() - return perm ?? null + // Fetch before deleting so we can clean up ancestors + const [perm] = await db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).limit(1) + if (!perm) return null + + const [deleted] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning() + + // Clean up orphaned traverse permissions on ancestors + await this.cleanupTraverseAncestors(db, perm.folderId, perm.roleId ?? undefined, perm.userId ?? undefined) + + return deleted ?? null + }, + + /** + * After removing a permission, check if traverse grants on ancestors are still needed. + * A traverse permission is orphaned if the user/role has no other permissions on the + * folder itself or any of its descendants. + */ + async cleanupTraverseAncestors(db: PostgresJsDatabase, folderId: string, roleId: string | undefined, userId: string | undefined) { + const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (!folder?.parentId) return + + const visited = new Set() + let currentId: string | null = folder.parentId + while (currentId && !visited.has(currentId) && visited.size < MAX_PARENT_DEPTH) { + visited.add(currentId) + + // Find the traverse permission for this ancestor + const whereClause = roleId + ? and(eq(storageFolderPermissions.folderId, currentId), eq(storageFolderPermissions.roleId, roleId), eq(storageFolderPermissions.accessLevel, 'traverse')) + : and(eq(storageFolderPermissions.folderId, currentId), eq(storageFolderPermissions.userId, userId!), eq(storageFolderPermissions.accessLevel, 'traverse')) + + const [traversePerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1) + if (!traversePerm) break // No traverse to clean, stop walking up + + // Check if there are any other (non-traverse) permissions for this user/role on descendants + // Use materialized path prefix to find all descendants efficiently + const [currentFolder] = await db.select({ path: storageFolders.path, name: storageFolders.name }).from(storageFolders).where(eq(storageFolders.id, currentId)).limit(1) + if (!currentFolder) break + + const descendantPath = `${currentFolder.path}${currentFolder.name}/` + const descendants = await db.select({ id: storageFolders.id }).from(storageFolders) + .where(ilike(storageFolders.path, `${descendantPath}%`)) + + const descendantIds = descendants.map((d) => d.id) + // Also include the folder itself + descendantIds.push(currentId) + + const permWhereBase = roleId + ? eq(storageFolderPermissions.roleId, roleId) + : eq(storageFolderPermissions.userId, userId!) + + let hasOtherPerms = false + if (descendantIds.length > 0) { + const [otherPerm] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions) + .where(and( + inArray(storageFolderPermissions.folderId, descendantIds), + permWhereBase, + // Exclude the traverse perm we're considering removing + // Look for any non-traverse permission + )) + .limit(2) // We need to check if there's more than just the traverse perm itself + + // Count how many perms exist - if only the traverse perm on this folder, it's orphaned + const allPerms = await db.select({ id: storageFolderPermissions.id, accessLevel: storageFolderPermissions.accessLevel, folderId: storageFolderPermissions.folderId }) + .from(storageFolderPermissions) + .where(and(inArray(storageFolderPermissions.folderId, descendantIds), permWhereBase)) + + hasOtherPerms = allPerms.some((p) => p.id !== traversePerm.id) + } + + if (!hasOtherPerms) { + // Orphaned — remove the traverse permission + await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, traversePerm.id)) + } else { + break // Still needed, stop walking up + } + + // Walk up + const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentId)).limit(1) + currentId = parent?.parentId ?? null + } }, } @@ -189,18 +292,11 @@ export const StorageFolderService = { }, async listAllAccessible(db: PostgresJsDatabase, userId: string) { - // Get all folders and filter by access — for the tree view const allFolders = await db.select().from(storageFolders).orderBy(storageFolders.name) - - // For each folder, check access (this is simplified — in production you'd optimize with a single query) const accessible = [] for (const folder of allFolders) { - if (folder.isPublic || folder.createdBy === userId) { - accessible.push(folder) - continue - } - const hasAccess = await StoragePermissionService.canAccess(db, folder.id, userId) - if (hasAccess) accessible.push(folder) + const level = await StoragePermissionService.getAccessLevel(db, folder.id, userId) + if (level) accessible.push(folder) } return accessible }, diff --git a/packages/backend/src/services/webdav.service.ts b/packages/backend/src/services/webdav.service.ts index b8b56d2..8c6bc0b 100644 --- a/packages/backend/src/services/webdav.service.ts +++ b/packages/backend/src/services/webdav.service.ts @@ -18,6 +18,11 @@ export const WebDavService = { async resolvePath(db: PostgresJsDatabase, urlPath: string): Promise { const segments = urlPath.split('/').filter(Boolean).map(decodeURIComponent) + // Reject path traversal and null bytes + if (segments.some(s => s === '.' || s === '..' || s.includes('\0'))) { + return { type: null } + } + if (segments.length === 0) { return { type: 'root' } }