268 lines
7.2 KiB
Go
268 lines
7.2 KiB
Go
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
|
|
}
|