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
@@ -0,0 +1,8 @@
-- =====================================================================
-- Lizenz-Vertrieb — Nachtrag 2 (2026-08-26)
-- * bestellte vs. gelieferte Menge (Zugabe +10 %)
-- Additiv & non-breaking. (Mail-Config: siehe Nachtrag 3, app_settings.)
-- =====================================================================
ALTER TABLE license_batches
ADD COLUMN qty_ordered INT UNSIGNED NULL AFTER qty; -- bestellt (verrechnet); qty = geliefert inkl. Zugabe
@@ -0,0 +1,19 @@
-- =====================================================================
-- Lizenz-Vertrieb — Nachtrag 3 (2026-08-26): portable App-Settings
-- Eigene Key/Value-Tabelle fuer die Mail-Konfiguration (Provider/Absender/URL).
-- Ersetzt die Nutzung der lokalen Orphan-Tabelle platform_config_v2 (existiert
-- auf v3/Prod nicht). Additiv & non-breaking. MariaDB + MySQL 8 kompatibel.
-- =====================================================================
CREATE TABLE IF NOT EXISTS app_settings (
skey VARCHAR(64) NOT NULL PRIMARY KEY,
sval TEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
updated_by INT UNSIGNED NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO app_settings (skey, sval) VALUES
('mail_provider', 'server'),
('mail_from_email', 'support@geograsim.at'),
('mail_from_name', 'GeoGraSim'),
('mail_public_url', 'https://geograsim.at');
+105 -19
View File
@@ -128,8 +128,8 @@
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
<div class="frm">
<div>
<label>Anzahl</label>
<input type="number" id="iss-count" value="12" min="1" max="5000">
<label>Anzahl (bestellt)</label>
<input type="number" id="iss-count" value="12" min="1" max="5000" oninput="updatePreview()">
</div>
<div>
<label>Typ</label>
@@ -168,6 +168,12 @@
</div>
<datalist id="cust-hints"></datalist>
<datalist id="order-hints"></datalist>
<div style="margin-top:.6rem;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.75rem;font-weight:600;color:#4a4a4a">
<input type="checkbox" id="iss-bonus" checked onchange="updatePreview()"> +10 % Gratis-Zugabe (aufgerundet)
</label>
<span id="iss-preview" class="mini"></span>
</div>
<div style="margin-top:.7rem" class="row-act">
<button class="btn" onclick="issueBatch()">Codes erzeugen &amp; ausgeben</button>
<span id="iss-msg" class="mini"></span>
@@ -192,6 +198,41 @@
<div id="batch-table" style="overflow-x:auto"></div>
</div>
<!-- ============ Mail-Einstellungen ============ -->
<div class="card">
<div class="card-h">✉️ Mail-Einstellungen</div>
<div class="frm">
<div>
<label>Versand-Treiber</label>
<select id="mc-provider">
<option value="server">Eigener Server (SMTP)</option>
<option value="aws">Amazon SES (später)</option>
</select>
<div class="hint-s">AWS wird aktiv, sobald konfiguriert.</div>
</div>
<div>
<label>Absender-Name</label>
<input type="text" id="mc-fromname" placeholder="GeoGraSim">
</div>
<div>
<label>Absender-Adresse</label>
<input type="email" id="mc-fromemail" placeholder="support@geograsim.at">
<div class="hint-s">SMTP-Zugang bleibt geheim in der .env.</div>
</div>
<div>
<label>Öffentliche Basis-URL</label>
<input type="text" id="mc-publicurl" placeholder="https://geograsim.at">
<div class="hint-s">Für Links in Mails (nicht v3/localhost).</div>
</div>
</div>
<div class="row-act" style="margin-top:.6rem">
<button class="btn" onclick="saveMailConfig()">Speichern</button>
<input type="email" id="mc-testto" placeholder="Test an…" style="padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem">
<button class="btn btn-outline" onclick="testMail()">Test senden</button>
<span id="mc-msg" class="mini"></span>
</div>
</div>
<div class="card">
<div class="card-h">Neue Lizenzen generieren</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
@@ -409,16 +450,18 @@ async function issueBatch(){
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
var bonus = document.getElementById('iss-bonus').checked;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
var delivered = calcDelivered(count, bonus);
var who = name||email||custno||'(kein Kunde)';
if(count>=100 && !confirm(count+' Codes für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
msg.textContent = 'Erzeuge …';
var r = await api('licenses', {action:'issue_batch', count:count, sales_type:type, order_no:order,
var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
customer:{email:email, customer_no:custno, name:name}, valid_from:vf, valid_until:vu});
if(r.error){ msg.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Charge #'+r.batch_id+').</span>';
lastIssue = {batch_id:r.batch_id, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').</span>';
lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
renderIssueResult();
loadBatches();
loadLicenses();
@@ -429,17 +472,15 @@ function renderIssueResult(){
var codesText = m.codes.join('\n');
var mailText = buildMailText(m);
var h = '<div class="out">'
+ '<div class="mini" style="margin-bottom:.5rem">Bestellung <b>#'+m.internal_no+'</b> · bestellt <b>'+m.ordered+'</b> · geliefert <b>'+m.count+'</b>'+(m.count>m.ordered?' <span style="color:#5a8a5e">(+'+(m.count-m.ordered)+' gratis)</span>':'')+' · gültig bis '+fmtDate(m.valid_until)+'</div>'
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
+ '<div class="row-act" style="margin-top:.4rem">'
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv (mit Gültig-bis)</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv</button>'
+ '</div>'
+ '<h4 style="margin-top:.9rem">2 · Sammel-Einlöse-Link (alle auf einmal)</h4>'
+ '<div class="row-act"><input class="lnk" id="charge-link" readonly value="'+esc(absUrl('/einloesen?charge='+m.token))+'"><button class="btn btn-outline" onclick="copyEl(\'charge-link\')">📋</button></div>'
+ '<div class="hint-s">Pro Code funktioniert auch: '+esc(absUrl('/einloesen?code=CODE'))+'</div>'
+ '<h4 style="margin-top:.9rem">3 · Per Mail senden</h4>'
+ '<h4 style="margin-top:.9rem">2 · Per Mail senden</h4>'
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
@@ -457,20 +498,65 @@ function buildMailText(m){
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
+ codesPart + '\n\n'
+ 'Einlösen: Als Lehrperson unter '+absUrl('/')+' anmelden und die Codes unter „Lizenzen" eingeben.\n'
+ 'Oder alle auf einmal über diesen Link: '+absUrl('/einloesen?charge='+m.token)+'\n\n'
+ 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
+ 'Freundliche Grüße\nGeoGraSim';
}
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
// CSV: Metadaten (inkl. Gültig-bis) in die KOPFZEILE, danach reine Code-Zeilen
// -> der Code-Block bleibt per Copy-Paste einlösbar (kein Datum je Zeile).
function downloadCodes(fmt){
var m = lastIssue; if(!m) return;
var content, mime;
if(fmt==='csv'){ content='Code;Gueltig_bis\n'+m.codes.map(function(c){return c+';'+m.valid_until;}).join('\n')+'\n'; mime='text/csv'; }
else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
var base = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=base+'.'+fmt; a.click();
if(fmt==='csv'){
var head='Bestellung;#'+(m.internal_no||'')+'\nExterne Bestellnummer;'+(m.order_no||'')+'\nGültig bis;'+fmtDate(m.valid_until)+'\nAnzahl;'+m.count+'\n\n';
content=head+m.codes.join('\r\n')+'\r\n'; mime='text/csv';
} else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
}
function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
function updatePreview(){
var o=parseInt(document.getElementById('iss-count').value)||0;
var b=document.getElementById('iss-bonus').checked;
var el=document.getElementById('iss-preview'); if(!el) return;
if(o<1){ el.textContent=''; return; }
var d=calcDelivered(o,b);
el.innerHTML = b ? ('→ im Paket <b>'+d+'</b> Codes (+'+(d-o)+' gratis)') : ('→ <b>'+d+'</b> Codes (keine Zugabe)');
}
// ── Mail-Einstellungen ──
var PUBLIC_URL='https://geograsim.at';
async function loadMailConfig(){
var r=await api('licenses?admin=1&mail_config=1');
if(!r || r.error) return;
PUBLIC_URL=r.public_url||PUBLIC_URL;
document.getElementById('mc-provider').value=r.provider||'server';
document.getElementById('mc-fromname').value=r.from_name||'';
document.getElementById('mc-fromemail').value=r.from_email||'';
document.getElementById('mc-publicurl').value=r.public_url||'';
}
async function saveMailConfig(){
var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
var r=await api('licenses',{action:'set_mail_config',
provider:document.getElementById('mc-provider').value,
from_name:document.getElementById('mc-fromname').value.trim(),
from_email:document.getElementById('mc-fromemail').value.trim(),
public_url:document.getElementById('mc-publicurl').value.trim()});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gespeichert.</span>'; loadMailConfig();
}
async function testMail(){
var to=document.getElementById('mc-testto').value.trim();
var mm=document.getElementById('mc-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Test-Adresse fehlt.</span>'; return; }
mm.textContent='Sende Test …';
var r=await api('licenses',{action:'test_mail', to_email:to});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Test gesendet.</span>';
}
async function sendMail(batchId){
@@ -542,8 +628,6 @@ async function openBatch(id){
+ ' · gültig '+fmtDate(b.default_valid_from)+''+fmtDate(b.default_valid_until)
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
h+='<div class="row-act" style="margin-bottom:.6rem">'
+ '<input class="lnk" readonly value="'+esc(absUrl('/einloesen?charge='+b.redeem_token))+'">'
+ '<button class="btn-sm" onclick="copyEl2(this)">📋 Sammellink</button>'
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
@@ -608,6 +692,8 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
loadLicenses();
prefillDefaults();
loadBatches();
loadMailConfig();
updatePreview();
});
</script>
</body>
-80
View File
@@ -1,80 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lizenz einlösen — GeoGraSim</title>
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#f0eee6;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#2a2a2a}
.card{background:#fff;border-radius:18px;max-width:480px;width:100%;padding:2.2rem 2rem;box-shadow:0 6px 30px rgba(31,78,90,.1);border:1px solid #e6e0d0;text-align:center}
.wm{font-family:'Arial Black','Helvetica Neue',Arial,sans-serif;font-weight:900;font-size:26px;letter-spacing:-.02em;margin-bottom:.3rem}
.wm .g{color:#143b4b}.wm .r{color:#1d707b}.wm .a{color:#f17f13}
.sub{font-size:.62rem;font-weight:700;letter-spacing:.28em;color:#83a3a0;text-transform:uppercase;margin-bottom:1.6rem}
h1{font-size:1.2rem;color:#1f4e5a;margin-bottom:.6rem}
p{font-size:.9rem;color:#4a5a5f;line-height:1.6;margin-bottom:1rem}
.big{font-size:2.6rem;font-weight:900;color:#5a8a5e;line-height:1;margin:.4rem 0}
.btn{display:inline-block;padding:.7rem 1.6rem;background:#4a7c8a;color:#fff;text-decoration:none;border-radius:9px;font-weight:700;font-size:.9rem;border:none;cursor:pointer;margin-top:.4rem}
.btn:hover{background:#3a6470}
.spin{width:34px;height:34px;border:3px solid #dfe8ea;border-top-color:#4a7c8a;border-radius:50%;animation:sp 1s linear infinite;margin:1rem auto}
@keyframes sp{to{transform:rotate(360deg)}}
.err{color:#b0453a}
.foot{font-size:.72rem;color:#9aa5a9;margin-top:1.4rem}
.code{font-family:'Courier New',monospace;font-weight:700;color:#1f4e5a}
</style>
</head>
<body>
<div class="card">
<div class="wm"><span class="g">Geo</span><span class="r">Gra</span><span class="a">Sim</span></div>
<div class="sub">Lizenz einlösen</div>
<div id="content"><div class="spin"></div></div>
<div class="foot">Bei Fragen: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a></div>
</div>
<script>
(function(){
var G = window.__GGS__ || {};
var BASE = G.basePath || '';
var content = document.getElementById('content');
var params = new URLSearchParams(location.search);
var charge = params.get('charge');
var code = params.get('code');
function html(h){ content.innerHTML = h; }
function loginPrompt(){
html('<h1>Bitte anmelden</h1>'
+ '<p>Melde dich als <b>Lehrperson</b> an, um '+(charge?'diese Lizenzen':'diesen Lizenzcode')+' einzulösen. Öffne danach diesen Link erneut.</p>'
+ '<a class="btn" href="'+BASE+'/login">Als Lehrperson anmelden</a>');
}
if (!charge && !code) { html('<h1>Ungültiger Link</h1><p>Es fehlt ein Lizenzcode oder eine Charge.</p>'); return; }
if (!G.teacherId) { loginPrompt(); return; }
var body = charge ? {action:'redeem_charge', token:charge} : {action:'redeem', code:code};
fetch(BASE + '/api/licenses', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify(body)})
.then(function(r){ return r.json(); })
.then(function(r){
if (r.error) {
html('<h1 class="err">Nicht eingelöst</h1><p class="err">'+String(r.error)+'</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
return;
}
if (charge) {
var n = r.redeemed || 0;
html('<h1>Geschafft!</h1><div class="big">'+n+'</div>'
+ '<p>'+(n===1?'Lizenz wurde':'Lizenzen wurden')+' deinem Konto gutgeschrieben. Du kannst sie jetzt im Dashboard deinen Schüler:innen zuweisen.</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
} else {
html('<h1>Code eingelöst ✓</h1>'
+ '<p>Der Lizenzcode <span class="code">'+String(code)+'</span> wurde deinem Konto gutgeschrieben.</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
}
})
.catch(function(){
html('<h1 class="err">Fehler</h1><p>Verbindung fehlgeschlagen. Bitte später erneut versuchen.</p>');
});
})();
</script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
<?php renderPage('einloesen.html');
+65 -11
View File
@@ -87,6 +87,14 @@ function lic_attempt(Database $db, ?int $teacherId, string $code, bool $ok): voi
[$ip !== '' ? $ip : null, $teacherId, substr($code, 0, 32), $ok ? 1 : 0]);
}
/** App-Setting lesen (app_settings), robust mit Default. */
function lic_cfg(Database $db, string $key, string $default = ''): string {
try {
$r = $db->fetchOne("SELECT sval FROM app_settings WHERE skey = ?", [$key]);
return ($r && $r['sval'] !== null && $r['sval'] !== '') ? (string)$r['sval'] : $default;
} catch (\Throwable $e) { return $default; }
}
// === GET ===
if ($method === 'GET') {
// Super-Admin: alle Lizenzen
@@ -105,6 +113,16 @@ if ($method === 'GET') {
]);
}
// Mail-System-Konfiguration
if (isset($_GET['mail_config'])) {
Response::ok([
'provider' => lic_cfg($db, 'mail_provider', 'server'),
'from_email' => lic_cfg($db, 'mail_from_email', 'support@geograsim.at'),
'from_name' => lic_cfg($db, 'mail_from_name', 'GeoGraSim'),
'public_url' => lic_cfg($db, 'mail_public_url', 'https://geograsim.at'),
]);
}
// Kunden-Autocomplete
if (isset($_GET['customers'])) {
$like = '%' . trim($_GET['customers']) . '%';
@@ -214,9 +232,10 @@ if ($method === 'GET') {
[$classId]
);
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen)
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen), storniert ausgeschlossen.
// Reihenfolge = id (= Reihenfolge, in der die Codes ausgegeben/reinkopiert wurden).
$freeLicenses = $db->fetchAll(
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY code',
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL AND canceled_at IS NULL ORDER BY id',
[$teacherId]
);
@@ -348,8 +367,11 @@ if ($method === 'POST') {
$adminId = Session::adminId();
if (!$adminId) Response::error('Nur Admin', 403);
$count = (int)($body['count'] ?? 0);
if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
$ordered = (int)($body['count'] ?? 0);
if ($ordered < 1 || $ordered > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
// Gratis-Zugabe: +10 %, aufgerundet (14 → 16). Standard aktiv.
$bonus = !array_key_exists('bonus', $body) || !empty($body['bonus']);
$count = $bonus ? (int)ceil($ordered * 1.1) : $ordered;
$type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat';
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
$note = trim((string)($body['note'] ?? '')) ?: null;
@@ -366,9 +388,9 @@ if ($method === 'POST') {
$db->begin();
try {
$db->execute(
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by)
VALUES (?,?,?,?,?,?,?,?,?)",
[$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]);
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, qty_ordered, default_valid_from, default_valid_until, note, redeem_token, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?)",
[$customerId, $orderNo, $type, $count, $ordered, $validFrom, $validUntil, $note, $token, $adminId]);
$batchId = (int)$db->lastInsertId();
$codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50;
@@ -383,16 +405,16 @@ if ($method === 'POST') {
} catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ }
}
if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt");
lic_event($db, $batchId, null, 'issued', "$count Codes, gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
lic_event($db, $batchId, null, 'issued', "bestellt $ordered, geliefert $made (Zugabe " . ($bonus ? 'ja' : 'nein') . "), gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
}
Response::ok([
'batch_id' => $batchId, 'redeem_token' => $token,
'batch_id' => $batchId, 'internal_no' => $batchId, 'redeem_token' => $token,
'valid_from' => $validFrom, 'valid_until' => $validUntil,
'count' => $made, 'codes' => $codes,
'ordered' => $ordered, 'count' => $made, 'codes' => $codes,
]);
}
@@ -444,6 +466,8 @@ if ($method === 'POST') {
if ($action === 'send_batch_mail') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
$provider = lic_cfg($db, 'mail_provider', 'server');
if ($provider !== 'server') Response::error('Aktiver Mail-Treiber „' . $provider . '" ist noch nicht verfügbar. In den Mail-Einstellungen auf „server" stellen.');
$to = trim((string)($body['to_email'] ?? ''));
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
$textBody = (string)($body['body_text'] ?? '');
@@ -459,7 +483,7 @@ if ($method === 'POST') {
}
$db->execute(
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
[$bid, $to, $subject, $textBody, $html, 'server', 'queued', $adminId]);
[$bid, $to, $subject, $textBody, $html, $provider, 'queued', $adminId]);
$mailId = (int)$db->lastInsertId();
try {
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
@@ -489,6 +513,36 @@ if ($method === 'POST') {
Response::ok(['redeemed' => $n]);
}
// ═══════════════ Mail-System-Konfiguration ═══════════════
if ($action === 'set_mail_config') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$prov = in_array($body['provider'] ?? '', ['server', 'aws'], true) ? $body['provider'] : 'server';
$femail = trim((string)($body['from_email'] ?? ''));
$fname = trim((string)($body['from_name'] ?? ''));
$purl = rtrim(trim((string)($body['public_url'] ?? '')), '/');
if ($femail !== '' && !filter_var($femail, FILTER_VALIDATE_EMAIL)) Response::error('Absender-Adresse ungültig');
$set = ['mail_provider' => $prov, 'mail_from_email' => $femail, 'mail_from_name' => $fname, 'mail_public_url' => $purl];
foreach ($set as $k => $v) {
if ($v === '') continue;
$db->execute("INSERT INTO app_settings (skey, sval, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE sval = VALUES(sval), updated_by = VALUES(updated_by)", [$k, $v, $adminId]);
}
Response::ok();
}
if ($action === 'test_mail') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$to = trim((string)($body['to_email'] ?? ''));
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Test-Adresse erforderlich');
$provider = lic_cfg($db, 'mail_provider', 'server');
if ($provider !== 'server') Response::error('Aktiver Treiber „' . $provider . '" ist noch nicht verfügbar. Auf „server" stellen.');
try {
Mailer::sendWithAttachments($to, 'GeoGraSim — Test-Mail',
Mailer::renderPlainBody("Test-Mail aus der Lizenz-Verwaltung.\nAbsender und Versand funktionieren."),
'Test-Mail — Versand funktioniert.');
Response::ok(['status' => 'sent']);
} catch (\Throwable $e) { Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500); }
}
Response::error('Unbekannte Aktion');
}
+21 -4
View File
@@ -16,13 +16,30 @@ class Mailer {
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = defined('SMTP_PORT') ? (int)SMTP_PORT : 587;
$mail->CharSet = 'UTF-8';
$mail->setFrom(
defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at',
defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim'
);
[$fromEmail, $fromName] = self::configuredFrom();
$mail->setFrom($fromEmail, $fromName);
return $mail;
}
/** Absender aus Admin-Config (platform_config_v2), Fallback auf .env-Konstanten.
* SMTP-Zugangsdaten bleiben bewusst in .env (geheim) — nur die sichtbare
* Absenderadresse/-name ist zentral in der Admin-Oberflaeche pflegbar. */
private static function configuredFrom(): array {
$email = defined('SMTP_FROM') ? SMTP_FROM : 'support@geograsim.at';
$name = defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : 'GeoGraSim';
try {
if (class_exists('Database')) {
$rows = Database::get()->fetchAll(
"SELECT skey, sval FROM app_settings WHERE skey IN ('mail_from_email','mail_from_name')");
foreach ($rows as $row) {
if ($row['skey'] === 'mail_from_email' && !empty($row['sval'])) $email = $row['sval'];
if ($row['skey'] === 'mail_from_name' && !empty($row['sval'])) $name = $row['sval'];
}
}
} catch (\Throwable $e) { /* Config nicht verfuegbar -> Defaults */ }
return [$email, $name];
}
public static function send(string $to, string $subject, string $htmlBody, string $textBody = ''): bool {
try {
$mail = self::create();
+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;