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:
232
internal/handlers/auth.go
Normal file
232
internal/handlers/auth.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
117
internal/handlers/imageenc.go
Normal file
117
internal/handlers/imageenc.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user