From cb237c248e4c77811b3b4d899cff3d9b62c68a12 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Mon, 1 Jun 2026 10:17:02 +0200 Subject: [PATCH] Rewrite backend in Go with JSON API and JS frontend Reimplements the Flask app as a Go HTTP API (chi + modernc sqlite) with a minimal vanilla-JS frontend in web/. Endpoints mirror the original Flask routes but return JSON instead of HTML. - auth: login/logout/register/sessioninfo/headerbar with crypto/rand tokens - entry: paginated feed (single JOIN), create with image scaling - user: profile, userinfo; delete still a stub - requireAuth middleware passes uid via context - notes/api.md documents the API and schema Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + auth.go | 198 +++++++++++++++++++++++++++++++++++++++++ db.go | 58 ++++++++++++ entry.go | 169 +++++++++++++++++++++++++++++++++++ go.mod | 22 +++++ go.sum | 57 ++++++++++++ main.go | 63 +++++++++++++ middleware.go | 28 ++++++ notes/api.md | 229 ++++++++++++++++++++++++++++++++++++++++++++++++ user.go | 76 ++++++++++++++++ web/css/app.css | 75 ++++++++++++++++ web/index.html | 45 ++++++++++ web/js/app.js | 139 +++++++++++++++++++++++++++++ 13 files changed, 1160 insertions(+) create mode 100644 auth.go create mode 100644 db.go create mode 100644 entry.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 middleware.go create mode 100644 notes/api.md create mode 100644 user.go create mode 100644 web/css/app.css create mode 100644 web/index.html create mode 100644 web/js/app.js diff --git a/.gitignore b/.gitignore index ea926a6..61106d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv/ kver.db +/kver *.env __pycache__/ static/media/ diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..7d87d77 --- /dev/null +++ b/auth.go @@ -0,0 +1,198 @@ +package main + +import ( + crand "crypto/rand" + "database/sql" + "encoding/hex" + "math/rand/v2" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type Session struct { + UID int64 + CreatedAt int64 + Expires int64 + Description string + Value string +} + +var ( + reNonWord = regexp.MustCompile(`[^._\w\s-]`) + reSpace = regexp.MustCompile(`\s+`) +) + +// getSession liest den session-Cookie und liefert die zugehörige, noch gültige Session. +func getSession(r *http.Request) (*Session, bool) { + c, err := r.Cookie("session") + if err != nil || c.Value == "" { + return nil, false + } + + s := Session{Value: c.Value} + err = db.QueryRow( + `SELECT uid, created_at, expires, description FROM session WHERE value = ?`, + c.Value, + ).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description) + if err != nil { + return nil, false + } + + if s.Expires < time.Now().Unix() { + db.Exec(`DELETE FROM session WHERE value = ?`, c.Value) + return nil, false + } + + return &s, true +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + if _, ok := getSession(r); ok { + writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"}) + return + } + + username := sanitizeUsername(r.FormValue("user")) + if len(username) < 3 { + writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.") + return + } + + var uid int64 + var hash []byte + err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash) + + // Gegen Timing-Angriffe: immer eine kleine, zufällige Verzögerung. + time.Sleep(time.Duration(rand.IntN(2000)) * time.Millisecond) + + if err == sql.ErrNoRows { + writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } + + if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil { + writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.") + return + } + + now := time.Now().Unix() + timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64) + expires := now + timeoutSec + + token, err := newToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden") + return + } + + db.Exec(`DELETE FROM session WHERE expires < ?`, now) + if _, err := db.Exec( + `INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`, + uid, now, expires, "", token, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Session konnte nicht erstellt werden") + return + } + db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid) + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Expires: time.Unix(expires, 0), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + if s, ok := getSession(r); ok { + db.Exec(`DELETE FROM session WHERE value = ?`, s.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func handleNewUser(w http.ResponseWriter, r *http.Request) { + var errs []string + + username := sanitizeUsername(r.FormValue("user")) + if len(username) < 3 { + errs = append(errs, "Der Nutzername ist zu kurz.") + } + if idFromUsername(username) != 0 { + errs = append(errs, "Der Nutzername wird bereits verwendet.") + } + + pass1 := r.FormValue("pass1") + pass2 := r.FormValue("pass2") + if pass1 != pass2 { + errs = append(errs, "Passwort und Passwortbestätigung sind ungleich.") + } + if len(pass1) < 10 { + errs = append(errs, "Das Passwort ist kürzer als 10 Zeichen.") + } + + if len(errs) > 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{"errors": errs}) + return + } + + if err := createUser(username, pass1); err != nil { + writeError(w, http.StatusInternalServerError, "Account konnte nicht erstellt werden") + return + } + writeJSON(w, http.StatusCreated, map[string]string{"username": username}) +} + +func handleSessionInfo(w http.ResponseWriter, r *http.Request) { + s, ok := getSession(r) + if !ok { + writeError(w, http.StatusUnauthorized, "Invalid session") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "uid": s.UID, + "created_at": s.CreatedAt, + "expires": s.Expires, + "description": s.Description, + }) +} + +func handleHeaderbar(w http.ResponseWriter, r *http.Request) { + _, ok := getSession(r) + writeJSON(w, http.StatusOK, map[string]bool{"loggedin": ok}) +} + +func sanitizeUsername(u string) string { + u = strings.TrimSpace(u) + if len(u) > 32 { + u = u[:32] + } + u = reNonWord.ReplaceAllString(u, "") + u = reSpace.ReplaceAllString(u, "_") + return u +} + +func newToken() (string, error) { + b := make([]byte, 32) + if _, err := crand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..6af20ec --- /dev/null +++ b/db.go @@ -0,0 +1,58 @@ +package main + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +var db *sql.DB + +const schema = ` +CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER UNIQUE, + username TEXT UNIQUE, + password BLOB, + created_at INTEGER, + last_login INTEGER +); + +CREATE TABLE IF NOT EXISTS session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER, + value TEXT UNIQUE, + created_at INTEGER, + expires INTEGER, + description TEXT +); + +CREATE TABLE IF NOT EXISTS entry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pid INTEGER UNIQUE, + uid INTEGER, + created_at INTEGER, + content TEXT, + filepath TEXT +); + +CREATE TABLE IF NOT EXISTS vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER, + pid INTEGER, + mode TEXT +); +` + +func initDB() error { + var err error + db, err = sql.Open("sqlite", "kver.db") + if err != nil { + return err + } + if err = db.Ping(); err != nil { + return err + } + _, err = db.Exec(schema) + return err +} diff --git a/entry.go b/entry.go new file mode 100644 index 0000000..f2d5efa --- /dev/null +++ b/entry.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "image" + "image/gif" + "image/jpeg" + _ "image/png" + "math/rand/v2" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + xdraw "golang.org/x/image/draw" +) + +type Entry struct { + PID int64 `json:"pid"` + UID int64 `json:"uid"` + CreatedAt int64 `json:"created_at"` + Content string `json:"content"` + Filepath string `json:"filepath"` + Username string `json:"username"` +} + +func handleFeed(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(chi.URLParam(r, "page")) + if page < 0 { + page = 0 + } + if page > 100 { + page = 100 + } + + const count = 20 + offset := page * count + + rows, err := db.Query(` + SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, u.username + FROM entry e JOIN user u ON u.uid = e.uid + ORDER BY e.created_at DESC + LIMIT ? OFFSET ?`, count, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } + 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.Username); err != nil { + continue + } + entries = append(entries, e) + } + writeJSON(w, http.StatusOK, entries) +} + +func handleCreateEntry(w http.ResponseWriter, r *http.Request) { + uid := uidFromContext(r.Context()) + + if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + content := strings.TrimSpace(r.FormValue("content")) + if len(content) > 1000 { + content = content[:1000] + } + + filepath := "none" + if r.MultipartForm != nil { + if files := r.MultipartForm.File["file"]; len(files) > 0 { + if stored, err := storeImage(files[0]); err == nil && stored != "" { + filepath = stored + } + } + } + + if filepath == "none" && content == "" { + writeError(w, http.StatusBadRequest, "Leerer Beitrag!") + return + } + + pid := int64(rand.IntN(999999999999)) + now := time.Now().Unix() + if _, err := db.Exec( + `INSERT INTO entry (pid, uid, created_at, content, filepath) VALUES (?, ?, ?, ?, ?)`, + pid, uid, now, content, filepath, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "pid": pid, + "content": content, + "filepath": filepath, + }) +} + +func handleInteractions(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotImplemented, "Voting ist noch nicht implementiert") +} + +// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media. +// Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert. +func storeImage(fh *multipart.FileHeader) (string, error) { + const maxSize = 1024 + + src, err := fh.Open() + if err != nil { + return "", err + } + defer src.Close() + + img, format, err := image.Decode(src) + if err != nil { + return "", err + } + + b := img.Bounds() + width, height := b.Dx(), b.Dy() + + nw, nh := width, height + if width > maxSize || height > maxSize { + if width > height { + nw, nh = maxSize, height*maxSize/width + } else { + nw, nh = width*maxSize/height, maxSize + } + } + + ext := "jpg" + if format == "gif" { + ext = "gif" + } + + if err := os.MkdirAll("static/media", 0o755); err != nil { + return "", err + } + name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) + + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil) + + out, err := os.Create(name) + if err != nil { + return "", err + } + defer out.Close() + + if ext == "gif" { + err = gif.Encode(out, dst, nil) + } else { + err = jpeg.Encode(out, dst, &jpeg.Options{Quality: 85}) + } + if err != nil { + os.Remove(name) + return "", err + } + return name, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8b329cb --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module kver + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + golang.org/x/crypto v0.52.0 + golang.org/x/image v0.41.0 + modernc.org/sqlite v1.51.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.45.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df930d1 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/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.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..40f2184 --- /dev/null +++ b/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + if err := initDB(); err != nil { + log.Fatalf("db init: %v", err) + } + defer db.Close() + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // --- Auth (öffentlich) --- + r.Post("/auth/login", handleLogin) + r.Get("/auth/logout", handleLogout) + r.Post("/auth/newuser", handleNewUser) + r.Get("/auth/sessioninfo", handleSessionInfo) + r.Get("/auth/headerbar", handleHeaderbar) + + // --- Entries --- + r.Get("/entry/feed/{page}", handleFeed) + r.Get("/entry/{pid}/interactions/{mode}", handleInteractions) + + // --- User (öffentlich) --- + r.Get("/u/{username}", handleUserPage) + r.Get("/user/delete", handleUserDelete) + + // --- Geschützt (Session erforderlich) --- + r.Group(func(r chi.Router) { + r.Use(requireAuth) + r.Post("/entry/create", handleCreateEntry) + r.Get("/user/info", handleUserInfo) + }) + + // Hochgeladene Medien + alte statische Assets + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Neues JS-Frontend + r.Handle("/*", http.FileServer(http.Dir("web"))) + + const addr = ":8080" + log.Printf("kver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, r)) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..72f47e1 --- /dev/null +++ b/middleware.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "net/http" +) + +type ctxKey string + +const uidKey ctxKey = "uid" + +// requireAuth lehnt Anfragen ohne gültige Session ab und legt die uid in den Context. +func requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, ok := getSession(r) + if !ok { + writeError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + ctx := context.WithValue(r.Context(), uidKey, s.UID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func uidFromContext(ctx context.Context) int64 { + uid, _ := ctx.Value(uidKey).(int64) + return uid +} diff --git a/notes/api.md b/notes/api.md new file mode 100644 index 0000000..7c37f32 --- /dev/null +++ b/notes/api.md @@ -0,0 +1,229 @@ +# kver API Definition + +Abgeleitet aus dem bestehenden Flask-Code. Basis für die Go-Reimplementierung. + +--- + +## Authentifizierung + +Sessions werden als Cookie (`session`) übertragen. Alle geschützten Endpunkte erwarten diesen Cookie. + +--- + +## Auth + +### `POST /auth/login` + +Einloggen. + +**Request (form-encoded):** +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `user` | string | Nutzername | +| `pass` | string | Passwort | +| `timeout` | int | Session-Dauer in Sekunden | + +**Response:** +- `200` + setzt `session`-Cookie → Redirect auf `/` +- `200` + Fehlertext bei falschen Credentials + +--- + +### `GET /auth/login` + +Login-Seite anzeigen (wird in der Go-API nicht benötigt, Frontend übernimmt das). + +--- + +### `POST /auth/newuser` + +Neuen Account registrieren. + +**Request (form-encoded):** +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `user` | string | Nutzername (3–32 Zeichen, `[a-zA-Z0-9._\s-]`) | +| `pass1` | string | Passwort (min. 10 Zeichen) | +| `pass2` | string | Passwort-Bestätigung | + +**Response:** +- `200` bei Erfolg +- `200` + Fehlerliste bei Validierungsfehler + +--- + +### `GET /auth/logout` + +Session beenden und Cookie löschen. + +**Response:** Redirect auf `/` + +--- + +### `GET /auth/headerbar` + +Gibt zurück ob der Nutzer eingeloggt ist (für die UI-Headerleiste). + +**Response:** HTML-Partial (in Go-API: JSON) + +--- + +### `GET /auth/sessioninfo` + +Infos zur aktuellen Session. + +**Auth:** erforderlich + +**Response:** +```json +{ + "uid": 123456, + "created_at": 1700000000, + "expires": 1700086400, + "description": "" +} +``` + +--- + +## Entries (Beiträge) + +### `GET /entry/feed/:page` + +Paginierter Feed aller Beiträge, neueste zuerst. + +**Parameter:** +| Name | Typ | Beschreibung | +|------|-----|--------------| +| `page` | int | Seite (0–100), 20 Einträge pro Seite | + +**Response:** +```json +[ + { + "pid": 123456789, + "created_at": 1700000000, + "uid": 42, + "content": "...", + "filepath": "static/media/...", + "username": "max" + } +] +``` +Leeres Array `[]` wenn keine weiteren Einträge vorhanden. + +--- + +### `POST /entry/create` + +Neuen Beitrag erstellen. + +**Auth:** erforderlich + +**Request (multipart/form-data):** +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `content` | string | Text (max. 1000 Zeichen) | +| `file` | file (optional) | Bild (JPG oder GIF, wird auf max. 1024px skaliert) | + +Mindestens `content` oder `file` muss vorhanden sein. + +**Response:** +- `201` bei Erfolg +- `400` bei leerem Beitrag +- `401` wenn nicht eingeloggt + +--- + +### `GET /entry/:pid/interactions/:mode` + +Abstimmung auf einen Beitrag. (*Aktuell nicht implementiert.*) + +**Parameter:** +| Name | Typ | Beschreibung | +|------|-----|--------------| +| `pid` | int | Post-ID | +| `mode` | string | `left` oder `right` | + +--- + +## User + +### `GET /u/:username` + +Öffentliche Profilseite eines Nutzers. + +**Response:** Nutzerprofil + Beiträge (Details noch offen) + +--- + +### `GET /user/info` + +Eigene Account-Informationen. + +**Auth:** erforderlich + +**Response:** +```json +{ + "uid": 42, + "username": "max", + "created_at": 1700000000 +} +``` + +--- + +### `GET /user/delete` + +Account löschen. (*Noch nicht implementiert.*) + +--- + +## Datenbankschema (SQLite) + +```sql +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER UNIQUE, + username TEXT UNIQUE, + password BLOB, + created_at INTEGER, + last_login INTEGER +); + +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, + uid INTEGER, + created_at INTEGER, + content TEXT, + filepath TEXT +); + +CREATE TABLE vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER, + pid INTEGER, + mode TEXT -- 'left' | 'right' +); +``` + +--- + +## Offene Punkte + +- `/entry/:pid/interactions/:mode` ist auskommentiert — Voting-Logik muss neu designed werden +- `/user/delete` ist noch ein Stub +- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login +- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden +- Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON diff --git a/user.go b/user.go new file mode 100644 index 0000000..4ffe62b --- /dev/null +++ b/user.go @@ -0,0 +1,76 @@ +package main + +import ( + "database/sql" + "math/rand/v2" + "net/http" + "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 +} + +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, + }) +} + +func handleUserDelete(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotImplemented, "Account-Löschung ist noch nicht implementiert") +} diff --git a/web/css/app.css b/web/css/app.css new file mode 100644 index 0000000..4392b3c --- /dev/null +++ b/web/css/app.css @@ -0,0 +1,75 @@ +:root { + --bg: #15171a; + --card: #1e2126; + --fg: #e8e8e8; + --muted: #9aa0a6; + --accent: #4a9eff; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: var(--bg); + color: var(--fg); +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid #2a2e35; + position: sticky; + top: 0; + background: var(--bg); +} + +header h1 { margin: 0; font-size: 1.4rem; } + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem; +} + +section { margin-bottom: 1.5rem; } + +input, textarea, button { + font: inherit; + border-radius: 6px; + border: 1px solid #2a2e35; + background: var(--card); + color: var(--fg); + padding: 0.6rem 0.8rem; +} + +input, textarea { width: 100%; margin-bottom: 0.6rem; } +textarea { min-height: 80px; resize: vertical; } + +button { + cursor: pointer; + background: var(--accent); + border-color: var(--accent); + color: #fff; + width: auto; +} +button:hover { opacity: 0.9; } + +.entry { + background: var(--card); + border: 1px solid #2a2e35; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.entry .meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; } +.entry .content { white-space: pre-wrap; word-wrap: break-word; } +.entry img { max-width: 100%; border-radius: 6px; margin-top: 0.6rem; } + +.entry a { color: var(--accent); } + +.msg { color: #ff6b6b; font-size: 0.9rem; min-height: 1.2em; } +.end { text-align: center; color: var(--muted); padding: 2rem 0; } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..80b9d22 --- /dev/null +++ b/web/index.html @@ -0,0 +1,45 @@ + + + + + + kver + + + +
+

kver

+
+
+ +
+ +
+
+

Login

+ + + + +
+

+
+ + + + + +
+
+
+ + + + diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..272b5a9 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,139 @@ +"use strict"; + +const api = { + async get(url) { + const r = await fetch(url, { credentials: "same-origin" }); + return r.json(); + }, + async post(url, formData) { + const r = await fetch(url, { + method: "POST", + body: formData, + credentials: "same-origin", + }); + return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }; + }, +}; + +// --- Headerbar (eingeloggt?) --- +let loggedIn = false; + +async function refreshHeader() { + const h = await api.get("/auth/headerbar"); + loggedIn = !!h.loggedin; + + const bar = document.getElementById("headerbar"); + document.getElementById("auth-box").hidden = loggedIn; + document.getElementById("compose").hidden = !loggedIn; + + if (loggedIn) { + bar.innerHTML = ``; + document.getElementById("logout-btn").addEventListener("click", async () => { + await api.get("/auth/logout"); + await refreshHeader(); + }); + } else { + bar.innerHTML = ""; + } +} + +// --- Login --- +document.getElementById("login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const res = await api.post("/auth/login", new FormData(e.target)); + const msg = document.getElementById("auth-msg"); + if (res.ok) { + msg.textContent = ""; + await refreshHeader(); + } else { + msg.textContent = res.data.error || "Login fehlgeschlagen."; + } +}); + +// --- Neuer Beitrag --- +document.getElementById("entry-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const res = await api.post("/entry/create", new FormData(e.target)); + const msg = document.getElementById("entry-msg"); + if (res.ok) { + msg.textContent = ""; + e.target.reset(); + resetFeed(); + } else { + msg.textContent = res.data.error || "Beitrag fehlgeschlagen."; + } +}); + +// --- Feed mit Infinite Scroll --- +let page = 0; +let loading = false; +let done = false; + +function renderEntry(e) { + const el = document.createElement("article"); + el.className = "entry"; + + const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); + let media = ""; + if (e.filepath && e.filepath !== "none") { + media = ``; + } + + el.innerHTML = ` +
${escapeHtml(e.username)} · ${when}
+
${linkify(e.content)}
+ ${media} + `; + return el; +} + +async function loadMore() { + if (loading || done) return; + loading = true; + + const entries = await api.get(`/entry/feed/${page}`); + const feed = document.getElementById("feed"); + + if (!entries.length) { + done = true; + const end = document.createElement("p"); + end.className = "end"; + end.textContent = "YOU REACHED THE END!"; + feed.appendChild(end); + } else { + entries.forEach((e) => feed.appendChild(renderEntry(e))); + page++; + } + loading = false; +} + +function resetFeed() { + page = 0; + done = false; + document.getElementById("feed").innerHTML = ""; + loadMore(); +} + +const observer = new IntersectionObserver((items) => { + if (items.some((i) => i.isIntersecting)) loadMore(); +}); +observer.observe(document.getElementById("sentinel")); + +// --- Helpers --- +function escapeHtml(s) { + const d = document.createElement("div"); + d.textContent = s ?? ""; + return d.innerHTML; +} + +function linkify(s) { + const escaped = escapeHtml(s); + const url = /https?:\/\/[^\s<]+/g; + return escaped + .replace(url, (m) => `${m}`) + .replace(/\n/g, "
"); +} + +// --- Start --- +refreshHeader(); +loadMore();