Files
Kontrollverlust/user.go
T
irrlicht d9028a8400 Header: Statuszeile "Ohne Datenverlust seit n Tagen" + Kennzahlen zurückholen
Schlanke Wiederbelebung des alten Uptime-Schriftzugs aus der Python-Version,
jetzt als kleine Zeile direkt unter dem Titel: Tage seit fixem Startpunkt
(13.12.2025, Kontinuität) plus aktuelle Profil- und Beitragszahlen.

- /stats: neuer öffentlicher Endpunkt (COUNT user / COUNT entry WHERE deleted=0)
- app.js: renderStats() rechnet die Tage und füllt #stats per textContent
- index.html: <p id="stats" class="tagline"> unter der h1
- app.css: kleine graue .tagline-Zeile

Kein Inline-JS/CSS -> strikte CSP bleibt unangetastet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:46:05 +02:00

250 lines
7.6 KiB
Go

package main
import (
"database/sql"
"math/rand/v2"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
func createUser(username, password string) error {
now := time.Now().Unix()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
// uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry,
// uidFromContext). Bei der seltenen UNIQUE-Kollision auf uid neu würfeln.
var lastErr error
for i := 0; i < 5; i++ {
uid := int64(rand.IntN(99999999)) + 1
_, lastErr = db.Exec(
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`,
uid, now, username, hash,
)
if lastErr == nil {
return nil
}
// username ist ebenfalls UNIQUE und wird vom Aufrufer vorab geprüft;
// eine Kollision hier kann also nur die uid sein -> erneut versuchen.
}
return lastErr
}
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
func idFromUsername(username string) int64 {
var id int64
if err := db.QueryRow(`SELECT id FROM user WHERE username = ?`, username).Scan(&id); err != nil {
return 0
}
return id
}
// serveUserPage liefert die statische Profilseite; das Frontend lädt die Daten
// dann über /u/{username}/info und /u/{username}/feed/{page}.
func serveUserPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/user.html")
}
func handleUserPage(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var uid, createdAt int64
var avatar string
err := db.QueryRow(`SELECT uid, created_at, avatar FROM user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar)
if err != nil {
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"uid": uid,
"username": username,
"created_at": createdAt,
"avatar": avatar,
})
}
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
// registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) {
var users, entries int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
"entries": entries,
})
}
func handleUserInfo(w http.ResponseWriter, r *http.Request) {
uid := uidFromContext(r.Context())
var createdAt, lastLogin sql.NullInt64
var username, avatar string
err := db.QueryRow(
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
).Scan(&createdAt, &lastLogin, &username, &avatar)
if err != nil {
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"uid": uid,
"username": username,
"created_at": createdAt.Int64,
"last_login": lastLogin.Int64,
"avatar": avatar,
})
}
// handleUserRename benennt den eingeloggten Account um. Nichts außer der user-Zeile
// referenziert den username (alles hängt an der uid), daher genügt ein UPDATE.
func handleUserRename(w http.ResponseWriter, r *http.Request) {
uid := uidFromContext(r.Context())
name := sanitizeUsername(r.FormValue("user"))
if len(name) < 3 {
writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.")
return
}
var current string
if err := db.QueryRow(`SELECT username FROM user WHERE uid = ?`, uid).Scan(&current); err != nil {
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
return
}
if name == current {
writeError(w, http.StatusBadRequest, "Das ist bereits dein Name.")
return
}
if idFromUsername(name) != 0 {
writeError(w, http.StatusConflict, "Der Nutzername wird bereits verwendet.")
return
}
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil {
writeError(w, http.StatusInternalServerError, "Umbenennen fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]string{"username": name})
}
// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten
// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes.
func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
uid := uidFromContext(r.Context())
// Wie bei handleCreateEntry: Request-Größe hart deckeln, bevor Multipart
// in Temp-Dateien streamen darf.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 {
writeError(w, http.StatusBadRequest, "Kein Bild hochgeladen.")
return
}
stored, err := storeImage(r.MultipartForm.File["avatar"][0])
if err != nil {
writeError(w, http.StatusBadRequest, "Bild konnte nicht verarbeitet werden.")
return
}
var old string
db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&old)
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil {
os.Remove(stored)
writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
if old != "" && old != stored {
os.Remove(old)
}
writeJSON(w, http.StatusOK, map[string]string{"avatar": stored})
}
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
// Zugehörige Mediendateien werden best effort entfernt.
func handleUserDelete(w http.ResponseWriter, r *http.Request) {
uid := uidFromContext(r.Context())
var hash []byte
if err := db.QueryRow(`SELECT password FROM user WHERE uid = ?`, uid).Scan(&hash); err != nil {
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
return
}
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil {
writeError(w, http.StatusForbidden, "Passwort ist falsch.")
return
}
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
// Pfade geleert bzw. die user-Zeile gelöscht wird.
var media []string
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
for rows.Next() {
var fp string
if rows.Scan(&fp) == nil && fp != "" {
media = append(media, fp)
}
}
rows.Close()
}
var avatar string
if db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&avatar) == nil && avatar != "" {
media = append(media, avatar)
}
tx, err := db.Begin()
if err != nil {
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
return
}
for _, q := range []string{
`DELETE FROM session WHERE uid = ?`,
`DELETE FROM vote WHERE uid = ?`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`,
`DELETE FROM user WHERE uid = ?`,
} {
if _, err := tx.Exec(q, uid); err != nil {
tx.Rollback()
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
return
}
}
if err := tx.Commit(); err != nil {
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
return
}
// Erst nach erfolgreichem Commit die Dateien entfernen.
for _, fp := range media {
os.Remove(fp)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}