SB Sommar – Project Documentation

SB Sommar – Architecture: Platform and Security

The Progressive Web App layer, admin token infrastructure, authorization endpoint rate limiting, and regex performance and escape hygiene.

Part of the architecture index. Section IDs (03-§N.M) are stable and cited from code; they do not encode the file path.


28. Progressive Web App (PWA)

The site is installable as a Progressive Web App. This gives participants a native-app-like experience when they add sbsommar.se to their home screen, including offline access to cached pages and event data.

Manifest

source/static/app.webmanifest defines the app name, icons, theme color, and display mode. It is copied to public/app.webmanifest during the build.

Service worker

source/static/sw.js lives at the site root (public/sw.js) so its scope covers all pages. It uses a versioned cache name (currently sb-sommar-v6).

Scheme guard: The fetch handler returns early for any request whose URL scheme is not http: or https:. This prevents errors from browser-extension schemes such as chrome-extension:.

Cache strategy:

Request type Strategy Rationale
HTML (navigation) Network-first, cache fallback (ignoreSearch) Users should see fresh content when online
CSS, JS, images Cache-first (exact match), network fallback Static assets are served by cache-busted URL; exact match ensures a new hash triggers a fresh network fetch
events.json Network-first, cache fallback (ignoreSearch) Event data should be fresh when online but available offline
API calls (/api/, /add-event, /edit-event) Network-only (not cached) Mutations must always reach the server

Cache-matching for the network-first strategies (HTML and events.json) uses { ignoreSearch: true } so that cache-busted or query-stringed URLs still match the pre-cached file when falling back offline. The cache-first strategy for static assets does not use ignoreSearch — a request for style.css?v=<newHash> must not satisfy from a cache entry keyed at style.css?v=<oldHash> (§96.5).

Offline fallback: When a navigation request fails and the requested page is not in the cache, the service worker responds with /offline.html — a pre-cached page that tells the user they are offline.

Build-time pre-cache manifest (§92):

The build scans all files in public/ after post-processing and injects a complete pre-cache URL list into sw.js by replacing the /* __PRE_CACHE_URLS__ */ placeholder. This ensures every asset — HTML pages, CSS, JS, images, events.json — is available offline from the first launch. Files excluded from pre-cache: .htaccess, robots.txt, sw.js, version.json, .ics, .rss, and per-event detail pages.

Lifecycle (§96 — self-healing upgrade):

Combined, skipWaiting + clients.claim + cache: 'reload' on pre-cache mean that any client whose prior service worker had cached a stale /style.css self-heals on the first post-deploy visit: the browser fetches the new sw.js (bypassing its HTTP cache because updateViaCache: 'imports' is Chrome’s default for registered service workers), installs it, activates it immediately, wipes the old cache, and rebuilds the new cache from network-only responses.

Offline guard (§92):

Form pages (lagg-till.html, redigera.html) and the feedback modal include offline detection. When the user is offline, a Swedish-language banner appears and submit buttons are disabled. When connectivity returns, the banner disappears and buttons are re-enabled. offline-guard.js handles the form pages; feedback.js handles the feedback modal internally.

Registration

source/assets/js/client/sw-register.js registers the service worker on every page. It checks for navigator.serviceWorker support before registering.

HTML head tags

Every render function adds to <head>:

Icons

Two PNG icons are required in source/content/images/:

File Size Purpose
sbsommar-icon-192.png 192×192 Android home screen, Apple touch icon
sbsommar-icon-512.png 512×512 Android splash screen

Files

File Role
source/static/app.webmanifest PWA manifest
source/static/sw.js Service worker
source/assets/js/client/sw-register.js Service worker registration
source/build/render-offline.js Offline fallback page renderer
source/content/images/sbsommar-icon-192.png App icon 192×192
source/content/images/sbsommar-icon-512.png App icon 512×512
source/assets/js/client/pwa-install.js PWA install button logic
source/assets/js/client/offline-guard.js Offline detection for form pages

28.7 Install guide

A discreet install button in the site header helps users discover that the site can be installed as an app. The button appears alongside the existing header controls (hamburger menu, scroll-to-top, feedback).

Platform detection and behaviour:

Platform Detection Button action
Chrome / Edge (Android, desktop) beforeinstallprompt event fires Captures event; click calls prompt() on the deferred event
iOS Safari UA contains iPhone/iPad + no beforeinstallprompt Click shows tooltip with manual instruction
Standalone mode matchMedia('(display-mode: standalone)') Button not rendered
Other browsers Neither condition met Button not rendered

After successful installation (appinstalled event), the button is hidden.

The iOS tooltip provides a Swedish instruction (“Tryck på Dela-ikonen och välj ‘Lägg till på hemskärmen’”) and closes on outside click or Escape.

All logic lives in source/assets/js/client/pwa-install.js, loaded with defer on every page via the shared layout.


30. Admin Token Infrastructure

Purpose

A lightweight admin mechanism allows designated administrators to bypass the per-event cookie ownership model. The token infrastructure covers storage, activation, verification, and status display. The edit/delete endpoints and client-side code accept a valid admin token as an alternative to session-cookie ownership (OR condition).

Token storage (server)

Admin tokens are stored in the environment variable ADMIN_TOKENS as a comma-separated list of opaque strings (e.g. UUIDs). Both the Node.js server (app.js) and the PHP API (api/) read this variable at startup. When the variable is unset or empty, all admin functionality is disabled.

Verification endpoint

POST /verify-admin accepts { "token": "<string>" } and responds:

The comparison uses constant-time string comparison to prevent timing attacks.

Token storage (client)

The admin token is stored in localStorage under the key sb_admin:

{ "token": "<string>", "activated": 1710000000000 }

The token is never placed in a cookie. It is sent explicitly in API request bodies when admin privileges are needed.

Expiry

A token is considered expired if more than 30 days (2 592 000 000 ms) have passed since the activated timestamp. Expiry is checked client-side. An expired token behaves as if no token exists.

Activation page

/admin.html — a minimal page with a text input and submit button. On submit, it calls POST /verify-admin. If valid, the token and timestamp are stored in localStorage. The page shares the site layout (header, navigation, footer) but is not listed in the navigation.

A small icon in the shared site footer shows admin status:

State Display
No token in localStorage Nothing shown
Valid token (< 30 days) Filled/locked icon with title “Admin aktiv”
Expired token (> 30 days) Open/unlocked icon with title “Admin utgången”, links to /admin.html

The icon is rendered client-side by a script included in the footer layout, reading from localStorage.

Files (planned)

File Role
source/build/render-admin.js Build-time render of /admin.html
source/assets/js/client/admin.js Client-side: activation form + footer icon
source/build/layout.js Updated pageFooter() to include admin icon container
app.js New POST /verify-admin endpoint (Node.js)
api/index.php New POST /verify-admin endpoint (PHP)

31. Authorization Endpoint Rate Limiting

31.1 Goal

Protect authorization and write endpoints (/verify-admin, /edit-event, /delete-event, /feedback) from brute-force probing and abusive bursts, and give CodeQL’s js/missing-rate-limiting analysis a visible, explicit counter to stop flagging these handlers.

The mechanism is intentionally minimal: a per-IP counter per endpoint with an hourly window, returned as HTTP 429 with a Swedish error message. Coordination across processes is out of scope — single-process Node and single-host PHP both store counter state locally.

31.2 Per-endpoint limits

Endpoint Limit (per IP per hour) Rationale
/verify-admin 5 Brute-force target; admins only verify once per session
/edit-event 30 Normal owner edits; 2–3 attempts per edit, batching tolerated
/delete-event 30 Same profile as edit
/feedback 5 Existing §73.14 limit, unchanged

The rate-limit check runs before validation, authorization, and time-gating, so a throttled client never reaches the admin-token comparison path or the GitHub API.

31.3 Node (app.js + express-rate-limit)

31.4 PHP (api/src/RateLimit.php)

31.5 Failure semantics

On limit exceeded, both runtimes return:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{ "success": false, "error": "För många förfrågningar. Försök igen senare." }

/verify-admin returns the same payload but omits success since its success response uses { "valid": true } — it returns { "error": "För många förfrågningar. Försök igen senare." } with status 429 and no valid key.

31.6 Files

File Role
app.js Defines per-endpoint rateLimit() middleware and applies it to routes
package.json Declares express-rate-limit as a runtime dependency
api/src/RateLimit.php Shared PHP helper
api/src/Feedback.php Uses shared PHP helper
api/index.php Calls helper at the top of each guarded handler

31.7 Known limitations


33. Regex Performance and Escape Hygiene

33.1 Goal

Close CodeQL alerts #17 (js/polynomial-redos) and #30–#32 (js/incomplete-sanitization) and remove two regex patterns that invite future bugs: a polynomial-time trim in the slug builder and an incomplete ad-hoc escape in a test file. The fixes are behaviour- preserving; nothing user-facing or data-facing changes.

33.2 slugify() — linear-time trim

slugify() in source/api/github.js runs on user-supplied activity titles. Before this change the trim step used two separate passes:

.replace(/^-+/, '')
.replace(/-+$/, '')

/^-+/ is linear (anchored at start), but /-+$/ without a start anchor can be O(n²) when the input contains a long run of - characters: the engine retries the -+$ match at every position. After the earlier collapse [^a-z0-9]+/g → '-', runs of - already cannot exceed length 1, so + in the trim regexes is redundant. The fix is a single combined pass:

.replace(/^-|-$/g, '')

Linear, matches at most two characters (one leading, one trailing), and produces identical output for every input.

33.3 tests/helpers/regex-escape.js — shared escapeRegExp

tests/scoped-headings.test.js built a RegExp from a CSS selector string by escaping only . and \s, which is fine for the current literals (.md-preview, .event-desc, .event-description) but malformed as soon as any callsite passes a selector with *, +, ?, ^, $, {, }, (, ), |, [, ], or \. CodeQL’s js/incomplete-sanitization correctly flags this pattern.

A single helper at tests/helpers/regex-escape.js exports the canonical escape:

function escapeRegExp(str) {
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

tests/scoped-headings.test.js imports this helper at each site where a container or heading value is interpolated into a RegExp. No other test file currently builds a pattern from a variable string, so no broader refactor is needed.

33.4 Files changed

File Change
source/api/github.js Collapse two-step trim in slugify() into /^-\|-$/g
tests/helpers/regex-escape.js New helper exporting escapeRegExp(str)
tests/scoped-headings.test.js Replace hand-rolled selector escape with escapeRegExp()
tests/slugify-redos.test.js New test — verifies ReDoS-safe runtime and output equivalence
tests/regex-escape.test.js New test — verifies helper covers every metacharacter