"""Bike-Tinder API (Phase 2) — FastAPI app factory. create_app(db_path, img_dir, state_dir=None) -> FastAPI The factory keeps every path injectable so the selfcheck can run the real router against a temp copy of the DB (`fastapi.testclient.TestClient`). Serve via app/serve.py (uvicorn) — see docs/DESIGN.md for the surface. Payload contract (deck/list/listing items — exact field names the FE relies on; frozen in the task brief): {id, title, price_display, price_num, km, location, region ('ns'|'nb'), url, img ("/img/{id}" if img_url or cached file exists else null), desc, family, score, year, seller_type, extras (array of strings), decision (null|'like'|'maybe'|'nope'), first_seen} B1 additions (UI-SPEC §11 / DATA-MODEL "B1 additions"): n_photos (int), last_photo_idx (int|null); GET /img/{id}?i=n serves gallery photo n. """ import datetime import json import os from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from . import agentpass as agmod from . import db as dbmod from . import importer as imp from . import sections as secmod ACTIONS = ('like', 'nope', 'maybe') # --- SQL ------------------------------------------------------------------ DECK_WHERE = ( "l.status = 'active'" " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" " AND s.action = 'nope')" " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" " AND s.action = 'like')" " AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id" " AND s.action = 'maybe'" " AND COALESCE(s.updated_at, s.created_at)" " > datetime('now', '-7 days'))" ) DECK_SELECT = ( "SELECT l.*, s.action AS decision, s.last_photo_idx AS last_photo_idx" " FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id" " WHERE " + DECK_WHERE ) DECK_ORDER = " ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC" LIST_SORTS = { 'score': 'l.score DESC, COALESCE(l.first_seen,\'\') DESC, l.id DESC', 'price_asc': 'l.price_num IS NULL ASC, l.price_num ASC, l.id DESC', 'price_desc': 'l.price_num IS NULL ASC, l.price_num DESC, l.id DESC', 'newest': 'COALESCE(l.first_seen,\'\') DESC, l.id DESC', 'km': 'l.km IS NULL ASC, l.km ASC, l.id DESC', } LIST_DECISIONS = { 'all': '1=1', 'undecided': 's.action IS NULL', 'like': "s.action = 'like'", 'maybe': "s.action = 'maybe'", 'nope': "s.action = 'nope'", } REGION_NAMES = {'ns': 'NS', 'nb': 'NB'} # --- helpers -------------------------------------------------------------- def get_conn(app): conn = dbmod.connect(app.state.db_path) dbmod.init_db(conn) return conn def utcnow(): return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) def live_configured(): return bool(os.environ.get('OPENROUTER_API_KEY')) def set_pref(conn, key, value): conn.execute( "INSERT INTO prefs (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value)) def get_pref(conn, key, default=None): row = conn.execute('SELECT value FROM prefs WHERE key=?', (key,)).fetchone() return row['value'] if row else default def parse_cursor(cursor): """Opaque '||' -> (int, str, int) or None.""" if not cursor: return None try: score, first_seen, listing_id = cursor.split('|', 2) return int(score), first_seen, int(listing_id) except (ValueError, TypeError): raise HTTPException(status_code=400, detail='bad cursor') def cursor_clause(): """Strictly-after in (score, first_seen, id) DESC tuple order.""" return (" AND (l.score < ? OR" " (l.score = ? AND (COALESCE(l.first_seen,'') < ? OR" " (COALESCE(l.first_seen,'') = ? AND l.id < ?))))") def count_deck_after(app, score, first_seen, listing_id): conn = get_conn(app) try: n = conn.execute( 'SELECT COUNT(*) AS n FROM listings l' ' LEFT JOIN swipes s ON s.listing_id = l.id' ' WHERE ' + DECK_WHERE + cursor_clause(), (score, score, first_seen, first_seen, listing_id)).fetchone()['n'] return n finally: conn.close() def build_payload(img_dir): def _payload(conn, row): leg = os.path.join(img_dir, '%d.webp' % row['id']) b1 = os.path.join(img_dir, '%d_0.webp' % row['id']) has_photo = (bool(row['img_url']) or os.path.exists(leg) or os.path.exists(b1)) img = '/img/%d' % row['id'] if has_photo else None # B1: gallery size + swipe-time photo index (UI-SPEC §11 / DATA-MODEL B1) imgs = [] if isinstance(row['imgs'], str) and row['imgs']: try: imgs = json.loads(row['imgs']) except ValueError: imgs = [] if not isinstance(imgs, list): imgs = [] n_photos = len(imgs) if imgs else (1 if has_photo else 0) extras = row['extras'] if isinstance(extras, str): try: extras = json.loads(extras) except ValueError: extras = [] return { 'id': row['id'], 'title': row['title'], 'price_display': row['price_display'], 'price_num': row['price_num'], 'km': row['km'], 'location': row['location'], 'region': row['region'], 'url': row['url'], 'img': img, 'n_photos': n_photos, 'last_photo_idx': (row['last_photo_idx'] if 'last_photo_idx' in row.keys() else None), 'desc': row['desc'], 'family': row['family'], 'score': row['score'], 'year': row['year'], 'seller_type': row['seller_type'], 'extras': extras or [], 'decision': row['decision'] if 'decision' in row.keys() else None, 'first_seen': row['first_seen'], } return _payload # --- P3 listing payload (additive over the P2 contract) -------------------- def build_p3_payload(img_dir): base = build_payload(img_dir) def _payload(conn, engine, row, slot=None, include_breakdown=False): p = base(conn, row) p['points'] = engine.points(row['id']) p['tank'] = engine.tank(row['id']) p['slot'] = slot p['round_next'] = engine.round_next(row['id']) p['points_breakdown'] = (engine.breakdown(row['id']) if include_breakdown else None) return p return _payload # --- app factory ---------------------------------------------------------- def create_app(db_path, img_dir, state_dir=None): if state_dir is None: state_dir = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d') os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) os.makedirs(os.path.abspath(img_dir), exist_ok=True) static_dir = os.path.join(os.path.dirname(__file__), 'static') icons_dir = os.path.join(static_dir, 'icons') app = FastAPI(title='Bike-Tinder') app.state.db_path = os.path.abspath(db_path) app.state.img_dir = os.path.abspath(img_dir) app.state.state_dir = state_dir app.state.static_dir = static_dir app.state.icons_dir = icons_dir payload = build_payload(app.state.img_dir) p3_payload = build_p3_payload(app.state.img_dir) # -- meta ----------------------------------------------------------- @app.get('/api/health') def health(): conn = get_conn(app) try: counts = {} for status in ('active', 'gone', 'dup'): counts[status] = conn.execute( 'SELECT COUNT(*) AS n FROM listings WHERE status=?', (status,)).fetchone()['n'] counts['total'] = sum(counts.values()) return {'ok': True, 'db': os.path.basename(app.state.db_path), 'counts': counts} finally: conn.close() @app.get('/api/stats') def stats(): conn = get_conn(app) try: live = conn.execute( "SELECT COUNT(*) AS n FROM listings WHERE status='active'" ).fetchone()['n'] # item 7: fresh = active with no swipe row (the deck's new-pool # predicate, verbatim). A swipe of ANY age removes freshness. fresh = conn.execute( "SELECT COUNT(*) AS n FROM listings l" " WHERE l.status='active'" " AND NOT EXISTS (SELECT 1 FROM swipes s" " WHERE s.listing_id = l.id)" ).fetchone()['n'] badge = get_pref(conn, 'badge_seen_at') or '' new = conn.execute( "SELECT COUNT(*) AS n FROM listings" " WHERE status='active' AND COALESCE(first_seen,'') > ?", (badge,)).fetchone()['n'] per = {} for action in ('like', 'maybe', 'nope'): per[action] = conn.execute( 'SELECT COUNT(*) AS n FROM swipes WHERE action=?', (action,)).fetchone()['n'] last = conn.execute( 'SELECT started_at, ok FROM import_runs' ' ORDER BY id DESC LIMIT 1').fetchone() return { 'live': live, 'fresh': fresh, 'new': new, 'likes': per['like'], 'maybes': per['maybe'], 'nopes': per['nope'], 'last_import': {'at': last['started_at'], 'ok': bool(last['ok'])} if last else None, } finally: conn.close() # -- deck ----------------------------------------------------------- @app.get('/api/deck') def deck(limit: int = Query(40, ge=1, le=100), cursor: str = Query(None)): cur = parse_cursor(cursor) conn = get_conn(app) try: engine = secmod.build_engine(conn, now=utcnow()) if cur is None: rows = conn.execute(DECK_SELECT + DECK_ORDER + ' LIMIT ?', (limit,)).fetchall() else: rows = conn.execute( DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?', (cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall() items = [p3_payload(conn, engine, r) for r in rows] next_cursor = None remaining = 0 if rows: last = rows[-1] if len(rows) == limit: nc = (last['score'], last['first_seen'] or '', last['id']) next_cursor = '%s|%s|%s' % nc remaining = count_deck_after(app, *nc) return {'items': items, 'next_cursor': next_cursor, 'remaining': remaining} finally: conn.close() # -- swipes --------------------------------------------------------- @app.post('/api/swipe') def swipe(body: dict): listing_id = body.get('listing_id') action = body.get('action') if not isinstance(listing_id, int) or action not in ACTIONS: raise HTTPException(status_code=400, detail='body must be {listing_id:int, action:like|nope|maybe}') # optional P3: section this verdict happened in (NULL for list edits) section_id = body.get('section_id') if not isinstance(section_id, int): section_id = None # B1: optional photo index on screen at swipe time (§11.5 rotation). has_photo_idx = 'photo_idx' in body photo_idx = body.get('photo_idx') if has_photo_idx and photo_idx is not None: if (isinstance(photo_idx, bool) or not isinstance(photo_idx, int) or photo_idx < 0): raise HTTPException( status_code=400, detail='photo_idx must be a non-negative int (or null)') conn = get_conn(app) try: row = conn.execute('SELECT id FROM listings WHERE id=?', (listing_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail='unknown listing') now = imp.now_iso() if has_photo_idx: # explicit photo_idx (int or null) overwrites; an absent key # preserves the stored index (list edits etc.) conn.execute( '''INSERT INTO swipes (listing_id, action, created_at, updated_at, last_photo_idx) VALUES (?,?,?,?,?) ON CONFLICT(listing_id) DO UPDATE SET action=excluded.action, updated_at=excluded.updated_at, last_photo_idx=excluded.last_photo_idx''', (listing_id, action, now, now, photo_idx)) else: conn.execute( '''INSERT INTO swipes (listing_id, action, created_at, updated_at) VALUES (?,?,?,?) ON CONFLICT(listing_id) DO UPDATE SET action=excluded.action, updated_at=excluded.updated_at''', (listing_id, action, now, now)) # P3: append the learning event (round = prior events + 1) round_ = dbmod.append_event(conn, listing_id, action, section_id, now) conn.commit() stored = conn.execute( 'SELECT listing_id, action, created_at, updated_at,' ' last_photo_idx FROM swipes WHERE listing_id=?', (listing_id,)).fetchone() result = dict(stored) result['round'] = round_ result['section_id'] = section_id return result finally: conn.close() @app.delete('/api/swipe/{listing_id}') def swipe_clear(listing_id: int): conn = get_conn(app) try: cur = conn.execute('DELETE FROM swipes WHERE listing_id=?', (listing_id,)) conn.commit() return {'listing_id': listing_id, 'cleared': cur.rowcount > 0} finally: conn.close() # -- list + listing ------------------------------------------------- @app.get('/api/list') def list_(decision: str = Query('all'), sort: str = Query('score'), q: str = Query(None), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0)): if decision not in LIST_DECISIONS: raise HTTPException(status_code=400, detail='bad decision filter') if sort not in LIST_SORTS: raise HTTPException(status_code=400, detail='bad sort') where = ("l.status = 'active' AND " + LIST_DECISIONS[decision]) params = [] if q: escaped = (q.replace('\\', '\\\\').replace('%', '\\%') .replace('_', '\\_')) where += " AND l.title LIKE ? ESCAPE '\\'" params.append('%' + escaped + '%') conn = get_conn(app) try: engine = secmod.build_engine(conn, now=utcnow()) total = conn.execute( 'SELECT COUNT(*) AS n FROM listings l' ' LEFT JOIN swipes s ON s.listing_id = l.id' ' WHERE ' + where, params).fetchone()['n'] rows = conn.execute( 'SELECT l.*, s.action AS decision,' ' s.last_photo_idx AS last_photo_idx FROM listings l' ' LEFT JOIN swipes s ON s.listing_id = l.id' ' WHERE ' + where + ' ORDER BY ' + LIST_SORTS[sort] + ' LIMIT ? OFFSET ?', params + [limit, offset]).fetchall() return {'items': [p3_payload(conn, engine, r) for r in rows], 'total': total} finally: conn.close() @app.get('/api/listing/{listing_id}') def listing(listing_id: int): conn = get_conn(app) try: engine = secmod.build_engine(conn, now=utcnow()) row = conn.execute( 'SELECT l.*, s.action AS decision,' ' s.last_photo_idx AS last_photo_idx FROM listings l' ' LEFT JOIN swipes s ON s.listing_id = l.id' ' WHERE l.id=?', (listing_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail='unknown listing') return p3_payload(conn, engine, row, include_breakdown=True) finally: conn.close() # -- import --------------------------------------------------------- @app.post('/api/import') def run_import_endpoint(): try: result = imp.run_import(app.state.state_dir, app.state.db_path) except Exception as e: # noqa: BLE001 — surface anything to the caller result = {'ok': False, 'errors': {'exception': str(e)}} return JSONResponse(status_code=200, content=result) # -- badge ---------------------------------------------------------- @app.post('/api/badge/seen') def badge_seen(): conn = get_conn(app) try: now = imp.now_iso() set_pref(conn, 'badge_seen_at', now) conn.commit() return {'badge_seen_at': now} finally: conn.close() # -- P3: sections ---------------------------------------------------- def section_state(conn, sec, note=None): """Shared body for GET /api/section and POST /api/section/forge.""" now = utcnow() engine = secmod.build_engine(conn, now=now) data = secmod.read_section(conn, sec, now) sec = conn.execute('SELECT * FROM sections WHERE id=?', (sec['id'],)).fetchone() mode = get_pref(conn, 'forge_mode', 'ondemand') decided_ids = secmod.section_decided_ids(conn, sec) items = [p3_payload(conn, engine, r, slot=r['slot'], include_breakdown=True) for r in data['items']] for p in items: # item 5: decided IN this section (real verdict), not the joined # lifetime swipes decision — present on EVERY item so the FE's # local-intent branch can't misfire p['decided_this_section'] = p['id'] in decided_ids data['items'] = items data['mode'] = mode data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {}) data['pool'] = secmod.pool_counts(conn) data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')} data['can_forge'] = (mode != 'strict' or secmod.fresh_profile_exists(conn, sec)) data['counts'] = secmod.section_counts(conn, sec) prof = secmod.latest_profile(conn) data['read_info'] = { 'events': engine.n_events, 'profile_version': prof['version'] if prof else None, 'profile_source': prof['source'] if prof else None, 'profile_at': prof['created_at'] if prof else None, } if note: data['note'] = note return data @app.get('/api/section') def get_section(): conn = get_conn(app) try: sec = secmod.get_current_section(conn) if sec is None: # first-ever read composes a cold section prof = secmod.latest_profile(conn) engine = secmod.build_engine(conn, prof, now=utcnow()) sec = secmod.compose_section( conn, engine, size=25, profile_version=prof['version'] if prof else None, note='cold compose (first read)') else: # item 8: an empty composed section (the catalog was fully # drained) recomposes on the read once stock exists — the # nightly-import recovery path. Zero items + zero pool # keeps serving the empty section (loud empty2). n_items = conn.execute( 'SELECT COUNT(*) AS n FROM section_items' ' WHERE section_id=?', (sec['id'],)).fetchone()['n'] if n_items == 0 and any(secmod.pool_counts(conn).values()): prof = secmod.latest_profile(conn) engine = secmod.build_engine(conn, prof, now=utcnow()) secmod.close_section(conn, sec, utcnow(), status='closed') sec = secmod.compose_section( conn, engine, size=25, profile_version=prof['version'] if prof else None, note='refill compose (stock returned)') return section_state(conn, sec) finally: conn.close() @app.post('/api/section/forge') def forge(body: dict): size = body.get('size', 25) if not isinstance(size, int) or not 1 <= size <= 100: raise HTTPException(status_code=400, detail='size must be an int 1..100') conn = get_conn(app) try: mode = get_pref(conn, 'forge_mode', 'ondemand') sec = secmod.get_current_section(conn) note = None if mode == 'strict': if not secmod.fresh_profile_exists(conn, sec): raise HTTPException( status_code=409, detail=( 'strict mode: no fresh agent pass since this ' 'section — the nightly read provides it')) note = 'strict: forged on the fresh pass' else: # on-demand: agent pass between sections (mock when the # live key is not configured; live failures fall back) src = 'live' if live_configured() else 'mock' res = agmod.run_pass(app.state.db_path, source=src) note = ('pass %s v%d' % (res['source'], res['profile_version'])) if res['source'] == 'fallback': note += ' (agent offline — last read used)' if sec is not None: secmod.close_section(conn, sec, utcnow()) prof = secmod.latest_profile(conn) engine = secmod.build_engine(conn, prof, now=utcnow()) newsec = secmod.compose_section( conn, engine, size=size, profile_version=prof['version'] if prof else None, note=note) # v1.1 item 6: label the dry-catalog state on the forge note recipe = (json.loads(newsec['recipe']) if newsec['recipe'] else {}) if recipe.get('new') == 0: note = '%s · catalog fully reviewed' % (note or '') return section_state(conn, newsec, note=note) finally: conn.close() # -- P3: trends ------------------------------------------------------ @app.get('/api/trends') def trends(): conn = get_conn(app) try: prof = secmod.latest_profile(conn) profile = None if prof: payload_prof = {} try: payload_prof = json.loads(prof['payload']) except ValueError: payload_prof = {} maker = conn.execute( 'SELECT * FROM agent_runs WHERE profile_version=?' ' ORDER BY id DESC LIMIT 1', (prof['version'],) ).fetchone() profile = { 'version': prof['version'], 'source': prof['source'], 'created_at': prof['created_at'], 'model': maker['model'] if maker else None, 'lead': payload_prof.get('lead', ''), 'leanings': payload_prof.get('leanings', [])[:8], 'changes': payload_prof.get('changes', []), 'chips': payload_prof.get('chips', []), } per = {} for action in ('like', 'maybe', 'nope'): per[action] = conn.execute( 'SELECT COUNT(*) AS n FROM swipes WHERE action=?', (action,)).fetchone()['n'] decided = per['like'] + per['maybe'] + per['nope'] events = conn.execute( 'SELECT listing_id, action FROM swipe_events ORDER BY id' ).fetchall() prev = {} flips = 0 for ev in events: if ev['listing_id'] in prev and prev[ev['listing_id']] != ev[ 'action']: flips += 1 prev[ev['listing_id']] = ev['action'] ret_placed = conn.execute( "SELECT COUNT(*) AS n FROM section_items " "WHERE slot='return'").fetchone()['n'] ret_struck = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE si.slot='return'""").fetchone()['n'] wild_placed = conn.execute( "SELECT COUNT(*) AS n FROM section_items " "WHERE slot='wildcard'").fetchone()['n'] wild_hits = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e JOIN section_items si ON si.section_id = e.section_id AND si.listing_id = e.listing_id WHERE si.slot='wildcard' AND e.action IN ('like','maybe')""" ).fetchone()['n'] last_run = None row = conn.execute( 'SELECT * FROM agent_runs ORDER BY id DESC LIMIT 1' ).fetchone() if row: last_run = { 'started_at': row['started_at'], 'finished_at': row['finished_at'], 'ok': bool(row['ok']), 'source': row['source'], 'model': row['model'], 'cost_note': row['cost_note'], 'profile_version': row['profile_version'], } return { 'profile': profile, 'stats': { 'decided': decided, 'likes': per['like'], 'maybes': per['maybe'], 'nopes': per['nope'], 'hit_rate': (per['like'] / decided) if decided else None, 'hit_rate_plus_maybe': ( (per['like'] + per['maybe']) / decided if decided else None), 'returns_placed': ret_placed, 'returns_re_struck': ret_struck, 'wildcards_placed': wild_placed, 'wildcards_hit': wild_hits, 'flips': flips, }, 'last_run': last_run, 'cold_start': decided < 10, 'forge_mode': get_pref(conn, 'forge_mode', 'ondemand'), } finally: conn.close() # -- P3: agent pass trigger ------------------------------------------ @app.post('/api/agent/run') def agent_run(body: dict): source = body.get('source') if isinstance(body, dict) else None if source is None: source = 'live' if live_configured() else 'mock' if source not in ('mock', 'live'): raise HTTPException(status_code=400, detail='source must be mock|live') if source == 'live' and not live_configured(): raise HTTPException( status_code=400, detail='live pass not configured (OPENROUTER_API_KEY unset)') model = body.get('model') if isinstance(body, dict) else None res = agmod.run_pass(app.state.db_path, source=source, model=model) return res # -- prefs (P3 forge_mode toggle persistence) ------------------------- @app.post('/api/prefs') def set_pref_endpoint(body: dict): key = body.get('key') value = body.get('value') allowed = {'forge_mode': ('ondemand', 'strict'), 'rubric_debug': ('off', 'on')} if key not in allowed or value not in allowed[key]: raise HTTPException(status_code=400, detail='unknown or invalid pref') conn = get_conn(app) try: set_pref(conn, key, value) conn.commit() return {'key': key, 'value': value} finally: conn.close() # -- images + static ------------------------------------------------- @app.get('/img/{listing_id}') def img(listing_id: int, i: int = Query(0, ge=0)): """Photo n of a gallery (B1 §11.1): mirrored file -> CDN 302 -> 404. i=0 keeps the legacy chain ({id}.webp, with {id}_0.webp accepted as the gallery-mirror name for the primary); i>0 serves {id}_{n}.webp with the CDN fallback taken from imgs[n]. """ fpath = os.path.join( app.state.img_dir, '%d.webp' % listing_id if i == 0 else '%d_%d.webp' % (listing_id, i)) if i == 0 and not os.path.exists(fpath): alt = os.path.join(app.state.img_dir, '%d_0.webp' % listing_id) if os.path.exists(alt): fpath = alt if os.path.exists(fpath): return FileResponse(fpath, media_type='image/webp') conn = get_conn(app) try: row = conn.execute( 'SELECT img_url, imgs FROM listings WHERE id=?', (listing_id,)).fetchone() finally: conn.close() url = None if row: if i == 0 and row['img_url']: url = row['img_url'] elif row['imgs']: try: arr = json.loads(row['imgs']) except ValueError: arr = [] if isinstance(arr, list) and len(arr) > i and arr[i]: url = arr[i] if url: return RedirectResponse(url=url, status_code=302) raise HTTPException(status_code=404, detail='no image') @app.get('/') def index(): return FileResponse( os.path.join(static_dir, 'index.html'), headers={'Cache-Control': 'no-cache'}) @app.get('/manifest.json') def manifest(): return FileResponse(os.path.join(static_dir, 'manifest.json')) @app.get('/apple-touch-icon.png') def apple_touch(): return FileResponse(os.path.join(icons_dir, 'apple-touch-icon.png')) @app.get('/sw.js') def sw(): return FileResponse(os.path.join(static_dir, 'sw.js'), headers={'Cache-Control': 'no-cache'}) @app.get('/icons/{name}') def icon(name: str): if '/' in name or '..' in name: raise HTTPException(status_code=404, detail='no such icon') fpath = os.path.join(icons_dir, name) if not os.path.exists(fpath): raise HTTPException(status_code=404, detail='no such icon') return FileResponse(fpath) return app