simpleadmin-web/internal/web/pwa.go
Astra 17232bd5a2 Staff push notifications, and revalidate embedded assets
Notifications: staff turn them on per device from a new Notifications page,
pick each type (bans, gags, mutes and silences added or lifted, server down
and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game
penalties are sent too. Web Push with VAPID and RFC 8291 encryption in
internal/push, standard library only. Tables in sql/push.sql, created at
startup; the VAPID key is made on first start and kept in saw_settings.
Penalty notifications link to the public list searching for the player
(#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3).

Caching: pages, scripts and styles are served with Cache-Control: no-cache
and an ETag, so a deploy shows up at once instead of after Cloudflare's
default 4-hour browser cache.
2026-10-03 13:49:40 +01:00

99 lines
3.2 KiB
Go

package web
import (
"encoding/json"
"io/fs"
"net/http"
"strings"
)
// The site installs as an app (a PWA): a web app manifest, plus the service worker in
// assets/sw.js. The public site and the staff panel are separate apps, so each opens on its own
// page; both cover the whole site, since signing in goes through /auth.
type manifest struct {
ID string `json:"id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Description string `json:"description"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Icons []manifestIcon `json:"icons"`
}
type manifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose,omitempty"`
}
func (s *Server) manifest(staff bool) http.HandlerFunc {
m := manifest{
ID: "/",
Name: s.site.Title,
ShortName: s.site.Title,
Description: "Live server, bans and staff.",
StartURL: "/",
Scope: "/",
Display: "standalone",
BackgroundColor: "#14161b",
ThemeColor: "#1b1e25",
Icons: []manifestIcon{
{Src: "/static/icon-192.png", Sizes: "192x192", Type: "image/png"},
{Src: "/static/icon-512.png", Sizes: "512x512", Type: "image/png"},
{Src: "/static/icon-maskable-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
},
}
if staff {
m.ID = "/admin"
m.Name = s.site.Title + " staff panel"
m.ShortName = s.site.Title + " staff"
m.Description = "Bans, mutes, ranks and the live server, for staff."
m.StartURL = "/admin"
}
body, _ := json.Marshal(m)
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "no-cache")
w.Write(body)
}
}
// assetLinks tells Chrome the Android app belongs to this site, so it opens the site full screen
// (a Trusted Web Activity) instead of with its URL bar showing.
func (s *Server) assetLinks(w http.ResponseWriter, r *http.Request) {
var certs []string
for _, c := range s.cfg.AndroidCerts {
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
certs = append(certs, c)
}
}
if s.cfg.AndroidPackage == "" || len(certs) == 0 {
http.NotFound(w, r)
return
}
type target struct {
Namespace string `json:"namespace"`
PackageName string `json:"package_name"`
Fingerprints []string `json:"sha256_cert_fingerprints"`
}
type statement struct {
Relation []string `json:"relation"`
Target target `json:"target"`
}
writeJSON(w, http.StatusOK, []statement{{
Relation: []string{"delegate_permission/common.handle_all_urls"},
Target: target{Namespace: "android_app", PackageName: s.cfg.AndroidPackage, Fingerprints: certs},
}})
}
// serviceWorker serves sw.js from the root, since a worker only controls pages under its own path.
func serviceWorker(static fs.FS) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
serveAsset(w, r, static, "sw.js")
}
}