package handlers import ( "crypto/subtle" "errors" "fmt" "html/template" "net/http" "net/url" "strconv" "time" "sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/templates" ) // errInvalidFormField reports a generator form field whose value is // non-numeric or out of range. The offending field name is wrapped in so the // response can name it. var errInvalidFormField = errors.New("invalid") // 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) { if r.Method == http.MethodPost { s.handleLoginPost(w, r) return } // Check if authenticated if s.sessMgr.IsAuthenticated(r) { s.renderGenerator(w, r, nil) return } // Show login page s.renderLogin(w, r, "") } } // handleLoginPost handles login form submission. func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { s.renderLogin(w, r, "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, r, "Invalid signing key") return } // Create session err = s.sessMgr.CreateSession(w) if err != nil { s.log.Error("failed to create session", "error", err) s.renderLogin(w, r, "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) { 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.IsAuthenticated(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } err := r.ParseForm() if err != nil { s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"}) return } sourceURL := r.FormValue("url") // Validate source URL parsed, err := url.Parse(sourceURL) if err != nil || parsed.Host == "" { s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form, http.StatusBadRequest) return } payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form) if err != nil { s.renderGeneratorWithForm(w, r, err.Error(), r.Form, http.StatusBadRequest) return } // 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, r, "Failed to generate URL", r.Form, http.StatusInternalServerError) return } generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format")) // Format expiry for display expiresAtStr := "Never" if ttl > 0 { expiresAtStr = expiresAt.Format(time.RFC3339) } s.renderGenerator(w, r, &generatorData{ GeneratedURL: generatedURL, ExpiresAt: expiresAtStr, FormURL: sourceURL, FormWidth: r.FormValue("width"), FormHeight: r.FormValue("height"), FormFormat: r.FormValue("format"), FormQuality: r.FormValue("quality"), FormFit: r.FormValue("fit"), FormTTL: r.FormValue("ttl"), }) } } // buildGeneratePayload parses the numeric form fields and assembles the // encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A // non-numeric or out-of-range field, or an unrecognized fit mode, is a client // error naming the offending field, so an unusable token is never minted. func buildGeneratePayload( parsed *url.URL, form url.Values, ) (*encurl.Payload, time.Time, int, error) { width, err := parseFormDimension(form, "width") if err != nil { return nil, time.Time{}, 0, err } height, err := parseFormDimension(form, "height") if err != nil { return nil, time.Time{}, 0, err } quality, err := parseFormCount(form, "quality", encurl.DefaultQuality) if err != nil { return nil, time.Time{}, 0, err } ttl, err := parseFormCount(form, "ttl", 0) if err != nil { return nil, time.Time{}, 0, err } fitMode := imgcache.FitMode(form.Get("fit")) err = imgcache.ValidateFitMode(fitMode) if err != nil { return nil, time.Time{}, 0, fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit")) } var ( expiresAt time.Time expiresAtUnix int64 ) if ttl > 0 { expiresAt = time.Now().Add(time.Duration(ttl) * time.Second) expiresAtUnix = expiresAt.Unix() } payload := &encurl.Payload{ SourceHost: parsed.Host, SourcePath: parsed.Path, SourceQuery: parsed.RawQuery, Width: width, Height: height, Format: imgcache.ImageFormat(form.Get("format")), Quality: quality, FitMode: fitMode, ExpiresAt: expiresAtUnix, } return payload, expiresAt, ttl, nil } // parseFormDimension reads an optional width or height form field. An empty // value means "original size" (0). A non-numeric, negative, or over-limit // value is rejected with an error naming the field. func parseFormDimension(form url.Values, field string) (int, error) { raw := form.Get(field) if raw == "" { return 0, nil } value, err := strconv.Atoi(raw) if err != nil || value < 0 || value > imgcache.MaxDimension { return 0, fmt.Errorf("%w %s", errInvalidFormField, field) } return value, nil } // parseFormCount reads an optional non-negative integer form field, returning // def when the field is empty and an error naming the field when the value is // non-numeric or negative. func parseFormCount(form url.Values, field string, def int) (int, error) { raw := form.Get(field) if raw == "" { return def, nil } value, err := strconv.Atoi(raw) if err != nil || value < 0 { return 0, fmt.Errorf("%w %s", errInvalidFormField, field) } return value, nil } // 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 CSRFField template.HTML } func (s *Handlers) renderLogin( w http.ResponseWriter, r *http.Request, errorMsg string, ) { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { Error string CSRFField template.HTML }{ Error: errorMsg, CSRFField: csrfField(r), } err := templates.Render(w, "login.html", data) if 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, r *http.Request, data *generatorData, ) { s.renderGeneratorStatus(w, r, data, http.StatusOK) } // renderGeneratorStatus renders the generator page with an explicit HTTP // status. The status is written before the body so both it and the // Content-Type header take effect; a rejected form uses 400. func (s *Handlers) renderGeneratorStatus( w http.ResponseWriter, r *http.Request, data *generatorData, status int, ) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if data == nil { data = &generatorData{} } data.CSRFField = csrfField(r) w.WriteHeader(status) err := templates.Render(w, "generator.html", data) if err != nil { s.log.Error("failed to render generator template", "error", err) } } func (s *Handlers) renderGeneratorWithForm( w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values, status int, ) { s.renderGeneratorStatus(w, r, &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"), }, status) } func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string { // Build full URL (URL-encode the token for safety) scheme := "https" if s.config.Debug { scheme = "http" } // Determine file extension for the trailing filename ext := format if ext == "" || ext == "orig" { ext = "jpg" // Default extension } return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext }