Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite. YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen. Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
232 lines
7.3 KiB
Go
232 lines
7.3 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"
|
|
)
|
|
|
|
// errUsernameTaken: der UNIQUE-Constraint auf username hat beim INSERT
|
|
// gegriffen (Rennen zwischen Vorab-Prüfung und Anlegen). Als Sentinel nutzbar
|
|
// (errors.Is) und zugleich direkt an den Adapter zurückgebbar.
|
|
var errUsernameTaken = Conflict("user.name_taken", "Der Nutzername wird bereits verwendet.")
|
|
|
|
// errUserNotFound deckt sowohl "gibt es nicht" als auch den DB-Fehler beim
|
|
// Lesen ab: für den Aufrufer ist beides dasselbe, und die Unterscheidung würde
|
|
// nur die Existenz von Accounts verraten.
|
|
var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden")
|
|
|
|
// createUser legt den Account an und liefert dessen uid zurück.
|
|
func createUser(username, password string) (int64, error) {
|
|
now := time.Now().Unix()
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return 0, 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 account (uid, created_at, username, password) VALUES ($1, $2, $3, $4)`,
|
|
uid, now, username, hash,
|
|
)
|
|
if lastErr == nil {
|
|
return uid, nil
|
|
}
|
|
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
|
|
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden.
|
|
if isUniqueViolation(lastErr, "account_username_key") {
|
|
return 0, errUsernameTaken
|
|
}
|
|
if !isUniqueViolation(lastErr, "account_uid_key") {
|
|
return 0, lastErr
|
|
}
|
|
}
|
|
return 0, lastErr
|
|
}
|
|
|
|
func usernameTaken(username string) bool {
|
|
var taken bool
|
|
db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken)
|
|
return taken
|
|
}
|
|
|
|
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
|
username := chi.URLParam(r, "username")
|
|
|
|
var uid, createdAt int64
|
|
var avatar string
|
|
err := db.QueryRow(`SELECT uid, created_at, avatar FROM account WHERE username = $1`, username).Scan(&uid, &createdAt, &avatar)
|
|
if err != nil {
|
|
return errUserNotFound
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"uid": uid,
|
|
"username": username,
|
|
"created_at": createdAt,
|
|
"avatar": avatar,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
var createdAt, lastLogin sql.NullInt64
|
|
var username, avatar string
|
|
err := db.QueryRow(
|
|
`SELECT created_at, last_login, username, avatar FROM account WHERE uid = $1`, uid,
|
|
).Scan(&createdAt, &lastLogin, &username, &avatar)
|
|
if err != nil {
|
|
return errUserNotFound
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"uid": uid,
|
|
"username": username,
|
|
"created_at": createdAt.Int64,
|
|
"last_login": lastLogin.Int64,
|
|
"avatar": avatar,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// 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) error {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
name := sanitizeUsername(r.FormValue("user"))
|
|
if len(name) < 3 {
|
|
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
|
}
|
|
|
|
var current string
|
|
if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(¤t); err != nil {
|
|
return errUserNotFound
|
|
}
|
|
if name == current {
|
|
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
|
}
|
|
if usernameTaken(name) {
|
|
return errUsernameTaken
|
|
}
|
|
|
|
if _, err := db.Exec(`UPDATE account SET username = $1 WHERE uid = $2`, name, uid); err != nil {
|
|
// Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409.
|
|
if isUniqueViolation(err, "account_username_key") {
|
|
return errUsernameTaken
|
|
}
|
|
return Internal(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"username": name})
|
|
return nil
|
|
}
|
|
|
|
// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten
|
|
// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes.
|
|
func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
|
|
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 {
|
|
return errUploadTooLargeOrBroken(err)
|
|
}
|
|
if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 {
|
|
return Invalid("avatar.missing", "Kein Bild hochgeladen.").At("avatar")
|
|
}
|
|
|
|
stored, err := storeImage(r.MultipartForm.File["avatar"][0])
|
|
if err != nil {
|
|
return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("avatar").WithCause(err)
|
|
}
|
|
|
|
var old string
|
|
db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&old)
|
|
|
|
if _, err := db.Exec(`UPDATE account SET avatar = $1 WHERE uid = $2`, stored, uid); err != nil {
|
|
os.Remove(stored)
|
|
return Internal(err)
|
|
}
|
|
if old != "" && old != stored {
|
|
os.Remove(old)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"avatar": stored})
|
|
return nil
|
|
}
|
|
|
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
|
// durch das Passwort (Formularfeld pass1). Sessions, Reset-Links 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) error {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
var hash []byte
|
|
if err := db.QueryRow(`SELECT password FROM account WHERE uid = $1`, uid).Scan(&hash); err != nil {
|
|
return errUserNotFound
|
|
}
|
|
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil {
|
|
return Forbidden("auth.bad_password", "Passwort ist falsch.").At("pass1")
|
|
}
|
|
|
|
// 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 = $1`, 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 account WHERE uid = $1`, uid).Scan(&avatar) == nil && avatar != "" {
|
|
media = append(media, avatar)
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return Internal(err)
|
|
}
|
|
for _, q := range []string{
|
|
`DELETE FROM session WHERE uid = $1`,
|
|
`DELETE FROM password_reset WHERE uid = $1`,
|
|
`DELETE FROM vote WHERE uid = $1`,
|
|
`UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE uid = $1`,
|
|
`DELETE FROM account WHERE uid = $1`,
|
|
} {
|
|
if _, err := tx.Exec(q, uid); err != nil {
|
|
tx.Rollback()
|
|
return Internal(err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
// Erst nach erfolgreichem Commit die Dateien entfernen.
|
|
for _, fp := range media {
|
|
os.Remove(fp)
|
|
}
|
|
|
|
clearSessionCookie(w, r)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
return nil
|
|
}
|