Web client (warp-web) with E2EE, and two sync fixes
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
@@ -0,0 +1,4 @@
|
||||
`pnpm build` in `apps/web` writes the client to `dist/` here, and
|
||||
`internal/client` embeds it into the `warp-web` binary. This file keeps the
|
||||
directory in git so the Go code compiles before the frontend is built; such a
|
||||
binary serves a page saying the client has not been built.
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package client holds the built web client, embedded into the binary.
|
||||
package client
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:build
|
||||
var build embed.FS
|
||||
|
||||
// FS returns the built client, rooted at its index.html. ok is false when
|
||||
// the binary was compiled before `pnpm build`, and the client is missing.
|
||||
func FS() (files fs.FS, ok bool) {
|
||||
sub, err := fs.Sub(build, "build/dist")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return sub, true
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Package host is the warp-web HTTP server: it serves the built web client,
|
||||
// its runtime configuration and, by default, proxies the client's Matrix
|
||||
// requests to the homeserver.
|
||||
//
|
||||
// Proxying keeps the client same-origin: no CORS, no homeserver URL baked
|
||||
// into the build, and a homeserver that only has to be reachable from
|
||||
// warp-web. A deployment that already serves the homeserver publicly can
|
||||
// turn the proxy off and send the browser there directly instead.
|
||||
package host
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options configures the handler. Exactly one of Homeserver and
|
||||
// PublicHomeserver is set.
|
||||
type Options struct {
|
||||
// Homeserver is where proxied requests go, e.g. http://warp:8008. The
|
||||
// browser then talks only to warp-web.
|
||||
Homeserver *url.URL
|
||||
// PublicHomeserver is the homeserver's public URL, for a deployment
|
||||
// where the browser calls it directly; warp-web then proxies nothing.
|
||||
PublicHomeserver *url.URL
|
||||
// Client is the built web client rooted at its index.html; nil serves
|
||||
// a page saying it has not been built.
|
||||
Client fs.FS
|
||||
// Version is reported in /config.json.
|
||||
Version string
|
||||
// Logger receives proxy errors. Defaults to slog.Default().
|
||||
Logger *slog.Logger
|
||||
// Transport overrides the proxy's round tripper, for tests.
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
|
||||
// Client-Server API and media. Federation, server keys and the admin API
|
||||
// are not reachable through warp-web.
|
||||
var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"}
|
||||
|
||||
// Config is the body of /config.json, which the client reads on start.
|
||||
type Config struct {
|
||||
// HomeserverURL is where the client sends Matrix requests; empty
|
||||
// means its own origin, through warp-web's proxy.
|
||||
HomeserverURL string `json:"homeserver_url"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// New returns the warp-web handler.
|
||||
func New(opts Options) (http.Handler, error) {
|
||||
if (opts.Homeserver == nil) == (opts.PublicHomeserver == nil) {
|
||||
return nil, errors.New("host: set exactly one of Homeserver and PublicHomeserver")
|
||||
}
|
||||
for _, u := range []*url.URL{opts.Homeserver, opts.PublicHomeserver} {
|
||||
if u != nil && ((u.Scheme != "http" && u.Scheme != "https") || u.Host == "") {
|
||||
return nil, errors.New("host: the homeserver must be an absolute http(s) URL")
|
||||
}
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
|
||||
h := &handler{opts: opts}
|
||||
|
||||
cfg := Config{Version: opts.Version}
|
||||
connect := "'self'"
|
||||
if u := opts.PublicHomeserver; u != nil {
|
||||
cfg.HomeserverURL = strings.TrimRight(u.String(), "/")
|
||||
connect += " " + u.Scheme + "://" + u.Host
|
||||
}
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.config = body
|
||||
// Media is fetched with the access token and shown from blob: URLs,
|
||||
// because authenticated media can't be loaded by an <img src>. The
|
||||
// end-to-end encryption code is WebAssembly (matrix-sdk-crypto-wasm),
|
||||
// which needs 'wasm-unsafe-eval' to compile; it allows no JavaScript eval.
|
||||
h.csp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src " + connect + "; " +
|
||||
"worker-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
|
||||
|
||||
if opts.Homeserver != nil {
|
||||
h.proxy = newProxy(opts)
|
||||
}
|
||||
|
||||
if opts.Client != nil {
|
||||
index, err := fs.ReadFile(opts.Client, "index.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.index = index
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func newProxy(opts Options) *httputil.ReverseProxy {
|
||||
transport := opts.Transport
|
||||
if transport == nil {
|
||||
transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 256,
|
||||
MaxIdleConnsPerHost: 256,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// /sync long-polls for up to its timeout, 30s from this client.
|
||||
ResponseHeaderTimeout: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
upstream := opts.Homeserver
|
||||
return &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(upstream)
|
||||
pr.SetXForwarded()
|
||||
// The client authenticates with its access token, never a
|
||||
// cookie; don't pass on any a shared domain might have set.
|
||||
pr.Out.Header.Del("Cookie")
|
||||
},
|
||||
Transport: transport,
|
||||
// Pass data on as it arrives, so a large download streams.
|
||||
FlushInterval: -1,
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
res.Header.Del("Set-Cookie")
|
||||
if res.Header.Get("Cache-Control") == "" {
|
||||
res.Header.Set("Cache-Control", "no-store")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err)
|
||||
writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The web client service can’t reach the homeserver.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
opts Options
|
||||
proxy *httputil.ReverseProxy
|
||||
index []byte
|
||||
config []byte
|
||||
csp string
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
hdr := w.Header()
|
||||
hdr.Set("X-Content-Type-Options", "nosniff")
|
||||
hdr.Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case p == "/healthz":
|
||||
hdr.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
hdr.Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
return
|
||||
|
||||
case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"):
|
||||
// A path that cleans to something else (dot segments, doubled
|
||||
// slashes) is refused rather than normalised: what is allowed
|
||||
// here must be exactly what the homeserver routes.
|
||||
if h.proxy == nil || !clean(p) || !proxied(p) {
|
||||
writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the web client service.")
|
||||
return
|
||||
}
|
||||
h.proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
hdr.Set("X-Frame-Options", "DENY")
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
hdr.Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if p == "/config.json" {
|
||||
hdr.Set("Content-Type", "application/json")
|
||||
hdr.Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, "config.json", time.Time{}, bytes.NewReader(h.config))
|
||||
return
|
||||
}
|
||||
|
||||
hdr.Set("Content-Security-Policy", h.csp)
|
||||
h.serveStatic(w, r)
|
||||
}
|
||||
|
||||
// clean reports whether p is already in canonical form. A trailing slash
|
||||
// counts as canonical: the C-S API has routes that end in one, like
|
||||
// GET /pushrules/.
|
||||
func clean(p string) bool {
|
||||
c := path.Clean(p)
|
||||
if strings.HasSuffix(p, "/") && c != "/" {
|
||||
c += "/"
|
||||
}
|
||||
return c == p
|
||||
}
|
||||
|
||||
func proxied(p string) bool {
|
||||
for _, prefix := range proxiedPrefixes {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.opts.Client == nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(notBuiltPage))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if name != "" && name != "index.html" {
|
||||
if st, err := fs.Stat(h.opts.Client, name); err == nil && st.Mode().IsRegular() {
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
// Vite puts a content hash in every file name under assets/.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
http.ServeFileFS(w, r, h.opts.Client, name)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") {
|
||||
// A missing asset is a 404, not the app: answering a stale
|
||||
// script URL with HTML only produces a confusing MIME error.
|
||||
// Deeper paths are routes even with a dot in them, like
|
||||
// /room/!abc:warp.example.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is a route of the single-page app.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, errcode, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg})
|
||||
}
|
||||
|
||||
const notBuiltPage = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Warp</title>
|
||||
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
|
||||
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
|
||||
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
|
||||
<body><h1 style="font-size:20px">The web client isn’t built into this binary</h1>
|
||||
<p>This warp-web was compiled before the frontend. Build it, then the binary:</p>
|
||||
<p><code>pnpm --filter @warp/web build</code><br><code>make web</code></p></body></html>
|
||||
`
|
||||
@@ -0,0 +1,306 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// upstream records what reached the homeserver.
|
||||
type upstream struct {
|
||||
srv *httptest.Server
|
||||
hits []*http.Request
|
||||
}
|
||||
|
||||
func newUpstream(t *testing.T) *upstream {
|
||||
t.Helper()
|
||||
u := &upstream{}
|
||||
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u.hits = append(u.hits, r.Clone(r.Context()))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Set-Cookie", "tracker=1")
|
||||
if strings.HasPrefix(r.URL.Path, "/_matrix/client/v1/media/") {
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"path":"` + r.URL.EscapedPath() + `"}`))
|
||||
}))
|
||||
t.Cleanup(u.srv.Close)
|
||||
return u
|
||||
}
|
||||
|
||||
var clientFS = fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><title>client</title>")},
|
||||
"favicon.svg": {Data: []byte("<svg/>")},
|
||||
"assets/index-a1.js": {Data: []byte("console.log(1)")},
|
||||
}
|
||||
|
||||
func newHandler(t *testing.T, up *upstream, built bool) http.Handler {
|
||||
t.Helper()
|
||||
target, _ := url.Parse(up.srv.URL)
|
||||
opts := Options{Homeserver: target, Version: "1.2.3"}
|
||||
if built {
|
||||
opts.Client = clientFS
|
||||
}
|
||||
h, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func do(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
for k, v := range header {
|
||||
req.Header[k] = v
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestProxiesClientAndMediaPaths(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
|
||||
cases := []struct{ method, path string }{
|
||||
{http.MethodGet, "/_matrix/client/versions"},
|
||||
{http.MethodPost, "/_matrix/client/v3/login"},
|
||||
{http.MethodGet, "/_matrix/client/v3/sync?timeout=30000&since=s1_2"},
|
||||
{http.MethodPut, "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1"},
|
||||
{http.MethodPost, "/_matrix/media/v3/upload?filename=cat.png"},
|
||||
{http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc"},
|
||||
{http.MethodGet, "/_matrix/client/v3/pushrules/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := do(h, c.method, c.path, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s %s: status %d, want 200", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
if len(up.hits) != len(cases) {
|
||||
t.Fatalf("upstream saw %d requests, want %d", len(up.hits), len(cases))
|
||||
}
|
||||
// Escaped IDs and the query reach the homeserver unchanged.
|
||||
if got := up.hits[3].URL.EscapedPath(); got != "/_matrix/client/v3/rooms/%21a%3Awarp.example/send/m.room.message/txn1" {
|
||||
t.Errorf("escaped path = %q", got)
|
||||
}
|
||||
if got := up.hits[2].URL.RawQuery; got != "timeout=30000&since=s1_2" {
|
||||
t.Errorf("query = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusesOtherHomeserverPaths(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
|
||||
for _, p := range []string{
|
||||
"/_matrix/federation/v1/version",
|
||||
"/_matrix/key/v2/server",
|
||||
"/_matrix/app/v1/transactions/1",
|
||||
"/_warp/admin/v1/stats",
|
||||
"/_synapse/admin/v1/users",
|
||||
"/_matrix/client/../federation/v1/version",
|
||||
"/_matrix/client/v3/../../federation/v1/version",
|
||||
"/_matrix/client//v3/sync",
|
||||
"/_matrix/client/v3/pushrules//",
|
||||
"/_matrix/client/v3/../../federation/",
|
||||
} {
|
||||
rec := do(h, http.MethodGet, p, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d, want 404", p, rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if body["errcode"] != "M_UNRECOGNIZED" {
|
||||
t.Errorf("%s: errcode %q", p, body["errcode"])
|
||||
}
|
||||
}
|
||||
if len(up.hits) != 0 {
|
||||
t.Fatalf("upstream saw %d requests, want none", len(up.hits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyStripsCookiesAndForwardsToken(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
|
||||
rec := do(h, http.MethodGet, "/_matrix/client/v3/account/whoami", http.Header{
|
||||
"Authorization": {"Bearer tok"},
|
||||
"Cookie": {"session=secret"},
|
||||
})
|
||||
if rec.Header().Get("Set-Cookie") != "" {
|
||||
t.Error("Set-Cookie from the homeserver was passed to the browser")
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
got := up.hits[0]
|
||||
if got.Header.Get("Cookie") != "" {
|
||||
t.Error("Cookie was passed to the homeserver")
|
||||
}
|
||||
if got.Header.Get("Authorization") != "Bearer tok" {
|
||||
t.Errorf("Authorization = %q", got.Header.Get("Authorization"))
|
||||
}
|
||||
if got.Header.Get("X-Forwarded-For") == "" {
|
||||
t.Error("X-Forwarded-For not set")
|
||||
}
|
||||
|
||||
// The homeserver's own caching of media is kept.
|
||||
rec = do(h, http.MethodGet, "/_matrix/client/v1/media/download/warp.example/abc", nil)
|
||||
if got := rec.Header().Get("Cache-Control"); got != "private, max-age=86400" {
|
||||
t.Errorf("media Cache-Control = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableHomeserverIs502(t *testing.T) {
|
||||
target, _ := url.Parse("http://127.0.0.1:1")
|
||||
h, err := New(Options{Homeserver: target, Client: clientFS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(h, http.MethodGet, "/_matrix/client/versions", nil)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d, want 502", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "M_UNKNOWN") {
|
||||
t.Errorf("body %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesClient(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
|
||||
cases := []struct {
|
||||
path, wantBody, wantCache string
|
||||
wantCode int
|
||||
}{
|
||||
{"/", "client", "no-cache", 200},
|
||||
{"/room/!abc:warp.example", "client", "no-cache", 200},
|
||||
{"/room/%21abc%3Awarp.example", "client", "no-cache", 200},
|
||||
{"/index.html", "client", "no-cache", 200},
|
||||
{"/assets/index-a1.js", "console.log", "public, max-age=31536000, immutable", 200},
|
||||
{"/favicon.svg", "<svg", "public, max-age=3600", 200},
|
||||
{"/assets/gone-b2.js", "", "", 404},
|
||||
{"/robots.txt", "", "", 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := do(h, http.MethodGet, c.path, nil)
|
||||
if rec.Code != c.wantCode {
|
||||
t.Errorf("%s: status %d, want %d", c.path, rec.Code, c.wantCode)
|
||||
continue
|
||||
}
|
||||
if c.wantCode != 200 {
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
if !strings.Contains(string(body), c.wantBody) {
|
||||
t.Errorf("%s: body %q", c.path, body)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != c.wantCache {
|
||||
t.Errorf("%s: Cache-Control %q, want %q", c.path, got, c.wantCache)
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self';") {
|
||||
t.Errorf("%s: CSP %q", c.path, csp)
|
||||
}
|
||||
// WebAssembly for end-to-end encryption, and no JavaScript eval.
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self' 'wasm-unsafe-eval';") {
|
||||
t.Errorf("%s: CSP %q lacks wasm-unsafe-eval", c.path, csp)
|
||||
}
|
||||
if rec.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("%s: X-Frame-Options %q", c.path, rec.Header().Get("X-Frame-Options"))
|
||||
}
|
||||
}
|
||||
|
||||
if rec := do(h, http.MethodPost, "/", nil); rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("POST /: status %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigProxied(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
|
||||
rec := do(h, http.MethodGet, "/config.json", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.HomeserverURL != "" || cfg.Version != "1.2.3" {
|
||||
t.Errorf("config = %+v, want same-origin and the version", cfg)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectModeProxiesNothing(t *testing.T) {
|
||||
public, _ := url.Parse("https://matrix.warp.example")
|
||||
h, err := New(Options{PublicHomeserver: public, Client: clientFS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := do(h, http.MethodGet, "/config.json", nil)
|
||||
var cfg Config
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &cfg)
|
||||
if cfg.HomeserverURL != "https://matrix.warp.example" {
|
||||
t.Errorf("homeserver_url = %q", cfg.HomeserverURL)
|
||||
}
|
||||
|
||||
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("proxy in direct mode: status %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// The browser may now connect to the homeserver, and only there.
|
||||
rec = do(h, http.MethodGet, "/", nil)
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "connect-src 'self' https://matrix.warp.example;") {
|
||||
t.Errorf("CSP %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotBuiltClient(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, false)
|
||||
|
||||
rec := do(h, http.MethodGet, "/", nil)
|
||||
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "isn’t built") {
|
||||
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The proxy still works without the client.
|
||||
if rec := do(h, http.MethodGet, "/_matrix/client/versions", nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("proxy status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
if rec := do(h, http.MethodGet, "/healthz", nil); rec.Code != 200 || rec.Body.String() != "ok\n" {
|
||||
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesHomeserver(t *testing.T) {
|
||||
rel, _ := url.Parse("warp:8008")
|
||||
ftp, _ := url.Parse("ftp://warp.example")
|
||||
ok, _ := url.Parse("http://warp:8008")
|
||||
for name, opts := range map[string]Options{
|
||||
"relative": {Homeserver: rel},
|
||||
"scheme": {PublicHomeserver: ftp},
|
||||
"neither": {},
|
||||
"both": {Homeserver: ok, PublicHomeserver: ok},
|
||||
} {
|
||||
if _, err := New(opts); err == nil {
|
||||
t.Errorf("%s: want an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user