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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
venv/
|
||||
kver.db
|
||||
/kver
|
||||
*.env
|
||||
__pycache__/
|
||||
static/media/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+229
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>kver</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>kver</h1>
|
||||
<div id="headerbar"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Login / Registrierung -->
|
||||
<section id="auth-box">
|
||||
<form id="login-form">
|
||||
<h2>Login</h2>
|
||||
<input name="user" placeholder="Nutzername" autocomplete="username">
|
||||
<input name="pass" type="password" placeholder="Passwort" autocomplete="current-password">
|
||||
<input name="timeout" type="hidden" value="86400">
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
<p class="msg" id="auth-msg"></p>
|
||||
</section>
|
||||
|
||||
<!-- Neuer Beitrag -->
|
||||
<section id="compose" hidden>
|
||||
<form id="entry-form">
|
||||
<textarea name="content" maxlength="1000" placeholder="Was gibt's Neues?"></textarea>
|
||||
<input name="file" type="file" accept="image/*">
|
||||
<button type="submit">Posten</button>
|
||||
</form>
|
||||
<p class="msg" id="entry-msg"></p>
|
||||
</section>
|
||||
|
||||
<!-- Feed -->
|
||||
<section id="feed"></section>
|
||||
<div id="sentinel"></div>
|
||||
</main>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+139
@@ -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 = `<button id="logout-btn">Logout</button>`;
|
||||
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 = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="meta"><strong>${escapeHtml(e.username)}</strong> · ${when}</div>
|
||||
<div class="content">${linkify(e.content)}</div>
|
||||
${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) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
// --- Start ---
|
||||
refreshHeader();
|
||||
loadMore();
|
||||
Reference in New Issue
Block a user