Harden storage permissions and WebDAV security
Permission service:
- Add hasAccess() with explicit minLevel param, deprecate canAccess()
- Cycle protection + depth limit (50) on all parent traversal
- Pick highest access level across multiple roles (was using first match)
- isPublic only grants view on directly requested folder, not inherited
- Sanitize file extension from content-type
- Clean up orphaned traverse perms when removing permissions
- Add getPermissionById() for authz checks on permission deletion
Storage routes:
- All write ops require edit via hasAccess() — traverse can no longer
create folders, upload files, rename, toggle isPublic, or delete
- Permission delete requires admin access on the folder
- Permission list requires admin access on the folder
- Folder children listing filtered by user access
- File search results filtered by user access (was returning all)
- Signed URL requires view (was using canAccess which allows traverse)
WebDAV:
- 100MB upload size limit (was unbounded — OOM risk)
- PROPFIND root filters folders by user access (was listing all)
- COPY uses hasAccess('view') not canAccess (traverse bypass)
- All writes use hasAccess('edit') consistently
- MKCOL at root requires files.delete permission
- Lock ownership enforced on UNLOCK (was allowing any user)
- Lock conflict check on LOCK (423 if locked by another user)
- Lock enforcement on PUT and DELETE (423 if locked by another)
- Max 100 locks per user, periodic expired lock cleanup
- Path traversal protection: reject .. and null bytes in segments
- Brute-force protection: 10 failed attempts per IP, 5min lockout
This commit is contained in:
@@ -30,6 +30,40 @@ function expandPermissions(slugs: string[]): Set<string> {
|
|||||||
return expanded
|
return expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple rate limiter: track failed attempts per IP
|
||||||
|
const failedAttempts = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
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.
|
* WebDAV Basic Auth pre-handler.
|
||||||
* Verifies HTTP Basic Auth credentials against the users table
|
* Verifies HTTP Basic Auth credentials against the users table
|
||||||
@@ -37,6 +71,13 @@ function expandPermissions(slugs: string[]): Set<string> {
|
|||||||
*/
|
*/
|
||||||
export function webdavBasicAuth(app: FastifyInstance) {
|
export function webdavBasicAuth(app: FastifyInstance) {
|
||||||
return async function (request: FastifyRequest, reply: FastifyReply) {
|
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
|
const authHeader = request.headers.authorization
|
||||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||||
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
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 decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8')
|
||||||
const colonIndex = decoded.indexOf(':')
|
const colonIndex = decoded.indexOf(':')
|
||||||
if (colonIndex === -1) {
|
if (colonIndex === -1) {
|
||||||
|
recordFailure(ip)
|
||||||
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
||||||
return reply.status(401).send('Invalid credentials')
|
return reply.status(401).send('Invalid credentials')
|
||||||
}
|
}
|
||||||
@@ -60,17 +102,20 @@ export function webdavBasicAuth(app: FastifyInstance) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
|
recordFailure(ip)
|
||||||
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
||||||
return reply.status(401).send('Invalid credentials')
|
return reply.status(401).send('Invalid credentials')
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
recordFailure(ip)
|
||||||
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
reply.header('WWW-Authenticate', 'Basic realm="Forte WebDAV"')
|
||||||
return reply.status(401).send('Invalid credentials')
|
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 }
|
request.user = { id: user.id, role: user.role }
|
||||||
const permSlugs = await RbacService.getUserPermissions(app.db, user.id)
|
const permSlugs = await RbacService.getUserPermissions(app.db, user.id)
|
||||||
request.permissions = expandPermissions(permSlugs)
|
request.permissions = expandPermissions(permSlugs)
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { name, parentId, isPublic } = request.body as { name?: string; parentId?: string; isPublic?: boolean }
|
const { name, parentId, isPublic } = request.body as { name?: string; parentId?: string; isPublic?: boolean }
|
||||||
if (!name?.trim()) throw new ValidationError('Folder name is required')
|
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) {
|
if (parentId) {
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentId, request.user.id)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentId, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
if (!hasEdit) {
|
||||||
return reply.status(403).send({ error: { message: 'No edit access to parent folder', statusCode: 403 } })
|
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) => {
|
app.get('/storage/folders', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
|
||||||
const { parentId } = request.query as { parentId?: string }
|
const { parentId } = request.query as { parentId?: string }
|
||||||
const folders = parentId
|
const allChildren = parentId
|
||||||
? await StorageFolderService.listChildren(app.db, parentId)
|
? await StorageFolderService.listChildren(app.db, parentId)
|
||||||
: await StorageFolderService.listChildren(app.db, null)
|
: 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) => {
|
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 { id } = request.params as { id: string }
|
||||||
const { name, isPublic } = request.body as { name?: string; isPublic?: boolean }
|
const { name, isPublic } = request.body as { name?: string; isPublic?: boolean }
|
||||||
|
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, id, request.user.id)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, id, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
if (!hasEdit) {
|
||||||
return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
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) => {
|
app.get('/storage/folders/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
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)
|
const permissions = await StoragePermissionService.listPermissions(app.db, id)
|
||||||
return reply.send({ data: permissions })
|
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) => {
|
app.delete('/storage/folder-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
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 } })
|
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 ---
|
// --- 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) => {
|
app.post('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => {
|
||||||
const { folderId } = request.params as { folderId: string }
|
const { folderId } = request.params as { folderId: string }
|
||||||
|
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, folderId, request.user.id)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, folderId, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
if (!hasEdit) {
|
||||||
return reply.status(403).send({ error: { message: 'No edit access to this folder', statusCode: 403 } })
|
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)
|
const file = await StorageFileService.getById(app.db, id)
|
||||||
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
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)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
if (!hasEdit) {
|
||||||
return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
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)
|
const file = await StorageFileService.getById(app.db, id)
|
||||||
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
|
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)
|
const hasView = await StoragePermissionService.hasAccess(app.db, file.folderId, request.user.id, 'view')
|
||||||
if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
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 token = app.jwt.sign({ path: file.path, purpose: 'file-access' } as any, { expiresIn: '15m' })
|
||||||
const signedUrl = `/v1/files/s/${file.path}?token=${token}`
|
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 params = PaginationSchema.parse(request.query)
|
||||||
const result = await StorageFileService.search(app.db, q, params)
|
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 } })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,20 @@ const DAV_METHODS = 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL
|
|||||||
const WEBDAV_PREFIX = '/webdav'
|
const WEBDAV_PREFIX = '/webdav'
|
||||||
|
|
||||||
export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
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) {
|
app.addContentTypeParser('*', function (_request, payload, done) {
|
||||||
const chunks: Buffer[] = []
|
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('end', () => done(null, Buffer.concat(chunks)))
|
||||||
payload.on('error', done)
|
payload.on('error', done)
|
||||||
})
|
})
|
||||||
@@ -85,7 +95,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
})
|
})
|
||||||
if (depth !== '0') {
|
if (depth !== '0') {
|
||||||
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
|
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) {
|
} else if (resolved.type === 'folder' && resolved.folder) {
|
||||||
// Check access — traverse or higher lets you see the folder
|
// Check access — traverse or higher lets you see the folder
|
||||||
@@ -144,7 +166,17 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
if (depth !== '0') {
|
if (depth !== '0') {
|
||||||
const children = await WebDavService.listChildren(app.db, null, `${WEBDAV_PREFIX}/`)
|
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)
|
const xml = buildMultistatus(resources)
|
||||||
@@ -189,15 +221,19 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
if (!name) return reply.status(400).send('Invalid path')
|
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
|
// Resolve parent to find the folder
|
||||||
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
|
const parentResolved = await WebDavService.resolvePath(app.db, parentPath)
|
||||||
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
|
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
|
||||||
return reply.status(409).send('Parent folder not found')
|
return reply.status(409).send('Parent folder not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check edit permission
|
// Check edit permission — traverse and view are not enough
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
if (!hasEdit) {
|
||||||
return reply.status(403).send('No edit access')
|
return reply.status(403).send('No edit access')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +263,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
// --- DELETE ---
|
// --- DELETE ---
|
||||||
app.delete('/*', { preHandler: auth }, async (request, reply) => {
|
app.delete('/*', { preHandler: auth }, async (request, reply) => {
|
||||||
const resourcePath = getResourcePath(request)
|
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)
|
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
|
||||||
|
|
||||||
if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit')
|
||||||
if (!accessLevel || accessLevel === 'view') return reply.status(403).send('No edit access')
|
if (!hasEdit) return reply.status(403).send('No edit access')
|
||||||
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
|
await StorageFileService.delete(app.db, app.storage, resolved.file.id)
|
||||||
return reply.status(204).send('')
|
return reply.status(204).send('')
|
||||||
}
|
}
|
||||||
@@ -268,12 +309,16 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
|
if (parentResolved.type !== 'folder' || !parentResolved.folder) {
|
||||||
return reply.status(409).send('Parent folder not found')
|
return reply.status(409).send('Parent folder not found')
|
||||||
}
|
}
|
||||||
// Check edit permission on parent
|
const hasEdit = await StoragePermissionService.hasAccess(app.db, parentResolved.folder.id, request.user.id, 'edit')
|
||||||
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, parentResolved.folder.id, request.user.id)
|
if (!hasEdit) {
|
||||||
if (!accessLevel || accessLevel === 'view') {
|
|
||||||
return reply.status(403).send('No edit access to parent folder')
|
return reply.status(403).send('No edit access to parent folder')
|
||||||
}
|
}
|
||||||
parentFolderId = parentResolved.folder.id
|
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, {
|
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)')
|
return reply.status(404).send('Source not found (only file copy supported)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check read access on source
|
// Check view access on source (traverse is not enough to read file data)
|
||||||
const srcAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
|
const hasSrcView = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'view')
|
||||||
if (!srcAccess) return reply.status(403).send('No access to source')
|
if (!hasSrcView) return reply.status(403).send('No access to source')
|
||||||
|
|
||||||
// Resolve destination parent
|
// Resolve destination parent
|
||||||
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
|
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
|
||||||
@@ -320,8 +365,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check edit access on destination
|
// Check edit access on destination
|
||||||
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
|
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit')
|
||||||
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
|
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
|
||||||
|
|
||||||
// Copy: read source file data and upload to destination
|
// Copy: read source file data and upload to destination
|
||||||
const fileData = await app.storage.get(resolved.file.path)
|
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) {
|
if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
||||||
// Check edit access on source folder
|
// Check edit access on source folder
|
||||||
const srcAccess = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
|
const hasSrcEdit = await StoragePermissionService.hasAccess(app.db, resolved.folder.id, request.user.id, 'edit')
|
||||||
if (!srcAccess || srcAccess === 'view') return reply.status(403).send('No edit access to source')
|
if (!hasSrcEdit) return reply.status(403).send('No edit access to source')
|
||||||
|
|
||||||
// Resolve destination
|
// Resolve destination
|
||||||
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
|
const { parentPath, name: destName } = WebDavService.parseParentAndName(destPath)
|
||||||
@@ -375,8 +420,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (destParent.type !== 'folder' || !destParent.folder) {
|
if (destParent.type !== 'folder' || !destParent.folder) {
|
||||||
return reply.status(409).send('Destination parent not found')
|
return reply.status(409).send('Destination parent not found')
|
||||||
}
|
}
|
||||||
const destAccess = await StoragePermissionService.getAccessLevel(app.db, destParent.folder.id, request.user.id)
|
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, destParent.folder.id, request.user.id, 'edit')
|
||||||
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
|
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
|
||||||
|
|
||||||
// Move: copy data then delete source
|
// Move: copy data then delete source
|
||||||
const overwrite = (request.headers['overwrite'] as string)?.toUpperCase() !== 'F'
|
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') {
|
if (destParent.type === 'folder' || destParent.type === 'root') {
|
||||||
const newParentId = destParent.type === 'folder' ? destParent.folder?.id : undefined
|
const newParentId = destParent.type === 'folder' ? destParent.folder?.id : undefined
|
||||||
if (newParentId) {
|
if (newParentId) {
|
||||||
const destAccess = await StoragePermissionService.getAccessLevel(app.db, newParentId, request.user.id)
|
const hasDestEdit = await StoragePermissionService.hasAccess(app.db, newParentId, request.user.id, 'edit')
|
||||||
if (!destAccess || destAccess === 'view') return reply.status(403).send('No edit access to destination')
|
if (!hasDestEdit) return reply.status(403).send('No edit access to destination')
|
||||||
}
|
}
|
||||||
|
|
||||||
await StorageFolderService.update(app.db, resolved.folder.id, { name: destName })
|
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 ---
|
// --- LOCK ---
|
||||||
app.route({
|
app.route({
|
||||||
method: 'LOCK' as any,
|
method: 'LOCK' as any,
|
||||||
@@ -435,22 +497,27 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
preHandler: auth,
|
preHandler: auth,
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const resourcePath = getResourcePath(request)
|
const resourcePath = getResourcePath(request)
|
||||||
const timeout = 300 // 5 minutes
|
cleanExpiredLocks()
|
||||||
const token = `opaquelocktoken:${randomUUID()}`
|
|
||||||
|
|
||||||
// Clean expired locks
|
// Check if locked by another user
|
||||||
const now = Date.now()
|
const existing = locks.get(resourcePath)
|
||||||
for (const [path, lock] of locks) {
|
if (existing && existing.expires > Date.now() && existing.owner !== request.user.id) {
|
||||||
if (lock.expires < now) locks.delete(path)
|
return reply.status(423).send('Resource is locked by another user')
|
||||||
}
|
}
|
||||||
|
|
||||||
locks.set(resourcePath, {
|
// Cap locks per user
|
||||||
token,
|
let userLockCount = 0
|
||||||
owner: request.user.id,
|
for (const lock of locks.values()) {
|
||||||
expires: now + timeout * 1000,
|
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
|
return reply
|
||||||
.header('Content-Type', 'application/xml; charset=utf-8')
|
.header('Content-Type', 'application/xml; charset=utf-8')
|
||||||
.header('Lock-Token', `<${token}>`)
|
.header('Lock-Token', `<${token}>`)
|
||||||
@@ -459,15 +526,15 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// LOCK on root
|
|
||||||
app.route({
|
app.route({
|
||||||
method: 'LOCK' as any,
|
method: 'LOCK' as any,
|
||||||
url: '/',
|
url: '/',
|
||||||
preHandler: auth,
|
preHandler: auth,
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
|
cleanExpiredLocks()
|
||||||
const token = `opaquelocktoken:${randomUUID()}`
|
const token = `opaquelocktoken:${randomUUID()}`
|
||||||
const xml = buildLockResponse(token, request.user.id, 300)
|
locks.set('/', { token, owner: request.user.id, expires: Date.now() + LOCK_TIMEOUT * 1000 })
|
||||||
locks.set('/', { token, owner: request.user.id, expires: Date.now() + 300000 })
|
const xml = buildLockResponse(token, request.user.id, LOCK_TIMEOUT)
|
||||||
return reply
|
return reply
|
||||||
.header('Content-Type', 'application/xml; charset=utf-8')
|
.header('Content-Type', 'application/xml; charset=utf-8')
|
||||||
.header('Lock-Token', `<${token}>`)
|
.header('Lock-Token', `<${token}>`)
|
||||||
@@ -483,6 +550,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
preHandler: auth,
|
preHandler: auth,
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
const resourcePath = getResourcePath(request)
|
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)
|
locks.delete(resourcePath)
|
||||||
return reply.status(204).send('')
|
return reply.status(204).send('')
|
||||||
},
|
},
|
||||||
@@ -492,7 +564,11 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
method: 'UNLOCK' as any,
|
method: 'UNLOCK' as any,
|
||||||
url: '/',
|
url: '/',
|
||||||
preHandler: auth,
|
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('/')
|
locks.delete('/')
|
||||||
return reply.status(204).send('')
|
return reply.status(204).send('')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { randomUUID } from 'crypto'
|
|||||||
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
||||||
import type { PaginationInput } from '@forte/shared/schemas'
|
import type { PaginationInput } from '@forte/shared/schemas'
|
||||||
|
|
||||||
|
const MAX_PARENT_DEPTH = 50
|
||||||
|
|
||||||
|
const ACCESS_RANK: Record<string, number> = { 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 {
|
function getExtension(contentType: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp',
|
'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',
|
'application/msword': 'doc', 'application/vnd.ms-excel': 'xls',
|
||||||
'text/plain': 'txt', 'text/csv': 'csv',
|
'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 ---
|
// --- Permission Service ---
|
||||||
|
|
||||||
export const StoragePermissionService = {
|
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<any>, folderId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise<boolean> {
|
||||||
|
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<any>, folderId: string, userId: string): Promise<boolean> {
|
async canAccess(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<boolean> {
|
||||||
// Check if folder is public or user is creator
|
const level = await this.getAccessLevel(db, folderId, userId)
|
||||||
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
|
return level !== null
|
||||||
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
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAccessLevel(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> {
|
async getAccessLevel(db: PostgresJsDatabase<any>, 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<string>()
|
||||||
|
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<any>, folderId: string, userId: string,
|
||||||
|
visited: Set<string>, 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
|
if (!folder) return null
|
||||||
|
|
||||||
// Creator always has admin
|
// Creator always has admin
|
||||||
if (folder.createdBy === userId) return '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
|
// Check direct user permission
|
||||||
const [userPerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
|
const [userPerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
|
||||||
.where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId)))
|
.where(and(eq(storageFolderPermissions.folderId, folderId), eq(storageFolderPermissions.userId, userId)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (userPerm) return userPerm.accessLevel
|
if (userPerm) best = userPerm.accessLevel
|
||||||
|
|
||||||
// Check role-based permission
|
// Check role-based permissions — pick highest across all roles
|
||||||
const userRoleIds = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
|
const userRoleRows = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
|
||||||
if (userRoleIds.length > 0) {
|
if (userRoleRows.length > 0) {
|
||||||
const roleIds = userRoleIds.map((r) => r.roleId)
|
const roleIds = userRoleRows.map((r) => r.roleId)
|
||||||
const [rolePerm] = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
|
const rolePerms = await db.select({ accessLevel: storageFolderPermissions.accessLevel }).from(storageFolderPermissions)
|
||||||
.where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds)))
|
.where(and(eq(storageFolderPermissions.folderId, folderId), inArray(storageFolderPermissions.roleId, roleIds)))
|
||||||
.limit(1)
|
for (const rp of rolePerms) {
|
||||||
if (rolePerm) return rolePerm.accessLevel
|
best = best ? highestAccess(best, rp.accessLevel) : rp.accessLevel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check parent (inherited)
|
if (best) return best
|
||||||
const [parentFolder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
|
|
||||||
if (parentFolder?.parentId) {
|
// Check parent (inherited permissions) — only explicit permissions inherit, not isPublic
|
||||||
return this.getAccessLevel(db, parentFolder.parentId, userId)
|
if (folder.parentId) {
|
||||||
|
const inherited = await this._getAccessLevelInner(db, folder.parentId, userId, visited, true)
|
||||||
|
if (inherited) return inherited
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public folders give view access
|
// isPublic only grants view on the directly requested folder, not via inheritance
|
||||||
if (folder.isPublic) return 'view'
|
// This prevents a public grandparent from granting view on a private child
|
||||||
|
if (!isInherited && folder.isPublic) return 'view'
|
||||||
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
@@ -93,6 +113,11 @@ export const StoragePermissionService = {
|
|||||||
return db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.folderId, folderId))
|
return db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.folderId, folderId))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPermissionById(db: PostgresJsDatabase<any>, permissionId: string) {
|
||||||
|
const [perm] = await db.select().from(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).limit(1)
|
||||||
|
return perm ?? null
|
||||||
|
},
|
||||||
|
|
||||||
async setPermission(db: PostgresJsDatabase<any>, folderId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) {
|
async setPermission(db: PostgresJsDatabase<any>, folderId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) {
|
||||||
// Remove existing permission for this role/user on this folder
|
// Remove existing permission for this role/user on this folder
|
||||||
if (roleId) {
|
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)
|
const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
|
||||||
if (!folder?.parentId) return
|
if (!folder?.parentId) return
|
||||||
|
|
||||||
|
const visited = new Set<string>()
|
||||||
let currentParentId: string | null = folder.parentId
|
let currentParentId: string | null = folder.parentId
|
||||||
while (currentParentId) {
|
while (currentParentId && !visited.has(currentParentId) && visited.size < MAX_PARENT_DEPTH) {
|
||||||
// Check if this role/user already has any permission on this ancestor
|
visited.add(currentParentId)
|
||||||
|
|
||||||
const whereClause = roleId
|
const whereClause = roleId
|
||||||
? and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.roleId, roleId))
|
? and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.roleId, roleId))
|
||||||
: and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.userId, userId!))
|
: 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)
|
const [existing] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1)
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// No permission — add traverse
|
|
||||||
await db.insert(storageFolderPermissions).values({
|
await db.insert(storageFolderPermissions).values({
|
||||||
folderId: currentParentId,
|
folderId: currentParentId,
|
||||||
roleId: roleId ?? null,
|
roleId: roleId ?? null,
|
||||||
@@ -143,17 +169,94 @@ export const StoragePermissionService = {
|
|||||||
accessLevel: 'traverse' as any,
|
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)
|
const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentParentId)).limit(1)
|
||||||
currentParentId = parent?.parentId ?? null
|
currentParentId = parent?.parentId ?? null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async removePermission(db: PostgresJsDatabase<any>, permissionId: string) {
|
async removePermission(db: PostgresJsDatabase<any>, permissionId: string) {
|
||||||
const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning()
|
// Fetch before deleting so we can clean up ancestors
|
||||||
return perm ?? null
|
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<any>, 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<string>()
|
||||||
|
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<any>, userId: string) {
|
async listAllAccessible(db: PostgresJsDatabase<any>, userId: string) {
|
||||||
// Get all folders and filter by access — for the tree view
|
|
||||||
const allFolders = await db.select().from(storageFolders).orderBy(storageFolders.name)
|
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 = []
|
const accessible = []
|
||||||
for (const folder of allFolders) {
|
for (const folder of allFolders) {
|
||||||
if (folder.isPublic || folder.createdBy === userId) {
|
const level = await StoragePermissionService.getAccessLevel(db, folder.id, userId)
|
||||||
accessible.push(folder)
|
if (level) accessible.push(folder)
|
||||||
continue
|
|
||||||
}
|
|
||||||
const hasAccess = await StoragePermissionService.canAccess(db, folder.id, userId)
|
|
||||||
if (hasAccess) accessible.push(folder)
|
|
||||||
}
|
}
|
||||||
return accessible
|
return accessible
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const WebDavService = {
|
|||||||
async resolvePath(db: PostgresJsDatabase<any>, urlPath: string): Promise<ResolvedPath> {
|
async resolvePath(db: PostgresJsDatabase<any>, urlPath: string): Promise<ResolvedPath> {
|
||||||
const segments = urlPath.split('/').filter(Boolean).map(decodeURIComponent)
|
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) {
|
if (segments.length === 0) {
|
||||||
return { type: 'root' }
|
return { type: 'root' }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user