Compare commits
2 Commits
3b15831565
...
f5bcbad27e
| Author | SHA1 | Date | |
|---|---|---|---|
| f5bcbad27e | |||
| 7d623dad6b |
@@ -1 +1,4 @@
|
||||
# patchping
|
||||
|
||||
- https://support.hpe.com/hpesc/public/api/document/sec_bull_rss_feed
|
||||
- https://ubuntu.com/security/notices/rss.xml
|
||||
|
||||
@@ -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
485
cmd/post/main.go
Normal 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.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>
|
||||
</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 (0–10)');
|
||||
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
14
docs/compose.yml
Normal 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
444
docs/openapi.yaml
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
42
internal/httpserver/handlers/channels.go
Normal file
42
internal/httpserver/handlers/channels.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
52
internal/httpserver/handlers/routing.go
Normal file
52
internal/httpserver/handlers/routing.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
internal/httpserver/handlers/run.go
Normal file
41
internal/httpserver/handlers/run.go
Normal 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})
|
||||
}
|
||||
}
|
||||
111
internal/httpserver/handlers/sources.go
Normal file
111
internal/httpserver/handlers/sources.go
Normal 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)})
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
498
internal/ui/post.html
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user