domma-cms 1.6.34 → 1.6.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effects-builder.js +1 -1
  3. package/admin/js/lib/pages-arrange.js +1 -1
  4. package/admin/js/lib/scribe-composer.js +1 -1
  5. package/admin/js/lib/scribe-model.js +1 -1
  6. package/admin/js/lib/shortcode-preview.js +1 -1
  7. package/admin/js/lib/shortcode-registry.js +2 -2
  8. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  9. package/admin/js/templates/page-editor.html +2 -1
  10. package/admin/js/templates/pages.html +1 -1
  11. package/admin/js/templates/theme.html +1 -1
  12. package/admin/js/views/pages.js +3 -3
  13. package/admin/js/views/theme.js +5 -5
  14. package/package.json +1 -1
  15. package/plugins/_lib/call/ice.js +5 -2
  16. package/plugins/_lib/call/rooms.js +9 -3
  17. package/plugins/_lib/call/routes.js +7 -4
  18. package/plugins/_lib/mail/defaults.js +12 -0
  19. package/plugins/_lib/mail/hostGuard.js +86 -0
  20. package/plugins/_lib/mail/readRoutes.js +20 -1
  21. package/plugins/_lib/outreach/audience.js +76 -0
  22. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  23. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  24. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  25. package/plugins/_lib/outreach/expiry.js +46 -0
  26. package/plugins/_lib/outreach/index.js +15 -0
  27. package/plugins/_lib/outreach/ledger.js +127 -0
  28. package/plugins/_lib/outreach/sending.js +121 -0
  29. package/plugins/_lib/outreach/suppression.js +62 -0
  30. package/plugins/_lib/outreach/tokens.js +35 -0
  31. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  32. package/plugins/_lib/public/call/call.js +3 -1
  33. package/plugins/blog/CLAUDE.md +11 -1
  34. package/plugins/blog/docs/guide.md +1 -1
  35. package/plugins/blog/lib/page.js +32 -0
  36. package/plugins/blog/plugin.js +58 -16
  37. package/plugins/blog/plugin.json +2 -2
  38. package/plugins/blog/plugin.public.js +5 -4
  39. package/plugins/blog/tests/public.test.js +30 -1
  40. package/plugins/booking/CLAUDE.md +4 -1
  41. package/plugins/booking/docs/guide.md +1 -1
  42. package/plugins/booking/plugin.js +16 -21
  43. package/plugins/booking/plugin.json +2 -2
  44. package/plugins/booking/tests/api.test.js +26 -1
  45. package/plugins/feedback/CLAUDE.md +7 -2
  46. package/plugins/feedback/admin/views/feedback.js +1 -1
  47. package/plugins/feedback/lib/receiver.js +1 -1
  48. package/plugins/feedback/plugin.json +3 -3
  49. package/plugins/free-tier.lock.json +63 -51
  50. package/plugins/mail-reader/CLAUDE.md +6 -0
  51. package/plugins/mail-reader/docs/guide.md +1 -0
  52. package/plugins/mail-reader/plugin.json +2 -2
  53. package/plugins/shopping-cart/CLAUDE.md +62 -1
  54. package/plugins/shopping-cart/admin/css/index.css +16 -0
  55. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  56. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  57. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  58. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  59. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  60. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  61. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  62. package/plugins/shopping-cart/config.js +1 -0
  63. package/plugins/shopping-cart/docs/guide.md +26 -1
  64. package/plugins/shopping-cart/inject/body.html +2 -2
  65. package/plugins/shopping-cart/lib/accounts.js +185 -0
  66. package/plugins/shopping-cart/lib/commission.js +90 -0
  67. package/plugins/shopping-cart/lib/extensions.js +12 -4
  68. package/plugins/shopping-cart/lib/payments.js +7 -4
  69. package/plugins/shopping-cart/lib/render.js +13 -1
  70. package/plugins/shopping-cart/lib/sales.js +193 -0
  71. package/plugins/shopping-cart/lib/shop.js +21 -3
  72. package/plugins/shopping-cart/plugin.js +180 -35
  73. package/plugins/shopping-cart/plugin.json +3 -3
  74. package/plugins/shopping-cart/plugin.public.js +17 -1
  75. package/plugins/shopping-cart/public/account.js +277 -0
  76. package/plugins/shopping-cart/public/cart.js +16 -0
  77. package/plugins/shopping-cart/public/lib/session.js +82 -0
  78. package/plugins/shopping-cart/public/shop.css +27 -0
  79. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  80. package/plugins/shopping-cart/tests/accounts.test.js +136 -0
  81. package/plugins/shopping-cart/tests/api.test.js +34 -0
  82. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  83. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  84. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  85. package/plugins/support/CLAUDE.md +6 -0
  86. package/plugins/support/admin/views/support.js +1 -1
  87. package/plugins/support/docs/guide.md +90 -0
  88. package/plugins/support/lib/receiver.js +1 -1
  89. package/plugins/support/plugin.json +3 -3
  90. package/public/css/site.css +1 -1
  91. package/public/js/effects.js +1 -1
  92. package/public/js/scribe-style.mjs +1 -1
  93. package/public/js/site.js +1 -1
  94. package/public/js/text-style.mjs +1 -1
  95. package/server/routes/api/analytics.js +32 -0
  96. package/server/routes/api/collections.js +148 -15
  97. package/server/routes/api/theme.js +12 -2
  98. package/server/routes/outreach-public.js +68 -0
  99. package/server/server.js +10 -1
  100. package/server/services/authChecks.js +8 -0
  101. package/server/services/collectionAccess.js +191 -0
  102. package/server/services/collections.js +8 -2
  103. package/server/services/markdown.js +7 -2
  104. package/server/services/outreachUnsubscribe.js +54 -0
  105. package/server/services/request-log.js +70 -0
  106. package/server/templates/page.html +3 -3
@@ -1,4 +1,4 @@
1
- import{api as K}from"../api.js";import{SHAPES as be,whileLoading as ye}from"../lib/loading.js";import{addBannerAction as we}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ke,openMenuAt as Z,settingsStore as xe,sidebarCount as Me,viewLifetime as Pe}from"../lib/tool-kit.js";import{filterByProject as Ee,getProjectFromHash as Se}from"../lib/project-context.js";import{arrangePages as Ce,checkPrune as $e,normaliseSettings as ee,pageRow as Te,statusCounts as Le,treeRows as Ae}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,De="#/pages",P="pages.prune.draft",je=`
1
+ import{api as y}from"../api.js";import{SHAPES as ke,whileLoading as Pe}from"../lib/loading.js";import{addBannerAction as Me}from"../lib/plugin-chrome.js";import{helpPopovers as Z}from"../lib/help-popover.js";import{onceClosed as Ee,openMenuAt as ee,settingsStore as xe,sidebarCount as te,viewLifetime as Se}from"../lib/tool-kit.js";import{filterByProject as Ce,getProjectFromHash as $e}from"../lib/project-context.js";import{arrangePages as Te,checkPrune as Le,normaliseSettings as ae,pageRow as De,statusCounts as Ae,treeRows as Re}from"../lib/pages-arrange.js";const se=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,ne="#/pages",C="pages.prune.draft",je=`
2
2
  <div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
4
4
  <span class="pgx-chips">
@@ -9,7 +9,7 @@ import{api as K}from"../api.js";import{SHAPES as be,whileLoading as ye}from"../l
9
9
  <span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
10
10
  </span>
11
11
  <button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
12
- </div>`,y=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Re(l){const o=document.createElement("template");return o.innerHTML=l.trim(),o.content.firstElementChild}const ae=xe("pages.settings",ee),s=Pe(te),Be=l=>`#/pages/edit${l}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(l){s.reset();const o=l.get(0).querySelector(".pgx")||l.get(0),C=()=>te.test(location.hash)&&o.isConnected,d=Se(),$=ae.read(),r=M.observable([]),q=M.observable(0),H=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),N=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const A=M.observable([]);s.add(M.effect(()=>{A.value=Ce(r.value,{status:p.value,query:N.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=A.value.map(e=>Te(e))}));const ne=M.computed(()=>Le(r.value)),se=M.computed(()=>r.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=q.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>r.peek().find(t=>t.urlPath===e);async function re(){try{const e=await ye(o.querySelector(".dm-loading-slot"),K.pages.list(),be.rows);if(!C())return;const t=Array.isArray(e)?e:[],a=d?Ee(t,d):t;q.value=t.length-a.length,r.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{H.value=!1}}const w=e=>{location.hash=Be(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),r.value=r.peek().filter(a=>a.urlPath!==e.urlPath),Me(De)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const D=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,v=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>$e(v.value,a)),Y={keep:v,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(v.peek()),G=()=>String(v.peek())!==he||!!t,i=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Re(`
12
+ </div>`,w=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Be(l){const r=document.createElement("template");return r.innerHTML=l.trim(),r.content.firstElementChild}const oe=xe("pages.settings",ae),o=Se(se),Ke=l=>`#/pages/edit${l}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(l){o.reset();const r=l.get(0).querySelector(".pgx")||l.get(0),$=()=>se.test(location.hash)&&r.isConnected,u=$e(),T=oe.read(),n=M.observable([]),H=M.observable(0),N=M.observable(!0),L=M.observable(""),p=M.observable(T.status),D=M.observable(T.sort),m=M.observable(T.mode),U=M.observable("");o.add(M.effect(()=>oe.write(ae({status:p.value,sort:D.value,mode:m.value}))));const A=M.observable([]);o.add(M.effect(()=>{A.value=Te(n.value,{status:p.value,query:U.value,sort:D.value})}));const _=M.observable([]);o.add(M.effect(()=>{_.value=A.value.map(e=>De(e))}));const re=M.computed(()=>Ae(n.value)),ie=M.computed(()=>n.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),le=M.computed(()=>{const e=H.value;return!u||!e?"":`Showing only pages in ${u}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),F=e=>n.peek().find(t=>t.urlPath===e);async function ce(){try{const e=await Pe(r.querySelector(".dm-loading-slot"),y.pages.list(),ke.rows);if(!$())return;const t=Array.isArray(e)?e:[],a=u?Ce(t,u):t;H.value=t.length-a.length,n.value=a,L.value=""}catch(e){L.value=`Could not load the pages: ${e.message||e}`}finally{N.value=!1}}const k=e=>{location.hash=Ke(e)},de=()=>{location.hash=u?`#/pages/new?project=${encodeURIComponent(u)}`:"#/pages/new"};async function ue(e){const t=e.plugin?`"${w(e.title||e.urlPath)}" is managed by the <strong>${w(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${w(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await y.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),n.value=n.peek().filter(a=>a.urlPath!==e.urlPath),te(ne)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function pe(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}async function R(e,t){if(e.status!==t)try{const a=await y.pages.get(e.urlPath);await y.pages.update(e.urlPath,{frontmatter:{status:t},body:a.content||""}),n.value=n.peek().map(c=>c.urlPath===e.urlPath?{...c,status:t}:c),E.toast(t==="published"?"Page published.":t==="review"?"Page moved to review.":"Page moved to drafts.",{type:"success"}),te(ne)}catch(a){E.toast(`Could not change page status: ${a.message||a}`,{type:"error"})}}let d=null;const j=new Set;let V=!1;function O(e,t=null){const a=e.versionCount||0,c=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Le(c.value,a)),G={keep:c,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const s=f.value;return s.error?"":s.deletes?`Deletes the ${s.deletes} oldest, keeps the newest ${Math.min(s.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},ve=String(c.peek()),J=()=>String(c.peek())!==ve||!!t,i=E.slideover({title:`Trim revisions - ${w(e.title||e.urlPath)}`,size:"sm",position:"right"}),g=Be(`
13
13
  <div class="pgx-form">
14
14
  <p class="pgx-note" data-bind-text="intro"></p>
15
15
  <div>
@@ -25,4 +25,4 @@ import{api as K}from"../api.js";import{SHAPES as be,whileLoading as ye}from"../l
25
25
  <button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
26
26
  <span data-icon="archive" data-icon-size="13"></span> Trim</button>
27
27
  </div>
28
- </div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),r.value=r.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,i.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}i.element.appendChild(h);const ge=X(h),me=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>i.close()}}),ve=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",ve);const Q={path:e.urlPath,dirty:G,values:()=>({keep:v.peek()})};c=Q,D.add(i),ke(i,()=>{me.dispose(),ge(),D.delete(i),c===Q&&(c=null)});const fe=i.close.bind(i);let R=!1,B=!1;i.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),i.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=o.querySelector(".pgx-list");u.innerHTML=je;const pe=M.applyBindings({pages:r,loading:H,loadError:T,status:p,sort:L,mode:g,query:N,rows:U,counts:ne,emptyText:se,projectNote:oe},o,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(o,{position:"bottom"}));const j=o.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Ae(A.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),we(l,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...D].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(o),await re(),!C())return;let m=null;try{m=S.get(P)}catch{}if(m?.path&&m.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(m.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,m.values)}}};
28
+ </div>`);async function Q(){if(!G.cannot.peek()){x.value=!0;try{const s=await y.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${s.deleted} revision${s.deleted===1?"":"s"}; kept ${s.kept}.`,{type:"success"}),n.value=n.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:s.kept}:b),K=!0,i.close()}catch(s){E.toast(s.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}i.element.appendChild(g);const fe=Z(g),be=M.applyBindings(G,g,{methods:{trim:Q,cancel:()=>i.close()}}),ye=s=>{s.key==="Enter"&&s.target.id==="pgx-f-keep"&&(s.preventDefault(),Q())};g.addEventListener("keydown",ye);const X={path:e.urlPath,dirty:J,values:()=>({keep:c.peek()})};d=X,j.add(i),Ee(i,()=>{be.dispose(),fe(),j.delete(i),d===X&&(d=null)});const we=i.close.bind(i);let K=!1,q=!1;i.close=async s=>{if(!K&&s!==!0&&!V&&J()){if(q)return;q=!0;const b=await E.confirm("Discard the change you have not applied?");if(q=!1,!b)return}return K=!0,we()},I.scan(g),i.open(),g.querySelector("#pgx-f-keep")?.select()}function he(e){const t=F(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>k(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>pe(t)},{label:"Transition",icon:"circle",submenu:[{label:"Published",disabled:t.status==="published",action:()=>R(t,"published")},{label:"Draft",disabled:t.status==="draft",action:()=>R(t,"draft")},{label:"Review",disabled:t.status==="review",action:()=>R(t,"review")}]},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ue(t)}]}const h=r.querySelector(".pgx-list");h.innerHTML=je;const ge=M.applyBindings({pages:n,loading:N,loadError:L,status:p,sort:D,mode:m,query:U,rows:_,counts:re,emptyText:ie,projectNote:le},r,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{m.value="list"},showTree:()=>{m.value="tree"}}});o.add(()=>ge.dispose()),o.add(Z(r,{position:"bottom"}));const B=r.querySelector(".pgx-tree");let P=null;o.add(M.effect(()=>{const e=m.value==="tree",t=e?Re(A.value):null;try{P?.destroy?.()}catch{}P=null,B.replaceChildren(),!(!e||!t.length)&&(P=E.treeView(B,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>k(a)}),I.scan(B))})),o.add(()=>{try{P?.destroy?.()}catch{}});const me=E.contextMenu(h,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.path)});o.add(()=>me?.destroy?.());const z=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){ee(a);return}k(t.dataset.path)},W=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ee(t)):e.key==="Enter"&&e.target===t&&k(t.dataset.path))};h.addEventListener("click",z),h.addEventListener("keydown",W),o.add(()=>{h.removeEventListener("click",z),h.removeEventListener("keydown",W)}),Me(l,{id:"pages-new",icon:"plus",title:"New page",onClick:de});const Y=e=>{d?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Y),o.add(()=>window.removeEventListener("beforeunload",Y)),o.add(()=>{if(d?.dirty())try{S.set(C,{path:d.path,values:d.values(),at:new Date().toISOString()})}catch{}V=!0,[...j].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(r),await ce(),!$())return;let v=null;try{v=S.get(C)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(C):S.set(C,null)}catch{}const e=F(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${w(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&$()&&O(e,v.values)}}};
@@ -1,11 +1,11 @@
1
- import{api as N}from"../api.js";import{addBannerAction as je}from"../lib/plugin-chrome.js";import{helpPopovers as Ie}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Pe,sidebarCount as lt}from"../lib/tool-kit.js";import{populateThemeSelect as Ke}from"../lib/themes.js";import{cloneOverride as G,dayOrNight as it,formFromConfig as Q,knownThemeIds as ct,moveItem as Fe,overrideRow as dt,payloadFromForm as X,snapshot as ue,swatchColour as ze,themeDiff as mt,themeLabel as ht,themeProblems as ut}from"../lib/theme-settings-arrange.js";import{fmtDateTime as pt}from"../lib/dates.js";const pe=/^\/theme(\?.*)?$/,ft="#/theme",D="theme.draft",vt=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}],bt=`
1
+ import{api as x}from"../api.js";import{applyAdminTheme as dt,refreshAdminTheme as Pe}from"../lib/admin-theme.js";import{addBannerAction as Ke}from"../lib/plugin-chrome.js";import{helpPopovers as Fe}from"../lib/help-popover.js";import{onceClosed as mt,openMenuAt as ze,sidebarCount as ht}from"../lib/tool-kit.js";import{populateThemeSelect as Be}from"../lib/themes.js";import{cloneOverride as G,dayOrNight as ut,formFromConfig as Q,knownThemeIds as pt,moveItem as _e,overrideRow as ft,payloadFromForm as X,snapshot as pe,swatchColour as He,themeDiff as vt,themeLabel as bt,themeProblems as yt}from"../lib/theme-settings-arrange.js";import{fmtDateTime as kt}from"../lib/dates.js";const fe=/^\/theme(\?.*)?$/,gt="#/theme",D="theme.draft",wt=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}],xt=`
2
2
  <div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
3
3
  <span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
4
4
  <span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
5
5
  <span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
6
6
  <span class="thx-chip" data-bind-hidden="!off">Off</span>
7
7
  <button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
8
- </div>`,fe=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function yt(m){const y=document.createElement("template");return y.innerHTML=m.trim(),y.content.firstElementChild}let x=null,Be=!1;function kt(){Be||!window.R?.use||(Be=!0,R.use((m,y,u)=>!(pe.test(y?.path||"")&&!pe.test(m?.path||""))||!x?.dirty()?u():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?u():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:m}={})=>{if(x&&!pe.test(m?.path||"")){const y=x;x=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(m){if(kt(),x){const e=x;x=null,e.dispose()}const y=[],u=e=>(y.push(e),e);let Z=!1;const p=m.get(0).querySelector(".thx")||m.get(0),q=()=>!Z&&p.isConnected,ve=E.loader(m.get(0),{type:"dots"});let j,L;try{[j,L]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){ve.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(ve.destroy(),!p.isConnected)return;const ee=L.tokens||[],P=Array.isArray(L.custom)?L.custom:[],be=ct(L),f=M.observable(j),$=Q(j),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ye=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),te=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),K=M.observable(ue($,j)),F=M.observable(!1),z=M.observable([]),oe=M.observable("(nothing yet)"),ne=M.observable(!1),_e=M.computed(()=>ue(ye(),f.value)),B=M.computed(()=>_e.value!==K.value),ke=M.computed(()=>ut(ye(),be)),ge=M.observable([]);u(M.effect(()=>{ge.value=s.overrides.value.map((e,t)=>dt(e,t,{custom:P,known:be}))}));const _=e=>ht(e,P),He={...s,saving:F,rows:ge,cssText:oe,cssError:ne,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ke.value.length?`${ke.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=f.value;if(e.autoTheme?.enabled){const t=it(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(f.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=f.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>f.value.switcher?.enabled?"Theme switcher on":"")};function we(e){if(!P.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of P){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function xe(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const H=(e,t,o)=>{const a=p.querySelector(e);return Ke(a,o),we(a),xe(a,t),a};H("#thx-site",$.theme),H("#thx-admin",$.adminTheme,{includeAdmin:!0}),H("#thx-day",$.dayTheme),H("#thx-night",$.nightTheme);let ae=null,se=0;async function V(){const e=++se;try{const t=await N.theme.compile(X(te(),f.peek()));if(e!==se||!q())return;oe.value=t.css?.trim()||"(no overrides - nothing is injected)",ne.value=!1}catch(t){if(e!==se||!q())return;oe.value=t.message||String(t),ne.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(ae),ae=setTimeout(V,400)})),u(()=>clearTimeout(ae));async function re(){if(!F.peek()){F.value=!0,z.value=[];try{const e=await N.theme.save(X(te(),f.peek()));if(!q())return;f.value=e;const t=Q(e);for(const[a,l]of Object.entries(t))s[a].value=l;K.value=ue(t,e);try{S.remove?S.remove(D):S.set(D,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),lt(ft),E.toast("Theme saved.",{type:"success"}),V()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{F.value=!1}}}async function Ve(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=Q(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Ee=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},le=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ye=e=>s.overrides.peek().findIndex(t=>t.id===e),ie=new Map;async function Se(e){if(!e)return null;if(ie.has(e))return ie.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ie.set(e,o),o}const Ue=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",Y=new Set;function U(e){const t=e?Ee(e):G({});if(!t)return;const o=!e,a=G(t),l={label:M.observable(a.label),scope:M.observable(a.scope),enabled:M.observable(a.enabled),theme:M.observable(a.theme),css:M.observable(a.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${fe(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
8
+ </div>`,ve=h=>String(h??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Et(h){const y=document.createElement("template");return y.innerHTML=h.trim(),y.content.firstElementChild}let T=null,Ue=!1;function St(){Ue||!window.R?.use||(Ue=!0,R.use((h,y,m)=>!(fe.test(y?.path||"")&&!fe.test(h?.path||""))||!T?.dirty()?m():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(f=>{f?m():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:h}={})=>{if(T&&!fe.test(h?.path||"")){const y=T;T=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(h){if(St(),T){const e=T;T=null,e.dispose()}const y=[],m=e=>(y.push(e),e);let Z=!1;const f=h.get(0).querySelector(".thx")||h.get(0),L=()=>!Z&&f.isConnected,be=E.loader(h.get(0),{type:"dots"});let j,q;try{[j,q]=await Promise.all([x.theme.get(),x.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){be.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(be.destroy(),!f.isConnected)return;const ee=q.tokens||[],P=Array.isArray(q.custom)?q.custom:[],ye=pt(q),p=M.observable(j),O=Q(j),n={};for(const[e,t]of Object.entries(O))n[e]=M.observable(t);const ke=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),te=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),K=M.observable(pe(O,j)),F=M.observable(!1),z=M.observable([]),oe=M.observable("(nothing yet)"),ne=M.observable(!1),Ve=M.computed(()=>pe(ke(),p.value)),B=M.computed(()=>Ve.value!==K.value),ge=M.computed(()=>yt(ke(),ye)),we=M.observable([]);m(M.effect(()=>{we.value=n.overrides.value.map((e,t)=>ft(e,t,{custom:P,known:ye}))}));const _=e=>bt(e,P),Ye={...n,saving:F,rows:we,cssText:oe,cssError:ne,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ge.value.length?`${ge.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=p.value;if(e.autoTheme?.enabled){const t=ut(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(p.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=p.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>p.value.switcher?.enabled?"Theme switcher on":"")};function xe(e){if(!P.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of P){const s=document.createElement("option");s.value=o.id,s.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(s)}e.appendChild(t)}function Ee(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const H=(e,t,o)=>{const s=f.querySelector(e);return Be(s,o),xe(s),Ee(s,t),s};H("#thx-site",O.theme),H("#thx-admin",O.adminTheme,{includeAdmin:!0}),H("#thx-day",O.dayTheme),H("#thx-night",O.nightTheme);let ae=0;async function Je(e){const t=++ae;try{const o=await x.theme.admin(e);if(t!==ae||!L())return;dt(o)}catch{}}let Se=!0;m(M.effect(()=>{const e=n.adminTheme.value||"charcoal-dark";if(Se){Se=!1;return}Je(e)})),m(()=>{ae++,(n.adminTheme.peek()||"charcoal-dark")!==(p.peek().adminTheme||"charcoal-dark")&&Pe(x)});let se=null,re=0;async function U(){const e=++re;try{const t=await x.theme.compile(X(te(),p.peek()));if(e!==re||!L())return;oe.value=t.css?.trim()||"(no overrides - nothing is injected)",ne.value=!1}catch(t){if(e!==re||!L())return;oe.value=t.message||String(t),ne.value=!0}}m(M.effect(()=>{n.overrides.value,clearTimeout(se),se=setTimeout(U,400)})),m(()=>clearTimeout(se));async function le(){if(!F.peek()){F.value=!0,z.value=[];try{const e=await x.theme.save(X(te(),p.peek()));if(!L())return;p.value=e;const t=Q(e);for(const[o,s]of Object.entries(t))n[o].value=s;K.value=pe(t,e);try{S.remove?S.remove(D):S.set(D,null)}catch{}await Pe(x),ht(gt),E.toast("Theme saved.",{type:"success"}),U()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{F.value=!1}}}async function We(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=Q(p.peek());for(const[t,o]of Object.entries(e))n[t].value=o;z.value=[]}const Te=e=>n.overrides.peek().find(t=>t.id===e),C=e=>{n.overrides.value=e},ie=(e,t)=>C(n.overrides.peek().map(o=>o.id===e?t:o)),Ge=e=>n.overrides.peek().findIndex(t=>t.id===e),ce=new Map;async function Ce(e){if(!e)return null;if(ce.has(e))return ce.get(e);const o=(await x.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ce.set(e,o),o}const Qe=()=>(n.autoEnabled.peek()?n.dayTheme.peek():n.theme.peek())||"charcoal-dark",V=new Set;function Y(e){const t=e?Te(e):G({});if(!t)return;const o=!e,s=G(t),i={label:M.observable(s.label),scope:M.observable(s.scope),enabled:M.observable(s.enabled),theme:M.observable(s.theme),css:M.observable(s.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${ve(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=Et(`
9
9
  <div class="thx-form">
10
10
  <div class="thx-grid">
11
11
  <div>
@@ -22,7 +22,7 @@ import{api as N}from"../api.js";import{addBannerAction as je}from"../lib/plugin-
22
22
  <label class="form-label" for="thx-o-scope">Target selector
23
23
  <span data-help-title="Target selector" data-help="The region to re-theme - a CSS selector. Everything inside it follows. Pick a common region from the list or type your own (#site-navbar, .card, .page-body \u2026)."></span></label>
24
24
  <input id="thx-o-scope" type="text" class="form-input font-mono" list="thx-scope-options" placeholder="#site-navbar" data-model="scope.value">
25
- <datalist id="thx-scope-options">${vt.map(n=>`<option value="${n.value}" label="${n.label}"></option>`).join("")}</datalist>
25
+ <datalist id="thx-scope-options">${wt.map(a=>`<option value="${a.value}" label="${a.label}"></option>`).join("")}</datalist>
26
26
  </div>
27
27
  <div>
28
28
  <label class="form-label" for="thx-o-theme">Adopt a theme here
@@ -39,7 +39,7 @@ import{api as N}from"../api.js";import{addBannerAction as je}from"../lib/plugin-
39
39
  <span data-icon="plus" data-icon-size="13"></span> Add token</button>
40
40
  <small class="thx-err" data-bind-hidden="!tokenErr.value" data-bind-text="tokenErr.value"></small>
41
41
  </div>
42
- <details class="thx-o-css"${a.css?" open":""}>
42
+ <details class="thx-o-css"${s.css?" open":""}>
43
43
  <summary>Custom CSS for this override</summary>
44
44
  <textarea class="form-input font-mono" rows="4" style="margin-top:.5rem" placeholder="#site-navbar .brand { letter-spacing: .04em; }" data-model="css.value"></textarea>
45
45
  <small class="thx-hint">Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade; hand-written rules fight the cascade.</small>
@@ -52,4 +52,4 @@ import{api as N}from"../api.js";import{addBannerAction as je}from"../lib/plugin-
52
52
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">
53
53
  <span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
54
54
  </div>
55
- </div>`),ce=v.querySelector("#thx-o-theme");Ke(ce,{includeDefault:!0,defaultLabel:"Keep the site theme"}),we(ce),xe(ce,a.theme);const Qe=v.querySelector("#thx-token-names");for(const n of ee){const i=document.createElement("option");i.value=n,Qe.appendChild(i)}const de=v.querySelector(".thx-toks");function Ae(n="",i=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=n,r.setAttribute("aria-label","Token");const h=document.createElement("input");h.type="text",h.className="form-input thx-tok-value",h.placeholder="#0096c7",h.value=i,h.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=ze(i);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{h.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,h,k,w),b}for(const[n,i]of Object.entries(a.tokens))de.appendChild(Ae(n,i));function Ne(){const n={},i=[];return de.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(ee.length&&!ee.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const De=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:Ne().tokens}),Xe=JSON.stringify(X({overrides:[a]}).overrides),Ze=()=>JSON.stringify(X({overrides:[De()]}).overrides)!==Xe,me=v.querySelector(".thx-diff-host");let Le=0;async function et(){const n=++Le;me.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Ue(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",i===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),me.appendChild(r);return}const[h,k]=await Promise.all([Se(b),Se(i)]);if(n!==Le||!h||!k)return;const g=mt(h,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",r.appendChild(d)}const W=document.createElement("div");W.className="thx-diff-list";const qe=d=>{const A=document.createElement("span");return A.className="thx-sw",A.style.background=ze(d)||"transparent",A};for(const d of g.shown){const A=document.createElement("span");A.append(qe(d.from),document.createTextNode(`${d.name} `),qe(d.to)),W.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,W.appendChild(d)}r.appendChild(W),me.appendChild(r)}const tt=M.effect(()=>{l.theme.value,et()});let J=!1,he=!1;const Re={addToken:()=>{const n=Ae();de.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${fe(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),J=!0,c.close())},done(){const{bad:n}=Ne();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=De();o?C([...s.overrides.peek(),i]):le(t.id,i),J=!0,c.close()}};c.element.appendChild(v);const ot=Ie(v),nt=M.applyBindings(l,v,{methods:Re}),at=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),Re.done())};v.addEventListener("keydown",at),Y.add(c),rt(c,()=>{tt(),nt.dispose(),ot(),Y.delete(c)});const st=c.close.bind(c);c.close=async n=>{if(!J&&n!==!0&&Ze()){if(he)return;he=!0;const i=await E.confirm("Discard your changes to this override?");if(he=!1,!i)return}return J=!0,st()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function Je(e){const t=Ee(e);if(!t)return[];const o=Ye(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>U(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>le(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>le(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=G({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...s.overrides.peek()];c.splice(o+1,0,l),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(Fe(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(Fe(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${fe(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(s.overrides.peek().filter(l=>l.id!==e))}}]}const O=p.querySelector(".thx-list");O.innerHTML=bt,E.tabs(p.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const We=M.applyBindings(He,p,{methods:{save:re,discard:Ve,refreshCss:V,addOverride:()=>U(null)}});u(()=>We.dispose()),u(Ie(p,{position:"bottom"}));const Ge=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>Je(e.dataset.id)});u(()=>Ge?.destroy?.());const Ce=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Pe(e.target.closest(".thx-kebab"));return}U(t.dataset.id)}},Te=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Pe(t)):e.key==="Enter"&&e.target===t&&U(t.dataset.id))};O.addEventListener("click",Ce),O.addEventListener("keydown",Te),u(()=>{O.removeEventListener("click",Ce),O.removeEventListener("keydown",Te)}),je(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),je(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>re()});const Me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||Y.size||(e.preventDefault(),e.stopImmediatePropagation(),re())};document.addEventListener("keydown",Me,!0),u(()=>document.removeEventListener("keydown",Me,!0));const $e=()=>{if(B.peek())try{S.set(D,{form:te(),base:K.peek(),at:new Date().toISOString()})}catch{}},Oe=e=>{B.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Oe),u(()=>window.removeEventListener("beforeunload",Oe)),x={dirty:()=>B.peek(),dispose(){Z=!0,$e(),[...Y].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),V();let T=null;try{T=S.get(D)}catch{}if(T?.form){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=T.base&&T.base!==K.peek(),t=T.at?` (${pt(T.at)})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&q()){const a={...Q(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>G(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
55
+ </div>`),de=v.querySelector("#thx-o-theme");Be(de,{includeDefault:!0,defaultLabel:"Keep the site theme"}),xe(de),Ee(de,s.theme);const tt=v.querySelector("#thx-token-names");for(const a of ee){const l=document.createElement("option");l.value=a,tt.appendChild(l)}const me=v.querySelector(".thx-toks");function De(a="",l=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=a,r.setAttribute("aria-label","Token");const u=document.createElement("input");u.type="text",u.className="form-input thx-tok-value",u.placeholder="#0096c7",u.value=l,u.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=He(l);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{u.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,u,k,w),b}for(const[a,l]of Object.entries(s.tokens))me.appendChild(De(a,l));function Le(){const a={},l=[];return me.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),u=b.querySelector(".thx-tok-value").value.trim();!r||!u||(ee.length&&!ee.includes(r)&&l.push(r),a[r]=u)}),{tokens:a,bad:l}}const Re=()=>({...s,label:i.label.peek(),scope:i.scope.peek(),enabled:i.enabled.peek(),theme:i.theme.peek(),css:i.css.peek(),tokens:Le().tokens}),ot=JSON.stringify(X({overrides:[s]}).overrides),nt=()=>JSON.stringify(X({overrides:[Re()]}).overrides)!==ot,he=v.querySelector(".thx-diff-host");let qe=0;async function at(){const a=++qe;he.replaceChildren();const l=i.theme.peek();if(!l)return;const b=Qe(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",l===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),he.appendChild(r);return}const[u,k]=await Promise.all([Ce(b),Ce(l)]);if(a!==qe||!u||!k)return;const g=vt(u,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",r.appendChild(d)}const W=document.createElement("div");W.className="thx-diff-list";const Ie=d=>{const N=document.createElement("span");return N.className="thx-sw",N.style.background=He(d)||"transparent",N};for(const d of g.shown){const N=document.createElement("span");N.append(Ie(d.from),document.createTextNode(`${d.name} `),Ie(d.to)),W.appendChild(N)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,W.appendChild(d)}r.appendChild(W),he.appendChild(r)}const st=M.effect(()=>{i.theme.value,at()});let J=!1,ue=!1;const je={addToken:()=>{const a=De();me.appendChild(a),a.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(n.overrides.peek().filter(a=>a.id!==t.id)),J=!0,c.close())},done(){const{bad:a}=Le();if(a.length){i.tokenErr.value=`Not a token this Domma defines: ${a.join(", ")}. Pick one from the list.`;return}const l=Re();o?C([...n.overrides.peek(),l]):ie(t.id,l),J=!0,c.close()}};c.element.appendChild(v);const rt=Fe(v),lt=M.applyBindings(i,v,{methods:je}),it=a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),je.done())};v.addEventListener("keydown",it),V.add(c),mt(c,()=>{st(),lt.dispose(),rt(),V.delete(c)});const ct=c.close.bind(c);c.close=async a=>{if(!J&&a!==!0&&nt()){if(ue)return;ue=!0;const l=await E.confirm("Discard your changes to this override?");if(ue=!1,!l)return}return J=!0,ct()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function Xe(e){const t=Te(e);if(!t)return[];const o=Ge(e),s=n.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>Y(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>ie(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>ie(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const i=G({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...n.overrides.peek()];c.splice(o+1,0,i),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(_e(n.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=s-1,action:()=>C(_e(n.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(n.overrides.peek().filter(i=>i.id!==e))}}]}const A=f.querySelector(".thx-list");A.innerHTML=xt,E.tabs(f.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const Ze=M.applyBindings(Ye,f,{methods:{save:le,discard:We,refreshCss:U,addOverride:()=>Y(null)}});m(()=>Ze.dispose()),m(Fe(f,{position:"bottom"}));const et=E.contextMenu(A,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>Xe(e.dataset.id)});m(()=>et?.destroy?.());const Me=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){ze(e.target.closest(".thx-kebab"));return}Y(t.dataset.id)}},$e=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ze(t)):e.key==="Enter"&&e.target===t&&Y(t.dataset.id))};A.addEventListener("click",Me),A.addEventListener("keydown",$e),m(()=>{A.removeEventListener("click",Me),A.removeEventListener("keydown",$e)}),Ke(h,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),Ke(h,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const Oe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||V.size||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",Oe,!0),m(()=>document.removeEventListener("keydown",Oe,!0));const Ae=()=>{if(B.peek())try{S.set(D,{form:te(),base:K.peek(),at:new Date().toISOString()})}catch{}},Ne=e=>{B.peek()&&(Ae(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ne),m(()=>window.removeEventListener("beforeunload",Ne)),T={dirty:()=>B.peek(),dispose(){Z=!0,Ae(),[...V].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(f),U();let $=null;try{$=S.get(D)}catch{}if($?.form){try{S.remove?S.remove(D):S.set(D,null)}catch{}const e=$.base&&$.base!==K.peek(),t=$.at?` (${kt($.at)})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&L()){const s={...Q(p.peek()),...$.form};s.overrides=(s.overrides||[]).map(i=>G(i));for(const[i,c]of Object.entries(s))n[i]&&(n[i].value=c)}}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.34",
3
+ "version": "1.6.36",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -6,12 +6,15 @@
6
6
  * {
7
7
  * "stun": ["stun:stun.l.google.com:19302"],
8
8
  * "turn": {"urls": ["turn:turn.example.org:3478?transport=udp", "turn:turn.example.org:3478?transport=tcp"],
9
- * "secret": "<coturn static-auth-secret>", "ttlHours": 12}
9
+ * "secret": "<coturn static-auth-secret>", "ttlHours": 4}
10
10
  * }
11
11
  *
12
12
  * TURN credentials are coturn's time-limited kind (use-auth-secret): the
13
13
  * username is "<expiry>:<label>" and the password an HMAC of it, so no
14
14
  * account is ever stored and a copied credential stops working on its own.
15
+ * The default life is 4 hours (was 12): it is made when a person is let in,
16
+ * and a call rarely runs longer; a shorter one limits what a leaked credential
17
+ * is worth as a relay.
15
18
  *
16
19
  * @module _lib/call/ice
17
20
  */
@@ -32,7 +35,7 @@ export function readCallConfig(root = process.cwd()) {
32
35
  const stun = list(raw?.stun);
33
36
  const turnUrls = list(raw?.turn?.urls);
34
37
  const secret = typeof raw?.turn?.secret === 'string' ? raw.turn.secret : '';
35
- const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 12));
38
+ const ttlHours = Math.max(1, Math.min(48, Number(raw?.turn?.ttlHours) || 4));
36
39
  return {stun: stun.length ? stun : DEFAULT_STUN, turn: turnUrls.length && secret ? {urls: turnUrls, secret, ttlHours} : null};
37
40
  } catch {
38
41
  return {stun: DEFAULT_STUN, turn: null};
@@ -132,8 +132,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
132
132
  const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
133
133
  tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
134
134
  // `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
135
+ // The ICE servers (TURN credentials) come HERE, not in the join reply: a guest still waiting, or turned
136
+ // away, never holds a relay credential. Made at admission, so its life starts now.
137
+ const ice = typeof person.ice === 'function' ? person.ice() : person.ice;
135
138
  push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
136
- chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c}))});
139
+ chat: room.chat.filter(c => person.seeHistory || c.seq > person.fromSeq).map(c => ({...c})), ...(ice ? {iceServers: ice} : {})});
137
140
  tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
138
141
  }
139
142
 
@@ -149,9 +152,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
149
152
  * `canInvite`: this person may use the Tool's "Add people" (routes.js decides; see member()), with
150
153
  * `inviteOptions` (what of it). `seeHistory: false`: when let in they get only the chat from their own
151
154
  * "joined" line on - not what was said before (someone added to a conversation's call is not shown it).
155
+ * `ice`: the ICE servers for this person (an array, or a function making them) - handed over in their
156
+ * `admitted` event, never before: a waiting or turned-away guest holds no TURN credential.
152
157
  */
153
158
  join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history, canInvite = false,
154
- inviteOptions = null, seeHistory = true}) {
159
+ inviteOptions = null, seeHistory = true, ice = null}) {
155
160
  let room = rooms.get(key);
156
161
  if (!room) {
157
162
  const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
@@ -177,7 +182,8 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
177
182
  if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
178
183
  const id = newId(9);
179
184
  const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
180
- host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
185
+ host: Boolean(host), canInvite: canInvite === true, inviteOptions, seeHistory: seeHistory !== false, fromSeq: room.chatSeq, state: 'waiting', seq: 0, queue: [], waiters: [], seen: now(),
186
+ ice: ice || null};
181
187
  room.people.set(person.id, person);
182
188
  if (host || autoAdmit) {
183
189
  // admit() also tells every host - this one included - who is already waiting.
@@ -4,7 +4,7 @@
4
4
  * nothing: only a Tool that mounts this, and says who may join which room,
5
5
  * has calls.
6
6
  *
7
- * POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, iceServers, ...}
7
+ * POST {prefix}/:room/join {name, ...credentials} -> {id, secret, host, state, ...} (ICE servers come in the `admitted` event)
8
8
  * GET {prefix}/:room/poll?since=N -> {events, state} (long-poll, 25 s)
9
9
  * POST {prefix}/:room/signal {to, data}
10
10
  * POST {prefix}/:room/chat {text}
@@ -118,12 +118,15 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
118
118
  try {
119
119
  const canInvite = inviting && (typeof r.mayInvite === 'boolean' ? r.mayInvite : r.host === true);
120
120
  const inviteOptions = canInvite ? inviteOptionsOf(r) : null;
121
+ // The ICE servers (a TURN credential good for ttlHours, when configured) go in this person's `admitted`
122
+ // event, made at admission - not in this reply, which a guest still waiting, or about to be turned away,
123
+ // also gets. Before, anyone holding a call link had a relay credential for the TTL.
124
+ const hostname = request.hostname;
121
125
  const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
122
126
  recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history, canInvite,
123
- inviteOptions, seeHistory: r.history !== false});
127
+ inviteOptions, seeHistory: r.history !== false, ice: () => iceServers(config(), hostname)});
124
128
  // What "Add people" offers this person: {search, typed, copyLink, external} (invite.js inviteOptionsOf).
125
- return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null,
126
- ...(inviteOptions ? {inviteOptions} : {})});
129
+ return reply.send({...joined, closesAt: r.closesAt || null, ...(inviteOptions ? {inviteOptions} : {})});
127
130
  } catch (err) { return fail(reply, err); }
128
131
  });
129
132
 
@@ -40,6 +40,18 @@ export default {
40
40
  */
41
41
  allowInsecureTLS: false,
42
42
 
43
+ /**
44
+ * Let a mailbox or outgoing server be on a private, loopback or link-local
45
+ * address.
46
+ *
47
+ * Off, deliberately: "test these details" makes this server open TCP to the
48
+ * host typed in and report refused / no answer / unknown - a port scanner of
49
+ * the server's own network behind the Email permission. The site's own
50
+ * configured outgoing server (config/site.json smtp.host) is always allowed.
51
+ * Turn on only for a site whose mail server really is on its LAN.
52
+ */
53
+ allowPrivateMailHosts: false,
54
+
43
55
  /** Largest inline image embedded into a rendered body, in bytes. */
44
56
  inlineImageMaxBytes: 2_000_000,
45
57
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * May this server open a connection to that mail host?
3
+ *
4
+ * `/accounts/test`, saving a mailbox and the outgoing-server routes make the
5
+ * CMS open TCP to whatever host:port the caller typed, and `describeError()`
6
+ * answers "refused", "did not respond" or "could not be resolved" - a port
7
+ * scanner of the server's own network behind the `email` permission
8
+ * (127.0.0.1:27018 is the site's Mongo, :8192 the manager). So a private,
9
+ * loopback or link-local host is refused unless it is the host the site's own
10
+ * outgoing mail already goes to (config/site.json smtp.host - the LAN relay
11
+ * the admin chose) or `allowPrivateMailHosts` is set for a site whose mail
12
+ * server really is on its LAN.
13
+ *
14
+ * The pure half is `isPrivateAddress` / `isLocalName` in unsubscribe.js; this
15
+ * module adds the lookup, which the tests inject.
16
+ *
17
+ * @module _lib/mail/hostGuard
18
+ */
19
+ import dns from 'node:dns/promises';
20
+ import net from 'node:net';
21
+ import {isLocalName, isPrivateAddress} from './unsubscribe.js';
22
+
23
+ const REFUSED = 'That server is on this machine\'s own network, so the site will not connect to it. '
24
+ + 'Use the mail server\'s public name, or ask the site administrator to allow private mail hosts.';
25
+
26
+ /**
27
+ * Normalise a typed host for comparison: case, trailing dot, IPv6 brackets.
28
+ *
29
+ * @param {string} host
30
+ * @returns {string}
31
+ */
32
+ export function normaliseHost(host) {
33
+ return String(host ?? '').trim().toLowerCase().replace(/\.$/, '').replace(/^\[(.*)\]$/, '$1');
34
+ }
35
+
36
+ /**
37
+ * Check one host.
38
+ *
39
+ * @param {string} host
40
+ * @param {object} [options]
41
+ * @param {boolean} [options.allowPrivate] - the site's `allowPrivateMailHosts` setting
42
+ * @param {string} [options.ownSmtpHost] - the site's configured outgoing server; always allowed
43
+ * @param {(host: string) => Promise<{address: string}[]>} [options.lookup] - DNS, injectable
44
+ * @returns {Promise<{ok: true} | {ok: false, error: string}>}
45
+ */
46
+ export async function checkMailHost(host, {allowPrivate = false, ownSmtpHost = '', lookup} = {}) {
47
+ const name = normaliseHost(host);
48
+ if (!name) return {ok: false, error: 'A server is required.'};
49
+ if (allowPrivate) return {ok: true};
50
+ if (ownSmtpHost && normaliseHost(ownSmtpHost) === name) return {ok: true};
51
+
52
+ if (net.isIP(name)) {
53
+ return isPrivateAddress(name) ? {ok: false, error: REFUSED} : {ok: true};
54
+ }
55
+ if (isLocalName(name)) return {ok: false, error: REFUSED};
56
+
57
+ let answers;
58
+ try {
59
+ answers = await (lookup ?? ((h) => dns.lookup(h, {all: true})))(name);
60
+ } catch {
61
+ // Unresolvable: let the connection attempt say so in its own words.
62
+ // The check is about where a name points, not whether it exists.
63
+ return {ok: true};
64
+ }
65
+ // Every answer must be public: a name with one private A record among
66
+ // public ones is exactly the rebinding trick the check exists for.
67
+ const addresses = (answers ?? []).map(a => (typeof a === 'string' ? a : a?.address)).filter(Boolean);
68
+ if (addresses.some(isPrivateAddress)) return {ok: false, error: REFUSED};
69
+ return {ok: true};
70
+ }
71
+
72
+ /**
73
+ * The site's own outgoing server, from config/site.json `smtp.host` (what
74
+ * core `createTransport` sends with), read through a getter so a test can
75
+ * hand in its own.
76
+ *
77
+ * @param {(section: string) => any} getConfig
78
+ * @returns {string}
79
+ */
80
+ export function siteSmtpHost(getConfig) {
81
+ try {
82
+ return String(getConfig('site')?.smtp?.host ?? '').trim();
83
+ } catch {
84
+ return '';
85
+ }
86
+ }
@@ -28,6 +28,8 @@
28
28
  import {simpleParser} from 'mailparser';
29
29
 
30
30
  import {listUsers} from '../../../server/services/users.js';
31
+ import {getConfig} from '../../../server/config.js';
32
+ import {checkMailHost, siteSmtpHost} from './hostGuard.js';
31
33
  import * as accounts from './accounts.js';
32
34
  import * as bodyTokens from './bodyTokens.js';
33
35
  import {buildDiagnostics} from './diagnostics.js';
@@ -207,6 +209,11 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
207
209
  });
208
210
  }
209
211
 
212
+ /** Where this server may be asked to connect - see hostGuard.js. */
213
+ const guardHost = (host) => checkMailHost(host, {allowPrivate: config.allowPrivateMailHosts === true, ownSmtpHost: siteSmtpHost(getConfig)});
214
+ // Each test is a connection attempt whose answer says what is listening: a few a minute per address is plenty.
215
+ const testLimit = {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}};
216
+
210
217
  /** GET /accounts - every mailbox this user owns, without passwords. */
211
218
  fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
212
219
  const uid = userId(request);
@@ -221,6 +228,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
221
228
 
222
229
  const result = accounts.validate(request.body ?? {}, null);
223
230
  if (!result.ok) return reply.code(400).send({error: result.error});
231
+ const where = await guardHost(result.value.host);
232
+ if (!where.ok) return reply.code(400).send({error: where.error});
224
233
 
225
234
  const saved = await accounts.saveAccount(uid, null, result.value);
226
235
  return {account: accounts.toPublic(saved)};
@@ -236,6 +245,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
236
245
 
237
246
  const result = accounts.validate(request.body ?? {}, existing);
238
247
  if (!result.ok) return reply.code(400).send({error: result.error});
248
+ const where = await guardHost(result.value.host);
249
+ if (!where.ok) return reply.code(400).send({error: where.error});
239
250
 
240
251
  const saved = await accounts.saveAccount(uid, request.params.id, result.value);
241
252
  // The pooled connection was opened with the old details.
@@ -271,7 +282,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
271
282
  * password field is blank and `id` names an existing mailbox, the stored
272
283
  * password is used - otherwise editing a hostname would mean retyping it.
273
284
  */
274
- fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
285
+ fastify.post('/accounts/test', {preHandler: [authenticate], ...testLimit}, async (request, reply) => {
275
286
  const uid = userId(request);
276
287
  if (!uid) return reply.code(401).send({error: 'Authentication required.'});
277
288
 
@@ -281,6 +292,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
281
292
 
282
293
  const result = accounts.validate(body, existing);
283
294
  if (!result.ok) return reply.code(400).send({error: result.error});
295
+ const where = await guardHost(result.value.host);
296
+ if (!where.ok) return reply.code(400).send({error: where.error});
284
297
 
285
298
  let pass = result.value.password;
286
299
  if (!pass) {
@@ -711,6 +724,12 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
711
724
  reply.code(404).send({error: 'That message is no longer in this folder.'});
712
725
  return null;
713
726
  }
727
+ // The same ceiling as /messages/:uid and /body: without it a hostile
728
+ // 200 MB message cost a 200 MB buffer plus mailparser output per click.
729
+ if (fetched.source.length > config.maxMessageBytes) {
730
+ reply.code(413).send({error: 'That message is too large to open its attachments.'});
731
+ return null;
732
+ }
714
733
 
715
734
  const parsed = await simpleParser(fetched.source);
716
735
  const attachment = parsed.attachments?.[index];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The shared outreach audience: one store of contacts and named groups,
3
+ * imported from the Contacts Tool once and targeted by every outreach Tool
4
+ * (Surveys, Campaigns, Onboard...). Collections default to the shared
5
+ * `outreach-contacts` / `outreach-groups` (schemas in ./collections - each
6
+ * consuming plugin ships a copy; setupPlugin skips a slug that already
7
+ * exists, so the first Tool on the site creates them and the rest share).
8
+ *
9
+ * Lifted from Surveys 1.4 (lib/audience.js), generalised. A contact's
10
+ * `groups` holds group NAMES, not ids - a group row is a label with a
11
+ * description, membership lives on the contact.
12
+ *
13
+ * @module _lib/outreach/audience
14
+ */
15
+
16
+ function parseGroups(raw) {
17
+ if (Array.isArray(raw)) return raw;
18
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return raw ? [raw] : []; } }
19
+ return [];
20
+ }
21
+
22
+ /**
23
+ * @param {{createEntry,listEntries,getEntry,deleteEntry?}} col collections service
24
+ * @param {{contactsSlug?: string, groupsSlug?: string}} [opts]
25
+ */
26
+ export function createAudience(col, {contactsSlug = 'outreach-contacts', groupsSlug = 'outreach-groups'} = {}) {
27
+ async function listContacts() {
28
+ const {entries} = await col.listEntries(contactsSlug, {limit: 100000});
29
+ return entries.map(e => ({id: e.id, name: e.data.name ?? '', email: e.data.email ?? '', groups: parseGroups(e.data.groups)}));
30
+ }
31
+
32
+ async function contactIdsForGroups(groupNames) {
33
+ const want = new Set(groupNames);
34
+ const contacts = await listContacts();
35
+ return contacts.filter(c => c.groups.some(g => want.has(g))).map(c => c.id);
36
+ }
37
+
38
+ async function importFromContacts(sourceContacts) {
39
+ let imported = 0;
40
+ for (const c of sourceContacts) {
41
+ const email = (c.email ?? '').trim();
42
+ if (!email) continue;
43
+ await col.createEntry(contactsSlug, {
44
+ name: (c.name ?? '').trim(),
45
+ email,
46
+ groups: parseGroups(c.groups)
47
+ }, {source: 'import-contacts'});
48
+ imported++;
49
+ }
50
+ return {imported};
51
+ }
52
+
53
+ /** Add one contact by hand. Email required by the schema. */
54
+ async function addContact({name, email, groups} = {}, source = 'admin') {
55
+ return col.createEntry(contactsSlug, {
56
+ name: name ?? '', email, groups: Array.isArray(groups) ? groups : []
57
+ }, {source});
58
+ }
59
+
60
+ async function removeContact(id) {
61
+ if (typeof col.deleteEntry !== 'function') return false;
62
+ await col.deleteEntry(contactsSlug, id);
63
+ return true;
64
+ }
65
+
66
+ async function listGroups() {
67
+ const {entries} = await col.listEntries(groupsSlug, {limit: 10000});
68
+ return entries.map(e => ({id: e.id, name: e.data.name, description: e.data.description ?? ''}));
69
+ }
70
+
71
+ async function addGroup({name, description} = {}, source = 'admin') {
72
+ return col.createEntry(groupsSlug, {name, description: description ?? ''}, {source});
73
+ }
74
+
75
+ return {listContacts, contactIdsForGroups, importFromContacts, addContact, removeContact, listGroups, addGroup, contactsSlug, groupsSlug};
76
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "slug": "outreach-contacts",
3
+ "title": "Outreach Contacts",
4
+ "description": "The shared outreach audience - contacts targeted by Surveys, Campaigns and other outreach Tools.",
5
+ "storage": {"adapter": "mongodb", "connection": "default"},
6
+ "fields": [
7
+ {"name": "name", "label": "Name", "type": "text", "required": false},
8
+ {"name": "email", "label": "Email", "type": "text", "required": true},
9
+ {"name": "groups","label": "Groups","type": "text", "required": false},
10
+ {"name": "meta", "label": "Meta", "type": "textarea", "required": false}
11
+ ]
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "slug": "outreach-groups",
3
+ "title": "Outreach Groups",
4
+ "description": "Named audience groups for the shared outreach contacts.",
5
+ "storage": {"adapter": "mongodb", "connection": "default"},
6
+ "fields": [
7
+ {"name": "name", "label": "Name", "type": "text", "required": true},
8
+ {"name": "description", "label": "Description", "type": "textarea", "required": false}
9
+ ]
10
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "slug": "outreach-unsubscribes",
3
+ "title": "Outreach Unsubscribes",
4
+ "description": "The shared suppression list - addresses no outreach Tool may email.",
5
+ "storage": {"adapter": "mongodb", "connection": "default"},
6
+ "fields": [
7
+ {"name": "email", "label": "Email", "type": "text", "required": true},
8
+ {"name": "at", "label": "At", "type": "text", "required": false},
9
+ {"name": "source", "label": "Source", "type": "text", "required": false}
10
+ ]
11
+ }