Initial commit: QR code generator (encodage et rendu côté navigateur)

This commit is contained in:
florian committed 2026-08-27 15:19:52 +02:00
commit 8be33ed3f6
11 files changed
+2864

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
.DS_Store
*.log
+16
View File
@@ -0,0 +1,16 @@
# Image "unprivileged" officielle : nginx tourne nativement en non-root, écoute sur un port >1024
FROM nginxinc/nginx-unprivileged:1.27-alpine
USER root
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY app/ /usr/share/nginx/html/
RUN chown -R nginx:nginx /usr/share/nginx/html
USER nginx
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Florian Utiel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
# QR Code Generator
Générateur de QR codes entièrement statique, sans backend.
L'encodage et le rendu du QR code se font **intégralement dans le navigateur** : aucun texte, lien ou logo n'est jamais envoyé à un serveur.
## Aperçu
![Aperçu du générateur](docs/screenshot.png)
## Fonctionnalités
- Encodage de texte libre ou d'URL en QR code, côté client, via [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator)
- Niveaux de correction d'erreur configurables (L / M / Q / H)
- Couleurs de modules et de fond personnalisables
- Superposition d'un logo au centre du QR code (force automatiquement la correction d'erreur au niveau H pour rester scannable)
- Export du QR code en PNG
- Aucune dépendance réseau au runtime (CSP `connect-src 'none'`)
## Déploiement
```bash
docker compose up -d --build
```
L'application est alors disponible sur `http://<hôte>:8091/`.
## Stack technique
- HTML / CSS / JavaScript vanilla, aucun build step
- [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) pour l'encodage QR
- Rendu et superposition du logo via l'API Canvas du navigateur
- Servi par `nginx-unprivileged` (image non-root)
- Conteneur durci : `read_only`, `cap_drop: ALL`, `no-new-privileges`, en-têtes de sécurité (CSP, X-Frame-Options, etc.)
## Sécurité
Le contenu encodé et les images de logo restent uniquement dans le navigateur (traitement via l'API Canvas / FileReader). Le serveur ne fait que servir des fichiers statiques.
## Licence
Ce projet est distribué sous licence [MIT](LICENSE).
+129
View File
@@ -0,0 +1,129 @@
(() => {
"use strict";
const textEl = document.getElementById("qr-text");
const cellSizeEl = document.getElementById("qr-cell-size");
const correctionEl = document.getElementById("qr-correction");
const fgColorEl = document.getElementById("qr-fg-color");
const bgColorEl = document.getElementById("qr-bg-color");
const logoInputEl = document.getElementById("qr-logo-input");
const logoClearBtn = document.getElementById("qr-logo-clear");
const generateBtn = document.getElementById("generate-btn");
const downloadBtn = document.getElementById("download-btn");
const canvas = document.getElementById("qr-canvas");
const statusEl = document.getElementById("qr-status");
const ctx = canvas.getContext("2d");
let logoImage = null;
function readLogoFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
logoImage = img;
render();
};
img.src = reader.result;
};
reader.readAsDataURL(file);
}
logoInputEl.addEventListener("change", (e) => {
readLogoFile(e.target.files[0]);
});
logoClearBtn.addEventListener("click", () => {
logoImage = null;
logoInputEl.value = "";
render();
});
function render() {
const text = textEl.value;
statusEl.classList.remove("error");
if (!text) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 280;
canvas.height = 280;
statusEl.textContent = "Saisissez un texte ou une URL pour générer le QR code.";
return;
}
const cellSize = parseInt(cellSizeEl.value, 10) || 8;
const requestedLevel = correctionEl.value;
const level = logoImage ? "H" : requestedLevel;
const fgColor = fgColorEl.value || "#000000";
const bgColor = bgColorEl.value || "#ffffff";
const margin = 4; // modules
try {
const qr = qrcode(0, level);
qr.addData(text);
qr.make();
const moduleCount = qr.getModuleCount();
const size = (moduleCount + margin * 2) * cellSize;
canvas.width = size;
canvas.height = size;
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = fgColor;
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect((col + margin) * cellSize, (row + margin) * cellSize, cellSize, cellSize);
}
}
}
if (logoImage) {
const logoSize = size * 0.22;
const pad = logoSize * 0.14;
const x = (size - logoSize) / 2;
const y = (size - logoSize) / 2;
ctx.fillStyle = bgColor;
ctx.fillRect(x - pad, y - pad, logoSize + pad * 2, logoSize + pad * 2);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(logoImage, x, y, logoSize, logoSize);
}
statusEl.textContent = logoImage
? `QR code généré (correction ${level}, forcée par le logo).`
: `QR code généré (correction ${level}).`;
} catch (err) {
statusEl.textContent = "Erreur : " + (err.message || "contenu trop long pour être encodé.");
statusEl.classList.add("error");
}
}
function downloadPng() {
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "qrcode.png";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, "image/png");
}
[textEl, cellSizeEl, correctionEl, fgColorEl, bgColorEl].forEach((el) => {
el.addEventListener("input", render);
});
generateBtn.addEventListener("click", render);
downloadBtn.addEventListener("click", downloadPng);
render();
})();
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QR Code Generator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<h1>QR Code <span>Generator</span></h1>
<p>Générez des QR codes pour du texte ou des liens, avec logo optionnel.</p>
<span class="badge">🔒 100% local — rien n'est envoyé à un serveur</span>
</header>
<main>
<section class="panel">
<h2>Contenu</h2>
<div class="field">
<label for="qr-text">Texte ou URL à encoder</label>
<textarea id="qr-text" placeholder="https://example.com">https://example.com</textarea>
</div>
<div class="grid">
<div class="field">
<label for="qr-cell-size">Taille des modules (px)</label>
<input type="range" id="qr-cell-size" min="4" max="16" value="8" />
</div>
<div class="field">
<label for="qr-correction">Correction d'erreur</label>
<select id="qr-correction">
<option value="L">L — basse (7%)</option>
<option value="M" selected>M — moyenne (15%)</option>
<option value="Q">Q — haute (25%)</option>
<option value="H">H — maximale (30%)</option>
</select>
</div>
</div>
<div class="color-fields">
<div class="field">
<label for="qr-fg-color">Couleur des modules</label>
<input type="color" id="qr-fg-color" value="#0c0a09" />
</div>
<div class="field">
<label for="qr-bg-color">Couleur de fond</label>
<input type="color" id="qr-bg-color" value="#ffffff" />
</div>
</div>
</section>
<section class="panel">
<h2>Logo (optionnel)</h2>
<div class="file-row">
<input type="file" id="qr-logo-input" accept="image/*" />
<button class="btn-danger" id="qr-logo-clear" type="button">Retirer le logo</button>
</div>
<p class="hint">Un logo au centre force automatiquement la correction d'erreur au niveau H pour rester scannable.</p>
</section>
<div class="actions">
<button class="btn-primary" id="generate-btn">Générer le QR code</button>
<button class="btn-secondary" id="download-btn">Télécharger en PNG</button>
</div>
<section class="panel preview-panel">
<div class="qr-canvas-wrap">
<canvas id="qr-canvas"></canvas>
</div>
<p class="hint" id="qr-status">Aperçu généré automatiquement à chaque modification.</p>
</section>
</main>
<footer>
Générateur de QR code — tout le traitement (encodage, rendu, superposition du logo) se fait dans votre navigateur.
</footer>
<script src="vendor/qrcode.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
:root {
--bg: #0c0a09;
--bg-elevated: #171310;
--surface: #1f1a15;
--surface-hover: #2a221a;
--border: #33291f;
--border-strong: #4a3a29;
--text: #f5efe8;
--text-muted: #b8ab9a;
--text-faint: #877a6a;
--accent: #f97316;
--accent-strong: #fb923c;
--accent-contrast: #1a0f04;
--accent-soft: rgba(249, 115, 22, 0.14);
--ok: #34d399;
--ok-soft: rgba(52, 211, 153, 0.12);
--warn: #facc15;
--warn-soft: rgba(250, 204, 21, 0.12);
--danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.12);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
header {
padding: 2.5rem 1.5rem 1rem;
text-align: center;
}
header h1 {
margin: 0 0 0.4rem;
font-size: 1.7rem;
}
header h1 span {
color: var(--accent-strong);
}
header p {
margin: 0;
color: var(--text-muted);
font-size: 0.95rem;
}
.badge {
display: inline-block;
margin-top: 0.7rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
background: var(--ok-soft);
color: var(--ok);
border: 1px solid rgba(52, 211, 153, 0.3);
font-size: 0.78rem;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 1rem 1.5rem 4rem;
display: grid;
gap: 1.5rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1.25rem 1.5rem;
}
.panel h2 {
margin-top: 0;
font-size: 1.1rem;
color: var(--text);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
textarea, input[type="text"], input[type="number"], input[type="range"], input[type="color"], select {
width: 100%;
padding: 0.55rem 0.7rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
}
textarea {
min-height: 90px;
resize: vertical;
}
input[type="color"] {
padding: 0.25rem;
height: 42px;
}
textarea:focus, input:focus, select:focus {
outline: 2px solid var(--accent-strong);
outline-offset: 1px;
border-color: var(--accent);
}
.field {
margin-bottom: 0.9rem;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-row input { width: auto; }
.file-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
button {
cursor: pointer;
border: none;
border-radius: var(--radius-sm);
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-family: inherit;
font-weight: 600;
}
.btn-primary {
background: var(--accent);
color: var(--accent-contrast);
}
.btn-primary:hover { background: var(--accent-strong); }
.btn-secondary {
background: var(--surface-hover);
color: var(--text);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent-strong); background: var(--accent-soft); }
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid rgba(248, 113, 113, 0.4);
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
.btn-danger:hover { background: var(--danger-soft); }
.actions {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.preview-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.qr-canvas-wrap {
background: white;
padding: 16px;
border-radius: var(--radius-md);
display: inline-flex;
}
#qr-canvas {
display: block;
max-width: 100%;
height: auto;
}
.hint {
color: var(--text-faint);
font-size: 0.8rem;
margin-top: 0.3rem;
}
footer {
text-align: center;
color: var(--text-faint);
font-size: 0.78rem;
padding: 1rem 1.5rem 2.5rem;
}
.section-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.color-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
+2297
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
services:
qrcode-generator:
build: .
container_name: qrcode-generator
restart: unless-stopped
ports:
- "8091:8080"
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+23
View File
@@ -0,0 +1,23 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Rien ne doit jamais sortir du navigateur : CSP verrouillée, pas de connect-src externe.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
location / {
try_files $uri $uri/ =404;
}
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
}