Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -16,6 +17,15 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
|
||||
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal
|
||||
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden.
|
||||
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
log.Fatalf("KVER_DIR: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := initDB(envOr("KVER_DB", "kver.db")); err != nil {
|
||||
log.Fatalf("db init: %v", err)
|
||||
}
|
||||
@@ -26,6 +36,12 @@ func main() {
|
||||
initGeoASN()
|
||||
defer closeGeoASN()
|
||||
|
||||
// Impression-Writer erst nach DB und GeoASN starten und VOR deren Close
|
||||
// stoppen (LIFO der defers) -- sonst könnte er nach dem Shutdown noch auf
|
||||
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
|
||||
startImpressionWorker()
|
||||
defer stopImpressionWorker()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: envOr("KVER_ADDR", ":8080"),
|
||||
Handler: routes(),
|
||||
@@ -80,48 +96,87 @@ func routes() http.Handler {
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(secHeaders)
|
||||
r.Use(checkOrigin)
|
||||
|
||||
// --- Auth (öffentlich) ---
|
||||
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
|
||||
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httprate.LimitByIP(20, time.Minute))
|
||||
r.Post("/auth/login", handleLogin)
|
||||
r.Post("/auth/newuser", handleNewUser)
|
||||
// handleLogin bedient JSON- und Formular-Clients (siehe wantsJSON) und
|
||||
// schreibt HTML-Antworten selbst -- api() greift nur noch für den
|
||||
// JSON-Zweig.
|
||||
r.Post("/auth/login", api(handleLogin))
|
||||
r.Post("/auth/newuser", api(handleNewUser))
|
||||
})
|
||||
r.Get("/auth/logout", handleLogout)
|
||||
r.Get("/auth/sessioninfo", handleSessionInfo)
|
||||
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
|
||||
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
||||
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
|
||||
r.Post("/auth/logout", handleLogout)
|
||||
// Serverseitig gerenderte Auth-Seiten (noscript-Weg). Kein Rate-Limit: das
|
||||
// bloße Anzeigen der Formulare ist kein Brute-Force-Vektor, die POSTs oben
|
||||
// sind gedrosselt.
|
||||
r.Get("/auth/login", page(showLogin))
|
||||
r.Get("/auth/register", page(showRegister))
|
||||
|
||||
// Serverseitig gerenderte Feeds. Eigene Pfade, solange "/" und "/u/{name}"
|
||||
// noch die JS-Varianten ausliefern -- so bleibt die Seite während des
|
||||
// Umbaus durchgehend benutzbar. Sobald das Frontend auf gerendertem Markup
|
||||
// aufsetzt, übernehmen diese Handler die ursprünglichen Pfade.
|
||||
r.With(trackImpression).Get("/feed", page(showFeed))
|
||||
r.With(trackImpression).Get("/u/{username}/beitraege", page(showUserFeed))
|
||||
r.With(trackImpression).Get("/beitrag/{pid}", page(showThread))
|
||||
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
||||
r.Get("/auth/headerbar", handleHeaderbar)
|
||||
r.Get("/stats", handleStats)
|
||||
r.Get("/stats/detail", handleStatsDetail)
|
||||
|
||||
// Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth),
|
||||
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen --
|
||||
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
|
||||
// erzwingt zusätzlich TLS für den Clearnet-Weg.
|
||||
r.With(modBasicAuth).Get("/moderation", page(handleModeration))
|
||||
|
||||
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
|
||||
// echte Seitenaufrufe, nicht API/Assets.
|
||||
r.With(trackImpression).Get("/", serveIndex)
|
||||
|
||||
// --- Entries ---
|
||||
r.Get("/entry/feed/{page}", handleFeed)
|
||||
r.Get("/entry/{pid}/votes", handleVotes)
|
||||
r.Get("/entry/{pid}/thread", handleThread)
|
||||
r.Get("/entry/feed/{page}", api(handleFeed))
|
||||
r.Get("/entry/{pid}/votes", api(handleVotes))
|
||||
r.Get("/entry/{pid}/thread", api(handleThread))
|
||||
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
||||
|
||||
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
|
||||
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
|
||||
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
||||
// Bestätigungsseite nach einer Meldung ohne JavaScript. Reines Anzeigen,
|
||||
// deshalb ohne Rate-Limit -- gemeldet wird im POST darüber.
|
||||
r.Get("/entry/{pid}/gemeldet", page(showReported))
|
||||
|
||||
// --- User (öffentlich) ---
|
||||
r.With(trackImpression).Get("/u/{username}", serveUserPage)
|
||||
r.Get("/u/{username}/info", handleUserPage)
|
||||
r.Get("/u/{username}/feed/{page}", handleUserFeed)
|
||||
r.Get("/u/{username}/info", api(handleUserPage))
|
||||
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
|
||||
|
||||
// --- Geschützt (Session erforderlich) ---
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireAuth)
|
||||
r.With(blockTOR).Post("/entry/create", handleCreateEntry)
|
||||
r.Post("/entry/{pid}/vote", handleVote)
|
||||
r.Post("/entry/{pid}/bump", handleBump)
|
||||
r.Post("/entry/{pid}/edit", handleEditEntry)
|
||||
r.Post("/entry/{pid}/delete", handleDeleteEntry)
|
||||
r.Get("/user/info", handleUserInfo)
|
||||
r.Post("/user/rename", handleUserRename)
|
||||
r.Post("/user/avatar", handleSetAvatar)
|
||||
r.Post("/user/delete", handleUserDelete)
|
||||
r.With(blockTOR).Post("/entry/create", api(handleCreateEntry))
|
||||
r.Post("/entry/{pid}/vote", api(handleVote))
|
||||
r.Post("/entry/{pid}/bump", api(handleBump))
|
||||
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
||||
// Bearbeitungsformular (noscript-Weg). In der requireAuth-Gruppe, weil
|
||||
// nur der Autor den bisherigen Text zu sehen bekommen soll.
|
||||
r.Get("/beitrag/{pid}/bearbeiten", page(showEditEntry))
|
||||
// Löschbestätigung: das eigentliche Löschen bleibt ein POST, damit ein
|
||||
// gefolgter Link (Linkvorschau, Crawler) nichts vernichtet.
|
||||
r.Get("/beitrag/{pid}/loeschen", page(showDeleteEntry))
|
||||
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
||||
r.Get("/user/info", api(handleUserInfo))
|
||||
r.Post("/user/rename", api(handleUserRename))
|
||||
r.Post("/user/avatar", api(handleSetAvatar))
|
||||
r.Post("/user/delete", api(handleUserDelete))
|
||||
})
|
||||
|
||||
// Hochgeladene Medien + alte statische Assets. nosniff (global via
|
||||
@@ -154,12 +209,35 @@ func secHeaders(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// checkOrigin weist zustandsändernde Cross-Site-Requests ab (CSRF-Schutz,
|
||||
// zusätzlich zu SameSite=Lax für ältere Browser bzw. GET-Navigationen).
|
||||
// Browser setzen bei Cross-Site-Requests immer einen Origin-Header; fehlt er
|
||||
// (Nicht-Browser-Clients wie curl, gleiche Site bei manchen Browsern), lassen
|
||||
// wir durch -- der Cookie-Schutz greift dort ohnehin nicht bzw. schon.
|
||||
var checkOrigin = guard(func(_ http.ResponseWriter, r *http.Request) error {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
u, err := url.Parse(o)
|
||||
if err != nil || u.Host != r.Host {
|
||||
return Forbidden("csrf.bad_origin", "Anfrage von fremder Seite abgelehnt")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
// truncateRunes kürzt s auf höchstens n Runen. Ein Byte-Slice s[:n] könnte
|
||||
// mitten in eine Multibyte-Rune schneiden und kaputtes UTF-8 erzeugen (gleiches
|
||||
// Problem wie beim Username-Kürzen, siehe sanitizeUsername).
|
||||
func truncateRunes(s string, n int) string {
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user