A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
6.4 KiB
Go
248 lines
6.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// InputGateConfig holds configuration for input validation.
|
|
type InputGateConfig struct {
|
|
// Maximum request body size (default: 10MB)
|
|
MaxBodySize int64
|
|
|
|
// Maximum file upload size (default: 50MB)
|
|
MaxFileSize int64
|
|
|
|
// Allowed content types
|
|
AllowedContentTypes map[string]bool
|
|
|
|
// Allowed file types for uploads
|
|
AllowedFileTypes map[string]bool
|
|
|
|
// Blocked file extensions
|
|
BlockedExtensions map[string]bool
|
|
|
|
// Paths that allow larger uploads
|
|
LargeUploadPaths []string
|
|
|
|
// Paths excluded from validation
|
|
ExcludedPaths []string
|
|
|
|
// Enable strict content type checking
|
|
StrictContentType bool
|
|
}
|
|
|
|
// DefaultInputGateConfig returns sensible default configuration.
|
|
func DefaultInputGateConfig() InputGateConfig {
|
|
maxSize := int64(10 * 1024 * 1024) // 10MB
|
|
if envSize := os.Getenv("MAX_REQUEST_BODY_SIZE"); envSize != "" {
|
|
if size, err := strconv.ParseInt(envSize, 10, 64); err == nil {
|
|
maxSize = size
|
|
}
|
|
}
|
|
|
|
return InputGateConfig{
|
|
MaxBodySize: maxSize,
|
|
MaxFileSize: 50 * 1024 * 1024, // 50MB
|
|
AllowedContentTypes: map[string]bool{
|
|
"application/json": true,
|
|
"application/x-www-form-urlencoded": true,
|
|
"multipart/form-data": true,
|
|
"text/plain": true,
|
|
},
|
|
AllowedFileTypes: map[string]bool{
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
"application/pdf": true,
|
|
"text/csv": true,
|
|
"application/msword": true,
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
|
"application/vnd.ms-excel": true,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
|
},
|
|
BlockedExtensions: map[string]bool{
|
|
".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true,
|
|
".dll": true, ".scr": true, ".pif": true, ".vbs": true, ".js": true,
|
|
".jar": true, ".sh": true, ".ps1": true, ".app": true,
|
|
},
|
|
LargeUploadPaths: []string{
|
|
"/api/v1/files/upload",
|
|
"/api/v1/documents/upload",
|
|
"/api/v1/attachments",
|
|
},
|
|
ExcludedPaths: []string{
|
|
"/health",
|
|
"/metrics",
|
|
"/api/v1/health",
|
|
},
|
|
StrictContentType: true,
|
|
}
|
|
}
|
|
|
|
// isExcludedPath checks if path is excluded from validation.
|
|
func (c *InputGateConfig) isExcludedPath(path string) bool {
|
|
for _, excluded := range c.ExcludedPaths {
|
|
if path == excluded {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isLargeUploadPath checks if path allows larger uploads.
|
|
func (c *InputGateConfig) isLargeUploadPath(path string) bool {
|
|
for _, uploadPath := range c.LargeUploadPaths {
|
|
if strings.HasPrefix(path, uploadPath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getMaxSize returns the maximum allowed body size for the path.
|
|
func (c *InputGateConfig) getMaxSize(path string) int64 {
|
|
if c.isLargeUploadPath(path) {
|
|
return c.MaxFileSize
|
|
}
|
|
return c.MaxBodySize
|
|
}
|
|
|
|
// validateContentType validates the content type.
|
|
func (c *InputGateConfig) validateContentType(contentType string) (bool, string) {
|
|
if contentType == "" {
|
|
return true, ""
|
|
}
|
|
|
|
// Extract base content type (remove charset, boundary, etc.)
|
|
baseType := strings.Split(contentType, ";")[0]
|
|
baseType = strings.TrimSpace(strings.ToLower(baseType))
|
|
|
|
if !c.AllowedContentTypes[baseType] {
|
|
return false, "Content-Type '" + baseType + "' is not allowed"
|
|
}
|
|
|
|
return true, ""
|
|
}
|
|
|
|
// hasBlockedExtension checks if filename has a blocked extension.
|
|
func (c *InputGateConfig) hasBlockedExtension(filename string) bool {
|
|
if filename == "" {
|
|
return false
|
|
}
|
|
|
|
lowerFilename := strings.ToLower(filename)
|
|
for ext := range c.BlockedExtensions {
|
|
if strings.HasSuffix(lowerFilename, ext) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// InputGate returns a middleware that validates incoming request bodies.
|
|
//
|
|
// Usage:
|
|
//
|
|
// r.Use(middleware.InputGate())
|
|
//
|
|
// // Or with custom config:
|
|
// config := middleware.DefaultInputGateConfig()
|
|
// config.MaxBodySize = 5 * 1024 * 1024 // 5MB
|
|
// r.Use(middleware.InputGateWithConfig(config))
|
|
func InputGate() gin.HandlerFunc {
|
|
return InputGateWithConfig(DefaultInputGateConfig())
|
|
}
|
|
|
|
// InputGateWithConfig returns an input gate middleware with custom configuration.
|
|
func InputGateWithConfig(config InputGateConfig) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Skip excluded paths
|
|
if config.isExcludedPath(c.Request.URL.Path) {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Skip validation for GET, HEAD, OPTIONS requests
|
|
method := c.Request.Method
|
|
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Validate content type for requests with body
|
|
contentType := c.GetHeader("Content-Type")
|
|
if config.StrictContentType {
|
|
valid, errMsg := config.validateContentType(contentType)
|
|
if !valid {
|
|
c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
|
|
"error": "unsupported_media_type",
|
|
"message": errMsg,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check Content-Length header
|
|
contentLength := c.GetHeader("Content-Length")
|
|
if contentLength != "" {
|
|
length, err := strconv.ParseInt(contentLength, 10, 64)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
|
"error": "invalid_content_length",
|
|
"message": "Invalid Content-Length header",
|
|
})
|
|
return
|
|
}
|
|
|
|
maxSize := config.getMaxSize(c.Request.URL.Path)
|
|
if length > maxSize {
|
|
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
|
|
"error": "payload_too_large",
|
|
"message": "Request body exceeds maximum size",
|
|
"max_size": maxSize,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Set max multipart memory for file uploads
|
|
if strings.Contains(contentType, "multipart/form-data") {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, config.MaxFileSize)
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// ValidateFileUpload validates a file upload.
|
|
// Use this in upload handlers for detailed validation.
|
|
func ValidateFileUpload(filename, contentType string, size int64, config *InputGateConfig) (bool, string) {
|
|
if config == nil {
|
|
defaultConfig := DefaultInputGateConfig()
|
|
config = &defaultConfig
|
|
}
|
|
|
|
// Check size
|
|
if size > config.MaxFileSize {
|
|
return false, "File size exceeds maximum allowed"
|
|
}
|
|
|
|
// Check extension
|
|
if config.hasBlockedExtension(filename) {
|
|
return false, "File extension is not allowed"
|
|
}
|
|
|
|
// Check content type
|
|
if contentType != "" && !config.AllowedFileTypes[contentType] {
|
|
return false, "File type '" + contentType + "' is not allowed"
|
|
}
|
|
|
|
return true, ""
|
|
}
|