All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
Bildbearbeitung im Nachgang: Drehen in 90-Grad-Schritten, Ausrichten per
Schieberegler, Zuschneiden per Rahmen mit anfassbaren Ecken. Der Server
rechnet jede Korrektur vom unbearbeiteten Original, das beim ersten
Eingriff daneben abgelegt wird - sonst summieren sich die Verluste über
mehrere Korrekturen, und Zurücksetzen wäre nicht möglich. Zwei
Zuschnitte hintereinander werden ineinander verrechnet.
Der Ort ließ sich nicht übernehmen, weil beide Quellen versagen können:
iOS entfernt beim Weitergeben an eine Webseite oft die GPS-Daten aus dem
Foto, und der Browserstandort scheiterte bisher stumm. Jetzt meldet er
seinen Grund ("Standortfreigabe fehlt"), die Detailansicht zeigt die
Herkunft des Ortes, und der Scan-Ort ist von Hand nachtragbar.
E-Mail, Web, Telefon und Mobil sind in der Tabelle jetzt Links; Straße,
PLZ, Ort und Land stehen als eine Adresszeile, die die eingestellte
Navigations-App öffnet.
Einstellungen als vierter Reiter: Navigations-App (Apple Karten, Google
Maps, OpenStreetMap), Statusübersicht und Abmelden - der Knopf saß
vorher in der Kopfzeile.
Die Kopfzeile ist weg. Sie kostete auf dem Telefon eine Bildschirmzeile
und wiederholte nur, was die Tableiste schon sagt.
Kein full_name mehr, nur Vor- und Nachname; akademische Titel gehören in
den Vornamen. Bestehende Datensätze werden bei der Migration am letzten
Leerzeichen aufgeteilt. Die Spalte bleibt ungenutzt stehen: SQLite baut
zum Löschen die ganze Tabelle neu, der Gewinn wären ein paar Byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
512 lines
18 KiB
Python
512 lines
18 KiB
Python
"""HTTP-Schnittstelle und Auslieferung der PWA."""
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import (
|
|
config, db, duplicates, extract, geocode, imaging, reminders, security,
|
|
segment, vcard,
|
|
)
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
log = logging.getLogger("bcs")
|
|
|
|
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
|
|
|
EDITABLE = [
|
|
"first_name", "last_name", "job_title", "company", "department",
|
|
"email", "phone", "mobile", "fax", "website",
|
|
"street", "postal_code", "city", "country", "notes", "occasion", "place",
|
|
]
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
config.ensure_dirs()
|
|
db.connect()
|
|
if not security.enabled():
|
|
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
|
|
if not extract.available():
|
|
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
|
|
|
|
# Karten, die beim letzten Herunterfahren noch in der Warteschlange
|
|
# standen, wuerden sonst dauerhaft auf "wird gelesen" stehen bleiben.
|
|
unfinished = db.query("SELECT id FROM cards WHERE extract_status = 'offen'")
|
|
if unfinished:
|
|
log.info("%d Karte(n) aus dem letzten Lauf werden nachgeholt", len(unfinished))
|
|
start_reading([row["id"] for row in unfinished])
|
|
|
|
task = asyncio.create_task(reminders.run_forever())
|
|
try:
|
|
yield
|
|
finally:
|
|
task.cancel()
|
|
|
|
|
|
app = FastAPI(title="Business Card Scanner", lifespan=lifespan, docs_url=None, redoc_url=None)
|
|
auth = Depends(security.require_auth)
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def card_dict(row) -> dict:
|
|
card = dict(row)
|
|
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
|
card["back_image_url"] = (
|
|
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
|
|
)
|
|
card["has_contact"] = any(
|
|
card.get(f) for f in ("last_name", "company", "email", "phone", "mobile")
|
|
)
|
|
return card
|
|
|
|
|
|
# --------------------------------------------------------------------------- Sitzung
|
|
|
|
@app.get("/healthz")
|
|
def healthz() -> dict:
|
|
db.query_one("SELECT 1 AS ok")
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/session")
|
|
def session(request: Request) -> dict:
|
|
return {
|
|
"auth_required": security.enabled(),
|
|
"authenticated": not security.enabled()
|
|
or security.valid_token(request.cookies.get(security.COOKIE)),
|
|
"extraction_available": extract.available(),
|
|
"mail_configured": bool(config.SMTP_HOST and config.REMINDER_TO),
|
|
}
|
|
|
|
|
|
@app.post("/api/login")
|
|
def login(response: Response, password: str = Form(...)) -> dict:
|
|
if not security.enabled():
|
|
return {"ok": True}
|
|
if not security.check_password(password):
|
|
raise HTTPException(status_code=401, detail="Passwort stimmt nicht")
|
|
response.set_cookie(
|
|
security.COOKIE,
|
|
security.issue_token(),
|
|
max_age=config.SESSION_DAYS * 86400,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=True,
|
|
path="/",
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/logout")
|
|
def logout(response: Response) -> dict:
|
|
response.delete_cookie(security.COOKIE, path="/")
|
|
return {"ok": True}
|
|
|
|
|
|
# ------------------------------------------------------------------------------ Scan
|
|
|
|
def _process_photo(raw: bytes) -> tuple:
|
|
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
|
|
|
|
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
|
|
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
|
|
"""
|
|
meta = imaging.read_metadata(raw)
|
|
crops, fallback = segment.segment(imaging.decode(raw))
|
|
encoded = []
|
|
while crops:
|
|
encoded.append(imaging.encode_jpeg(crops.pop(0)))
|
|
return meta, encoded, fallback
|
|
|
|
|
|
# Laufende Lesevorgaenge festhalten, sonst raeumt die Garbage Collection sie
|
|
# mitten im Lauf weg.
|
|
_reading: set = set()
|
|
|
|
|
|
def start_reading(card_ids: list, side: str = "front") -> None:
|
|
task = asyncio.create_task(_read_cards(card_ids, side))
|
|
_reading.add(task)
|
|
task.add_done_callback(_reading.discard)
|
|
|
|
|
|
async def _read_cards(card_ids: list, side: str = "front") -> None:
|
|
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
|
|
Karten sofort, die Felder tropfen nach."""
|
|
column = "back_image_file" if side == "back" else "image_file"
|
|
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
|
|
rows = [row for row in rows if row is not None and row[column]]
|
|
images = [(config.IMAGE_DIR / row[column]).read_bytes() for row in rows]
|
|
|
|
results = await extract.extract_all(images)
|
|
|
|
for row, result in zip(rows, results):
|
|
rotation = int(result.get("rotation") or 0) % 360
|
|
if rotation:
|
|
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
|
|
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
|
|
path = config.IMAGE_DIR / row[column]
|
|
path.write_bytes(
|
|
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
|
|
)
|
|
fields = {f: (result.get(f) or None) for f in extract.FIELDS}
|
|
# COALESCE, weil das Auslesen laeuft, waehrend die Karte schon
|
|
# sichtbar ist: was in der Zwischenzeit von Hand eingetragen wurde,
|
|
# darf das Modell nicht ueberschreiben.
|
|
assignments = ", ".join(f"{name} = COALESCE({name}, ?)" for name in fields)
|
|
db.execute(
|
|
f"UPDATE cards SET {assignments}, extract_status = ?, extract_error = ? "
|
|
"WHERE id = ? AND extract_status = 'offen'",
|
|
[
|
|
*fields.values(),
|
|
"fehler" if result.get("_error") else "ok",
|
|
result.get("_error"),
|
|
row["id"],
|
|
],
|
|
)
|
|
log.info("%d Karte(n) ausgelesen", len(rows))
|
|
|
|
|
|
@app.post("/api/scan", dependencies=[auth])
|
|
async def scan(
|
|
file: UploadFile,
|
|
lat: float = Form(None),
|
|
lon: float = Form(None),
|
|
occasion: str = Form(""),
|
|
) -> JSONResponse:
|
|
raw = await file.read()
|
|
if not raw:
|
|
raise HTTPException(status_code=400, detail="Leere Datei")
|
|
if len(raw) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
|
|
|
|
try:
|
|
meta, encoded, fallback = await asyncio.to_thread(_process_photo, raw)
|
|
except Exception as exc:
|
|
log.exception("Foto konnte nicht verarbeitet werden")
|
|
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
|
|
|
|
# Der Ort des Fotos schlaegt den des Uploads: Karten werden oft erst
|
|
# abends am Schreibtisch abfotografiert, nicht auf der Messe.
|
|
if meta["lat"] is not None:
|
|
geo_source = "exif"
|
|
elif lat is not None and lon is not None:
|
|
meta["lat"], meta["lon"], geo_source = lat, lon, "browser"
|
|
else:
|
|
geo_source = None
|
|
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
|
|
|
|
batch_id = uuid.uuid4().hex
|
|
created = now_iso()
|
|
source = "einzel" if fallback else "stapel"
|
|
cards = []
|
|
for upright in encoded:
|
|
card_id = uuid.uuid4().hex
|
|
image_file = f"{card_id}.jpg"
|
|
(config.IMAGE_DIR / image_file).write_bytes(upright)
|
|
columns = {
|
|
"id": card_id,
|
|
"created_at": created,
|
|
"captured_at": meta["captured_at"] or created,
|
|
"batch_id": batch_id,
|
|
"image_file": image_file,
|
|
"source": source,
|
|
"notes": "",
|
|
"lat": meta["lat"],
|
|
"lon": meta["lon"],
|
|
"place": place,
|
|
"geo_source": geo_source,
|
|
"occasion": occasion.strip() or None,
|
|
"extract_status": "offen",
|
|
}
|
|
names = ", ".join(columns)
|
|
placeholders = ", ".join("?" for _ in columns)
|
|
db.execute(
|
|
f"INSERT INTO cards ({names}) VALUES ({placeholders})", list(columns.values())
|
|
)
|
|
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
|
|
|
|
# Antwort geht sofort raus, das Auslesen laeuft danach weiter.
|
|
start_reading([card["id"] for card in cards])
|
|
|
|
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
|
|
return JSONResponse(
|
|
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------------- Karten
|
|
|
|
@app.get("/api/cards", dependencies=[auth])
|
|
def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|
sql = "SELECT * FROM cards"
|
|
where, params = [], []
|
|
if q.strip():
|
|
needle = f"%{q.strip()}%"
|
|
searchable = [
|
|
"first_name", "last_name", "company", "job_title", "email", "phone", "mobile",
|
|
"website", "city", "place", "notes", "department", "street", "occasion",
|
|
]
|
|
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
|
params += [needle] * len(searchable)
|
|
if filter == "offen":
|
|
where.append("exported_at IS NULL")
|
|
elif filter == "unvollstaendig":
|
|
where.append(
|
|
"(extract_status = 'fehler' "
|
|
"OR (extract_status = 'ok' AND last_name IS NULL AND company IS NULL))"
|
|
)
|
|
elif filter == "erinnerung":
|
|
where.append(
|
|
"id IN (SELECT card_id FROM reminders WHERE sent_at IS NULL)"
|
|
)
|
|
if where:
|
|
sql += " WHERE " + " AND ".join(where)
|
|
sql += " ORDER BY COALESCE(captured_at, created_at) DESC, rowid DESC"
|
|
|
|
rows = [card_dict(r) for r in db.query(sql, params)]
|
|
totals = db.query_one(
|
|
"""
|
|
SELECT COUNT(*) AS total,
|
|
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen,
|
|
SUM(CASE WHEN extract_status = 'offen' THEN 1 ELSE 0 END) AS laeuft
|
|
FROM cards
|
|
"""
|
|
)
|
|
pending = db.query_one(
|
|
"SELECT COUNT(*) AS n FROM reminders WHERE sent_at IS NULL"
|
|
)
|
|
return {
|
|
"cards": rows,
|
|
"stats": {
|
|
"total": totals["total"] or 0,
|
|
"offen": totals["offen"] or 0,
|
|
"erinnerungen": pending["n"] or 0,
|
|
"laeuft": totals["laeuft"] or 0,
|
|
},
|
|
}
|
|
|
|
|
|
def _card_or_404(card_id: str):
|
|
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Karte nicht gefunden")
|
|
return row
|
|
|
|
|
|
@app.get("/api/cards/{card_id}", dependencies=[auth])
|
|
def get_card(card_id: str) -> dict:
|
|
card = card_dict(_card_or_404(card_id))
|
|
card["reminders"] = [
|
|
dict(r)
|
|
for r in db.query(
|
|
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
|
)
|
|
]
|
|
card["duplicates"] = duplicates.find(card)
|
|
return card
|
|
|
|
|
|
@app.patch("/api/cards/{card_id}", dependencies=[auth])
|
|
async def update_card(card_id: str, request: Request) -> dict:
|
|
_card_or_404(card_id)
|
|
payload = await request.json()
|
|
changes = {k: (payload[k] or None) for k in EDITABLE if k in payload}
|
|
if "notes" in changes:
|
|
changes["notes"] = changes["notes"] or ""
|
|
if not changes:
|
|
raise HTTPException(status_code=400, detail="Keine bekannten Felder im Aufruf")
|
|
assignments = ", ".join(f"{k} = ?" for k in changes)
|
|
db.execute(
|
|
f"UPDATE cards SET {assignments} WHERE id = ?", [*changes.values(), card_id]
|
|
)
|
|
return card_dict(_card_or_404(card_id))
|
|
|
|
|
|
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
|
def delete_card(card_id: str) -> dict:
|
|
row = _card_or_404(card_id)
|
|
for name in (row["image_file"], row["back_image_file"]):
|
|
if name:
|
|
(config.IMAGE_DIR / name).unlink(missing_ok=True)
|
|
_original_of(name).unlink(missing_ok=True)
|
|
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
|
def card_image(card_id: str, side: str = "front") -> FileResponse:
|
|
row = _card_or_404(card_id)
|
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
|
path = config.IMAGE_DIR / (name or "")
|
|
if not name or not path.exists():
|
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
|
return FileResponse(path, media_type="image/jpeg")
|
|
|
|
|
|
def _original_of(name: str) -> Path:
|
|
"""Unbearbeitete Fassung neben dem Bild. Damit bleibt jede Korrektur
|
|
zuruecknehmbar und wird nicht auf eine schon bearbeitete Datei gerechnet."""
|
|
return config.IMAGE_DIR / (name[:-4] + "-orig.jpg")
|
|
|
|
|
|
@app.post("/api/cards/{card_id}/edit", dependencies=[auth])
|
|
async def edit_image(card_id: str, request: Request) -> dict:
|
|
row = _card_or_404(card_id)
|
|
payload = await request.json()
|
|
side = payload.get("side", "front")
|
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
|
if not name:
|
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
|
|
|
current = config.IMAGE_DIR / name
|
|
original = _original_of(name)
|
|
if not original.exists():
|
|
original.write_bytes(current.read_bytes())
|
|
|
|
if payload.get("reset"):
|
|
current.write_bytes(original.read_bytes())
|
|
return card_dict(_card_or_404(card_id))
|
|
|
|
rotate = int(payload.get("rotate") or 0)
|
|
if rotate % 90:
|
|
raise HTTPException(status_code=400, detail="Drehung nur in 90-Grad-Schritten")
|
|
angle = max(-20.0, min(20.0, float(payload.get("angle") or 0)))
|
|
|
|
crop = payload.get("crop")
|
|
if crop is not None:
|
|
try:
|
|
crop = {key: float(crop[key]) for key in ("x", "y", "w", "h")}
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail="Zuschnitt unvollstaendig") from exc
|
|
if crop["w"] <= 0.02 or crop["h"] <= 0.02:
|
|
raise HTTPException(status_code=400, detail="Zuschnitt zu klein")
|
|
|
|
edited = await asyncio.to_thread(
|
|
imaging.edit_jpeg, original.read_bytes(), rotate, angle, crop
|
|
)
|
|
current.write_bytes(edited)
|
|
return card_dict(_card_or_404(card_id))
|
|
|
|
|
|
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
|
|
async def add_back(card_id: str, file: UploadFile) -> dict:
|
|
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
|
|
die Vorderseite offen gelassen hat - dort steht oft nur die Mobilnummer."""
|
|
row = _card_or_404(card_id)
|
|
raw = await file.read()
|
|
if not raw:
|
|
raise HTTPException(status_code=400, detail="Leere Datei")
|
|
if len(raw) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
|
|
|
|
try:
|
|
_, encoded, _ = await asyncio.to_thread(_process_photo, raw)
|
|
except Exception as exc:
|
|
log.exception("Rueckseite konnte nicht verarbeitet werden")
|
|
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
|
|
if not encoded:
|
|
raise HTTPException(status_code=400, detail="Keine Karte im Bild gefunden")
|
|
|
|
name = f"{card_id}-back.jpg"
|
|
(config.IMAGE_DIR / name).write_bytes(encoded[0])
|
|
db.execute(
|
|
"UPDATE cards SET back_image_file = ?, extract_status = 'offen' WHERE id = ?",
|
|
(name, card_id),
|
|
)
|
|
start_reading([card_id], side="back")
|
|
return card_dict(_card_or_404(card_id))
|
|
|
|
|
|
# ---------------------------------------------------------------------------- Export
|
|
|
|
@app.get("/vcf/{card_id}", dependencies=[auth])
|
|
def download_vcard(card_id: str) -> Response:
|
|
card = dict(_card_or_404(card_id))
|
|
db.execute("UPDATE cards SET exported_at = ? WHERE id = ?", (now_iso(), card_id))
|
|
return Response(
|
|
content=vcard.build(card),
|
|
media_type="text/vcard; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{vcard.filename(card)}"'},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------- Erinnerungen
|
|
|
|
@app.get("/api/reminders", dependencies=[auth])
|
|
def list_reminders() -> dict:
|
|
rows = db.query(
|
|
"""
|
|
SELECT r.*, c.first_name, c.last_name, c.company
|
|
FROM reminders r JOIN cards c ON c.id = r.card_id
|
|
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
|
"""
|
|
)
|
|
return {"reminders": [dict(r) for r in rows], "mail_configured": bool(config.SMTP_HOST)}
|
|
|
|
|
|
@app.post("/api/cards/{card_id}/reminders", dependencies=[auth])
|
|
async def create_reminder(card_id: str, request: Request) -> dict:
|
|
_card_or_404(card_id)
|
|
payload = await request.json()
|
|
text = (payload.get("text") or "").strip()
|
|
due_at = (payload.get("due_at") or "").strip()
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="Erinnerungstext fehlt")
|
|
try:
|
|
parsed = datetime.fromisoformat(due_at.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Datum nicht lesbar") from exc
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
|
|
reminder_id = uuid.uuid4().hex
|
|
db.execute(
|
|
"INSERT INTO reminders (id, card_id, text, due_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(reminder_id, card_id, text, parsed.astimezone(timezone.utc).isoformat(), now_iso()),
|
|
)
|
|
return dict(db.query_one("SELECT * FROM reminders WHERE id = ?", (reminder_id,)))
|
|
|
|
|
|
@app.delete("/api/reminders/{reminder_id}", dependencies=[auth])
|
|
def delete_reminder(reminder_id: str) -> dict:
|
|
db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
|
return {"ok": True}
|
|
|
|
|
|
# --------------------------------------------------------------------------- Statisch
|
|
|
|
@app.get("/")
|
|
def index() -> FileResponse:
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
@app.get("/sw.js")
|
|
def service_worker() -> FileResponse:
|
|
# Muss von der Wurzel kommen, sonst gilt der Service Worker nur fuer /static.
|
|
return FileResponse(STATIC_DIR / "sw.js", media_type="application/javascript")
|
|
|
|
|
|
@app.get("/manifest.webmanifest")
|
|
def manifest() -> FileResponse:
|
|
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
|
|
|
|
|
|
@app.get("/robots.txt")
|
|
def robots() -> PlainTextResponse:
|
|
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
|
|
|
|
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|