diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9d2eff --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Development fallback: local nickname login +AUTH_MODE=local + +# Pocket ID / OIDC login +# AUTH_MODE=oidc +# OIDC_ISSUER=https://id.example.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URL=http://localhost:8080/auth/callback +# OIDC_SCOPES=openid profile email + +# Generate with: openssl rand -base64 32 +SESSION_SECRET=replace-with-a-random-32-byte-secret diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9855dc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/data/ +*.tmp +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6def50b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS build +WORKDIR /app +COPY go.mod ./ +COPY . . +RUN go build -o /chat ./cmd/server + +FROM alpine:3.22 +WORKDIR /app +COPY --from=build /chat /usr/local/bin/chat +COPY templates ./templates +COPY static ./static +EXPOSE 8080 +CMD ["chat", "-addr", ":8080", "-db", "data/chat.json"] diff --git a/README.md b/README.md index e76b9ae..5fd7412 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ -# chat +# Go HTMX Chat +Ein kleiner webbasierter Chat-Server mit Go, HTMX, Server-Sent Events, Datei-Persistenz und optionalem Pocket-ID/OIDC-Login. + +## Features + +- mehrere Chaträume +- neue Räume über das UI erstellen +- lokaler Entwicklungslogin oder echter Login via Pocket ID / OpenID Connect +- signierte HttpOnly-Session-Cookies +- Nachrichten senden per HTMX POST +- Live-Updates per Server-Sent Events +- persistente Speicherung in `data/chat.json` +- keine Node-/Frontend-Build-Toolchain +- responsives Dark-UI + +## Start im lokalen Entwicklungsmodus + +```bash +go run ./cmd/server +``` + +Dann öffnen: + +```text +http://localhost:8080 +``` + +Optional: + +```bash +go run ./cmd/server -addr :3000 -db data/dev-chat.json +``` + +## Pocket ID / OIDC Login aktivieren + +Lege in Pocket ID einen OIDC Client an und trage als Callback URL ein: + +```text +http://localhost:8080/auth/callback +``` + +Für produktiven Betrieb mit Reverse Proxy entsprechend: + +```text +https://chat.example.com/auth/callback +``` + +Dann den Chat mit diesen Umgebungsvariablen starten: + +```bash +export AUTH_MODE=oidc +export OIDC_ISSUER=https://id.example.com +export OIDC_CLIENT_ID= +export OIDC_CLIENT_SECRET= +export OIDC_REDIRECT_URL=http://localhost:8080/auth/callback +export OIDC_SCOPES="openid profile email" +export SESSION_SECRET=$(openssl rand -base64 32) + +go run ./cmd/server +``` + +Hinweise: + +- `OIDC_ISSUER` ist die Basis-URL deiner Pocket-ID-Instanz, zum Beispiel `https://id.example.com`. +- Die App liest automatisch `/.well-known/openid-configuration` und verwendet daraus Authorization-, Token- und UserInfo-Endpunkte. +- Als Anzeigename wird bevorzugt `preferred_username` genutzt, danach `name`, `email` und zuletzt `sub`. +- Hinter einem HTTPS-Reverse-Proxy sollte `X-Forwarded-Proto: https` gesetzt werden, damit Cookies als `Secure` markiert werden. + +## Projektstruktur + +```text +cmd/server/main.go Einstiegspunkt +internal/chat/hub.go SSE-Broadcast pro Raum +internal/store/store.go einfache JSON-Persistenz +internal/web/auth.go lokaler Login + OIDC/OAuth2 Flow +templates/ HTML Templates +static/app.css Styling +``` + +## Hinweise zur Sicherheit + +Diese Version ist gut als Basis für einen privaten oder internen Chat geeignet. Für öffentlich produktiven Betrieb wären zusätzlich sinnvoll: + +- CSRF-Schutz für schreibende POST-Routen +- Rate-Limits für Login, Raum-Erstellung und Nachrichten +- SQLite oder PostgreSQL als Store +- Rollen, Moderation und Admin-Funktionen +- vollständige ID-Token/JWKS-Prüfung mit einer OIDC-Library +- sichere Reverse-Proxy-Konfiguration mit HTTPS diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..b42f159 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "errors" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "go-htmx-chat/internal/chat" + "go-htmx-chat/internal/store" + "go-htmx-chat/internal/web" +) + +func main() { + addr := flag.String("addr", ":8080", "HTTP listen address") + dbPath := flag.String("db", "data/chat.json", "JSON data file path") + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + st, err := store.Open(*dbPath) + if err != nil { + logger.Error("open store", "error", err) + os.Exit(1) + } + defer st.Close() + + authCfg, err := web.AuthConfigFromEnv(logger) + if err != nil { + logger.Error("auth config", "error", err) + os.Exit(1) + } + + srv, err := web.NewServer(st, chat.NewHub(), logger, authCfg) + if err != nil { + logger.Error("new server", "error", err) + os.Exit(1) + } + + httpServer := &http.Server{ + Addr: *addr, + Handler: srv.Routes(), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + logger.Info("listening", "addr", *addr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("listen", "error", err) + os.Exit(1) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(ctx) + logger.Info("server stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..062f990 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go-htmx-chat + +go 1.22 diff --git a/internal/chat/hub.go b/internal/chat/hub.go new file mode 100644 index 0000000..817ea2e --- /dev/null +++ b/internal/chat/hub.go @@ -0,0 +1,54 @@ +package chat + +import ( + "sync" + + "go-htmx-chat/internal/store" +) + +type Subscriber chan store.Message + +type Hub struct { + mu sync.RWMutex + rooms map[int64]map[Subscriber]struct{} +} + +func NewHub() *Hub { + return &Hub{rooms: make(map[int64]map[Subscriber]struct{})} +} + +func (h *Hub) Subscribe(roomID int64) Subscriber { + ch := make(Subscriber, 16) + h.mu.Lock() + if h.rooms[roomID] == nil { + h.rooms[roomID] = make(map[Subscriber]struct{}) + } + h.rooms[roomID][ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *Hub) Unsubscribe(roomID int64, ch Subscriber) { + h.mu.Lock() + if subs := h.rooms[roomID]; subs != nil { + delete(subs, ch) + close(ch) + if len(subs) == 0 { + delete(h.rooms, roomID) + } + } + h.mu.Unlock() +} + +func (h *Hub) Publish(msg store.Message) { + h.mu.RLock() + subs := h.rooms[msg.RoomID] + for sub := range subs { + select { + case sub <- msg: + default: + // Drop for slow clients. They will still see history after refresh. + } + } + h.mu.RUnlock() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1ac959f --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,187 @@ +package store + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +var ErrNotFound = errors.New("not found") + +type Room struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + RoomID int64 `json:"room_id"` + Username string `json:"username"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` +} + +type Store struct { + mu sync.RWMutex + path string + nextRoomID int64 + nextMessageID int64 + rooms []Room + messages []Message +} + +type diskData struct { + NextRoomID int64 `json:"next_room_id"` + NextMessageID int64 `json:"next_message_id"` + Rooms []Room `json:"rooms"` + Messages []Message `json:"messages"` +} + +func Open(path string) (*Store, error) { + s := &Store{path: path, nextRoomID: 1, nextMessageID: 1} + if err := s.load(); err != nil { + return nil, err + } + if len(s.rooms) == 0 { + now := time.Now().UTC() + s.rooms = []Room{ + {ID: s.nextRoomID, Name: "Lobby", Description: "Allgemeiner Chat für alle", CreatedAt: now}, + {ID: s.nextRoomID + 1, Name: "Go", Description: "Golang, Backend und Deployment", CreatedAt: now}, + {ID: s.nextRoomID + 2, Name: "Random", Description: "Alles, was sonst nirgends passt", CreatedAt: now}, + } + s.nextRoomID += 3 + if err := s.saveLocked(); err != nil { + return nil, err + } + } + return s, nil +} + +func (s *Store) Close() error { return nil } + +func (s *Store) load() error { + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var d diskData + if err := json.Unmarshal(b, &d); err != nil { + return err + } + s.nextRoomID = maxInt64(d.NextRoomID, 1) + s.nextMessageID = maxInt64(d.NextMessageID, 1) + s.rooms = d.Rooms + s.messages = d.Messages + return nil +} + +func (s *Store) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil && filepath.Dir(s.path) != "." { + return err + } + d := diskData{NextRoomID: s.nextRoomID, NextMessageID: s.nextMessageID, Rooms: s.rooms, Messages: s.messages} + b, err := json.MarshalIndent(d, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func (s *Store) Rooms() ([]Room, error) { + s.mu.RLock() + defer s.mu.RUnlock() + rooms := append([]Room(nil), s.rooms...) + sort.Slice(rooms, func(i, j int) bool { return rooms[i].Name < rooms[j].Name }) + return rooms, nil +} + +func (s *Store) CreateRoom(name, description string) (Room, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.rooms { + if r.Name == name { + return Room{}, errors.New("room exists") + } + } + r := Room{ID: s.nextRoomID, Name: name, Description: description, CreatedAt: time.Now().UTC()} + s.nextRoomID++ + s.rooms = append(s.rooms, r) + if err := s.saveLocked(); err != nil { + return Room{}, err + } + return r, nil +} + +func (s *Store) Room(id int64) (Room, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.rooms { + if r.ID == id { + return r, nil + } + } + return Room{}, ErrNotFound +} + +func (s *Store) RecentMessages(roomID int64, limit int) ([]Message, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var msgs []Message + for _, m := range s.messages { + if m.RoomID == roomID { + msgs = append(msgs, m) + } + } + sort.Slice(msgs, func(i, j int) bool { + if msgs[i].CreatedAt.Equal(msgs[j].CreatedAt) { + return msgs[i].ID < msgs[j].ID + } + return msgs[i].CreatedAt.Before(msgs[j].CreatedAt) + }) + if limit > 0 && len(msgs) > limit { + msgs = msgs[len(msgs)-limit:] + } + return append([]Message(nil), msgs...), nil +} + +func (s *Store) AddMessage(roomID int64, username, body string) (Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + found := false + for _, r := range s.rooms { + if r.ID == roomID { + found = true + break + } + } + if !found { + return Message{}, ErrNotFound + } + m := Message{ID: s.nextMessageID, RoomID: roomID, Username: username, Body: body, CreatedAt: time.Now().UTC()} + s.nextMessageID++ + s.messages = append(s.messages, m) + if err := s.saveLocked(); err != nil { + return Message{}, err + } + return m, nil +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..533c28d --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,358 @@ +package web + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type AuthMode string + +const ( + AuthModeLocal AuthMode = "local" + AuthModeOIDC AuthMode = "oidc" +) + +type AuthConfig struct { + Mode AuthMode + Issuer string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string + SessionSecret []byte +} + +type oidcDiscovery struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + Issuer string `json:"issuer"` +} + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` +} + +type userClaims struct { + Subject string `json:"sub"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + Email string `json:"email"` +} + +type sessionData struct { + Username string `json:"username"` + Subject string `json:"sub,omitempty"` + Email string `json:"email,omitempty"` + Expires int64 `json:"exp"` +} + +func AuthConfigFromEnv(logger *slog.Logger) (AuthConfig, error) { + mode := AuthMode(strings.ToLower(strings.TrimSpace(getenv("AUTH_MODE", "local")))) + if mode != AuthModeLocal && mode != AuthModeOIDC { + return AuthConfig{}, fmt.Errorf("unsupported AUTH_MODE %q", mode) + } + + secret := strings.TrimSpace(os.Getenv("SESSION_SECRET")) + var secretBytes []byte + if secret == "" { + secretBytes = make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + return AuthConfig{}, err + } + logger.Warn("SESSION_SECRET is not set; generated a temporary secret. Existing sessions will be invalid after restart") + } else { + decoded, err := base64.StdEncoding.DecodeString(secret) + if err == nil && len(decoded) >= 32 { + secretBytes = decoded + } else { + secretBytes = []byte(secret) + } + if len(secretBytes) < 32 { + return AuthConfig{}, errors.New("SESSION_SECRET must be at least 32 bytes or a base64 encoded 32 byte value") + } + } + + cfg := AuthConfig{ + Mode: mode, + Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OIDC_ISSUER")), "/"), + ClientID: strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")), + ClientSecret: strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")), + RedirectURL: strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")), + Scopes: splitScopes(getenv("OIDC_SCOPES", "openid profile email")), + SessionSecret: secretBytes, + } + if cfg.Mode == AuthModeOIDC { + if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { + return AuthConfig{}, errors.New("AUTH_MODE=oidc requires OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL") + } + } + return cfg, nil +} + +func (s *Server) discoverOIDC() error { + if s.auth.Mode != AuthModeOIDC { + return nil + } + discoveryURL := s.auth.Issuer + "/.well-known/openid-configuration" + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("OIDC discovery failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("OIDC discovery returned %s", resp.Status) + } + var d oidcDiscovery + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil { + return err + } + if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.UserInfoEndpoint == "" { + return errors.New("OIDC discovery response is missing required endpoints") + } + s.oidc = d + return nil +} + +func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { + if s.auth.Mode != AuthModeOIDC { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + state, err := randomString(32) + if err != nil { + http.Error(w, "could not create login state", http.StatusInternalServerError) + return + } + setCookie(w, &http.Cookie{Name: "oauth_state", Value: state, MaxAge: 10 * 60, Path: "/auth", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)}) + + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", s.auth.ClientID) + q.Set("redirect_uri", s.auth.RedirectURL) + q.Set("scope", strings.Join(s.auth.Scopes, " ")) + q.Set("state", state) + authURL := s.oidc.AuthorizationEndpoint + "?" + q.Encode() + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if s.auth.Mode != AuthModeOIDC { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if errText := r.URL.Query().Get("error"); errText != "" { + s.renderStatus(w, r, http.StatusUnauthorized, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode, "Error": "Login abgebrochen: " + errText}) + return + } + stateCookie, err := r.Cookie("oauth_state") + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "invalid oauth state", http.StatusBadRequest) + return + } + clearCookie(w, "oauth_state", "/auth") + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + tok, err := s.exchangeCode(r.Context(), code) + if err != nil { + s.logger.Error("oauth token exchange", "error", err) + http.Error(w, "token exchange failed", http.StatusBadGateway) + return + } + claims, err := s.fetchUserInfo(r.Context(), tok.AccessToken) + if err != nil { + s.logger.Error("oauth userinfo", "error", err) + http.Error(w, "could not fetch user info", http.StatusBadGateway) + return + } + username := normalizeUsername(claims) + if username == "" { + http.Error(w, "identity provider did not return a usable username", http.StatusBadGateway) + return + } + if err := s.setSession(w, r, sessionData{Username: username, Subject: claims.Subject, Email: claims.Email, Expires: time.Now().Add(30 * 24 * time.Hour).Unix()}); err != nil { + http.Error(w, "could not create session", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/rooms", http.StatusSeeOther) +} + +func (s *Server) exchangeCode(ctx context.Context, code string) (oauthTokenResponse, error) { + // Keep the public signature simple for Go 1.22 without adding external OAuth dependencies. + values := url.Values{} + values.Set("grant_type", "authorization_code") + values.Set("code", code) + values.Set("redirect_uri", s.auth.RedirectURL) + values.Set("client_id", s.auth.ClientID) + values.Set("client_secret", s.auth.ClientSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.oidc.TokenEndpoint, strings.NewReader(values.Encode())) + if err != nil { + return oauthTokenResponse{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return oauthTokenResponse{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return oauthTokenResponse{}, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return oauthTokenResponse{}, fmt.Errorf("token endpoint returned %s: %s", resp.Status, string(body)) + } + var tok oauthTokenResponse + if err := json.Unmarshal(body, &tok); err != nil { + return oauthTokenResponse{}, err + } + if tok.AccessToken == "" { + return oauthTokenResponse{}, errors.New("token endpoint did not return access_token") + } + return tok, nil +} + +func (s *Server) fetchUserInfo(ctx context.Context, accessToken string) (userClaims, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.oidc.UserInfoEndpoint, nil) + if err != nil { + return userClaims{}, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return userClaims{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return userClaims{}, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return userClaims{}, fmt.Errorf("userinfo endpoint returned %s: %s", resp.Status, string(body)) + } + var claims userClaims + if err := json.Unmarshal(body, &claims); err != nil { + return userClaims{}, err + } + return claims, nil +} + +func (s *Server) setSession(w http.ResponseWriter, r *http.Request, data sessionData) error { + b, err := json.Marshal(data) + if err != nil { + return err + } + payload := base64.RawURLEncoding.EncodeToString(b) + sig := sign(payload, s.auth.SessionSecret) + setCookie(w, &http.Cookie{Name: "chat_session", Value: payload + "." + sig, Path: "/", MaxAge: 60 * 60 * 24 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)}) + return nil +} + +func (s *Server) readSession(r *http.Request) (sessionData, bool) { + c, err := r.Cookie("chat_session") + if err != nil || c.Value == "" { + return sessionData{}, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 2 || !verify(parts[0], parts[1], s.auth.SessionSecret) { + return sessionData{}, false + } + b, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return sessionData{}, false + } + var data sessionData + if err := json.Unmarshal(b, &data); err != nil { + return sessionData{}, false + } + if data.Expires < time.Now().Unix() || strings.TrimSpace(data.Username) == "" { + return sessionData{}, false + } + return data, true +} + +func sign(payload string, secret []byte) string { + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func verify(payload, got string, secret []byte) bool { + expected := sign(payload, secret) + return hmac.Equal([]byte(expected), []byte(got)) +} + +func randomString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func normalizeUsername(c userClaims) string { + for _, v := range []string{c.PreferredUsername, c.Name, c.Email, c.Subject} { + v = strings.TrimSpace(v) + if v != "" { + if strings.Contains(v, "@") { + v = strings.Split(v, "@")[0] + } + if len(v) > 40 { + v = v[:40] + } + return v + } + } + return "" +} + +func splitScopes(s string) []string { + fields := strings.Fields(s) + if len(fields) == 0 { + return []string{"openid", "profile", "email"} + } + return fields +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func setCookie(w http.ResponseWriter, c *http.Cookie) { http.SetCookie(w, c) } + +func clearCookie(w http.ResponseWriter, name, path string) { + http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: path, MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) +} + +func isSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..c022192 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,318 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "go-htmx-chat/internal/chat" + "go-htmx-chat/internal/store" +) + +type Server struct { + store *store.Store + hub *chat.Hub + templates *template.Template + logger *slog.Logger + auth AuthConfig + oidc oidcDiscovery +} + +func NewServer(st *store.Store, hub *chat.Hub, logger *slog.Logger, auth AuthConfig) (*Server, error) { + funcs := template.FuncMap{ + "formatTime": func(t time.Time) string { return t.Format("15:04") }, + "initial": func(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "?" + } + for _, r := range s { + return strings.ToUpper(string(r)) + } + return "?" + }, + } + tmpl, err := template.New("base").Funcs(funcs).ParseGlob("templates/**/*.html") + if err != nil { + return nil, err + } + tmpl, err = tmpl.ParseGlob("templates/*.html") + if err != nil { + return nil, err + } + + srv := &Server{store: st, hub: hub, templates: tmpl, logger: logger, auth: auth} + if err := srv.discoverOIDC(); err != nil { + return nil, err + } + return srv, nil +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + mux.HandleFunc("GET /", s.handleHome) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("GET /auth/login", s.handleOIDCLogin) + mux.HandleFunc("GET /auth/callback", s.handleOIDCCallback) + mux.HandleFunc("POST /logout", s.handleLogout) + mux.HandleFunc("GET /rooms", s.handleRooms) + mux.HandleFunc("POST /rooms", s.handleCreateRoom) + mux.HandleFunc("GET /rooms/{id}", s.handleRoom) + mux.HandleFunc("POST /rooms/{id}/messages", s.handlePostMessage) + mux.HandleFunc("GET /rooms/{id}/events", s.handleEvents) + return s.recover(nextSecurityHeaders(mux)) +} + +func nextSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) recover(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + s.logger.Error("panic", "error", rec) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + username := s.currentUser(r) + if username == "" { + s.render(w, r, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode}) + return + } + http.Redirect(w, r, "/rooms", http.StatusSeeOther) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if s.auth.Mode == AuthModeOIDC { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + username := strings.TrimSpace(r.FormValue("username")) + if len(username) < 2 || len(username) > 24 { + s.renderStatus(w, r, http.StatusBadRequest, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode, "Error": "Bitte nutze einen Namen mit 2 bis 24 Zeichen."}) + return + } + cookie := &http.Cookie{ + Name: "chat_user", + Value: username, + Path: "/", + MaxAge: 60 * 60 * 24 * 30, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + http.SetCookie(w, cookie) + w.Header().Set("HX-Redirect", "/rooms") + http.Redirect(w, r, "/rooms", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "chat_user", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + clearCookie(w, "chat_session", "/") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleRooms(w http.ResponseWriter, r *http.Request) { + username := s.requireUser(w, r) + if username == "" { + return + } + rooms, err := s.store.Rooms() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + s.render(w, r, "rooms.html", map[string]any{"Title": "Räume", "Username": username, "Rooms": rooms}) +} + +func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { + username := s.requireUser(w, r) + if username == "" { + return + } + name := strings.TrimSpace(r.FormValue("name")) + description := strings.TrimSpace(r.FormValue("description")) + if len(name) < 2 || len(name) > 40 { + http.Error(w, "Raumname muss 2 bis 40 Zeichen haben.", http.StatusBadRequest) + return + } + room, err := s.store.CreateRoom(name, description) + if err != nil { + http.Error(w, "Raum konnte nicht erstellt werden. Existiert er schon?", http.StatusBadRequest) + return + } + w.Header().Set("HX-Redirect", fmt.Sprintf("/rooms/%d", room.ID)) + http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) +} + +func (s *Server) handleRoom(w http.ResponseWriter, r *http.Request) { + username := s.requireUser(w, r) + if username == "" { + return + } + roomID, ok := parseID(w, r) + if !ok { + return + } + room, err := s.store.Room(roomID) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + http.Error(w, err.Error(), 500) + return + } + messages, err := s.store.RecentMessages(roomID, 100) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + s.render(w, r, "room.html", map[string]any{"Title": room.Name, "Username": username, "Room": room, "Messages": messages}) +} + +func (s *Server) handlePostMessage(w http.ResponseWriter, r *http.Request) { + username := s.requireUser(w, r) + if username == "" { + return + } + roomID, ok := parseID(w, r) + if !ok { + return + } + body := strings.TrimSpace(r.FormValue("body")) + if body == "" { + w.WriteHeader(http.StatusNoContent) + return + } + if len(body) > 2000 { + http.Error(w, "Nachricht ist zu lang.", http.StatusBadRequest) + return + } + msg, err := s.store.AddMessage(roomID, username, body) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + s.hub.Publish(msg) + + // Clear the form without duplicating the message; the SSE stream appends it. + w.Header().Set("HX-Trigger", "message-sent") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + username := s.requireUser(w, r) + if username == "" { + return + } + roomID, ok := parseID(w, r) + if !ok { + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + sub := s.hub.Subscribe(roomID) + defer s.hub.Unsubscribe(roomID, sub) + + fmt.Fprint(w, ": connected\n\n") + flusher.Flush() + + heartbeat := time.NewTicker(25 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-heartbeat.C: + fmt.Fprint(w, ": heartbeat\n\n") + flusher.Flush() + case msg := <-sub: + html, err := s.renderPartial(r.Context(), "partials/message.html", msg) + if err != nil { + s.logger.Error("render partial", "error", err) + continue + } + payload := map[string]string{"html": html} + b, _ := json.Marshal(payload) + fmt.Fprintf(w, "event: message\ndata: %s\n\n", b) + flusher.Flush() + } + } +} + +func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return 0, false + } + return id, true +} + +func (s *Server) currentUser(r *http.Request) string { + if sess, ok := s.readSession(r); ok { + return strings.TrimSpace(sess.Username) + } + if s.auth.Mode == AuthModeLocal { + c, err := r.Cookie("chat_user") + if err != nil { + return "" + } + return strings.TrimSpace(c.Value) + } + return "" +} + +func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) string { + username := s.currentUser(r) + if username == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return "" + } + return username +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { + s.renderStatus(w, r, http.StatusOK, name, data) +} + +func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + s.logger.Error("template", "name", name, "error", err) + } +} + +func (s *Server) renderPartial(_ context.Context, name string, data any) (string, error) { + var buf bytes.Buffer + err := s.templates.ExecuteTemplate(&buf, name, data) + return buf.String(), err +} diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..30903d8 --- /dev/null +++ b/static/app.css @@ -0,0 +1,122 @@ +:root { + color-scheme: dark; + --bg: #0f172a; + --panel: rgba(15, 23, 42, .78); + --panel-2: rgba(30, 41, 59, .86); + --text: #e5e7eb; + --muted: #94a3b8; + --line: rgba(148, 163, 184, .22); + --accent: #38bdf8; + --accent-2: #a78bfa; + --danger: #fb7185; +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: + radial-gradient(circle at top left, rgba(56, 189, 248, .18), transparent 34rem), + radial-gradient(circle at bottom right, rgba(167, 139, 250, .16), transparent 30rem), + var(--bg); + color: var(--text); +} + +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button { + border: 0; + border-radius: 999px; + padding: .8rem 1.1rem; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #020617; + font-weight: 800; + cursor: pointer; +} +button:hover { filter: brightness(1.08); } +button.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--line); +} +input { + width: 100%; + border: 1px solid var(--line); + border-radius: 1rem; + background: rgba(2, 6, 23, .72); + color: var(--text); + padding: .9rem 1rem; + outline: none; +} +input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(56,189,248,.12); } +label { display: grid; gap: .45rem; color: var(--muted); font-size: .95rem; } + +.shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; } +.topbar { display:flex; align-items:center; justify-content:space-between; padding: 1.2rem 0; } +.topbar form { display:flex; gap:.7rem; align-items:center; } +.brand { font-size: 1.15rem; font-weight: 900; letter-spacing: -.02em; } +.user-pill { padding:.45rem .75rem; border:1px solid var(--line); border-radius:999px; color:var(--muted); } +.card, .room-card { + border: 1px solid var(--line); + border-radius: 1.5rem; + background: var(--panel); + box-shadow: 0 24px 80px rgba(2,6,23,.28); + backdrop-filter: blur(16px); +} +.center-card { min-height: calc(100vh - 6rem); display:grid; place-items:center; } +.login-card { width:min(460px, 100%); padding:2rem; } +.stack { display:grid; gap:1rem; } +.eyebrow { margin:0 0 .35rem; color:var(--accent); text-transform:uppercase; font-size:.75rem; letter-spacing:.14em; font-weight:900; } +h1 { margin:.1rem 0 .8rem; font-size: clamp(2rem, 5vw, 4rem); line-height:.95; letter-spacing:-.06em; } +h2 { margin:.2rem 0 .5rem; } +.muted { color:var(--muted); line-height:1.55; } +.error { border:1px solid rgba(251,113,133,.35); background:rgba(251,113,133,.10); color:#fecdd3; padding:.8rem 1rem; border-radius:1rem; margin:1rem 0; } +.grid-page { display:grid; gap:1rem; padding: 1rem 0 3rem; } +.hero { padding:2rem; } +.room-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; } +.room-card { padding:1.3rem; transition: transform .16s ease, border-color .16s ease; } +.room-card:hover { transform: translateY(-2px); border-color: rgba(56,189,248,.6); } +.room-card p { color:var(--muted); } +.two-col-form { grid-template-columns: 1fr 1fr auto; align-items:end; } + +.chat-page { display:grid; grid-template-columns: 310px 1fr; gap:1rem; min-height: calc(100vh - 6rem); padding-bottom:1rem; } +.sidebar { padding:1.3rem; align-self:start; position:sticky; top:1rem; } +.back { color:var(--accent); display:inline-block; margin-bottom:1.3rem; } +.hint { margin-top:1rem; color:var(--muted); border:1px solid var(--line); border-radius:1rem; padding:.85rem; background:rgba(2,6,23,.32); } +.chat-card { display:grid; grid-template-rows: 1fr auto; overflow:hidden; min-height: 72vh; } +.messages { overflow:auto; padding:1rem; display:flex; flex-direction:column; gap:.85rem; } +.message { display:flex; gap:.75rem; align-items:flex-start; } +.avatar { flex:0 0 2.35rem; width:2.35rem; height:2.35rem; border-radius:999px; display:grid; place-items:center; background:linear-gradient(135deg, var(--accent), var(--accent-2)); color:#020617; font-weight:900; text-transform:uppercase; } +.bubble { max-width:min(760px, 100%); padding:.75rem .9rem; border-radius:1.1rem; background:var(--panel-2); border:1px solid var(--line); } +.meta { display:flex; gap:.7rem; align-items:center; color:var(--muted); font-size:.86rem; } +.meta strong { color:var(--text); } +.bubble p { margin:.35rem 0 0; white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.45; } +.message-form { display:grid; grid-template-columns:1fr auto; gap:.75rem; padding:1rem; border-top:1px solid var(--line); background:rgba(2,6,23,.42); } +.empty { color:var(--muted); text-align:center; margin:auto; padding:2rem; } + +@media (max-width: 820px) { + .chat-page, .two-col-form { grid-template-columns: 1fr; } + .sidebar { position:static; } + .message-form { grid-template-columns: 1fr; } + .topbar { gap:1rem; align-items:flex-start; } + .topbar form { flex-wrap:wrap; justify-content:flex-end; } +} +.button-link { + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 999px; + padding: .9rem 1.15rem; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #020617; + font-weight: 900; + margin-top: 1rem; +} +code { + border: 1px solid var(--line); + border-radius: .45rem; + padding: .08rem .35rem; + background: rgba(2, 6, 23, .55); + color: var(--text); +} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..46b5d58 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,36 @@ + + + + + + {{.Title}} · SEND.NRW Chat + + + + +
+
Go HTMX Chat
+
+ +
+
+ + diff --git a/templates/partials/message.html b/templates/partials/message.html new file mode 100644 index 0000000..df3b92d --- /dev/null +++ b/templates/partials/message.html @@ -0,0 +1,9 @@ +{{define "partials/message.html"}} +
+
{{initial .Username}}
+
+
{{.Username}}
+

{{.Body}}

+
+
+{{end}} diff --git a/templates/room.html b/templates/room.html new file mode 100644 index 0000000..d59b95b --- /dev/null +++ b/templates/room.html @@ -0,0 +1,63 @@ + + + + + + {{.Title}} · SEND.NRW Chat + + + + +
+
+ SEND.NRW Chat +
+ {{.Username}} + +
+
+ +
+ + +
+
+ {{range .Messages}} + {{template "partials/message.html" .}} + {{else}} +
Noch keine Nachrichten. Schreib die erste.
+ {{end}} +
+ +
+ + +
+
+
+
+ + + + diff --git a/templates/rooms.html b/templates/rooms.html new file mode 100644 index 0000000..4c0661d --- /dev/null +++ b/templates/rooms.html @@ -0,0 +1,48 @@ + + + + + + {{.Title}} · SEND.NRW Chat + + + + +
+
+ SEND.NRW Chat +
+ {{.Username}} + +
+
+
+
+
+

Räume

+

Wähle einen Chatraum

+

Nachrichten werden persistent gespeichert und live per Server-Sent Events verteilt.

+
+
+ +
+ {{range .Rooms}} + +

{{.Name}}

+

{{.Description}}

+
+ {{end}} +
+ +
+

Neuen Raum erstellen

+
+ + + +
+
+
+
+ +