check / check (push) Successful in 3m6s
The encrypted /v1/e/ route used the decrypted payload unchecked, so a token could request an over-limit size or an unknown fit mode; the generator turned unparseable numbers into 0. imgcache.ValidateDimension alone holds the MaxDimension bound and is used by the path parser, by the new ValidateImageRequest (which adds ValidateFitMode) and by the generator. Both image routes call ValidateImageRequest, so each answers 400. The generator answers 400 naming the field for a width or height that is not a number or fails that check, a quality that is not a number from 1 to 100, a ttl that is not a number from 0 to the largest the expiry calculation can hold, or an unknown fit. Empty quality is 85; empty ttl never expires. The form's size inputs stop at 8192. Model: opus-4-8 (implementation); opus-5-5 (rework)
379 lines
9.5 KiB
Go
379 lines
9.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/clientip"
|
|
"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")
|
|
|
|
// Bounds for the generator's quality and ttl fields. maxTTL is in seconds:
|
|
// the expiry calculation time.Duration(ttl) * time.Second overflows above it.
|
|
const (
|
|
minQuality = 1
|
|
maxQuality = 100
|
|
maxTTL = int(math.MaxInt64 / time.Second)
|
|
)
|
|
|
|
// 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", clientip.FromContext(r.Context()))
|
|
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", clientip.FromContext(r.Context()))
|
|
|
|
// 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 width, height, quality or ttl, or an
|
|
// unrecognized fit mode, is a client error naming the offending field. The
|
|
// format field is passed through unchecked.
|
|
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 := parseFormInt(form, "quality",
|
|
encurl.DefaultQuality, minQuality, maxQuality)
|
|
if err != nil {
|
|
return nil, time.Time{}, 0, err
|
|
}
|
|
|
|
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
|
|
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 value, or one
|
|
// imgcache.ValidateDimension rejects, is 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 {
|
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
|
}
|
|
|
|
err = imgcache.ValidateDimension(field, value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// parseFormInt reads an optional integer form field, returning def when the
|
|
// field is empty and an error naming the field when the value is non-numeric
|
|
// or outside minValue to maxValue.
|
|
func parseFormInt(
|
|
form url.Values, field string, def, minValue, maxValue int,
|
|
) (int, error) {
|
|
raw := form.Get(field)
|
|
if raw == "" {
|
|
return def, nil
|
|
}
|
|
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
|
}
|
|
|
|
if value < minValue || value > maxValue {
|
|
return 0, fmt.Errorf("%w %s: must be from %d to %d",
|
|
errInvalidFormField, field, minValue, maxValue)
|
|
}
|
|
|
|
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; a rejected form uses 400. The page is rendered into a buffer before
|
|
// the status is written, so a template failure can still answer 500.
|
|
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)
|
|
|
|
var page bytes.Buffer
|
|
|
|
err := templates.Render(&page, "generator.html", data)
|
|
if err != nil {
|
|
s.log.Error("failed to render generator template", "error", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(status)
|
|
|
|
_, err = page.WriteTo(w)
|
|
if err != nil {
|
|
s.log.Error("failed to write generator page", "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
|
|
}
|