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.