first commit
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# paste.ridgwaysystems.org configuration
|
||||
# Copy to .env and fill in values. Never commit .env to version control.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Server
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Port to listen on (default: 8081)
|
||||
PORT=8081
|
||||
|
||||
# Public URL of the site (no trailing slash)
|
||||
SITE_URL=https://paste.ridgwaysystems.org
|
||||
|
||||
# Path to data directory where paste JSON files are stored (default: data)
|
||||
DATA_DIR=data
|
||||
|
||||
# Set to "1" to enable development mode (template reloading on each request)
|
||||
DEV=0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin authentication
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# bcrypt hash of the admin password.
|
||||
# Generate with: make genhash
|
||||
ADMIN_PASSWORD_HASH=$2a$12$examplehashgoeshere...
|
||||
|
||||
# Secret key for signing session cookies. Use a long random string.
|
||||
# Generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=change-me-use-openssl-rand-hex-32
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
paste
|
||||
paste-freebsd-amd64
|
||||
65
Makefile
Normal file
65
Makefile
Normal file
@@ -0,0 +1,65 @@
|
||||
BINARY = paste
|
||||
MODULE = ridgwaysystems.org/paste
|
||||
CMD = ./cmd/server
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build -o $(BINARY) $(CMD)
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
DEV=1 PORT=8081 go run $(CMD)
|
||||
|
||||
.PHONY: run-env
|
||||
run-env:
|
||||
@test -f .env || (echo "No .env file found. Copy .env.example and fill it in." && exit 1)
|
||||
env $$(cat .env | grep -v '^\#' | xargs) go run $(CMD)
|
||||
|
||||
.PHONY: cross
|
||||
cross:
|
||||
GOOS=freebsd GOARCH=amd64 go build -ldflags="-s -w" -o $(BINARY)-freebsd-amd64 $(CMD)
|
||||
|
||||
.PHONY: genhash
|
||||
genhash:
|
||||
@read -p "Password: " pw && go run ./tools/genhash "$$pw"
|
||||
|
||||
.PHONY: tidy
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -f $(BINARY) $(BINARY)-freebsd-amd64
|
||||
rm -f static/css/syntax.css
|
||||
|
||||
DEPLOY_HOST ?= srv01
|
||||
DEPLOY_DIR ?= /var/www/paste
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: cross
|
||||
ssh $(DEPLOY_HOST) "rcctl stop $(BINARY); pkill $(BINARY); true"
|
||||
scp $(BINARY)-freebsd-amd64 $(DEPLOY_HOST):/usr/local/bin/$(BINARY)
|
||||
rsync -av --delete templates/ $(DEPLOY_HOST):$(DEPLOY_DIR)/templates/
|
||||
rsync -av --delete static/ $(DEPLOY_HOST):$(DEPLOY_DIR)/static/
|
||||
ssh $(DEPLOY_HOST) rcctl start $(BINARY)
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " build Build binary for current OS"
|
||||
@echo " run Run locally in dev mode (port 8081)"
|
||||
@echo " run-env Run with .env file"
|
||||
@echo " cross Cross-compile for FreeBSD amd64"
|
||||
@echo " genhash Generate bcrypt hash for admin password"
|
||||
@echo " tidy go mod tidy"
|
||||
@echo " vet go vet"
|
||||
@echo " deploy Cross-compile and deploy to server"
|
||||
@echo " clean Remove build artifacts"
|
||||
88
cmd/server/main.go
Normal file
88
cmd/server/main.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
|
||||
"ridgwaysystems.org/paste/internal/handler"
|
||||
"ridgwaysystems.org/paste/internal/paste"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := getenv("PORT", "8081")
|
||||
dataDir := getenv("DATA_DIR", "data")
|
||||
|
||||
// Generate syntax.css at startup (light + dark via media query)
|
||||
if err := generateSyntaxCSS("static/css/syntax.css"); err != nil {
|
||||
log.Printf("warning: could not generate syntax.css: %v", err)
|
||||
}
|
||||
|
||||
store, err := paste.NewStore(dataDir)
|
||||
if err != nil {
|
||||
log.Fatal("paste store:", err)
|
||||
}
|
||||
|
||||
h := handler.New(store)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Static files
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("GET /{$}", h.List)
|
||||
mux.HandleFunc("GET /{id}", h.ViewPaste)
|
||||
mux.HandleFunc("GET /raw/{id}", h.RawPaste)
|
||||
|
||||
// Paste creation (auth required)
|
||||
mux.HandleFunc("GET /new", h.RequireAuth(h.NewPasteForm))
|
||||
mux.HandleFunc("POST /new", h.RequireAuth(h.NewPastePost))
|
||||
|
||||
// Admin
|
||||
mux.HandleFunc("GET /admin", h.RequireAuth(h.AdminDashboard))
|
||||
mux.HandleFunc("GET /admin/login", h.AdminLogin)
|
||||
mux.HandleFunc("POST /admin/login", h.AdminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", h.RequireAuth(h.AdminLogout))
|
||||
mux.HandleFunc("POST /admin/delete/{id}", h.RequireAuth(h.DeletePaste))
|
||||
|
||||
srv := handler.Chain(mux,
|
||||
handler.LoggingMiddleware,
|
||||
handler.SecurityHeadersMiddleware,
|
||||
)
|
||||
|
||||
log.Printf("paste.ridgwaysystems.org starting on :%s (DEV=%s)", port, os.Getenv("DEV"))
|
||||
log.Fatal(http.ListenAndServe(":"+port, srv))
|
||||
}
|
||||
|
||||
// generateSyntaxCSS writes a chroma CSS file with light and dark themes.
|
||||
func generateSyntaxCSS(path string) error {
|
||||
formatter := chromahtml.New(chromahtml.WithClasses(true))
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("/* Auto-generated by chroma at server startup. Do not edit. */\n\n")
|
||||
if err := formatter.WriteCSS(&buf, styles.Get("github")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
|
||||
var dark bytes.Buffer
|
||||
if err := formatter.WriteCSS(&dark, styles.Get("github-dark")); err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(dark.Bytes())
|
||||
buf.WriteString("}\n")
|
||||
|
||||
return os.WriteFile(path, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module ridgwaysystems.org/paste
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
)
|
||||
|
||||
require github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
12
go.sum
Normal file
12
go.sum
Normal file
@@ -0,0 +1,12 @@
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
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
|
||||
}
|
||||
603
static/css/style.css
Normal file
603
static/css/style.css
Normal file
@@ -0,0 +1,603 @@
|
||||
/* paste.ridgwaysystems.org
|
||||
OpenBSD-inspired: clean, functional, no-nonsense.
|
||||
-------------------------------------------------- */
|
||||
|
||||
/* === Custom Properties === */
|
||||
|
||||
:root {
|
||||
--bg: #f5f3ef;
|
||||
--bg-alt: #eceae4;
|
||||
--bg-code: #e5e2dc;
|
||||
--text: #1e1c1a;
|
||||
--text-muted: #5a5650;
|
||||
--accent: #c75000;
|
||||
--accent-dim: #9e3f00;
|
||||
--border: #ccc8c0;
|
||||
--border-dark: #b0aba0;
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--font-mono: "SFMono-Regular", "Consolas", "Liberation Mono", Menlo, monospace;
|
||||
--max-w: 900px;
|
||||
--radius: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1a1918;
|
||||
--bg-alt: #222120;
|
||||
--bg-code: #252422;
|
||||
--text: #d4d1ca;
|
||||
--text-muted: #888580;
|
||||
--accent: #e8870a;
|
||||
--accent-dim: #c06b00;
|
||||
--border: #333230;
|
||||
--border-dark: #4a4845;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Reset & Base === */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.5em 0 0.5em;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.9rem; margin-top: 0; }
|
||||
h2 { font-size: 1.4rem; }
|
||||
h3 { font-size: 1.15rem; }
|
||||
|
||||
p { margin: 0 0 1em; }
|
||||
|
||||
ul, ol { padding-left: 1.5em; margin: 0 0 1em; }
|
||||
li { margin-bottom: 0.25em; }
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
background: var(--bg-code);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1em 1.25em;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1.5em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* === Layout === */
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
|
||||
/* === Header / Nav === */
|
||||
|
||||
.site-header {
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-brand:hover { color: var(--accent); text-decoration: none; }
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-logout-btn:hover { color: var(--accent); }
|
||||
|
||||
/* === Footer === */
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.site-footer a { color: var(--text-muted); }
|
||||
.site-footer a:hover { color: var(--accent); }
|
||||
|
||||
/* === Buttons === */
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.4em 0.85em;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--accent-dim); border-color: var(--accent-dim); text-decoration: none; color: #fff; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-outline:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25em 0.6em;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #b00;
|
||||
border-color: #b00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover { background: #900; border-color: #900; color: #fff; }
|
||||
|
||||
/* === Tags / Badges === */
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.1em 0.45em;
|
||||
text-decoration: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lang-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.1em 0.45em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* === Page Header === */
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.page-header h1 { margin-bottom: 0.25em; }
|
||||
|
||||
.page-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* === Table (paste list) === */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1.5em;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5em 0.75em;
|
||||
background: var(--bg-alt);
|
||||
border-bottom: 2px solid var(--border-dark);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5em 0.75em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.post-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Empty State === */
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* === Paste View === */
|
||||
|
||||
.paste-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.paste-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chroma-highlighted code block */
|
||||
.paste-code .chroma {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Line numbers table layout produced by chroma */
|
||||
.paste-code .chroma .lntable {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.paste-code .chroma .lntd:first-child {
|
||||
user-select: none;
|
||||
padding: 0.85em 0.75em 0.85em 1em;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-alt);
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.paste-code .chroma .lntd:last-child {
|
||||
padding: 0.85em 1.25em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paste-code .chroma pre {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* === New Paste Form === */
|
||||
|
||||
.paste-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.paste-form-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.paste-form-meta .form-row {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.form-row input[type="text"] {
|
||||
max-width: 480px;
|
||||
padding: 0.4em 0.7em;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-row input[type="text"]:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-row select {
|
||||
padding: 0.4em 0.7em;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
padding-top: 1.5rem; /* align with bottom of adjacent selects */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 450px;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
padding: 0.85rem 1rem;
|
||||
resize: vertical;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* === Forms (general) === */
|
||||
|
||||
.inline-form { display: inline; }
|
||||
|
||||
.form-error {
|
||||
background: #fee;
|
||||
border: 1px solid #faa;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6em 0.9em;
|
||||
color: #c00;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* === Admin === */
|
||||
|
||||
.admin-wrap {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-header h1 { margin: 0; }
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table th { font-size: 0.8rem; }
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* === Admin Login === */
|
||||
|
||||
.admin-login-wrap {
|
||||
max-width: 360px;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.admin-login-wrap h1 {
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
width: 100%;
|
||||
padding: 0.5em 0.75em;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.login-form input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
152
static/css/syntax.css
Normal file
152
static/css/syntax.css
Normal file
@@ -0,0 +1,152 @@
|
||||
/* Auto-generated by chroma at server startup. Do not edit. */
|
||||
|
||||
/* Background */ .bg { background-color: #ffffff; }
|
||||
/* PreWrapper */ .chroma { background-color: #ffffff; }
|
||||
/* Error */ .chroma .err { color: #a61717; background-color: #e3d2d2 }
|
||||
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .chroma .hl { background-color: #e5e5e5 }
|
||||
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||
/* Line */ .chroma .line { display: flex; }
|
||||
/* Keyword */ .chroma .k { color: #000000; font-weight: bold }
|
||||
/* KeywordConstant */ .chroma .kc { color: #000000; font-weight: bold }
|
||||
/* KeywordDeclaration */ .chroma .kd { color: #000000; font-weight: bold }
|
||||
/* KeywordNamespace */ .chroma .kn { color: #000000; font-weight: bold }
|
||||
/* KeywordPseudo */ .chroma .kp { color: #000000; font-weight: bold }
|
||||
/* KeywordReserved */ .chroma .kr { color: #000000; font-weight: bold }
|
||||
/* KeywordType */ .chroma .kt { color: #445588; font-weight: bold }
|
||||
/* NameAttribute */ .chroma .na { color: #008080 }
|
||||
/* NameBuiltin */ .chroma .nb { color: #0086b3 }
|
||||
/* NameBuiltinPseudo */ .chroma .bp { color: #999999 }
|
||||
/* NameClass */ .chroma .nc { color: #445588; font-weight: bold }
|
||||
/* NameConstant */ .chroma .no { color: #008080 }
|
||||
/* NameDecorator */ .chroma .nd { color: #3c5d5d; font-weight: bold }
|
||||
/* NameEntity */ .chroma .ni { color: #800080 }
|
||||
/* NameException */ .chroma .ne { color: #990000; font-weight: bold }
|
||||
/* NameFunction */ .chroma .nf { color: #990000; font-weight: bold }
|
||||
/* NameLabel */ .chroma .nl { color: #990000; font-weight: bold }
|
||||
/* NameNamespace */ .chroma .nn { color: #555555 }
|
||||
/* NameTag */ .chroma .nt { color: #000080 }
|
||||
/* NameVariable */ .chroma .nv { color: #008080 }
|
||||
/* NameVariableClass */ .chroma .vc { color: #008080 }
|
||||
/* NameVariableGlobal */ .chroma .vg { color: #008080 }
|
||||
/* NameVariableInstance */ .chroma .vi { color: #008080 }
|
||||
/* LiteralString */ .chroma .s { color: #dd1144 }
|
||||
/* LiteralStringAffix */ .chroma .sa { color: #dd1144 }
|
||||
/* LiteralStringBacktick */ .chroma .sb { color: #dd1144 }
|
||||
/* LiteralStringChar */ .chroma .sc { color: #dd1144 }
|
||||
/* LiteralStringDelimiter */ .chroma .dl { color: #dd1144 }
|
||||
/* LiteralStringDoc */ .chroma .sd { color: #dd1144 }
|
||||
/* LiteralStringDouble */ .chroma .s2 { color: #dd1144 }
|
||||
/* LiteralStringEscape */ .chroma .se { color: #dd1144 }
|
||||
/* LiteralStringHeredoc */ .chroma .sh { color: #dd1144 }
|
||||
/* LiteralStringInterpol */ .chroma .si { color: #dd1144 }
|
||||
/* LiteralStringOther */ .chroma .sx { color: #dd1144 }
|
||||
/* LiteralStringRegex */ .chroma .sr { color: #009926 }
|
||||
/* LiteralStringSingle */ .chroma .s1 { color: #dd1144 }
|
||||
/* LiteralStringSymbol */ .chroma .ss { color: #990073 }
|
||||
/* LiteralNumber */ .chroma .m { color: #009999 }
|
||||
/* LiteralNumberBin */ .chroma .mb { color: #009999 }
|
||||
/* LiteralNumberFloat */ .chroma .mf { color: #009999 }
|
||||
/* LiteralNumberHex */ .chroma .mh { color: #009999 }
|
||||
/* LiteralNumberInteger */ .chroma .mi { color: #009999 }
|
||||
/* LiteralNumberIntegerLong */ .chroma .il { color: #009999 }
|
||||
/* LiteralNumberOct */ .chroma .mo { color: #009999 }
|
||||
/* Operator */ .chroma .o { color: #000000; font-weight: bold }
|
||||
/* OperatorWord */ .chroma .ow { color: #000000; font-weight: bold }
|
||||
/* Comment */ .chroma .c { color: #999988; font-style: italic }
|
||||
/* CommentHashbang */ .chroma .ch { color: #999988; font-style: italic }
|
||||
/* CommentMultiline */ .chroma .cm { color: #999988; font-style: italic }
|
||||
/* CommentSingle */ .chroma .c1 { color: #999988; font-style: italic }
|
||||
/* CommentSpecial */ .chroma .cs { color: #999999; font-weight: bold; font-style: italic }
|
||||
/* CommentPreproc */ .chroma .cp { color: #999999; font-weight: bold; font-style: italic }
|
||||
/* CommentPreprocFile */ .chroma .cpf { color: #999999; font-weight: bold; font-style: italic }
|
||||
/* GenericDeleted */ .chroma .gd { color: #000000; background-color: #ffdddd }
|
||||
/* GenericEmph */ .chroma .ge { color: #000000; font-style: italic }
|
||||
/* GenericError */ .chroma .gr { color: #aa0000 }
|
||||
/* GenericHeading */ .chroma .gh { color: #999999 }
|
||||
/* GenericInserted */ .chroma .gi { color: #000000; background-color: #ddffdd }
|
||||
/* GenericOutput */ .chroma .go { color: #888888 }
|
||||
/* GenericPrompt */ .chroma .gp { color: #555555 }
|
||||
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .chroma .gu { color: #aaaaaa }
|
||||
/* GenericTraceback */ .chroma .gt { color: #aa0000 }
|
||||
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||
/* TextWhitespace */ .chroma .w { color: #bbbbbb }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Background */ .bg { color: #e6edf3; background-color: #0d1117; }
|
||||
/* PreWrapper */ .chroma { color: #e6edf3; background-color: #0d1117; }
|
||||
/* Error */ .chroma .err { color: #f85149 }
|
||||
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .chroma .hl { background-color: #6e7681 }
|
||||
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #737679 }
|
||||
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #6e7681 }
|
||||
/* Line */ .chroma .line { display: flex; }
|
||||
/* Keyword */ .chroma .k { color: #ff7b72 }
|
||||
/* KeywordConstant */ .chroma .kc { color: #79c0ff }
|
||||
/* KeywordDeclaration */ .chroma .kd { color: #ff7b72 }
|
||||
/* KeywordNamespace */ .chroma .kn { color: #ff7b72 }
|
||||
/* KeywordPseudo */ .chroma .kp { color: #79c0ff }
|
||||
/* KeywordReserved */ .chroma .kr { color: #ff7b72 }
|
||||
/* KeywordType */ .chroma .kt { color: #ff7b72 }
|
||||
/* NameClass */ .chroma .nc { color: #f0883e; font-weight: bold }
|
||||
/* NameConstant */ .chroma .no { color: #79c0ff; font-weight: bold }
|
||||
/* NameDecorator */ .chroma .nd { color: #d2a8ff; font-weight: bold }
|
||||
/* NameEntity */ .chroma .ni { color: #ffa657 }
|
||||
/* NameException */ .chroma .ne { color: #f0883e; font-weight: bold }
|
||||
/* NameFunction */ .chroma .nf { color: #d2a8ff; font-weight: bold }
|
||||
/* NameLabel */ .chroma .nl { color: #79c0ff; font-weight: bold }
|
||||
/* NameNamespace */ .chroma .nn { color: #ff7b72 }
|
||||
/* NameProperty */ .chroma .py { color: #79c0ff }
|
||||
/* NameTag */ .chroma .nt { color: #7ee787 }
|
||||
/* NameVariable */ .chroma .nv { color: #79c0ff }
|
||||
/* Literal */ .chroma .l { color: #a5d6ff }
|
||||
/* LiteralDate */ .chroma .ld { color: #79c0ff }
|
||||
/* LiteralString */ .chroma .s { color: #a5d6ff }
|
||||
/* LiteralStringAffix */ .chroma .sa { color: #79c0ff }
|
||||
/* LiteralStringBacktick */ .chroma .sb { color: #a5d6ff }
|
||||
/* LiteralStringChar */ .chroma .sc { color: #a5d6ff }
|
||||
/* LiteralStringDelimiter */ .chroma .dl { color: #79c0ff }
|
||||
/* LiteralStringDoc */ .chroma .sd { color: #a5d6ff }
|
||||
/* LiteralStringDouble */ .chroma .s2 { color: #a5d6ff }
|
||||
/* LiteralStringEscape */ .chroma .se { color: #79c0ff }
|
||||
/* LiteralStringHeredoc */ .chroma .sh { color: #79c0ff }
|
||||
/* LiteralStringInterpol */ .chroma .si { color: #a5d6ff }
|
||||
/* LiteralStringOther */ .chroma .sx { color: #a5d6ff }
|
||||
/* LiteralStringRegex */ .chroma .sr { color: #79c0ff }
|
||||
/* LiteralStringSingle */ .chroma .s1 { color: #a5d6ff }
|
||||
/* LiteralStringSymbol */ .chroma .ss { color: #a5d6ff }
|
||||
/* LiteralNumber */ .chroma .m { color: #a5d6ff }
|
||||
/* LiteralNumberBin */ .chroma .mb { color: #a5d6ff }
|
||||
/* LiteralNumberFloat */ .chroma .mf { color: #a5d6ff }
|
||||
/* LiteralNumberHex */ .chroma .mh { color: #a5d6ff }
|
||||
/* LiteralNumberInteger */ .chroma .mi { color: #a5d6ff }
|
||||
/* LiteralNumberIntegerLong */ .chroma .il { color: #a5d6ff }
|
||||
/* LiteralNumberOct */ .chroma .mo { color: #a5d6ff }
|
||||
/* Operator */ .chroma .o { color: #ff7b72; font-weight: bold }
|
||||
/* OperatorWord */ .chroma .ow { color: #ff7b72; font-weight: bold }
|
||||
/* Comment */ .chroma .c { color: #8b949e; font-style: italic }
|
||||
/* CommentHashbang */ .chroma .ch { color: #8b949e; font-style: italic }
|
||||
/* CommentMultiline */ .chroma .cm { color: #8b949e; font-style: italic }
|
||||
/* CommentSingle */ .chroma .c1 { color: #8b949e; font-style: italic }
|
||||
/* CommentSpecial */ .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic }
|
||||
/* CommentPreproc */ .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic }
|
||||
/* CommentPreprocFile */ .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic }
|
||||
/* GenericDeleted */ .chroma .gd { color: #ffa198; background-color: #490202 }
|
||||
/* GenericEmph */ .chroma .ge { font-style: italic }
|
||||
/* GenericError */ .chroma .gr { color: #ffa198 }
|
||||
/* GenericHeading */ .chroma .gh { color: #79c0ff; font-weight: bold }
|
||||
/* GenericInserted */ .chroma .gi { color: #56d364; background-color: #0f5323 }
|
||||
/* GenericOutput */ .chroma .go { color: #8b949e }
|
||||
/* GenericPrompt */ .chroma .gp { color: #8b949e }
|
||||
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .chroma .gu { color: #79c0ff }
|
||||
/* GenericTraceback */ .chroma .gt { color: #ff7b72 }
|
||||
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||
/* TextWhitespace */ .chroma .w { color: #6e7681 }
|
||||
}
|
||||
14
static/favicon.svg
Normal file
14
static/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="5" fill="#1a1918"/>
|
||||
<polyline
|
||||
points="7,10 18,16 7,22"
|
||||
fill="none"
|
||||
stroke="#e8870a"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
<line x1="20" y1="22" x2="27" y2="22"
|
||||
stroke="#e8870a"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
51
templates/admin/dashboard.html
Normal file
51
templates/admin/dashboard.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{{define "title"}}admin — paste.ridgwaysystems.org{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<div class="admin-wrap">
|
||||
<div class="admin-header">
|
||||
<h1>all pastes</h1>
|
||||
<div class="admin-actions">
|
||||
<a href="/new" class="btn">+ new paste</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Pastes}}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>title</th>
|
||||
<th>language</th>
|
||||
<th>created</th>
|
||||
<th>expires</th>
|
||||
<th>unlisted</th>
|
||||
<th>actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Pastes}}
|
||||
<tr {{if .Expired}}style="opacity:0.5"{{end}}>
|
||||
<td><code><a href="/{{.ID}}">{{.ID}}</a></code></td>
|
||||
<td>{{.Title}}</td>
|
||||
<td><span class="lang-badge">{{.Language}}</span></td>
|
||||
<td class="post-date">{{formatDate .CreatedAt}}</td>
|
||||
<td class="post-date">{{expiryStr .}}</td>
|
||||
<td>{{if .Unlisted}}<span class="tag">unlisted</span>{{end}}</td>
|
||||
<td class="actions-cell">
|
||||
<a href="/raw/{{.ID}}" class="btn btn-outline btn-sm">raw</a>
|
||||
<form method="POST" action="/admin/delete/{{.ID}}" class="inline-form"
|
||||
onsubmit="return confirm('Delete paste {{.ID}}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{$csrf}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty-state">No pastes yet. <a href="/new">Create one.</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
18
templates/admin/login.html
Normal file
18
templates/admin/login.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "title"}}login — paste.ridgwaysystems.org{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="admin-login-wrap">
|
||||
<h1>admin login</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="form-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/admin/login" class="login-form">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label for="password">password</label>
|
||||
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit" class="btn">login</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
46
templates/base.html
Normal file
46
templates/base.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .Inner}}paste.ridgwaysystems.org{{end}}</title>
|
||||
<meta name="description" content="{{block "meta-desc" .Inner}}A self-hosted code paste service.{{end}}">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/syntax.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-brand">paste.ridgwaysystems.org</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">pastes</a></li>
|
||||
{{if .IsAdmin}}
|
||||
<li><a href="/new">new paste</a></li>
|
||||
<li><a href="/admin">admin</a></li>
|
||||
<li>
|
||||
<form method="POST" action="/admin/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="">
|
||||
<button type="submit" class="nav-logout-btn">logout</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
<li><a href="https://ridgwaysystems.org">ridgwaysystems.org →</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
{{block "content" .Inner}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>
|
||||
<a href="/">paste.ridgwaysystems.org</a> —
|
||||
<a href="https://ridgwaysystems.org">ridgwaysystems.org</a> —
|
||||
self-hosted on FreeBSD
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
33
templates/list.html
Normal file
33
templates/list.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "title"}}pastes — paste.ridgwaysystems.org{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>pastes</h1>
|
||||
<p class="page-desc">Public code snippets.</p>
|
||||
</div>
|
||||
|
||||
{{if .Pastes}}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>title</th>
|
||||
<th>language</th>
|
||||
<th>created</th>
|
||||
<th>expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Pastes}}
|
||||
<tr>
|
||||
<td><a href="/{{.ID}}">{{.Title}}</a></td>
|
||||
<td><span class="lang-badge">{{.Language}}</span></td>
|
||||
<td class="post-date">{{formatDate .CreatedAt}}</td>
|
||||
<td class="post-date">{{expiryStr .}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty-state">No public pastes yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
58
templates/new.html
Normal file
58
templates/new.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{{define "title"}}new paste — paste.ridgwaysystems.org{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>new paste</h1>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="form-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/new" class="paste-form">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="form-row">
|
||||
<label for="title">title</label>
|
||||
<input type="text" id="title" name="title" placeholder="Untitled" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="paste-form-meta">
|
||||
<div class="form-row">
|
||||
<label for="language">language</label>
|
||||
<select id="language" name="language">
|
||||
{{range .Languages}}
|
||||
<option value="{{.}}">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="expiry">expires</label>
|
||||
<select id="expiry" name="expiry">
|
||||
<option value="never">never</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="unlisted">
|
||||
unlisted (not shown in public index)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="body">paste</label>
|
||||
<textarea id="body" name="body" class="paste-textarea" placeholder="Paste your code here..." spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor-footer">
|
||||
<button type="submit" class="btn">create paste</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
29
templates/paste.html
Normal file
29
templates/paste.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "title"}}{{.Paste.Title}} — paste.ridgwaysystems.org{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="paste-header">
|
||||
<div>
|
||||
<h1 style="margin:0 0 0.4em;">{{.Paste.Title}}</h1>
|
||||
<div class="paste-meta">
|
||||
<span><span class="lang-badge">{{.Paste.Language}}</span></span>
|
||||
<span>created {{formatDateTime .Paste.CreatedAt}}</span>
|
||||
<span>expires {{expiryStr .Paste}}</span>
|
||||
{{if .Paste.Unlisted}}<span class="tag">unlisted</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="paste-actions">
|
||||
<a href="/raw/{{.Paste.ID}}" class="btn btn-outline btn-sm">raw</a>
|
||||
{{if .IsAdmin}}
|
||||
<form method="POST" action="/admin/delete/{{.Paste.ID}}" class="inline-form"
|
||||
onsubmit="return confirm('Delete this paste?')">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paste-code">
|
||||
{{.Highlighted}}
|
||||
</div>
|
||||
{{end}}
|
||||
34
tools/genhash/main.go
Normal file
34
tools/genhash/main.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// genhash generates a bcrypt password hash suitable for use as ADMIN_PASSWORD_HASH.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tools/genhash <password>
|
||||
// make genhash
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: genhash <password>")
|
||||
fmt.Fprintln(os.Stderr, " or: make genhash")
|
||||
os.Exit(1)
|
||||
}
|
||||
password := os.Args[1]
|
||||
if len(password) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "error: password cannot be empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(hash))
|
||||
fmt.Fprintln(os.Stderr, "\nSet this as ADMIN_PASSWORD_HASH in your .env file.")
|
||||
}
|
||||
Reference in New Issue
Block a user