Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
tphakala committed Jan 21, 2025
2 parents e842700 + 267419f commit 8982e1b
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 34 deletions.
13 changes: 10 additions & 3 deletions internal/analysis/processor/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -134,7 +136,14 @@ func (a DatabaseAction) Execute(data interface{}) error {

// Execute saves the audio clip to a file
func (a SaveAudioAction) Execute(data interface{}) error {
outputPath := a.ClipName
// Get the full path by joining the export path with the relative clip name
outputPath := filepath.Join(a.Settings.Realtime.Audio.Export.Path, a.ClipName)

// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
log.Printf("error creating directory for audio clip: %s\n", err)
return err
}

if a.Settings.Realtime.Audio.Export.Type == "wav" {
if err := myaudio.SavePCMDataToWAV(outputPath, a.pcmData); err != nil {
Expand All @@ -148,8 +157,6 @@ func (a SaveAudioAction) Execute(data interface{}) error {
}
}

log.Printf("Saved audio clip to %s\n", outputPath)

if a.Settings.Debug {
log.Printf("Saved audio clip to %s\n", outputPath)
}
Expand Down
7 changes: 2 additions & 5 deletions internal/analysis/processor/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,6 @@ func (p *Processor) getBaseConfidenceThreshold(speciesLowercase string) float32

// generateClipName generates a clip name for the given scientific name and confidence.
func (p *Processor) generateClipName(scientificName string, confidence float32) string {
// Get the base path from the configuration
basePath := p.Settings.Realtime.Audio.Export.Path

// Replace whitespaces with underscores and convert to lowercase
formattedName := strings.ToLower(strings.ReplaceAll(scientificName, " ", "_"))

Expand All @@ -332,8 +329,8 @@ func (p *Processor) generateClipName(scientificName string, confidence float32)
fileType := myaudio.GetFileExtension(p.Settings.Realtime.Audio.Export.Type)

// Construct the clip name with the new pattern, including year and month subdirectories
// Use filepath.ToSlash to convert the path to a forward slash on Windows to avoid issues with URL encoding
clipName := filepath.ToSlash(filepath.Join(basePath, year, month, fmt.Sprintf("%s_%s_%s.%s", formattedName, formattedConfidence, timestamp, fileType)))
// Use filepath.ToSlash to convert the path to a forward slash for web URLs
clipName := filepath.ToSlash(filepath.Join(year, month, fmt.Sprintf("%s_%s_%s.%s", formattedName, formattedConfidence, timestamp, fileType)))

return clipName
}
Expand Down
190 changes: 183 additions & 7 deletions internal/httpcontroller/handlers/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"html"
"html/template"
"log"
"net/http"
"net/url"
"os"
"os/exec"
Expand All @@ -33,8 +34,8 @@ var (
ErrPathTraversal = errors.New("path traversal attempt detected")
)

// sanitizeClipName performs sanity checks on the clip name
func sanitizeClipName(clipName string) (string, error) {
// sanitizeClipName performs sanity checks on the clip name and ensures it's a relative path
func (h *Handlers) sanitizeClipName(clipName string) (string, error) {
// Check if the clip name is empty
if clipName == "" {
return "", ErrEmptyClipName
Expand All @@ -45,6 +46,7 @@ func sanitizeClipName(clipName string) (string, error) {
if err != nil {
return "", fmt.Errorf("error decoding clip name: %w", err)
}
h.Debug("sanitizeClipName: Decoded clip name: %s", decodedClipName)

// Check the length of the decoded clip name
if len(decodedClipName) > MaxClipNameLength {
Expand All @@ -53,18 +55,66 @@ func sanitizeClipName(clipName string) (string, error) {

// Check for allowed characters
if !regexp.MustCompile(AllowedCharacters).MatchString(decodedClipName) {
h.Debug("sanitizeClipName: Invalid characters in clip name: %s", decodedClipName)
return "", ErrInvalidCharacters
}

// Check for potential path traversal attempts
// Clean the path and ensure it's relative
cleanPath := filepath.Clean(decodedClipName)
h.Debug("sanitizeClipName: Cleaned path: %s", cleanPath)

if strings.Contains(cleanPath, "..") {
h.Debug("sanitizeClipName: Path traversal attempt detected: %s", cleanPath)
return "", ErrPathTraversal
}

// Remove 'clips/' prefix if present
cleanPath = strings.TrimPrefix(cleanPath, "clips/")
h.Debug("sanitizeClipName: Path after removing clips prefix: %s", cleanPath)

// If the path is absolute, make it relative to the export path
if filepath.IsAbs(cleanPath) {
h.Debug("sanitizeClipName: Found absolute path: %s", cleanPath)
exportPath := conf.Setting().Realtime.Audio.Export.Path
h.Debug("sanitizeClipName: Export path from settings: %s", exportPath)

if strings.HasPrefix(cleanPath, exportPath) {
// Remove the export path prefix to make it relative
cleanPath = strings.TrimPrefix(cleanPath, exportPath)
cleanPath = strings.TrimPrefix(cleanPath, string(os.PathSeparator))
h.Debug("sanitizeClipName: Converted to relative path: %s", cleanPath)
} else {
h.Debug("sanitizeClipName: Absolute path not under export directory: %s", cleanPath)
return "", fmt.Errorf("invalid path: absolute path not under export directory")
}
}

// Convert to forward slashes for web URLs
cleanPath = filepath.ToSlash(cleanPath)
h.Debug("sanitizeClipName: Final path with forward slashes: %s", cleanPath)

return cleanPath, nil
}

// getFullPath returns the full filesystem path for a relative clip path
func getFullPath(relativePath string) string {
exportPath := conf.Setting().Realtime.Audio.Export.Path
return filepath.Join(exportPath, relativePath)
}

// getWebPath converts a filesystem path to a web-safe path
func getWebPath(path string) string {
// Convert absolute path to relative path if it starts with the export path
exportPath := conf.Setting().Realtime.Audio.Export.Path
if strings.HasPrefix(path, exportPath) {
path = strings.TrimPrefix(path, exportPath)
path = strings.TrimPrefix(path, string(os.PathSeparator))
}

// Convert path separators to forward slashes for web URLs
return filepath.ToSlash(path)
}

// Thumbnail returns the URL of a given bird's thumbnail image.
// It takes the bird's scientific name as input and returns the image URL as a string.
// If the image is not found or an error occurs, it returns an empty string.
Expand Down Expand Up @@ -125,16 +175,19 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
clipName := c.QueryParam("clip")

// Sanitize the clip name
sanitizedClipName, err := sanitizeClipName(clipName)
sanitizedClipName, err := h.sanitizeClipName(clipName)
if err != nil {
log.Printf("Error sanitizing clip name: %v", err)
h.Debug("Error sanitizing clip name: %v", err)
return c.File("assets/images/spectrogram-placeholder.svg")
}

// Get the full path to the audio file
fullPath := getFullPath(sanitizedClipName)

// Construct the path to the spectrogram image
spectrogramPath, err := h.getSpectrogramPath(sanitizedClipName, 400) // Assuming 400px width
spectrogramPath, err := h.getSpectrogramPath(fullPath, 400) // Assuming 400px width
if err != nil {
log.Printf("Error getting spectrogram path: %v", err)
h.Debug("Error getting spectrogram path: %v", err)
return c.File("assets/images/spectrogram-placeholder.svg")
}

Expand Down Expand Up @@ -389,3 +442,126 @@ func createSpectrogramWithFFmpeg(audioClipPath, spectrogramPath string, width in

return nil
}

// sanitizeContentDispositionFilename sanitizes a filename for use in Content-Disposition header
func sanitizeContentDispositionFilename(filename string) string {
// Remove any characters that could cause issues in headers
// Replace quotes with single quotes, remove control characters, and escape special characters
sanitized := strings.Map(func(r rune) rune {
switch {
case r == '"':
return '\''
case r < 32: // Control characters
return -1
case r == '\\' || r == '/' || r == ':' || r == '*' || r == '?' || r == '<' || r == '>' || r == '|':
return '_'
default:
return r
}
}, filename)

// URL encode the filename to handle non-ASCII characters
encoded := url.QueryEscape(sanitized)

return encoded
}

// ServeAudioClip serves an audio clip file
func (h *Handlers) ServeAudioClip(c echo.Context) error {
h.Debug("ServeAudioClip: Starting to handle request for path: %s", c.Request().URL.String())

// Extract clip name from the query parameters
clipName := c.QueryParam("clip")
h.Debug("ServeAudioClip: Raw clip name from query: %s", clipName)

// Sanitize the clip name
sanitizedClipName, err := h.sanitizeClipName(clipName)
if err != nil {
h.Debug("ServeAudioClip: Error sanitizing clip name: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusNotFound, "Audio file not found")
}
h.Debug("ServeAudioClip: Sanitized clip name: %s", sanitizedClipName)

// Get the full path to the audio file
fullPath := getFullPath(sanitizedClipName)
h.Debug("ServeAudioClip: Full path: %s", fullPath)

// Verify that the full path is within the export directory
absFullPath, err := filepath.Abs(fullPath)
if err != nil {
h.Debug("ServeAudioClip: Error obtaining absolute path: %v", err)
return c.String(http.StatusInternalServerError, "Internal server error")
}
absExportPath, err := filepath.Abs(conf.Setting().Realtime.Audio.Export.Path)
if err != nil {
h.Debug("ServeAudioClip: Error obtaining absolute export path: %v", err)
return c.String(http.StatusInternalServerError, "Internal server error")
}
if !strings.HasPrefix(absFullPath, absExportPath) {
h.Debug("ServeAudioClip: Resolved path outside export directory: %s", absFullPath)
return c.String(http.StatusForbidden, "Forbidden")
}

// Check if the file exists
if _, err := os.Stat(fullPath); err != nil {
if os.IsNotExist(err) {
h.Debug("ServeAudioClip: Audio file not found: %s", fullPath)
} else {
h.Debug("ServeAudioClip: Error checking audio file: %v", err)
}
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusNotFound, "Audio file not found")
}
h.Debug("ServeAudioClip: File exists at path: %s", fullPath)

// Get the filename for Content-Disposition
filename := filepath.Base(sanitizedClipName)
safeFilename := sanitizeContentDispositionFilename(filename)
h.Debug("ServeAudioClip: Using filename for disposition: %s (safe: %s)", filename, safeFilename)

// Get MIME type
mimeType := getAudioMimeType(fullPath)
h.Debug("ServeAudioClip: MIME type for file: %s", mimeType)

// Set response headers
c.Response().Header().Set(echo.HeaderContentType, mimeType)
c.Response().Header().Set("Content-Transfer-Encoding", "binary")
c.Response().Header().Set("Content-Description", "File Transfer")
// Set both ASCII and UTF-8 versions of the filename for better browser compatibility
c.Response().Header().Set(echo.HeaderContentDisposition,
fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
safeFilename,
safeFilename))

h.Debug("ServeAudioClip: Set headers - Content-Type: %s, Content-Disposition: %s",
c.Response().Header().Get(echo.HeaderContentType),
c.Response().Header().Get(echo.HeaderContentDisposition))

// Serve the file
h.Debug("ServeAudioClip: Attempting to serve file: %s", fullPath)
return c.File(fullPath)
}

// getAudioMimeType returns the MIME type for an audio file based on its extension
func getAudioMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".mp3":
return "audio/mpeg"
case ".ogg", ".opus":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".flac":
return "audio/flac"
case ".aac":
return "audio/aac"
case ".m4a":
return "audio/mp4"
case ".alac":
return "audio/x-alac"
default:
return "application/octet-stream"
}
}
28 changes: 24 additions & 4 deletions internal/httpcontroller/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,51 @@ func (s *Server) configureMiddleware() {
MinLength: 2048,
}))
// Apply the Cache Control Middleware
s.Echo.Use(CacheControlMiddleware())
s.Echo.Use(s.CacheControlMiddleware())
s.Echo.Use(VaryHeaderMiddleware())
}

func CacheControlMiddleware() echo.MiddlewareFunc {
// CacheControlMiddleware sets appropriate cache control headers based on the request path
func (s *Server) CacheControlMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
path := c.Request().URL.Path
s.Debug("CacheControlMiddleware: Processing request for path: %s", path)

switch {
case strings.HasSuffix(path, ".css"), strings.HasSuffix(path, ".js"), strings.HasSuffix(path, ".html"):
// CSS and JS files - shorter cache with validation
c.Response().Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
c.Response().Header().Set("ETag", generateETag(path))
s.Debug("CacheControlMiddleware: Set cache headers for static file: %s", path)
case strings.HasSuffix(path, ".png"), strings.HasSuffix(path, ".jpg"),
strings.HasSuffix(path, ".ico"), strings.HasSuffix(path, ".svg"):
// Images can be cached longer
c.Response().Header().Set("Cache-Control", "public, max-age=604800, immutable")
case strings.HasPrefix(path, "/clips/"):
s.Debug("CacheControlMiddleware: Set cache headers for image: %s", path)
case strings.HasPrefix(path, "/media/audio"):
// Audio files - set proper headers for downloads
c.Response().Header().Set("Cache-Control", "private, no-store")
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
s.Debug("CacheControlMiddleware: Set headers for audio file: %s", path)
s.Debug("CacheControlMiddleware: Headers after setting - Cache-Control: %s, X-Content-Type-Options: %s",
c.Response().Header().Get("Cache-Control"),
c.Response().Header().Get("X-Content-Type-Options"))
case strings.HasPrefix(path, "/media/spectrogram"):
// Spectrograms can be cached
c.Response().Header().Set("Cache-Control", "public, max-age=2592000, immutable")
s.Debug("CacheControlMiddleware: Set cache headers for spectrogram: %s", path)
default:
// Dynamic content
c.Response().Header().Set("Cache-Control", "private, no-cache, must-revalidate")
s.Debug("CacheControlMiddleware: Set default cache headers for: %s", path)
}
return next(c)

err := next(c)
if err != nil {
s.Debug("CacheControlMiddleware: Error processing request: %v", err)
}
return err
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions internal/httpcontroller/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"html/template"
"io/fs"
"net/http"
"strings"

"github.com/labstack/echo/v4"
"github.com/tphakala/birdnet-go/internal/conf"
Expand Down Expand Up @@ -91,14 +92,16 @@ func (s *Server) initRoutes() {
"/top-birds": {Path: "/top-birds", TemplateName: "birdsTableHTML", Title: "Top Birds", Handler: h.WithErrorHandling(h.TopBirds)},
"/notes": {Path: "/notes", TemplateName: "notes", Title: "All Notes", Handler: h.WithErrorHandling(h.GetAllNotes)},
"/media/spectrogram": {Path: "/media/spectrogram", TemplateName: "", Title: "", Handler: h.WithErrorHandling(h.ServeSpectrogram)},
"/media/audio": {Path: "/media/audio", TemplateName: "", Title: "", Handler: h.WithErrorHandling(h.ServeAudioClip)},
"/login": {Path: "/login", TemplateName: "login", Title: "Login", Handler: h.WithErrorHandling(s.handleLoginPage)},
}

// Set up partial routes
for _, route := range s.partialRoutes {
s.Echo.GET(route.Path, func(c echo.Context) error {
// If the request is a hx-request or spectrogram, call the partial route handler
if c.Request().Header.Get("HX-Request") != "" || c.Request().URL.Path == "/media/spectrogram" {
// If the request is a hx-request or media request, call the partial route handler
if c.Request().Header.Get("HX-Request") != "" ||
strings.HasPrefix(c.Request().URL.Path, "/media/") {
return route.Handler(c)
} else {
// Call the full page route handler
Expand Down Expand Up @@ -187,5 +190,4 @@ func (s *Server) setupStaticFileServing() {
s.Echo.Logger.Fatal(err)
}
s.Echo.StaticFS("/assets", echo.MustSubFS(assetsFS, ""))
s.Echo.Static("/clips", "clips")
}
Loading

0 comments on commit 8982e1b

Please sign in to comment.