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.
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.
source/static/app.webmanifest defines the app name, icons, theme color, and
display mode. It is copied to public/app.webmanifest during the build.
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):
install: Calls self.skipWaiting() and pre-caches all assets from
the build-injected list. Each URL is wrapped in
new Request(url, { cache: 'reload' }) so the fetch bypasses the
browser’s HTTP cache. This prevents a stale HTTP-cache entry (kept
fresh for up to a week by Cache-Control: max-age=604800) from being
copied into the new service-worker cache on install.activate: Deletes every cache whose name does not match the current
CACHE_NAME, then calls self.clients.claim() so the new worker
immediately controls every open tab without waiting for the user to
close and reopen them.fetch: Intercepts requests and applies the strategy table above.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.
source/assets/js/client/sw-register.js registers the service worker on every
page. It checks for navigator.serviceWorker support before registering.
Every render function adds to <head>:
<link rel="manifest" href="app.webmanifest"><meta name="theme-color" content="..."> (terracotta from design palette)<meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="default"><link rel="apple-touch-icon" href="images/sbsommar-icon-192.png">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 |
| 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 |
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.
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).
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.
POST /verify-admin accepts { "token": "<string>" } and responds:
200 { "valid": true } — token found in ADMIN_TOKENS403 { "valid": false } — token not foundThe comparison uses constant-time string comparison to prevent timing attacks.
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.
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.
/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.
| 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) |
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.
| 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.
app.js + express-rate-limit)express-rate-limit
middleware, installed as a runtime dependency.rateLimit({ windowMs, limit, ... })
instance defined at the top of app.js; the instance is applied as
per-route middleware. Separate instances keep per-endpoint counters
independent.express-rate-limit derives the key from
req.ip, which honours the Express trust proxy setting. app.js
sets app.set('trust proxy', 'loopback') so only loopback-connected
reverse proxies are permitted to set X-Forwarded-For. Deployments
behind a non-loopback proxy are expected to configure an appropriate
trust boundary in their reverse-proxy layer.429 with the standard
Retry-After and RateLimit-* headers; app.js overrides the
response body via the handler option to produce the Swedish error
payload defined in §31.5.api/src/RateLimit.php)SBSommar\RateLimit::isLimited(string $ip, string $namespace,
int $limit, int $windowSeconds): bool.sys_get_temp_dir()
(sbsommar_rate_limit.json), with namespaced keys so endpoints share
storage but not quotas.api/index.php: HTTP_X_FORWARDED_FOR
→ REMOTE_ADDR.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.
| 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 |
trust proxy is set to 'loopback'. Deployments that place
a non-loopback reverse proxy in front of app.js must widen the
trust setting or accept that X-Forwarded-For will not be honoured
for rate-limit keying. The PHP handler continues to trust
HTTP_X_FORWARDED_FOR as-is, matching the hosting-environment
assumption for shared PHP hosts.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.
slugify() — linear-time trimslugify() 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.
tests/helpers/regex-escape.js — shared escapeRegExptests/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.
| 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 |