check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
455 lines
12 KiB
Go
455 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/clientip"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/templates"
|
|
)
|
|
|
|
// errInvalidFormField reports a generator form field, or the q or exp
|
|
// parameter of /v1/image/, 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; the quality bounds also
|
|
// apply to the q parameter of /v1/image/. 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 logs out, which ends every login session (see
|
|
// session.Manager.Logout), and redirects to login.
|
|
func (s *Handlers) HandleLogout() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
err := s.sessMgr.Logout(w, r)
|
|
if err != nil {
|
|
s.log.Error("failed to log out", "error", err)
|
|
http.Error(w, "Failed to log out", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
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 := parseSourceURL(sourceURL)
|
|
if err != nil || parsed.Host == "" {
|
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// /v1/image/ refuses a ".." segment in the source path, and so does
|
|
// the generator. The path is decoded, so %2e%2e counts too
|
|
if slices.Contains(strings.Split(parsed.Path, "/"), "..") {
|
|
s.renderGeneratorWithForm(w, r,
|
|
"Source URL path cannot hold a .. segment", r.Form,
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
req, expiresAt, ttl, err := buildGenerateRequest(parsed, r.Form)
|
|
if err != nil {
|
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// The image URL must fetch the source URL as it was written. The
|
|
// fetcher fetches over http only when allow_http is set.
|
|
fetchURL := req.Source.URL(s.config.AllowHTTP)
|
|
|
|
if !fetchedAsWritten(parsed, fetchURL) {
|
|
s.renderGeneratorWithForm(w, r, "Source URL cannot be fetched as "+
|
|
"written: pixa would fetch "+fetchURL.String(),
|
|
r.Form, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Generate encrypted token
|
|
token, err := s.encGen.Generate(req, expiresAt)
|
|
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"),
|
|
})
|
|
}
|
|
}
|
|
|
|
// parseSourceURL parses the source URL entered on the generator page with its
|
|
// host lower-cased and an empty path written as "/", as /v1/image/ reads a
|
|
// source URL, so that each spelling of one source URL makes one cache key and
|
|
// one upstream request.
|
|
func parseSourceURL(sourceURL string) (*url.URL, error) {
|
|
parsed, err := url.Parse(sourceURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
|
|
if parsed.Path == "" {
|
|
parsed.Path = "/"
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
// fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an
|
|
// image URL made from the source URL source, is source as it was written,
|
|
// apart from the scheme, which the token does not keep. It is not when source
|
|
// holds a user name and password, which the token does not keep either, or a
|
|
// "?" with no query after it. The token keeps the source path decoded and
|
|
// pixa fetches it percent-encoded again, so a path written another way, such
|
|
// as with %2F, is fetched as another path. url.URL keeps the path as written
|
|
// in RawPath when it is not the decoded path percent-encoded again.
|
|
// EscapedPath ignores RawPath when it also holds a character net/url encodes,
|
|
// such as a space, so RawPath is compared itself.
|
|
func fetchedAsWritten(source, fetchURL *url.URL) bool {
|
|
writtenPath := source.RawPath
|
|
if writtenPath == "" {
|
|
writtenPath = source.EscapedPath()
|
|
}
|
|
|
|
return source.User == nil && !source.ForceQuery &&
|
|
fetchURL.EscapedPath() == writtenPath &&
|
|
fetchURL.RawQuery == source.RawQuery
|
|
}
|
|
|
|
// buildGenerateRequest parses the numeric form fields and assembles the
|
|
// image request the encrypted URL's token is made from, and its expiry.
|
|
// ttl=0 means never expires (the expiry is the zero time). 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 buildGenerateRequest(
|
|
parsed *url.URL, form url.Values,
|
|
) (*imagerequest.Request, 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 := imagerequest.Fit(form.Get("fit"))
|
|
|
|
err = imagerequest.ValidateFitMode(fitMode)
|
|
if err != nil {
|
|
return nil, time.Time{}, 0,
|
|
fmt.Errorf("%w: %s", imagerequest.ErrInvalidFitMode, form.Get("fit"))
|
|
}
|
|
|
|
var expiresAt time.Time
|
|
if ttl > 0 {
|
|
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
|
}
|
|
|
|
req := &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: parsed.Host,
|
|
Path: parsed.Path,
|
|
Query: parsed.RawQuery,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: width, Height: height},
|
|
Format: imagerequest.Format(form.Get("format")),
|
|
Quality: quality,
|
|
FitMode: fitMode,
|
|
},
|
|
}
|
|
|
|
return req, 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
|
|
// imagerequest.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 = imagerequest.ValidateDimension(field, value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// parseFormInt reads an optional integer form field or URL query parameter,
|
|
// 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. A form with no
|
|
// format makes a token with none, which is served as encurl.DefaultFormat.
|
|
ext := format
|
|
|
|
switch format {
|
|
case "":
|
|
ext = string(encurl.DefaultFormat)
|
|
case "orig", "auto":
|
|
ext = "jpg"
|
|
}
|
|
|
|
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
|
}
|