package api import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/ant0ine/go-json-rest/rest" "github.com/openaccounting/oa-server/core/storage" ) // TokenService instance for file access var tokenService *storage.TokenService // InitSecureFileServer initializes the token service for secure file serving func InitSecureFileServer(signingKey string) { tokenService = storage.NewTokenService(signingKey) } // GetSecureFile serves files with JWT token validation func GetSecureFile(w rest.ResponseWriter, r *rest.Request) { // Extract token from query parameter token := r.URL.Query().Get("token") if token == "" { rest.Error(w, "Missing access token", http.StatusUnauthorized) return } // Validate the token claims, err := tokenService.ValidateFileToken(token) if err != nil { rest.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } // Get the file path from the token claims filePath := claims.FilePath // Validate the file path (additional security check) if err := validateSecureFilePath(filePath); err != nil { rest.Error(w, "Invalid file path", http.StatusBadRequest) return } // Serve the file if err := serveFile(w, r, filePath); err != nil { if os.IsNotExist(err) { rest.Error(w, "File not found", http.StatusNotFound) } else { rest.Error(w, "Failed to serve file", http.StatusInternalServerError) } return } } // serveFile serves a file with proper headers and security measures func serveFile(w rest.ResponseWriter, r *rest.Request, filePath string) error { // Get the full path relative to the uploads directory // This assumes the local storage root directory is "./uploads" fullPath := filepath.Join("./uploads", filePath) // Open the file file, err := os.Open(fullPath) if err != nil { return err } defer file.Close() // Get file info for headers info, err := file.Stat() if err != nil { return err } // Set security headers responseWriter := w.(http.ResponseWriter) responseWriter.Header().Set("X-Content-Type-Options", "nosniff") responseWriter.Header().Set("X-Frame-Options", "DENY") responseWriter.Header().Set("Content-Security-Policy", "default-src 'none'") // Set content headers responseWriter.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) responseWriter.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat)) // Detect content type based on file extension contentType := getContentType(filePath) responseWriter.Header().Set("Content-Type", contentType) // Set cache headers for temporary access responseWriter.Header().Set("Cache-Control", "private, max-age=300") // 5 minutes responseWriter.Header().Set("Expires", time.Now().Add(5*time.Minute).UTC().Format(http.TimeFormat)) // Copy file content to response _, err = io.Copy(responseWriter, file) return err } // validateSecureFilePath validates that the file path is safe to serve func validateSecureFilePath(path string) error { // Clean the path and check for traversal attempts cleanPath := filepath.Clean(path) // Reject paths that try to go up directories if strings.Contains(cleanPath, "..") { return fmt.Errorf("path traversal attempt detected") } // Reject absolute paths if filepath.IsAbs(cleanPath) { return fmt.Errorf("absolute paths not allowed") } // Additional validation: ensure path starts with expected date format parts := strings.Split(cleanPath, string(filepath.Separator)) if len(parts) < 4 { return fmt.Errorf("invalid path structure") } return nil } // getContentType returns the MIME type based on file extension func getContentType(filePath string) string { ext := strings.ToLower(filepath.Ext(filePath)) switch ext { case ".pdf": return "application/pdf" case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".doc": return "application/msword" case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" case ".xls": return "application/vnd.ms-excel" case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".ppt": return "application/vnd.ms-powerpoint" case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation" case ".txt": return "text/plain" case ".csv": return "text/csv" default: return "application/octet-stream" } }