844bbb44e7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
4.9 KiB
Go
178 lines
4.9 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 {
|
|
uid := int64(rand.IntN(99999999))
|
|
now := time.Now().Unix()
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = db.Exec(
|
|
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`,
|
|
uid, now, username, hash,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
err := db.QueryRow(`SELECT uid, created_at FROM user WHERE username = ?`, username).Scan(&uid, &createdAt)
|
|
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,
|
|
})
|
|
}
|
|
|
|
func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
var createdAt, lastLogin sql.NullInt64
|
|
var username string
|
|
err := db.QueryRow(
|
|
`SELECT created_at, last_login, username FROM user WHERE uid = ?`, uid,
|
|
).Scan(&createdAt, &lastLogin, &username)
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
|
// durch das Passwort (Formularfeld pass1). Beiträge, Votes und Sessions werden
|
|
// in einer Transaktion mitgelöscht; zugehörige Mediendateien 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 einsammeln, bevor die Zeilen verschwinden.
|
|
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()
|
|
}
|
|
|
|
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 = ?`,
|
|
`DELETE FROM entry 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,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|