Bei der Bestandsaufnahme als "keine Route hat eine Bremse" gemeldet -- das war falsch (grep suchte nach rateLimit/bremse, im Code heißen sie Kontingent/Sperre/Fehlversuche). Fast alle öffentlichen Routen SIND gebremst: Anmeldung, Supporter-Login, Upload, Kundenanfragen. Übrig blieben genau zwei schreibende Routen: Bewerbungen und Stimmen. Ohne Bremse könnte ein Skript die Datenbank mit Müll fluten. Kein Sicherheitsleck (beide landen in einer Warteschlange, nichts wird ungesehen veröffentlicht), aber eine sinnvolle Härtung. Statt das vorhandene Muster ein drittes Mal zu kopieren: ein Baustein lib/kontingent.js, den nun alle drei Routen nutzen. Zwei Verbesserungen gegenüber dem Original in testimonials.js: - getrennte Töpfe je Zweck (ein Bild-Upload verbraucht kein Bewerbungs-Kontingent) - Selbstreinigung: die alte Zähler-Map ließ jede IP für immer im Speicher stehen (langsames Leck), die neue räumt abgelaufene Einträge auf Grenzen: Uploads 10/Stunde/IP (belegen Plattenplatz), Text-Einreichungen 20/Stunde/IP (großzügig für geteilte Anschlüsse, stoppt Fluten). Tests: pruef-kontingent.mjs (Baustein, 7/7), test-kontingent-routen.mjs (echte Routen liefern 429 ab Grenze, getrennte Töpfe, IPs unabhängig, 4/4). Bestehende Tests unverändert grün (37/37). NOCH NICHT LIVE: server-internal läuft aus /home/dogiintern (kein Zugriff), wird mit den übrigen Server-Änderungen in einem Deploy live geschaltet. Co-Authored-By: Claude Opus 5 <[email protected]>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
/* =====================================================================
|
|
routes/applications.js — Bewerbungen (Postfach), mit vollem Rollen-/
|
|
Rechte-Modell statt einem pauschalen Owner/Helfer-Zugriff. Status,
|
|
Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. 1:1 portiert aus
|
|
cloudflare-worker/src/routes/applications.js.
|
|
===================================================================== */
|
|
import { db } from "../db.js";
|
|
import { generateId, nowIso } from "../lib/crypto.js";
|
|
import { hasPermission } from "../lib/permissions.js";
|
|
import { logAction } from "../lib/audit.js";
|
|
import { json } from "../lib/http.js";
|
|
import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js";
|
|
import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js";
|
|
import { kontingentFrei } from "../lib/kontingent.js";
|
|
|
|
const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"];
|
|
|
|
/* Öffentliche Route, keine Anmeldung: ohne Bremse könnte ein Skript die
|
|
Bewerbungstabelle mit Müll fluten. 20/Stunde/IP ist großzügig genug
|
|
für mehrere echte Bewerbungen hinter einem geteilten Anschluss
|
|
(Familie, Mobilfunk-NAT) und stoppt trotzdem eine maschinelle Flut. */
|
|
const BEWERBUNG_MAX_PRO_STUNDE = 20;
|
|
|
|
function can(session, key) {
|
|
if (!session) return false;
|
|
if (session.isOwner) return true;
|
|
return hasPermission(session.permissions, session.overrides, key);
|
|
}
|
|
|
|
function viewPermissionForType(type) {
|
|
// Der Rechte-Katalog kommt fest aus dem Anforderungsdokument und kennt nur
|
|
// zwei Bewerbungs-Sichtbereiche (Creator / "Rest"). Scout, Kooperation und
|
|
// Modi laufen deshalb alle über APPLICATIONS_VIEW_MANAGER_SCOUT — es wird
|
|
// bewusst KEIN neuer Permission-Key erfunden, der nicht im Dokument steht.
|
|
return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT";
|
|
}
|
|
|
|
export async function submitApplication(req, res) {
|
|
const ip = req.ip || req.socket?.remoteAddress || "unbekannt";
|
|
if (!kontingentFrei("bewerbung", ip, { maxProFenster: BEWERBUNG_MAX_PRO_STUNDE })) {
|
|
return json(res, { ok: false, error: "Zu viele Bewerbungen in kurzer Zeit. Bitte versuch es später noch einmal." }, 429);
|
|
}
|
|
const body = req.body;
|
|
const { type, data } = body || {};
|
|
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
|
|
return json(res, { ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
|
|
}
|
|
const values = Object.values(data).map((v) => String(v || "").trim());
|
|
if (values.every((v) => v === "")) return json(res, { ok: false, error: "Leere Bewerbung." }, 400);
|
|
|
|
const id = generateId();
|
|
db.prepare(`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`).run(
|
|
id,
|
|
type,
|
|
JSON.stringify(data),
|
|
nowIso()
|
|
);
|
|
|
|
// Zusätzlich (rein informativ, blockiert die Bewerbung selbst nie):
|
|
// 1) echtes Ticket bei Ticketanizer im passenden Bereich anlegen
|
|
// (Web-Inbox zum Bearbeiten/Antworten)
|
|
// 2) direkte, garantiert sichtbare Discord-Nachricht in den passenden
|
|
// Kanal posten (Ticketanizer erzeugt bei API-Tickets keinen echten
|
|
// Discord-Kanal, siehe discord-webhooks.js)
|
|
// Läuft im Hintergrund weiter (kein ctx.waitUntil-Äquivalent in Express
|
|
// nötig — der Node-Prozess bleibt am Leben), blockiert aber die Antwort
|
|
// an die bewerbende Person nicht.
|
|
Promise.all([bewerbungAlsTicketAnlegen(type, data), bewerbungAlsDiscordNachrichtPosten(type, data)]).catch(() => {});
|
|
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function listApplications(req, res, session) {
|
|
if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401);
|
|
const body = req.body || {};
|
|
const includeArchived = !!body.includeArchived;
|
|
|
|
const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t)));
|
|
if (allowedTypes.length === 0) return json(res, { ok: true, applications: [] });
|
|
if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) {
|
|
return json(res, { ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403);
|
|
}
|
|
|
|
const placeholders = allowedTypes.map(() => "?").join(",");
|
|
const archivedClause = includeArchived ? "" : "AND archived = 0";
|
|
const rows = db
|
|
.prepare(`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`)
|
|
.all(...allowedTypes);
|
|
|
|
const applications = rows.map((r) => ({
|
|
id: r.id,
|
|
type: r.type,
|
|
data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null,
|
|
status: r.status,
|
|
assignedTo: r.assigned_to,
|
|
archived: !!r.archived,
|
|
createdAt: r.created_at,
|
|
}));
|
|
|
|
return json(res, { ok: true, applications });
|
|
}
|
|
|
|
export async function changeApplicationStatus(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const status = String(body.status || "").slice(0, 40);
|
|
db.prepare(`UPDATE applications SET status = ? WHERE id = ?`).run(status, id);
|
|
logAction(session.actor, "application.status", id, { status });
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function takeOverApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_TAKE_OVER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const who = session.isOwner ? "owner" : session.userId;
|
|
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(who, id);
|
|
logAction(session.actor, "application.take_over", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function assignApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_ASSIGN")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
const assignedTo = body.assignedTo ? String(body.assignedTo) : null;
|
|
db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(assignedTo, id);
|
|
logAction(session.actor, "application.assign", id, { assignedTo });
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function archiveApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_ARCHIVE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.archive", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function restoreApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_RESTORE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.restore", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function deleteApplication(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`DELETE FROM application_notes WHERE application_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).run(id);
|
|
db.prepare(`DELETE FROM applications WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.delete", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function markAnswered(req, res, session) {
|
|
if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const id = String(body.id || "");
|
|
db.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).run(id);
|
|
logAction(session.actor, "application.mark_answered", id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---- Notizen ---- */
|
|
export async function listNotes(req, res, session) {
|
|
if (!can(session, "NOTES_READ")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const rows = db
|
|
.prepare(`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`)
|
|
.all(String(body.applicationId || ""));
|
|
return json(res, { ok: true, notes: rows });
|
|
}
|
|
|
|
export async function addNote(req, res, session) {
|
|
if (!can(session, "NOTES_WRITE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
const text = String(body.text || "").slice(0, 2000);
|
|
if (!text.trim()) return json(res, { ok: false, error: "Leere Notiz." }, 400);
|
|
db.prepare(`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`).run(
|
|
String(body.applicationId || ""),
|
|
session.actor,
|
|
text,
|
|
nowIso()
|
|
);
|
|
logAction(session.actor, "note.add", body.applicationId, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
export async function deleteNote(req, res, session) {
|
|
if (!can(session, "NOTES_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
const body = req.body || {};
|
|
db.prepare(`DELETE FROM application_notes WHERE id = ?`).run(Number(body.id));
|
|
logAction(session.actor, "note.delete", String(body.id), null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---- Antwortentwürfe ---- */
|
|
export async function listDrafts(req, res, session) {
|
|
const body = req.body || {};
|
|
const rows = db
|
|
.prepare(`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`)
|
|
.all(String(body.applicationId || ""));
|
|
return json(res, { ok: true, drafts: rows });
|
|
}
|
|
|
|
export async function saveDraft(req, res, session) {
|
|
const body = req.body || {};
|
|
const content = String(body.content || "").slice(0, 5000);
|
|
|
|
if (body.id) {
|
|
const existing = db.prepare(`SELECT * FROM application_drafts WHERE id = ?`).get(Number(body.id));
|
|
if (!existing) return json(res, { ok: false, error: "Entwurf nicht gefunden." }, 404);
|
|
const isOwnDraft = existing.author === session.actor;
|
|
if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) {
|
|
return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
}
|
|
db.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`).run(content, nowIso(), Number(body.id));
|
|
logAction(session.actor, "draft.update", String(body.id), null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
if (!can(session, "DRAFTS_CREATE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403);
|
|
db.prepare(`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run(
|
|
String(body.applicationId || ""),
|
|
session.actor,
|
|
content,
|
|
nowIso(),
|
|
nowIso()
|
|
);
|
|
logAction(session.actor, "draft.create", body.applicationId, null);
|
|
return json(res, { ok: true });
|
|
}
|