check / check (push) Successful in 2m30s
The encrypted /v1/e/ route built its image request straight from the decrypted payload, so a token could request an over-limit dimension (reaching libvips and exhausting memory) or an unknown fit mode (surfacing as a 500 from the processor). The token generator discarded every strconv.Atoi error, silently turning non-numeric width, height, quality, or ttl into 0 and applying no upper bound on dimensions. Add a shared ValidateImageRequest in internal/imgcache enforcing the MaxDimension bound and ValidateFitMode, and apply it on both the plain image route and the encrypted route so both reject an over-limit size or an unrecognized fit mode with 400. The generator now parses each numeric field explicitly and returns 400 naming the field for non-numeric or out-of-range input, with width and height bounds-checked so an unusable token cannot be minted. Model: opus-4-8
342 lines
8.6 KiB
Go
342 lines
8.6 KiB
Go
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
|
|
}
|