diff --git a/requirements.txt b/requirements.txt index c87dabe..a82223b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ cryptography>=42.0.0 requests>=2.31.0 fake-useragent>=1.5.0 python-multipart>=0.0.9 +qrcode[pil]>=7.4 diff --git a/src/main.py b/src/main.py index 3d3e46e..b6f0ca8 100644 --- a/src/main.py +++ b/src/main.py @@ -87,7 +87,7 @@ async def _security_headers(request: Request, call_next) -> Response: response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Content-Security-Policy"] = ( - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" ) return response diff --git a/src/routers/keypasses.py b/src/routers/keypasses.py index c349fb6..c7217bd 100644 --- a/src/routers/keypasses.py +++ b/src/routers/keypasses.py @@ -1,10 +1,12 @@ +import io import json import secrets import string from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +import qrcode +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session from core.config import utcnow @@ -88,3 +90,28 @@ async def revoke_keypass( kp.revoked = True kp.revoked_at = utcnow() db.commit() + + +@router.get("/{kp_id}/qr") +async def keypass_qr( + kp_id: int, + request: Request, + db: Session = Depends(get_db), + _: dict = Depends(require_manager), +): + kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first() + if not kp: + raise HTTPException(404, "Keypass not found") + + base_url = str(request.base_url).rstrip("/") + url = f"{base_url}/?k={kp.code}" + + qr = qrcode.QRCode(box_size=10, border=4) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return Response(content=buf.read(), media_type="image/png") diff --git a/src/static/admin.html b/src/static/admin.html index 1bf8ee8..d79f580 100644 --- a/src/static/admin.html +++ b/src/static/admin.html @@ -418,6 +418,22 @@ + + + diff --git a/src/static/admin.js b/src/static/admin.js index d179168..ebb1718 100644 --- a/src/static/admin.js +++ b/src/static/admin.js @@ -142,7 +142,9 @@ async function loadKeypasses() { ${expiresCell} ${badge}
- ${!kp.revoked ? `` : ""} + ${!kp.revoked ? `` : ""} ${!kp.revoked && expMs >= now ? `` : ""} @@ -189,6 +191,33 @@ async function loadKeypasses() { document.getElementById("kp-edit-modal").classList.remove("hidden"); }); }); + tbody.querySelectorAll("[data-qr-kp-id]").forEach(btn => { + btn.addEventListener("click", async () => { + const id = btn.dataset.qrKpId; + const desc = btn.dataset.qrKpDesc; + document.getElementById("qr-modal-desc").textContent = desc; + const img = document.getElementById("qr-img"); + const dl = document.getElementById("qr-download"); + img.src = ""; + dl.removeAttribute("href"); + document.getElementById("qr-modal").classList.remove("hidden"); + try { + const res = await fetch(`/api/admin/keypasses/${id}/qr`, { + headers: { "Authorization": `Bearer ${getToken()}` }, + }); + if (!res.ok) throw new Error("Failed to load QR code"); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + img.src = blobUrl; + dl.href = blobUrl; + dl.download = `keypass-${id}-qr.png`; + } catch (e) { + showToast(e.message, true); + document.getElementById("qr-modal").classList.add("hidden"); + } + }); + }); + tbody.querySelectorAll("[data-kp-id]").forEach(btn => { btn.addEventListener("click", async () => { if (!confirm("Revoke this keypass?")) return; @@ -285,6 +314,13 @@ document.getElementById("kp-edit-gate-checks").addEventListener("change", () => document.getElementById("kp-edit-cancel").addEventListener("click", () => { document.getElementById("kp-edit-modal").classList.add("hidden"); }); + +document.getElementById("qr-close").addEventListener("click", () => { + const img = document.getElementById("qr-img"); + if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src); + img.src = ""; + document.getElementById("qr-modal").classList.add("hidden"); +}); document.getElementById("kp-edit-form").addEventListener("submit", async e => { e.preventDefault(); const id = document.getElementById("kp-edit-id").value; diff --git a/src/static/app.js b/src/static/app.js index a9f9b7d..10ba896 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -204,6 +204,30 @@ document.getElementById("logout-btn").addEventListener("click", () => { // ── Init ────────────────────────────────────────────────────────────────────── (function init() { + // Auto-login when the URL contains ?k=CODE (e.g. scanned from a QR code) + const params = new URLSearchParams(window.location.search); + const k = params.get("k"); + if (k) { + // Remove the code from the URL immediately so it doesn't linger in history + history.replaceState(null, "", window.location.pathname); + fetch("/api/auth/keypass", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: k.toUpperCase() }), + }) + .then(res => res.ok ? res.json() : Promise.reject()) + .then(data => { + saveToken(data.token); + showGatesView(); + loadGates(); + }) + .catch(() => { + clearToken(); + showLogin(); + }); + return; + } + const t = getToken(); if (tokenValid(t)) { showGatesView();