diff --git a/grails-app/conf/SecurityFilters.groovy b/grails-app/conf/SecurityFilters.groovy new file mode 100644 index 00000000..caf19510 --- /dev/null +++ b/grails-app/conf/SecurityFilters.groovy @@ -0,0 +1,57 @@ +package streama + +class SecurityFilters { + def springSecurityService + + def filters = { + all(controller: '*', action: '*') { + before = { + if (!controllerName) { + return true + } + + if (isPublicController(controllerName)) { + return true + } + + if (!springSecurityService.isLoggedIn()) { + redirect(controller: 'login', action: 'index') + return false + } + + if (isAdminController(controllerName) && !isAdminUser()) { + redirect(controller: 'login', action: 'denied') + return false + } + } + } + } + + private boolean isPublicController(String controllerName) { + def publicControllers = [ + 'login', + 'static', + 'asset', + 'errors' + ] + return publicControllers.contains(controllerName) + } + + private boolean isAdminController(String controllerName) { + def adminControllers = [ + 'admin', + 'settings', + 'userManagement' + ] + return adminControllers.contains(controllerName) + } + + private boolean isAdminUser() { + def principal = springSecurityService.getPrincipal() + if (!principal) { + return false + } + def authorities = principal.getAuthorities() + return authorities.any { it.getAuthority() == 'ROLE_ADMIN' } + } +} diff --git a/grails-app/controllers/streama/AbstractByteStreamaController.groovy b/grails-app/controllers/streama/AbstractByteStreamaController.groovy new file mode 100644 index 00000000..3301906a --- /dev/null +++ b/grails-app/controllers/streama/AbstractByteStreamaController.groovy @@ -0,0 +1,99 @@ +package streama + +import grails.plugin.springsecurity.annotation.Secured + +import static org.springframework.http.HttpStatus.NOT_MODIFIED + +@Secured('permitAll') +abstract class AbstractByteStreamaController { + + def fileService + def springSecurityService + + def streamFile(String filePath, String contentType, String fileName) { + def file = new File(filePath) + + if (!file.exists() || !file.isFile()) { + response.status = 404 + return + } + + response.contentType = contentType + response.setHeader('Content-Disposition', "inline; filename=\"${fileName}\"") + response.setHeader('Content-Length', "${file.length()}") + + def rangeHeader = request.getHeader('Range') + + if (rangeHeader) { + handleRangeRequest(file, rangeHeader) + } else { + response.outputStream << file.bytes + } + } + + def downloadFile(String filePath, String contentType, String fileName) { + def file = new File(filePath) + + if (!file.exists() || !file.isFile()) { + response.status = 404 + return + } + + response.contentType = contentType + response.setHeader('Content-Disposition', "attachment; filename=\"${fileName}\"") + response.setHeader('Content-Length', "${file.length()}") + response.outputStream << file.bytes + } + + protected void handleRangeRequest(File file, String rangeHeader) { + def totalBytes = file.length() + def rangeParts = rangeHeader.replace('bytes=', '').split('-') + def start = rangeParts[0].toLong() + def end = rangeParts.length > 1 && rangeParts[1] ? rangeParts[1].toLong() : totalBytes - 1 + + if (start >= totalBytes || end >= totalBytes) { + response.setHeader('Content-Range', "bytes */${totalBytes}") + response.status = 416 + return + } + + response.setHeader('Content-Range', "bytes ${start}-${end}/${totalBytes}") + response.setHeader('Content-Length', "${end - start + 1}") + response.status = 206 + + def fileInputStream = new FileInputStream(file) + fileInputStream.skip(start) + + def buffer = new byte[8192] + def remaining = end - start + 1 + def outputStream = response.outputStream + + while (remaining > 0) { + def bytesToRead = Math.min(buffer.length, remaining as int) + def bytesRead = fileInputStream.read(buffer, 0, bytesToRead) + if (bytesRead == -1) break + outputStream.write(buffer, 0, bytesRead) + remaining -= bytesRead + } + + fileInputStream.close() + outputStream.flush() + } + + protected boolean validateRequest(Object videoInstance) { + if (!videoInstance) { + response.status = 404 + return false + } + + def user = springSecurityService.currentUser + def canAccess = fileService.canUserAccessMedia(user, videoInstance) + + if (!canAccess) { + response.status = 403 + return false + } + + return true + } +} diff --git a/grails-app/controllers/streama/VideoController.groovy b/grails-app/controllers/streama/VideoController.groovy index e920b2a2..58bc2935 100644 --- a/grails-app/controllers/streama/VideoController.groovy +++ b/grails-app/controllers/streama/VideoController.groovy @@ -252,6 +252,8 @@ class VideoController { return } viewingStatus.delete() + respond status: OK + }gStatus.delete() render status: 200 } diff --git a/grails-app/controllers/streama/VideoFileController.groovy b/grails-app/controllers/streama/VideoFileController.groovy new file mode 100644 index 00000000..357a376a --- /dev/null +++ b/grails-app/controllers/streama/VideoFileController.groovy @@ -0,0 +1,61 @@ +package streama + +import grails.converters.JSON +import grails.transaction.Transactional +import org.springframework.http.HttpStatus + +class VideoFileController { + def VideoFileService + def VideoController + def springSecurityService + + def serve() { + def file = videoFileService.getVideoFile(params.id) + if (!file) { + render status: 404 + return + } + + if (file.uploadStatus == 'pending') { + response.sendError(403, 'File is still processing') + return + } + + if (!file.hasViewingRights(springSecurityService.currentUser)) { + response.sendError(403, 'No viewing rights') + return + } + + def absolutePath = videoFileService.getAbsolutePath(file) + def fileToServe = new File(absolutePath) + + if (!fileToServe.exists()) { + render status: 404 + return + } + + // Prevent path traversal attacks by validating the canonical path + def canonicalPath = fileToServe.canonicalPath + def baseDir = grailsApplication.config.streama.videoFilesDirectory + def canonicalBaseDir = new File(baseDir).canonicalPath + + if (!canonicalPath.startsWith(canonicalBaseDir)) { + log.warn "Potential path traversal attempt detected: ${params.id}" + response.sendError(403, 'Access denied') + return + } + + response.setHeader('Accept-Ranges', 'bytes') + response.setContentType(file.contentType ?: 'application/octet-stream') + response.setHeader('Content-Disposition', "attachment;filename=${file.name}".toString()) + response.setContentLength(fileToServe.size().toInteger()) + + if (request.getHeader('Range')) { + response.setStatus(206) + DataSourceUtils.rangeSupport(fileToServe, response.getHeader('Range'), response) + } else { + response.setStatus(200) + DataSourceUtils.streamFile(fileToServe, response) + } + } +} \ No newline at end of file