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 @@ + + +
+ + +