Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Reorganize routes into /api/v1 #492

Merged
merged 2 commits into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions internal/httpcontroller/auth_routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ func (s *Server) initAuthRoutes() {
g.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(10)))

// OAuth2 routes
g.GET("/oauth2/authorize", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthorize))
g.POST("/oauth2/token", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthToken))
g.GET("/callback", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthCallback))
g.GET("/api/v1/oauth2/authorize", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthorize))
g.POST("/api/v1/oauth2/token", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthToken))
g.GET("/api/v1/oauth2/callback", s.Handlers.WithErrorHandling(s.OAuth2Server.HandleBasicAuthCallback))

// Social authentication routes
g.GET("/auth/:provider", s.Handlers.WithErrorHandling(handleGothProvider))
g.GET("/auth/:provider/callback", s.Handlers.WithErrorHandling(handleGothCallback))
g.GET("/api/v1/auth/:provider", s.Handlers.WithErrorHandling(handleGothProvider))
g.GET("/api/v1/auth/:provider/callback", s.Handlers.WithErrorHandling(handleGothCallback))

// Basic authentication routes
g.GET("/login", s.Handlers.WithErrorHandling(s.handleLoginPage))
Expand Down Expand Up @@ -127,7 +127,7 @@ func (s *Server) handleBasicAuthLogin(c echo.Context) error {
if !isValidRedirect(redirect) {
redirect = "/"
}
redirectURL := fmt.Sprintf("/callback?code=%s&redirect=%s", authCode, redirect)
redirectURL := fmt.Sprintf("/api/v1/oauth2/callback?code=%s&redirect=%s", authCode, redirect)
c.Response().Header().Set("HX-Redirect", redirectURL)
return c.String(http.StatusOK, "")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/httpcontroller/handlers/audio_level_sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ func checkSourceActivity(levels map[string]myaudio.AudioLevelData, lastUpdateTim
return updated
}

// AudioLevelSSE handles Server-Sent Events for real-time audio level updates
// AudioLevelSSE handles Server-Sent Events for audio level monitoring
// API: GET /api/v1/audio-level
func (h *Handlers) AudioLevelSSE(c echo.Context) error {
clientIP := c.RealIP()

Expand Down
10 changes: 9 additions & 1 deletion internal/httpcontroller/handlers/detections.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type NoteWithWeather struct {
}

// ListDetections handles requests for hourly, species-specific, and search detections
// API: GET /api/v1/detections
func (h *Handlers) Detections(c echo.Context) error {
req := new(DetectionRequest)
if err := c.Bind(req); err != nil {
Expand Down Expand Up @@ -168,7 +169,8 @@ func (h *Handlers) Detections(c echo.Context) error {
return nil
}

// getNoteHandler retrieves a single note from the database and renders it.
// DetectionDetails retrieves a single detection from the database and renders it.
// API: GET /api/v1/detections/details
func (h *Handlers) DetectionDetails(c echo.Context) error {
noteID := c.QueryParam("id")
if noteID == "" {
Expand Down Expand Up @@ -210,6 +212,7 @@ func (h *Handlers) DetectionDetails(c echo.Context) error {
}

// RecentDetections handles requests for the latest detections.
// API: GET /api/v1/detections/recent
func (h *Handlers) RecentDetections(c echo.Context) error {
h.Debug("RecentDetections: Starting handler")

Expand Down Expand Up @@ -317,6 +320,7 @@ func (h *Handlers) addWeatherAndTimeOfDay(notes []datastore.Note) ([]NoteWithWea
}

// DeleteDetection handles the deletion of a detection and its associated files
// API: DELETE /api/v1/detections/delete
func (h *Handlers) DeleteDetection(c echo.Context) error {
id := c.QueryParam("id")

Expand Down Expand Up @@ -481,6 +485,7 @@ func (h *Handlers) processComment(noteID uint, comment string, maxRetries int, b
}

// processReview handles the review status update and related operations
// API: POST /api/v1/detections/review
func (h *Handlers) processReview(noteID uint, verified string, lockDetection bool, maxRetries int, baseDelay time.Duration) error {
h.Debug("processReview: Starting review process for note ID %d", noteID)
h.Debug("processReview: Verified status: %s", verified)
Expand Down Expand Up @@ -585,6 +590,8 @@ func (h *Handlers) processReview(noteID uint, verified string, lockDetection boo
return lastErr
}

// ReviewDetection handles the review status update and related operations
// API: POST /api/v1/detections/review
func (h *Handlers) ReviewDetection(c echo.Context) error {
id := c.FormValue("id")
if id == "" {
Expand Down Expand Up @@ -657,6 +664,7 @@ func (h *Handlers) ReviewDetection(c echo.Context) error {
}

// LockDetection handles the locking and unlocking of detections
// API: POST /api/v1/detections/lock
func (h *Handlers) LockDetection(c echo.Context) error {
id := c.QueryParam("id")
if id == "" {
Expand Down
12 changes: 12 additions & 0 deletions internal/httpcontroller/handlers/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func (h *Handlers) ThumbnailAttribution(scientificName string) template.HTML {
}

// ServeSpectrogram serves or generates a spectrogram for a given clip.
// API: GET /api/v1/media/spectrogram
func (h *Handlers) ServeSpectrogram(c echo.Context) error {
h.Debug("ServeSpectrogram: Handler called with URL: %s", c.Request().URL.String())

Expand All @@ -231,6 +232,7 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
sanitizedClipName, err := h.sanitizeClipName(clipName)
if err != nil {
h.Debug("ServeSpectrogram: Error sanitizing clip name: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
h.Debug("ServeSpectrogram: Sanitized clip name: %s", sanitizedClipName)
Expand All @@ -243,10 +245,12 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
exists, err := fileExists(fullPath)
if err != nil {
h.Debug("ServeSpectrogram: Error checking audio file: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
if !exists {
h.Debug("ServeSpectrogram: Audio file not found: %s", fullPath)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
h.Debug("ServeSpectrogram: Audio file exists at: %s", fullPath)
Expand All @@ -255,6 +259,7 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
spectrogramPath, err := h.getSpectrogramPath(fullPath, 400) // Assuming 400px width
if err != nil {
h.Debug("ServeSpectrogram: Error getting spectrogram path: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
h.Debug("ServeSpectrogram: Final spectrogram path: %s", spectrogramPath)
Expand All @@ -263,13 +268,15 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
exists, err = fileExists(spectrogramPath)
if err != nil {
h.Debug("ServeSpectrogram: Error checking spectrogram file: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
if !exists {
h.Debug("ServeSpectrogram: Spectrogram file not found, attempting to create it")
// Try to create the spectrogram
if err := createSpectrogramWithSoX(fullPath, spectrogramPath, 400); err != nil {
h.Debug("ServeSpectrogram: Failed to create spectrogram: %v", err)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}
h.Debug("ServeSpectrogram: Successfully created spectrogram at: %s", spectrogramPath)
Expand All @@ -279,10 +286,14 @@ func (h *Handlers) ServeSpectrogram(c echo.Context) error {
exists, _ = fileExists(spectrogramPath)
if !exists {
h.Debug("ServeSpectrogram: Spectrogram still not found after creation attempt: %s", spectrogramPath)
c.Response().Header().Set(echo.HeaderContentType, "image/svg+xml")
return c.File("assets/images/spectrogram-placeholder.svg")
}

h.Debug("ServeSpectrogram: Serving spectrogram file: %s", spectrogramPath)
// Set the correct Content-Type header for PNG images
c.Response().Header().Set(echo.HeaderContentType, "image/png")
c.Response().Header().Set("Cache-Control", "public, max-age=2592000, immutable") // Cache spectrograms for 30 days
return c.File(spectrogramPath)
}

Expand Down Expand Up @@ -584,6 +595,7 @@ func sanitizeContentDispositionFilename(filename string) string {
}

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

Expand Down
1 change: 1 addition & 0 deletions internal/httpcontroller/handlers/mqtt.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
)

// TestMQTT handles requests to test MQTT connectivity and functionality
// API: GET/POST /api/v1/mqtt/test
func (h *Handlers) TestMQTT(c echo.Context) error {
// Define a struct for the test configuration
type TestConfig struct {
Expand Down
2 changes: 2 additions & 0 deletions internal/httpcontroller/handlers/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var fieldsToSkip = map[string]bool{
}

// GetAudioDevices handles the request to list available audio devices
// API: GET /api/v1/settings/audio/get
func (h *Handlers) GetAudioDevices(c echo.Context) error {
devices, err := myaudio.ListAudioSources()

Expand All @@ -45,6 +46,7 @@ func (h *Handlers) GetAudioDevices(c echo.Context) error {
}

// SaveSettings handles the request to save settings
// API: POST /api/v1/settings/save
func (h *Handlers) SaveSettings(c echo.Context) error {
settings := conf.Setting()
if settings == nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/httpcontroller/handlers/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func NewSSEHandler() *SSEHandler {
}
}

// ServeSSE handles Server-Sent Events connections
// API: GET /api/v1/sse
func (h *SSEHandler) ServeSSE(c echo.Context) error {
h.Debug("SSE: New connection request from %s", c.Request().RemoteAddr)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,22 @@ func (s *Server) initRoutes() {

// Partial routes (HTMX responses)
s.partialRoutes = map[string]PartialRouteConfig{
"/detections": {Path: "/detections", TemplateName: "", Title: "", Handler: h.WithErrorHandling(h.Detections)},
"/detections/recent": {Path: "/detections/recent", TemplateName: "recentDetections", Title: "Recent Detections", Handler: h.WithErrorHandling(h.RecentDetections)},
"/detections/details": {Path: "/detections/details", TemplateName: "detectionDetails", Title: "Detection Details", Handler: h.WithErrorHandling(h.DetectionDetails)},
"/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)},
"/api/v1/detections": {Path: "/api/v1/detections", TemplateName: "", Title: "", Handler: h.WithErrorHandling(h.Detections)},
"/api/v1/detections/recent": {Path: "/api/v1/detections/recent", TemplateName: "recentDetections", Title: "Recent Detections", Handler: h.WithErrorHandling(h.RecentDetections)},
"/api/v1/detections/details": {Path: "/api/v1/detections/details", TemplateName: "detectionDetails", Title: "Detection Details", Handler: h.WithErrorHandling(h.DetectionDetails)},
"/api/v1/top-birds": {Path: "/api/v1/top-birds", TemplateName: "birdsTableHTML", Title: "Top Birds", Handler: h.WithErrorHandling(h.TopBirds)},
"/api/v1/notes": {Path: "/api/v1/notes", TemplateName: "notes", Title: "All Notes", Handler: h.WithErrorHandling(h.GetAllNotes)},
"/api/v1/media/spectrogram": {Path: "/api/v1/media/spectrogram", TemplateName: "", Title: "", Handler: h.WithErrorHandling(h.ServeSpectrogram)},
"/api/v1/media/audio": {Path: "/api/v1/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 media request, call the partial route handler
if c.Request().Header.Get("HX-Request") != "" ||
strings.HasPrefix(c.Request().URL.Path, "/media/") {
strings.HasPrefix(c.Request().URL.Path, "/api/v1/media/") {
return route.Handler(c)
} else {
// Call the full page route handler
Expand All @@ -112,30 +112,27 @@ func (s *Server) initRoutes() {
})
}

// s.Echo.POST("/login", s.handleBasicAuthLogin)
// s.Echo.GET("/logout", s.handleLogout)

// Special routes
s.Echo.GET("/sse", s.Handlers.SSE.ServeSSE)
s.Echo.GET("/audio-level", s.Handlers.WithErrorHandling(s.Handlers.AudioLevelSSE))
s.Echo.POST("/settings/save", h.WithErrorHandling(h.SaveSettings), s.AuthMiddleware)
s.Echo.GET("/settings/audio/get", h.WithErrorHandling(h.GetAudioDevices), s.AuthMiddleware)
s.Echo.GET("/api/v1/sse", s.Handlers.SSE.ServeSSE)
s.Echo.GET("/api/v1/audio-level", s.Handlers.WithErrorHandling(s.Handlers.AudioLevelSSE))
s.Echo.POST("/api/v1/settings/save", h.WithErrorHandling(h.SaveSettings), s.AuthMiddleware)
s.Echo.GET("/api/v1/settings/audio/get", h.WithErrorHandling(h.GetAudioDevices), s.AuthMiddleware)

// Add DELETE method for detection deletion
s.Echo.DELETE("/detections/delete", h.WithErrorHandling(h.DeleteDetection), s.AuthMiddleware)
s.Echo.DELETE("/api/v1/detections/delete", h.WithErrorHandling(h.DeleteDetection), s.AuthMiddleware)

// Add POST method for ignoring species
s.Echo.POST("/detections/ignore", h.WithErrorHandling(h.IgnoreSpecies), s.AuthMiddleware)
s.Echo.POST("/api/v1/detections/ignore", h.WithErrorHandling(h.IgnoreSpecies), s.AuthMiddleware)

// Add POST method for reviewing detections
s.Echo.POST("/detections/review", h.WithErrorHandling(h.ReviewDetection), s.AuthMiddleware)
s.Echo.POST("/api/v1/detections/review", h.WithErrorHandling(h.ReviewDetection), s.AuthMiddleware)

// Add POST method for locking/unlocking detections
s.Echo.POST("/detections/lock", h.WithErrorHandling(h.LockDetection), s.AuthMiddleware)
s.Echo.POST("/api/v1/detections/lock", h.WithErrorHandling(h.LockDetection), s.AuthMiddleware)

// Add GET method for testing MQTT connection
s.Echo.GET("/mqtt/test", h.WithErrorHandling(h.TestMQTT), s.AuthMiddleware)
s.Echo.POST("/mqtt/test", h.WithErrorHandling(h.TestMQTT), s.AuthMiddleware)
s.Echo.GET("/api/v1/mqtt/test", h.WithErrorHandling(h.TestMQTT), s.AuthMiddleware)
s.Echo.POST("/api/v1/mqtt/test", h.WithErrorHandling(h.TestMQTT), s.AuthMiddleware)

// Setup Error handler
s.Echo.HTTPErrorHandler = func(err error, c echo.Context) {
Expand Down
70 changes: 28 additions & 42 deletions internal/httpcontroller/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,44 +39,30 @@ func (s *Server) CSRFMiddleware() echo.MiddlewareFunc {
ContextKey: CSRFContextKey,
Skipper: func(c echo.Context) bool {
path := c.Path()
// Skip CSRF for static assets and auth endpoints only
return strings.HasPrefix(path, "/assets/") ||
strings.HasPrefix(path, "/media/") ||
strings.HasPrefix(path, "/auth/") ||
strings.HasPrefix(path, "/oauth2/token") ||
path == "/callback"
strings.HasPrefix(path, "/api/v1/media/") ||
strings.HasPrefix(path, "/api/v1/sse") ||
strings.HasPrefix(path, "/api/v1/audio-level") ||
strings.HasPrefix(path, "/api/v1/auth/") ||
strings.HasPrefix(path, "/api/v1/oauth2/token") ||
path == "/api/v1/oauth2/callback"
},
ErrorHandler: func(err error, c echo.Context) error {
s.Debug("🚨 CSRF ERROR: Rejected request")

// Log request method and path
s.Debug("🔍 Request Method: %s, Path: %s", c.Request().Method, c.Request().URL.Path)

// Log CSRF token lookup sources
s.Debug("📌 CSRF Token in Header: %s", c.Request().Header.Get("X-CSRF-Token"))
s.Debug("📌 CSRF Token in Form: %s", c.FormValue("_csrf"))

// Log CSRF cookie details
csrfCookie, cookieErr := c.Cookie("csrf")
if cookieErr == nil {
s.Debug("🍪 CSRF Cookie: %s", csrfCookie.Value)
} else {
s.Debug("⚠️ No CSRF Cookie found")
}

// Log full request cookies for debugging
s.Debug("📝 All Cookies: %s", c.Request().Header.Get("Cookie"))
s.Debug("💡 Error Details: %v", err)

return echo.NewHTTPError(http.StatusForbidden, "Invalid CSRF token")
},
}

// Wrap the middleware to access context
return func(next echo.HandlerFunc) echo.HandlerFunc {
csrfMiddleware := middleware.CSRFWithConfig(config)
return func(c echo.Context) error {
clientIP := net.ParseIP(s.RealIP(c))
// Set the cookie secure option based on the client IP
config.CookieSecure = !security.IsInLocalSubnet(clientIP)
return csrfMiddleware(next)(c)
}
Expand All @@ -101,35 +87,29 @@ func (s *Server) CacheControlMiddleware() echo.MiddlewareFunc {
}

path := c.Request().URL.Path
//s.Debug("CacheControlMiddleware: Processing request for path: %s", 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")
//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")
case strings.HasPrefix(path, "/api/v1/media/audio"):
c.Response().Header().Set("Cache-Control", "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)
c.Response().Header().Set("Accept-Ranges", "bytes")
s.Debug("CacheControlMiddleware: Set headers for audio file: %s", path)
case strings.HasPrefix(path, "/api/v1/media/spectrogram"):
c.Response().Header().Set("Cache-Control", "public, max-age=2592000, immutable") // Cache spectrograms for 30 days
s.Debug("CacheControlMiddleware: Set headers for spectrogram: %s", path)
case strings.HasPrefix(path, "/api/v1/"):
c.Response().Header().Set("Cache-Control", "no-store")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
default:
// Dynamic content
c.Response().Header().Set("Cache-Control", "private, no-cache, must-revalidate")
//s.Debug("CacheControlMiddleware: Set default cache headers for: %s", path)
c.Response().Header().Set("Cache-Control", "no-store")
}

err := next(c)
Expand Down Expand Up @@ -191,9 +171,15 @@ func (s *Server) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
}

// isProtectedRoute checks if the request is protected
// TODO: Add more protected routes
func isProtectedRoute(path string) bool {
return strings.HasPrefix(path, "/settings/")
return strings.HasPrefix(path, "/settings/") ||
strings.HasPrefix(path, "/api/v1/settings/") ||
strings.HasPrefix(path, "/api/v1/detections/delete") ||
strings.HasPrefix(path, "/api/v1/detections/ignore") ||
strings.HasPrefix(path, "/api/v1/detections/review") ||
strings.HasPrefix(path, "/api/v1/detections/lock") ||
strings.HasPrefix(path, "/api/v1/mqtt/") ||
strings.HasPrefix(path, "/logout")
}

// generateETag creates a simple hash-based ETag for a given path
Expand Down
Loading