PageSourceSearch

https://board.wartenberger-sv.com/js/WoltLabSuite/wsv/ViecodeGuard.js?v=1.0.0

js wartenberger-sv.com collected 2026-10-02 22:24:13 UTC 16,108 bytes, 409 lines download raw bytes

1/**
2 * WSV VieCode-Doppelsubmit-Schutz - Stufe 2 (Komfort).
3 *
4 * Reines Vanilla-JS, KEIN AMD-Modul, KEIN Core-Patch. Wird inline ueber den
5 * templateListener (headIncludeJavaScript / javascriptInit) geladen und laeuft
6 * eigenstaendig.
7 *
8 * Zwei Wirkungen, beide nur in VieCode-Formular-Dialogen:
9 *
10 *   (a) Bestaetigungs-Zwischenschritt: Beim ersten Klick auf den Absende-Knopf
11 *       wird der Submit gestoppt, eine Zusammenfassung der ausgefuellten Felder
12 *       + Rueckfrage "Wirklich absenden?" eingeblendet. Erst nach Bestaetigung
13 *       laeuft der Original-Submit durch.
14 *   (b) Knopf-Sperre: Zwischen Bestaetigung und Server-Antwort bleibt der Knopf
15 *       gesperrt (zusaetzlich zur WoltLab-eigenen Sperre), damit der seltene
16 *       Sofort-Doppelklick (<1 s) keinen zweiten Request ausloest. Nach der
17 *       Antwort (Erfolg ODER Fehler) wird wieder freigegeben, damit der Nutzer
18 *       bei einem echten Fehler nicht festsitzt.
19 *
20 * Der eigentliche, nicht umgehbare Riegel ist Stufe 1 (Server). Diese Stufe ist
21 * Komfort/UX - sie fragt nach und entschaerft Hektik-Doppelklicks.
22 *
23 * VieCode hat ZWEI Render-Pfade fuer dasselbe Formular (am gerenderten DOM
24 * verifiziert 2026-06-30):
25 *   - POPUP (BBCode in einem Beitrag): FormBuilderDialog → Container
26 *     <section id="form<formID>">, Submit = <button data-type="submit">
27 *     (formBBCodeTag.tpl Z.88, DialogFormDocument Z.59).
28 *   - VOLLSEITE (eigene Formular-Seite, der WSV-Normalfall): Controller
29 *     wcf\form\FormUserResponseForm (pageID 231) → <form id="formUserResponse"
30 *     action="...form-user-response...">, Submit = <input type="submit">
31 *     (__formButton.tpl Z.1-8: isSubmit()=true bei Nicht-AJAX).
32 * Pfad-NEUTRALES Alleinstellungsmerkmal beider VieCode-Formulare ist das
33 * versteckte Feld input[name="formID"] (HiddenFormField, FormResponseAction
34 * Z.574). Fremde WoltLab-Forms (Login/Suche/Profil/Reaktionen) haben es NICHT.
35 * Wir greifen, wenn der geklickte Submit-Knopf in einem Container mit
36 * input[name="formID"] liegt UND der Container eine der beiden Pfad-Kennungen
37 * traegt. (Der alte Bau pruefte nur den Popup-Pfad form\d+ → griff auf der
38 * Vollseite NIE, weil die <input>-Submits + die id="formUserResponse" nicht
39 * passten.)
40 *
41 * Mobile-First: Das Overlay nutzt position:fixed + 100% Breite mit max-width,
42 * grosse Tap-Flaechen (min 44px), und bricht am Handy nicht (kein fixes px-Layout).
43 */
44(function () {
45    "use strict";
46
47    // Verhindert doppelte Initialisierung, falls das Template mehrfach laedt.
48    if (window.__wsvViecodeGuardLoaded) {
49        return;
50    }
51    window.__wsvViecodeGuardLoaded = true;
52
53    var OVERLAY_ID = "wsvViecodeGuardOverlay";
54    // Knopf der gerade durch unsere Bestaetigung "durchgewunken" wurde.
55    var bypassButton = null;
56
57    /**
58     * Prueft, ob ein Submit-Knopf zu einem VieCode-Response-Formular gehoert
59     * (Vollseite ODER Popup) und gibt {container, mode} zurueck, sonst null.
60     *
61     * Pfad-neutrales Pflicht-Merkmal: der umschliessende Container enthaelt
62     * input[name="formID"] (VieCode-Spezifikum, fremde WoltLab-Forms haben es
63     * nicht). Zusaetzlich muss EINE der beiden Pfad-Kennungen zutreffen, damit
64     * ein theoretisches Fremd-Form mit zufaelligem formID-Feld nicht greift:
65     *   - mode 'page':   <form id="formUserResponse"> oder action enthaelt
66     *                    "form-user-response" (FormUserResponseForm, pageID 231).
67     *   - mode 'dialog': Container-id matcht /^form\d+$/ (FormBuilderDialog).
68     */
69    function findViecodeForm(el) {
70        // Vollseiten-Pfad: naechstes umschliessendes <form>.
71        var form = (el && typeof el.closest === "function") ? el.closest("form") : null;
72        if (form && form.querySelector('input[name="formID"]')) {
73            var action = form.getAttribute("action") || "";
74            if (form.id === "formUserResponse" || action.indexOf("form-user-response") !== -1) {
75                return { container: form, mode: "page" };
76            }
77        }
78        // Popup-Pfad: aufsteigen zu Container-id="form<Zahl>".
79        var node = el;
80        while (node && node !== document.body) {
81            if (
82                node.id &&
83                /^form\d+$/.test(node.id) &&
84                node.querySelector &&
85                node.querySelector('input[name="formID"]')
86            ) {
87                return { container: node, mode: "dialog" };
88            }
89            node = node.parentNode;
90        }
91        return null;
92    }
93
94    /**
95     * Sammelt eine lesbare Zusammenfassung der ausgefuellten Felder.
96     * Deckt Text/Textarea/Select/Radio/Checkbox ab. Leere Felder werden
97     * uebersprungen. Werte werden als Text eingefuegt (textContent), nie als
98     * HTML - kein Injection-Risiko.
99     */
100    function collectSummary(dialog) {
101        var rows = [];
102        var controls = dialog.querySelectorAll("input, textarea, select");
103
104        for (var i = 0; i < controls.length; i++) {
105            var el = controls[i];
106            var type = (el.type || "").toLowerCase();
107
108            // Technische/irrelevante Felder ueberspringen.
109            if (type === "hidden" || type === "submit" || type === "button" || type === "file") {
110                continue;
111            }
112            // Captcha-Felder nicht in die Zusammenfassung ziehen.
113            if (el.name && /captcha/i.test(el.name)) {
114                continue;
115            }
116
117            var label = findLabel(el, dialog);
118            var value = "";
119
120            if (type === "checkbox" || type === "radio") {
121                if (!el.checked) {
122                    continue;
123                }
124                value = label ? "ja" : (el.value || "ja");
125            } else if (el.tagName.toLowerCase() === "select") {
126                if (el.selectedIndex < 0) {
127                    continue;
128                }
129                value = el.options[el.selectedIndex] ? el.options[el.selectedIndex].text : el.value;
130            } else {
131                value = (el.value || "").trim();
132            }
133
134            if (value === "") {
135                continue;
136            }
137
138            rows.push({ label: label || (el.name || "Feld"), value: value });
139        }
140
141        // WYSIWYG-Editoren (CKEditor) liegen nicht als textarea-Wert vor dem
142        // Submit, sondern im contenteditable-Bereich. Best effort auslesen.
143        var editors = dialog.querySelectorAll(".ck-content, .redactor-layer, [contenteditable='true']");
144        for (var j = 0; j < editors.length; j++) {
145            var text = (editors[j].textContent || "").trim();
146            if (text !== "") {
147                rows.push({ label: "Text", value: text });
148            }
149        }
150
151        return rows;
152    }
153
154    /**
155     * Findet das zugehoerige Label eines Form-Feldes (best effort).
156     */
157    function findLabel(el, dialog) {
158        if (el.id) {
159            var lbl = dialog.querySelector('label[for="' + cssEscape(el.id) + '"]');
160            if (lbl) {
161                return (lbl.textContent || "").trim();
162            }
163        }
164        // dl/dt-Struktur von WoltLab-Form-Buildern.
165        var dd = el.closest ? el.closest("dd") : null;
166        if (dd && dd.previousElementSibling && dd.previousElementSibling.tagName === "DT") {
167            return (dd.previousElementSibling.textContent || "").trim();
168        }
169        return "";
170    }
171
172    /**
173     * Minimaler CSS.escape-Ersatz fuer aeltere Browser.
174     */
175    function cssEscape(value) {
176        if (window.CSS && typeof window.CSS.escape === "function") {
177            return window.CSS.escape(value);
178        }
179        return value.replace(/["\\\]\[#.:>~+*^$|()=]/g, "\\$&");
180    }
181
182    /**
183     * Baut + zeigt das Bestaetigungs-Overlay. onConfirm() laeuft bei "Absenden".
184     */
185    function showConfirm(rows, onConfirm) {
186        removeOverlay();
187
188        var overlay = document.createElement("div");
189        overlay.id = OVERLAY_ID;
190        overlay.setAttribute("role", "dialog");
191        overlay.setAttribute("aria-modal", "true");
192        overlay.setAttribute("aria-labelledby", "wsvViecodeGuardTitle");
193        applyStyle(overlay, {
194            position: "fixed",
195            top: "0",
196            left: "0",
197            right: "0",
198            bottom: "0",
199            zIndex: "100000000",
200            background: "rgba(0,0,0,0.6)",
201            display: "flex",
202            alignItems: "center",
203            justifyContent: "center",
204            padding: "16px",
205            boxSizing: "border-box"
206        });
207
208        var box = document.createElement("div");
209        applyStyle(box, {
210            background: "#fff",
211            color: "#1a1a1a",
212            borderRadius: "10px",
213            maxWidth: "440px",
214            width: "100%",
215            maxHeight: "85vh",
216            overflowY: "auto",
217            boxShadow: "0 10px 40px rgba(0,0,0,0.35)",
218            padding: "20px",
219            boxSizing: "border-box",
220            font: "15px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
221        });
222
223        var title = document.createElement("h3");
224        title.id = "wsvViecodeGuardTitle";
225        title.textContent = "Eingaben prüfen";
226        applyStyle(title, { margin: "0 0 12px", fontSize: "18px" });
227        box.appendChild(title);
228
229        if (rows.length > 0) {
230            var list = document.createElement("dl");
231            applyStyle(list, { margin: "0 0 16px" });
232            for (var i = 0; i < rows.length; i++) {
233                var dt = document.createElement("dt");
234                dt.textContent = rows[i].label;
235                applyStyle(dt, { fontWeight: "600", marginTop: "8px", color: "#555" });
236                var dd = document.createElement("dd");
237                dd.textContent = rows[i].value;
238                applyStyle(dd, { margin: "2px 0 0", wordBreak: "break-word" });
239                list.appendChild(dt);
240                list.appendChild(dd);
241            }
242            box.appendChild(list);
243        }
244
245        var question = document.createElement("p");
246        question.textContent = "Möchtest du das Formular jetzt absenden?";
247        applyStyle(question, { margin: "0 0 18px" });
248        box.appendChild(question);
249
250        var buttons = document.createElement("div");
251        applyStyle(buttons, { display: "flex", gap: "10px", flexWrap: "wrap" });
252
253        var ok = document.createElement("button");
254        ok.type = "button";
255        ok.textContent = "Ja, absenden";
256        applyStyle(ok, btnStyle("#1565c0", "#fff"));
257
258        var cancel = document.createElement("button");
259        cancel.type = "button";
260        cancel.textContent = "Zurück";
261        applyStyle(cancel, btnStyle("#e0e0e0", "#1a1a1a"));
262
263        ok.addEventListener("click", function () {
264            removeOverlay();
265            onConfirm();
266        });
267        cancel.addEventListener("click", function () {
268            removeOverlay();
269        });
270
271        buttons.appendChild(ok);
272        buttons.appendChild(cancel);
273        box.appendChild(buttons);
274        overlay.appendChild(box);
275
276        // Klick auf den dunklen Hintergrund schliesst (= Zurück).
277        overlay.addEventListener("click", function (e) {
278            if (e.target === overlay) {
279                removeOverlay();
280            }
281        });
282
283        document.body.appendChild(overlay);
284        ok.focus();
285    }
286
287    function btnStyle(bg, fg) {
288        return {
289            flex: "1 1 auto",
290            minHeight: "44px",
291            padding: "10px 16px",
292            border: "none",
293            borderRadius: "6px",
294            background: bg,
295            color: fg,
296            fontSize: "15px",
297            fontWeight: "600",
298            cursor: "pointer"
299        };
300    }
301
302    function applyStyle(el, styles) {
303        for (var key in styles) {
304            if (Object.prototype.hasOwnProperty.call(styles, key)) {
305                el.style[key] = styles[key];
306            }
307        }
308    }
309
310    function removeOverlay() {
311        var existing = document.getElementById(OVERLAY_ID);
312        if (existing && existing.parentNode) {
313            existing.parentNode.removeChild(existing);
314        }
315    }
316
317    /**
318     * Klick-Abfang in der Capture-Phase: laeuft VOR WoltLabs eigenem
319     * Submit-Handler, sodass wir den Submit zurueckhalten koennen.
320     */
321    document.addEventListener("click", function (event) {
322        var target = event.target;
323        if (!target || typeof target.closest !== "function") {
324            return;
325        }
326
327        // Beide Pfade: Popup-Submit (<button data-type="submit">) UND
328        // Vollseiten-Submit (<input type="submit">).
329        var button = target.closest('button[data-type="submit"], input[type="submit"]');
330        if (!button) {
331            return;
332        }
333
334        var info = findViecodeForm(button);
335        if (!info) {
336            // Kein VieCode-Response-Formular -> nicht eingreifen.
337            return;
338        }
339
340        // Wurde dieser Klick bereits von uns bestaetigt? Dann durchlassen +
341        // Knopf bis zur Antwort gesperrt halten. Nur Popup-Pfad - die Vollseite
342        // wird per form.requestSubmit() ausgeloest (kein erneuter click).
343        if (button === bypassButton) {
344            bypassButton = null;
345            lockButton(button);
346            return;
347        }
348
349        if (button.disabled) {
350            return;
351        }
352
353        // Ersten Klick stoppen und Bestaetigung zeigen.
354        event.preventDefault();
355        event.stopPropagation();
356
357        var rows = collectSummary(info.container);
358        showConfirm(rows, function () {
359            if (info.mode === "page") {
360                // Vollseite: nativen Form-Submit ausloesen. requestSubmit() macht
361                // Browser-Validierung + feuert das submit-Event und uebergibt den
362                // Knopf als submitter (dessen name/value landet im POST). Es loest
363                // KEINEN erneuten click aus -> der Capture-Handler feuert nicht
364                // wieder, daher kein bypass/lock noetig (Seite laedt nach Submit neu).
365                if (typeof info.container.requestSubmit === "function") {
366                    info.container.requestSubmit(button);
367                } else {
368                    // Fallback nur fuer Safari < 16 (kein requestSubmit). Uebergibt
369                    // keinen submitter - unkritisch, da der VieCode-Submit-Knopf
370                    // (input#submitButton) KEIN name-Attribut hat, also ohnehin
371                    // keinen benannten POST-Wert beisteuert.
372                    info.container.submit();
373                }
374            } else {
375                // Popup: Original-Klick programmatisch durchwinken. Das Flag laesst
376                // den zweiten Klick im Capture-Handler durch + sperrt den Knopf bis
377                // zur AJAX-Antwort.
378                bypassButton = button;
379                button.click();
380                // Sicherheitsnetz: falls click() den Capture-Handler NICHT synchron
381                // erneut ausloest (z.B. ein fremder stopImmediatePropagation-Listener
382                // davor), bliebe bypassButton dauerhaft gesetzt -> der naechste echte
383                // Klick auf denselben Button rutschte ohne Bestaetigung durch. Daher
384                // nach kurzem Fenster zuruecksetzen (No-op, wenn der Re-Entry es sc
384hon
385                // genullt hat).
386                window.setTimeout(function () {
387                    if (bypassButton === button) {
388                        bypassButton = null;
389                    }
390                }, 500);
391            }
392        });
393    }, true);
394
395    /**
396     * Sperrt den Knopf bis zur Server-Antwort, gibt ihn dann wieder frei.
397     * WoltLab leert bei Erfolg den Dialog (Knopf weg), bei Fehler zeigt es ihn
398     * erneut - der Timeout greift nur im Fehlerfall, damit niemand festsitzt.
399     */
400    function lockButton(button) {
401        button.disabled = true;
402        window.setTimeout(function () {
403            // Knopf koennte durch Dialog-Rebuild ersetzt sein - dann ist das ein No-op.
404            if (button && document.body.contains(button)) {
405                button.disabled = false;
406            }
407        }, 4000);
408    }
409})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.