Compare commits

...

2 Commits

Author SHA1 Message Date
f5bcbad27e RC-1.0 2025-10-19 23:17:48 +02:00
7d623dad6b check 2025-10-19 22:03:06 +02:00
18 changed files with 1990 additions and 80 deletions

View File

@@ -1 +1,4 @@
# patchping
- https://support.hpe.com/hpesc/public/api/document/sec_bull_rss_feed
- https://ubuntu.com/security/notices/rss.xml

View File

@@ -16,7 +16,6 @@ import (
"git.send.nrw/patchping/patchping/internal/httpserver"
"git.send.nrw/patchping/patchping/internal/i18n"
"git.send.nrw/patchping/patchping/internal/store"
"git.send.nrw/patchping/patchping/internal/ui"
)
func getenv(k, d string) string {
@@ -28,13 +27,13 @@ func getenv(k, d string) string {
func main() {
// Env
token := os.Getenv("DISCORD_TOKEN")
appID := os.Getenv("APPLICATION_ID")
token := getenv("DISCORD_TOKEN", "MTQyOTIzMDkwMzY0NDY1MTY2MQ.Gn5omN.sfXDoI6DEXhpzE2Z8WOCBN4GnVzqwfo7Qe_AXA")
appID := getenv("APPLICATION_ID", "1429230903644651661")
if token == "" || appID == "" {
log.Fatal("Bitte DISCORD_TOKEN und APPLICATION_ID setzen.")
}
// Für Betrieb hinter Pangolin am besten loopback binden:
httpAddr := getenv("HTTP_ADDR", "127.0.0.1:8080")
httpAddr := getenv("HTTP_ADDR", ":8080")
apiBase := getenv("ADMIN_API_BASE", "/api") // optional für Ersetzung __API_BASE__
dbPath := getenv("DB_PATH", "./subs.db")
translationsFile := getenv("TRANSLATIONS_FILE", "./language.json")
@@ -74,18 +73,6 @@ func main() {
// Discord-Interactions anbinden
core.AttachDiscordHandlers(dg, svc)
// HTTP Server (JSON API; ohne eigene Auth Pangolin davor)
router := httpserver.NewRouter(svc,
httpserver.WithAdminHTML(ui.AdminHTML),
httpserver.WithAdminAPIBase(apiBase))
srv := &http.Server{
Addr: httpAddr,
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Collector/Dispatcher
reg := core.FetcherRegistry{
"rss": fetchers.RSSFetcher{},
@@ -96,6 +83,21 @@ func main() {
sched.Start(context.Background())
defer sched.Stop()
// HTTP Server (JSON API; ohne eigene Auth Pangolin davor)
router := httpserver.NewRouter(
svc, // /post
httpserver.WithAdminAPIBase(apiBase),
// ⬇️ WICHTIG:
httpserver.WithCollector(extRepo, reg, pipe),
)
srv := &http.Server{
Addr: httpAddr,
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
// HTTP starten
go func() {
log.Printf("HTTP: lausche auf %s", httpAddr)

485
cmd/post/main.go Normal file
View File

@@ -0,0 +1,485 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
)
func main() {
addr := ":3000"
if v := os.Getenv("ADDR"); v != "" {
addr = v
}
defaultAPI := os.Getenv("API_BASE")
if defaultAPI == "" {
defaultAPI = "http://127.0.0.1:8080/api"
}
tpl := template.Must(template.New("page").Parse(pageHTML))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct{ DefaultAPI string }{DefaultAPI: defaultAPI}
if err := tpl.Execute(w, data); err != nil {
log.Println("template error:", err)
}
})
log.Printf("Listening on %s (API_BASE default %q)", addr, defaultAPI)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
}
}
const pageHTML = `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>PatchPing Security Advisory Sender</title>
<style>
:root { --bg:#f8fafc; --card:#ffffff; --muted:#64748b; --border:#e2e8f0; --ink:#0f172a; --ink-soft:#334155; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font: 14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Inter,Helvetica,Arial,sans-serif; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 24px; }
.row { display:grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.card { background:var(--card); border:1px solid var(--border); border-radius: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.03); padding: 18px; }
label { font-weight: 600; font-size: 13px; }
input, select, textarea { width:100%; margin-top:6px; padding:10px 12px; border:1px solid var(--border); border-radius: 10px; font-size: 14px; background:#fff; color:var(--ink); }
input[type="date"]{ padding:8px 10px; }
textarea { resize: vertical; }
.muted { color: var(--muted); }
.btn { appearance:none; border:0; background:#4f46e5; color:#fff; padding:10px 14px; border-radius: 10px; font-weight: 700; cursor:pointer; }
.btn.secondary { background:#0f172a; }
.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--border); }
.btn:disabled { opacity:.6; cursor:not-allowed; }
.split { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
.kv { display:grid; grid-template-columns: 140px 1fr; gap:10px; }
pre.preview { background:#f1f5f9; border:1px solid var(--border); border-radius:12px; padding:12px; white-space:pre-wrap; word-wrap:break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align:left; padding:8px 10px; border-top:1px solid var(--border); font-size: 13px; }
thead th { background:#f8fafc; border-top:0; font-weight:700; font-size:12px; }
.pill { display:inline-block; padding:4px 8px; border-radius:999px; border:1px solid var(--border); background:#fff; font-size:12px; }
.toast { padding:10px 12px; border-radius:12px; border:1px solid; margin-bottom:14px; font-weight:600; }
.toast.ok { background:#ecfdf5; border-color:#bbf7d0; color:#065f46; }
.toast.err { background:#fef2f2; border-color:#fecaca; color:#7f1d1d; }
.toast.info { background:#eff6ff; border-color:#bfdbfe; color:#1e3a8a; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<header style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 16px;">
<div>
<h1 style="margin:0; font-size:22px;">PatchPing Security Advisory Sender</h1>
<div class="muted" style="font-size:12px;">Compose → Preview → Send (DM/Bulk) über PatchPing API</div>
</div>
<div class="card" style="padding:10px 12px; display:flex; align-items:center; gap:10px;">
<div class="muted" style="font-size:12px;">API Base</div>
<input id="apiBase" style="width:380px" value="{{.DefaultAPI}}" placeholder="https://example.com/api"/>
<button id="btnMeta" class="btn ghost">Health & Meta</button>
</div>
</header>
<div id="toast" class="toast info" style="display:none"></div>
<div class="row" style="margin-top: 16px;">
<section class="card">
<h2 style="margin-top:0">Eingabe</h2>
<div class="kv" style="margin-bottom:12px;">
<label for="stand">Stand <span style="color:#dc2626">*</span></label>
<input type="date" id="stand"/>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="risiko">Risiko <span style="color:#dc2626">*</span></label>
<select id="risiko">
<option value="">— auswählen —</option>
<option>Kritisch</option>
<option>Hoch</option>
<option>Mittel</option>
<option>Niedrig</option>
<option>Info</option>
</select>
</div>
<div>
<label for="cvss">CVSS Base</label>
<input type="number" step="0.1" min="0" max="10" id="cvss" placeholder="0.010.0"/>
</div>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="advid">ID <span style="color:#dc2626">*</span></label>
<input id="advid" placeholder="z. B. ADV-2025-0001"/>
</div>
<div>
<label for="status">Status <span style="color:#dc2626">*</span></label>
<input id="status" placeholder="Neu / Mitigation verfügbar / Gepatcht …"/>
</div>
</div>
<div style="margin-bottom:12px;">
<label for="titel">Titel <span style="color:#dc2626">*</span></label>
<input id="titel" placeholder="Kurz & prägnant"/>
</div>
<div style="margin-bottom:12px;">
<label for="produkte">Betroffene Produkte <span style="color:#dc2626">*</span></label>
<textarea id="produkte" rows="2" placeholder="Produkt A (vXY), Produkt B ≥ Z …"></textarea>
</div>
<div style="margin-bottom:12px;">
<label for="cve">CVE <span style="color:#dc2626">*</span></label>
<input id="cve" placeholder="CVE-2025-0001, CVE-2025-1234"/>
</div>
<div style="margin-bottom:12px;">
<label for="mitigation">Mitigation <span style="color:#dc2626">*</span></label>
<textarea id="mitigation" rows="4" placeholder="Workaround bzw. Patch-Hinweise, Rollout-Plan, Priorisierung …"></textarea>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="style">Ausgabeformat</label>
<select id="style">
<option value="table">Tabelle + Details</option>
<option value="compact">Kompakt</option>
</select>
</div>
<div>
<label>Schnellaktionen</label>
<div style="display:flex; gap:8px; margin-top:6px;">
<button id="btnCopy" class="btn secondary">Kopieren</button>
<button id="btnReset" class="btn ghost">Zurücksetzen</button>
</div>
</div>
</div>
<div class="card" style="border-style:dashed; margin-top:14px;">
<div style="display:flex; gap:16px; align-items:center; margin-bottom:8px;">
<div style="font-weight:700;">Versandart</div>
<label><input type="radio" name="mode" value="dm" checked/> DM an User</label>
<label><input type="radio" name="mode" value="bulk"/> Bulk an alle Abonnenten</label>
</div>
<div id="dmFields" class="split">
<div style="grid-column: span 2;">
<label for="userid">Discord User-ID <span style="color:#dc2626">*</span></label>
<input id="userid" placeholder="123456789012345678"/>
</div>
</div>
<div id="bulkFields" class="split" style="display:none;">
<div>
<label for="limit">Limit (optional)</label>
<input id="limit" type="number" min="1" placeholder="z. B. 500"/>
</div>
<div style="grid-column: span 1; display:flex; align-items:flex-end;">
<div class="muted" id="subsHint">Senden an alle registrierten Abonnenten.</div>
</div>
</div>
<div style="margin-top:12px;">
<button id="btnSend" class="btn">Jetzt senden</button>
</div>
</div>
</section>
<aside class="card">
<h2 style="margin-top:0">Vorschau (Discord)</h2>
<pre id="preview" class="preview"></pre>
<div class="muted" style="font-size:12px; margin-top:8px;">Formatierung nutzt Discord-Markdown (Fettdruck, Codeblöcke, Blockquotes). Für Links einfach URLs in den Text aufnehmen.</div>
<div class="card" style="margin-top:16px;">
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:12px;">
<div>
<div class="muted" style="font-size:12px;">Health</div>
<div id="healthVal" style="font-weight:700;">—</div>
<div class="muted" style="font-size:12px;">GET /healthz</div>
</div>
<div>
<div class="muted" style="font-size:12px;">Abonnenten</div>
<div id="subsCount" style="font-weight:700;">—</div>
<div class="muted" style="font-size:12px;">GET /subscribers</div>
</div>
<div>
<div class="muted" style="font-size:12px;">Channels</div>
<div id="chanCount" style="font-weight:700;">—</div>
<div class="muted" style="font-size:12px;">GET /channels</div>
</div>
</div>
<div style="max-height:220px; overflow:auto; margin-top:12px; border:1px solid var(--border); border-radius:12px;">
<table>
<thead>
<tr><th>Channel-ID</th><th>Name</th></tr>
</thead>
<tbody id="chanRows">
<tr><td colspan="2" class="muted">Keine Channels geladen.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="muted" style="font-size:12px; margin-top:12px;">API-Aufrufe: <code>POST /messages/dm</code>, <code>POST /messages/bulk</code>, <code>GET /subscribers</code>, <code>GET /channels</code>, <code>GET /healthz</code>.</div>
</aside>
</div>
</div>
<script>
var $ = function(id){ return document.getElementById(id); };
var todayISO = function(){ return (new Date()).toISOString().slice(0,10); };
function rootOfApi(url){
var u = url;
if(u.slice(-4) === '/api') return u.slice(0, -4);
if(u.slice(-5) === '/api/') return u.slice(0, -5);
return u;
}
function riskBadge(risk){
var r = String(risk||'').toLowerCase().trim();
if (r === 'kritisch' || r === 'critical' || r === 'cr' || r === 'k') return '🟥 Kritisch';
if (r === 'hoch' || r === 'high' || r === 'h') return '🟧 Hoch';
if (r === 'mittel' || r === 'medium' || r === 'm') return '🟨 Mittel';
if (r === 'niedrig' || r === 'low' || r === 'l') return '🟩 Niedrig';
if (r === 'info' || r === 'informational' || r === 'i') return ' Info';
return risk || '';
}
function trimLines(s){
var lines = String(s||'').split(String.fromCharCode(10));
for (var i=0; i<lines.length; i++){
var l = lines[i];
var j = l.length;
while (j>0 && (l.charAt(j-1) === ' ' || l.charAt(j-1) === ' ')) j--;
lines[i] = l.slice(0, j);
}
return lines.join(String.fromCharCode(10)).trim();
}
function replaceAll(str, find, repl){
var out = '';
var i = 0;
var idx;
while ((idx = str.indexOf(find, i)) !== -1){
out += str.slice(i, idx) + repl;
i = idx + find.length;
}
return out + str.slice(i);
}
function sanitizeCVEList(s){
var t = String(s||'');
// remove plain spaces only (avoids backslashes in regex)
t = replaceAll(t, ' ', '');
// ensure comma+space
t = replaceAll(t, ',', ', ');
return t;
}
function buildDiscordMessage(fields, style){
var stand = fields.stand,
risiko = fields.risiko,
cvssBase = fields.cvssBase,
id = fields.id,
titel = fields.titel,
produkte = fields.produkte,
cve = fields.cve,
status = fields.status,
mitigation = fields.mitigation;
var NL = String.fromCharCode(10);
var BQ = String.fromCharCode(96);
var header = '**🔔 Sicherheitshinweis** — *' + (stand || 'n. a.') + '*';
var line1 = '**ID:** ' + (id || '') + ' | **Status:** ' + (status || '');
var line2 = '**Risiko:** ' + riskBadge(risiko) + ' (CVSS ' + (cvssBase || '') + ')';
var title = titel ? '**Titel:** ' + titel : '';
var affected = produkte ? '**Betroffene Produkte:** ' + produkte : '';
var cves = cve ? '**CVE:** ' + BQ + sanitizeCVEList(cve) + BQ : '';
var mit = '';
if (mitigation){
var lines = trimLines(mitigation).split(NL);
for (var i=0; i<lines.length; i++){ lines[i] = '> ' + lines[i]; }
mit = '**Mitigation/Workaround:**' + NL + lines.join(NL);
}
if (style === 'compact'){
var parts = [header, line1, line2, title, affected, cves, mit];
var out = [];
for (var p=0; p<parts.length; p++){ if(parts[p]) out.push(parts[p]); }
return out.join(NL);
}
var pad = function(k,v){
var key = k;
while (key.length < 18) key += ' ';
return key + ': ' + v;
};
var table = [
pad('Stand', stand || ''),
pad('Risiko', riskBadge(risiko)),
pad('CVSS Base', cvssBase || ''),
pad('ID', id || ''),
pad('Status', status || '')
].join(NL);
var parts2 = [header, '', BQ+BQ+BQ, table, BQ+BQ+BQ, title, affected, cves, mit];
var out2 = [];
for (var q=0; q<parts2.length; q++){ if(parts2[q]) out2.push(parts2[q]); }
return out2.join(NL);
}
function setToast(type, text){
var el = $('toast');
el.className = 'toast ' + type;
el.textContent = text;
el.style.display = 'block';
clearTimeout(setToast._t);
setToast._t = setTimeout(function(){ el.style.display = 'none'; }, 6000);
}
var state = { mode: 'dm' };
function updatePreview(){
var fields = {
stand: $('stand').value,
risiko: $('risiko').value,
cvssBase: $('cvss').value,
id: $('advid').value,
titel: $('titel').value,
produkte: $('produkte').value,
cve: $('cve').value,
status: $('status').value,
mitigation: $('mitigation').value
};
var style = $('style').value;
$('preview').textContent = buildDiscordMessage(fields, style);
}
function resetForm(){
$('stand').value = todayISO();
$('risiko').value = '';
$('cvss').value = '';
$('advid').value = '';
$('titel').value = '';
$('produkte').value = '';
$('cve').value = '';
$('status').value = 'Neu';
$('mitigation').value = '';
updatePreview();
}
function validate(){
var miss = [];
if(!$('stand').value) miss.push('Stand');
if(!$('risiko').value) miss.push('Risiko');
if(!$('advid').value) miss.push('ID');
if(!$('titel').value) miss.push('Titel');
if(!$('produkte').value) miss.push('betroffene Produkte');
if(!$('cve').value) miss.push('CVE');
if(!$('status').value) miss.push('Status');
if(!$('mitigation').value) miss.push('Mitigation');
var cv = parseFloat($('cvss').value);
if($('cvss').value && (isNaN(cv) || cv < 0 || cv > 10)) miss.push('CVSS Base (010)');
if(state.mode === 'dm' && !$('userid').value) miss.push('Discord User-ID');
return miss;
}
async function loadMeta(){
var base = $('apiBase').value.trim();
var root = rootOfApi(base);
try{
var hz = await fetch(root + '/healthz', { credentials: 'include' });
$('healthVal').textContent = hz.ok ? 'ok' : (hz.status + ' ' + hz.statusText);
}catch(e){ $('healthVal').textContent = 'Fehler'; }
try{
var resS = await fetch(base + '/subscribers', { credentials: 'include' });
var jsS = await resS.json();
var itemsS = (jsS && jsS.items) || [];
$('subsCount').textContent = itemsS.length || '0';
$('subsHint').textContent = 'Senden an alle registrierten Abonnenten (' + itemsS.length + ').';
}catch(e){ $('subsCount').textContent = '—'; }
try{
var resC = await fetch(base + '/channels', { credentials: 'include' });
var jsC = await resC.json();
var itemsC = (jsC && jsC.items) || [];
$('chanCount').textContent = itemsC.length || '0';
var tbody = $('chanRows');
tbody.innerHTML = '';
if(itemsC.length === 0){
var tr0 = document.createElement('tr'); tr0.innerHTML = '<td colspan="2" class="muted">Keine Channels gefunden.</td>';
tbody.appendChild(tr0);
} else {
for (var i=0; i<itemsC.length; i++){
var c = itemsC[i];
var tr = document.createElement('tr');
tr.innerHTML = '<td><code>' + c.id + '</code></td><td>' + (c.name || '—') + '</td>';
tbody.appendChild(tr);
}
}
}catch(e){
$('chanCount').textContent = '—';
}
}
async function doSend(){
var errs = validate();
if(errs.length){ setToast('err', 'Bitte ausfüllen: ' + errs.join(', ')); return; }
var base = $('apiBase').value.trim();
var msg = $('preview').textContent;
var btn = $('btnSend');
btn.disabled = true; btn.textContent = 'Sendet…';
try{
if(state.mode === 'dm'){
var bodyDM = { user_id: $('userid').value.trim(), message: msg };
var resDM = await fetch(base + '/messages/dm', {
method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bodyDM)
});
if(!resDM.ok){ throw new Error(resDM.status + ' ' + resDM.statusText); }
setToast('ok', 'DM erfolgreich versendet.');
} else {
var bodyB = { message: msg };
if($('limit').value) bodyB.limit = Number($('limit').value);
var resB = await fetch(base + '/messages/bulk', {
method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bodyB)
});
var js = await resB.json().catch(function(){ return {}; });
if(!resB.ok){ throw new Error(resB.status + ' ' + resB.statusText); }
setToast('ok', 'Bulk gesendet: ok=' + (js.ok == null ? '?' : js.ok) + ', fail=' + (js.fail == null ? '?' : js.fail));
}
}catch(e){ setToast('err', 'Senden fehlgeschlagen: ' + e); }
finally{ btn.disabled = false; btn.textContent = 'Jetzt senden'; }
}
function copyPreview(){
navigator.clipboard.writeText($('preview').textContent)
.then(function(){ setToast('ok', 'Nachricht in Zwischenablage kopiert.'); })
.catch(function(e){ setToast('err', 'Kopieren fehlgeschlagen: ' + e); });
}
(function init(){
$('stand').value = todayISO();
$('status').value = 'Neu';
var ids = [ 'stand','risiko','cvss','advid','titel','produkte','cve','status','mitigation','style' ];
for (var i=0; i<ids.length; i++){ $(ids[i]).addEventListener('input', updatePreview); }
updatePreview();
var radios = document.querySelectorAll('input[name="mode"]');
for (var j=0; j<radios.length; j++){
radios[j].addEventListener('change', function(e){
state.mode = e.target.value;
$('dmFields').style.display = state.mode === 'dm' ? 'grid' : 'none';
$('bulkFields').style.display = state.mode === 'bulk' ? 'grid' : 'none';
});
}
$('btnReset').addEventListener('click', resetForm);
$('btnCopy').addEventListener('click', copyPreview);
$('btnSend').addEventListener('click', doSend);
$('btnMeta').addEventListener('click', loadMeta);
loadMeta();
})();
</script>
</body>
</html>
`

14
docs/compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
swagger:
image: swaggerapi/swagger-ui:latest
container_name: patchping-swagger
environment:
# Pfad zur Spec *im Container*
SWAGGER_JSON: /docs/openapi.yaml
volumes:
# mappe deine Spec schreibgeschützt in /usr/share/nginx/html/docs/
- ./openapi.yaml:/usr/share/nginx/html/docs/openapi.yaml:ro
ports:
# lokal erreichbar unter http://localhost:8081
- "8081:8080"
restart: unless-stopped

444
docs/openapi.yaml Normal file
View File

@@ -0,0 +1,444 @@
openapi: 3.0.3
info:
title: PatchPing API
version: "1.0.0"
description: |
Backend-API für PatchPing (Discord-Bot + Collector + Dispatcher).
Die API ist **nur** hinter Pangolin erreichbar; Authentifizierung/Autorisierung erfolgen dort.
servers:
- url: https://example.com/api
description: Produktions-Endpoint (hinter Pangolin)
- url: http://localhost:8080/api
description: Lokale Entwicklung
tags:
- name: Subscribers
description: Verwaltung der DM-Abonnenten
- name: Messages
description: Nachrichtenversand (DM & Bulk)
- name: Sources
description: Quellen (RSS, NVD, …) verwalten
- name: Channels
description: Discord-Ziel-Channels registrieren
- name: Routing
description: Zuordnung Quelle ↔ Channel
- name: Operations
description: Manuelle Ausführung / globaler Run
- name: Health
description: Healthcheck (außerhalb /api)
paths:
/subscribers:
get:
tags: [Subscribers]
summary: Liste aller Abonnenten
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ListSubscribersResponse'
post:
tags: [Subscribers]
summary: Abonnent hinzufügen oder aktualisieren
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriberCreate'
examples:
example:
value: { user_id: "123456789012345678", username: "alice" }
responses:
"201":
description: Created
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/subscribers/{userID}:
delete:
tags: [Subscribers]
summary: Abonnent entfernen
parameters:
- in: path
name: userID
required: true
schema: { type: string }
description: Discord User-ID
responses:
"204":
description: No Content
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/messages/dm:
post:
tags: [Messages]
summary: Einzel-DM an einen Nutzer senden
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/DMRequest' }
examples:
example:
value: { user_id: "123456789012345678", message: "Patch available for CVE-2025-0001." }
responses:
"200":
description: OK
content: { application/json: { schema: { $ref: '#/components/schemas/StatusOk' } } }
"502":
description: Upstream/Discord-Fehler
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/messages/bulk:
post:
tags: [Messages]
summary: Bulk-Nachricht an alle Abonnenten senden
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BulkRequest' }
examples:
example:
value: { message: "New critical advisories available.", limit: 500 }
responses:
"200":
description: Ergebnis
content:
application/json:
schema: { $ref: '#/components/schemas/BulkResponse' }
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/sources:
get:
tags: [Sources]
summary: Liste aller Quellen
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/ListSourcesResponse' }
post:
tags: [Sources]
summary: Quelle anlegen
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SourceCreate' }
examples:
rss:
value:
name: "Vendor X RSS"
type: "rss"
url: "https://vendor.example.com/security/rss"
poll_interval_sec: 900
enabled: true
nvd:
value:
name: "NVD Daily"
type: "nvd"
url: "https://services.nvd.nist.gov/rest/json/cves/2.0"
poll_interval_sec: 3600
enabled: true
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Source' }
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/sources/{id}:
patch:
tags: [Sources]
summary: Quelle ändern (z. B. aktivieren/deaktivieren)
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SourcePatch' }
examples:
toggle:
value: { enabled: false }
responses:
"200":
description: Updated
content:
application/json:
schema: { $ref: '#/components/schemas/Source' }
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
"404":
description: Not Found
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/sources/{id}/run:
post:
tags: [Sources, Operations]
summary: Quelle sofort ausführen (abholen & verteilen)
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
responses:
"200":
description: Ausführung gestartet/erledigt
content:
application/json:
schema: { $ref: '#/components/schemas/RunSourceResponse' }
"404":
description: Not Found
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
"502":
description: Fetcher/Upstream-Fehler
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/channels:
get:
tags: [Channels]
summary: Liste registrierter Discord-Channels
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/ListChannelsResponse' }
post:
tags: [Channels]
summary: Channel registrieren
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ChannelCreate' }
examples:
example:
value: { id: "123456789012345678", name: "sec-alerts" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Channel' }
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/routing:
post:
tags: [Routing]
summary: Quelle mit Channel verknüpfen
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RoutingLink' }
examples:
example:
value: { source_id: 1, channel_id: "123456789012345678" }
responses:
"201":
description: Created
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
delete:
tags: [Routing]
summary: Verknüpfung Quelle ↔ Channel entfernen
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RoutingLink' }
responses:
"204":
description: No Content
"400":
description: Bad Request
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/run:
post:
tags: [Operations]
summary: Globaler „Run now“ (alle aktiven Quellen)
responses:
"200":
description: Ergebnis
content:
application/json:
schema: { $ref: '#/components/schemas/RunAllResponse' }
# Außerhalb /api (direkt im Server; vgl. Router)
paths:
/healthz:
get:
tags: [Health]
summary: Healthcheck
responses:
"200": { description: OK }
components:
schemas:
Error:
type: object
properties:
error:
type: string
example: { error: "bad request: message required" }
StatusOk:
type: object
properties:
status:
type: string
enum: [ok]
example: { status: "ok" }
# -------- Subscribers --------
Subscriber:
type: object
properties:
user_id: { type: string }
username: { type: string }
added_at: { type: string, description: ISO-8601 Timestamp }
required: [user_id]
SubscriberCreate:
type: object
properties:
user_id: { type: string }
username: { type: string }
required: [user_id]
ListSubscribersResponse:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Subscriber' }
# -------- Messages --------
DMRequest:
type: object
properties:
user_id: { type: string }
message: { type: string }
required: [user_id, message]
BulkRequest:
type: object
properties:
message: { type: string }
limit: { type: integer, minimum: 1 }
required: [message]
BulkResponse:
type: object
properties:
ok: { type: integer }
fail: { type: integer }
required: [ok, fail]
# -------- Sources --------
Source:
type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
type: { type: string, enum: [rss, nvd, json] }
url: { type: string }
params: { type: string, description: "JSON-String mit Parametern (optional)" }
enabled: { type: boolean }
poll_interval_sec: { type: integer }
last_run_ts:
type: string
nullable: true
description: ISO-8601 Timestamp
required: [id, name, type, url, enabled, poll_interval_sec]
SourceCreate:
type: object
properties:
name: { type: string }
type: { type: string, enum: [rss, nvd, json] }
url: { type: string }
params: { type: string }
enabled: { type: boolean, default: true }
poll_interval_sec: { type: integer, default: 900 }
required: [name, type, url]
SourcePatch:
type: object
properties:
name: { type: string }
type: { type: string, enum: [rss, nvd, json] }
url: { type: string }
params: { type: string }
enabled: { type: boolean }
poll_interval_sec: { type: integer }
ListSourcesResponse:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Source' }
RunSourceResponse:
type: object
properties:
status: { type: string, enum: [ok] }
items: { type: integer, description: "Anzahl gefundener Items im Fetch" }
required: [status]
# -------- Channels --------
Channel:
type: object
properties:
id: { type: string, description: "Discord Channel-ID" }
name: { type: string }
required: [id]
ChannelCreate:
type: object
properties:
id: { type: string }
name: { type: string }
required: [id]
ListChannelsResponse:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/Channel' }
# -------- Routing --------
RoutingLink:
type: object
properties:
source_id: { type: integer, format: int64 }
channel_id: { type: string }
required: [source_id, channel_id]
# -------- Operations --------
RunAllResponse:
type: object
properties:
status: { type: string, enum: [ok] }
sources_triggered: { type: integer }
required: [status, sources_triggered]
securitySchemes: {}
security: [] # Auth liegt bei Pangolin (Reverse Proxy mit SSO/RBAC)

View File

@@ -14,17 +14,23 @@ type VulnItem struct {
}
type Source struct {
ID int64
Name string
Type string // "rss","nvd","json"
URL string
ParamsJSON string
Enabled bool
PollIntervalSec int
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "rss","nvd","json", ...
URL string `json:"url"`
ParamsJSON string `json:"params,omitempty"`
Enabled bool `json:"enabled"`
PollIntervalSec int `json:"poll_interval_sec"`
LastRunTS string `json:"last_run_ts,omitempty"`
}
type Fetcher interface {
Fetch(src Source) ([]VulnItem, error)
}
type Channel struct {
ID string `json:"id"`
Name string `json:"name"`
}
type FetcherRegistry map[string]Fetcher

View File

@@ -12,12 +12,18 @@ import (
)
type RepoExt interface {
// vorhandene Subscriber-Methoden
// schon vorhanden:
ListSubscribers(ctx context.Context, limit int) ([]Subscriber, error)
// neue Methoden
ListSources(ctx context.Context) ([]Source, error)
GetSourceByID(ctx context.Context, id int64) (Source, error)
CreateSource(ctx context.Context, s Source) (int64, error)
UpdateSourcePartial(ctx context.Context, id int64, patch map[string]any) error
ListChannels(ctx context.Context) ([]Channel, error)
CreateChannel(ctx context.Context, id, name string) error
LinkRouting(ctx context.Context, sourceID int64, channelID string) error
UnlinkRouting(ctx context.Context, sourceID int64, channelID string) error
ListChannelsForSource(ctx context.Context, sourceID int64) ([]string, error)
MarkSeen(ctx context.Context, sourceID int64, key string) (wasNew bool, err error)
MarkSeen(ctx context.Context, sourceID int64, key string) (bool, error)
LogDelivery(ctx context.Context, sourceID int64, key, targetType, targetID, status, errText string) error
TouchSourceRan(ctx context.Context, sourceID int64, ts time.Time) error
}

View File

@@ -21,6 +21,11 @@ type Services struct {
translations map[string]map[string]string
}
func (s *Services) SendChannelMessage(channelID, content string) error {
_, err := s.dg.ChannelMessageSend(channelID, content)
return err
}
func NewServices(dg *discordgo.Session, repo Repo, translations map[string]map[string]string) *Services {
return &Services{dg: dg, repo: repo, translations: translations}
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"encoding/json"
"net/http"
"git.send.nrw/patchping/patchping/internal/core"
)
func ListChannels(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
ch, err := repo.ListChannels(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]any{"items": ch})
}
}
func CreateChannel(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in struct{ ID, Name string }
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.ID == "" {
http.Error(w, "bad request", 400)
return
}
if err := repo.CreateChannel(r.Context(), in.ID, in.Name); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusCreated)
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"git.send.nrw/patchping/patchping/internal/core"
)
@@ -18,6 +19,37 @@ type bulkReq struct {
Limit int `json:"limit"` // optional
}
type channelReq struct {
ChannelID string `json:"channel_id"`
Message string `json:"message"`
}
func SendToChannel(svc *core.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req channelReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
ch := strings.TrimSpace(req.ChannelID)
msg := strings.TrimSpace(req.Message)
if ch == "" || msg == "" {
http.Error(w, `{"error":"channel_id and message required"}`, http.StatusBadRequest)
return
}
if err := svc.SendChannelMessage(ch, msg); err != nil {
// 502, weil Upstream (Discord) fehlschlug
http.Error(w, `{"error":"`+strings.ReplaceAll(err.Error(), `"`, `\"`)+`"}`, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
}
func SendDM(svc *core.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req dmReq

View File

@@ -0,0 +1,52 @@
package handlers
import (
"encoding/json"
"net/http"
"git.send.nrw/patchping/patchping/internal/core"
)
func LinkRouting(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
var in struct {
SourceID int64 `json:"source_id"`
ChannelID string `json:"channel_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.SourceID == 0 || in.ChannelID == "" {
http.Error(w, "bad request", 400)
return
}
if err := repo.LinkRouting(r.Context(), in.SourceID, in.ChannelID); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusCreated)
}
}
func UnlinkRouting(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
var in struct {
SourceID int64 `json:"source_id"`
ChannelID string `json:"channel_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.SourceID == 0 || in.ChannelID == "" {
http.Error(w, "bad request", 400)
return
}
if err := repo.UnlinkRouting(r.Context(), in.SourceID, in.ChannelID); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"git.send.nrw/patchping/patchping/internal/core"
)
func RunAll(repo core.RepoExt, reg core.FetcherRegistry, p *core.Pipeline) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
srcs, err := repo.ListSources(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
total := 0
for _, src := range srcs {
if !src.Enabled {
continue
}
f := reg[src.Type]
if f == nil {
continue
}
items, err := f.Fetch(src)
if err != nil {
continue
}
p.RunSource(r.Context(), src, items)
_ = repo.TouchSourceRan(r.Context(), src.ID, time.Now())
total += len(items)
}
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "sources": len(srcs), "items_total": total})
}
}

View File

@@ -0,0 +1,111 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"git.send.nrw/patchping/patchping/internal/core"
"github.com/go-chi/chi/v5"
)
func ListSources(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
srcs, err := repo.ListSources(r.Context())
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(map[string]any{"items": srcs})
}
}
func CreateSource(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
var in struct {
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
ParamsJSON string `json:"params"`
PollIntervalSec int `json:"poll_interval_sec"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" || in.Type == "" || in.URL == "" {
http.Error(w, "bad request", 400)
return
}
id, err := repo.CreateSource(r.Context(), core.Source{
Name: in.Name, Type: in.Type, URL: in.URL,
ParamsJSON: in.ParamsJSON, PollIntervalSec: in.PollIntervalSec, Enabled: in.Enabled,
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"id": id})
}
}
func PatchSource(repo core.RepoExt) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if id == 0 {
http.Error(w, "bad id", 400)
return
}
var patch map[string]any
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
http.Error(w, "bad json", 400)
return
}
if err := repo.UpdateSourcePartial(r.Context(), id, patch); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func RunSourceNow(repo core.RepoExt, reg core.FetcherRegistry, p *core.Pipeline) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if repo == nil {
http.Error(w, "collector not configured", http.StatusServiceUnavailable)
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if id == 0 {
http.Error(w, "bad id", 400)
return
}
src, err := repo.GetSourceByID(r.Context(), id)
if err != nil {
http.Error(w, "not found", 404)
return
}
f := reg[src.Type]
if f == nil {
http.Error(w, "no fetcher", 400)
return
}
items, err := f.Fetch(src)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
p.RunSource(r.Context(), src, items)
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "items": len(items)})
}
}

View File

@@ -1,3 +1,4 @@
// internal/httpserver/router.go
package httpserver
import (
@@ -8,36 +9,60 @@ import (
"git.send.nrw/patchping/patchping/internal/core"
"git.send.nrw/patchping/patchping/internal/httpserver/handlers"
"git.send.nrw/patchping/patchping/internal/ui"
)
type config struct {
AdminEnabled bool
AdminAPIBase string
AdminHTML []byte
PostHTML []byte
RepoExt core.RepoExt // Interface (siehe unten)
FetcherReg core.FetcherRegistry
Pipeline *core.Pipeline
}
type Option func(*config)
func WithAdminHTML(b []byte) Option { return func(c *config) { c.AdminHTML = b } }
func WithPostHTML(b []byte) Option { return func(c *config) { c.PostHTML = b } }
func WithAdminAPIBase(s string) Option { return func(c *config) { c.AdminAPIBase = s } }
func WithCollector(repo core.RepoExt, reg core.FetcherRegistry, p *core.Pipeline) Option {
return func(c *config) { c.RepoExt = repo; c.FetcherReg = reg; c.Pipeline = p }
}
func NewRouter(svc *core.Services, opts ...Option) http.Handler {
// Option pattern, falls du später mehr Settings willst
cfg := config{AdminEnabled: true, AdminAPIBase: "/api", AdminHTML: nil}
cfg := config{AdminEnabled: true, AdminAPIBase: "/api"}
for _, o := range opts {
o(&cfg)
}
r := chi.NewRouter()
r.Use(RequestLog)
// hinter Pangolin ist CORS i. d. R. nicht nötig; wenn doch, lass den cors.Handler drin
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "DELETE", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type"},
AllowCredentials: false,
AllowedOrigins: []string{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8081", // Swagger/Static falls separat
"http://127.0.0.1:8081",
"https://dein.host", // falls du bewusst von anderer Origin sprechen willst
},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type", "Authorization"},
ExposedHeaders: []string{"Content-Length"},
AllowCredentials: false, // auf true nur, wenn du Cookies/Credentials cross-origin brauchst
MaxAge: 300,
}))
// Admin-UI (optional)
if cfg.AdminEnabled && cfg.AdminHTML != nil {
h := AdminHandler(cfg.AdminHTML, cfg.AdminAPIBase)
r.Get("/admin", h)
r.Get("/admin.html", h)
}
// === HTML-Dashboards ===
r.Get("/admin", ui.TemplateHandler("admin.html", ui.PageData{DefaultAPI: cfg.AdminAPIBase}))
r.Get("/admin.html", ui.TemplateHandler("admin.html", ui.PageData{DefaultAPI: cfg.AdminAPIBase}))
r.Get("/post", ui.TemplateHandler("post.html", ui.PageData{DefaultAPI: cfg.AdminAPIBase}))
r.Get("/post.html", ui.TemplateHandler("post.html", ui.PageData{DefaultAPI: cfg.AdminAPIBase}))
// API
r.Route("/api", func(api chi.Router) {
// — Subscribers & Messages (hattest du schon) —
api.Route("/subscribers", func(sr chi.Router) {
sr.Get("/", handlers.ListSubscribers(svc))
sr.Post("/", handlers.AddSubscriber(svc))
@@ -46,23 +71,28 @@ func NewRouter(svc *core.Services, opts ...Option) http.Handler {
api.Route("/messages", func(m chi.Router) {
m.Post("/dm", handlers.SendDM(svc))
m.Post("/bulk", handlers.SendBulk(svc))
m.Post("/channel", handlers.SendToChannel(svc))
})
// Deine weiteren Collector-Endpoints (sources/channels/routing/run/...) hier einhängen
// — NEU: Sources/Channels/Routing/Run —
api.Route("/sources", func(sr chi.Router) {
sr.Get("/", handlers.ListSources(cfg.RepoExt))
sr.Post("/", handlers.CreateSource(cfg.RepoExt))
sr.Patch("/{id}", handlers.PatchSource(cfg.RepoExt))
sr.Post("/{id}/run", handlers.RunSourceNow(cfg.RepoExt, cfg.FetcherReg, cfg.Pipeline))
})
api.Route("/channels", func(cr chi.Router) {
cr.Get("/", handlers.ListChannels(cfg.RepoExt))
cr.Post("/", handlers.CreateChannel(cfg.RepoExt))
})
api.Route("/routing", func(rr chi.Router) {
rr.Post("/", handlers.LinkRouting(cfg.RepoExt))
rr.Delete("/", handlers.UnlinkRouting(cfg.RepoExt))
})
api.Post("/run", handlers.RunAll(cfg.RepoExt, cfg.FetcherReg, cfg.Pipeline))
})
// Health
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
return r
}
type config struct {
AdminEnabled bool
AdminAPIBase string
AdminHTML []byte
}
type Option func(*config)
func WithAdminHTML(b []byte) Option { return func(c *config) { c.AdminHTML = b } }
func WithAdminAPIBase(s string) Option { return func(c *config) { c.AdminAPIBase = s } }
func WithoutAdmin() Option { return func(c *config) { c.AdminEnabled = false } }

View File

@@ -2,17 +2,48 @@ package store
import (
"context"
"strings"
"time"
"git.send.nrw/patchping/patchping/internal/core"
)
type RepoExt struct{ *SQLiteRepo } // embed
type RepoExt struct{ *SQLiteRepo }
func NewRepoExt(base *SQLiteRepo) *RepoExt { return &RepoExt{SQLiteRepo: base} }
func (r *RepoExt) LogDelivery(ctx context.Context, sourceID int64, key, targetType, targetID, status, errText string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO deliveries(source_id, item_key, target_type, target_id, status, error)
VALUES (?, ?, ?, ?, ?, ?)
`, sourceID, key, targetType, targetID, status, errText)
return err
}
func (r *RepoExt) MarkSeen(ctx context.Context, sourceID int64, key string) (bool, error) {
res, err := r.db.ExecContext(ctx,
`INSERT OR IGNORE INTO seen_items(source_id, item_key) VALUES(?, ?)`,
sourceID, key,
)
if err != nil {
return false, err
}
// Wenn genau 1 Zeile eingefügt wurde, ist es neu.
n, _ := res.RowsAffected()
return n == 1, nil
}
// ---- Sources ----
func (r *RepoExt) CreateSource(ctx context.Context, s core.Source) (int64, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO sources(name,type,url,params,enabled,poll_interval_sec) VALUES(?,?,?,?,?,?)`,
s.Name, s.Type, s.URL, s.ParamsJSON, boolToInt(s.Enabled), s.PollIntervalSec)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (r *RepoExt) ListSources(ctx context.Context) ([]core.Source, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,name,type,url,COALESCE(params,''),enabled,poll_interval_sec FROM sources WHERE enabled=1`)
rows, err := r.db.QueryContext(ctx, `SELECT id,name,type,url,COALESCE(params,''),enabled,poll_interval_sec,COALESCE(last_run_ts,'') FROM sources ORDER BY id`)
if err != nil {
return nil, err
}
@@ -21,14 +52,82 @@ func (r *RepoExt) ListSources(ctx context.Context) ([]core.Source, error) {
for rows.Next() {
var s core.Source
var enabled int
if err := rows.Scan(&s.ID, &s.Name, &s.Type, &s.URL, &s.ParamsJSON, &enabled, &s.PollIntervalSec); err != nil {
var last string
if err := rows.Scan(&s.ID, &s.Name, &s.Type, &s.URL, &s.ParamsJSON, &enabled, &s.PollIntervalSec, &last); err != nil {
return nil, err
}
s.Enabled = enabled == 1
s.LastRunTS = last
out = append(out, s)
}
return out, nil
}
func (r *RepoExt) GetSourceByID(ctx context.Context, id int64) (core.Source, error) {
var s core.Source
var enabled int
err := r.db.QueryRowContext(ctx, `SELECT id,name,type,url,COALESCE(params,''),enabled,poll_interval_sec FROM sources WHERE id=?`, id).
Scan(&s.ID, &s.Name, &s.Type, &s.URL, &s.ParamsJSON, &enabled, &s.PollIntervalSec)
if err != nil {
return core.Source{}, err
}
s.Enabled = enabled == 1
return s, nil
}
func (r *RepoExt) UpdateSourcePartial(ctx context.Context, id int64, patch map[string]any) error {
if len(patch) == 0 {
return nil
}
// erlaubte Felder
allowed := map[string]bool{"name": true, "type": true, "url": true, "params": true, "enabled": true, "poll_interval_sec": true}
sets := []string{}
args := []any{}
for k, v := range patch {
if !allowed[k] {
continue
}
if k == "enabled" {
switch vv := v.(type) {
case bool:
v = boolToInt(vv)
case float64:
v = int(vv)
}
}
if k == "params" {
k = "params"
} // alias
sets = append(sets, k+"=?")
args = append(args, v)
}
if len(sets) == 0 {
return nil
}
args = append(args, id)
_, err := r.db.ExecContext(ctx, `UPDATE sources SET `+strings.Join(sets, ",")+` WHERE id=?`, args...)
return err
}
func (r *RepoExt) TouchSourceRan(ctx context.Context, id int64, ts time.Time) error {
_, err := r.db.ExecContext(ctx, `UPDATE sources SET last_run_ts=? WHERE id=?`, ts.Format(time.RFC3339), id)
return err
}
// ---- Channels ----
func (r *RepoExt) ListChannels(ctx context.Context) ([]core.Channel, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, COALESCE(name,'') FROM channels ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []core.Channel
for rows.Next() {
var c core.Channel
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
func (r *RepoExt) ListChannelsForSource(ctx context.Context, sourceID int64) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT channel_id FROM routing WHERE source_id=?`, sourceID)
@@ -36,33 +135,36 @@ func (r *RepoExt) ListChannelsForSource(ctx context.Context, sourceID int64) ([]
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
_ = rows.Scan(&id)
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
return ids, rows.Err()
}
func (r *RepoExt) MarkSeen(ctx context.Context, sourceID int64, key string) (bool, error) {
// returns true if newly inserted
_, err := r.db.ExecContext(ctx, `INSERT OR IGNORE INTO seen_items(source_id,item_key) VALUES(?,?)`, sourceID, key)
if err != nil {
return false, err
func (r *RepoExt) CreateChannel(ctx context.Context, id, name string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO channels(id,name) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name`, id, name)
return err
}
// ---- Routing ----
func (r *RepoExt) LinkRouting(ctx context.Context, sourceID int64, channelID string) error {
_, err := r.db.ExecContext(ctx, `INSERT OR IGNORE INTO routing(source_id,channel_id) VALUES(?,?)`, sourceID, channelID)
return err
}
func (r *RepoExt) UnlinkRouting(ctx context.Context, sourceID int64, channelID string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM routing WHERE source_id=? AND channel_id=?`, sourceID, channelID)
return err
}
func boolToInt(b bool) int {
if b {
return 1
}
var cnt int
_ = r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM seen_items WHERE source_id=? AND item_key=?`, sourceID, key).Scan(&cnt)
return cnt == 1, nil
}
func (r *RepoExt) LogDelivery(ctx context.Context, sourceID int64, key, targetType, targetID, status, errText string) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO deliveries(source_id,item_key,target_type,target_id,status,error) VALUES(?,?,?,?,?,?)`,
sourceID, key, targetType, targetID, status, errText)
return err
}
func (r *RepoExt) TouchSourceRan(ctx context.Context, sourceID int64, ts time.Time) error {
_, err := r.db.ExecContext(ctx, `UPDATE sources SET last_run_ts=? WHERE id=?`, ts.Format(time.RFC3339), sourceID)
return err
return 0
}

498
internal/ui/post.html Normal file
View File

@@ -0,0 +1,498 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>PatchPing - Security Advisory Sender</title>
<style>
:root { --bg:#f8fafc; --card:#ffffff; --muted:#64748b; --border:#e2e8f0; --ink:#0f172a; --ink-soft:#334155; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font: 14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Inter,Helvetica,Arial,sans-serif; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 24px; }
.row { display:grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.card { background:var(--card); border:1px solid var(--border); border-radius: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.03); padding: 18px; }
label { font-weight: 600; font-size: 13px; }
input, select, textarea { width:100%; margin-top:6px; padding:10px 12px; border:1px solid var(--border); border-radius: 10px; font-size: 14px; background:#fff; color:var(--ink); }
input[type="date"]{ padding:8px 10px; }
textarea { resize: vertical; }
.muted { color: var(--muted); }
.btn { appearance:none; border:0; background:#4f46e5; color:#fff; padding:10px 14px; border-radius: 10px; font-weight: 700; cursor:pointer; }
.btn.secondary { background:#0f172a; }
.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--border); }
.btn:disabled { opacity:.6; cursor:not-allowed; }
.split { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
.kv { display:grid; grid-template-columns: 140px 1fr; gap:10px; }
pre.preview { background:#f1f5f9; border:1px solid var(--border); border-radius:12px; padding:12px; white-space:pre-wrap; word-wrap:break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align:left; padding:8px 10px; border-top:1px solid var(--border); font-size: 13px; }
thead th { background:#f8fafc; border-top:0; font-weight:700; font-size:12px; }
.pill { display:inline-block; padding:4px 8px; border-radius:999px; border:1px solid var(--border); background:#fff; font-size:12px; }
.toast { padding:10px 12px; border-radius:12px; border:1px solid; margin-bottom:14px; font-weight:600; }
.toast.ok { background:#ecfdf5; border-color:#bbf7d0; color:#065f46; }
.toast.err { background:#fef2f2; border-color:#fecaca; color:#7f1d1d; }
.toast.info { background:#eff6ff; border-color:#bfdbfe; color:#1e3a8a; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<header style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 16px;">
<div>
<h1 style="margin:0; font-size:22px;">PatchPing - Security Advisory Sender</h1>
<div class="muted" style="font-size:12px;">Compose → Preview → Send (DM/Bulk) über PatchPing API</div>
</div>
<div class="card" style="padding:10px 12px; display:flex; align-items:center; gap:10px;">
<div class="muted" style="font-size:12px;">API Base</div>
<input id="apiBase" style="width:380px" value="{{.DefaultAPI}}" placeholder="https://example.com/api"/>
<button id="btnMeta" class="btn ghost">Health & Meta</button>
</div>
</header>
<div id="toast" class="toast info" style="display:none"></div>
<div class="row" style="margin-top: 16px;">
<section class="card">
<h2 style="margin-top:0">Eingabe</h2>
<div class="kv" style="margin-bottom:12px;">
<label for="stand">Stand <span style="color:#dc2626">*</span></label>
<input type="date" id="stand"/>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="risiko">Risiko <span style="color:#dc2626">*</span></label>
<select id="risiko">
<option value="">— auswählen —</option>
<option>Kritisch</option>
<option>Hoch</option>
<option>Mittel</option>
<option>Niedrig</option>
<option>Info</option>
</select>
</div>
<div>
<label for="cvss">CVSS Base</label>
<input type="number" step="0.1" min="0" max="10" id="cvss" placeholder="0.0-10.0"/>
</div>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="advid">ID <span style="color:#dc2626">*</span></label>
<input id="advid" placeholder="z. B. ADV-2025-0001"/>
</div>
<div>
<label for="status">Status <span style="color:#dc2626">*</span></label>
<input id="status" placeholder="Neu / Mitigation verfügbar / Gepatcht …"/>
</div>
</div>
<div style="margin-bottom:12px;">
<label for="titel">Titel <span style="color:#dc2626">*</span></label>
<input id="titel" placeholder="Kurz & prägnant"/>
</div>
<div style="margin-bottom:12px;">
<label for="produkte">Betroffene Produkte <span style="color:#dc2626">*</span></label>
<textarea id="produkte" rows="2" placeholder="Produkt A (vX-Y), Produkt B ≥ Z …"></textarea>
</div>
<div style="margin-bottom:12px;">
<label for="cve">CVE <span style="color:#dc2626">*</span></label>
<input id="cve" placeholder="CVE-2025-0001, CVE-2025-1234"/>
</div>
<div style="margin-bottom:12px;">
<label for="mitigation">Mitigation <span style="color:#dc2626">*</span></label>
<textarea id="mitigation" rows="4" placeholder="Workaround bzw. Patch-Hinweise, Rollout-Plan, Priorisierung …"></textarea>
</div>
<div class="split" style="margin-bottom:12px;">
<div>
<label for="style">Ausgabeformat</label>
<select id="style">
<option value="table">Tabelle + Details</option>
<option value="compact">Kompakt</option>
</select>
</div>
<div>
<label>Schnellaktionen</label>
<div style="display:flex; gap:8px; margin-top:6px;">
<button id="btnCopy" class="btn secondary">Kopieren</button>
<button id="btnReset" class="btn ghost">Zurücksetzen</button>
</div>
</div>
</div>
<div class="card" style="border-style:dashed; margin-top:14px;">
<div style="display:flex; gap:16px; align-items:center; margin-bottom:8px;">
<div style="font-weight:700;">Versandart</div>
<label><input type="radio" name="mode" value="dm" checked/> DM an User</label>
<label><input type="radio" name="mode" value="bulk"/> Bulk an alle Abonnenten</label>
<label><input type="radio" name="mode" value="channel"/> In Channel posten</label>
</div>
<div id="dmFields" class="split">
<div style="grid-column: span 2;">
<label for="userid">Discord User-ID <span style="color:#dc2626">*</span></label>
<input id="userid" placeholder="123456789012345678"/>
</div>
</div>
<div id="bulkFields" class="split" style="display:none;">
<div>
<label for="limit">Limit (optional)</label>
<input id="limit" type="number" min="1" placeholder="z. B. 500"/>
</div>
<div style="grid-column: span 1; display:flex; align-items:flex-end;">
<div class="muted" id="subsHint">Senden an alle registrierten Abonnenten.</div>
</div>
</div>
<!-- NEU: Channel-Felder -->
<div id="channelFields" class="split" style="display:none;">
<div style="grid-column: span 2;">
<label for="channelid">Discord Channel-ID <span style="color:#dc2626">*</span></label>
<input id="channelid" placeholder="123456789012345678"/>
<div class="muted" style="margin-top:6px;font-size:12px">
Tipp: Rechts in der Tabelle auf einen Channel klicken, um die ID zu übernehmen.
</div>
</div>
</div>
<div style="margin-top:12px;">
<button id="btnSend" class="btn">Jetzt senden</button>
</div>
</div>
</section>
<aside class="card">
<h2 style="margin-top:0">Vorschau (Discord)</h2>
<pre id="preview" class="preview"></pre>
<div class="muted" style="font-size:12px; margin-top:8px;">Formatierung nutzt Discord-Markdown (Fettdruck, Codeblöcke, Blockquotes). Für Links einfach URLs in den Text aufnehmen.</div>
<div class="card" style="margin-top:16px;">
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:12px;">
<div>
<div class="muted" style="font-size:12px;">Health</div>
<div id="healthVal" style="font-weight:700;"></div>
<div class="muted" style="font-size:12px;">GET /healthz</div>
</div>
<div>
<div class="muted" style="font-size:12px;">Abonnenten</div>
<div id="subsCount" style="font-weight:700;"></div>
<div class="muted" style="font-size:12px;">GET /subscribers</div>
</div>
<div>
<div class="muted" style="font-size:12px;">Channels</div>
<div id="chanCount" style="font-weight:700;"></div>
<div class="muted" style="font-size:12px;">GET /channels</div>
</div>
</div>
<div style="max-height:220px; overflow:auto; margin-top:12px; border:1px solid var(--border); border-radius:12px;">
<table>
<thead>
<tr><th>Channel-ID</th><th>Name</th></tr>
</thead>
<tbody id="chanRows">
<tr><td colspan="2" class="muted">Keine Channels geladen.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="muted" style="font-size:12px; margin-top:12px;">
API-Aufrufe:
<code>POST /messages/dm</code>,
<code>POST /messages/bulk</code>,
<code>POST /messages/channel</code>,
<code>GET /subscribers</code>,
<code>GET /channels</code>,
<code>GET /healthz</code>.
</div>
</aside>
</div>
</div>
<script>
var $ = function(id){ return document.getElementById(id); };
var todayISO = function(){ return (new Date()).toISOString().slice(0,10); };
function rootOfApi(url){
var u = url;
if(u.slice(-4) === '/api') return u.slice(0, -4);
if(u.slice(-5) === '/api/') return u.slice(0, -5);
return u;
}
function riskBadge(risk){
var r = String(risk||'').toLowerCase().trim();
if (r === 'kritisch' || r === 'critical' || r === 'cr' || r === 'k') return '🟥 Kritisch';
if (r === 'hoch' || r === 'high' || r === 'h') return '🟧 Hoch';
if (r === 'mittel' || r === 'medium' || r === 'm') return '🟨 Mittel';
if (r === 'niedrig' || r === 'low' || r === 'l') return '🟩 Niedrig';
if (r === 'info' || r === 'informational' || r === 'i') return ' Info';
return risk || '-';
}
function trimLines(s){
var lines = String(s||'').split(String.fromCharCode(10));
for (var i=0; i<lines.length; i++){
var l = lines[i];
var j = l.length;
while (j>0 && (l.charAt(j-1) === ' ' || l.charAt(j-1) === ' ')) j--;
lines[i] = l.slice(0, j);
}
return lines.join(String.fromCharCode(10)).trim();
}
function replaceAll(str, find, repl){
var out = '';
var i = 0;
var idx;
while ((idx = str.indexOf(find, i)) !== -1){
out += str.slice(i, idx) + repl;
i = idx + find.length;
}
return out + str.slice(i);
}
function sanitizeCVEList(s){
var t = String(s||'');
// remove plain spaces only (avoids backslashes in regex)
t = replaceAll(t, ' ', '');
// ensure comma+space
t = replaceAll(t, ',', ', ');
return t;
}
function buildDiscordMessage(fields, style){
var stand = fields.stand,
risiko = fields.risiko,
cvssBase = fields.cvssBase,
id = fields.id,
titel = fields.titel,
produkte = fields.produkte,
cve = fields.cve,
status = fields.status,
mitigation = fields.mitigation;
var NL = String.fromCharCode(10);
var BQ = String.fromCharCode(96);
var header = '**🔔 Sicherheitshinweis** — *' + (stand || 'n. a.') + '*';
var line1 = '**ID:** ' + (id || '-') + ' | **Status:** ' + (status || '');
var line2 = '**Risiko:** ' + riskBadge(risiko) + ' (CVSS ' + (cvssBase || '') + ')';
var title = titel ? '**Titel:** ' + titel : '';
var affected = produkte ? '**Betroffene Produkte:** ' + produkte : '';
var cves = cve ? '**CVE:** ' + BQ + sanitizeCVEList(cve) + BQ : '';
var mit = '';
if (mitigation){
var lines = trimLines(mitigation).split(NL);
for (var i=0; i<lines.length; i++){ lines[i] = '> ' + lines[i]; }
mit = '**Mitigation/Workaround:**' + NL + lines.join(NL);
}
if (style === 'compact'){
var parts = [header, line1, line2, title, affected, cves, mit];
var out = [];
for (var p=0; p<parts.length; p++){ if(parts[p]) out.push(parts[p]); }
return out.join(NL);
}
var pad = function(k,v){
var key = k;
while (key.length < 18) key += ' ';
return key + ': ' + v;
};
var table = [
pad('Stand', stand || ''),
pad('Risiko', riskBadge(risiko)),
pad('CVSS Base', cvssBase || ''),
pad('ID', id || ''),
pad('Status', status || '')
].join(NL);
var parts2 = [header, '', BQ+BQ+BQ, table, BQ+BQ+BQ, title, affected, cves, mit];
var out2 = [];
for (var q=0; q<parts2.length; q++){ if(parts2[q]) out2.push(parts2[q]); }
return out2.join(NL);
}
function setToast(type, text){
var el = $('toast');
el.className = 'toast ' + type;
el.textContent = text;
el.style.display = 'block';
clearTimeout(setToast._t);
setToast._t = setTimeout(function(){ el.style.display = 'none'; }, 6000);
}
var state = { mode: 'dm' };
function updatePreview(){
var fields = {
stand: $('stand').value,
risiko: $('risiko').value,
cvssBase: $('cvss').value,
id: $('advid').value,
titel: $('titel').value,
produkte: $('produkte').value,
cve: $('cve').value,
status: $('status').value,
mitigation: $('mitigation').value
};
var style = $('style').value;
$('preview').textContent = buildDiscordMessage(fields, style);
}
function resetForm(){
$('stand').value = todayISO();
$('risiko').value = '';
$('cvss').value = '';
$('advid').value = '';
$('titel').value = '';
$('produkte').value = '';
$('cve').value = '';
$('status').value = 'Neu';
$('mitigation').value = '';
updatePreview();
}
function validate(){
var miss = [];
if(!$('stand').value) miss.push('Stand');
if(!$('risiko').value) miss.push('Risiko');
if(!$('advid').value) miss.push('ID');
if(!$('titel').value) miss.push('Titel');
if(!$('produkte').value) miss.push('betroffene Produkte');
if(!$('cve').value) miss.push('CVE');
if(!$('status').value) miss.push('Status');
if(!$('mitigation').value) miss.push('Mitigation');
var cv = parseFloat($('cvss').value);
if($('cvss').value && (isNaN(cv) || cv < 0 || cv > 10)) miss.push('CVSS Base (0-10)');
if(state.mode === 'dm' && !$('userid').value) miss.push('Discord User-ID');
if(state.mode === 'channel' && !$('channelid').value) miss.push('Discord Channel-ID');
return miss;
}
async function loadMeta(){
var base = $('apiBase').value.trim();
var root = rootOfApi(base);
try{
var hz = await fetch(root + '/healthz', { credentials: 'include' });
$('healthVal').textContent = hz.ok ? 'ok' : (hz.status + ' ' + hz.statusText);
}catch(e){ $('healthVal').textContent = 'Fehler'; }
try{
var resS = await fetch(base + '/subscribers', { credentials: 'include' });
var jsS = await resS.json();
var itemsS = (jsS && jsS.items) || [];
$('subsCount').textContent = itemsS.length || '0';
$('subsHint').textContent = 'Senden an alle registrierten Abonnenten (' + itemsS.length + ').';
}catch(e){ $('subsCount').textContent = '—'; }
try{
var resC = await fetch(base + '/channels', { credentials: 'include' });
var jsC = await resC.json();
var itemsC = (jsC && jsC.items) || [];
$('chanCount').textContent = itemsC.length || '0';
var tbody = $('chanRows');
tbody.innerHTML = '';
if(itemsC.length === 0){
var tr0 = document.createElement('tr'); tr0.innerHTML = '<td colspan="2" class="muted">Keine Channels gefunden.</td>';
tbody.appendChild(tr0);
} else {
for (var i=0; i<itemsC.length; i++){
var c = itemsC[i];
var tr = document.createElement('tr');
tr.innerHTML = '<td><code>' + c.id + '</code></td><td>' + (c.name || '—') + '</td>';
tr.style.cursor = 'pointer';
tr.title = 'Klicken, um die Channel-ID zu übernehmen';
(function(id){
tr.addEventListener('click', function(){
$('channelid').value = id;
// optional: gleich in den Channel-Modus wechseln
var m = document.querySelector('input[name="mode"][value="channel"]');
if (m){ m.checked = true; m.dispatchEvent(new Event('change')); }
setToast('info', 'Channel-ID übernommen: ' + id);
});
})(c.id);
tbody.appendChild(tr);
}
}
}catch(e){
$('chanCount').textContent = '—';
}
}
async function doSend(){
var errs = validate();
if(errs.length){ setToast('err', 'Bitte ausfüllen: ' + errs.join(', ')); return; }
var base = $('apiBase').value.trim();
var msg = $('preview').textContent;
var btn = $('btnSend');
btn.disabled = true; btn.textContent = 'Sendet…';
try{
if(state.mode === 'dm'){
var bodyDM = { user_id: $('userid').value.trim(), message: msg };
var resDM = await fetch(base + '/messages/dm', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyDM)
});
if(!resDM.ok){ throw new Error(resDM.status + ' ' + resDM.statusText); }
setToast('ok', 'DM erfolgreich versendet.');
} else if(state.mode === 'bulk'){
var bodyB = { message: msg };
if($('limit').value) bodyB.limit = Number($('limit').value);
var resB = await fetch(base + '/messages/bulk', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyB)
});
var js = await resB.json().catch(function(){ return {}; });
if(!resB.ok){ throw new Error(resB.status + ' ' + resB.statusText); }
setToast('ok', 'Bulk gesendet: ok=' + (js.ok == null ? '?' : js.ok) + ', fail=' + (js.fail == null ? '?' : js.fail));
} else { // channel
var bodyC = { channel_id: $('channelid').value.trim(), message: msg };
var resC = await fetch(base + '/messages/channel', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyC)
});
if(!resC.ok){ throw new Error(resC.status + ' ' + resC.statusText); }
setToast('ok', 'Nachricht im Channel gepostet.');
}
}catch(e){ setToast('err', 'Senden fehlgeschlagen: ' + e); }
finally{ btn.disabled = false; btn.textContent = 'Jetzt senden'; }
}
function copyPreview(){
navigator.clipboard.writeText($('preview').textContent)
.then(function(){ setToast('ok', 'Nachricht in Zwischenablage kopiert.'); })
.catch(function(e){ setToast('err', 'Kopieren fehlgeschlagen: ' + e); });
}
(function init(){
$('stand').value = todayISO();
$('status').value = 'Neu';
var ids = [ 'stand','risiko','cvss','advid','titel','produkte','cve','status','mitigation','style' ];
for (var i=0; i<ids.length; i++){ $(ids[i]).addEventListener('input', updatePreview); }
updatePreview();
var radios = document.querySelectorAll('input[name="mode"]');
for (var j=0; j<radios.length; j++){
radios[j].addEventListener('change', function(e){
state.mode = e.target.value;
$('dmFields').style.display = state.mode === 'dm' ? 'grid' : 'none';
$('bulkFields').style.display = state.mode === 'bulk' ? 'grid' : 'none';
$('channelFields').style.display = state.mode === 'channel' ? 'grid' : 'none';
});
}
$('btnReset').addEventListener('click', resetForm);
$('btnCopy').addEventListener('click', copyPreview);
$('btnSend').addEventListener('click', doSend);
$('btnMeta').addEventListener('click', loadMeta);
loadMeta();
})();
</script>
</body>
</html>

View File

@@ -1,6 +1,43 @@
package ui
import _ "embed"
import (
"embed"
"html/template"
"net/http"
)
//go:embed admin.html
var AdminHTML []byte
// beide HTMLs einbetten
//
//go:embed admin.html post.html
var Files embed.FS
// Templates einmalig parsen (Namen = Dateinamen)
var Tpl = template.Must(template.ParseFS(Files, "admin.html", "post.html"))
type PageData struct {
DefaultAPI string // wird in der HTML als {{.DefaultAPI}} benutzt
}
// TemplateHandler rendert ein Template + setzt sinnvolle Security-Header
func TemplateHandler(tplName string, defaults PageData) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Defaults ggf. überschreiben (z. B. per Query oder Header optional)
data := defaults
if data.DefaultAPI == "" {
data.DefaultAPI = "/api"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
// Inline-Skripte: dafür 'unsafe-inline' (oder JS auslagern, dann kannst du es entfernen)
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := Tpl.ExecuteTemplate(w, tplName, data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
return
}
}
}

BIN
subs.db Normal file

Binary file not shown.