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(¤t); 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"}) }