Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere Datei-Einloesung, Mail-Config

Umsetzung des Thomas-Feedbacks:
- Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der
  reinkopierten Liste); stornierte ausgeblendet.
- Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert =
  ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und
  qty (geliefert); Live-Vorschau im Formular.
- Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account).
  einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus.
- CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen
  (per Copy-Paste einloesbar). Dateiname: <ExtBestellnr|Datum>_geograsim_<intNr>.
- Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil).
- Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen-
  limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server
  validiert) — kein Upload, keine Ausfuehrung/Rendering.
- Mailsystem: zentrale Config in neuer app_settings (Provider server|aws,
  Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand.
  Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt
  kanonische geograsim.at (nicht v3/localhost).

Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 11:15:53 +02:00
parent 96a73e22b2
commit 50c72fa8d8
8 changed files with 253 additions and 117 deletions
+35 -2
View File
@@ -431,8 +431,12 @@ schueler01,Max M.,,,,,</pre>
<div class="modal hidden" id="modal-license">
<div class="modal-card">
<h3>🔑 Lizenzen einlösen</h3>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile):</p>
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-2026"></textarea>
<p style="font-size:.7rem;color:#8a8a8a;margin-bottom:.5rem">Ein oder mehrere Codes (einer pro Zeile) — oder eine .txt/.csv-Datei ablegen:</p>
<textarea id="ml-codes" rows="4" style="font-family:'Courier New',monospace" placeholder="ABCD-EFGH-JKLM-NPQR-STUV"></textarea>
<div id="ml-drop" style="margin-top:.4rem;border:1.5px dashed #cbd8dc;border-radius:8px;padding:.5rem;text-align:center;font-size:.66rem;color:#8a8a8a;cursor:pointer" ondragover="event.preventDefault();this.style.background='#eef6f8'" ondragleave="this.style.background=''" ondrop="licenseDrop(event)">
📄 .txt/.csv hierher ziehen oder <label style="color:#4a7c8a;font-weight:700;cursor:pointer;text-decoration:underline">auswählen<input type="file" id="ml-file" accept=".txt,.csv,text/plain,text/csv" style="display:none" onchange="licenseFilePick(this)"></label>
<div id="ml-fileinfo" style="margin-top:.2rem"></div>
</div>
<div style="display:flex;gap:.4rem;margin-top:.5rem">
<button class="btn" onclick="redeemLicenses()">Einlösen</button>
<button class="btn btn-outline" onclick="hideModal('modal-license')">Abbrechen</button>
@@ -5512,6 +5516,35 @@ async function assignLicense(studentId) {
loadLicenses();
}
// Sicherer Datei-Import fuer Codes — mehrstufig:
// (1) Endung+MIME .txt/.csv, (2) Groessenlimit, (3) NUR als Text lesen,
// (4) Whitelist-Regex extrahiert ausschliesslich Code-Tokens (nichts wird
// interpretiert/gerendert/ausgefuehrt), (5) Server validiert jeden Code.
function extractCodes(text){
var m = String(text).toUpperCase().match(/\b[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2,4}\b/g) || [];
var seen={}, out=[];
for (var i=0;i<m.length;i++){ var c=m[i]; if(!seen[c]){ seen[c]=1; out.push(c); } if(out.length>=5000) break; }
return out;
}
function ingestLicenseFile(file){
var info=document.getElementById('ml-fileinfo'); if(!file) return;
var name=(file.name||'').toLowerCase();
if(!/\.(txt|csv)$/.test(name) || (file.type && !/^text\//.test(file.type))){
info.innerHTML='<span style="color:#c07a6b">Nur .txt oder .csv erlaubt.</span>'; return; }
if(file.size > 256*1024){ info.innerHTML='<span style="color:#c07a6b">Datei zu groß (max 256 KB).</span>'; return; }
var reader=new FileReader();
reader.onload=function(){
var codes=extractCodes(reader.result);
if(!codes.length){ info.innerHTML='<span style="color:#c07a6b">Keine gültigen Codes gefunden.</span>'; return; }
document.getElementById('ml-codes').value=codes.join('\n');
info.innerHTML='<span style="color:#5a8a5e">'+codes.length+' Codes aus Datei übernommen.</span>';
};
reader.onerror=function(){ info.innerHTML='<span style="color:#c07a6b">Datei konnte nicht gelesen werden.</span>'; };
reader.readAsText(file);
}
function licenseFilePick(input){ if(input.files && input.files[0]) ingestLicenseFile(input.files[0]); input.value=''; }
function licenseDrop(ev){ ev.preventDefault(); if(ev.currentTarget) ev.currentTarget.style.background=''; var f=ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; if(f) ingestLicenseFile(f); }
async function redeemLicenses() {
var text = document.getElementById('ml-codes').value.trim();
if (!text) return;