Skip to main content

max / makenotwork

audit Run 15 Phase 5: CSP script-src drops 'unsafe-inline' Removed every inline script from templates so the CSP can forbid inline script execution — the main-site XSS backstop that was defanged by ~200 inline handlers. - Added a data-action / data-change / data-input / data-submit delegation dispatcher and a data-hx-* htmx-callback dispatcher to mnw.js (built-in DOM verbs, prevent/stop modifiers, global-function passthrough, and named behaviors for the multi-step cases). - Migrated all 204 inline on* handlers, 2 javascript: hrefs, and 30 hx-on:: attributes across 70 templates to those dispatchers; per-area wrapper functions live in static/actions-{pages,partials,tabs,dashboards}.js. - Standalone embed player (no dispatcher) wires its play/seek controls directly in embed-item-player.js. - lib.rs CSP: script-src 'self' https://js.stripe.com (was + 'unsafe-inline'). style-src keeps 'unsafe-inline' (inline style="" attributes remain in scope). Needs manual UI verification: automated tests cover template rendering and server behavior, not client-side click/change/submit wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 01:51 UTC
Commit: ee9853ebb5d21fb3b5deea869bac2dd4bb4614a3
Parent: 64dbc1d
75 files changed, +806 insertions, -238 deletions
@@ -376,9 +376,14 @@ async fn security_headers_middleware(
376 376 // user-pages host, so that origin must be a permitted frame source.
377 377 let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" };
378 378 let user_pages_origin = format!("{scheme}://{}", state.config.user_pages_host);
379 + // script-src is 'self' (+ Stripe) with NO 'unsafe-inline': all inline
380 + // on*/hx-on handlers were moved to delegated listeners in static/*.js
381 + // (the data-action / data-hx-* dispatchers in mnw.js), so any injected
382 + // markup can no longer execute script. style-src keeps 'unsafe-inline'
383 + // because inline style="" attributes are still used throughout.
379 384 let csp = format!(
380 385 "default-src 'self'; \
381 - script-src 'self' 'unsafe-inline' https://js.stripe.com; \
386 + script-src 'self' https://js.stripe.com; \
382 387 style-src 'self' 'unsafe-inline'; \
383 388 img-src 'self' data: https:; \
384 389 font-src 'self'; \
@@ -0,0 +1,33 @@
1 + // Delegated event handlers extracted from inline on* attributes in the
2 + // dashboard, dashboard editor, embed, and wizard templates, moved here for CSP
3 + // compliance (so script-src can drop 'unsafe-inline'). The dispatcher in
4 + // mnw.js invokes each of these by name with `this` bound to the dispatching
5 + // element and any data-arg/data-arg2 attributes passed as arguments. Bodies
6 + // are preserved verbatim from the original inline handlers.
7 +
8 + // setActiveTab is defined in mnw.js and takes the clicked button as its
9 + // argument; the inline handlers called setActiveTab(this), so wrap it.
10 + window.onSetActiveTab = function () {
11 + setActiveTab(this);
12 + };
13 +
14 + // Reactivate-account form: schedule a page reload after the HTMX request.
15 + window.reactivateReload = function () {
16 + setTimeout(function(){ window.location.reload(); }, 500);
17 + };
18 +
19 + // Blog editor breadcrumb / cancel links: after navigating, activate the Blog tab.
20 + window.blogTabNav = function () {
21 + setTimeout(function(){document.getElementById('tab-blog').click()},100);
22 + };
23 +
24 + // Copy the item id to the clipboard and flash feedback on the button.
25 + window.onCopyItemId = function (id) {
26 + navigator.clipboard.writeText(id); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy Item ID', 1500);
27 + };
28 +
29 + // Custom page reset form: confirm before submitting. data-submit auto-prevents
30 + // the native submit, so re-submit programmatically when confirmed.
31 + window.confirmResetPage = function () {
32 + if (confirm('Reset this page to the platform default? This clears your custom HTML and CSS.')) this.submit();
33 + };
@@ -0,0 +1,64 @@
1 + // Delegated handlers extracted from inline on* attributes for CSP compliance.
2 + // These are the named wrappers referenced by data-action / data-change /
3 + // data-input / data-submit in the pages templates. The action dispatcher in
4 + // mnw.js invokes each with `this` bound to the dispatching element, so the
5 + // bodies read args from the element (this.value, this.dataset, this itself)
6 + // exactly as the original inline handlers did.
7 +
8 + // No-op verb: used where the original inline handler only stopped propagation
9 + // (the data-stop modifier does the actual work; this satisfies the dispatcher's
10 + // requirement for a data-action value).
11 + window.noop = function () {};
12 +
13 + // cart.html: onclick="removeCartGroup(this)"
14 + window.onRemoveCartGroup = function () {
15 + removeCartGroup(this);
16 + };
17 +
18 + // discover.html: onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');"
19 + window.toggleDiscoverFilters = function () {
20 + document.querySelector('.discover-sidebar').classList.toggle('show');
21 + this.classList.toggle('active');
22 + };
23 +
24 + // index.html: onsubmit="return submitNotify(event)" (data-submit auto-prevents;
25 + // submitNotify only uses the event for preventDefault).
26 + window.onNotifySubmit = function () {
27 + submitNotify({ preventDefault: function () {} });
28 + };
29 +
30 + // item.html: onclick="openCollectionPicker('{{ item.id }}', this)"
31 + window.onOpenCollectionPicker = function () {
32 + openCollectionPicker(this.getAttribute('data-arg'), this);
33 + };
34 +
35 + // item.html / library_downloads.html / project.html:
36 + // onclick="switchSectionTab(this, 'section-{{ section.slug }}')"
37 + window.onSwitchSectionTab = function () {
38 + switchSectionTab(this, this.getAttribute('data-arg'));
39 + };
40 +
41 + // library.html: onclick="setActiveTab(this)"
42 + window.onSetActiveTab = function () {
43 + setActiveTab(this);
44 + };
45 +
46 + // purchase.html: oninput="updatePwywButton(this.value)"
47 + window.onPwywInput = function () {
48 + updatePwywButton(this.value);
49 + };
50 +
51 + // purchase.html: onclick="fetch('/api/cart/{{ item.id }}',{...}).then(...);return false;"
52 + window.onAddToCartLink = function () {
53 + fetch('/api/cart/' + this.getAttribute('data-arg'), {method:'POST',headers:csrfHeaders()}).then(function(r){if(!r.ok)throw new Error('Failed');return r.json()}).then(function(){window.location.href='/cart'}).catch(function(){alert('Could not add to cart. Please try again.')});
54 + };
55 +
56 + // purchase.html: onclick="window.history.back()"
57 + window.historyBack = function () {
58 + window.history.back();
59 + };
60 +
61 + // receipt.html: onclick="window.print()"
62 + window.printPage = function () {
63 + window.print();
64 + };
@@ -0,0 +1,60 @@
1 + /*
2 + * actions-partials.js
3 + *
4 + * Delegated event handlers extracted from inline on* attributes and
5 + * href="javascript:" links in templates/partials/*.html, for CSP compliance
6 + * (lets script-src drop 'unsafe-inline'). The delegated dispatcher lives in
7 + * static/mnw.js; it looks these up by name as globals and invokes them with
8 + * `this` bound to the dispatching element and args from data-arg/data-arg2.
9 + *
10 + * Each function preserves the original inline behavior verbatim.
11 + */
12 +
13 + // No-op verb: lets an element use data-stop/data-prevent modifiers without
14 + // running any action (former onclick="event.stopPropagation();").
15 + window.noop = function () {};
16 +
17 + // link_row.html
18 + window.onMoveLink = function (dir) { moveLink(this, parseInt(dir, 10)); };
19 + window.onEditLink = function () { editLink(this); };
20 + window.onSaveLink = function () { saveLink(this); };
21 + window.onCancelLink = function () { cancelLink(this); };
22 +
23 + // git_nav.html
24 + window.navToValue = function () { if (this.value) window.location.href = this.value; };
25 +
26 + // insertion_list.html
27 + window.insertionsStartUpload = function () { MNW.insertions.startUpload(); };
28 + window.onInsertionFileSelected = function () { MNW.insertions.handleFileSelected(this); };
29 + window.onInsertionRename = function (id) { MNW.insertions.rename(id, this.dataset.title); };
30 +
31 + // promo_codes_list.html and item_license_keys.html (copy to clipboard)
32 + window.copyText = function () { navigator.clipboard.writeText(this.dataset.copy); };
33 +
34 + // promo_codes_list.html (toggle inline edit row)
35 + window.togglePromoEditRow = function () { this.closest('tr').nextElementSibling.classList.toggle('hidden'); };
36 +
37 + // tag_suggestions.html
38 + window.onAddTagSuggestion = function (id) { addTagById(id); this.remove(); };
39 +
40 + // discover_results.html
41 + window.onOpenCollectionPicker = function () { openCollectionPicker(this.dataset.itemId, this); };
42 +
43 + // item_edit_row.html
44 + window.onPriceInput = function () { this.nextElementSibling.value = Math.round(parseFloat(this.value || 0) * 100); };
45 +
46 + // placement_list.html
47 + window.onPlacementPositionChange = function () { MNW.insertions.toggleOffsetInput(this.value); };
48 + window.onAddPlacement = function (id) { MNW.insertions.addPlacement(id); };
49 +
50 + // toast.html
51 + window.dismissToast = function () { this.parentElement.remove(); };
52 +
53 + // slug_status.html
54 + window.applySlugSuggestion = function () {
55 + var i = this.closest('.form-group').querySelector('input[name=slug]');
56 + if (i) { i.value = this.dataset.slug; i.dispatchEvent(new Event('keyup', { bubbles: true })); }
57 + };
58 +
59 + // tip_button.html
60 + window.toggleTipForm = function () { this.nextElementSibling.classList.toggle('hidden'); };
@@ -0,0 +1,213 @@
1 + // actions-tabs.js
2 + // Delegated handler functions extracted from inline on* attributes in
3 + // templates/partials/tabs/**, for CSP compliance (no script-src 'unsafe-inline').
4 + // The dispatcher in mnw.js invokes each named function with `this` === the
5 + // dispatching element and args drawn from data-arg / data-arg2. Bodies are the
6 + // verbatim former inline handlers, adjusted only to read args from the element.
7 +
8 + // --- item_overview.html ---
9 + window.openInNewTab = function (url) {
10 + window.open(url, '_blank');
11 + };
12 +
13 + window.copyItemLink = function (path) {
14 + navigator.clipboard.writeText(window.location.origin + path).then(() => this.textContent = 'Copied!').catch(() => this.textContent = 'Failed');
15 + };
16 +
17 + window.openEmbedSection = function () {
18 + var d = document.querySelector('details.content-section'); if (d) { d.open = true; d.scrollIntoView({ behavior: 'smooth' }); }
19 + };
20 +
21 + // --- item_embed.html ---
22 + window.copyEmbedBtn = function () {
23 + copyEmbed(this);
24 + };
25 +
26 + // --- project_settings.html ---
27 + window.submitProjectInfo = function () {
28 + saveProjectInfo({ preventDefault: function () {} }, this.getAttribute('data-project-id'));
29 + };
30 +
31 + window.submitProjectPricing = function () {
32 + saveProjectPricing({ preventDefault: function () {} }, this.getAttribute('data-project-id'));
33 + };
34 +
35 + // --- item_pricing.html ---
36 + window.togglePwywSettings = function () {
37 + document.getElementById('pwyw-settings').classList.toggle('hidden', !this.checked);
38 + };
39 +
40 + window.toggleCustomLicense = function () {
41 + document.getElementById('dash-custom-license').classList.toggle('hidden', this.value !== 'custom');
42 + };
43 +
44 + window.toggleLicenseKeys = function () {
45 + document.getElementById('license-keys-section').classList.toggle('hidden', !this.checked);
46 + };
47 +
48 + // --- CSV export buttons (project_subscriptions, buyer_contacts, user_payments, user_creator) ---
49 + window.exportCsvButton = function (url, filename) {
50 + this.textContent = 'Exporting...'; this.disabled = true; var btn = this; fetch(url, { method: 'POST', headers: csrfHeaders() }).then(function (r) { return r.blob(); }).then(function (b) { var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = filename; a.click(); btn.textContent = 'Export CSV'; btn.disabled = false; }).catch(function () { btn.textContent = 'Export CSV'; btn.disabled = false; showToast('Export failed'); });
51 + };
52 +
53 + // --- project_synckit.html / user_synckit.html ---
54 + window.syncKitShowSlugFromBtn = function (appId) {
55 + syncKitShowSlugForm(appId, this.dataset.slug);
56 + };
57 +
58 + window.syncKitDeleteFromBtn = function (appId) {
59 + syncKitDeleteApp(appId, this.dataset.name);
60 + };
61 +
62 + // --- project_promotions.html ---
63 + window.onPromoTypeChange = function () {
64 + togglePromoFields(this.value);
65 + };
66 +
67 + window.syncTrialDays = function () {
68 + document.getElementById('pc-trial-days').value = this.value;
69 + };
70 +
71 + // --- user_payments.html ---
72 + window.submitOwnForm = function () {
73 + this.form.requestSubmit();
74 + };
75 +
76 + // --- project_content.html ---
77 + window.deselectAll = function () {
78 + toggleSelectAll(false);
79 + };
80 +
81 + window.toggleSelectAllFromEl = function () {
82 + toggleSelectAll(this.checked);
83 + };
84 +
85 + window.sortContent = function (colIndex, sortType) {
86 + sortContentTable(parseInt(colIndex, 10), sortType);
87 + };
88 +
89 + // --- library_collections.html ---
90 + window.slugifyCollName = function () {
91 + document.getElementById('new-coll-slug').value = this.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
92 + };
93 +
94 + // --- library_purchases.html ---
95 + window.filterLibraryRows = function () {
96 + (function (q) { var rows = document.querySelectorAll('#library-table tbody tr'); q = q.toLowerCase(); rows.forEach(function (r) { r.classList.toggle('hidden', r.textContent.toLowerCase().indexOf(q) === -1); }); })(this.value);
97 + };
98 +
99 + window.copyKeyCode = function (code) {
100 + navigator.clipboard.writeText(code); this.textContent = 'Copied!'; setTimeout(() => this.textContent = code, 1500);
101 + };
102 +
103 + window.toggleContextMenuBtn = function (itemId) {
104 + toggleContextMenu({ stopPropagation: function () {} }, itemId);
105 + };
106 +
107 + window.openCollectionPickerBtn = function (itemId) {
108 + openCollectionPicker(itemId, this);
109 + };
110 +
111 + // --- user_profile.html ---
112 + window.moveLinkUp = function () {
113 + moveLink(this, -1);
114 + };
115 +
116 + window.moveLinkDown = function () {
117 + moveLink(this, 1);
118 + };
119 +
120 + window.editLinkBtn = function () {
121 + editLink(this);
122 + };
123 +
124 + window.saveLinkBtn = function () {
125 + saveLink(this);
126 + };
127 +
128 + window.cancelLinkBtn = function () {
129 + cancelLink(this);
130 + };
131 +
132 + window.copyElementText = function (id) {
133 + navigator.clipboard.writeText(document.getElementById(id).textContent).then(() => { this.textContent = 'Copied'; setTimeout(() => this.textContent = 'Copy', 1500); });
134 + };
135 +
136 + window.copyFeedUrl = function () {
137 + navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy URL', 2000); });
138 + };
139 +
140 + // --- user_media.html ---
141 + window.onMediaFilesChange = function () {
142 + mediaUploadFiles(this.files);
143 + };
144 +
145 + window.mediaFilterAll = function () {
146 + mediaFilterFolder(null, this);
147 + };
148 +
149 + window.mediaFilterFolderBtn = function (folder) {
150 + mediaFilterFolder(folder, this);
151 + };
152 +
153 + window.mediaDeleteFileBtn = function (id) {
154 + mediaDeleteFile(id, this.dataset.filename);
155 + };
156 +
157 + // --- user_settings.html ---
158 + window.setSettingsSectionBtn = function () {
159 + setSettingsSection(this);
160 + };
161 +
162 + // --- item_details.html ---
163 + window.onSearchTagsInput = function () {
164 + searchTags(this.value);
165 + };
166 +
167 + window.syncItemHiddenFields = function () {
168 + document.getElementById('hidden-title').value = document.getElementById('item-name').value; document.getElementById('hidden-desc').value = document.getElementById('item-description').value; document.getElementById('hidden-ai-tier').value = document.getElementById('ai_tier').value; document.getElementById('hidden-ai-disclosure').value = document.getElementById('ai_disclosure').value;
169 + };
170 +
171 + // --- user_creator.html ---
172 + window.toggleTrialDetails = function () {
173 + document.getElementById('trial-details').classList.toggle('hidden', !this.checked);
174 + };
175 +
176 + // --- Non-dispatcher delegated listeners ---
177 + // The mnw.js dispatcher only covers click/change/input/submit. The handlers
178 + // below cover drag-and-drop (user_media) and conditional submit (user_account),
179 + // keyed on data attributes so the inline handlers can be removed.
180 +
181 + // Media upload drop zone (user_media.html): former ondragover/ondragleave/ondrop.
182 + document.addEventListener('dragover', function (e) {
183 + var el = e.target.closest('[data-media-dropzone]');
184 + if (!el) return;
185 + e.preventDefault(); el.classList.add('user-media-upload--dragover');
186 + });
187 + document.addEventListener('dragleave', function (e) {
188 + var el = e.target.closest('[data-media-dropzone]');
189 + if (!el) return;
190 + el.classList.remove('user-media-upload--dragover');
191 + });
192 + document.addEventListener('drop', function (e) {
193 + var el = e.target.closest('[data-media-dropzone]');
194 + if (!el) return;
195 + mediaHandleDrop(e); el.classList.remove('user-media-upload--dragover');
196 + });
197 +
198 + // Conditional submit guard (user_account.html): a former onsubmit that returned
199 + // false to block submission. Runs in capture phase so a false result can stop
200 + // propagation before htmx's submit handler sees the event.
201 + document.addEventListener('submit', function (e) {
202 + var el = e.target.closest('[data-check-submit]');
203 + if (!el) return;
204 + var fn = window[el.getAttribute('data-check-submit')];
205 + if (typeof fn === 'function') {
206 + var r = fn.call(el, e);
207 + if (r === false) { e.preventDefault(); e.stopPropagation(); }
208 + }
209 + }, true);
210 +
211 + window.checkPasswordMatch = function () {
212 + var np = document.getElementById('new-password').value, cp = document.getElementById('confirm-password').value; if (np !== cp) { var s = document.getElementById('password-status'); s.textContent = 'Passwords do not match'; s.classList.add('danger-text'); return false; }
213 + };
@@ -24,3 +24,13 @@ function seek(e) {
24 24 const rect = e.currentTarget.getBoundingClientRect();
25 25 audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
26 26 }
27 +
28 + // Wire the play/seek controls without inline on* handlers (the embed page runs
29 + // under the same CSP that forbids script-src 'unsafe-inline'). This page loads
30 + // only this file, not mnw.js's data-action dispatcher, so bind directly.
31 + document.addEventListener('DOMContentLoaded', function () {
32 + var play = document.getElementById('play');
33 + if (play) play.addEventListener('click', togglePlay);
34 + var bar = document.getElementById('progress-bar');
35 + if (bar) bar.addEventListener('click', seek);
36 + });
@@ -674,3 +674,181 @@ document.addEventListener('click', function(evt) {
674 674 window.prompt('Copy this link:', url);
675 675 }
676 676 });
677 +
678 + /* ===========================================
679 + ACTION DISPATCHER — delegated on* replacement
680 + =========================================== *
681 + * Lets templates drop inline on* handlers so the CSP can drop
682 + * script-src 'unsafe-inline'. An element declares behavior via a data-*
683 + * attribute whose value is either a built-in verb or a global function name:
684 + *
685 + * data-action -> click
686 + * data-change -> change
687 + * data-input -> input
688 + * data-submit -> submit (preventDefault is automatic for submit)
689 + *
690 + * Modifiers (presence of the attribute): data-prevent -> preventDefault,
691 + * data-stop -> stopPropagation. Applied before the verb runs.
692 + *
693 + * Built-in verbs operate on data-target (one or more element ids, space
694 + * separated); data-href supplies a URL:
695 + * show / hide / toggle -> classList remove/add/toggle 'hidden' on target(s)
696 + * click -> .click() the target(s)
697 + * remove -> .remove() the target(s)
698 + * remove-self -> .remove() the dispatching element
699 + * nav -> window.location.href = data-href
700 + *
701 + * Any other value is treated as a global function name and invoked as
702 + * fn.apply(element, args) with `this` === the element and args drawn from
703 + * data-arg / data-arg2 (both optional). So a former `fn()` becomes
704 + * data-action="fn"; `fn('x')` becomes data-action="fn" data-arg="x"; and a
705 + * former `fn(this)` becomes data-action="fn" with fn reading `this`.
706 + */
707 + (function () {
708 + function targets(el) {
709 + var raw = el.getAttribute('data-target');
710 + if (!raw) return [];
711 + return raw.split(/\s+/).filter(Boolean).map(function (id) {
712 + return document.getElementById(id);
713 + }).filter(Boolean);
714 + }
715 +
716 + var BUILTINS = {
717 + show: function (el) { targets(el).forEach(function (t) { t.classList.remove('hidden'); }); },
718 + hide: function (el) { targets(el).forEach(function (t) { t.classList.add('hidden'); }); },
719 + toggle: function (el) { targets(el).forEach(function (t) { t.classList.toggle('hidden'); }); },
720 + click: function (el) { targets(el).forEach(function (t) { t.click(); }); },
721 + remove: function (el) { targets(el).forEach(function (t) { t.remove(); }); },
722 + 'remove-self': function (el) { el.remove(); }
723 + };
724 +
725 + function run(el, verb, evt) {
726 + if (el.hasAttribute('data-prevent')) evt.preventDefault();
727 + if (el.hasAttribute('data-stop')) evt.stopPropagation();
728 + if (verb === 'nav') {
729 + var href = el.getAttribute('data-href');
730 + if (href) window.location.href = href;
731 + return;
732 + }
733 + if (BUILTINS[verb]) { BUILTINS[verb](el, evt); return; }
734 + var fn = window[verb];
735 + if (typeof fn !== 'function') {
736 + if (window.console) console.warn('data-action: no global function', verb);
737 + return;
738 + }
739 + var args = [];
740 + if (el.hasAttribute('data-arg')) args.push(el.getAttribute('data-arg'));
741 + if (el.hasAttribute('data-arg2')) args.push(el.getAttribute('data-arg2'));
742 + fn.apply(el, args);
743 + }
744 +
745 + function listen(eventName, attr, autoPrevent) {
746 + document.addEventListener(eventName, function (evt) {
747 + var el = evt.target.closest('[' + attr + ']');
748 + if (!el) return;
749 + if (autoPrevent) evt.preventDefault();
750 + run(el, el.getAttribute(attr), evt);
751 + });
752 + }
753 +
754 + listen('click', 'data-action', false);
755 + listen('change', 'data-change', false);
756 + listen('input', 'data-input', false);
757 + listen('submit', 'data-submit', true);
758 + })();
759 +
760 + /* ===========================================
761 + HTMX CALLBACK DISPATCHER — hx-on:: replacement
762 + =========================================== *
763 + * Replaces inline `hx-on::after-request` / `hx-on:htmx:config-request`
764 + * attributes (which are inline scripts and would keep script-src pinned to
765 + * 'unsafe-inline') with delegated listeners driven by data attributes.
766 + *
767 + * On the element that fired the request (evt.detail.elt):
768 + * data-hx-always behavior name that runs regardless of outcome
769 + * data-hx-always-click=ID .click() that element id regardless of outcome
770 + * ...and, only when the request succeeded (evt.detail.successful):
771 + * data-hx-reset this.reset() (form)
772 + * data-hx-click=ID .click() the element with that id
773 + * data-hx-get=URL + data-hx-target=SEL htmx.ajax('GET', URL, SEL)
774 + * data-hx-nav=URL window.location.href = URL
775 + * data-hx-reload window.location.reload()
776 + * data-hx-toast=MSG (+ data-hx-toast-type) showToast(MSG, type)
777 + * data-hx-behavior=NAME run a named behavior (custom multi-step cases)
778 + *
779 + * configRequest (before send):
780 + * data-hx-config="publish-at-iso" normalize publish_at to ISO 8601
781 + */
782 + (function () {
783 + function byId(id) { return id && document.getElementById(id); }
784 +
785 + function refreshProfileTab() {
786 + var t = document.getElementById('settings-body');
787 + if (t) {
788 + htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
789 + } else {
790 + var b = document.getElementById('tab-profile');
791 + if (b) b.click();
792 + }
793 + }
794 +
795 + var BEHAVIORS = {
796 + 'mark-added': function (el) { el.textContent = 'Added'; el.disabled = true; },
797 + // Faithful to the original inline handler: the row fades only on success,
798 + // but the label flips to 'Added' unconditionally. Registered under
799 + // data-hx-always so it sees both outcomes.
800 + 'fade-row-added': function (el, evt) {
801 + if (evt.detail.successful) {
802 + var tr = el.closest('tr');
803 + if (tr) tr.classList.add('is-faded');
804 + }
805 + el.textContent = 'Added';
806 + },
807 + 'refresh-profile': function () { refreshProfileTab(); },
808 + 'refresh-profile-if-verified': function (el, evt) {
809 + if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
810 + refreshProfileTab();
811 + }
812 + }
813 + };
814 +
815 + document.body.addEventListener('htmx:afterRequest', function (evt) {
816 + var el = evt.detail.elt;
817 + if (!el || !el.getAttribute) return;
818 +
819 + var always = el.getAttribute('data-hx-always');
820 + if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
821 + if (el.hasAttribute('data-hx-always-click')) {
822 + var ac = byId(el.getAttribute('data-hx-always-click'));
823 + if (ac) ac.click();
824 + }
825 +
826 + if (!evt.detail.successful) return;
827 +
828 + if (el.hasAttribute('data-hx-reset') && typeof el.reset === 'function') el.reset();
829 + if (el.hasAttribute('data-hx-click')) {
830 + var t = byId(el.getAttribute('data-hx-click'));
831 + if (t) t.click();
832 + }
833 + if (el.hasAttribute('data-hx-get')) {
834 + var url = el.getAttribute('data-hx-get');
835 + var target = el.getAttribute('data-hx-target');
836 + if (url && target) htmx.ajax('GET', url, target);
837 + }
838 + if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav');
839 + if (el.hasAttribute('data-hx-reload')) window.location.reload();
840 + if (el.hasAttribute('data-hx-toast')) {
841 + showToast(el.getAttribute('data-hx-toast'), el.getAttribute('data-hx-toast-type') || 'info');
842 + }
843 + var beh = el.getAttribute('data-hx-behavior');
844 + if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
845 + });
846 +
847 + document.body.addEventListener('htmx:configRequest', function (evt) {
848 + var el = evt.detail.elt;
849 + if (el && el.getAttribute && el.getAttribute('data-hx-config') === 'publish-at-iso') {
850 + var v = evt.detail.parameters.publish_at;
851 + if (v) evt.detail.parameters.publish_at = new Date(v).toISOString();
852 + }
853 + });
854 + })();
@@ -35,8 +35,8 @@
35 35 <a href="/changelog">Changelog</a>
36 36 <a href="mailto:info@makenot.work">Contact</a>
37 37 <a href="/health" title="Service status and uptime">Status</a>
38 - <a href="#" onclick="event.preventDefault(); showWhatsNewModal();">What's new</a>
39 - <a href="#" onclick="event.preventDefault(); toggleShortcutsHelp();" title="Keyboard shortcuts (?)">Shortcuts</a>
38 + <a href="#" data-prevent data-action="showWhatsNewModal">What's new</a>
39 + <a href="#" data-prevent data-action="toggleShortcutsHelp" title="Keyboard shortcuts (?)">Shortcuts</a>
40 40 </div>
41 41 <p>&copy; 2026 Make Creative, LLC</p>
42 42 </footer>
@@ -44,7 +44,14 @@
44 44 <!-- Toast notification container -->
45 45 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
46 46
47 - <script src="/static/mnw.js?v=0531"></script>
47 + <script src="/static/mnw.js?v=0701"></script>
48 + <!-- Delegated on* handlers, extracted from inline attributes so the CSP can
49 + drop script-src 'unsafe-inline'. Loaded globally after mnw.js (which
50 + defines the data-action dispatcher). -->
51 + <script src="/static/actions-pages.js?v=0701"></script>
52 + <script src="/static/actions-partials.js?v=0701"></script>
53 + <script src="/static/actions-tabs.js?v=0701"></script>
54 + <script src="/static/actions-dashboards.js?v=0701"></script>
48 55 <script src="/static/carousel.js?v=0605"></script>
49 56 <script src="/static/collections.js?v=0514"></script>
50 57 <script src="/static/synckit-billing.js?v=0620"></script>
@@ -86,7 +86,7 @@
86 86 </section>
87 87
88 88 <form class="cp-reset" method="post" action="{{ base_path }}/reset"
89 - onsubmit="return confirm('Reset this page to the platform default? This clears your custom HTML and CSS.');">
89 + data-submit="confirmResetPage">
90 90 {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
91 91 <button type="submit" class="btn-danger">Reset to default</button>
92 92 </form>
@@ -10,7 +10,7 @@
10 10 <div class="breadcrumb">
11 11 <a href="/dashboard">Dashboard</a> /
12 12 <a href="/dashboard/project/{{ project_slug }}">{{ project_slug }}</a> /
13 - <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)">Blog</a> /
13 + <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav">Blog</a> /
14 14 {% if editing %}Edit{% else %}New Post{% endif %}
15 15 </div>
16 16
@@ -37,7 +37,7 @@
37 37 <div class="form-group">
38 38 <label for="post-body">Content (Markdown)</label>
39 39 <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea>
40 - <button type="button" class="btn-secondary blog-editor-media-btn" onclick="mediaPickerOpen('post-body')">Insert Image</button>
40 + <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button>
41 41 </div>
42 42 {% if is_changelog_project %}
43 43 <div class="form-group blog-editor-landing-toggle">
@@ -51,7 +51,7 @@
51 51 <div class="blog-editor-actions">
52 52 <button class="btn-primary" id="save-draft-btn">{% if editing %}Save as Draft{% else %}Save Draft{% endif %}</button>
53 53 <button class="btn-secondary" id="publish-btn">{% if editing && post_is_published %}Update{% else %}Publish{% endif %}</button>
54 - <a href="/dashboard/project/{{ project_slug }}" onclick="setTimeout(function(){document.getElementById('tab-blog').click()},100)">
54 + <a href="/dashboard/project/{{ project_slug }}" data-action="blogTabNav">
55 55 <button class="btn-secondary" type="button">Cancel</button>
56 56 </a>
57 57 </div>
@@ -17,7 +17,7 @@
17 17 <h1 class="page-title">Export Your Data</h1>
18 18 <p class="subtitle">Download your content, projects, and transaction history.</p>
19 19 </div>
20 - <button class="btn-primary export-all-btn" id="export-all-btn" onclick="exportAll()">
20 + <button class="btn-primary export-all-btn" id="export-all-btn" data-action="exportAll">
21 21 Export All
22 22 </button>
23 23 </header>
@@ -33,7 +33,7 @@
33 33 <span>{{ item.sales_count }} sales</span>
34 34 <button class="btn-secondary small dashboard-item-share-btn"
35 35 title="Copy item ID for use in embeds or integrations"
36 - onclick="navigator.clipboard.writeText('{{ item.id }}'); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy Item ID', 1500);">Copy Item ID</button>
36 + data-action="onCopyItemId" data-arg="{{ item.id }}">Copy Item ID</button>
37 37 </div>
38 38 </header>
39 39
@@ -48,7 +48,7 @@
48 48 hx-target="#tab-content"
49 49 hx-swap="innerHTML"
50 50 hx-indicator="#tab-spinner"
51 - onclick="setActiveTab(this)">Overview</button>
51 + data-action="onSetActiveTab">Overview</button>
52 52 <button class="tab"
53 53 role="tab"
54 54 aria-selected="false"
@@ -59,7 +59,7 @@
59 59 hx-target="#tab-content"
60 60 hx-swap="innerHTML"
61 61 hx-indicator="#tab-spinner"
62 - onclick="setActiveTab(this)">Details</button>
62 + data-action="onSetActiveTab">Details</button>
63 63 <button class="tab"
64 64 role="tab"
65 65 aria-selected="false"
@@ -70,7 +70,7 @@
70 70 hx-target="#tab-content"
71 71 hx-swap="innerHTML"
72 72 hx-indicator="#tab-spinner"
73 - onclick="setActiveTab(this)">Pricing</button>
73 + data-action="onSetActiveTab">Pricing</button>
74 74 {% if item.item_type != "bundle" %}
75 75 <button class="tab"
76 76 role="tab"
@@ -82,7 +82,7 @@
82 82 hx-target="#tab-content"
83 83 hx-swap="innerHTML"
84 84 hx-indicator="#tab-spinner"
85 - onclick="setActiveTab(this)">Files</button>
85 + data-action="onSetActiveTab">Files</button>
86 86 {% endif %}
87 87 <button class="tab"
88 88 role="tab"
@@ -94,7 +94,7 @@
94 94 hx-target="#tab-content"
95 95 hx-swap="innerHTML"
96 96 hx-indicator="#tab-spinner"
97 - onclick="setActiveTab(this)">Sales</button>
97 + data-action="onSetActiveTab">Sales</button>
98 98 <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
99 99 </div>
100 100
@@ -40,7 +40,7 @@
40 40 hx-target="#tab-content"
41 41 hx-swap="innerHTML"
42 42 hx-indicator="#tab-spinner"
43 - onclick="setActiveTab(this)">Overview</button>
43 + data-action="onSetActiveTab">Overview</button>
44 44 <button class="tab"
45 45 role="tab"
46 46 aria-selected="false"
@@ -51,7 +51,7 @@
51 51 hx-target="#tab-content"
52 52 hx-swap="innerHTML"
53 53 hx-indicator="#tab-spinner"
54 - onclick="setActiveTab(this)">Content</button>
54 + data-action="onSetActiveTab">Content</button>
55 55 <button class="tab"
56 56 role="tab"
57 57 aria-selected="false"
@@ -62,7 +62,7 @@
62 62 hx-target="#tab-content"
63 63 hx-swap="innerHTML"
64 64 hx-indicator="#tab-spinner"
65 - onclick="setActiveTab(this)">Analytics</button>
65 + data-action="onSetActiveTab">Analytics</button>
66 66 <button class="tab"
67 67 role="tab"
68 68 aria-selected="false"
@@ -73,7 +73,7 @@
73 73 hx-target="#tab-content"
74 74 hx-swap="innerHTML"
75 75 hx-indicator="#tab-spinner"
76 - onclick="setActiveTab(this)">Monetization</button>
76 + data-action="onSetActiveTab">Monetization</button>
77 77 {% if git_enabled %}
78 78 <button class="tab"
79 79 role="tab"
@@ -85,7 +85,7 @@
85 85 hx-target="#tab-content"
86 86 hx-swap="innerHTML"
87 87 hx-indicator="#tab-spinner"
88 - onclick="setActiveTab(this)">Code</button>
88 + data-action="onSetActiveTab">Code</button>
89 89 {% endif %}
90 90 {% if synckit_enabled %}
91 91 <button class="tab"
@@ -98,7 +98,7 @@
98 98 hx-target="#tab-content"
99 99 hx-swap="innerHTML"
100 100 hx-indicator="#tab-spinner"
101 - onclick="setActiveTab(this)">Cloud Sync</button>
101 + data-action="onSetActiveTab">Cloud Sync</button>
102 102 {% endif %}
103 103 <button class="tab"
104 104 role="tab"
@@ -110,7 +110,7 @@
110 110 hx-target="#tab-content"
111 111 hx-swap="innerHTML"
112 112 hx-indicator="#tab-spinner"
113 - onclick="setActiveTab(this)">Settings</button>
113 + data-action="onSetActiveTab">Settings</button>
114 114 <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
115 115 </div>
116 116
@@ -21,7 +21,7 @@
21 21 <p>You can:</p>
22 22 <div class="account-status-actions">
23 23 <form hx-post="/api/users/me/reactivate" hx-swap="none"
24 - onsubmit="setTimeout(function(){ window.location.reload(); }, 500)">
24 + data-submit="reactivateReload">
25 25 <button type="submit" class="btn-primary">Reactivate Account</button>
26 26 </form>
27 27 <a href="/dashboard/export" class="button">Export Data</a>
@@ -83,7 +83,7 @@
83 83 hx-target="#tab-content"
84 84 hx-swap="innerHTML"
85 85 hx-indicator="#tab-spinner"
86 - onclick="setActiveTab(this)">Support</button>
86 + data-action="onSetActiveTab">Support</button>
87 87 <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span>
88 88 </div>
89 89
@@ -106,7 +106,7 @@
106 106 hx-target="#tab-content"
107 107 hx-swap="innerHTML"
108 108 hx-indicator="#tab-spinner"
109 - onclick="setActiveTab(this)">Projects</button>
109 + data-action="onSetActiveTab">Projects</button>
110 110 {% endif %}{% endif %}
111 111 <button class="tab{% if let Some(su) = session_user %}{% if !su.can_create_projects %} is-selected{% endif %}{% endif %}"
112 112 role="tab"
@@ -118,7 +118,7 @@
118 118 hx-target="#tab-content"
119 119 hx-swap="innerHTML"
120 120 hx-indicator="#tab-spinner"
121 - onclick="setActiveTab(this)">Payments</button>
121 + data-action="onSetActiveTab">Payments</button>
122 122 {% if let Some(su) = session_user %}{% if su.can_create_projects %}
123 123 <button class="tab"
124 124 role="tab"
@@ -130,7 +130,7 @@
130 130 hx-target="#tab-content"
131 131 hx-swap="innerHTML"
132 132 hx-indicator="#tab-spinner"
133 - onclick="setActiveTab(this)">Analytics</button>
133 + data-action="onSetActiveTab">Analytics</button>
134 134 {% endif %}{% endif %}
135 135 <button class="tab"
136 136 role="tab"
@@ -142,7 +142,7 @@
142 142 hx-target="#tab-content"
143 143 hx-swap="innerHTML"
144 144 hx-indicator="#tab-spinner"
145 - onclick="setActiveTab(this)">Settings</button>
145 + data-action="onSetActiveTab">Settings</button>
146 146 <button class="tab"
147 147 role="tab"
148 148 aria-selected="false"
@@ -153,7 +153,7 @@
153 153 hx-target="#tab-content"
154 154 hx-swap="innerHTML"
155 155 hx-indicator="#tab-spinner"
156 - onclick="setActiveTab(this)">Support</button>
156 + data-action="onSetActiveTab">Support</button>
157 157 <span id="tab-spinner" class="htmx-indicator tab-spinner" aria-live="polite"> Loading...</span>
158 158 </div>
159 159
@@ -54,8 +54,8 @@ body {
54 54 </div>
55 55 <div class="creator">by {{ creator_display_name }}</div>
56 56 <div class="controls">
57 - <button class="play-btn" id="play" onclick="togglePlay()">&#9654;</button>
58 - <div class="progress-bar" id="progress-bar" onclick="seek(event)">
57 + <button class="play-btn" id="play">&#9654;</button>
58 + <div class="progress-bar" id="progress-bar">
59 59 <div class="progress-fill" id="progress"></div>
60 60 </div>
61 61 <span class="time" id="time">0:00</span>
@@ -38,7 +38,7 @@
38 38 <input type="number" id="pwyw_amount" value="{{ suggested_price }}" min="{{ pwyw_min_dollars }}" step="0.01">
39 39 </div>
40 40 {% endif %}
41 - <button class="buy-btn" id="buy-btn" onclick="buy()">Buy Now — {{ item.price }}</button>
41 + <button class="buy-btn" id="buy-btn" data-action="buy">Buy Now — {{ item.price }}</button>
42 42 {% endif %}
43 43
44 44 <div class="note">
@@ -124,7 +124,7 @@
124 124 <p>This creator hasn't set up payments yet. You can keep these items in your cart for later, or remove them.</p>
125 125 <button type="button" class="btn-secondary small"
126 126 data-seller-name="{{ group.seller_username }}"
127 - onclick="removeCartGroup(this)">Remove all from {{ group.seller_username }}</button>
127 + data-action="onRemoveCartGroup">Remove all from {{ group.seller_username }}</button>
128 128 </div>
129 129 {% endif %}
130 130 </div>
@@ -158,7 +158,7 @@
158 158 <td>
159 159 <button class="btn-primary small cart-row-btn"
160 160 hx-post="/api/cart/{{ item.item_id }}"
161 - hx-on::after-request="if(event.detail.successful) this.closest('tr').classList.add('is-faded'); this.textContent='Added'">Add to Cart</button>
161 + data-hx-always="fade-row-added">Add to Cart</button>
162 162 </td>
163 163 </tr>
164 164 {% endfor %}
@@ -85,9 +85,9 @@
85 85 <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span>
86 86 {% if session_user.is_some() && !tg.id.is_empty() %}
87 87 {% if tg.following %}
88 - <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" onclick="event.stopPropagation();">Following</button>
88 + <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button>
89 89 {% else %}
90 - <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" onclick="event.stopPropagation();">Follow</button>
90 + <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button>
91 91 {% endif %}
92 92 {% endif %}
93 93 </span>
@@ -151,7 +151,7 @@
151 151 {% endif %}
152 152 </aside>
153 153
154 - <button type="button" class="discover-filter-toggle" onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');" aria-label="Toggle filters">
154 + <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters">
155 155 Filters{% if active_filter_count > 0 %} <span class="filter-count">{{ active_filter_count }}</span>{% endif %}
156 156 </button>
157 157
@@ -206,7 +206,7 @@
206 206 <div class="tier-section">
207 207 <h2 class="section-label">Stay in the loop</h2>
208 208 <p class="landing-prose mb-4">Get notified when something ships.</p>
209 - <form class="notify-form" id="notify-form" onsubmit="return submitNotify(event)">
209 + <form class="notify-form" id="notify-form" data-submit="onNotifySubmit">
210 210 <input type="email" name="email" placeholder="you@example.com" required class="notify-input" aria-label="Email address">
211 211 <button type="submit" class="btn-primary">Notify Me</button>
212 212 </form>
@@ -196,17 +196,17 @@
196 196 {% if !is_owner %}
197 197 <div class="action-row">
198 198 <button class="btn-secondary full-width-btn" id="cart-btn"
199 - onclick="toggleCart('{{ item.id }}')">{% if in_cart %}In Cart{% else %}Add to Cart{% endif %}</button>
199 + data-action="toggleCart" data-arg="{{ item.id }}">{% if in_cart %}In Cart{% else %}Add to Cart{% endif %}</button>
200 200 </div>
201 201 <div class="action-row-tight">
202 202 <button class="btn-secondary full-width-btn" id="wishlist-btn"
203 - onclick="toggleWishlist('{{ item.id }}')">{% if is_wishlisted %}Wishlisted{% else %}Add to Wishlist{% endif %}</button>
203 + data-action="toggleWishlist" data-arg="{{ item.id }}">{% if is_wishlisted %}Wishlisted{% else %}Add to Wishlist{% endif %}</button>
204 204 </div>
205 205 {% endif %}
206 206 <div class="action-row-tight collection-picker-anchor">
207 207 <button class="btn-secondary full-width-btn{% if collection_count > 0 %} saved{% endif %}"
208 208 data-collection-trigger data-item-id="{{ item.id }}" data-collection-label
209 - onclick="openCollectionPicker('{{ item.id }}', this)">{% if collection_count > 0 %}Saved ({{ collection_count }}){% else %}Save to collection{% endif %}</button>
209 + data-action="onOpenCollectionPicker" data-arg="{{ item.id }}">{% if collection_count > 0 %}Saved ({{ collection_count }}){% else %}Save to collection{% endif %}</button>
210 210 </div>
211 211 {% endif %}
212 212 </div>
@@ -231,7 +231,7 @@
231 231 {% for section in sections %}
232 232 <button class="section-tab{% if loop.first %} is-selected{% endif %}"
233 233 data-tab="section-{{ section.slug }}"
234 - onclick="switchSectionTab(this, 'section-{{ section.slug }}')">{{ section.title }}</button>
234 + data-action="onSwitchSectionTab" data-arg="section-{{ section.slug }}">{{ section.title }}</button>
235 235 {% endfor %}
236 236 </div>
237 237 {% for section in sections %}
@@ -312,7 +312,7 @@
312 312 {% endif %}
313 313 <a href="/i/{{ item.id }}" data-copy-link>Copy link</a> &middot;
314 314 {% if session_user.is_some() %}
315 - <a href="javascript:void(0)" onclick="document.getElementById('report-modal').classList.remove('hidden')">Report this item</a> &middot;
315 + <a href="#" data-prevent data-action="show" data-target="report-modal">Report this item</a> &middot;
316 316 {% else %}
317 317 <a href="/login">Report this item</a> &middot;
318 318 {% endif %}