Code-Review: tote Stellen entfernt, Umwege vereinfacht
- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt - Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für Cookie-Löschen und Mediennamen, usernameTaken - Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt - Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d1bd0f5f1d
commit
df0cfec132
@@ -12,7 +12,6 @@ kver
|
||||
*.db.bak
|
||||
*.env
|
||||
static/media/
|
||||
migrate.sh
|
||||
Containerfile
|
||||
.containerignore
|
||||
frontend/node_modules/
|
||||
|
||||
+2
-4
@@ -8,8 +8,7 @@ RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
|
||||
# einmaligen Import sind pures Go).
|
||||
# Build-Stage: statisches Binary, CGO aus (pgx ist pures Go).
|
||||
FROM docker.io/library/golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -24,8 +23,7 @@ WORKDIR /app
|
||||
COPY --from=build /kver /usr/local/bin/kver
|
||||
COPY --from=frontend /src/web web/
|
||||
COPY static/ static/
|
||||
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
|
||||
# static/media: Uploads. Beide als Volume persistieren.
|
||||
# data: GeoLite2-ASN-DB; static/media: Uploads. Beide als Volume persistieren.
|
||||
RUN mkdir -p data static/media && chown kver:kver data static/media
|
||||
USER kver
|
||||
ENV KVER_ADDR=:8080
|
||||
|
||||
@@ -69,8 +69,6 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
||||
}
|
||||
|
||||
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
|
||||
// wird der Endpunkt zum Nutzernamen-Orakel.
|
||||
var uid int64
|
||||
var hash []byte
|
||||
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
|
||||
@@ -142,6 +140,12 @@ func handleLogout(w http.ResponseWriter, r *http.Request) error {
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
@@ -152,8 +156,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) error {
|
||||
Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
|
||||
@@ -165,7 +167,7 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
||||
username := sanitizeUsername(r.FormValue("user"))
|
||||
if len(username) < 3 {
|
||||
fields["user"] = "Der Nutzername ist zu kurz."
|
||||
} else if idFromUsername(username) != 0 {
|
||||
} else if usernameTaken(username) {
|
||||
fields["user"] = "Der Nutzername wird bereits verwendet."
|
||||
}
|
||||
|
||||
@@ -217,12 +219,7 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
|
||||
func sanitizeUsername(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
// Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt
|
||||
// mitten in eine Multibyte-Rune fallen und kaputtes UTF-8 erzeugen.
|
||||
if r := []rune(u); len(r) > 32 {
|
||||
u = string(r[:32])
|
||||
}
|
||||
u = truncateRunes(strings.TrimSpace(u), 32)
|
||||
u = reNonWord.ReplaceAllString(u, "")
|
||||
u = reSpace.ReplaceAllString(u, "_")
|
||||
return u
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
@@ -56,15 +55,26 @@ const entrySelect = `
|
||||
COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
||||
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
|
||||
|
||||
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
||||
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
|
||||
func scanEntry(row interface{ Scan(...any) error }) (Entry, error) {
|
||||
var e Entry
|
||||
err := row.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
||||
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// queryEntries führt einen Query mit entrySelect-Spalten aus.
|
||||
func queryEntries(query string, args ...any) ([]Entry, error) {
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := []Entry{}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
||||
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
@@ -74,12 +84,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
|
||||
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
||||
func entryByPID(pid int64) (Entry, error) {
|
||||
var e Entry
|
||||
err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).Scan(
|
||||
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
||||
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
|
||||
return e, err
|
||||
return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -89,49 +94,14 @@ const (
|
||||
maxFeedPage = 100
|
||||
)
|
||||
|
||||
// feedWhere baut die Filterbedingung für Feed-Abfragen.
|
||||
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
|
||||
args := []any{}
|
||||
where := []string{}
|
||||
if uid != 0 {
|
||||
args = append(args, uid)
|
||||
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
|
||||
}
|
||||
if rootsOnly {
|
||||
where = append(where, "e.reply_to = 0")
|
||||
}
|
||||
if len(where) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
// feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge
|
||||
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
||||
// sonst werden alle Beiträge chronologisch geliefert.
|
||||
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page > maxFeedPage {
|
||||
page = maxFeedPage
|
||||
}
|
||||
|
||||
where, args := feedWhere(uid, rootsOnly)
|
||||
query := entrySelect + where
|
||||
if rootsOnly {
|
||||
query += ` ORDER BY e.last_activity DESC`
|
||||
} else {
|
||||
query += ` ORDER BY e.created_at DESC`
|
||||
}
|
||||
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, feedPageSize, page*feedPageSize)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanEntries(rows)
|
||||
// feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit
|
||||
// entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt.
|
||||
func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) {
|
||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||
page = min(max(page, 0), maxFeedPage)
|
||||
n := len(args)
|
||||
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2)
|
||||
return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...)
|
||||
}
|
||||
|
||||
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
|
||||
@@ -141,7 +111,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
//
|
||||
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
|
||||
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
|
||||
// deckelt die Tiefe wie bei der Ahnenkette (threadData).
|
||||
// deckelt die Tiefe wie bei der Ahnenkette (handleThread).
|
||||
func attachLatest(entries []Entry) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
@@ -187,11 +157,7 @@ func attachLatest(entries []Entry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := scanEntries(rows)
|
||||
latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -209,9 +175,10 @@ func attachLatest(entries []Entry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFeed liefert die Wurzelbeiträge nach letzter Aktivität, jeweils mit
|
||||
// ihrer jüngsten Antwort.
|
||||
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||
entries, err := feedPage(page, 0, true)
|
||||
entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
@@ -222,15 +189,15 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
||||
// handleUserFeed liefert alle Beiträge eines Nutzers chronologisch, Antworten
|
||||
// eingeschlossen.
|
||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
var uid int64
|
||||
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||
return errUserNotFound
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||
entries, err := feedPage(page, uid, false)
|
||||
entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
@@ -253,49 +220,30 @@ func pidParam(r *http.Request) (int64, error) {
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||
// direkten Antworten samt deren jüngstem Nachfahren (öffentlich).
|
||||
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
||||
// direkte Antworten samt deren jüngstem Nachfahren.
|
||||
type thread struct {
|
||||
Entry Entry `json:"entry"`
|
||||
Ancestors []Entry `json:"ancestors"`
|
||||
Replies []Entry `json:"replies"`
|
||||
}
|
||||
|
||||
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err := threadData(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entry": t.Entry,
|
||||
"ancestors": t.Ancestors,
|
||||
"replies": t.Replies,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
||||
// direkte Antworten.
|
||||
type thread struct {
|
||||
Entry Entry
|
||||
Ancestors []Entry
|
||||
Replies []Entry
|
||||
}
|
||||
|
||||
// threadData holt einen Thread aus der Datenbank.
|
||||
func threadData(pid int64) (thread, error) {
|
||||
entry, err := entryByPID(pid)
|
||||
if err != nil {
|
||||
return thread{}, errEntryNotFound
|
||||
return errEntryNotFound
|
||||
}
|
||||
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
|
||||
t := thread{Entry: entry, Ancestors: []Entry{}}
|
||||
|
||||
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu
|
||||
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC
|
||||
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und
|
||||
// bricht auch einen (theoretischen) Zyklus ab.
|
||||
// depth zählt vom Beitrag aufwärts; ORDER BY depth DESC dreht die Kette
|
||||
// auf Root-zuerst. Das Limit deckelt die Kettenlänge und bricht auch einen
|
||||
// (theoretischen) Zyklus ab.
|
||||
if entry.ReplyTo != 0 {
|
||||
rows, err := db.Query(
|
||||
t.Ancestors, err = queryEntries(
|
||||
`WITH RECURSIVE chain(pid, depth) AS (
|
||||
SELECT $1::bigint, 1
|
||||
UNION ALL
|
||||
@@ -306,24 +254,20 @@ func threadData(pid int64) (thread, error) {
|
||||
JOIN chain c ON c.pid = e.pid
|
||||
ORDER BY c.depth DESC`, entry.ReplyTo)
|
||||
if err != nil {
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
if t.Ancestors, err = scanEntries(rows); err != nil {
|
||||
return thread{}, Internal(err)
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||
t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||
if err != nil {
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
if t.Replies, err = scanEntries(rows); err != nil {
|
||||
return thread{}, Internal(err)
|
||||
return Internal(err)
|
||||
}
|
||||
if err := attachLatest(t.Replies); err != nil {
|
||||
return thread{}, Internal(err)
|
||||
return Internal(err)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
@@ -504,10 +448,9 @@ const bumpStep = 24 * 60 * 60
|
||||
|
||||
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
|
||||
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
|
||||
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen
|
||||
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
||||
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
||||
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
||||
// wieder nach oben. Gegen Spam wächst der Cooldown linear mit der Zahl
|
||||
// bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht
|
||||
// am bumpenden Nutzer.
|
||||
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
@@ -539,9 +482,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
// Bewusst kein Taxonomie-Fehler -- die Antwort trägt
|
||||
// Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des
|
||||
// Buttons braucht, und der Cooldown ist ein regulärer Ausgang.
|
||||
// Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten
|
||||
// (retry_after, bump_count), mit denen das Frontend den Button sperrt.
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": apiErrorBody{
|
||||
@@ -656,8 +598,7 @@ const (
|
||||
|
||||
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
|
||||
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
|
||||
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides
|
||||
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
|
||||
// war zu groß -> 413) oder kaputtes Multipart (-> 400).
|
||||
func errUploadTooLargeOrBroken(err error) error {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
@@ -749,7 +690,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
|
||||
if format == "gif" {
|
||||
ext = "gif"
|
||||
}
|
||||
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
||||
name := newMediaName(ext)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
||||
@@ -776,7 +717,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
|
||||
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
|
||||
// fließt nie ein (kein Path-Traversal).
|
||||
func copyUpload(src io.Reader, ext string) (string, error) {
|
||||
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
||||
name := newMediaName(ext)
|
||||
out, err := os.Create(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -795,3 +736,8 @@ func copyUpload(src io.Reader, ext string) (string, error) {
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// newMediaName erzeugt einen serverseitigen Dateinamen unter static/media.
|
||||
func newMediaName(ext string) string {
|
||||
return fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder
|
||||
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
|
||||
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
||||
// Fehler-Taxonomie: trennt, wie schwer ein Fehler ist (Kind), was genau
|
||||
// passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
||||
//
|
||||
// Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein
|
||||
// Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können,
|
||||
@@ -92,9 +91,7 @@ func (e *Error) WithCause(cause error) *Error {
|
||||
return e
|
||||
}
|
||||
|
||||
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit
|
||||
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
|
||||
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
|
||||
// asError normalisiert alles, was kein *Error ist, zu einem Internal.
|
||||
func asError(err error) *Error {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
|
||||
@@ -10,9 +10,8 @@ function plural(n: number, one: string, many: string) {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten
|
||||
// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat
|
||||
// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
|
||||
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
|
||||
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
|
||||
export function Layout() {
|
||||
const isHome = useLocation().pathname === "/";
|
||||
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
|
||||
|
||||
+14
-45
@@ -87,23 +87,6 @@ h2 {
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe,
|
||||
gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen,
|
||||
eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look).
|
||||
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
|
||||
#sigils {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
#sigils img {
|
||||
height: 50px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#headerbar {
|
||||
background-color: white;
|
||||
text-align: center;
|
||||
@@ -258,9 +241,13 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der
|
||||
Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
|
||||
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem
|
||||
rechten Avatarrand. Der .body füllt per flex:1 den Rest. */
|
||||
rechten Avatarrand. Der .body füllt per flex:1 den Rest.
|
||||
Dasselbe gilt für die Diskussionskette in Feed und Antworten (Discussion:
|
||||
Beitrag, ggf. "k weitere Beiträge", jüngste Antwort) an allen Gliedern
|
||||
außer dem letzten. */
|
||||
#ancestors .threadline,
|
||||
#replies .threadline {
|
||||
#replies .threadline,
|
||||
.chain > :not(:last-child) .threadline {
|
||||
display: block;
|
||||
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
|
||||
landet genau auf der Avatarmitte statt rechts daneben. */
|
||||
@@ -268,16 +255,10 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
margin-right: calc(var(--avatar-sm) / 2);
|
||||
}
|
||||
|
||||
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
|
||||
#ancestors .threadline { border-right: var(--line-w) solid gray; }
|
||||
|
||||
/* Diskussionskette in Feed und Antworten (Discussion): Beitrag, ggf.
|
||||
"k weitere Beiträge", jüngste Antwort. Gleiches Bild wie die Ahnenkette --
|
||||
Strich an allen Gliedern außer dem letzten. */
|
||||
/* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte
|
||||
Antworten ohne Kette sind nur eingerückt. */
|
||||
#ancestors .threadline,
|
||||
.chain > :not(:last-child) .threadline {
|
||||
display: block;
|
||||
flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w));
|
||||
margin-right: calc(var(--avatar-sm) / 2);
|
||||
border-right: var(--line-w) solid gray;
|
||||
}
|
||||
|
||||
@@ -299,7 +280,7 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
|
||||
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
|
||||
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
|
||||
wandert in die Byline (siehe setupBump). */
|
||||
wandert in die Byline (siehe useBump). */
|
||||
.interactions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -308,8 +289,7 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown
|
||||
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
|
||||
/* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */
|
||||
.bump:disabled {
|
||||
color: gray;
|
||||
border-color: #ccc;
|
||||
@@ -328,12 +308,6 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
.ghost.selected { font-weight: bold; }
|
||||
.msg { color: red; }
|
||||
|
||||
p.warning {
|
||||
background-color: #faa;
|
||||
padding: 10px 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* --- Formulare --- */
|
||||
form {
|
||||
background-color: white;
|
||||
@@ -360,11 +334,6 @@ form input[type="file"] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* --- Statistik-Seite --- */
|
||||
.statgrid {
|
||||
display: flex;
|
||||
@@ -381,8 +350,8 @@ hr {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS
|
||||
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */
|
||||
/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
|
||||
(kein Inline-style im HTML -> CSP-konform). */
|
||||
.bars {
|
||||
padding: 0.3rem 0.5rem 0.6rem;
|
||||
}
|
||||
@@ -414,7 +383,7 @@ hr {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */
|
||||
/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */
|
||||
#stat-asns .bar-label {
|
||||
flex-basis: 13rem;
|
||||
white-space: normal;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError, api } from "./api";
|
||||
import type { Me } from "./types";
|
||||
|
||||
export const meKey = ["me"];
|
||||
const meKey = ["me"];
|
||||
|
||||
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
|
||||
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useState, type FormEvent, type MouseEvent } from "react";
|
||||
import { api, errMsg } from "../api";
|
||||
import { useAuthChanged } from "../auth";
|
||||
|
||||
// AuthBox: Login und Registrierung im Wechsel. Die {" "} bilden die
|
||||
// Leerzeichen des alten Markups nach -- Überschrift, Inputs und Knöpfe sind
|
||||
// inline, der Abstand zwischen ihnen ist also sichtbar.
|
||||
// AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht:
|
||||
// Überschrift, Inputs und Knöpfe sind inline, der Abstand ist sichtbar.
|
||||
export function AuthBox() {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
@@ -8,7 +8,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
|
||||
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
|
||||
export function cooldownText(retryAfter: number): string {
|
||||
function cooldownText(retryAfter: number): string {
|
||||
return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, errMsg } from "../api";
|
||||
import { pasteImage } from "../paste";
|
||||
import type { CreatedEntry } from "../types";
|
||||
|
||||
// Compose: neuer Beitrag. Nach Erfolg springt der Feed zurück auf Seite 0,
|
||||
// damit der eigene Beitrag oben steht.
|
||||
export function Compose() {
|
||||
const qc = useQueryClient();
|
||||
const [pasteMsg, setPasteMsg] = useState("");
|
||||
const create = useMutation({
|
||||
mutationFn: (fd: FormData) => api.post<CreatedEntry>("/entry/create", fd),
|
||||
});
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
create.mutate(new FormData(form), {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setPasteMsg("");
|
||||
qc.resetQueries({ queryKey: ["feed"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="compose">
|
||||
<form
|
||||
id="entry-form"
|
||||
onSubmit={onSubmit}
|
||||
onPaste={(e) => {
|
||||
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
|
||||
}}
|
||||
>
|
||||
<textarea name="content" rows={4} maxLength={1000} placeholder="Was gibt's Neues?"></textarea>{" "}
|
||||
<p className="muted" id="paste-msg">{pasteMsg}</p>{" "}
|
||||
<input name="file" type="file" accept="image/*" />{" "}
|
||||
<button type="submit" disabled={create.isPending}>Eintrag erstellen</button>
|
||||
</form>
|
||||
<p className="msg" id="entry-msg">{create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,15 +14,16 @@ function useOpenOnClick(pid: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function EntryCard({ entry: e }: { entry: Entry }) {
|
||||
const open = useOpenOnClick(e.pid);
|
||||
const when = formatTime(e.created_at);
|
||||
export function EntryCard({ entry }: { entry: Entry }) {
|
||||
return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
|
||||
}
|
||||
|
||||
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
|
||||
if (e.deleted) {
|
||||
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
|
||||
function DeletedCard({ entry: e }: { entry: Entry }) {
|
||||
const open = useOpenOnClick(e.pid);
|
||||
return (
|
||||
<article className="card" onClick={open}>
|
||||
<div className="muted byline"><span>[deleted] · {when}</span></div>
|
||||
<div className="muted byline"><span>[deleted] · {formatTime(e.created_at)}</span></div>
|
||||
<div className="threadline-wrapper">
|
||||
<div className="threadline"></div>
|
||||
<div className="body">
|
||||
@@ -31,11 +32,10 @@ export function EntryCard({ entry: e }: { entry: Entry }) {
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return <LiveCard entry={e} when={when} open={open} />;
|
||||
}
|
||||
|
||||
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) {
|
||||
function LiveCard({ entry: e }: { entry: Entry }) {
|
||||
const open = useOpenOnClick(e.pid);
|
||||
const bump = useBump(e);
|
||||
const profile = `/u/${encodeURIComponent(e.username)}`;
|
||||
|
||||
@@ -46,7 +46,7 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open:
|
||||
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
|
||||
</Link>
|
||||
<span>
|
||||
<Link to={profile}>{e.username}</Link> · {when}
|
||||
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
|
||||
<span className="cooldown">{bump.cooldown}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ const maxPage = 100;
|
||||
|
||||
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
|
||||
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
|
||||
// API-Pfad der Seite. as ist das Container-Element wie im alten Markup:
|
||||
// <section> auf der Startseite, <div> innerhalb des Profils.
|
||||
// API-Pfad der Seite. as ist das Container-Element: <section> auf der
|
||||
// Startseite, <div> innerhalb des Profils.
|
||||
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
|
||||
queryKey: unknown[];
|
||||
urlFor: (page: number) => string;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api, errMsg } from "../api";
|
||||
import { pasteImage } from "../paste";
|
||||
|
||||
// PostForm verfasst einen Beitrag, mit replyTo eine Antwort darauf.
|
||||
export function PostForm({ replyTo, rows, placeholder, submitLabel, fallback, onDone }: {
|
||||
replyTo?: string;
|
||||
rows: number;
|
||||
placeholder: string;
|
||||
submitLabel: string;
|
||||
fallback: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [pasteMsg, setPasteMsg] = useState("");
|
||||
const create = useMutation({
|
||||
mutationFn: (fd: FormData) => api.post("/entry/create", fd),
|
||||
});
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const fd = new FormData(form);
|
||||
if (replyTo) fd.append("reply_to", replyTo);
|
||||
create.mutate(fd, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setPasteMsg("");
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
onPaste={(e) => {
|
||||
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
|
||||
}}
|
||||
>
|
||||
<textarea name="content" rows={rows} maxLength={1000} placeholder={placeholder}></textarea>{" "}
|
||||
<p className="muted">{pasteMsg}</p>{" "}
|
||||
<input name="file" type="file" accept="image/*" />{" "}
|
||||
<button type="submit" disabled={create.isPending}>{submitLabel}</button>
|
||||
</form>
|
||||
<p className="msg">{create.isError ? errMsg(create.error, fallback) : ""}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,8 @@ export function formatTime(unix: number): string {
|
||||
|
||||
const urlPattern = /https?:\/\/[^\s<]+/g;
|
||||
|
||||
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. React
|
||||
// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein
|
||||
// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte.
|
||||
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. Den Text
|
||||
// escaped React selbst.
|
||||
export function linkify(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
text.split("\n").forEach((line, i) => {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useParams } from "react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, errMsg } from "../api";
|
||||
import { useMe } from "../auth";
|
||||
import { pasteImage } from "../paste";
|
||||
import { Discussion, EntryCard } from "../components/EntryCard";
|
||||
import type { CreatedEntry, Entry, Thread } from "../types";
|
||||
import { PostForm } from "../components/PostForm";
|
||||
import type { Entry, Thread } from "../types";
|
||||
|
||||
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
|
||||
// Meldungen) -- deshalb hängt die Seite per key an der pid.
|
||||
@@ -51,7 +51,14 @@ function ThreadView({ pid }: { pid: string }) {
|
||||
</div>
|
||||
|
||||
<section id="reply-box" hidden={!me}>
|
||||
<ReplyForm pid={pid} onDone={refresh} />
|
||||
<PostForm
|
||||
replyTo={pid}
|
||||
rows={3}
|
||||
placeholder="Antworten…"
|
||||
submitLabel="Antworten"
|
||||
fallback="Antwort fehlgeschlagen."
|
||||
onDone={refresh}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="replies">
|
||||
@@ -63,8 +70,6 @@ function ThreadView({ pid }: { pid: string }) {
|
||||
}
|
||||
|
||||
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag.
|
||||
// Die {" "} bilden die Leerzeichen des alten Markups nach -- Inputs und Knöpfe
|
||||
// sind inline, der Abstand zwischen ihnen ist also sichtbar.
|
||||
function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
@@ -111,43 +116,3 @@ function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyForm({ pid, onDone }: { pid: string; onDone: () => void }) {
|
||||
const [pasteMsg, setPasteMsg] = useState("");
|
||||
|
||||
const reply = useMutation({
|
||||
mutationFn: (fd: FormData) => api.post<CreatedEntry>("/entry/create", fd),
|
||||
});
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const fd = new FormData(form);
|
||||
fd.append("reply_to", pid);
|
||||
reply.mutate(fd, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setPasteMsg("");
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
id="reply-form"
|
||||
onSubmit={onSubmit}
|
||||
onPaste={(e) => {
|
||||
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
|
||||
}}
|
||||
>
|
||||
<textarea name="content" rows={3} maxLength={1000} placeholder="Antworten…"></textarea>{" "}
|
||||
<p className="muted" id="reply-paste-msg">{pasteMsg}</p>{" "}
|
||||
<input name="file" type="file" accept="image/*" />{" "}
|
||||
<button type="submit" disabled={reply.isPending}>Antworten</button>
|
||||
</form>
|
||||
<p className="msg" id="reply-msg">{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMe } from "../auth";
|
||||
import { AuthBox } from "../components/AuthBox";
|
||||
import { Compose } from "../components/Compose";
|
||||
import { Feed } from "../components/Feed";
|
||||
import { PostForm } from "../components/PostForm";
|
||||
|
||||
// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
|
||||
// sonst blitzt bei Angemeldeten kurz die Login-Box auf.
|
||||
@@ -14,3 +15,20 @@ export function FeedPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Nach einem neuen Beitrag springt der Feed zurück auf Seite 0, damit er oben
|
||||
// steht.
|
||||
function Compose() {
|
||||
const qc = useQueryClient();
|
||||
return (
|
||||
<section id="compose">
|
||||
<PostForm
|
||||
rows={4}
|
||||
placeholder="Was gibt's Neues?"
|
||||
submitLabel="Eintrag erstellen"
|
||||
fallback="Beitrag fehlgeschlagen."
|
||||
onDone={() => qc.resetQueries({ queryKey: ["feed"] })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function ProfileView({ username }: { username: string }) {
|
||||
<img
|
||||
id="profile-avatar"
|
||||
className="avatar avatar-lg"
|
||||
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"}
|
||||
src={avatarUrl(info?.avatar ?? "") + avatarBust}
|
||||
alt=""
|
||||
/>
|
||||
<div>
|
||||
@@ -74,8 +74,7 @@ function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTML
|
||||
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" };
|
||||
}
|
||||
|
||||
// Settings: nur für den Eigentümer. Die {" "} bilden die Leerzeichen des alten
|
||||
// Markups nach -- Inputs und Knöpfe sind inline.
|
||||
// Settings: nur für den Eigentümer.
|
||||
function Settings({ onAvatar }: { onAvatar: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -43,13 +43,6 @@ export interface BumpResult {
|
||||
retry_after: number;
|
||||
}
|
||||
|
||||
export interface CreatedEntry {
|
||||
pid: number;
|
||||
content: string;
|
||||
filepath: string;
|
||||
reply_to: number;
|
||||
}
|
||||
|
||||
// GET /api/user/info -- das eigene Konto.
|
||||
export interface Me {
|
||||
uid: number;
|
||||
|
||||
@@ -9,24 +9,15 @@ require (
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/image v0.41.0
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -23,16 +15,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -46,45 +32,13 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
+2
-4
@@ -3,10 +3,8 @@ package main
|
||||
import "net/http"
|
||||
|
||||
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber
|
||||
// mit Fehler-Rückgabe. Der Gewinn ist nicht nur Kürze -- die alte Form
|
||||
// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug:
|
||||
// der Handler lief weiter und schrieb eine zweite Antwort in denselben
|
||||
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
|
||||
// mit Fehler-Rückgabe. "return err" beendet den Handler zwingend -- ein
|
||||
// vergessenes return nach dem Fehlerschreiben kann es so nicht geben.
|
||||
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
||||
|
||||
// api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
|
||||
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// importTable beschreibt eine Tabelle für den einmaligen Umzug SQLite -> Postgres.
|
||||
// Die Spalten heißen in beiden Datenbanken gleich; nur user heißt jetzt account.
|
||||
type importTable struct {
|
||||
src, dst string
|
||||
cols []string
|
||||
identity bool // hat eine id-Identity-Spalte, deren Sequenz nachgezogen werden muss
|
||||
}
|
||||
|
||||
var importTables = []importTable{
|
||||
{"user", "account", []string{"id", "uid", "username", "password", "created_at", "last_login", "avatar"}, true},
|
||||
{"session", "session", []string{"id", "uid", "value", "created_at", "expires", "description"}, true},
|
||||
{"entry", "entry", []string{"id", "pid", "uid", "created_at", "content", "filepath", "reply_to",
|
||||
"reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true},
|
||||
{"vote", "vote", []string{"id", "uid", "pid", "mode"}, true},
|
||||
{"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false},
|
||||
}
|
||||
|
||||
// importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB
|
||||
// bereits verbundene und mit Schema versehene) Postgres-Datenbank. Alles in einer
|
||||
// Transaktion: entweder ist der Umzug vollständig oder es ist nichts passiert.
|
||||
// Bricht ab, wenn Zieltabellen schon Daten enthalten -- ein zweiter Lauf soll
|
||||
// nichts doppeln.
|
||||
func importSQLite(path string) error {
|
||||
src, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := src.Ping(); err != nil {
|
||||
return fmt.Errorf("sqlite öffnen: %w", err)
|
||||
}
|
||||
var check string
|
||||
if err := src.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil || check != "ok" {
|
||||
return fmt.Errorf("sqlite integrity_check: %q %v", check, err)
|
||||
}
|
||||
|
||||
for _, t := range importTables {
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("zieltabelle %s ist nicht leer (%d zeilen) -- abbruch", t.dst, n)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, t := range importTables {
|
||||
var exists int
|
||||
src.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, t.src).Scan(&exists)
|
||||
if exists == 0 {
|
||||
fmt.Printf("%-10s -> %-10s fehlt in sqlite, übersprungen\n", t.src, t.dst)
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := copyTable(src, tx, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", t.src, err)
|
||||
}
|
||||
|
||||
var got int64
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&got); err != nil {
|
||||
return err
|
||||
}
|
||||
if got != n {
|
||||
return fmt.Errorf("%s: %d gelesen, aber %d in postgres", t.src, n, got)
|
||||
}
|
||||
if t.identity {
|
||||
if _, err := tx.Exec(fmt.Sprintf(
|
||||
`SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 0) + 1, false) FROM %s`,
|
||||
t.dst, t.dst)); err != nil {
|
||||
return fmt.Errorf("%s: sequenz: %w", t.dst, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%-10s -> %-10s %6d zeilen\n", t.src, t.dst, n)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func copyTable(src *sql.DB, tx *sql.Tx, t importTable) (int64, error) {
|
||||
cols := strings.Join(t.cols, ", ")
|
||||
rows, err := src.Query(`SELECT ` + cols + ` FROM "` + t.src + `"`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
ph := make([]string, len(t.cols))
|
||||
for i := range ph {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
override := ""
|
||||
if t.identity {
|
||||
override = " OVERRIDING SYSTEM VALUE"
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO ` + t.dst + ` (` + cols + `)` + override +
|
||||
` VALUES (` + strings.Join(ph, ", ") + `)`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var n int64
|
||||
vals := make([]any, len(t.cols))
|
||||
ptrs := make([]any, len(t.cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if _, err := stmt.Exec(vals...); err != nil {
|
||||
return n, fmt.Errorf("zeile %d: %w", n+1, err)
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, rows.Err()
|
||||
}
|
||||
@@ -37,15 +37,6 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Einmaliger Umzug: kver import-sqlite /pfad/zur/kver.db
|
||||
if len(os.Args) == 3 && os.Args[1] == "import-sqlite" {
|
||||
if err := importSQLite(os.Args[2]); err != nil {
|
||||
log.Fatalf("import: %v", err)
|
||||
}
|
||||
log.Print("import abgeschlossen")
|
||||
return
|
||||
}
|
||||
|
||||
// Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
|
||||
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
|
||||
initGeoASN()
|
||||
@@ -69,7 +60,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen,
|
||||
// dann die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen.
|
||||
// dann über die defers Worker und DB sauber schließen.
|
||||
idle := make(chan struct{})
|
||||
go func() {
|
||||
sig := make(chan os.Signal, 1)
|
||||
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate.sh — liest kver-original.db und erzeugt daraus eine frische kver.db
|
||||
# mit dem aktuellen Go-Schema. Die Quelldatenbank wird nie verändert.
|
||||
# Idempotent: kver.db wird bei jedem Lauf neu erstellt.
|
||||
#
|
||||
# Verwendung:
|
||||
# ./migrate.sh [quelldatei [zieldatei]] # Standard: kver-original.db -> kver.db
|
||||
#
|
||||
# Schalter (als Umgebungsvariablen):
|
||||
# REPLIES=keep|drop Antworten (reply_to != 0) übernehmen oder verwerfen (Default: keep)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:-original-kver.db}"
|
||||
DST="${2:-kver.db}"
|
||||
REPLIES="${REPLIES:-keep}"
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo "FEHLER: Quelldatenbank '$SRC' nicht gefunden." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$REPLIES" != "keep" && "$REPLIES" != "drop" ]]; then
|
||||
echo "FEHLER: REPLIES muss 'keep' oder 'drop' sein (war: '$REPLIES')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SRC" -ef "$DST" ]]; then
|
||||
echo "FEHLER: Quelle und Ziel sind dieselbe Datei." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
qs() { sqlite3 "$SRC" "$1"; }
|
||||
qd() { sqlite3 "$DST" "$1"; }
|
||||
|
||||
echo "== kver-Migration =="
|
||||
echo "Quelle: $SRC"
|
||||
echo "Ziel: $DST"
|
||||
echo "REPLIES: $REPLIES"
|
||||
echo
|
||||
|
||||
echo "Quelle:"
|
||||
echo " Einträge gesamt: $(qs 'SELECT COUNT(*) FROM entry;')"
|
||||
echo " davon deleted=1: $(qs 'SELECT COUNT(*) FROM entry WHERE deleted=1;')"
|
||||
echo " davon Antworten: $(qs 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
||||
echo " User: $(qs 'SELECT COUNT(*) FROM user;')"
|
||||
|
||||
# Zieldatenbank immer frisch anlegen
|
||||
rm -f "$DST"
|
||||
echo
|
||||
echo "Erstelle $DST mit aktuellem Schema..."
|
||||
|
||||
sqlite3 "$DST" <<'SCHEMA'
|
||||
.bail on
|
||||
CREATE TABLE user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid INTEGER UNIQUE,
|
||||
username TEXT UNIQUE,
|
||||
password BLOB,
|
||||
created_at INTEGER,
|
||||
last_login INTEGER,
|
||||
avatar TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE session (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid INTEGER,
|
||||
value TEXT UNIQUE,
|
||||
created_at INTEGER,
|
||||
expires INTEGER,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE entry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pid INTEGER UNIQUE NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
filepath TEXT NOT NULL DEFAULT '',
|
||||
reply_to INTEGER NOT NULL DEFAULT 0,
|
||||
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_activity INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE vote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid INTEGER,
|
||||
pid INTEGER,
|
||||
mode TEXT,
|
||||
UNIQUE(uid, pid)
|
||||
);
|
||||
SCHEMA
|
||||
|
||||
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
|
||||
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
|
||||
has_col() {
|
||||
sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('$1') WHERE name='$2';"
|
||||
}
|
||||
|
||||
reply_count_expr="0"
|
||||
[[ "$(has_col entry reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)"
|
||||
|
||||
last_activity_expr="e.created_at"
|
||||
[[ "$(has_col entry last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)"
|
||||
|
||||
# Profilbild: alte DBs haben die Spalte nicht -> leerer String.
|
||||
avatar_expr="''"
|
||||
[[ "$(has_col user avatar)" == "1" ]] && avatar_expr="COALESCE(u.avatar, '')"
|
||||
|
||||
replies_filter=""
|
||||
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
|
||||
|
||||
has_vote_table=$(qs "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vote';")
|
||||
|
||||
# Quell-Pfad für SQL-String-Literal escapen (einfache Anführungszeichen verdoppeln)
|
||||
src_sql="${SRC//\'/\'\'}"
|
||||
|
||||
sqlite3 "$DST" <<SQL
|
||||
.bail on
|
||||
ATTACH '$src_sql' AS src;
|
||||
BEGIN;
|
||||
|
||||
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
|
||||
INSERT INTO user (id, uid, username, password, created_at, last_login, avatar)
|
||||
SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u;
|
||||
|
||||
-- Einträge transformieren:
|
||||
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
|
||||
-- content NULL -> ''
|
||||
-- deleted: nur nicht-gelöschte Einträge werden übernommen
|
||||
-- orphans: Einträge ohne bekannten User werden verworfen
|
||||
INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, deleted)
|
||||
SELECT
|
||||
e.pid,
|
||||
e.uid,
|
||||
e.created_at,
|
||||
COALESCE(e.content, ''),
|
||||
COALESCE(NULLIF(e.filepath, 'none'), ''),
|
||||
COALESCE(e.reply_to, 0),
|
||||
$reply_count_expr,
|
||||
$last_activity_expr,
|
||||
0
|
||||
FROM src.entry e
|
||||
WHERE e.deleted = 0
|
||||
AND e.uid IN (SELECT uid FROM src.user)
|
||||
$replies_filter;
|
||||
|
||||
-- Absolute /srv/kver/-Pfade aus der alten Python-Prod-Umgebung normalisieren.
|
||||
UPDATE entry SET filepath = REPLACE(filepath, '/srv/kver/', '') WHERE filepath LIKE '/srv/kver/%';
|
||||
|
||||
-- Sessions leer lassen: Alt-Tokens sind Integer (Python-Format),
|
||||
-- Go erzeugt crypto/rand-Hex-Tokens -> alle Alt-Sessions sind wertlos.
|
||||
|
||||
COMMIT;
|
||||
DETACH src;
|
||||
VACUUM;
|
||||
SQL
|
||||
|
||||
# Votes separat, da die Tabelle in alten DBs fehlen kann
|
||||
if [[ "$has_vote_table" == "1" ]]; then
|
||||
sqlite3 "$DST" <<SQL2
|
||||
.bail on
|
||||
ATTACH '$src_sql' AS src;
|
||||
INSERT OR IGNORE INTO vote (uid, pid, mode)
|
||||
SELECT uid, pid, mode FROM src.vote
|
||||
WHERE uid IN (SELECT uid FROM entry)
|
||||
AND pid IN (SELECT pid FROM entry);
|
||||
DETACH src;
|
||||
SQL2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Nachher:"
|
||||
echo " Einträge gesamt: $(qd 'SELECT COUNT(*) FROM entry;')"
|
||||
echo " davon Antworten: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
||||
echo " sichtbar im Feed: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to=0;')"
|
||||
echo " User: $(qd 'SELECT COUNT(*) FROM user;')"
|
||||
echo " Votes: $(qd 'SELECT COUNT(*) FROM vote;')"
|
||||
|
||||
# Medien-Check: referenzierte Bilder, die im Dateisystem fehlen
|
||||
missing=0
|
||||
total=0
|
||||
while IFS= read -r fp; do
|
||||
[[ -z "$fp" ]] && continue
|
||||
total=$((total+1))
|
||||
[[ -f "$fp" ]] || missing=$((missing+1))
|
||||
done < <(qd "SELECT filepath FROM entry WHERE filepath != '';")
|
||||
|
||||
echo
|
||||
echo "Medien:"
|
||||
echo " referenziert: $total"
|
||||
echo " fehlend im Dateisystem: $missing"
|
||||
if [[ "$missing" -gt 0 ]]; then
|
||||
echo " -> static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Fertig."
|
||||
@@ -95,23 +95,6 @@ REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC;
|
||||
Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
|
||||
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen.
|
||||
|
||||
## Umzug SQLite → Postgres (einmalig)
|
||||
|
||||
Dienst stoppen, dann mit dem neuen Image die alte Datei aus dem Volume
|
||||
importieren. Der Import läuft in einer Transaktion, prüft die Zeilenzahlen und
|
||||
bricht ab, wenn in Postgres schon Daten liegen:
|
||||
|
||||
```sh
|
||||
systemctl --user stop kver
|
||||
podman run --rm --env-file /etc/kver/kver.env \
|
||||
-v kver-data:/app/data:ro \
|
||||
kver import-sqlite /app/data/kver.db
|
||||
systemctl --user start kver
|
||||
```
|
||||
|
||||
Der Container braucht dabei (und im Betrieb) dieselbe Netzwerkanbindung an
|
||||
Postgres wie der Dienst selbst.
|
||||
|
||||
## Bestehende Medien übernehmen
|
||||
|
||||
Medien einmalig ins Volume kopieren (Volumes müssen existieren;
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# Datenbank-Migrationen
|
||||
|
||||
Manuelle Schritte, die beim Deploy gegen eine **bestehende** Datenbank nötig sind.
|
||||
Eine frische DB (Schema aus `db.go`) braucht keine davon.
|
||||
|
||||
---
|
||||
|
||||
## `avatar`-Spalte in `user`
|
||||
|
||||
**Wann:** beim Deploy der Profilbild-Version gegen eine bestehende `kver.db`.
|
||||
|
||||
**Hintergrund:** Profilbilder werden als Pfad (`static/media/...`) in der neuen Spalte
|
||||
`user.avatar` gespeichert (leerer String = kein Bild, Frontend zeigt dann das
|
||||
Platzhalterbild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
|
||||
vorhandenen Tabelle nicht.
|
||||
|
||||
**Migration:**
|
||||
```sql
|
||||
ALTER TABLE user ADD COLUMN avatar TEXT NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
Bestehende Nutzer haben damit kein Bild (`''`). Nur einmalig ausführen.
|
||||
|
||||
---
|
||||
|
||||
## `filepath` NULL → leerer String
|
||||
|
||||
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
|
||||
|
||||
**Hintergrund:** `scanEntries` scannt `filepath` in ein Go-`string`-Feld (kein Pointer).
|
||||
SQLite NULL in ein `string` scannen schlägt fehl; `scanEntries` überspringt diese Zeilen
|
||||
still mit `continue`. Ergebnis: Einträge ohne Bild fehlen im Feed komplett.
|
||||
Das aktuelle Go-Schema definiert `filepath TEXT NOT NULL DEFAULT ''`. `migrate.sh`
|
||||
normalisiert NULL → `''` beim Kopieren aus der Quelldatenbank.
|
||||
|
||||
```sql
|
||||
UPDATE entry SET filepath = '' WHERE filepath IS NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `filepath`-Sentinel `"none"` → leerer String
|
||||
|
||||
**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der
|
||||
Python-Zeit weiterverwendet wird.
|
||||
|
||||
**Hintergrund:** Die Python-Version schrieb für Beiträge ohne Bild den
|
||||
String `"none"` in `entry.filepath`. Die Go-Version nutzt stattdessen den
|
||||
leeren String (Go-Zero-Value) und prüft nur noch auf `filepath == ""`.
|
||||
Alte `"none"`-Zeilen würden sonst im Feed als Bildpfad interpretiert
|
||||
(`<img src="/none">` → kaputtes Bild).
|
||||
|
||||
**Migration:**
|
||||
```sql
|
||||
UPDATE entry SET filepath = '' WHERE filepath = 'none';
|
||||
```
|
||||
|
||||
Idempotent — kann gefahrlos mehrfach laufen.
|
||||
|
||||
---
|
||||
|
||||
## Threading-Spalten in `entry`
|
||||
|
||||
**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die
|
||||
die Tabelle `entry` noch ohne die Threading-Spalten hat.
|
||||
|
||||
**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root),
|
||||
`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity`
|
||||
(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese
|
||||
Spalten bei einer vorhandenen Tabelle nicht.
|
||||
|
||||
**Migration:**
|
||||
```sql
|
||||
ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE entry ADD COLUMN last_activity INTEGER;
|
||||
UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL;
|
||||
```
|
||||
|
||||
Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`)
|
||||
mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur
|
||||
einmalig ausführen.
|
||||
|
||||
---
|
||||
|
||||
## Soft-Delete-Spalte in `entry`
|
||||
|
||||
**Wann:** beim Deploy der Soft-Delete-Version gegen eine bestehende `kver.db`.
|
||||
|
||||
**Hintergrund:** Statt Beiträge hart zu löschen werden sie als `[deleted]`-Platzhalter
|
||||
markiert (`deleted = 1`, Inhalt/Bild/Autor geleert), damit Antworten nicht verwaisen.
|
||||
Das Frontend und die Feeds erkennen gelöschte Beiträge an dieser Spalte.
|
||||
|
||||
**Migration:**
|
||||
```sql
|
||||
ALTER TABLE entry ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
Bestehende Beiträge bleiben damit sichtbar (`deleted = 0`). Nur einmalig ausführen.
|
||||
|
||||
---
|
||||
|
||||
## `asn`-Spalte in `impression`
|
||||
|
||||
**Wann:** beim Deploy der ASN-Auswertung gegen eine bestehende `impression`-Tabelle.
|
||||
|
||||
**Hintergrund:** Pro Impression wird das autonome System (Netzbetreiber) gespeichert,
|
||||
aufgelöst aus der vollen Client-IP *vor* dem Maskieren (siehe `lookupASN`). Die Spalte
|
||||
liegt bewusst **nicht** im Primärschlüssel — pro `anon_ip` ist das ASN deterministisch,
|
||||
also entstehen keine Zeilen-Duplikate, und ein simples `ADD COLUMN` genügt (kein
|
||||
Tabellen-Rebuild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
|
||||
vorhandenen Tabelle nicht.
|
||||
|
||||
**Migration:**
|
||||
```sql
|
||||
ALTER TABLE impression ADD COLUMN asn TEXT NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
Bestehende Zeilen haben damit kein ASN (`''`, in der Auswertung „(unbekannt)"). Nur
|
||||
einmalig ausführen.
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||
<a href="/stats.html">Statistik</a>
|
||||
<a href="/stats">Statistik</a>
|
||||
</nav>
|
||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||
<div id="sigils" aria-hidden="true">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||
<a href="/stats.html">Statistik</a>
|
||||
<a href="/stats">Statistik</a>
|
||||
</nav>
|
||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||
</header>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||
<a href="/stats.html">Statistik</a>
|
||||
<a href="/stats">Statistik</a>
|
||||
</nav>
|
||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||
</header>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
$(() => {
|
||||
// $('#auth-visible').hide()
|
||||
|
||||
// Startdatum festlegen (Beispiel: 1. Januar 2025, 00:00:00 Uhr)
|
||||
const startDate = new Date('2025-12-13T12:44:00').getTime();
|
||||
|
||||
function updateCounter() {
|
||||
const now = new Date().getTime();
|
||||
const diff = now - startDate;
|
||||
|
||||
// Berechne Tage, Stunden, Minuten
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
// Zeige den Counter an
|
||||
$('#uptime-counter').html(`<strong>${days}</strong> Tage, <strong>${hours}</strong> Stunden, <strong>${minutes}</strong> Minuten und <strong>${seconds}</strong> Sekunden ohne Datenverlust!`);
|
||||
}
|
||||
|
||||
// Aktualisiere den Counter sofort und dann jede Minute
|
||||
updateCounter();
|
||||
setInterval(updateCounter, 1000);
|
||||
|
||||
let agreedToWarning = document.cookie.includes("agreedToWarning=True")
|
||||
if(agreedToWarning) {
|
||||
$("#contentnotice").hide()
|
||||
}
|
||||
})
|
||||
|
||||
function setCookie(cname, cvalue, exdays) {
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + (exdays*24*60*60*1000));
|
||||
let expires = "expires="+ d.toUTCString();
|
||||
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
|
||||
}
|
||||
@@ -220,6 +220,22 @@ func recordImpression(path, anonIP, asn string) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
var users, entries int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
|
||||
Scan(&users, &entries); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"users": users,
|
||||
"entries": entries,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
|
||||
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
||||
// rechnen. Öffentlich, kein Auth.
|
||||
|
||||
@@ -54,13 +54,10 @@ func createUser(username, password string) (int64, error) {
|
||||
return 0, 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 account WHERE username = $1`, username).Scan(&id); err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
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 {
|
||||
@@ -81,22 +78,6 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
var users, entries int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
|
||||
Scan(&users, &entries); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"users": users,
|
||||
"entries": entries,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
@@ -135,7 +116,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
|
||||
if name == current {
|
||||
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
||||
}
|
||||
if idFromUsername(name) != 0 {
|
||||
if usernameTaken(name) {
|
||||
return errUsernameTaken
|
||||
}
|
||||
|
||||
@@ -242,16 +223,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
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,
|
||||
})
|
||||
clearSessionCookie(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user