feat: rewrite the backend in Rust

Replaces the Go server with a Rust one (axum, tokio, sqlx, feed-rs,
ammonia, reqwest) serving the same REST/WebSocket API, so the React
frontend is unchanged.

- Same database: migrations keep the schema_migrations table and file
  names, Argon2id hashes and hashed session tokens created by the Go
  server keep working (users stay logged in), and feed item GUIDs are
  computed the same way, so existing articles are not re-ingested.
- Same security model: SSRF-safe fetching (public addresses only,
  checked at resolution time and on every redirect), HTML sanitization,
  CSP and security headers, same-origin guard, WebSocket origin check,
  per-IP / per-user rate limits, trusted-proxy aware client IPs.
- Fix: articles whose publication date lies in the future (ingested
  before the clamp existed) pinned the top of the list. The maintenance
  job now pulls such dates back at startup and daily.
- Relative links and images in feed content are resolved against the
  article URL instead of being dropped.
- Deploy: static musl binary in an Alpine image; the Unraid compose file
  uses pull_policy: always so the new image is applied on the next
  `docker compose up -d`. CI runs fmt, clippy and tests (including a
  PostgreSQL integration test) before building the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc1UFpB4V1MGWLxHAu3BMK
This commit is contained in:
Claude committed 2026-10-13 10:56:18 +00:00
1 parent 5a16769eb9
commit f3816f4d78
70 files changed
+8127 -5283

No files matched your search

-237
View File
@@ -1,237 +0,0 @@
package ws
import (
"encoding/json"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer (clients don't send data).
maxMessageSize = 512
)
// allowedWSOrigins holds optional extra origins (comma-separated) from the
// WS_ALLOWED_ORIGINS env var, for deployments where the WS host differs.
var allowedWSOrigins = parseAllowedOrigins(os.Getenv("WS_ALLOWED_ORIGINS"))
func parseAllowedOrigins(raw string) map[string]bool {
out := make(map[string]bool)
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(strings.ToLower(o)); o != "" {
out[o] = true
}
}
return out
}
// checkOrigin enforces a same-origin policy to prevent Cross-Site WebSocket
// Hijacking (CSWSH). Requests without an Origin header (non-browser clients)
// are allowed; browser requests must match the Host or an allow-listed origin.
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser client (e.g. native app, curl)
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
return allowedWSOrigins[strings.ToLower(u.Host)]
}
var upgrader = websocket.Upgrader{
CheckOrigin: checkOrigin,
}
// Event represents a websocket event.
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// userEvent is an event addressed to every connection of a single user.
type userEvent struct {
userID uuid.UUID
data []byte
}
// Client represents a connected user via websocket.
type Client struct {
ID uuid.UUID
Conn *websocket.Conn
Send chan []byte
Hub *Hub
}
// Hub maintains the set of active clients and routes messages to them.
// The clients map is only ever touched by the Run goroutine.
type Hub struct {
clients map[uuid.UUID]map[*Client]struct{}
broadcast chan userEvent
register chan *Client
unregister chan *Client
}
// NewHub creates a new hub.
func NewHub() *Hub {
return &Hub{
broadcast: make(chan userEvent, 256),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[uuid.UUID]map[*Client]struct{}),
}
}
// Run starts the hub loop.
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
set := h.clients[client.ID]
if set == nil {
set = make(map[*Client]struct{})
h.clients[client.ID] = set
}
set[client] = struct{}{}
case client := <-h.unregister:
h.remove(client)
case ev := <-h.broadcast:
for client := range h.clients[ev.userID] {
select {
case client.Send <- ev.data:
default:
// Slow consumer: drop it. remove() is idempotent so a
// later unregister from readPump is harmless.
h.remove(client)
}
}
}
}
}
// remove unregisters a client and closes its send channel exactly once.
func (h *Hub) remove(client *Client) {
set, ok := h.clients[client.ID]
if !ok {
return
}
if _, ok := set[client]; !ok {
return
}
delete(set, client)
close(client.Send)
if len(set) == 0 {
delete(h.clients, client.ID)
}
}
// SendToUser sends an event to every connection of the given user only.
func (h *Hub) SendToUser(userID uuid.UUID, eventType string, payload interface{}) {
raw, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling WS payload: %v", err)
return
}
data, err := json.Marshal(Event{Type: eventType, Payload: raw})
if err != nil {
log.Printf("Error marshaling WS event: %v", err)
return
}
select {
case h.broadcast <- userEvent{userID: userID, data: data}:
default:
// Never block request handlers on a saturated hub; clients resync
// on their next query anyway.
log.Printf("WS hub saturated, dropping %s event", eventType)
}
}
// ServeWS handles websocket requests.
func (h *Hub) ServeWS(userID uuid.UUID, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WS upgrade error: %v", err)
return
}
client := &Client{
ID: userID,
Conn: conn,
Send: make(chan []byte, 64),
Hub: h,
}
h.register <- client
go client.writePump()
go client.readPump()
}
func (c *Client) readPump() {
defer func() {
c.Hub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
return c.Conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
if _, _, err := c.Conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WS read error: %v", err)
}
return
}
// Clients don't send messages; anything received is ignored.
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// One frame per event so the client can JSON.parse each one.
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}