domma-cms 0.81.1 → 0.83.0

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 (43) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +5 -4
  4. package/admin/js/lib/avatar.js +1 -0
  5. package/admin/js/templates/login.html +16 -0
  6. package/admin/js/templates/my-profile.html +23 -3
  7. package/admin/js/views/login.js +2 -2
  8. package/admin/js/views/my-profile.js +1 -1
  9. package/bin/update.js +7 -3
  10. package/config/plugins.json +5 -0
  11. package/package.json +2 -1
  12. package/plugins/free-tier.lock.json +25 -1
  13. package/plugins/security/CLAUDE.md +41 -0
  14. package/plugins/security/admin/lib/blocks.js +46 -0
  15. package/plugins/security/admin/lib/health.js +148 -0
  16. package/plugins/security/admin/lib/ip.js +44 -0
  17. package/plugins/security/admin/lib/lockout.js +78 -0
  18. package/plugins/security/admin/lib/policy.js +32 -0
  19. package/plugins/security/admin/lib/probes.js +39 -0
  20. package/plugins/security/admin/lib/settings.js +86 -0
  21. package/plugins/security/admin/templates/security.html +170 -0
  22. package/plugins/security/admin/views/security.js +529 -0
  23. package/plugins/security/bin/unlock.js +81 -0
  24. package/plugins/security/config.js +7 -0
  25. package/plugins/security/plugin.js +303 -0
  26. package/plugins/security/plugin.json +46 -0
  27. package/plugins/security/server/common-passwords.NOTICE +3 -0
  28. package/plugins/security/server/common-passwords.txt +10001 -0
  29. package/plugins/security/server/facts.js +133 -0
  30. package/plugins/security/server/state.js +106 -0
  31. package/plugins/security/tests/security.test.js +134 -0
  32. package/server/routes/api/auth.js +241 -39
  33. package/server/routes/api/forms.js +3 -1
  34. package/server/routes/api/users.js +20 -0
  35. package/server/server.js +25 -7
  36. package/server/services/audit.js +63 -0
  37. package/server/services/authChecks.js +128 -0
  38. package/server/services/avatars.js +77 -0
  39. package/server/services/network.js +49 -0
  40. package/server/services/plugins.js +5 -1
  41. package/server/services/sessions.js +171 -0
  42. package/server/services/siteGitignore.js +1 -0
  43. package/server/services/users.js +5 -0
@@ -1,4 +1,4 @@
1
- import{api as o,isAuthenticated as u,setAuthData as l,takePendingLogout as p}from"../api.js";import{themesInDisplayOrder as f}from"../lib/themes.js";const h={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Your name",autocomplete:"name"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"admin@example.com",autocomplete:"email"}},password:{type:"password",required:!0,minLength:8,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}}},g={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}},password:{type:"password",required:!0,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"current-password"}}};export const loginView={templateUrl:"/admin/js/templates/login.html",async onMount(e){if(p(),u()){R.navigate("/");return}const a=window.location.hash,t=a.match(/[?&]token=([a-f0-9]{64})/);if(a.startsWith("#/reset-password")&&t){r(e,"reset"),S(e,t[1]),Domma.icons.scan();return}if(a.startsWith("#/reset-password")){R.navigate("/login");return}let s=!1;const i=E.loader(e.get(0),{type:"dots"});try{s=(await o.auth.setupStatus()).needsSetup}catch{E.toast("Could not reach the server.",{type:"error"})}finally{i.destroy()}s?(r(e,"setup"),k(e)):(r(e,"login"),C(e)),Domma.icons.scan()}};const b={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},w={password:{type:"password",required:!0,minLength:8,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}},confirmPassword:{type:"password",required:!0,label:"Confirm Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}}},y=["setup","onboarding-site","onboarding-theme","onboarding-done","login","forgot","forgot-success","reset"];function r(e,a){y.forEach(t=>e.find(`#${t}-panel`).hide()),e.find(`#${a}-panel`).show(),Domma.icons.scan()}function d(e,a,t){e.find(`#${a}`).text(t).show()}function k(e){F.render("#setup-form-container",h,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async a=>{try{const t=await o.auth.setup(a);l(t),r(e,"onboarding-site"),v(e)}catch(t){return E.toast(t.message||"Setup failed. Please try again.",{type:"error"}),!1}}})}function v(e){e.find("#ob-site-skip").on("click",a=>{a.preventDefault(),r(e,"onboarding-theme"),c(e),m(e)}),e.find("#ob-site-btn").on("click",async()=>{e.find("#ob-site-error").hide();const a=e.find("#ob-title").val().trim(),t=e.find("#ob-tagline").val().trim(),s=e.find("#ob-site-btn").prop("disabled",!0).text("Saving\u2026");try{const i=await o.settings.get();await o.settings.save({...i,title:a||i.title,tagline:t||i.tagline,seo:{...i.seo||{},defaultTitle:a||i.seo&&i.seo.defaultTitle}});const n=await o.navigation.get();await o.navigation.save({...n,brand:{...n.brand||{},text:a||n.brand.text}}),r(e,"onboarding-theme"),c(e),m(e)}catch(i){d(e,"ob-site-error",i.message||"Could not save site details.")}finally{s.prop("disabled",!1).text("Continue")}})}function c(e){const a=e.find("#theme-grid").empty();f().forEach(t=>{const s=$(`
1
+ import{api as i,isAuthenticated as y,setAuthData as m,takePendingLogout as v}from"../api.js";import{themesInDisplayOrder as k}from"../lib/themes.js";const x={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Your name",autocomplete:"name"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"admin@example.com",autocomplete:"email"}},password:{type:"password",required:!0,minLength:8,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}}},C={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}},password:{type:"password",required:!0,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"current-password"}}};export const loginView={templateUrl:"/admin/js/templates/login.html",async onMount(e){if(v(),y()){R.navigate("/");return}const s=window.location.hash,t=s.match(/[?&]token=([a-f0-9]{64})/);if(s.startsWith("#/reset-password")&&t){r(e,"reset"),M(e,t[1]),Domma.icons.scan();return}if(s.startsWith("#/reset-password")){R.navigate("/login");return}let o=!1;const a=E.loader(e.get(0),{type:"dots"});try{o=(await i.auth.setupStatus()).needsSetup}catch{E.toast("Could not reach the server.",{type:"error"})}finally{a.destroy()}o?(r(e,"setup"),T(e)):(r(e,"login"),q(e)),Domma.icons.scan()}};const S={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},P={password:{type:"password",required:!0,minLength:8,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}},confirmPassword:{type:"password",required:!0,label:"Confirm Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}}},D=["setup","onboarding-site","onboarding-theme","onboarding-done","login","forgot","forgot-success","reset","challenge"];function r(e,s){D.forEach(t=>e.find(`#${t}-panel`).hide()),e.find(`#${s}-panel`).show(),Domma.icons.scan()}function h(e,s,t){e.find(`#${s}`).text(t).show()}function T(e){F.render("#setup-form-container",x,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async s=>{try{const t=await i.auth.setup(s);m(t),r(e,"onboarding-site"),L(e)}catch(t){return E.toast(t.message||"Setup failed. Please try again.",{type:"error"}),!1}}})}function L(e){e.find("#ob-site-skip").on("click",s=>{s.preventDefault(),r(e,"onboarding-theme"),g(e),b(e)}),e.find("#ob-site-btn").on("click",async()=>{e.find("#ob-site-error").hide();const s=e.find("#ob-title").val().trim(),t=e.find("#ob-tagline").val().trim(),o=e.find("#ob-site-btn").prop("disabled",!0).text("Saving\u2026");try{const a=await i.settings.get();await i.settings.save({...a,title:s||a.title,tagline:t||a.tagline,seo:{...a.seo||{},defaultTitle:s||a.seo&&a.seo.defaultTitle}});const l=await i.navigation.get();await i.navigation.save({...l,brand:{...l.brand||{},text:s||l.brand.text}}),r(e,"onboarding-theme"),g(e),b(e)}catch(a){h(e,"ob-site-error",a.message||"Could not save site details.")}finally{o.prop("disabled",!1).text("Continue")}})}function g(e){const s=e.find("#theme-grid").empty();k().forEach(t=>{const o=$(`
2
2
  <div class="theme-swatch" data-theme="${t.id}" title="${t.id}">
3
3
  <div class="theme-swatch-preview" style="background:${t.bg}">
4
4
  <div class="theme-swatch-accent" style="background:${t.primary}"></div>
@@ -8,4 +8,4 @@ import{api as o,isAuthenticated as u,setAuthData as l,takePendingLogout as p}fro
8
8
  <span class="theme-swatch-mode">${t.dark?"Dark":"Light"}</span>
9
9
  </div>
10
10
  </div>
11
- `);s.on("click",()=>{e.find(".theme-swatch").removeClass("selected"),s.addClass("selected")}),a.append(s)}),e.find('[data-theme="charcoal-dark"]').addClass("selected")}function m(e){e.find("#ob-theme-skip").on("click",a=>{a.preventDefault(),r(e,"onboarding-done")}),e.find("#ob-theme-btn").on("click",async()=>{e.find("#ob-theme-error").hide();const a=e.find(".theme-swatch.selected").attr("data-theme")||"charcoal-dark",t=e.find("#ob-theme-btn").prop("disabled",!0).text("Applying\u2026");try{const s=await o.settings.get();await o.settings.save({...s,theme:a}),r(e,"onboarding-done")}catch(s){d(e,"ob-theme-error",s.message||"Could not save theme.")}finally{t.prop("disabled",!1).text("Apply theme")}})}function C(e){F.render("#login-form-container",g,{},{layout:"stacked",submitText:"Sign in",onSubmit:async a=>{try{const t=await o.auth.login(a);l(t),R.navigate("/")}catch(t){return E.toast(t.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),e.find("#forgot-link").on("click",a=>{a.preventDefault(),r(e,"forgot"),x(e)})}function x(e){F.render("#forgot-form-container",b,{},{layout:"stacked",submitText:"Send reset link",onSubmit:async t=>{try{await o.auth.forgotPassword(t.email)}catch{}r(e,"forgot-success"),Domma.icons.scan()}});const a=t=>{t.preventDefault(),r(e,"login")};e.find("#forgot-back-link").on("click",a),e.find("#forgot-success-back-link").on("click",a)}function S(e,a){F.render("#reset-form-container",w,{},{layout:"stacked",submitText:"Set new password",onSubmit:async t=>{if(t.password!==t.confirmPassword)return E.toast("Passwords do not match.",{type:"error"}),!1;try{await o.auth.resetPassword(a,t.password),E.toast("Password updated. Please sign in.",{type:"success"}),window.location.hash="#/login"}catch(s){return E.toast(s.message||"Link expired or invalid.",{type:"error"}),!1}}})}
11
+ `);o.on("click",()=>{e.find(".theme-swatch").removeClass("selected"),o.addClass("selected")}),s.append(o)}),e.find('[data-theme="charcoal-dark"]').addClass("selected")}function b(e){e.find("#ob-theme-skip").on("click",s=>{s.preventDefault(),r(e,"onboarding-done")}),e.find("#ob-theme-btn").on("click",async()=>{e.find("#ob-theme-error").hide();const s=e.find(".theme-swatch.selected").attr("data-theme")||"charcoal-dark",t=e.find("#ob-theme-btn").prop("disabled",!0).text("Applying\u2026");try{const o=await i.settings.get();await i.settings.save({...o,theme:s}),r(e,"onboarding-done")}catch(o){h(e,"ob-theme-error",o.message||"Could not save theme.")}finally{t.prop("disabled",!1).text("Apply theme")}})}function q(e){F.render("#login-form-container",C,{},{layout:"stacked",submitText:"Sign in",onSubmit:async s=>{try{const t=await i.auth.login(s);if(t?.challenge&&t.ticket)return w(e,t),!1;m(t),R.navigate("/")}catch(t){return E.toast(t.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),e.find("#forgot-link").on("click",s=>{s.preventDefault(),r(e,"forgot"),A(e)})}function w(e,{challenge:s,ticket:t}){let o="code";const a=e.find("#challenge-code"),l=e.find("#challenge-error"),u=e.find("#challenge-recovery"),p=n=>{o=n;const d=n==="recovery";e.find("#challenge-label").text(d?"Recovery code":s.label||"Code"),e.find("#challenge-help").text(d?"Type one of the recovery codes you saved when you set this up. Each works once.":s.help||"Enter the code to finish signing in."),a.val("").attr("inputmode",d||s.inputMode==="text"?"text":"numeric"),u.text(d?"Use the code instead":"Use a recovery code instead"),l.hide(),setTimeout(()=>a.trigger("focus"),0)};e.find("#challenge-trust-wrap").toggle(s.trust===!0),u.toggle(s.recovery===!0),p("code"),r(e,"challenge"),u.off("click").on("click",n=>{n.preventDefault(),p(o==="code"?"recovery":"code")}),e.find("#challenge-back").off("click").on("click",n=>{n.preventDefault(),r(e,"login")}),e.find("#challenge-form").off("submit").on("submit",async n=>{n.preventDefault();const d=e.find("#challenge-submit").prop("disabled",!0);try{const c=await i.auth.verifyLogin({ticket:t,response:String(a.val()||"").trim(),mode:o,trust:e.find("#challenge-trust").prop("checked")===!0});if(c?.challenge&&c.ticket){w(e,c);return}m(c),R.navigate("/")}catch(c){const f=c.message||"That did not match.";l.text(f).show(),/sign in again/i.test(f)&&setTimeout(()=>r(e,"login"),1800)}finally{d.prop("disabled",!1)}})}function A(e){F.render("#forgot-form-container",S,{},{layout:"stacked",submitText:"Send reset link",onSubmit:async t=>{try{await i.auth.forgotPassword(t.email)}catch{}r(e,"forgot-success"),Domma.icons.scan()}});const s=t=>{t.preventDefault(),r(e,"login")};e.find("#forgot-back-link").on("click",s),e.find("#forgot-success-back-link").on("click",s)}function M(e,s){F.render("#reset-form-container",P,{},{layout:"stacked",submitText:"Set new password",onSubmit:async t=>{if(t.password!==t.confirmPassword)return E.toast("Passwords do not match.",{type:"error"}),!1;try{await i.auth.resetPassword(s,t.password),E.toast("Password updated. Please sign in.",{type:"success"}),window.location.hash="#/login"}catch(o){return E.toast(o.message||"Link expired or invalid.",{type:"error"}),!1}}})}
@@ -1 +1 @@
1
- import{api as V}from"../api.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{accountFacts as re,buildPayload as he,checkForm as se,draftOf as be,fieldKind as H,fieldOptions as U,formFromMe as W,initials as ye,snapshot as T}from"../lib/my-profile-arrange.js";const z=/^\/my-profile\/?(\?.*)?$/,y=t=>String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Y=t=>{if(!t)return"";try{return D(t).format("D MMM YYYY")}catch{return String(t).slice(0,10)}};let $=null,le=!1;function $e(){le||!window.R?.use||(le=!0,R.use((t,o,r)=>!(z.test(o?.path||"")&&!z.test(t?.path||""))||!$?.dirty()?r():E.confirm("You have unsaved changes to your profile. Leave anyway? They are kept for your next visit - except a new password, which you would type again.").then(i=>{i?r():history.replaceState(null,"","#/my-profile")})),M.subscribe("router:afterChange",({to:t}={})=>{if($&&!z.test(t?.path||"")){const o=$;$=null,o.dispose()}}))}function ge(t,o){const r=H(t.type),m=`mpx-pf-${o}`,i=t.required?' <span class="text-danger">*</span>':"",v=t.help||t.description?` <span data-help="${y(t.help||t.description)}"></span>`:"",u=`<label class="form-label" for="${m}">${y(t.label||t.name)}${i}${v}</label>`,b=`<small class="mpx-err" data-bind-hidden="!pe_${o}.value" data-bind-text="pe_${o}.value"></small>`,s=t.placeholder?` placeholder="${y(t.placeholder)}"`:"";switch(r){case"textarea":return`<div class="mpx-wide">${u}<textarea id="${m}" class="form-input" rows="3"${s} data-model="pf_${o}.value"></textarea>${b}</div>`;case"select":return`<div>${u}<select id="${m}" class="form-input" data-model="pf_${o}.value"><option value="">-</option>${U(t).map(c=>`<option value="${y(c.value)}">${y(c.label)}</option>`).join("")}</select>${b}</div>`;case"checkbox":return`<div><label class="mpx-check"><input id="${m}" type="checkbox" data-model="pf_${o}.value"> ${y(t.label||t.name)}${i}</label>${v}${b}</div>`;case"multi":return`<div class="mpx-wide">${u}<div data-multi="${o}">${U(t).map((c,f)=>`<label class="mpx-check"><input type="checkbox" data-opt="${y(c.value)}" data-bind-checked="po_${o}_${f}.value"> ${y(c.label)}</label>`).join("")}</div>${b}</div>`;default:return`<div>${u}<input id="${m}" type="${r}" class="form-input"${s} data-model="pf_${o}.value">${b}</div>`}}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(t){if($e(),$){const e=$;$=null,e.dispose()}const o=[],r=e=>(o.push(e),e);let m=!1;const i=t.get(0).querySelector(".mpx")||t.get(0),v=()=>!m&&i.isConnected,u=M.observable(!0),b=M.observable(""),s=M.observable(null),c=M.observable(""),f=M.observable(""),w=M.observable(""),j=M.observable(""),ce=M.observable(!1),P=M.observable(!1),G=M.observable(!1);let p=[];const g=new Map,J=M.observable(0),h=M.observable(W({},[])),ie=()=>(J.value,Object.fromEntries([...g].map(([e,a])=>[e,a.value]))),ue=()=>Object.fromEntries([...g].map(([e,a])=>[e,a.peek()])),Q=()=>({name:c.value,email:f.value,password:w.value,confirm:j.value,profile:ie()}),X=()=>({name:c.peek(),email:f.peek(),password:w.peek(),confirm:j.peek(),profile:ue()}),B=M.computed(()=>se(Q(),p,{saved:h.value})),k=M.computed(()=>!u.value&&T(Q())!==T(h.value)),pe={loading:u,loadError:b,name:c,email:f,password:w,confirm:j,showPw:ce,hasProfile:G,err:B,dirty:k,cannotSave:M.computed(()=>P.value||!k.value||Object.keys(B.value).length>0),emailChanged:M.computed(()=>f.value.trim()!==""&&f.value.trim()!==h.value.email),pwMatch:M.computed(()=>w.value.length>=8&&w.value===j.value),avatar:M.computed(()=>ye(c.value||s.value?.name)),shownName:M.computed(()=>c.value.trim()||s.value?.name||""),shownEmail:M.computed(()=>f.value.trim()||s.value?.email||""),rolesText:M.computed(()=>re(s.value).roles.join(" + ")),accessText:M.computed(()=>re(s.value).access),factsText:M.computed(()=>{const e=s.value;return e?[e.createdAt&&`Member since ${Y(e.createdAt)}`,e.lastLogin&&`last signed in ${Y(e.lastLogin)}`,e.updatedAt&&`changed ${Y(e.updatedAt)}`,e.isActive===!1&&"account disabled"].filter(Boolean).join(" \xB7 "):""})},A=()=>`my-profile.draft.${s.peek()?.id||"me"}`;function N(e){c.value=e.name,f.value=e.email,w.value="",j.value="";for(const[a,l]of g)a in(e.profile||{})&&(l.value=e.profile[a])}async function q(){if(P.peek()||u.peek())return;const e=X();if(Object.keys(se(e,p,{saved:h.peek()})).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const a=he(h.peek(),e,p);if(!a){E.toast("Nothing has changed.",{type:"info"});return}P.value=!0;try{const l=await V.auth.updateMe(a.payload);if(!v())return;s.value=l;const C=W(l,p);h.value=C,N(C),F();try{S.set("auth_user",{...S.get("auth_user")||{},name:l.name,email:l.email})}catch{}const n=document.getElementById("topbar-user-name");n&&l.name&&(n.textContent=l.name);const d={name:"your name",email:"your email",password:"your password",profile:"your profile"};E.toast(`Saved ${a.parts.map(_=>d[_]).join(", ").replace(/, ([^,]*)$/," and $1")}.`,{type:"success"})}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{P.value=!1}}async function de(){k.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!v()||(N(h.peek()),F()))}function Z(){if(!k.peek())return;const e=be(X());if(T({...e,password:"",confirm:""})!==T(h.peek()))try{S.set(A(),{form:e,at:new Date().toISOString()})}catch{}}function F(){try{S.remove?S.remove(A()):S.set(A(),null)}catch{}}const me=M.applyBindings(pe,i,{methods:{save:q,discard:de}});r(()=>me.dispose()),r(ne(i,{position:"bottom"})),fe(t,{id:"my-profile-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>q()});const ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!v()||(e.preventDefault(),e.stopImmediatePropagation(),q())};document.addEventListener("keydown",ee,!0),r(()=>document.removeEventListener("keydown",ee,!0));const te=e=>{k.peek()&&(Z(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",te),r(()=>window.removeEventListener("beforeunload",te)),$={dirty:()=>k.peek(),dispose(){m=!0,Z(),o.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let ae=null;try{const[e,a]=await Promise.all([V.auth.me(),V.collections.get("user-profiles").catch(()=>null)]);if(!v())return;s.value=e,ae=a}catch(e){if(!v())return;b.value=`Could not load your account: ${e.message||e}`,u.value=!1;return}p=(ae?.fields||[]).filter(e=>e&&e.name);const L=W(s.peek(),p),K=p.map((e,a)=>({f:e,i:a})).filter(({f:e})=>H(e.type)!=="hidden");if(p.forEach((e,a)=>g.set(e.name,M.observable(L.profile[e.name]))),K.length){const e=i.querySelector(".mpx-profile");e.innerHTML=K.map(({f:n,i:d})=>ge(n,d)).join("");const a={};p.forEach((n,d)=>{a[`pf_${d}`]=g.get(n.name),a[`pe_${d}`]=M.computed(()=>B.value[`profile.${n.name}`]||""),H(n.type)==="multi"&&U(n).forEach((_,O)=>{a[`po_${d}_${O}`]=M.computed(()=>(g.get(n.name).value||[]).includes(_.value))})});const l=M.applyBindings(a,e);r(()=>l.dispose());const C=n=>{const d=n.target.closest("[data-multi]");if(!d||!n.target.matches("input[data-opt]"))return;const _=g.get(p[Number(d.dataset.multi)].name),O=n.target.dataset.opt,oe=_.peek()||[];_.value=n.target.checked?[...new Set([...oe,O])]:oe.filter(ve=>ve!==O)};e.addEventListener("change",C),r(()=>e.removeEventListener("change",C)),r(ne(e,{position:"bottom"})),I.scan(e)}G.value=K.length>0,h.value=L,N(L),J.value++,u.value=!1;let x=null;try{x=S.get(A())}catch{}if(x?.form){F();const e=x.at?` (${Y(x.at)})`:"";await E.confirm(`You left unsaved changes to your profile${e}. Restore them?`)&&v()&&N({...L,...x.form,profile:{...L.profile,...x.form.profile||{}}})}}};
1
+ import{api as G,apiRequest as Le,getToken as Te,refreshAccessToken as Ae}from"../api.js";import{avatarUrl as F,showTopbarAvatar as me}from"../lib/avatar.js";import{addBannerAction as _e}from"../lib/plugin-chrome.js";import{helpPopovers as fe}from"../lib/help-popover.js";import{accountFacts as he,buildPayload as De,checkForm as ye,draftOf as je,fieldKind as J,fieldOptions as Q,formFromMe as X,initials as Ce,snapshot as K}from"../lib/my-profile-arrange.js";const Z=/^\/my-profile\/?(\?.*)?$/,w=t=>String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),U=t=>{if(!t)return"";try{return D(t).format("D MMM YYYY")}catch{return String(t).slice(0,10)}};let $=null,ge=!1;function Pe(){ge||!window.R?.use||(ge=!0,R.use((t,s,l)=>!(Z.test(s?.path||"")&&!Z.test(t?.path||""))||!$?.dirty()?l():E.confirm("You have unsaved changes to your profile. Leave anyway? They are kept for your next visit - except a new password, which you would type again.").then(c=>{c?l():history.replaceState(null,"","#/my-profile")})),M.subscribe("router:afterChange",({to:t}={})=>{if($&&!Z.test(t?.path||"")){const s=$;$=null,s.dispose()}}))}function Ne(t,s){const l=J(t.type),m=`mpx-pf-${s}`,c=t.required?' <span class="text-danger">*</span>':"",u=t.help||t.description?` <span data-help="${w(t.help||t.description)}"></span>`:"",d=`<label class="form-label" for="${m}">${w(t.label||t.name)}${c}${u}</label>`,g=`<small class="mpx-err" data-bind-hidden="!pe_${s}.value" data-bind-text="pe_${s}.value"></small>`,r=t.placeholder?` placeholder="${w(t.placeholder)}"`:"";switch(l){case"textarea":return`<div class="mpx-wide">${d}<textarea id="${m}" class="form-input" rows="3"${r} data-model="pf_${s}.value"></textarea>${g}</div>`;case"select":return`<div>${d}<select id="${m}" class="form-input" data-model="pf_${s}.value"><option value="">-</option>${Q(t).map(p=>`<option value="${w(p.value)}">${w(p.label)}</option>`).join("")}</select>${g}</div>`;case"checkbox":return`<div><label class="mpx-check"><input id="${m}" type="checkbox" data-model="pf_${s}.value"> ${w(t.label||t.name)}${c}</label>${u}${g}</div>`;case"multi":return`<div class="mpx-wide">${d}<div data-multi="${s}">${Q(t).map((p,f)=>`<label class="mpx-check"><input type="checkbox" data-opt="${w(p.value)}" data-bind-checked="po_${s}_${f}.value"> ${w(p.label)}</label>`).join("")}</div>${g}</div>`;default:return`<div>${d}<input id="${m}" type="${l}" class="form-input"${r} data-model="pf_${s}.value">${g}</div>`}}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(t){if(Pe(),$){const e=$;$=null,e.dispose()}const s=[],l=e=>(s.push(e),e);let m=!1;const c=t.get(0).querySelector(".mpx")||t.get(0),u=()=>!m&&c.isConnected,d=M.observable(!0),g=M.observable(""),r=M.observable(null),p=M.observable(""),f=M.observable(""),x=M.observable(""),j=M.observable(""),be=M.observable(!1),N=M.observable(!1),ee=M.observable(!1);let v=[];const k=new Map,te=M.observable(0),h=M.observable(X({},[])),b=M.observable(!1),O=M.observable(!1),we=()=>(te.value,Object.fromEntries([...k].map(([e,a])=>[e,a.value]))),$e=()=>Object.fromEntries([...k].map(([e,a])=>[e,a.peek()])),ae=()=>({name:p.value,email:f.value,password:x.value,confirm:j.value,profile:we()}),re=()=>({name:p.peek(),email:f.peek(),password:x.peek(),confirm:j.peek(),profile:$e()}),H=M.computed(()=>ye(ae(),v,{saved:h.value})),L=M.computed(()=>!d.value&&K(ae())!==K(h.value)),ke={loading:d,loadError:g,name:p,email:f,password:x,confirm:j,showPw:be,hasProfile:ee,err:H,dirty:L,cannotSave:M.computed(()=>N.value||!L.value||Object.keys(H.value).length>0),emailChanged:M.computed(()=>f.value.trim()!==""&&f.value.trim()!==h.value.email),pwMatch:M.computed(()=>x.value.length>=8&&x.value===j.value),avatar:M.computed(()=>Ce(p.value||r.value?.name)),avatarUrl:M.computed(()=>F(r.value)),avatarAction:M.computed(()=>b.value?"Uploading\u2026":F(r.value)?"Change picture":"Add a picture"),uploading:b,dropping:O,shownName:M.computed(()=>p.value.trim()||r.value?.name||""),shownEmail:M.computed(()=>f.value.trim()||r.value?.email||""),rolesText:M.computed(()=>he(r.value).roles.join(" + ")),accessText:M.computed(()=>he(r.value).access),factsText:M.computed(()=>{const e=r.value;return e?[e.createdAt&&`Member since ${U(e.createdAt)}`,e.lastLogin&&`last signed in ${U(e.lastLogin)}`,e.updatedAt&&`changed ${U(e.updatedAt)}`,e.isActive===!1&&"account disabled"].filter(Boolean).join(" \xB7 "):""})},Y=()=>`my-profile.draft.${r.peek()?.id||"me"}`;function q(e){p.value=e.name,f.value=e.email,x.value="",j.value="";for(const[a,o]of k)a in(e.profile||{})&&(o.value=e.profile[a])}async function V(){if(N.peek()||d.peek())return;const e=re();if(Object.keys(ye(e,v,{saved:h.peek()})).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const a=De(h.peek(),e,v);if(!a){E.toast("Nothing has changed.",{type:"info"});return}N.value=!0;try{const o=await G.auth.updateMe(a.payload);if(!u())return;r.value=o;const y=X(o,v);h.value=y,q(y),z();try{S.set("auth_user",{...S.get("auth_user")||{},name:o.name,email:o.email})}catch{}const n=document.getElementById("topbar-user-name");n&&o.name&&(n.textContent=o.name);const i={name:"your name",email:"your email",password:"your password",profile:"your profile"};E.toast(`Saved ${a.parts.map(_=>i[_]).join(", ").replace(/, ([^,]*)$/," and $1")}.`,{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{N.value=!1}}async function Me(){L.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!u()||(q(h.peek()),z()))}function oe(){if(!L.peek())return;const e=je(re());if(K({...e,password:"",confirm:""})!==K(h.peek()))try{S.set(Y(),{form:e,at:new Date().toISOString()})}catch{}}function z(){try{S.remove?S.remove(Y()):S.set(Y(),null)}catch{}}const C=c.querySelector(".mpx-avatar-file");async function ne(e){if(!(!e||b.peek())){if(!/^image\//.test(e.type)&&!/\.(heic|heif|avif)$/i.test(e.name)){E.toast("Choose an image file.",{type:"warning"});return}if(e.size>8*1024*1024){E.toast("That image is too large - at most 8 MB.",{type:"warning"});return}b.value=!0;try{const a=n=>{const i=new FormData;return i.append("file",e,e.name),fetch("/api/auth/me/avatar",{method:"POST",body:i,headers:n?{Authorization:`Bearer ${n}`}:{}})};let o=await a(Te());o.status===401&&(o=await a(await Ae()));const y=await o.json().catch(()=>({}));if(!o.ok)throw new Error(y.error||`HTTP ${o.status}`);if(!u())return;r.value={...r.peek(),avatar:y.avatar},me(F(y)),E.toast("Your picture is saved.",{type:"success"})}catch(a){E.toast(`Not saved: ${a.message||a}`,{type:"error"})}finally{b.value=!1,C.value=""}}}async function Ee(){if(!(!F(r.peek())||b.peek())&&!(!await E.confirm("Remove your picture? Your initials show instead.")||!u())){b.value=!0;try{if(await Le("/auth/me/avatar",{method:"DELETE"}),!u())return;r.value={...r.peek(),avatar:""},me(""),E.toast("Picture removed.",{type:"success"})}catch(e){E.toast(`Not removed: ${e.message||e}`,{type:"error"})}finally{b.value=!1}}}const se=()=>ne(C.files?.[0]);C.addEventListener("change",se),l(()=>C.removeEventListener("change",se));const T=c.querySelector(".mpx-avatar"),le=e=>{[...e.dataTransfer?.types||[]].includes("Files")&&(e.preventDefault(),O.value=!0)},ie=()=>{O.value=!1},ce=e=>{e.preventDefault(),O.value=!1,ne(e.dataTransfer?.files?.[0])};T.addEventListener("dragover",le),T.addEventListener("dragleave",ie),T.addEventListener("drop",ce),l(()=>{T.removeEventListener("dragover",le),T.removeEventListener("dragleave",ie),T.removeEventListener("drop",ce)});const xe=M.applyBindings(ke,c,{methods:{save:V,discard:Me,removeAvatar:Ee,pickAvatar:()=>C.click()}});l(()=>xe.dispose()),l(fe(c,{position:"bottom"})),_e(t,{id:"my-profile-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>V()});const ue=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!u()||(e.preventDefault(),e.stopImmediatePropagation(),V())};document.addEventListener("keydown",ue,!0),l(()=>document.removeEventListener("keydown",ue,!0));const pe=e=>{L.peek()&&(oe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",pe),l(()=>window.removeEventListener("beforeunload",pe)),$={dirty:()=>L.peek(),dispose(){m=!0,oe(),s.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(c);let de=null;try{const[e,a]=await Promise.all([G.auth.me(),G.collections.get("user-profiles").catch(()=>null)]);if(!u())return;r.value=e,de=a}catch(e){if(!u())return;g.value=`Could not load your account: ${e.message||e}`,d.value=!1;return}v=(de?.fields||[]).filter(e=>e&&e.name);const P=X(r.peek(),v),W=v.map((e,a)=>({f:e,i:a})).filter(({f:e})=>J(e.type)!=="hidden");if(v.forEach((e,a)=>k.set(e.name,M.observable(P.profile[e.name]))),W.length){const e=c.querySelector(".mpx-profile");e.innerHTML=W.map(({f:n,i})=>Ne(n,i)).join("");const a={};v.forEach((n,i)=>{a[`pf_${i}`]=k.get(n.name),a[`pe_${i}`]=M.computed(()=>H.value[`profile.${n.name}`]||""),J(n.type)==="multi"&&Q(n).forEach((_,B)=>{a[`po_${i}_${B}`]=M.computed(()=>(k.get(n.name).value||[]).includes(_.value))})});const o=M.applyBindings(a,e);l(()=>o.dispose());const y=n=>{const i=n.target.closest("[data-multi]");if(!i||!n.target.matches("input[data-opt]"))return;const _=k.get(v[Number(i.dataset.multi)].name),B=n.target.dataset.opt,ve=_.peek()||[];_.value=n.target.checked?[...new Set([...ve,B])]:ve.filter(Se=>Se!==B)};e.addEventListener("change",y),l(()=>e.removeEventListener("change",y)),l(fe(e,{position:"bottom"})),I.scan(e)}ee.value=W.length>0,h.value=P,q(P),te.value++,d.value=!1;let A=null;try{A=S.get(Y())}catch{}if(A?.form){z();const e=A.at?` (${U(A.at)})`:"";await E.confirm(`You left unsaved changes to your profile${e}. Restore them?`)&&u()&&q({...P,...A.form,profile:{...P.profile,...A.form.profile||{}}})}}};
package/bin/update.js CHANGED
@@ -436,8 +436,12 @@ export default async function update(_positional, flags) {
436
436
  done();
437
437
 
438
438
  // -------------------------------------------------------------------------
439
- // 8. Register new plugins in plugins.json (disabled by default)
439
+ // 8. Register new plugins in plugins.json (disabled by default - except the
440
+ // ones that protect the site, which arrive switched on: a security tool
441
+ // left off protects nobody. Only the FIRST time: a site that switched it
442
+ // off keeps it off.)
440
443
  // -------------------------------------------------------------------------
444
+ const ON_ARRIVAL = new Set(['security']);
441
445
 
442
446
  const pluginsJsonPath = path.join(cwd, 'config/plugins.json');
443
447
  if (newPlugins.length > 0 && existsSync(pluginsJsonPath)) {
@@ -445,7 +449,7 @@ export default async function update(_positional, flags) {
445
449
  const pluginsCfg = readJson(pluginsJsonPath);
446
450
  for (const name of newPlugins) {
447
451
  if (!(name in pluginsCfg)) {
448
- pluginsCfg[name] = {enabled: false, settings: {}};
452
+ pluginsCfg[name] = {enabled: ON_ARRIVAL.has(name), settings: {}};
449
453
  }
450
454
  }
451
455
  writeJson(pluginsJsonPath, pluginsCfg);
@@ -579,7 +583,7 @@ export default async function update(_positional, flags) {
579
583
  }
580
584
 
581
585
  if (newPlugins.length > 0) {
582
- info(`New plugins added (disabled by default): ${newPlugins.join(', ')}`);
586
+ info(`New plugins added: ${newPlugins.join(', ')}`);
583
587
  }
584
588
 
585
589
  const allMergedKeys = Object.entries(mergedKeys);
@@ -26,5 +26,10 @@
26
26
  "enabled": false,
27
27
  "bundled": true,
28
28
  "settings": {}
29
+ },
30
+ "security": {
31
+ "enabled": true,
32
+ "bundled": true,
33
+ "settings": {}
29
34
  }
30
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.81.1",
3
+ "version": "0.83.0",
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",
@@ -25,6 +25,7 @@
25
25
  "!plugins/notes/data",
26
26
  "!plugins/todo/data",
27
27
  "!plugins/mail-reader/data",
28
+ "!plugins/security/data",
28
29
  "!plugins/_lib/data",
29
30
  "scripts/",
30
31
  "docs/",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@5f64a68",
3
+ "source": "dcms-marketplace@83880c0",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.6.3",
@@ -75,6 +75,30 @@
75
75
  "plugin.js": "ebe4c41bdd8836fb3128af3a0e789d47f89b00e9e37dc9378bb308aaae8d41b2",
76
76
  "plugin.json": "ed1f02d9405f6ae1257313ea4155ea49e307fb2210650fd247b92e6988aa78d1"
77
77
  }
78
+ },
79
+ "security": {
80
+ "version": "1.0.0",
81
+ "files": {
82
+ "CLAUDE.md": "ae819fd418724c202ec618aae0adb936d2bcebee94afcb40d9944687a335be7e",
83
+ "admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
84
+ "admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
85
+ "admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
86
+ "admin/lib/lockout.js": "c5e5e4b2934296f81ae8ed013725485bc269a0ef3cf3bdf3c16c53936635a1ee",
87
+ "admin/lib/policy.js": "a4aedfd28f992e0e7759bbdac6344681eebe617c27849e91fa4405f6190f8462",
88
+ "admin/lib/probes.js": "d56318903cb645a39f56e7762eddb7411941a72e1f446c1f9d79e35157f1d31b",
89
+ "admin/lib/settings.js": "9b6c2fb3b54a1dcb642e3af6f584f93375f824fce67717a1ee56bebee9c35645",
90
+ "admin/templates/security.html": "e96060b4849a1ef7691a918b9a77aff05ea3a2182a6093001454d04eb58c648d",
91
+ "admin/views/security.js": "dcd38e07c5e83bbe013f5d764c52edef76d28ed4c3fe1cba8c5a59ce1b93e962",
92
+ "bin/unlock.js": "5fcc3c0ef3ba3c424a366aa62896f51abad578183dfde283aa838a86549ca785",
93
+ "config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
94
+ "plugin.js": "1561f1ed79c919e6e95407a159a3b151aa7cd867893ba17d62a8761a7fbd383b",
95
+ "plugin.json": "01160560c8b0df5661859a1cdaf9ab81ee6b13735f06cce48602bf6700acb686",
96
+ "server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
97
+ "server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
98
+ "server/facts.js": "fc07b1d697d2d8dc28accbba133c0f407a4ec522597fb8e146c475d0fc0fcde5",
99
+ "server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
100
+ "tests/security.test.js": "951636a0e42ac8dbf78b69ca7e7e2adcd4e29d94a51998e738c3afa742c00866"
101
+ }
78
102
  }
79
103
  }
80
104
  }
@@ -0,0 +1,41 @@
1
+ # Security (free) - AI Assistant Guide
2
+
3
+ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it first arrives (bin/update.js
4
+ `ON_ARRIVAL`). Security Pro (paid) will supersede it. Plan: dcms-marketplace/docs/security-plan.md.
5
+
6
+ ## What it does
7
+ - **Guard** (global `onRequest`; `fastify-plugin`, like Analytics Pro): a blocked address gets a bare 403; a probe path
8
+ (`admin/lib/probes.js` - WordPress, .env, .git, *.php...) a bare 404 before any rendering; N probes in M minutes from
9
+ one address = a temporary block. Genuinely local calls (loopback socket, no X-Forwarded-For) are never touched; an
10
+ address someone signed in from in the last 7 days is never auto-blocked (a manual rule still applies). Fails OPEN.
11
+ - **Sign-in lockout**: a 'before' login check (core 0.83 `registerLoginCheck`) refuses while the account or the address
12
+ is locked (429, "Try again in N minutes" - never says whether the account exists). Counted from `auth:loginFailed`
13
+ (not the lockout's own refusals); progressive (`admin/lib/lockout.js`); a success clears the account.
14
+ - **Password rules**: `registerPasswordCheck` - min length (10), not in `server/common-passwords.txt` (SecLists 10k,
15
+ MIT - see the NOTICE), not containing the name/email, not one or two characters repeated.
16
+ - **Sign-in log**: `data/signins.jsonl`, kept `signinDays`; notifications (sources `security:lockouts`,
17
+ `security:suspicious`, `security:probes` - the last off by default).
18
+ - **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
19
+ public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
20
+ Unknown = 'info', never a pass. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
21
+ when a failing check is critical.
22
+ - Engines before 0.83 have no sign-in checks: the plugin logs it, the screen says so, lockout/rules are off.
23
+
24
+ ## Data (plugins/security/data - kept by updates, never bundled)
25
+ `state.json` = {lockouts, rules, temp, trusted, counters}, in memory + debounced writes; RE-READ when changed on disk
26
+ (the CLI). `signins.jsonl`.
27
+
28
+ ## Emergency
29
+ `node plugins/security/bin/unlock.js --list | --all | --ip X | --user email` in the site folder; the site picks it up
30
+ within ~3 s. It must always work - keep it dependency-free.
31
+
32
+ ## Permission
33
+ `security` (read / manage), granted to admin. Routes: /health /count /overview /signins /blocks (GET/POST/DELETE)
34
+ /lockouts (DELETE) /settings (GET/PUT).
35
+
36
+ ## Admin
37
+ `admin/views/security.js` + `admin/templates/security.html`: Overview / Sign-ins / Blocked, settings slideover
38
+ (cog), Refresh. Public data (emails, addresses, user-agents) only through data-bind-text; rows by index.
39
+
40
+ ## Tests
41
+ `tests/security.test.js` (pure). Headless: throwaway CMS with TRUST_PROXY=loopback and X-Forwarded-For.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Security - blocking an address: manual rules (ip.js syntax) and temporary
3
+ * blocks (an address that kept probing). An address that signed in recently is
4
+ * "trusted": probing never blocks it (an admin mistyping /wp-admin must not lock
5
+ * themselves out) - a manual rule still does. Loopback is never blocked. Pure.
6
+ */
7
+ import {ipMatches, isLoopbackIp, plainIp} from './ip.js';
8
+
9
+ export const TRUST_DAYS = 7;
10
+
11
+ /**
12
+ * Why an address is blocked, or null.
13
+ *
14
+ * @param {{rules: {rule: string}[], temp: Object<string, {until: number, reason: string}>}} state
15
+ */
16
+ export function blockedBy(state, ip, now = Date.now()) {
17
+ const a = plainIp(ip);
18
+ if (!a || isLoopbackIp(a)) return null;
19
+ const rule = (state.rules || []).find(r => ipMatches(a, [r.rule]));
20
+ if (rule) return {kind: 'rule', rule: rule.rule, reason: rule.note || 'Blocked by hand'};
21
+ const t = state.temp?.[a];
22
+ if (t && t.until > now) return {kind: 'temp', until: t.until, reason: t.reason};
23
+ return null;
24
+ }
25
+
26
+ export const isTrusted = (state, ip, now = Date.now()) => (state.trusted?.[plainIp(ip)] || 0) > now - TRUST_DAYS * 86_400_000;
27
+
28
+ /**
29
+ * Count a probe from an address (in `recent`, a Map kept in memory). True when
30
+ * it should now be blocked.
31
+ */
32
+ export function recordProbe(recent, ip, {now = Date.now(), blockAfter = 5, withinMin = 10} = {}) {
33
+ const a = plainIp(ip);
34
+ const list = (recent.get(a) || []).filter(t => t > now - withinMin * 60_000);
35
+ list.push(now);
36
+ recent.set(a, list);
37
+ if (recent.size > 10_000) recent.delete(recent.keys().next().value);
38
+ return list.length >= blockAfter;
39
+ }
40
+
41
+ /** Forget ended temporary blocks and old trust. */
42
+ export function pruneBlocks(state, now = Date.now()) {
43
+ for (const [ip, t] of Object.entries(state.temp || {})) if (t.until < now) delete state.temp[ip];
44
+ for (const [ip, at] of Object.entries(state.trusted || {})) if (at < now - TRUST_DAYS * 86_400_000) delete state.trusted[ip];
45
+ return state;
46
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Security - the health check: facts about the site in, checks out. Pure; the
3
+ * server gathers the facts (server/facts.js). Each check says what it found,
4
+ * why it matters and how to fix it - a check that cannot be answered is 'info',
5
+ * never a pass.
6
+ *
7
+ * status: 'pass' | 'warn' | 'fail' | 'info'; `critical` makes a fail red in the badge.
8
+ */
9
+
10
+ const DAY = 86_400_000;
11
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
12
+ const names = (list) => (list.length <= 3 ? list.join(', ') : `${list.slice(0, 3).join(', ')} and ${list.length - 3} more`);
13
+
14
+ function semverBehind(installed, latest) {
15
+ const a = String(installed || '').split('.').map(Number);
16
+ const b = String(latest || '').split('.').map(Number);
17
+ if (a.length < 3 || b.length < 3 || [...a, ...b].some(Number.isNaN)) return null;
18
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? {major: i === 0, minor: i === 1, patch: i === 2} : false;
19
+ return false;
20
+ }
21
+
22
+ /**
23
+ * @param {object} f - facts (see server/facts.js)
24
+ * @param {{now?: number, staleAdminDays?: number}} [opts]
25
+ * @returns {{checks: object[], score: number, failing: number, critical: number}}
26
+ */
27
+ export function evaluate(f = {}, {now = Date.now(), staleAdminDays = 90} = {}) {
28
+ const checks = [];
29
+ const add = (c) => checks.push({critical: false, link: '', ...c});
30
+
31
+ // --- People ---
32
+ const admins = f.admins || [];
33
+ const stale = admins.filter(a => !a.lastLogin || Date.parse(a.lastLogin) < now - staleAdminDays * DAY);
34
+ add({id: 'admins-stale', group: 'People', title: 'Unused admin accounts',
35
+ status: !admins.length ? 'info' : stale.length ? 'warn' : 'pass',
36
+ detail: stale.length ? `${plural(stale.length, 'admin')} not signed in for ${staleAdminDays}+ days: ${names(stale.map(a => a.name || a.email))}.` : `Every admin has signed in within ${staleAdminDays} days.`,
37
+ why: 'An admin account nobody uses is one nobody would notice being used by someone else.',
38
+ fix: 'Switch off or remove admin accounts that are no longer needed, or lower their role.', link: '#/users'});
39
+ add({id: 'admins-count', group: 'People', title: 'How many admins',
40
+ status: admins.length > 5 ? 'warn' : admins.length ? 'pass' : 'info',
41
+ detail: `${plural(admins.length, 'admin')} (admin or super admin).`,
42
+ why: 'Every admin can change everything. Fewer admins, fewer ways in.',
43
+ fix: 'Give people the lowest role that lets them do their job.', link: '#/users'});
44
+ const never = (f.users || []).filter(u => !u.lastLogin && u.createdAt && Date.parse(u.createdAt) < now - 30 * DAY);
45
+ add({id: 'users-never', group: 'People', title: 'Accounts never used',
46
+ status: never.length ? 'warn' : 'pass',
47
+ detail: never.length ? `${plural(never.length, 'account')} made over 30 days ago and never signed in: ${names(never.map(u => u.name || u.email))}.` : 'Every account older than 30 days has been used.',
48
+ why: 'An account with a password nobody changed is an easy one to guess.',
49
+ fix: 'Remove accounts nobody uses.', link: '#/users'});
50
+
51
+ // --- Sign-in ---
52
+ add({id: 'lockout', group: 'Sign-in', title: 'Sign-in lockout',
53
+ status: f.protections?.checks === false ? 'fail' : f.protections?.lockout ? 'pass' : 'warn', critical: f.protections?.checks === false,
54
+ detail: f.protections?.checks === false ? 'This engine is too old for sign-in checks (needs Domma CMS 0.83 or later).'
55
+ : f.protections?.lockout ? 'Repeated wrong passwords lock the account and the address for a while.' : 'Switched off in the settings.',
56
+ why: 'Without it, a password can be guessed as fast as the rate limit allows, forever.',
57
+ fix: f.protections?.checks === false ? 'Update the CMS.' : 'Switch it on in the settings (the cog).'});
58
+ add({id: 'policy', group: 'Sign-in', title: 'Password rules',
59
+ status: f.protections?.policy ? (f.policyMin >= 10 ? 'pass' : 'warn') : 'warn',
60
+ detail: f.protections?.policy ? `New passwords: at least ${f.policyMin} characters, not a common one, not your name or email.` : 'Only the CMS minimum (8 characters) applies.',
61
+ why: 'Most break-ins use passwords that are short, common or guessable from the person.',
62
+ fix: 'Switch the password rules on, with at least 10 characters.'});
63
+ add({id: 'two-factor', group: 'Sign-in', title: 'Two-factor sign-in',
64
+ status: f.twoFactor ? 'pass' : 'info',
65
+ detail: f.twoFactor ? 'Available.' : 'Not available - Security Pro adds two-factor sign-in with an authenticator app.',
66
+ why: 'A stolen or guessed password alone is then not enough to get in.',
67
+ fix: 'Security Pro.'});
68
+ add({id: 'smtp', group: 'Sign-in', title: 'Email for resets and alerts',
69
+ status: f.smtp ? 'pass' : 'warn',
70
+ detail: f.smtp ? 'Email is set up.' : 'No outgoing email: password resets and alerts cannot be sent.',
71
+ why: 'Without email, a locked-out admin has no self-service way back, and alerts go unseen.',
72
+ fix: 'Set up email in Settings.', link: '#/settings'});
73
+
74
+ // --- Server ---
75
+ const behind = semverBehind(f.engine?.installed, f.engine?.latest);
76
+ add({id: 'engine', group: 'Server', title: 'Domma CMS up to date',
77
+ status: behind === null ? 'info' : behind ? (behind.major || behind.minor ? 'warn' : 'pass') : 'pass',
78
+ detail: behind === null ? `Running ${f.engine?.installed || 'an unknown version'}; the latest could not be checked.`
79
+ : behind ? `Running ${f.engine.installed}; ${f.engine.latest} is out.` : `Running ${f.engine.installed}, the latest.`,
80
+ why: 'Updates carry security fixes.', fix: 'Update the CMS (or ask whoever hosts it).'});
81
+ add({id: 'production', group: 'Server', title: 'Production mode',
82
+ status: f.nodeEnv === 'production' ? 'pass' : 'warn',
83
+ detail: f.nodeEnv === 'production' ? 'NODE_ENV is production.' : `NODE_ENV is "${f.nodeEnv || 'not set'}".`,
84
+ why: 'Outside production, errors can show more than a visitor should see.',
85
+ fix: 'Set NODE_ENV=production in the site\'s .env and restart.'});
86
+ if (f.proxy?.via) {
87
+ const t = f.proxy.trust;
88
+ add({id: 'proxy', group: 'Server', title: 'Visitor addresses',
89
+ status: t === false ? 'fail' : t === true ? 'warn' : 'pass', critical: t === false,
90
+ detail: t === false ? 'The site is behind a proxy but does not trust it: every visitor looks like the proxy.'
91
+ : t === true ? 'Every proxy hop is trusted (TRUST_PROXY=1): a visitor can choose the address they appear as.' : `TRUST_PROXY=${t}.`,
92
+ why: 'Rate limits, lockouts and blocks all go by the visitor\'s address.',
93
+ fix: 'Set TRUST_PROXY=loopback in the site\'s .env and restart.'});
94
+ }
95
+ if (f.bind) {
96
+ add({id: 'bind', group: 'Server', title: 'Listening address',
97
+ status: f.bind === '0.0.0.0' && f.proxy?.via ? 'warn' : 'pass',
98
+ detail: f.bind === '0.0.0.0' && f.proxy?.via ? 'The CMS listens on every network interface although a proxy fronts it.' : `Listening on ${f.bind}.`,
99
+ why: 'Reached directly, the CMS bypasses the proxy and its protections.',
100
+ fix: 'Set "host": "127.0.0.1" in config/server.json when a proxy sits in front.'});
101
+ }
102
+
103
+ // --- Web ---
104
+ if (f.cert) {
105
+ const d = f.cert.daysLeft;
106
+ add({id: 'certificate', group: 'Web', title: 'HTTPS certificate',
107
+ status: d === null || d === undefined ? 'info' : d < 7 ? 'fail' : d < 21 ? 'warn' : 'pass', critical: d !== null && d < 7,
108
+ detail: d === null || d === undefined ? `Could not read the certificate${f.cert.error ? `: ${f.cert.error}` : ''}.` : d < 0 ? `Expired ${-d} days ago.` : `Valid for ${plural(d, 'more day')}${f.cert.issuer ? ` (${f.cert.issuer})` : ''}.`,
109
+ why: 'An expired certificate turns every visitor away with a warning.',
110
+ fix: 'Renew it (Site Manager renews automatically on the fleet).'});
111
+ }
112
+ const h = f.headers;
113
+ if (h) {
114
+ const missing = [['csp', 'Content-Security-Policy'], ['nosniff', 'X-Content-Type-Options'], ['frame', 'frame protection'],
115
+ ['referrer', 'Referrer-Policy'], ...(h.https ? [['hsts', 'Strict-Transport-Security']] : [])].filter(([k]) => !h[k]).map(([, n]) => n);
116
+ add({id: 'headers', group: 'Web', title: 'Security headers',
117
+ status: h.error ? 'info' : missing.length ? 'warn' : 'pass',
118
+ detail: h.error ? `Could not fetch the home page: ${h.error}.` : missing.length ? `Missing: ${missing.join(', ')}.` : 'All present.',
119
+ why: 'They stop pages being framed, content being mis-typed and scripts being injected.',
120
+ fix: 'The CMS sets them; missing ones are usually stripped by a proxy in front.'});
121
+ add({id: 'https', group: 'Web', title: 'HTTPS',
122
+ status: h.error ? 'info' : h.https ? 'pass' : 'warn',
123
+ detail: h.https ? 'The site is served over HTTPS.' : 'The site was reached over plain HTTP.',
124
+ why: 'Without it, passwords and sessions cross the network readable.', fix: 'Serve the site over HTTPS only.'});
125
+ }
126
+ const exposed = (f.probeFiles || []).filter(p => p.status === 200);
127
+ add({id: 'exposed', group: 'Web', title: 'Private files not served',
128
+ status: !(f.probeFiles || []).length ? 'info' : exposed.length ? 'fail' : 'pass', critical: exposed.length > 0,
129
+ detail: exposed.length ? `Served to anyone: ${exposed.map(p => p.path).join(', ')}.` : `${plural((f.probeFiles || []).length, 'private path')} checked - none served.`,
130
+ why: 'These hold secrets (keys, passwords, the code\'s history).',
131
+ fix: 'Remove the file from the site, or block it in the web server.'});
132
+
133
+ // --- Plugins ---
134
+ const lapsed = (f.licences || []).filter(l => !['valid', 'grace', 'support-ended', 'not-required'].includes(l.state));
135
+ add({id: 'licences', group: 'Plugins', title: 'Plugin licences',
136
+ status: lapsed.length ? 'warn' : 'pass',
137
+ detail: lapsed.length ? `Not loaded for want of a licence: ${names(lapsed.map(l => l.name))}.` : 'Every licensed plugin loaded.',
138
+ why: 'A plugin that does not load does not protect or do what you think it does.', fix: 'Renew or remove it.', link: '#/plugins'});
139
+
140
+ const scored = checks.filter(c => c.status !== 'info');
141
+ const points = scored.reduce((a, c) => a + (c.status === 'pass' ? 1 : c.status === 'warn' ? 0.5 : 0), 0);
142
+ return {
143
+ checks,
144
+ score: scored.length ? Math.round((points / scored.length) * 100) : 0,
145
+ failing: checks.filter(c => c.status === 'fail' || c.status === 'warn').length,
146
+ critical: checks.filter(c => c.status === 'fail' && c.critical).length
147
+ };
148
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Security - address rules: an address (81.2.69.142), a trailing wildcard
3
+ * (192.168.1.*), an IPv4 CIDR (10.0.0.0/8) or an IPv6 prefix (2a00:23c7:*).
4
+ * Pure. Copied from Analytics Pro (admin/lib/settings.js) - keep them alike.
5
+ */
6
+
7
+ /** Is this a rule we understand? */
8
+ export function ipRuleValid(rule) {
9
+ const r = String(rule).trim();
10
+ if (/^(\d{1,3}\.){3}\d{1,3}\/(\d|[12]\d|3[0-2])$/.test(r)) return r.split('/')[0].split('.').every(o => +o <= 255);
11
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(r)) return r.split('.').every(o => +o <= 255);
12
+ if (/^(\d{1,3}\.){1,3}\*$/.test(r)) return true;
13
+ if (/^[0-9a-f:]+(\*)?$/i.test(r) && r.includes(':')) return true;
14
+ return false;
15
+ }
16
+
17
+ const v4num = (ip) => ip.split('.').reduce((n, o) => n * 256 + (+o), 0);
18
+
19
+ /** The address as rules see it: no IPv4-mapped prefix, lower case. */
20
+ export const plainIp = (ip) => String(ip ?? '').trim().replace(/^::ffff:(?=\d+\.)/i, '').toLowerCase();
21
+
22
+ /** Does an address match one of the rules? */
23
+ export function ipMatches(ip, rules = []) {
24
+ const a = plainIp(ip);
25
+ if (!a) return false;
26
+ for (const raw of rules) {
27
+ const r = String(raw).trim().toLowerCase();
28
+ if (!r) continue;
29
+ if (r.endsWith('*')) { if (a.startsWith(r.slice(0, -1))) return true; continue; }
30
+ if (r.includes('/')) {
31
+ if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(a)) continue;
32
+ const [base, bits] = r.split('/');
33
+ const size = 2 ** (32 - Number(bits));
34
+ if (Math.floor(v4num(a) / size) === Math.floor(v4num(base) / size)) return true;
35
+ continue;
36
+ }
37
+ if (a === r) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1)$/;
43
+ /** Loopback: this machine. Never blocked or locked out. */
44
+ export const isLoopbackIp = (ip) => LOOPBACK.test(plainIp(ip));
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Security - sign-in lockout. Failures are counted per ACCOUNT and per ADDRESS
3
+ * inside a window; reaching the threshold locks that key, first for `baseMin`,
4
+ * each further lock twice as long, up to `maxMin`. A success clears the account
5
+ * (not the address - an attacker with one right password should not wipe their
6
+ * address's record). Pure: the state is a plain object the plugin saves.
7
+ *
8
+ * state: {[key]: {fails: number[] (ms), locks: number, until: number}}
9
+ * keys: 'a:<email>' and 'i:<address>'
10
+ */
11
+
12
+ export const DEFAULT_LOCKOUT = {
13
+ accountThreshold: 5, // failures on one account...
14
+ addressThreshold: 20, // ...or from one address (offices share one)...
15
+ windowMin: 15, // ...within this many minutes
16
+ baseMin: 1, // first lock
17
+ maxMin: 60 // longest lock
18
+ };
19
+
20
+ export const accountKey = (email) => `a:${String(email ?? '').trim().toLowerCase()}`;
21
+ export const addressKey = (ip) => `i:${String(ip ?? '').trim().toLowerCase()}`;
22
+
23
+ /** Until when a key is locked (ms), or 0. */
24
+ export function lockedUntil(state, key, now = Date.now()) {
25
+ const e = state[key];
26
+ return e && e.until > now ? e.until : 0;
27
+ }
28
+
29
+ /**
30
+ * Count a failure against a key; lock it when the threshold is reached.
31
+ *
32
+ * @returns {number} until when it is now locked (ms), or 0
33
+ */
34
+ export function recordFailure(state, key, {now = Date.now(), threshold, windowMin, baseMin, maxMin} = {}) {
35
+ const cfg = {...DEFAULT_LOCKOUT, windowMin: windowMin ?? DEFAULT_LOCKOUT.windowMin, baseMin: baseMin ?? DEFAULT_LOCKOUT.baseMin, maxMin: maxMin ?? DEFAULT_LOCKOUT.maxMin};
36
+ const limit = threshold ?? (key.startsWith('i:') ? cfg.addressThreshold : cfg.accountThreshold);
37
+ const e = state[key] ??= {fails: [], locks: 0, until: 0};
38
+ e.fails = e.fails.filter(t => t > now - cfg.windowMin * 60_000);
39
+ e.fails.push(now);
40
+ if (e.fails.length >= limit) {
41
+ const minutes = Math.min(cfg.maxMin, cfg.baseMin * 2 ** e.locks);
42
+ e.locks++;
43
+ e.until = now + minutes * 60_000;
44
+ e.fails = [];
45
+ return e.until;
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ /** A successful sign-in: the account starts afresh. */
51
+ export function recordSuccess(state, key) {
52
+ delete state[key];
53
+ }
54
+
55
+ /** Drop what no longer matters (no recent failure, no lock, locks forgotten after a day). */
56
+ export function pruneLockouts(state, {now = Date.now(), windowMin = DEFAULT_LOCKOUT.windowMin} = {}) {
57
+ for (const [k, e] of Object.entries(state)) {
58
+ e.fails = (e.fails || []).filter(t => t > now - windowMin * 60_000);
59
+ if (!e.fails.length && e.until < now - 24 * 3_600_000) delete state[k];
60
+ }
61
+ return state;
62
+ }
63
+
64
+ /** Live locks, soonest-ending last: [{key, kind, who, until}]. */
65
+ export function activeLocks(state, now = Date.now()) {
66
+ return Object.entries(state)
67
+ .filter(([, e]) => e.until > now)
68
+ .map(([key, e]) => ({key, kind: key.startsWith('a:') ? 'account' : 'address', who: key.slice(2), until: e.until, locks: e.locks}))
69
+ .sort((a, b) => b.until - a.until);
70
+ }
71
+
72
+ /** "in 3 minutes" / "in 1 hour" - for the refusal message. */
73
+ export function retryText(until, now = Date.now()) {
74
+ const min = Math.max(1, Math.ceil((until - now) / 60_000));
75
+ if (min < 60) return `in ${min} minute${min === 1 ? '' : 's'}`;
76
+ const h = Math.ceil(min / 60);
77
+ return `in ${h} hour${h === 1 ? '' : 's'}`;
78
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Security - the password policy. Pure: the common-password list is handed in
3
+ * (the server loads server/common-passwords.txt once).
4
+ */
5
+
6
+ export const DEFAULT_POLICY = {minLength: 10, blockCommon: true, blockPersonal: true};
7
+
8
+ const norm = (s) => String(s ?? '').toLowerCase();
9
+
10
+ /**
11
+ * What is wrong with a password, as sentences ([] = fine).
12
+ *
13
+ * @param {string} password
14
+ * @param {{email?: string, name?: string}} who
15
+ * @param {{minLength?: number, blockCommon?: boolean, blockPersonal?: boolean, common?: Set<string>}} policy
16
+ */
17
+ export function passwordProblems(password, {email = '', name = ''} = {}, {minLength = DEFAULT_POLICY.minLength, blockCommon = true, blockPersonal = true, common = new Set()} = {}) {
18
+ const pw = String(password ?? '');
19
+ const out = [];
20
+ if (pw.length < minLength) out.push(`Use at least ${minLength} characters.`);
21
+ const low = norm(pw);
22
+ if (blockCommon && (common.has(low) || common.has(low.replace(/[\d!?.]+$/, '')))) {
23
+ out.push('That password is one of the most common - pick something less guessable.');
24
+ }
25
+ if (blockPersonal) {
26
+ const local = norm(email).split('@')[0];
27
+ const parts = [local, ...norm(name).split(/\s+/)].filter(p => p && p.length >= 3);
28
+ if (parts.some(p => low.includes(p))) out.push('Leave your name and email out of your password.');
29
+ }
30
+ if (pw.length >= 4 && new Set(pw).size <= 2) out.push('Use more than one or two different characters.');
31
+ return out;
32
+ }