This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -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
|
||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -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 }}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/data/
|
||||
*.tmp
|
||||
.DS_Store
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -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"]
|
||||
90
README.md
90
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=<client-id-aus-pocket-id>
|
||||
export OIDC_CLIENT_SECRET=<client-secret-aus-pocket-id>
|
||||
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
|
||||
|
||||
67
cmd/server/main.go
Normal file
67
cmd/server/main.go
Normal file
@@ -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")
|
||||
}
|
||||
54
internal/chat/hub.go
Normal file
54
internal/chat/hub.go
Normal file
@@ -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()
|
||||
}
|
||||
187
internal/store/store.go
Normal file
187
internal/store/store.go
Normal file
@@ -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
|
||||
}
|
||||
358
internal/web/auth.go
Normal file
358
internal/web/auth.go
Normal file
@@ -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")
|
||||
}
|
||||
318
internal/web/server.go
Normal file
318
internal/web/server.go
Normal file
@@ -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
|
||||
}
|
||||
122
static/app.css
Normal file
122
static/app.css
Normal file
@@ -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);
|
||||
}
|
||||
36
templates/login.html
Normal file
36
templates/login.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · SEND.NRW Chat</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar"><a class="brand" href="/rooms">Go HTMX Chat</a></header>
|
||||
<main class="center-card">
|
||||
<section class="card login-card">
|
||||
<p class="eyebrow">Willkommen</p>
|
||||
<h1>Einloggen</h1>
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if eq .AuthMode "oidc"}}
|
||||
<p class="muted">Melde dich mit deinem Pocket-ID-Konto an. Die App nutzt OpenID Connect/OAuth2 und speichert nur eine signierte lokale Session.</p>
|
||||
<a class="button-link" href="/auth/login">Mit Pocket ID anmelden</a>
|
||||
{{else}}
|
||||
<p class="muted">Lokaler Entwicklungsmodus: Wähle nur einen Anzeigenamen. Für Pocket ID setze <code>AUTH_MODE=oidc</code>.</p>
|
||||
<form hx-post="/login" hx-target="body" method="post" class="stack">
|
||||
<label>
|
||||
Anzeigename
|
||||
<input name="username" autocomplete="nickname" placeholder="z. B. Jan" autofocus required minlength="2" maxlength="24">
|
||||
</label>
|
||||
<button type="submit">Chat starten</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
9
templates/partials/message.html
Normal file
9
templates/partials/message.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{define "partials/message.html"}}
|
||||
<article class="message">
|
||||
<div class="avatar">{{initial .Username}}</div>
|
||||
<div class="bubble">
|
||||
<div class="meta"><strong>{{.Username}}</strong><time>{{formatTime .CreatedAt}}</time></div>
|
||||
<p>{{.Body}}</p>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
63
templates/room.html
Normal file
63
templates/room.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · SEND.NRW Chat</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/rooms">SEND.NRW Chat</a>
|
||||
<form method="post" action="/logout">
|
||||
<span class="user-pill">{{.Username}}</span>
|
||||
<button class="ghost" type="submit">Logout</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main class="chat-page">
|
||||
<aside class="card sidebar">
|
||||
<a class="back" href="/rooms">← Zur Raumliste</a>
|
||||
<p class="eyebrow">Chatraum</p>
|
||||
<h1>{{.Room.Name}}</h1>
|
||||
<p class="muted">{{.Room.Description}}</p>
|
||||
<div class="hint"></div>
|
||||
</aside>
|
||||
|
||||
<section class="chat-card card">
|
||||
<div id="messages" class="messages">
|
||||
{{range .Messages}}
|
||||
{{template "partials/message.html" .}}
|
||||
{{else}}
|
||||
<div class="empty">Noch keine Nachrichten. Schreib die erste.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form id="message-form" class="message-form" hx-post="/rooms/{{.Room.ID}}/messages" hx-swap="none" hx-on::after-request="if(event.detail.successful) this.reset()">
|
||||
<input name="body" placeholder="Nachricht schreiben …" autocomplete="off" required maxlength="2000">
|
||||
<button type="submit">Senden</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const messages = document.getElementById('messages');
|
||||
const source = new EventSource('/rooms/{{.Room.ID}}/events');
|
||||
function scrollDown() { messages.scrollTop = messages.scrollHeight; }
|
||||
source.addEventListener('message', function (event) {
|
||||
const data = JSON.parse(event.data);
|
||||
const empty = messages.querySelector('.empty');
|
||||
if (empty) empty.remove();
|
||||
messages.insertAdjacentHTML('beforeend', data.html);
|
||||
scrollDown();
|
||||
});
|
||||
window.addEventListener('beforeunload', function () { source.close(); });
|
||||
scrollDown();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
templates/rooms.html
Normal file
48
templates/rooms.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · SEND.NRW Chat</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/rooms">SEND.NRW Chat</a>
|
||||
<form method="post" action="/logout">
|
||||
<span class="user-pill">{{.Username}}</span>
|
||||
<button class="ghost" type="submit">Logout</button>
|
||||
</form>
|
||||
</header>
|
||||
<main class="grid-page">
|
||||
<section class="hero card">
|
||||
<div>
|
||||
<p class="eyebrow">Räume</p>
|
||||
<h1>Wähle einen Chatraum</h1>
|
||||
<p class="muted">Nachrichten werden persistent gespeichert und live per Server-Sent Events verteilt.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="room-grid">
|
||||
{{range .Rooms}}
|
||||
<a class="room-card" href="/rooms/{{.ID}}">
|
||||
<h2>{{.Name}}</h2>
|
||||
<p>{{.Description}}</p>
|
||||
</a>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="card hero">
|
||||
<h2>Neuen Raum erstellen</h2>
|
||||
<form hx-post="/rooms" method="post" class="stack two-col-form">
|
||||
<label>Name<input name="name" placeholder="Projekt Alpha" required minlength="2" maxlength="40"></label>
|
||||
<label>Beschreibung<input name="description" placeholder="Worum geht es hier?" maxlength="120"></label>
|
||||
<button type="submit">Raum erstellen</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user