clone

a photo toolkit for your phone that never uploads: fuji film looks for raw files, social crops, error level analysis

Content-hashed static assets, network-first pages, immutable cache headers

Dami committed d0461c2
commit
d0461c2d4dcb2798d65f6f9cf553ae156650924a
parent
22d0b46654c9ca6c0cd0a818189410b6546e0ec0
author
Dami <dami.etoile@gmail.com>
4 files changed, +87 -22
MREADME.md2+-
M_headers17+++++++-
Mscripts/package.py70++++++++++++++++++++++++------
Msw.js20++++++---
M README.md +1 -1
M _headers +16 -1
M scripts/package.py +57 -13
@@ -1,17 +1,61 @@
1-"""Package public assets into dist/.1+"""Package public assets into dist/ with content-hashed filenames.22 SPDX-License-Identifier: AGPL-3.0-or-later3+4+Scripts, styles and wasm land in dist/static/ as name.<hash>.ext and every5+reference to them is rewritten, so browsers and the service worker can cache6+them forever while HTML pages are always fetched fresh.37 """48 from pathlib import Path5-import shutil, hashlib6-root=Path(__file__).resolve().parent.parent7-dist=root/'dist'8-dist.mkdir(exist_ok=True)9-public=['index.html','perspective.html','fuji.html','style.css','viewport.js','home.mjs','app.mjs','fuji.mjs','raf.mjs','worker.mjs','geometry.mjs','manifest.webmanifest','NOTICE.md','README.md','_headers']10-for f in public: shutil.copyfile(root/f,dist/f)11-for d in ['assets','LICENSES']: shutil.copytree(root/d,dist/d,dirs_exist_ok=True)12-digest=hashlib.sha256()9+import shutil, hashlib, re10+root = Path(__file__).resolve().parent.parent11+dist = root / 'dist'12+if dist.exists(): shutil.rmtree(dist)13+(dist / 'static').mkdir(parents=True)14+15+# Hashed files in dependency order: leaves first, so a file's hash covers its rewritten references.16+HASHED = ['assets/core.wasm', 'assets/core.mjs', 'geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'fuji.mjs', 'home.mjs', 'viewport.js', 'style.css']17+# Fixed-name text files that reference hashed ones.18+PAGES = ['index.html', 'perspective.html', 'fuji.html']19+FIXED = ['manifest.webmanifest', 'NOTICE.md', 'README.md', '_headers']20+DIRS = ['LICENSES']21+ICONS = ['assets/icon.svg', 'assets/icon-192.png', 'assets/icon-512.png']22+23+def digest(data): return hashlib.sha256(data).hexdigest()[:10]24+mapping = {} # source path -> dist path (relative to dist)25+26+def rewrite(text, at_dir):27+ """Replace './<source path>' references with the hashed dist path relative to at_dir."""28+ for src, dst in mapping.items():29+ rel = Path(dst).relative_to(at_dir) if Path(dst).is_relative_to(at_dir) else Path('..') / dst30+ text = text.replace("'./" + src + "'", "'./" + rel.as_posix() + "'").replace('"./' + src + '"', '"./' + rel.as_posix() + '"')31+ return text32+33+for src in HASHED:34+ p = root / src35+ data = p.read_bytes()36+ if p.suffix != '.wasm':37+ text = rewrite(data.decode(), 'static')38+ if src == 'assets/core.mjs': # emscripten loader refers to its binary by bare name39+ text = text.replace('"core.wasm"', '"' + Path(mapping['assets/core.wasm']).name + '"')40+ data = text.encode()41+ name = f'{p.stem}.{digest(data)}{p.suffix}'42+ mapping[src] = f'static/{name}'43+ (dist / 'static' / name).write_bytes(data)44+45+for src in PAGES + FIXED:46+ (dist / src).parent.mkdir(parents=True, exist_ok=True)47+ text = (root / src).read_text()48+ if src.endswith('.html'): text = rewrite(text, '.')49+ (dist / src).write_text(text)50+for d in DIRS: shutil.copytree(root / d, dist / d)51+(dist / 'assets').mkdir(exist_ok=True)52+for src in ICONS: shutil.copyfile(root / src, dist / src)53+54+# Service worker: precache list and a cache name derived from everything in dist.55+precache = ['./', './perspective', './fuji'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]56+h = hashlib.sha256()1357 for p in sorted(dist.rglob('*')):14- if p.is_file() and p.name!='sw.js': digest.update(p.read_bytes())15-sw=(root/'sw.js').read_text().replace("gudpics-v1",'gudpics-'+digest.hexdigest()[:12])16-(dist/'sw.js').write_text(sw)17-print(f'Packaged {sum(1 for p in dist.rglob("*") if p.is_file())} files')58+ if p.is_file(): h.update(p.read_bytes())59+sw = (root / 'sw.js').read_text().replace('__CACHE__', 'gudpics-' + h.hexdigest()[:12]).replace('__FILES__', ','.join(f"'{f}'" for f in precache))60+(dist / 'sw.js').write_text(sw)61+print(f'Packaged {sum(1 for p in dist.rglob("*") if p.is_file())} files; ' + ', '.join(Path(v).name for v in mapping.values()))
M sw.js +13 -7
@@ -1,17 +1,23 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later2-const CACHE='gudpics-v1';3-const FILES=['./','./index.html','./perspective','./fuji','./style.css','./viewport.js','./home.mjs','./app.mjs','./fuji.mjs','./raf.mjs','./worker.mjs','./geometry.mjs','./manifest.webmanifest','./assets/core.mjs','./assets/core.wasm','./assets/icon.svg','./assets/icon-192.png','./assets/icon-512.png','./NOTICE.md','./LICENSES/AGPL-3.0.txt','./LICENSES/GPL-3.0.txt'];2+// The cache name and precache list are filled in by scripts/package.py.3+const CACHE='__CACHE__';4+const FILES=[__FILES__];5+const PAGES={'/':'./','/perspective':'./perspective','/fuji':'./fuji'};46 self.addEventListener('install',e=>{self.skipWaiting();e.waitUntil(caches.open(CACHE).then(c=>c.addAll(FILES)));});57 self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));68 self.addEventListener('fetch',e=>{79 const url=new URL(e.request.url);810 if(e.request.method!=='GET'||url.origin!==self.location.origin)return;11+ const path=url.pathname.replace(/\/+$/,'')||'/';12+ if(e.request.mode==='navigate'||path in PAGES){13+ // Pages: network first so a new deploy is picked up on the next open; cache only when offline.14+ e.respondWith(fetch(e.request).then(r=>{if(r.ok)caches.open(CACHE).then(c=>c.put(PAGES[path]||e.request,r.clone()));return r;})15+ .catch(async()=>{const c=await caches.open(CACHE);return (await c.match(PAGES[path]||'./'))||c.match('./');}));16+ return;17+ }18+ // Hashed assets never change under the same name: cache first.919 e.respondWith(caches.open(CACHE).then(async c=>{1020 const cached=await c.match(e.request,{ignoreSearch:true});if(cached)return cached;11- try{return await fetch(e.request);}12- catch(error){13- if(e.request.mode==='navigate')return (await c.match(url.pathname.replace(/\/$/,'').endsWith('/perspective')?'./perspective':'./index.html'))||c.match('./index.html');14- throw error;15- }21+ const r=await fetch(e.request);if(r.ok&&url.pathname.startsWith('/static/'))c.put(e.request,r.clone());return r;1622 }));1723 });