feat: Unterschriften-Verifikation + Seiten-Reorder API
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
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:
parent
5db126210c
commit
f37445ac9c
5 changed files with 222 additions and 3 deletions
102
ajax/verify_signature.php
Normal file
102
ajax/verify_signature.php
Normal 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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
74
js/editor.js
74
js/editor.js
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue