2026-10-23 13:59:30 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"net/http"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// What the iOS app needs from a server that a browser gets for free by being on
|
|
|
|
|
// the same origin. The app loads its interface from capacitor://localhost and
|
|
|
|
|
// talks to whichever server its owner typed in, so every request it makes is
|
|
|
|
|
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
|
|
|
|
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
|
|
|
|
// has signed in.
|
|
|
|
|
|
|
|
|
|
// appCORS answers the app's origin, and nobody else's.
|
|
|
|
|
//
|
|
|
|
|
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
|
|
|
|
// token, never a cookie, so a response to a request with a cookie on it is not
|
|
|
|
|
// something this header ever needs to unlock. Every other origin gets no CORS
|
|
|
|
|
// headers at all, exactly as before — the browser then keeps a foreign page from
|
|
|
|
|
// reading anything, which is the default this must not loosen.
|
2026-10-23 14:13:14 +02:00
|
|
|
func (s *Server) appCORS(next http.Handler) http.Handler {
|
2026-10-23 13:59:30 +02:00
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
origin := r.Header.Get("Origin")
|
2026-10-23 14:13:14 +02:00
|
|
|
if !s.isAppOrigin(origin) {
|
2026-10-23 13:59:30 +02:00
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h := w.Header()
|
|
|
|
|
h.Set("Access-Control-Allow-Origin", origin)
|
|
|
|
|
h.Add("Vary", "Origin")
|
|
|
|
|
// Read by the client: a rate limit's wait, and a download's file name.
|
|
|
|
|
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
|
|
|
|
|
|
|
|
|
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
|
|
|
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
|
|
|
|
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
|
|
|
h.Set("Access-Control-Max-Age", "600")
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- the live-sync socket without a cookie -----------------------------------
|
|
|
|
|
|
|
|
|
|
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
|
|
|
|
// token has no way onto it. Putting the token in the URL would work and would
|
|
|
|
|
// leave a credential that lasts months in every proxy log the URL passes. A
|
|
|
|
|
// ticket is the usual answer: asked for with the token, good for one connection,
|
|
|
|
|
// gone in thirty seconds.
|
|
|
|
|
|
|
|
|
|
const wsTicketTTL = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
type wsTicket struct {
|
|
|
|
|
userID string
|
|
|
|
|
expires time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type wsTickets struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
m map[string]wsTicket
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
|
|
|
|
|
|
|
|
|
func (t *wsTickets) issue(userID string) (string, error) {
|
|
|
|
|
raw := make([]byte, 32)
|
|
|
|
|
if _, err := rand.Read(raw); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
ticket := hex.EncodeToString(raw)
|
|
|
|
|
|
|
|
|
|
t.mu.Lock()
|
|
|
|
|
defer t.mu.Unlock()
|
|
|
|
|
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
|
|
|
|
now := time.Now()
|
|
|
|
|
for k, v := range t.m {
|
|
|
|
|
if now.After(v.expires) {
|
|
|
|
|
delete(t.m, k)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
|
|
|
|
return ticket, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// redeem spends a ticket. It is deleted before it is checked, so a second
|
|
|
|
|
// presentation finds nothing, whether the first one was in time or not.
|
|
|
|
|
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
|
|
|
|
t.mu.Lock()
|
|
|
|
|
defer t.mu.Unlock()
|
|
|
|
|
v, ok := t.m[ticket]
|
|
|
|
|
delete(t.m, ticket)
|
|
|
|
|
if !ok || time.Now().After(v.expires) {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return v.userID, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "issue websocket ticket", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"ticket": ticket,
|
|
|
|
|
"expires_in": int(wsTicketTTL.Seconds()),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
|
|
|
|
// ordinary session check.
|
|
|
|
|
//
|
|
|
|
|
// A socket opened with a ticket skips the origin check, and that is the point
|
|
|
|
|
// rather than a gap: the origin check exists because a browser attaches the
|
|
|
|
|
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
|
|
|
|
// be asked for with a credential and put in the URL on purpose — so there is no
|
|
|
|
|
// ambient authority for a foreign page to borrow.
|
|
|
|
|
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ticket := r.URL.Query().Get("ticket")
|
|
|
|
|
if ticket == "" {
|
|
|
|
|
// The app never holds a cookie, so a handshake from its origin without a
|
|
|
|
|
// ticket is not the app. Refused here rather than left to the origin
|
|
|
|
|
// check, which compares host names only: capacitor://localhost and an
|
|
|
|
|
// instance served at http://localhost have the same one.
|
2026-10-23 14:13:14 +02:00
|
|
|
if s.isAppOrigin(r.Header.Get("Origin")) {
|
2026-10-23 13:59:30 +02:00
|
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userID, ok := s.wsTickets.redeem(ticket)
|
|
|
|
|
if !ok {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
user, err := s.db.UserByID(r.Context(), userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.storeError(w, r, "load user", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
|
|
|
|
s.handleWebSocket(w, r.WithContext(ctx))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- is this a Verdande? -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// handlePing answers before anybody has signed in, which is when the app needs
|
|
|
|
|
// it: the person has typed an address and the app has to tell "that is a
|
|
|
|
|
// Verdande" from "that is a router's login page" before it shows a password
|
|
|
|
|
// field for it. The edition decides which half of the interface to show.
|
|
|
|
|
//
|
|
|
|
|
// No version. /version sits behind a login on purpose, and a public number is a
|
|
|
|
|
// list of the instances still running an old one.
|
|
|
|
|
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"product": "verdande",
|
|
|
|
|
"edition": s.edition(),
|
|
|
|
|
"api": 1,
|
|
|
|
|
})
|
|
|
|
|
}
|