All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Jeder Teilen-Vorgang endete bisher in einer Sackgasse — Login-Hinweis oder "Keine geteilten Fotos gefunden", und danach war das Geteilte weg. Zwei Ursachen: 1. share.html prüfte api.getToken() — ein JWT in IndexedDB, das es seit der SSO-Umstellung nicht mehr gibt (api.login() legt nur noch 'user' ab, die Sitzung steckt im HttpOnly-Cookie awl_sso). Der Wert war immer leer, also kam IMMER "Bitte zuerst anmelden"; der Weg "Zur App" verwarf das Geteilte. Jetzt: Cookie-Prüfung über api/verify.php wie in ensureAuth(), und die Anmeldung (Passwort + Passkey) läuft IN der Teilen-Seite — das Geteilte bleibt dabei gesichert und der Ablauf geht danach direkt weiter. 2. Der Service Worker sicherte nur Dateien. Titel, Text und Link aus dem share_target wurden weggeworfen, ein reiner Text-Share schrieb gar nichts. Jetzt wird das ganze Paket gesichert und immer weitergeleitet (absolute Redirect-URL, leere File-Platzhalter fliegen raus). Die Teilen-Seite zeigt Bildvorschau, Betreff und Text zum Nachbessern und eine Auftragssuche (ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle). Beim Ablegen gehen die Bilder über die normale Upload-Queue (Persist-First), der Text wird als notiz_*.txt über api/note.php gespeichert. Scheitert die Notiz (kein Netz), bleibt der Text gesichert und der Vorgang ist wiederholbar. Dazu ein Banner in der App, wenn noch ein Paket unabgelegt bereitliegt — abgebrochene Vorgänge findet sonst niemand wieder. share.html liest auch den alten IndexedDB-Schlüssel, damit der erste Share nach dem Deploy (noch alter Service Worker) nicht verlorengeht. Lokal durchgespielt: Bild+Text, nur Text, und abgemeldet teilen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
/* Baustelle PWA Service Worker
|
|
* Pattern nach claude-db #201 (filemtime-basiert):
|
|
* - Cache-Version kommt aus URL-Query ?v=<mtime>, kein manuelles Hochzählen
|
|
* - Network-First für eigene Assets (immer aktuell, Fallback Cache)
|
|
* - skipWaiting() + clients.claim() damit Updates sofort greifen
|
|
* - Web Share Target via POST → share.html
|
|
*/
|
|
|
|
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
|
|
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
|
|
const CACHE = 'baustelle-' + SW_VERSION;
|
|
|
|
/* Bild-Cache für die Thumbnails aus photo.php — bewusst NICHT versioniert, damit ein
|
|
* Deploy die schon geladenen Fotos nicht wegräumt. Ohne das ist auf der Baustelle ohne
|
|
* Netz kein einziges Foto sichtbar, auch keins, das man vorher schon angesehen hat. */
|
|
const MEDIA_CACHE = 'baustelle-media';
|
|
const MEDIA_MAX = 300;
|
|
|
|
async function trimMediaCache() {
|
|
const c = await caches.open(MEDIA_CACHE);
|
|
const keys = await c.keys();
|
|
// keys() liefert in Einfüge-Reihenfolge — die ältesten stehen vorn
|
|
for (let i = 0; i < keys.length - MEDIA_MAX; i++) await c.delete(keys[i]);
|
|
}
|
|
|
|
/* Thumbnails: erst Cache, dann Netz (und im Hintergrund auffrischen).
|
|
* Die URL enthält relpath + Größe, und der Server schickt ein ETag aus der mtime —
|
|
* ein ersetztes Original bekommt damit ohnehin eine neue Antwort. */
|
|
async function mediaCacheFirst(request) {
|
|
const c = await caches.open(MEDIA_CACHE);
|
|
const hit = await c.match(request);
|
|
if (hit) {
|
|
// Auffrischen ohne den Nutzer warten zu lassen
|
|
fetch(request).then(r => {
|
|
if (r && r.ok) c.put(request, r.clone()).then(trimMediaCache);
|
|
}).catch(() => null);
|
|
return hit;
|
|
}
|
|
try {
|
|
const r = await fetch(request);
|
|
if (r && r.ok) { await c.put(request, r.clone()); trimMediaCache(); }
|
|
return r;
|
|
} catch (e) {
|
|
// Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter
|
|
return new Response('', { status: 504, statusText: 'Offline' });
|
|
}
|
|
}
|
|
const SHELL = [
|
|
'./',
|
|
'./index.php',
|
|
'./share.html',
|
|
'./icons/icon-192.png',
|
|
'./icons/icon-512.png',
|
|
'./icons/icon.svg',
|
|
// CSS/JS werden mit ?v=<mtime> geladen und dynamisch gecached
|
|
// beim ersten fetch — nicht in SHELL damit Pre-Cache nicht mit alten v's läuft
|
|
];
|
|
|
|
self.addEventListener('install', (e) => {
|
|
e.waitUntil(
|
|
caches.open(CACHE)
|
|
.then(c => c.addAll(SHELL).catch(() => null))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (e) => {
|
|
e.waitUntil(
|
|
caches.keys()
|
|
.then(keys => Promise.all(
|
|
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
|
|
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
|
|
.map(k => caches.delete(k))
|
|
))
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
/* ==========================================================================
|
|
* Web Share Target — was aus WhatsApp & Co. geteilt wird, hier annehmen
|
|
*
|
|
* Der POST-Body gibt seine Daten genau EINMAL her. Deshalb wird alles sofort in
|
|
* IndexedDB gesichert, bevor irgendetwas angezeigt wird; share.html liest es von dort.
|
|
*
|
|
* Bis 30.08.2026 wurden nur Dateien gesichert. Teilt jemand aus WhatsApp einen
|
|
* Beschreibungstext (mit oder ohne Bild), landete der Text nirgends — die Seite
|
|
* meldete dann "Keine geteilten Fotos gefunden". Titel, Text und Link gehoeren
|
|
* genauso ins Paket wie die Bilder.
|
|
* ========================================================================== */
|
|
const SHARE_KEY = 'shared_payload';
|
|
|
|
function shareDb() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open('baustelle-pwa-v1', 1);
|
|
req.onupgradeneeded = () => {
|
|
const d = req.result;
|
|
if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv');
|
|
if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function isSharedFile(v) {
|
|
return v && typeof v === 'object' && typeof v.arrayBuffer === 'function' && v.size > 0;
|
|
}
|
|
|
|
async function handleShareTarget(request) {
|
|
try {
|
|
const fd = await request.formData();
|
|
|
|
let files = [];
|
|
for (const key of ['photos', 'file', 'files', 'image', 'images']) {
|
|
for (const v of fd.getAll(key)) if (isSharedFile(v)) files.push(v);
|
|
}
|
|
// Fallback: manche Apps benennen das Feld anders — alles nehmen, was eine Datei ist
|
|
if (!files.length) {
|
|
for (const [, v] of fd.entries()) if (isSharedFile(v)) files.push(v);
|
|
}
|
|
|
|
const str = (k) => { const v = fd.get(k); return typeof v === 'string' ? v.trim() : ''; };
|
|
const payload = {
|
|
title: str('title'),
|
|
text: str('text'),
|
|
url: str('url'),
|
|
files: files.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', data: f })),
|
|
ts: Date.now(),
|
|
};
|
|
|
|
const db = await shareDb();
|
|
const tx = db.transaction('kv', 'readwrite');
|
|
tx.objectStore('kv').put(payload, SHARE_KEY);
|
|
tx.objectStore('kv').delete('shared_files'); // altes Format, sonst taucht eine alte Ladung wieder auf
|
|
await new Promise((resolve, reject) => {
|
|
tx.oncomplete = resolve;
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
} catch (e) {
|
|
// Nicht am Sichern haengenbleiben — share.html meldet dann, dass nichts ankam
|
|
}
|
|
// Absolute Adresse: eine relative wuerde Response.redirect je nach Browser ablehnen
|
|
return Response.redirect(new URL('share.html', self.location.href).href, 303);
|
|
}
|
|
|
|
self.addEventListener('fetch', (e) => {
|
|
const url = new URL(e.request.url);
|
|
|
|
// Web Share Target POST → share.html
|
|
if (e.request.method === 'POST' && url.pathname.endsWith('/share.html')) {
|
|
e.respondWith(handleShareTarget(e.request));
|
|
return;
|
|
}
|
|
|
|
// Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht
|
|
if (e.request.method === 'GET'
|
|
&& url.pathname.endsWith('/custom/bericht/api/photo.php')
|
|
&& url.searchParams.get('size') === 'thumb') {
|
|
e.respondWith(mediaCacheFirst(e.request));
|
|
return;
|
|
}
|
|
|
|
// Übrige API-Requests: nicht cachen, durchreichen
|
|
if (url.pathname.includes('/custom/bericht/api/')) {
|
|
return;
|
|
}
|
|
|
|
// Nicht-GETs: durchreichen
|
|
if (e.request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Eigene Assets: Network-First mit Cache-Fallback
|
|
if (url.origin === location.origin) {
|
|
e.respondWith(
|
|
fetch(e.request)
|
|
.then(response => {
|
|
// Cache aktualisieren (nur erfolgreiche responses)
|
|
if (response && response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone)).catch(() => null);
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => caches.match(e.request))
|
|
);
|
|
return;
|
|
}
|
|
});
|
|
|
|
/* Message-Handler: Client kann "SKIP_WAITING" schicken um ein wartenden SW
|
|
* sofort zu aktivieren (für In-App-Update-Button). */
|
|
self.addEventListener('message', (e) => {
|
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
// Beim Abmelden dürfen keine Kundenfotos auf dem Gerät zurückbleiben
|
|
if (e.data && e.data.type === 'CLEAR_MEDIA') {
|
|
e.waitUntil(caches.delete(MEDIA_CACHE));
|
|
}
|
|
});
|
|
|
|
/* ==========================================================================
|
|
* Background Sync — Fotos nachliefern, auch wenn die App gar nicht offen ist
|
|
*
|
|
* Bis hierher lief das Nachliefern ausschliesslich in der Seite selbst: über
|
|
* das online-Ereignis, „App wieder sichtbar" und einen 15-Sekunden-Takt. Ist
|
|
* das Display aus oder die App weggewischt, friert der Browser diese Timer ein
|
|
* — es passierte schlicht nichts, bis jemand die App wieder aufmachte.
|
|
*
|
|
* Der Service Worker läuft unabhängig davon. Er greift auf dieselbe
|
|
* IndexedDB-Queue zu (baustelle-pwa-v1 / Store "queue") und lädt selbst hoch;
|
|
* die Anmeldung steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
|
|
* automatisch mit — deshalb braucht es hier kein Token-Handling.
|
|
*
|
|
* Gelöscht wird ein Eintrag nur bei BESTÄTIGTEM Upload: HTTP 2xx, echtes JSON
|
|
* und ein relpath in der Antwort. Alles andere bleibt liegen; wir lehnen das
|
|
* Sync-Versprechen ab, dann plant der Browser den nächsten Versuch selbst.
|
|
* ========================================================================== */
|
|
|
|
const QUEUE_DB = 'baustelle-pwa-v1';
|
|
const QUEUE_STORE = 'queue';
|
|
const UPLOAD_BASE = '/custom/bericht/api/orders.php';
|
|
|
|
function queueDb() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(QUEUE_DB); // KEINE Version angeben:
|
|
req.onsuccess = () => resolve(req.result); // das Schema legt die Seite an
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function queueRead(db) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(QUEUE_STORE, 'readonly');
|
|
const req = tx.objectStore(QUEUE_STORE).getAll();
|
|
req.onsuccess = () => resolve(req.result || []);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function queueRemove(db, id) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
|
tx.objectStore(QUEUE_STORE).delete(id);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
|
|
async function tellClients(msg) {
|
|
const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
|
|
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
|
|
}
|
|
|
|
/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
|
|
* sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */
|
|
async function uploadQueued(item) {
|
|
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
|
|
const fd = new FormData();
|
|
fd.append('file', blob, item.filename || 'foto.jpg');
|
|
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten
|
|
let r;
|
|
try {
|
|
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
|
|
method: 'POST',
|
|
body: fd,
|
|
credentials: 'same-origin',
|
|
signal: ctrl.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
|
|
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
|
|
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
|
if (ct.indexOf('application/json') === -1) throw new Error('Antwort ist kein JSON');
|
|
|
|
const data = await r.json();
|
|
if (!data || typeof data.relpath !== 'string' || data.relpath === '') {
|
|
throw new Error('Upload nicht bestaetigt (kein relpath)');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function drainQueue() {
|
|
const db = await queueDb();
|
|
const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed);
|
|
if (!items.length) return;
|
|
|
|
let left = 0;
|
|
for (const it of items) {
|
|
try {
|
|
const res = await uploadQueued(it);
|
|
await queueRemove(db, it.id);
|
|
await tellClients({
|
|
type: 'photo-uploaded',
|
|
queueId: it.id,
|
|
relpath: res.relpath,
|
|
orderId: it.order_id,
|
|
filename: it.filename,
|
|
duplicate: !!res.duplicate,
|
|
});
|
|
} catch (_) {
|
|
left++; // bleibt in der Queue, naechster Sync versucht es erneut
|
|
}
|
|
}
|
|
|
|
// Noch etwas offen? Ablehnen, damit der Browser erneut zustellt.
|
|
if (left) throw new Error(left + ' Foto(s) weiterhin offen');
|
|
}
|
|
|
|
self.addEventListener('sync', (e) => {
|
|
if (e.tag === 'photo-queue') e.waitUntil(drainQueue());
|
|
});
|
|
|
|
/* Periodic Background Sync, falls der Browser ihn gewährt (installierte PWA,
|
|
* Chrome/Android). Fängt den Fall ab, dass gar kein sync-Ereignis mehr kommt. */
|
|
self.addEventListener('periodicsync', (e) => {
|
|
if (e.tag === 'photo-queue') e.waitUntil(drainQueue().catch(() => {}));
|
|
});
|