Der X-API-Key identifiziert den Benutzer; alle Endpunkte waren bereits auf dessen user_id gescoped (27 Operationen geprueft). Zwei Luecken blieben: - Die Aufloesung Token -> Benutzer nahm bei mehrdeutigem Token per LIMIT 1 einfach den ersten Treffer. Haetten zwei Benutzer denselben Token, saehe der eine die Daten des anderen. Jetzt: fail closed (401 + Logeintrag), und /einstellungen weist einen bereits vergebenen Token mit 409 ab. - Ein leerer Token galt als Wert: wer seine Einstellungen einmal gespeichert hatte, besass eine API_TOKEN-Zeile mit ''. Leere Werte matchen jetzt nie. Neu in den Einstellungen (Abschnitt REST-API): - "Neu generieren" erzeugt einen zufaelligen Token (32 Byte, crypto.get- RandomValues); er wird nur ins Feld gefuellt und erst beim Speichern aktiv, ein Fehlklick laesst sich also verwerfen. - "Kopieren" legt den Token in die Zwischenablage; das Auge blendet ihn ein (bestand bereits fuer Secret-Felder). API aktualisiert: - PUT /settings kannte nur name/adresse/kundennummer, GET lieferte aber alle acht Felder. Jetzt schreibt PUT alle (email, telefon, ort, webseite, geburtsdatum) und aendert nur die im Body uebergebenen Felder; die Antwort enthaelt den neuen Stand. Swagger: - Beschreibung sagte "konfiguriert via Umgebungsvariable API_TOKEN" - das gilt seit der Multi-User-Umstellung nicht mehr. Jetzt dokumentiert: Token pro Benutzer aus den Einstellungen, Zugriff nur auf eigene Daten, fremde id -> 404, unbekannter/leerer/mehrdeutiger Token -> 401. - Settings-Schema um die fehlenden fuenf Felder ergaenzt. Verifiziert mit zwei Benutzern und je eigenem Key: Lesen, Aendern, Loeschen, Timeline, Anhaenge, E-Mails, Generierung, Jobangebote und Blacklist des jeweils anderen liefern durchgaengig 404; Listen, Export und Statistik zeigen nur eigene Daten; kollidierender Token -> 409; mehrdeutiger Token in der DB -> 401 fuer beide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
12 KiB
Plaintext
176 lines
12 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<%- include('partials/head') %>
|
||
</head>
|
||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||
<%- include('partials/header') %>
|
||
|
||
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||
<!-- Back link -->
|
||
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||
</svg>
|
||
Zurück zur Übersicht
|
||
</a>
|
||
|
||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||
<div class="flex items-start gap-3">
|
||
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||
</svg>
|
||
<div>
|
||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Einstellungen</h2>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
|
||
Hier liegen alle Werte, die früher über die <code class="px-1 rounded bg-gray-100 dark:bg-gray-700">.env</code>-Datei
|
||
gesetzt wurden – Ollama, E-Mail, Kalender und die REST-API. Gespeichert wird in der Datenbank;
|
||
Änderungen wirken <em>sofort</em>, ein Neustart ist nicht nötig. Lediglich die Abruf-/Sync-Intervalle
|
||
(E-Mail & Kalender) greifen erst nach einem Neustart.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form action="/einstellungen" method="POST" class="space-y-6">
|
||
<% felder.forEach(function(sektion){ %>
|
||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||
<h3 class="text-base font-semibold text-gray-800 dark:text-white"><%= sektion.titel %></h3>
|
||
<% if (sektion.beschreibung) { %>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5 max-w-2xl"><%= sektion.beschreibung %></p>
|
||
<% } else { %>
|
||
<div class="mb-5"></div>
|
||
<% } %>
|
||
|
||
<div class="grid gap-4 sm:grid-cols-2">
|
||
<% sektion.items.forEach(function(f){
|
||
var val = werte[f.key] != null ? werte[f.key] : '';
|
||
var inputType = f.secret ? 'password' : 'text';
|
||
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
|
||
%>
|
||
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
|
||
<%= f.label %>
|
||
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
|
||
</label>
|
||
<div class="relative">
|
||
<input id="cfg-<%= f.key %>" name="<%= f.key %>" type="<%= inputType %>" autocomplete="off"
|
||
spellcheck="false" class="<%= inputClass %>" value="<%= val %>">
|
||
<% if (f.secret) { %>
|
||
<button type="button" data-toggle="cfg-<%= f.key %>"
|
||
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||
title="Wert anzeigen/verbergen" tabindex="-1">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||
</svg>
|
||
</button>
|
||
<% } %>
|
||
</div>
|
||
<% if (f.generate) { %>
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<button type="button" data-generate="cfg-<%= f.key %>"
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||
</svg>
|
||
Neu generieren
|
||
</button>
|
||
<button type="button" data-copy="cfg-<%= f.key %>"
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
Kopieren
|
||
</button>
|
||
<span data-hint="cfg-<%= f.key %>" class="text-xs text-amber-600 dark:text-amber-400 hidden"></span>
|
||
</div>
|
||
<% } %>
|
||
<% if (f.help) { %>
|
||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
|
||
<% } %>
|
||
</div>
|
||
<% }); %>
|
||
</div>
|
||
</section>
|
||
<% }); %>
|
||
|
||
<div class="flex items-center justify-end gap-3">
|
||
<a href="/einstellungen" class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||
Verwerfen
|
||
</a>
|
||
<button type="submit"
|
||
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||
</svg>
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</main>
|
||
|
||
<%- include('partials/footer') %>
|
||
|
||
<script>
|
||
(function () {
|
||
const cy = document.getElementById('currentYear');
|
||
if (cy) cy.textContent = new Date().getFullYear();
|
||
|
||
// Toggle reveal for secret (password) fields.
|
||
document.querySelectorAll('[data-toggle]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
const input = document.getElementById(btn.getAttribute('data-toggle'));
|
||
if (input) input.type = input.type === 'password' ? 'text' : 'password';
|
||
});
|
||
});
|
||
|
||
function hint(id, text, tone) {
|
||
const el = document.querySelector('[data-hint="' + id + '"]');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.remove('hidden');
|
||
el.classList.toggle('text-amber-600', tone !== 'ok');
|
||
el.classList.toggle('dark:text-amber-400', tone !== 'ok');
|
||
el.classList.toggle('text-green-600', tone === 'ok');
|
||
el.classList.toggle('dark:text-green-400', tone === 'ok');
|
||
}
|
||
|
||
// Generate a fresh random token (32 bytes hex, from the browser CSPRNG).
|
||
// It only fills the field — nothing changes until the form is saved,
|
||
// so an accidental click can still be discarded.
|
||
document.querySelectorAll('[data-generate]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
const id = btn.getAttribute('data-generate');
|
||
const input = document.getElementById(id);
|
||
if (!input) return;
|
||
const bytes = new Uint8Array(32);
|
||
crypto.getRandomValues(bytes);
|
||
input.value = Array.from(bytes).map(function (b) {
|
||
return b.toString(16).padStart(2, '0');
|
||
}).join('');
|
||
input.type = 'text'; // reveal, so it can be copied down
|
||
hint(id, 'Neuer Token erzeugt — zum Aktivieren speichern. Der alte wird damit ungültig.');
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-copy]').forEach(function (btn) {
|
||
btn.addEventListener('click', async function () {
|
||
const id = btn.getAttribute('data-copy');
|
||
const input = document.getElementById(id);
|
||
if (!input || !input.value) return;
|
||
try {
|
||
await navigator.clipboard.writeText(input.value);
|
||
hint(id, 'In die Zwischenablage kopiert.', 'ok');
|
||
} catch (e) {
|
||
input.type = 'text';
|
||
input.select();
|
||
hint(id, 'Kopieren nicht möglich — Wert ist markiert, bitte manuell kopieren.');
|
||
}
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |