Merge pull request #760 from christianlouis/copilot/debug-qr-code-rendering
fix(qr-login): render QR code server-side to eliminate CDN dependency
This commit is contained in:
@@ -19,10 +19,13 @@ Security properties:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import segno
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -72,6 +75,7 @@ class CreateChallengeResponse(BaseModel):
|
|||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
|
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
|
||||||
qr_payload: str = Field(description="The string to encode in the QR code.")
|
qr_payload: str = Field(description="The string to encode in the QR code.")
|
||||||
|
qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an <img> src.")
|
||||||
|
|
||||||
|
|
||||||
class ChallengeStatusResponse(BaseModel):
|
class ChallengeStatusResponse(BaseModel):
|
||||||
@@ -106,6 +110,29 @@ class ClaimChallengeResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# QR code rendering parameters
|
||||||
|
_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
|
||||||
|
_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_qr_svg(payload: str) -> str:
|
||||||
|
"""Generate a QR code for *payload* and return it as a base64 SVG data URI.
|
||||||
|
|
||||||
|
Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
|
||||||
|
QR code scales crisply at any resolution without requiring a canvas or any
|
||||||
|
client-side JavaScript library.
|
||||||
|
"""
|
||||||
|
qr = segno.make(payload, error=_QR_ERROR_LEVEL)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
|
||||||
|
svg_bytes = buf.getvalue()
|
||||||
|
return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -143,6 +170,7 @@ async def create_challenge(
|
|||||||
"expires_at": challenge.expires_at,
|
"expires_at": challenge.expires_at,
|
||||||
"ttl_seconds": ttl_seconds,
|
"ttl_seconds": ttl_seconds,
|
||||||
"qr_payload": qr_payload,
|
"qr_payload": qr_payload,
|
||||||
|
"qr_code_svg": _generate_qr_svg(qr_payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,13 @@
|
|||||||
id="qr-container"
|
id="qr-container"
|
||||||
aria-label="{{ _('qr_login.description') }}"
|
aria-label="{{ _('qr_login.description') }}"
|
||||||
>
|
>
|
||||||
<canvas id="qr-canvas" width="256" height="256"></canvas>
|
<img
|
||||||
|
:src="qrCodeSvg"
|
||||||
|
width="256"
|
||||||
|
height="256"
|
||||||
|
alt="{{ _('qr_login.description') }}"
|
||||||
|
id="qr-image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{{ _("qr_login.description") }}
|
{{ _("qr_login.description") }}
|
||||||
@@ -110,8 +116,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QR Code library (lightweight, no external deps) -->
|
<!-- QR code is rendered server-side; no external QR library needed -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function qrLoginPage() {
|
function qrLoginPage() {
|
||||||
@@ -120,6 +125,7 @@ function qrLoginPage() {
|
|||||||
challengeId: null,
|
challengeId: null,
|
||||||
challengeToken: '',
|
challengeToken: '',
|
||||||
qrPayload: '',
|
qrPayload: '',
|
||||||
|
qrCodeSvg: '',
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
countdown: 0,
|
countdown: 0,
|
||||||
deviceName: '',
|
deviceName: '',
|
||||||
@@ -157,24 +163,13 @@ function qrLoginPage() {
|
|||||||
this.challengeId = data.challenge_id;
|
this.challengeId = data.challenge_id;
|
||||||
this.challengeToken = data.challenge_token;
|
this.challengeToken = data.challenge_token;
|
||||||
this.qrPayload = data.qr_payload;
|
this.qrPayload = data.qr_payload;
|
||||||
|
this.qrCodeSvg = data.qr_code_svg;
|
||||||
this.expiresAt = new Date(data.expires_at);
|
this.expiresAt = new Date(data.expires_at);
|
||||||
this._ttlSeconds = data.ttl_seconds || 120;
|
this._ttlSeconds = data.ttl_seconds || 120;
|
||||||
this._receivedAt = Date.now();
|
this._receivedAt = Date.now();
|
||||||
this.status = 'pending';
|
this.status = 'pending';
|
||||||
this.deviceName = '';
|
this.deviceName = '';
|
||||||
|
|
||||||
// Render QR code
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const canvas = document.getElementById('qr-canvas');
|
|
||||||
if (canvas && typeof QRCode !== 'undefined') {
|
|
||||||
QRCode.toCanvas(canvas, this.qrPayload, {
|
|
||||||
width: 256,
|
|
||||||
margin: 2,
|
|
||||||
color: { dark: '#000000', light: '#ffffff' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start polling and countdown
|
// Start polling and countdown
|
||||||
this._startPolling();
|
this._startPolling();
|
||||||
this._startCountdown();
|
this._startCountdown();
|
||||||
|
|||||||
@@ -60,3 +60,4 @@ sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
|
|||||||
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
|
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
|
||||||
|
|
||||||
aiofiles>=24.1.0 # Asynchronous file I/O support
|
aiofiles>=24.1.0 # Asynchronous file I/O support
|
||||||
|
segno>=1.6.0 # Pure-Python QR code generator (server-side rendering, no Pillow dependency)
|
||||||
|
|||||||
Reference in New Issue
Block a user