Add auth and encrypted image handlers

Login page, logout, URL generator form, and /v1/e/{token}
endpoint for serving images from encrypted URLs.
This commit is contained in:
2026-01-08 07:38:15 -08:00
parent aad5e59d23
commit 08d6e264ed
2 changed files with 349 additions and 0 deletions

232
internal/handlers/auth.go Normal file
View File

@@ -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"),
})
}

View File

@@ -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)
}
}