diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..d892f87 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "crypto/subtle" + "net/http" + "net/url" + "strconv" + "time" + + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/imgcache" + "sneak.berlin/go/pixa/internal/templates" +) + +// HandleRoot serves the login page or generator page based on authentication state. +func (s *Handlers) HandleRoot() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Check if signing key is configured + if s.sessMgr == nil { + s.respondError(w, "signing key not configured", http.StatusServiceUnavailable) + + return + } + + if r.Method == http.MethodPost { + s.handleLoginPost(w, r) + + return + } + + // Check if authenticated + if s.sessMgr.IsAuthenticated(r) { + s.renderGenerator(w, nil) + + return + } + + // Show login page + s.renderLogin(w, "") + } +} + +// handleLoginPost handles login form submission. +func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderLogin(w, "Invalid form data") + + return + } + + submittedKey := r.FormValue("key") + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 { + s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr) + s.renderLogin(w, "Invalid signing key") + + return + } + + // Create session + if err := s.sessMgr.CreateSession(w); err != nil { + s.log.Error("failed to create session", "error", err) + s.renderLogin(w, "Failed to create session") + + return + } + + s.log.Info("successful login", "remote_addr", r.RemoteAddr) + + // Redirect to generator page + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// HandleLogout clears the session and redirects to login. +func (s *Handlers) HandleLogout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.sessMgr != nil { + s.sessMgr.ClearSession(w) + } + + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + +// HandleGenerateURL handles the URL generation form submission. +func (s *Handlers) HandleGenerateURL() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Check authentication + if s.sessMgr == nil || !s.sessMgr.IsAuthenticated(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + if err := r.ParseForm(); err != nil { + s.renderGenerator(w, &generatorData{Error: "Invalid form data"}) + + return + } + + // Parse form values + sourceURL := r.FormValue("url") + widthStr := r.FormValue("width") + heightStr := r.FormValue("height") + format := r.FormValue("format") + qualityStr := r.FormValue("quality") + fit := r.FormValue("fit") + ttlStr := r.FormValue("ttl") + + // Validate source URL + parsed, err := url.Parse(sourceURL) + if err != nil || parsed.Host == "" { + s.renderGeneratorWithForm(w, "Invalid source URL", r.Form) + + return + } + + // Parse dimensions + width, _ := strconv.Atoi(widthStr) + height, _ := strconv.Atoi(heightStr) + quality, _ := strconv.Atoi(qualityStr) + ttl, _ := strconv.Atoi(ttlStr) + + if quality <= 0 { + quality = 85 + } + + if ttl <= 0 { + ttl = 2592000 // 30 days default + } + + // Create payload + expiresAt := time.Now().Add(time.Duration(ttl) * time.Second) + payload := &encurl.Payload{ + SourceHost: parsed.Host, + SourcePath: parsed.Path, + SourceQuery: parsed.RawQuery, + Width: width, + Height: height, + Format: imgcache.ImageFormat(format), + Quality: quality, + FitMode: imgcache.FitMode(fit), + ExpiresAt: expiresAt.Unix(), + } + + // Generate encrypted token + token, err := s.encGen.Generate(payload) + if err != nil { + s.log.Error("failed to generate encrypted URL", "error", err) + s.renderGeneratorWithForm(w, "Failed to generate URL", r.Form) + + return + } + + // Build full URL + scheme := "https" + if s.config.Debug { + scheme = "http" + } + + host := r.Host + generatedURL := scheme + "://" + host + "/v1/e/" + token + + s.renderGenerator(w, &generatorData{ + GeneratedURL: generatedURL, + ExpiresAt: expiresAt.Format(time.RFC3339), + FormURL: sourceURL, + FormWidth: widthStr, + FormHeight: heightStr, + FormFormat: format, + FormQuality: qualityStr, + FormFit: fit, + FormTTL: ttlStr, + }) + } +} + +// generatorData holds template data for the generator page. +type generatorData struct { + GeneratedURL string + ExpiresAt string + Error string + FormURL string + FormWidth string + FormHeight string + FormFormat string + FormQuality string + FormFit string + FormTTL string +} + +func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + data := struct { + Error string + }{ + Error: errorMsg, + } + + if err := templates.Render(w, "login.html", data); err != nil { + s.log.Error("failed to render login template", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if data == nil { + data = &generatorData{} + } + + if err := templates.Render(w, "generator.html", data); err != nil { + s.log.Error("failed to render generator template", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) { + s.renderGenerator(w, &generatorData{ + Error: errorMsg, + FormURL: form.Get("url"), + FormWidth: form.Get("width"), + FormHeight: form.Get("height"), + FormFormat: form.Get("format"), + FormQuality: form.Get("quality"), + FormFit: form.Get("fit"), + FormTTL: form.Get("ttl"), + }) +} diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go new file mode 100644 index 0000000..efe2908 --- /dev/null +++ b/internal/handlers/imageenc.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "errors" + "io" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/imgcache" +) + +// HandleImageEnc handles requests to /v1/e/{token} for encrypted image URLs. +func (s *Handlers) HandleImageEnc() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + start := time.Now() + + // Check if encryption is configured + if s.encGen == nil { + s.respondError(w, "encrypted URLs not configured", http.StatusServiceUnavailable) + + return + } + + // Extract token from URL + token := chi.URLParam(r, "token") + if token == "" { + s.respondError(w, "missing token", http.StatusBadRequest) + + return + } + + // Decrypt and validate the payload + payload, err := s.encGen.Parse(token) + if err != nil { + if errors.Is(err, encurl.ErrExpired) { + s.log.Debug("encrypted URL expired", "error", err) + s.respondError(w, "URL has expired", http.StatusGone) + + return + } + + s.log.Debug("failed to decrypt URL", "error", err) + s.respondError(w, "invalid encrypted URL", http.StatusBadRequest) + + return + } + + // Convert payload to ImageRequest + req := payload.ToImageRequest() + + // Log the request + s.log.Debug("encrypted image request", + "host", req.SourceHost, + "path", req.SourcePath, + "size", req.Size, + "format", req.Format, + ) + + // Fetch and process the image (no signature validation needed - encrypted URL is trusted) + resp, err := s.imgSvc.Get(ctx, req) + if err != nil { + s.handleImageError(w, err) + + return + } + defer func() { _ = resp.Content.Close() }() + + // Set response headers + w.Header().Set("Content-Type", resp.ContentType) + if resp.ContentLength > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) + } + + // Cache headers - encrypted URLs can be cached since they're immutable + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) + + // Stream the response + written, err := io.Copy(w, resp.Content) + if err != nil { + s.log.Error("failed to write response", "error", err) + + return + } + + // Log completion + duration := time.Since(start) + s.log.Info("served encrypted image", + "host", req.SourceHost, + "path", req.SourcePath, + "format", req.Format, + "cache_status", resp.CacheStatus, + "served_bytes", written, + "duration_ms", duration.Milliseconds(), + ) + } +} + +// handleImageError converts image service errors to HTTP responses. +func (s *Handlers) handleImageError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, imgcache.ErrSSRFBlocked): + s.respondError(w, "forbidden", http.StatusForbidden) + case errors.Is(err, imgcache.ErrUpstreamError): + s.respondError(w, "upstream error", http.StatusBadGateway) + case errors.Is(err, imgcache.ErrUpstreamTimeout): + s.respondError(w, "upstream timeout", http.StatusGatewayTimeout) + default: + s.log.Error("image request failed", "error", err) + s.respondError(w, "internal error", http.StatusInternalServerError) + } +}