From 98ab939a3f10aa3162ecec1d471a8cc8e7b07ed3 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sun, 5 Apr 2026 06:46:54 +0100 Subject: Setup frontend infrastructure --- .github/workflows/build.yml | 11 +- .gitignore | 6 +- LICENSES/MIT.txt | 18 + REUSE.toml | 3 +- Taskfile.yml | 156 +- cmd/mirumd/main.go | 15 +- cmd/mirumd/server_web.go | 126 +- cmd/mirumd/static.go | 232 + cmd/mirumd/static_dev.go | 36 + cmd/mirumd/static_prod.go | 32 + cmd/mirumd/templates/index.html | 16 - cmd/mirumd/templates/layout.html | 14 - cmd/mirumd/templates/login.html | 21 - cmd/mirumd/web/api/client.ts | 17 + cmd/mirumd/web/components.json | 25 + cmd/mirumd/web/components/pages/dashboard.tsx | 62 + cmd/mirumd/web/components/pages/login.tsx | 83 + cmd/mirumd/web/components/ui/alert.tsx | 79 + cmd/mirumd/web/components/ui/button.tsx | 70 + cmd/mirumd/web/components/ui/card.tsx | 106 + cmd/mirumd/web/components/ui/field.tsx | 239 + cmd/mirumd/web/components/ui/input.tsx | 22 + cmd/mirumd/web/components/ui/label.tsx | 25 + cmd/mirumd/web/components/ui/separator.tsx | 30 + cmd/mirumd/web/entries/dashboard.tsx | 8 + cmd/mirumd/web/entries/login.tsx | 8 + cmd/mirumd/web/index.css | 133 + cmd/mirumd/web/lib/mount.tsx | 24 + cmd/mirumd/web/lib/utils.ts | 24 + cmd/mirumd/web/package-lock.json | 7388 +++++++++++++++++ cmd/mirumd/web/package.json | 52 + cmd/mirumd/web/shell.html | 30 + cmd/mirumd/web/tsconfig.json | 33 + cmd/mirumd/web/vite.config.ts | 46 + cmd/mirumw/main.go | 7 +- internal/database/database.go | 35 +- internal/database/user.go | 9 +- pkg/mirumw-default.yaml | 9 +- proto/buf.gen.yaml | 5 + 39 files changed, 9123 insertions(+), 132 deletions(-) create mode 100644 LICENSES/MIT.txt create mode 100644 cmd/mirumd/static.go create mode 100644 cmd/mirumd/static_dev.go create mode 100644 cmd/mirumd/static_prod.go delete mode 100644 cmd/mirumd/templates/index.html delete mode 100644 cmd/mirumd/templates/layout.html delete mode 100644 cmd/mirumd/templates/login.html create mode 100644 cmd/mirumd/web/api/client.ts create mode 100644 cmd/mirumd/web/components.json create mode 100644 cmd/mirumd/web/components/pages/dashboard.tsx create mode 100644 cmd/mirumd/web/components/pages/login.tsx create mode 100644 cmd/mirumd/web/components/ui/alert.tsx create mode 100644 cmd/mirumd/web/components/ui/button.tsx create mode 100644 cmd/mirumd/web/components/ui/card.tsx create mode 100644 cmd/mirumd/web/components/ui/field.tsx create mode 100644 cmd/mirumd/web/components/ui/input.tsx create mode 100644 cmd/mirumd/web/components/ui/label.tsx create mode 100644 cmd/mirumd/web/components/ui/separator.tsx create mode 100644 cmd/mirumd/web/entries/dashboard.tsx create mode 100644 cmd/mirumd/web/entries/login.tsx create mode 100644 cmd/mirumd/web/index.css create mode 100644 cmd/mirumd/web/lib/mount.tsx create mode 100644 cmd/mirumd/web/lib/utils.ts create mode 100644 cmd/mirumd/web/package-lock.json create mode 100644 cmd/mirumd/web/package.json create mode 100644 cmd/mirumd/web/shell.html create mode 100644 cmd/mirumd/web/tsconfig.json create mode 100644 cmd/mirumd/web/vite.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9126654..948dc34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,12 +41,6 @@ jobs: with: passphrase: ${{ secrets.GPG_PASSPHRASE }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - - - name: Lint - run: task lint - - - name: Test - run: task test - name: Determine version id: version @@ -71,6 +65,11 @@ jobs: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} NFPM_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + - name: Lint + run: task lint + + - name: Test + run: task test - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: diff --git a/.gitignore b/.gitignore index 3aeef41..cec9f17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ # Copyright (c) 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later +/dev /build /.task +cmd/mirumd/static +cmd/mirumd/web/node_modules # grpc generated code -internal/protocol/pb +/internal/protocol/pb +/cmd/mirumd/web/gen diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index f387a83..9da1d8d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,7 +12,8 @@ path = [ "README.md", "pkg/logo.svg", "pkg/dl/*", - "proto/buf.*" + "proto/buf.*", + "cmd/mirumd/web/*.json", ] SPDX-FileCopyrightText = "2026 Nikolay Govorov " SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/Taskfile.yml b/Taskfile.yml index 31d8e3f..0f11a70 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,41 +3,81 @@ version: "3" +output: prefixed + vars: BUILD_DIR: build DIST_DIR: build/dist ARCHES: amd64 arm64 riscv64 ppc64le + DEV_DB: mirum-local-dev env: GOEXPERIMENT: runtimesecret tasks: + lint: + desc: Run static checks + deps: [proto] + cmds: + - go vet ./... + - gofmt -l . | grep . && exit 1 || true proto: desc: Generate ConnectRPC code from proto files sources: - proto/*.proto + - proto/buf.gen.yaml + - proto/buf.yaml generates: - internal/protocol/pb/**/*.go + - cmd/mirumd/web/gen/**/*.ts cmds: - - rm -rf internal/protocol/pb + - rm -rf internal/protocol/pb cmd/mirumd/web/gen - cd proto && buf generate + # admin_pb.ts imports file_buf_validate_validate from buf/validate/validate_pb.js. + # buf.validate carries only server-side field annotations that the browser + # never decodes, so replace the 200+ KB generated schema with an empty + # descriptor stub that satisfies the GenFile type. + - mkdir -p cmd/mirumd/web/gen/buf/validate + - | + cat > cmd/mirumd/web/gen/buf/validate/validate_pb.ts <<'EOF' + // Stub: see Taskfile.yml proto task. buf.validate is server-only. + import { fileDesc, type GenFile } from "@bufbuild/protobuf/codegenv2" + export const file_buf_validate_validate: GenFile = /*@__PURE__*/ fileDesc("") + EOF - lint: - desc: Run static checks - deps: [proto] + web:install: + desc: Install frontend dependencies + dir: cmd/mirumd/web + sources: + - package.json + - package-lock.json + generates: + - node_modules/.package-lock.json cmds: - - go vet ./... - - gofmt -l . | grep . && exit 1 || true + - npm ci - test: - desc: Run all tests - deps: [proto] + web:build: + desc: Build the frontend into cmd/mirumd/static + dir: cmd/mirumd/web + deps: [web:install, proto] + sources: + - api/**/* + - gen/**/* + - lib/**/* + - pages/**/* + - components/**/* + - index.css + - vite.config.ts + - tsconfig.json + - tsconfig.app.json + generates: + - ../static/**/* cmds: - - go test -race -count=1 ./... + - npm run build build: desc: "Build binaries (override GOOS/GOARCH for cross-compilation)" - deps: [proto] + deps: [proto, web:build] vars: GOOS: { sh: "echo ${GOOS:-$(go env GOOS)}" } GOARCH: { sh: "echo ${GOARCH:-$(go env GOARCH)}" } @@ -52,8 +92,39 @@ tasks: - cp {{.BUILD_DIR}}/mirumd-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumd - cp {{.BUILD_DIR}}/mirumw-{{.GOOS}}-{{.GOARCH}} {{.BUILD_DIR}}/mirumw - dev: - desc: Generate dev TLS cert and worker key + test: + desc: Run all tests + deps: [build, web:size] + cmds: + - go test -race -count=1 ./... + + web:size: + desc: Check frontend bundle size budgets + dir: cmd/mirumd/web + deps: [web:build] + sources: + - ../static/assets/* + - package.json + cmds: + - npm run size + + web:dev: + desc: Run Vite dev server (use with `go run -tags dev ./cmd/mirumd`) + dir: cmd/mirumd/web + deps: [web:install, proto] + prefix: vite + env: + NO_COLOR: "1" + CI: "true" + cmds: + - npm run dev + + devenv:keys: + desc: Generate dev TLS cert and worker key pair + status: + - test -f dev/server.crt + - test -f dev/worker.key + - test -f dev/worker.pub cmds: - mkdir -p dev - >- @@ -65,6 +136,65 @@ tasks: - >- test -f dev/worker.key || openssl genpkey -algorithm ed25519 -out dev/worker.key + - >- + test -f dev/worker.pub || + openssl pkey -in dev/worker.key -pubout -out dev/worker.pub + + devenv:config: + desc: Write dev/mirumd.yaml if missing + status: + - test -f dev/mirumd.yaml + cmds: + - mkdir -p dev + - | + cat > dev/mirumd.yaml </dev/null 2>&1 + cmds: + - | + sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='{{.DEV_DB}}'" | grep -q 1 || + sudo -u postgres psql -c "CREATE ROLE \"{{.DEV_DB}}\" LOGIN PASSWORD '{{.DEV_DB}}'" + - | + sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='{{.DEV_DB}}'" | grep -q 1 || + sudo -u postgres psql -c "CREATE DATABASE \"{{.DEV_DB}}\" OWNER \"{{.DEV_DB}}\"" + + devenv: + desc: Provision a full local dev environment (keys, config, database) + deps: [devenv:keys, devenv:config, devenv:db] + + run: + desc: Build and run mirumd against the dev environment + deps: [build, devenv] + cmds: + - "{{.BUILD_DIR}}/mirumd daemon --config dev/mirumd.yaml" + + dev: + desc: Run Vite dev server and mirumd together (HMR-enabled) + deps: [web:dev, dev:server] + + dev:server: + desc: Build mirumd with -tags dev and run it against the dev environment + deps: [proto, devenv] + prefix: mirumd + env: + CGO_ENABLED: "0" + cmds: + - mkdir -p {{.BUILD_DIR}} + - go build -tags dev -o {{.BUILD_DIR}}/mirumd ./cmd/mirumd + - "{{.BUILD_DIR}}/mirumd daemon --config dev/mirumd.yaml" package: desc: Build deb/rpm packages for all architectures diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index c737932..d60c4eb 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -28,6 +28,13 @@ import ( "github.com/spf13/cobra" ) +func hardenServer(s *http.Server) *http.Server { + s.IdleTimeout = 120 * time.Second + s.MaxHeaderBytes = 1 << 16 + s.ReadHeaderTimeout = 10 * time.Second + return s +} + func main() { var socketPath string @@ -297,12 +304,12 @@ func daemon(configFile, socketFlag string) { adminPath, adminHandler := NewAdminHandler(srv) - webSrv := NewWebServer(ctx, srv, adminPath, adminHandler) - grpcSrv := NewGrpcServer(ctx, srv) + webSrv := hardenServer(NewWebServer(ctx, srv, adminPath, adminHandler)) + grpcSrv := hardenServer(NewGrpcServer(ctx, srv)) adminMux := http.NewServeMux() adminMux.Handle(adminPath, adminHandler) - adminSrv := &http.Server{ + adminSrv := hardenServer(&http.Server{ Handler: adminMux, ConnContext: func(ctx context.Context, _ net.Conn) context.Context { return context.WithValue(ctx, callerKey{}, &callerInfo{ @@ -314,7 +321,7 @@ func daemon(configFile, socketFlag string) { BaseContext: func(_ net.Listener) context.Context { return ctx }, - } + }) grpcLn, webLn, adminLn, err := listeners(cfg) if err != nil { diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go index fc4a6a6..7ce1a7f 100644 --- a/cmd/mirumd/server_web.go +++ b/cmd/mirumd/server_web.go @@ -8,10 +8,8 @@ import ( "crypto/rand" "crypto/subtle" "crypto/tls" - "embed" "encoding/base64" "errors" - "html/template" "io" "net" "net/http" @@ -28,34 +26,57 @@ import ( "dimidiumlabs/mirum/internal/forges" ) -//go:embed templates/*.html -var templateFS embed.FS - -var ( - indexTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/index.html")) - loginTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/login.html")) +// __Host- prefixed cookies can only be set with Secure, Path=/, and no +// Domain attribute. Browsers silently reject violations, so subdomain and +// network attackers cannot forge them. +const ( + sessionCookie = "__Host-session" + csrfCookie = "__Host-csrf" ) func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandler http.Handler) *http.Server { - h := &webHandler{srv: srv} + h := &webHandler{ + srv: srv, + assets: newAssetResolver(), + } r := chi.NewRouter() r.Use(middleware.CleanPath) r.Use(middleware.StripSlashes) r.Use(middleware.RequestID) - r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Compress(5)) r.Use(middleware.Heartbeat("/ping")) r.Use(middleware.Timeout(30 * time.Second)) r.Use(middleware.RequestSize(64 << 20)) // 64 MiB global body limit + r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", csp) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") + + if srv.cfg.WebTls != nil { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + } + + next.ServeHTTP(w, r) + }) + }) // The authorization session sets the user to ctx r.Use(h.SessionMiddleware) - r.Get("/", h.index) + r.With(middleware.SetHeader("Cache-Control", "public, max-age=31536000, immutable")). + Mount("/assets", assetsHandler()) + + r.Get("/", authonly(h.index)) r.Post("/webhook", h.webhook) r.Route("/auth", func(r chi.Router) { @@ -68,11 +89,8 @@ func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandl r.Post("/logout", h.logout) }) - r.Route("/api/v1", func(r chi.Router) { - r.Use(middleware.NoCache) - r.Use(httprate.LimitByIP(300, time.Minute)) - r.Mount(adminPath, adminHandler) - }) + r.With(middleware.NoCache, httprate.LimitByIP(300, time.Minute)). + Mount("/api/v1", http.StripPrefix("/api/v1", adminHandler)) var tlsCfg *tls.Config if srv.cfg.WebTls != nil { @@ -103,7 +121,8 @@ type callerInfo struct { } type webHandler struct { - srv *server + srv *server + assets *assetResolver } // CallerFromContext returns the authenticated caller, or nil. @@ -117,11 +136,12 @@ func CallerFromContext(ctx context.Context) *callerInfo { // SessionMiddleware resolves the session cookie and puts callerInfo in context. func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie("session"); err == nil { + if c, err := r.Cookie(sessionCookie); err == nil { if sess, err := h.srv.db.UserGetSession(r.Context(), uuid.Nil, c.Value); err == nil { caller := &callerInfo{ - UserID: sess.UserID, - Email: sess.Email, + UserID: sess.UserID, + Email: sess.Email, + Superuser: sess.Superuser, } ctx := context.WithValue(r.Context(), callerKey{}, caller) r = r.WithContext(ctx) @@ -131,14 +151,24 @@ func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { }) } -func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - var data struct{ Email, CSRF string } - if caller := CallerFromContext(r.Context()); caller != nil { - data.Email = caller.Email - data.CSRF = csrfToken(w, r) +type authedHandler func(w http.ResponseWriter, r *http.Request, caller *callerInfo) + +func authonly(next authedHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caller := CallerFromContext(r.Context()) + if caller == nil { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + next(w, r, caller) } - indexTmpl.ExecuteTemplate(w, "layout", data) +} + +func (h *webHandler) index(w http.ResponseWriter, r *http.Request, caller *callerInfo) { + h.assets.renderPage(w, "dashboard", map[string]any{ + "user": map[string]string{"email": caller.Email}, + "csrf": csrfToken(w, r), + }) } func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { @@ -167,13 +197,14 @@ func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { } func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - loginTmpl.ExecuteTemplate(w, "layout", map[string]string{"CSRF": csrfToken(w, r)}) + h.assets.renderPage(w, "login", map[string]any{ + "csrf": csrfToken(w, r), + }) } func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { if !csrfOK(r) { - clearCookie(w, "csrf") + clearCookie(w, csrfCookie) http.Error(w, "invalid csrf token", http.StatusForbidden) return } @@ -184,9 +215,9 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { userID, err := h.srv.db.UserVerifyPassword(r.Context(), uuid.Nil, email, password, []byte(h.srv.cfg.Pepper)) if err != nil { w.WriteHeader(http.StatusUnauthorized) - loginTmpl.ExecuteTemplate(w, "layout", map[string]string{ - "Error": "Invalid credentials", - "CSRF": csrfToken(w, r), + h.assets.renderPage(w, "login", map[string]any{ + "csrf": csrfToken(w, r), + "error": "Wrong email or password. Please try again.", }) return } @@ -198,7 +229,7 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, &http.Cookie{ - Name: "session", + Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, @@ -214,24 +245,26 @@ func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid csrf token", http.StatusForbidden) return } - if c, err := r.Cookie("session"); err == nil { + if c, err := r.Cookie(sessionCookie); err == nil { h.srv.db.UserDeleteSession(r.Context(), uuid.Nil, c.Value) } - clearCookie(w, "session") - clearCookie(w, "csrf") + clearCookie(w, sessionCookie) + clearCookie(w, csrfCookie) http.Redirect(w, r, "/auth/login", http.StatusSeeOther) } // csrfToken returns the current CSRF token, setting a cookie if absent. func csrfToken(w http.ResponseWriter, r *http.Request) string { - if c, err := r.Cookie("csrf"); err == nil && c.Value != "" { + if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" { return c.Value } b := make([]byte, 32) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } token := base64.RawURLEncoding.EncodeToString(b) http.SetCookie(w, &http.Cookie{ - Name: "csrf", + Name: csrfCookie, Value: token, Path: "/", HttpOnly: true, @@ -241,14 +274,19 @@ func csrfToken(w http.ResponseWriter, r *http.Request) string { return token } -// csrfOK checks that the form field matches the cookie (double-submit). +// csrfOK checks that the form field or X-CSRF-Token header matches the +// cookie (double-submit). Form posts use the hidden "csrf" field; API calls +// from the SPA pass the token via the X-CSRF-Token header. func csrfOK(r *http.Request) bool { - cookie, err := r.Cookie("csrf") + cookie, err := r.Cookie(csrfCookie) if err != nil || cookie.Value == "" { return false } - field := r.FormValue("csrf") - return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(field)) == 1 + token := r.FormValue("csrf") + if token == "" { + token = r.Header.Get("X-CSRF-Token") + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(token)) == 1 } func clearCookie(w http.ResponseWriter, name string) { diff --git a/cmd/mirumd/static.go b/cmd/mirumd/static.go new file mode 100644 index 0000000..3a23159 --- /dev/null +++ b/cmd/mirumd/static.go @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/sha512" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" +) + +//go:embed web/*.html +var templateFS embed.FS + +var shellTmpl = template.Must(template.ParseFS(templateFS, "web/shell.html")) + +type assetRef struct { + Href string + Integrity string // "sha384-BASE64" or empty +} + +type pageAssets struct { + CSS []assetRef // + Scripts []assetRef // + {{- if .Preamble}} + + {{- end}} + {{- range .Scripts}} + + {{- end}} + + diff --git a/cmd/mirumd/web/tsconfig.json b/cmd/mirumd/web/tsconfig.json new file mode 100644 index 0000000..90b2f46 --- /dev/null +++ b/cmd/mirumd/web/tsconfig.json @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./*"] }, + + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + }, + "include": ["components", "api", "lib", "entries", "gen"] +} diff --git a/cmd/mirumd/web/vite.config.ts b/cmd/mirumd/web/vite.config.ts new file mode 100644 index 0000000..6de11ff --- /dev/null +++ b/cmd/mirumd/web/vite.config.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readdirSync } from "node:fs" +import { resolve, parse } from "node:path" +import { defineConfig } from "vite" + +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" + +const input = readdirSync(resolve(__dirname, "entries")).reduce((acc, file) => { + const {name} = parse(file); + acc[name] = resolve(__dirname, "entries", file) + return acc +}, {} as Record) + +export default defineConfig({ + clearScreen: false, + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": resolve(__dirname) }, + }, + server: { + host: "127.0.0.1", + origin: "http://localhost:5173", + cors: { origin: "http://localhost:3000" }, + }, + build: { + outDir: "../static", + manifest: true, + emptyOutDir: true, + rollupOptions: { + input, + output: { + entryFileNames: "assets/[name].[hash].js", + chunkFileNames: "assets/[name].[hash].js", + assetFileNames: "assets/[name].[hash][extname]", + manualChunks(id) { + if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) { + return "vendor" + } + }, + }, + }, + }, +}) diff --git a/cmd/mirumw/main.go b/cmd/mirumw/main.go index 762b772..e2132b5 100644 --- a/cmd/mirumw/main.go +++ b/cmd/mirumw/main.go @@ -43,12 +43,17 @@ func main() { } slog.Info("connected", "server", cfg.Server) - backoff.Reset() if err := c.work(ctx); err != nil && ctx.Err() == nil { slog.Error("work loop failed", "err", err) + c.close() + if !backoff.Wait(ctx) { + break + } + continue } + backoff.Reset() c.close() } diff --git a/internal/database/database.go b/internal/database/database.go index 8e36e58..7a2ff26 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -172,13 +172,28 @@ func (db *DB) Migrate(ctx context.Context) error { ALTER TABLE users FORCE ROW LEVEL SECURITY; CREATE POLICY superuser ON users FOR ALL USING (app_issuper()); - CREATE POLICY user_select ON users FOR SELECT USING (id = app_user_id()); + + -- SELECT is split into base case (own row) and extended case (users + -- who share an org membership with the caller). The base case is a + -- pure predicate — it lets the extended subquery resolve + -- app_user_id()'s own memberships without recursion. + CREATE POLICY user_select_self ON users FOR SELECT + USING (id = app_user_id()); + CREATE POLICY user_select_shared_org ON users FOR SELECT + USING (EXISTS ( + SELECT 1 FROM org_members target + JOIN org_members mine ON mine.org_id = target.org_id + WHERE target.user_id = users.id + AND mine.user_id = app_user_id() + )); + CREATE POLICY user_update ON users FOR UPDATE USING (id = app_user_id()); CREATE POLICY user_delete ON users FOR DELETE USING (id = app_user_id()); CREATE POLICY user_insert ON users FOR INSERT WITH CHECK (app_issuper()); `, ` DROP POLICY superuser ON users; - DROP POLICY user_select ON users; + DROP POLICY user_select_self ON users; + DROP POLICY user_select_shared_org ON users; DROP POLICY user_insert ON users; DROP POLICY user_update ON users; DROP POLICY user_delete ON users; @@ -223,7 +238,18 @@ func (db *DB) Migrate(ctx context.Context) error { ALTER TABLE org_members FORCE ROW LEVEL SECURITY; CREATE POLICY superuser ON org_members FOR ALL USING (app_issuper()); - CREATE POLICY select_member ON org_members FOR SELECT USING (has_org_role(org_id, '{owner,admin,member}')); + + -- SELECT is split into two permissive policies. The base case + -- (own membership rows) is a pure column predicate with no function + -- call, which is enough for has_org_role's inner query to succeed — + -- has_org_role always filters by user_id = app_user_id(). Without + -- this split, the admin-scoped policy calls has_org_role which + -- queries org_members which calls has_org_role → stack overflow. + CREATE POLICY select_own_member ON org_members FOR SELECT + USING (user_id = app_user_id()); + CREATE POLICY select_org_member ON org_members FOR SELECT + USING (has_org_role(org_id, '{owner,admin,member}')); + CREATE POLICY insert_member ON org_members FOR INSERT WITH CHECK ( has_org_role(org_id, '{owner,admin}') OR ( @@ -236,7 +262,8 @@ func (db *DB) Migrate(ctx context.Context) error { CREATE POLICY delete_member ON org_members FOR DELETE USING (has_org_role(org_id, '{owner,admin}')); `, ` DROP POLICY superuser ON org_members; - DROP POLICY select_member ON org_members; + DROP POLICY select_own_member ON org_members; + DROP POLICY select_org_member ON org_members; DROP POLICY insert_member ON org_members; DROP POLICY update_member ON org_members; DROP POLICY delete_member ON org_members; diff --git a/internal/database/user.go b/internal/database/user.go index 577ac2f..972337d 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -66,8 +66,9 @@ type User struct { // Session holds info about an authenticated session. type Session struct { - UserID uuid.UUID - Email string + UserID uuid.UUID + Email string + Superuser bool } // hashToken returns the hex-encoded SHA-256 of a session token. @@ -405,11 +406,11 @@ func (db *DB) UserGetSession(ctx context.Context, actor uuid.UUID, token string) var expiresAt time.Time if err := tx.QueryRow(ctx, - `SELECT s.user_id, u.email, s.expires_at + `SELECT s.user_id, u.email, u.superuser, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, h, - ).Scan(&s.UserID, &s.Email, &expiresAt); err != nil { + ).Scan(&s.UserID, &s.Email, &s.Superuser, &expiresAt); err != nil { return nil, err } diff --git a/pkg/mirumw-default.yaml b/pkg/mirumw-default.yaml index 832e35c..3fc2076 100644 --- a/pkg/mirumw-default.yaml +++ b/pkg/mirumw-default.yaml @@ -3,12 +3,15 @@ server: localhost:2026 -# Ed25519 key pair for worker authentication. +# Ed25519 private key for worker authentication (PEM-encoded PKCS8). # Generate with: # openssl genpkey -algorithm Ed25519 -out /etc/mirum/mirumw-default.key -# openssl pkey -in /etc/mirum/mirumw-default.key -pubout -out /etc/mirum/mirumw-default.pub +# chmod 600 /etc/mirum/mirumw-default.key +# +# Register the public key on the daemon: +# PUBKEY=$(openssl pkey -in /etc/mirum/mirumw-default.key -pubout -outform der | base64 -w0) +# mirumd --socket /run/mirumd/admin.sock worker add --pubkey "$PUBKEY" key_file: /etc/mirum/mirumw-default.key -pub_key_file: /etc/mirum/mirumw-default.pub # Custom CA certificate for self-signed/dev TLS. # Leave empty to use system trust store. diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml index f0e725d..0f24cdc 100644 --- a/proto/buf.gen.yaml +++ b/proto/buf.gen.yaml @@ -9,3 +9,8 @@ plugins: - local: protoc-gen-connect-go out: ../internal/protocol/pb opt: paths=source_relative + - remote: buf.build/bufbuild/es:v2.11.0 + out: ../cmd/mirumd/web/gen + opt: + - target=ts + - import_extension=js -- Gilti