first commit
This commit is contained in:
120
internal/handler/auth.go
Normal file
120
internal/handler/auth.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const sessionCookie = "rs_paste_session"
|
||||
const sessionDuration = 24 * time.Hour
|
||||
|
||||
// sessionSecret returns the HMAC signing key from env.
|
||||
func sessionSecret() []byte {
|
||||
s := os.Getenv("SESSION_SECRET")
|
||||
if s == "" {
|
||||
return []byte("change-me-in-production")
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func adminPasswordHash() string {
|
||||
return os.Getenv("ADMIN_PASSWORD_HASH")
|
||||
}
|
||||
|
||||
func checkPassword(password string) bool {
|
||||
hash := adminPasswordHash()
|
||||
if hash == "" {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// signValue creates an HMAC-signed value: base64(payload)|base64(sig).
|
||||
func signValue(payload string) string {
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(payload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig
|
||||
}
|
||||
|
||||
// verifyValue checks the signature and returns the original payload.
|
||||
func verifyValue(signed string) (string, bool) {
|
||||
parts := strings.SplitN(signed, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
expected := signValue(payload)
|
||||
if !hmac.Equal([]byte(signed), []byte(expected)) {
|
||||
return "", false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// setSession writes a signed session cookie valid across the whole site.
|
||||
func setSession(w http.ResponseWriter) {
|
||||
expiry := time.Now().Add(sessionDuration).Format(time.RFC3339)
|
||||
value := signValue("admin|" + expiry)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// clearSession deletes the session cookie.
|
||||
func clearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
// isAuthenticated returns true if the request carries a valid session cookie.
|
||||
func isAuthenticated(r *http.Request) bool {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
payload, ok := verifyValue(c.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(payload, "|", 2)
|
||||
if len(parts) != 2 || parts[0] != "admin" {
|
||||
return false
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339, parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
// RequireAuth is middleware that redirects to /admin/login if not authenticated.
|
||||
func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAuthenticated(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
36
internal/handler/csrf.go
Normal file
36
internal/handler/csrf.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// csrfToken returns an HMAC token valid for the current and previous hour.
|
||||
func csrfToken() string {
|
||||
return csrfTokenForTime(time.Now().UTC())
|
||||
}
|
||||
|
||||
func csrfTokenForTime(t time.Time) string {
|
||||
bucket := t.Truncate(time.Hour).Unix()
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(fmt.Sprintf("csrf:%d", bucket)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// csrfValid returns true if token matches the current or previous hour's token.
|
||||
func csrfValid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, t := range []time.Time{now, now.Add(-time.Hour)} {
|
||||
expected := csrfTokenForTime(t)
|
||||
if hmac.Equal([]byte(token), []byte(expected)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
125
internal/handler/handler.go
Normal file
125
internal/handler/handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package handler contains all HTTP request handlers for the paste service.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/paste/internal/paste"
|
||||
)
|
||||
|
||||
// Handler holds shared dependencies for all HTTP handlers.
|
||||
type Handler struct {
|
||||
store *paste.Store
|
||||
siteURL string
|
||||
devMode bool
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
// New creates a Handler.
|
||||
func New(store *paste.Store) *Handler {
|
||||
h := &Handler{
|
||||
store: store,
|
||||
siteURL: getenv("SITE_URL", "https://paste.ridgwaysystems.org"),
|
||||
devMode: os.Getenv("DEV") == "1",
|
||||
}
|
||||
if !h.devMode {
|
||||
h.templates = mustLoadTemplates()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (h *Handler) tmpl(name string) *template.Template {
|
||||
if h.devMode {
|
||||
return mustLoadTemplates()[name]
|
||||
}
|
||||
return h.templates[name]
|
||||
}
|
||||
|
||||
func mustLoadTemplates() map[string]*template.Template {
|
||||
m := make(map[string]*template.Template)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2 January 2006")
|
||||
},
|
||||
"formatDateTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2 Jan 2006 15:04 UTC")
|
||||
},
|
||||
"expiryStr": func(p *paste.Paste) string {
|
||||
if p.ExpiresAt == nil {
|
||||
return "never"
|
||||
}
|
||||
if p.Expired() {
|
||||
return "expired"
|
||||
}
|
||||
return p.ExpiresAt.Format("2 Jan 2006 15:04 UTC")
|
||||
},
|
||||
"isAdmin": func(r *http.Request) bool {
|
||||
return isAuthenticated(r)
|
||||
},
|
||||
}
|
||||
|
||||
base := "templates/base.html"
|
||||
|
||||
pages := []struct {
|
||||
name string
|
||||
file string
|
||||
}{
|
||||
{"list", "templates/list.html"},
|
||||
{"paste", "templates/paste.html"},
|
||||
{"new", "templates/new.html"},
|
||||
{"admin-login", "templates/admin/login.html"},
|
||||
{"admin-dashboard", "templates/admin/dashboard.html"},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
t, err := template.New(filepath.Base(p.file)).Funcs(funcMap).ParseFiles(base, p.file)
|
||||
if err != nil {
|
||||
log.Fatalf("template %s: %v", p.name, err)
|
||||
}
|
||||
m[p.name] = t
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// envelope wraps page-specific data for the base template.
|
||||
type envelope struct {
|
||||
Inner any
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, r *http.Request, name string, data any) {
|
||||
t := h.tmpl(name)
|
||||
if t == nil {
|
||||
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
env := envelope{Inner: data, IsAdmin: isAuthenticated(r)}
|
||||
if err := t.ExecuteTemplate(w, "base", env); err != nil {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
48
internal/handler/middleware.go
Normal file
48
internal/handler/middleware.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain wraps h with each middleware in order (first applied outermost).
|
||||
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs method, path, status code, and duration.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lw := &loggingResponseWriter{ResponseWriter: w, code: http.StatusOK}
|
||||
next.ServeHTTP(lw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), lw.code, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lw.code = code
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// SecurityHeadersMiddleware sets security-related HTTP response headers.
|
||||
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
267
internal/handler/routes.go
Normal file
267
internal/handler/routes.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
|
||||
"ridgwaysystems.org/paste/internal/paste"
|
||||
)
|
||||
|
||||
// ── Public handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
type listData struct {
|
||||
Pastes []*paste.Paste
|
||||
}
|
||||
|
||||
// List renders the public paste index (non-expired, non-unlisted).
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pastes, err := h.store.List()
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "could not load pastes")
|
||||
return
|
||||
}
|
||||
h.render(w, r, "list", listData{Pastes: pastes})
|
||||
}
|
||||
|
||||
type pasteViewData struct {
|
||||
Paste *paste.Paste
|
||||
Highlighted template.HTML
|
||||
CSRFToken string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// ViewPaste renders a single paste with syntax highlighting.
|
||||
func (h *Handler) ViewPaste(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
h.renderErr(w, http.StatusNotFound, "paste not found")
|
||||
} else {
|
||||
h.renderErr(w, http.StatusInternalServerError, "could not load paste")
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.Expired() {
|
||||
h.renderErr(w, http.StatusGone, "this paste has expired")
|
||||
return
|
||||
}
|
||||
|
||||
highlighted, err := highlightCode(p.Body, p.Language)
|
||||
if err != nil {
|
||||
// Fall back to plain pre-formatted text on highlight failure
|
||||
highlighted = template.HTML("<pre>" + template.HTMLEscapeString(p.Body) + "</pre>")
|
||||
}
|
||||
|
||||
h.render(w, r, "paste", pasteViewData{
|
||||
Paste: p,
|
||||
Highlighted: highlighted,
|
||||
CSRFToken: csrfToken(),
|
||||
IsAdmin: isAuthenticated(r),
|
||||
})
|
||||
}
|
||||
|
||||
// RawPaste returns the paste body as plain text. Route: GET /raw/{id}
|
||||
func (h *Handler) RawPaste(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "paste not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "could not load paste", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.Expired() {
|
||||
http.Error(w, "this paste has expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(p.Body))
|
||||
}
|
||||
|
||||
// ── Admin / authenticated handlers ───────────────────────────────────────────
|
||||
|
||||
type newPasteData struct {
|
||||
Error string
|
||||
Languages []string
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// NewPasteForm renders the paste creation form (requires auth).
|
||||
func (h *Handler) NewPasteForm(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, r, "new", newPasteData{
|
||||
Languages: paste.Languages,
|
||||
CSRFToken: csrfToken(),
|
||||
})
|
||||
}
|
||||
|
||||
// NewPastePost handles paste creation (requires auth).
|
||||
func (h *Handler) NewPastePost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
if !csrfValid(r.FormValue("csrf_token")) {
|
||||
h.renderErr(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
|
||||
body := r.FormValue("body")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
h.render(w, r, "new", newPasteData{
|
||||
Error: "Paste body cannot be empty.",
|
||||
Languages: paste.Languages,
|
||||
CSRFToken: csrfToken(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" {
|
||||
title = "Untitled"
|
||||
}
|
||||
lang := r.FormValue("language")
|
||||
if lang == "" {
|
||||
lang = "text"
|
||||
}
|
||||
expiry := paste.ParseExpiry(r.FormValue("expiry"))
|
||||
unlisted := r.FormValue("unlisted") == "on"
|
||||
|
||||
p := &paste.Paste{
|
||||
Title: title,
|
||||
Language: lang,
|
||||
Body: body,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiry,
|
||||
Unlisted: unlisted,
|
||||
}
|
||||
|
||||
if err := h.store.Save(p); err != nil {
|
||||
h.render(w, r, "new", newPasteData{
|
||||
Error: "Failed to save paste. Please try again.",
|
||||
Languages: paste.Languages,
|
||||
CSRFToken: csrfToken(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/"+p.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type adminData struct {
|
||||
Pastes []*paste.Paste
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// AdminDashboard renders the admin view of all pastes (requires auth).
|
||||
func (h *Handler) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
pastes, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "could not load pastes")
|
||||
return
|
||||
}
|
||||
h.render(w, r, "admin-dashboard", adminData{
|
||||
Pastes: pastes,
|
||||
CSRFToken: csrfToken(),
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePaste deletes a paste by ID (requires auth).
|
||||
func (h *Handler) DeletePaste(w http.ResponseWriter, r *http.Request) {
|
||||
if !csrfValid(r.FormValue("csrf_token")) {
|
||||
h.renderErr(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.store.Delete(id); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
h.renderErr(w, http.StatusInternalServerError, "could not delete paste")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ── Auth handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type loginData struct {
|
||||
Error string
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// AdminLogin renders the login form.
|
||||
func (h *Handler) AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if isAuthenticated(r) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.render(w, r, "admin-login", loginData{CSRFToken: csrfToken()})
|
||||
}
|
||||
|
||||
// AdminLoginPost processes the login form.
|
||||
func (h *Handler) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
if !csrfValid(r.FormValue("csrf_token")) {
|
||||
h.renderErr(w, http.StatusForbidden, "invalid CSRF token")
|
||||
return
|
||||
}
|
||||
if !checkPassword(r.FormValue("password")) {
|
||||
h.render(w, r, "admin-login", loginData{
|
||||
Error: "Invalid password.",
|
||||
CSRFToken: csrfToken(),
|
||||
})
|
||||
return
|
||||
}
|
||||
setSession(w)
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminLogout clears the session and redirects to the login page.
|
||||
func (h *Handler) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSession(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ── Syntax highlighting ───────────────────────────────────────────────────────
|
||||
|
||||
func highlightCode(body, language string) (template.HTML, error) {
|
||||
var lexer chroma.Lexer
|
||||
if language != "" && language != "text" {
|
||||
lexer = lexers.Get(language)
|
||||
}
|
||||
if lexer == nil {
|
||||
lexer = lexers.Fallback
|
||||
}
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
|
||||
formatter := chromahtml.New(
|
||||
chromahtml.WithClasses(true),
|
||||
chromahtml.WithLineNumbers(true),
|
||||
chromahtml.LineNumbersInTable(true),
|
||||
)
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := formatter.Format(&buf, styles.Get("github"), iterator); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user