security+feat(web): kill DOM XSS, add CSP, in-browser macro editor
Security:
- Eliminate DOM-based XSS: renderMacros/renderTabs rebuilt with
createElement/textContent/setAttribute + addEventListener. No macro name,
id, image_path, or category ever reaches innerHTML.
- Remove all inline on*= handlers; add a strict Content-Security-Policy meta
(script-src 'self', object-src 'none', base-uri 'none', frame-ancestors
'none').
- Guard JSON.parse on WebSocket frames.
Robustness:
- Service worker: network-first for the app shell + JS/CSS so fixes actually
ship; synthetic 503 for offline API calls; drop no-op /ws intercept; bump
cache version.
- WebSocket reconnect: exponential backoff (1s→30s), tear down old socket
before reconnect, stop on pagehide/beforeunload.
- Tab-switch race fixed with a request-id guard; executing state cleared in
finally.
Accessibility:
- Macro cards and tabs are real buttons with aria-labels/roles; toast +
connection status are aria-live; removed user-scalable=no.
Feature — web-based macro creation/editing:
- Floating "+" button + modal command-sequence builder (text/key/hotkey/
wait/app) with reorder/remove; create via POST, edit via PUT (prefilled
from GET /api/macro/{id}), delete with a named confirm. Works in both local
and relay auth modes. Empty state now offers "Create your first macro".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+51
-33
@@ -1,5 +1,5 @@
|
||||
// MacroPad PWA Service Worker
|
||||
const CACHE_NAME = 'macropad-v3';
|
||||
const CACHE_NAME = 'macropad-v4';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/static/css/styles.css',
|
||||
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install event - cache assets
|
||||
// Install event - pre-cache the app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
// Network-first for navigations + app code so deployed fixes reach clients.
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (err) {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Offline fallback for navigations: serve the cached shell.
|
||||
if (request.mode === 'navigate') {
|
||||
const shell = await cache.match('/');
|
||||
if (shell) {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
return new Response('Offline', {
|
||||
status: 503,
|
||||
statusText: 'Offline',
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch API requests from network
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
|
||||
event.respondWith(fetch(event.request));
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
|
||||
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// For other requests, try cache first, then network
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request).then((networkResponse) => {
|
||||
// Cache successful responses
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Return offline fallback for navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
const url = new URL(request.url);
|
||||
|
||||
// API requests always go to the network; never serve stale data.
|
||||
// Return a synthetic offline JSON response instead of an unhandled undefined.
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => new Response(
|
||||
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
|
||||
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
||||
))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigations and static app assets: network-first with cache fallback.
|
||||
event.respondWith(networkFirst(request));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user