feat: Unterschriften-Verifikation + Seiten-Reorder API
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

ajax/verify_signature.php:
- Liest Metadaten aus .meta.json neben einer Unterschrift-Seite
- Rechnet SHA256 über die zum Signaturzeitpunkt existierenden Seiten
  (erste N laut meta.page_count_at_signing)
- Vergleicht mit dem gespeicherten Hash → verified true/false
- Liefert reason bei Fehlschlag (Seitenanzahl oder Inhalt geändert)

bericht_card.php:
- Seiten mit .meta.json kriegen 🔒-Badge + grünen Rand
- 🔍-Button neben dem Löschen-Button öffnet Verifikations-Modal

editor.js:
- showSignatureVerifyResult-Modal zeigt alle Metadaten,
  beide Hashes, GPS als OpenStreetMap-Link, IP, User, Zeit
- Grün/Rot je nach Verifikationsergebnis

api/pages.php:
- Neuer Action reorder: POST mit {order:[ids]} schreibt neue
  page_order-Werte als Transaktion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 08:18:51 +02:00
parent 5db126210c
commit f37445ac9c
5 changed files with 222 additions and 3 deletions

102
ajax/verify_signature.php Normal file
View file

@ -0,0 +1,102 @@
<?php
/* Verifiziert eine Unterschrift-Seite: liest den gespeicherten Hash aus der
* zugehörigen .meta.json und rechnet den aktuellen Hash über die Seiten
* die zum Signaturzeitpunkt existierten nach.
*
* POST: pageid, token
*
* Antwort:
* { success: true, verified: true, meta: {...} }
* Hash stimmt, keine nachträgliche Änderung
* { success: true, verified: false, reason: "...", meta: {...}, current_hash: "..." }
* Hash stimmt nicht, nachträgliche Änderung erkannt
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'read')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? $_GET['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
$sqlr = $db->query("SELECT rowid, fk_bericht, source_path FROM ".$db->prefix()."bericht_page WHERE rowid = ".$pageid);
if (!$sqlr || !($row = $db->fetch_object($sqlr))) bericht_ajax_fail('Seite nicht gefunden', 404);
$full = bericht_resolve_data_path($row->source_path);
if (!$full || !file_exists($full)) bericht_ajax_fail('Datei nicht gefunden', 404);
$meta_path = $full.'.meta.json';
if (!file_exists($meta_path)) {
bericht_ajax_ok(array(
'verified' => false,
'reason' => 'Keine Metadaten-Datei — diese Seite ist keine aufgewertete Unterschrift',
));
}
$meta = json_decode(file_get_contents($meta_path), true);
if (!is_array($meta) || empty($meta['chain_hash_sha256'])) {
bericht_ajax_ok(array(
'verified' => false,
'reason' => 'Metadaten ungültig oder kein Hash enthalten',
));
}
// Wir brauchen den Bericht
$bericht = new Bericht($db);
if ($bericht->fetch($row->fk_bericht) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
// Die zum Signaturzeitpunkt existierenden Seiten: alle mit page_order < unserer.
// Sort nach page_order wie beim Signieren.
$prev_pages = BerichtPage::fetchAllForBericht($db, $row->fk_bericht);
// Nur die Seiten die VOR der Signatur waren — wir nehmen die ersten N entsprechend meta.page_count_at_signing
$expected_count = (int) ($meta['page_count_at_signing'] ?? 0);
$to_hash = array_slice($prev_pages, 0, $expected_count);
$current_count = 0;
foreach ($prev_pages as $pp) {
if ($pp->id == $row->rowid) break;
$current_count++;
}
// Hash nachrechnen
$hash_ctx = hash_init('sha256');
hash_update($hash_ctx, 'bericht:'.$bericht->id.'|ref:'.$bericht->ref);
foreach ($to_hash as $pp) {
$full_pp = bericht_resolve_data_path($pp->source_path);
if ($full_pp && file_exists($full_pp)) {
hash_update_file($hash_ctx, $full_pp);
}
hash_update($hash_ctx, '|order:'.$pp->page_order.'|note:'.($pp->note ?? ''));
}
$current_hash = hash_final($hash_ctx);
$stored_hash = $meta['chain_hash_sha256'];
$verified = hash_equals($stored_hash, $current_hash);
$result = array(
'verified' => $verified,
'stored_hash' => $stored_hash,
'current_hash' => $current_hash,
'expected_page_count' => $expected_count,
'current_page_count' => $current_count,
'meta' => array(
'signer_name' => $meta['signer_name'] ?? '',
'signed_at' => $meta['signed_at'] ?? '',
'user_login' => $meta['user_login'] ?? '',
'kunde' => $meta['kunde'] ?? '',
'parent_ref' => $meta['parent_ref'] ?? '',
'gps_lat' => $meta['gps_lat'] ?? null,
'gps_lon' => $meta['gps_lon'] ?? null,
'remote_ip' => $meta['remote_ip'] ?? '',
),
);
if (!$verified) {
if ($current_count !== $expected_count) {
$result['reason'] = "Seitenanzahl hat sich geändert ($expected_count$current_count)";
} else {
$result['reason'] = "Hash der Vorseiten stimmt nicht — eine Seite wurde nach der Unterschrift verändert";
}
}
bericht_ajax_ok($result);

View file

@ -17,6 +17,24 @@ if (!$user->hasRight('bericht', 'write')) api_fail('Permission denied', 403);
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
/* ---------- Seiten umsortieren ---------- */
if ($method === 'POST' && $action === 'reorder') {
$in = api_input();
$ids = $in['order'] ?? null;
if (!is_array($ids) || empty($ids)) api_fail('order-Array fehlt');
$db->begin();
foreach ($ids as $pos => $pageid) {
$pid = (int) $pageid;
if ($pid <= 0) continue;
if (!$db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) ($pos + 1))." WHERE rowid = ".$pid)) {
$db->rollback();
api_fail($db->lasterror(), 500);
}
}
$db->commit();
api_ok();
}
/* ---------- Unterschrift als neue Seite anhängen ---------- */ /* ---------- Unterschrift als neue Seite anhängen ---------- */
if ($method === 'POST' && $action === 'signature') { if ($method === 'POST' && $action === 'signature') {
$bericht_id = (int) ($_GET['bericht_id'] ?? 0); $bericht_id = (int) ($_GET['bericht_id'] ?? 0);

View file

@ -267,6 +267,7 @@ if (!$bericht) {
'set_slot_image' => dol_buildpath('/bericht/ajax/set_slot_image.php', 1), 'set_slot_image' => dol_buildpath('/bericht/ajax/set_slot_image.php', 1),
'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1), 'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1),
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1), 'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
), ),
'lang' => array( 'lang' => array(
'undo' => $langs->trans("BerichtUndo"), 'undo' => $langs->trans("BerichtUndo"),
@ -456,12 +457,25 @@ if (!$bericht) {
print '</div>'; print '</div>';
print '<div id="bericht-page-list" class="page-list paper-light">'; print '<div id="bericht-page-list" class="page-list paper-light">';
foreach ($pages as $idx => $p) { foreach ($pages as $idx => $p) {
print '<div class="page-thumb" data-pageid="'.$p->id.'" data-order="'.$p->page_order.'">'; // Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path)
$is_signature = false;
if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) {
$full_check = DOL_DATA_ROOT.'/'.$p->source_path;
if (file_exists($full_check.'.meta.json')) {
$is_signature = true;
}
}
print '<div class="page-thumb'.($is_signature ? ' page-signature' : '').'" data-pageid="'.$p->id.'" data-order="'.$p->page_order.'">';
print '<div class="page-thumb-paper">'; print '<div class="page-thumb-paper">';
print '<canvas class="thumb-canvas"></canvas>'; print '<canvas class="thumb-canvas"></canvas>';
print '</div>'; print '</div>';
print '<div class="page-thumb-label"><span class="page-num">'.($idx + 1).'</span></div>'; print '<div class="page-thumb-label"><span class="page-num">'.($idx + 1).'</span>'
.($is_signature ? ' <span class="sig-badge" title="Unterschrift">🔒</span>' : '')
.'</div>';
print '<div class="page-thumb-actions">'; print '<div class="page-thumb-actions">';
if ($is_signature) {
print '<button type="button" class="thumb-verify" title="Unterschrift prüfen" data-pageid="'.$p->id.'">🔍</button>';
}
print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>'; print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>';
print '</div>'; print '</div>';
print '</div>'; print '</div>';

View file

@ -249,7 +249,7 @@
} }
.page-thumb-actions { position: absolute; top: 4px; right: 4px; } .page-thumb-actions { position: absolute; top: 4px; right: 4px; }
.thumb-del { .thumb-del, .thumb-verify {
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.6);
color: #fff; color: #fff;
border: 1px solid rgba(255,255,255,0.3); border: 1px solid rgba(255,255,255,0.3);
@ -257,8 +257,19 @@
cursor: pointer; cursor: pointer;
padding: 2px 6px; padding: 2px 6px;
font-size: 12px; font-size: 12px;
margin-left: 2px;
} }
.thumb-del:hover { background: rgba(217,83,79,0.9); } .thumb-del:hover { background: rgba(217,83,79,0.9); }
.thumb-verify:hover { background: rgba(92,184,92,0.9); }
.page-thumb.page-signature {
border-color: #5cb85c;
box-shadow: 0 0 0 1px rgba(92,184,92,0.4);
}
.page-thumb.page-signature .sig-badge {
margin-left: 4px;
font-size: 12px;
}
.bericht-actions { .bericht-actions {
margin-top: 16px; margin-top: 16px;

View file

@ -857,6 +857,80 @@
list.classList.toggle('paper-light'); list.classList.toggle('paper-light');
list.classList.toggle('paper-dark'); list.classList.toggle('paper-dark');
}); });
// Unterschriften-Verifikation
document.querySelectorAll('.thumb-verify').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pageid = btn.dataset.pageid;
btn.textContent = '⏳';
try {
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', pageid);
const r = await fetch(cfg.urls.verify_signature, { method: 'POST', body: fd });
const data = await r.json();
btn.textContent = '🔍';
if (!data.success) {
alert('Fehler: ' + (data.error || 'unbekannt'));
return;
}
showSignatureVerifyResult(data);
} catch (err) {
btn.textContent = '🔍';
alert('Netzwerkfehler: ' + err.message);
}
});
});
}
/**
* Zeigt das Ergebnis der Unterschriften-Verifikation in einem Modal.
*/
function showSignatureVerifyResult(data) {
const verified = data.verified;
const m = data.meta || {};
const icon = verified ? '✅' : '⚠️';
const title = verified ? 'Unterschrift verifiziert' : 'Unterschrift NICHT verifiziert';
const bg = verified ? '#5cb85c' : '#d9534f';
const html = `
<div class="bericht-modal" id="sig-verify-modal" style="display:block;">
<div class="bericht-modal-backdrop"></div>
<div class="bericht-modal-content" style="max-width:600px;left:50%;transform:translateX(-50%);height:auto;bottom:auto;top:10vh;">
<div class="bericht-modal-header" style="background:${bg};color:#fff;">
<h3 style="color:#fff;">${icon} ${escapeHtml(title)}</h3>
<button type="button" id="sig-verify-close" title="Schließen"></button>
</div>
<div class="bericht-modal-body" style="padding:20px;overflow-y:auto;">
${!verified ? `<p style="color:#d9534f;font-weight:600;">${escapeHtml(data.reason || '')}</p>` : ''}
<table style="width:100%;font-size:13px;">
<tr><td style="opacity:0.7;padding:4px 0;">Unterzeichner:</td><td><strong>${escapeHtml(m.signer_name || '')}</strong></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Kunde:</td><td>${escapeHtml(m.kunde || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Parent:</td><td>${escapeHtml(m.parent_ref || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Signiert am:</td><td>${escapeHtml(m.signed_at || '')}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Erfasst durch:</td><td>${escapeHtml(m.user_login || '')}</td></tr>
${m.gps_lat ? `<tr><td style="opacity:0.7;padding:4px 0;">GPS:</td><td><a href="https://www.openstreetmap.org/?mlat=${m.gps_lat}&mlon=${m.gps_lon}&zoom=18" target="_blank">${m.gps_lat}, ${m.gps_lon}</a></td></tr>` : ''}
<tr><td style="opacity:0.7;padding:4px 0;">IP:</td><td><code>${escapeHtml(m.remote_ip || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;vertical-align:top;">Gespeicherter Hash:</td><td><code style="font-size:10px;word-break:break-all;">${escapeHtml(data.stored_hash || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;vertical-align:top;">Aktueller Hash:</td><td><code style="font-size:10px;word-break:break-all;">${escapeHtml(data.current_hash || '')}</code></td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Seiten bei Signatur:</td><td>${data.expected_page_count}</td></tr>
<tr><td style="opacity:0.7;padding:4px 0;">Seiten jetzt vor Signatur:</td><td>${data.current_page_count}</td></tr>
</table>
</div>
</div>
</div>
`;
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
document.body.appendChild(wrapper.firstElementChild);
document.getElementById('sig-verify-close').onclick = () => {
document.getElementById('sig-verify-modal').remove();
};
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
/** /**