domma-cms 1.6.3 → 1.6.5
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.
- package/admin/js/lib/sidebar-frequent.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +6 -6
- package/package.json +1 -1
- package/server/middleware/auth.js +9 -0
- package/server/routes/api/pages.js +58 -5
- package/server/routes/api/projects.js +15 -5
- package/server/routes/api/sidebar.js +16 -0
- package/server/routes/api/versions.js +20 -3
- package/server/services/projects.js +15 -0
- package/server/services/sidebarFrequent.js +66 -0
- package/server/services/siteGitignore.js +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
export const FREQUENT_KEY="frequent",FREQUENT_MAX=12;export function findLeaf(t
|
|
1
|
+
export const FREQUENT_KEY="frequent",FREQUENT_MAX=12;export function findLeaf(e,t){for(const r of e||[])if(!(!r||r.type||r.hidden)){if(Array.isArray(r.items)&&r.items.length){const n=findLeaf(r.items,t);if(n)return n}else if(r.url===t)return r}return null}export function frequentFolder(e,t,{hidden:r=!1}={}){const n=[];for(const o of Array.isArray(t)?t:[]){const i=typeof o=="string"&&o?findLeaf(e,o):null;i&&n.push({text:i.text,url:i.url,...i.icon&&{icon:i.icon},...i.colour&&{colour:i.colour}})}return n.length?{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:n,frequent:!0}:r?null:{text:"Frequent",key:FREQUENT_KEY,icon:"star",items:[],frequent:!0,empty:!0}}export function addFrequent(e,t){const r=(Array.isArray(e)?e:[]).filter(n=>typeof n=="string"&&n);return!t||r.includes(t)||r.length>=12?r:[...r,t]}export function removeFrequent(e,t){return(Array.isArray(e)?e:[]).filter(r=>r!==t)}export function moveFrequent(e,t,r){const n=Array.isArray(e)?[...e]:[],o=n.indexOf(t),i=o+r;return o<0||i<0||i>=n.length||([n[o],n[i]]=[n[i],n[o]]),n}export function storageKey(e){const t=e&&(e.id||e._id||e.email);return`sidebar.frequent.${t?String(t):"anon"}`}export function readFrequent(e){try{const t=S.get(storageKey(e));return Array.isArray(t)?t.filter(r=>typeof r=="string"&&r):[]}catch{return[]}}export function readFrequentHidden(e){try{return S.get(`${storageKey(e)}.hidden`)===!0}catch{return!1}}export function writeFrequentHidden(e,t){try{return S.set(`${storageKey(e)}.hidden`,!!t),!0}catch{return!1}}export function writeFrequent(e,t){try{return S.set(storageKey(e),Array.isArray(t)?t:[]),!0}catch{return!1}}let u=null;export function resetFrequentSync(){u=null}export function saveFrequent(e,t){return e.put("/sidebar/frequent",{urls:readFrequent(t),hidden:readFrequentHidden(t)})}export async function syncFrequent(e,t){const r=t&&(t.id||t._id||t.email);if(!r||u===String(r))return;const n=await e.get("/sidebar/frequent");if(!(!n||typeof n!="object")){if(u=String(r),!n.saved){(readFrequent(t).length||readFrequentHidden(t))&&await saveFrequent(e,t);return}writeFrequent(t,Array.isArray(n.urls)?n.urls:[]),writeFrequentHidden(t,n.hidden===!0)}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import{api as g,getUser as
|
|
2
|
-
<summary${
|
|
1
|
+
import{api as g,getUser as L}from"../api.js";import{isPathAllowed as ue}from"./admin-scope.js";import{colourToCss as V,applyFloat as fe,applyHighlight as me,itemHighlightAttrs as pe}from"/public/js/menu-decor.mjs?v=20261009-hover-darken";import{placeTools as he,ensureLockedTools as ge,managePluginsItem as be,stripItemByUrl as ye,insertFoldersBeforeSystem as ve,pruneEmptyToolsFolder as xe,sidebarStateKey as X,canAny as O,filterByPermission as $e,MANAGE_PLUGINS_URL as Z}from"./sidebar-grouping.js";import{arrangeButtonMarkup as we,canArrange as ke,startArrange as Se}from"./sidebar-arrange.js";import{collapseButtonMarkup as qe,wireSidebarSize as Ce,COLLAPSED_WIDTH as Ae}from"./sidebar-size.js";import{FREQUENT_MAX as Y,addFrequent as Fe,frequentFolder as Le,moveFrequent as J,readFrequent as z,readFrequentHidden as Ee,removeFrequent as Te,saveFrequent as Me,syncFrequent as He,writeFrequent as Ne,writeFrequentHidden as Q}from"./sidebar-frequent.js";import{hotkeyFor as ee,hotkeyTitle as je,setHotkeyTree as Be}from"./hotkeys-ui.js";import{hotkeyLabel as Pe}from"./hotkeys.js";function m(e){return String(e??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}const Re=[{text:"Dashboard",url:"#/",icon:"home"},{text:"Menus",url:"#/menus",icon:"menu",permission:"menus"}];async function _e(e){if(!O(e,"projects"))return[];let n=[];try{n=await g.projects.listWithCounts()}catch{return[]}if(!Array.isArray(n))return[];const s=[{key:"pages",text:"Pages",icon:"file-text",path:"/pages"},{key:"collections",text:"Collections",icon:"database",path:"/collections"},{key:"forms",text:"Forms",icon:"layout",path:"/forms"},{key:"actions",text:"Actions",icon:"zap",path:"/actions"},{key:"menus",text:"Menus",icon:"menu",path:"/menus"},{key:"blocks",text:"Blocks",icon:"box",path:"/blocks"},{key:"components",text:"Components",icon:"component",path:"/components"},{key:"views",text:"Views",icon:"eye",path:"/views"},{key:"roles",text:"Roles",icon:"shield",path:"/roles"},{key:"users",text:"Users",icon:"users",path:"/users"},{key:"apis",text:"APIs",icon:"code",path:"/apis"}];return n.map(t=>{const o="#/projects/"+encodeURIComponent(t.slug),a=t.counts||{},d=[{text:"Overview",url:o,icon:t.icon||"folder"}];for(const c of s){const r=Number(a[c.key])||0;r<=0||d.push({text:`${c.text} (${r})`,url:o+c.path,icon:c.icon})}d.push({text:"Settings",url:o+"/settings",icon:"settings"});const u=t.enabled===!1;return{text:t.name||t.slug,key:`project.${t.slug}`,icon:t.icon||"folder",items:d,...u&&{colour:"neutral",badge:{text:"off"}}}})}function Ie(e,n){for(const s of e)if(!s.hidden&&(s.key==="projects"||!s.key&&(s.text||"").toLowerCase()==="projects")){s.items=Array.isArray(s.items)?s.items.slice():[],s.items.push(...n);return}}function te(e,n){const s=se.includes(n.tone)?n.tone:"info",t=n.label||String(n.count);n.details?j.set(e,n.details):j.delete(e);const i=!n.inventory&&A.some(o=>o.url===e&&!o.inventory)?" data-live":"";return`<span class="sidebar-badge sidebar-badge--${s}" data-count-for="${m(e)}"${i} data-badge-label="${m(t)}" aria-label="${m(t)}">${m(String(n.count))}</span>`}function ne(e){for(const n of e.querySelectorAll("details")){const s=n.querySelector(":scope > summary");if(!s)continue;let t=0,i=!1;const o=[];for(const r of n.querySelectorAll(":scope > .sidebar-children .sidebar-badge[data-live]")){const f=Number(r.textContent)||0;f&&(t+=f,i||(i=r.classList.contains("sidebar-badge--danger")),o.push({text:r.closest("a")?.querySelector(".sidebar-text")?.textContent.trim()??"",meta:r.dataset.badgeLabel,tone:r.classList.contains("sidebar-badge--danger")?"danger":void 0}))}const a=`folder:${n.dataset.stateKey||s.textContent.trim()}`;let d=s.querySelector(":scope > .sidebar-badge--sum");if(!t){d?.remove(),j.delete(a);continue}d||(d=document.createElement("span"),s.querySelector(".sidebar-text")?.after(d));const u=s.querySelector(".sidebar-text")?.textContent.trim()||"This folder",c=o.map(r=>`${r.text}: ${r.meta}`).join(" \xB7 ");d.className=`sidebar-badge sidebar-badge--sum sidebar-badge--${i?"danger":"info"}`,d.dataset.countFor=a,d.dataset.badgeLabel=c,d.setAttribute("aria-label",c),d.textContent=String(t),j.set(a,{title:u,items:o})}}const j=new Map;function Oe(e,n){if(!e.url||!n)return"";const s=n[e.url];return!s||!(s.count>0)&&!s.showZero?"":te(e.url,s)}const se=["info","attention","danger","live"],Ue={"#/pages":["page","pages"],"#/media":["file in the media library","files in the media library"],"#/menus":["menu","menus"],"#/context-menus":["context menu","context menus"],"#/theme":["element override in the theme","element overrides in the theme"],"#/collections":["collection","collections"],"#/forms":["form","forms"],"#/views":["view","views"],"#/actions":["action","actions"],"#/blocks":["block","blocks"],"#/components":["component","components"],"#/users":["user","users"],"#/plugins":["plugin installed","plugins installed"],"#/system/notifications":["unread notification","unread notifications"]};function W(e,{fallbackLabel:n="",tone:s="info"}={}){const t=Number(typeof e=="object"&&e!==null?e.count:e);if(!Number.isFinite(t)||t<0)return null;const i=typeof e?.label=="string"&&e.label||(n?`${t} - ${n}`:String(t)),o={count:Math.round(t),label:i,tone:se.includes(e?.tone)?e.tone:s};return e?.details&&typeof e.details=="object"&&(o.details=e.details),e?.inventory===!0&&(o.inventory=!0),e?.showZero===!0&&(o.showZero=!0),o}function oe(e,n,s=null,{inFrequent:t=!1}={}){if(e&&e.type==="separator")return'<hr class="sidebar-divider">';if(e&&e.type==="spacer"){const y=["xs","sm","md","lg","xl","flex"].includes(e.size)?e.size:"md";return`<div class="sidebar-spacer sidebar-spacer--${m(y)}" aria-hidden="true"></div>`}const i=String(e.text||"?").trim().charAt(0).toUpperCase()||"?",o=e.icon?`<span data-icon="${m(e.icon)}"></span>`:`<span class="sidebar-initial" aria-hidden="true">${m(i)}</span>`,a=m(e.text||""),d=e.url?m(e.url):"",u=Array.isArray(e.items)&&e.items.length>0,c=e.colour?V(e.colour):"",r=pe(e),f=[r.hover&&` data-menu-item-hover="${m(r.hover)}"`,r.active&&` data-menu-item-active="${m(r.active)}"`].filter(Boolean).join(""),x=[c&&`color:${m(c)}`,r.accent&&`--dm-menu-highlight:${m(r.accent)}`].filter(Boolean).join(";"),w=x?` style="${x}"`:"",k=e.url?je(e.url,e.text||"",{inFrequent:t}):"",q=k?` title="${m(k)}"`:"";let F="";if(e.badge&&e.badge.text!=null&&e.badge.text!==""){const y=e.badge.variant?V(e.badge.variant):"";F=`<span class="dm-menu-badge"${y?` style="background:${m(y)};color:var(--dm-text-inverse,#fff)"`:""}>${m(String(e.badge.text))}</span>`}if(e.frequent&&e.empty){const y=X(e,s),C=S.get(y)!==!1;return`<details data-state-key="${m(y)}"${C?" open":""} data-frequent data-frequent-empty>
|
|
2
|
+
<summary${w}>${o} <span class="sidebar-text">${a}</span></summary>
|
|
3
3
|
<div class="sidebar-children"><p class="sidebar-frequent-empty">Right-click any Tool and choose <strong>Add to Frequent</strong> to keep it here.</p></div>
|
|
4
|
-
</details>`}if(u){const
|
|
5
|
-
<summary${
|
|
6
|
-
<div class="sidebar-children">${
|
|
7
|
-
</details>`}return`<a href="${i}" class="sidebar-link" data-url="${i}"${v}${m}${C}>${c} <span class="sidebar-text">${a}</span>${Pe(e,n)}${q}</a>`}async function _e(e=[],n=[],o=null){const t=f=>f.then(x=>Array.isArray(x)?x.length:Array.isArray(x?.entries)?x.entries.length:0).catch(()=>0);F=Ue(e,o);const s=new Set(F.map(f=>f.url)),c={pages:"#/pages",media:"#/media",menus:"#/menus","context-menus":"#/context-menus",theme:"#/theme",collections:"#/collections",forms:"#/forms",views:"#/views",actions:"#/actions",blocks:"#/blocks",components:"#/components",users:"#/users",plugins:"#/plugins",notifications:"#/system/notifications"},a=(f,x)=>O(n,f)&&!s.has(c[f])?x():Promise.resolve(0),[i,u,d,r,m,y,v,k,C,q,b,A,j,B]=await Promise.all([a("pages",()=>t(g.pages.list())),a("media",()=>t(g.media.list())),a("menus",()=>t(g.menus.list())),a("context-menus",()=>t(g.contextMenus.list())),a("theme",()=>g.theme.get().then(f=>(f?.overrides||[]).length).catch(()=>0)),a("collections",()=>t(g.collections.list())),a("forms",()=>t(g.forms.list())),a("views",()=>t(g.views.list())),a("actions",()=>t(g.actions.list())),a("blocks",()=>t(g.blocks.list())),a("components",()=>t(g.components.list())),a("users",()=>t(g.users.list())),a("plugins",()=>t(g.plugins.list())),a("notifications",()=>g.system?.notifications?.unreadCount?.().then(f=>f?.count??0).catch(()=>0)??Promise.resolve(0))]),L={};await Promise.all(F.map(async f=>{const x=await oe(f);x&&(L[f.url]=x)}));const _={"#/pages":i,"#/media":u,"#/menus":d,"#/context-menus":r,"#/theme":m,"#/collections":y,"#/forms":v,"#/views":k,"#/actions":C,"#/blocks":q,"#/components":b,"#/users":A,"#/plugins":j,"#/system/notifications":B},I={};for(const[f,x]of Object.entries(_)){if(s.has(f))continue;const[l,w]=Re[f]??["item","items"],p=z(x);p&&(I[f]={...p,label:`${p.count} ${p.count===1?l:w}`})}return{...I,...L}}let F=[];const Ie="dm:sidebar-count",Oe=6e4;function Ue(e=[],n=null){const o=[],t=s=>{if(s&&!(n&&typeof s.url=="string"&&s.url.startsWith("#/")&&!ce(n,s.url.slice(1)))){s.url&&typeof s.countUrl=="string"&&s.countUrl&&o.push({url:s.url,countUrl:s.countUrl,text:s.text||"",countLabel:s.countLabel||"",inventory:s.inventory===!0});for(const c of[...s.children||[],...s.items||[]])t(c)}};for(const s of e??[])t(s?.item??s);return o}async function oe(e){try{const n=new Date,o=`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`,t=e.countUrl+(e.countUrl.includes("?")?"&":"?")+`today=${o}`;return z(await H.get(t),{fallbackLabel:e.countLabel||e.text})}catch{return null}}function ie(e,n,o){const t=[...e.querySelectorAll("a.sidebar-link")].filter(s=>s.dataset.url===n);if(t.length){for(const s of t){const c=s.querySelector(".sidebar-badge[data-count-for]");if(!o||!(o.count>0)&&!o.showZero)c?.remove();else{const a=document.createRange();a.selectNodeContents(s);const i=a.createContextualFragment(ee(n,o)).firstElementChild;c?c.replaceWith(i):s.querySelector(".sidebar-text")?.after(i)}}W.set(n,o),te(e)}}const W=new Map;async function D(e,n=null){const o=n?F.filter(t=>t.url===n):F;await Promise.all(o.map(async t=>{const s=await oe(t),c=W.get(t.url);JSON.stringify(c??null)!==JSON.stringify(s)&&ie(e,t.url,s)}))}function ze(e){e.dataset.liveCounts||(e.dataset.liveCounts="1",window.addEventListener(Ie,n=>{const o=n.detail||{};if(o.url)if(o.count!==void 0){const t=F.find(s=>s.url===o.url);ie(e,o.url,z(o,{fallbackLabel:t?.countLabel||t?.text||""}))}else D(e,o.url)}),setInterval(()=>{!document.hidden&&document.body.contains(e)&&D(e)},Oe),document.addEventListener("visibilitychange",()=>{document.hidden||D(e)}),We(e))}function ae(e,n,o){const t=(a,i,u)=>{const d=document.createElement(a);return i&&(d.className=i),u!=null&&(d.textContent=String(u)),d};if(!o){e.textContent=n;return}e.appendChild(t("strong","dm-badge-pop-title",o.title||n)),(o.rows?.length||!o.items?.length)&&e.appendChild(t("p","dm-badge-pop-sub",n));const s=Array.isArray(o.rows)?o.rows.slice(0,6):[];if(s.length){const a=t("dl","dm-badge-pop-rows");for(const i of s)a.appendChild(t("dt",null,i.label)),a.appendChild(t("dd",i.tone==="danger"?"is-danger":null,i.value));e.appendChild(a)}const c=Array.isArray(o.items)?o.items.slice(0,6):[];if(c.length){const a=t("ul","dm-badge-pop-items");for(const i of c){const u=t("li",i.tone==="danger"?"is-danger":null);u.appendChild(t("span","dm-badge-pop-text",i.text)),i.meta&&u.appendChild(t("span","dm-badge-pop-meta",i.meta)),a.appendChild(u)}e.appendChild(a)}else o.empty&&e.appendChild(t("p","dm-badge-pop-empty",o.empty))}function We(e){let n=null,o=null;const t=()=>e.classList.contains("is-collapsed"),s=()=>{clearTimeout(o),n?.remove(),n=null},c=(d,r)=>{s(),o=setTimeout(()=>{n=document.createElement("div"),n.className="dm-help-pop dm-badge-pop",n.setAttribute("role","tooltip"),r(n),document.body.appendChild(n);const m=d.getBoundingClientRect(),y=n.getBoundingClientRect(),v=Math.min(m.right+10,innerWidth-y.width-8);n.style.left=`${Math.max(8,v)}px`,n.style.top=`${Math.max(8,Math.min(m.top+m.height/2-y.height/2,innerHeight-y.height-8))}px`},150)},a=d=>{const r=d?.dataset.badgeLabel;if(!r)return s();c(d,m=>ae(m,r,N.get(d.dataset.countFor)))},i=d=>{const r=d.querySelector(":scope > .sidebar-text")?.textContent.trim()||"",m=d.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");c(d,y=>{const v=m&&N.get(m.dataset.countFor);if(v)return ae(y,m.dataset.badgeLabel,{...v,title:v.title||r});if(!m){y.textContent=r;return}const k=document.createElement("strong");k.className="dm-badge-pop-title",k.textContent=r;const C=document.createElement("p");C.className="dm-badge-pop-sub",C.textContent=m.dataset.badgeLabel,y.append(k,C)})},u=d=>{const r=d.closest?.("a.sidebar-link, summary");return r&&e.contains(r)?r:null};e.addEventListener("mouseover",d=>{if(t()){const m=u(d.target);m&&!m.contains(d.relatedTarget)&&i(m);return}const r=d.target.closest?.(".sidebar-badge[data-badge-label]");r&&a(r)}),e.addEventListener("mouseout",d=>{if(t()){const r=u(d.target);r&&!r.contains(d.relatedTarget)&&s();return}d.target.closest?.(".sidebar-badge[data-badge-label]")&&s()}),e.addEventListener("focusin",d=>{const r=d.target.closest?.("a, summary");if(t()&&r&&e.contains(r)&&r.querySelector(":scope > .sidebar-text"))return i(r);const m=r?.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");m?a(m):s()}),e.addEventListener("focusout",s),e.addEventListener("click",s),window.addEventListener("scroll",s,!0),window.addEventListener("hashchange",s)}async function De(e=[]){if(!O(e,"settings"))return"Admin";try{const n=await g.settings.get();return n?.adminBrand?.title||n?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:n,adminScope:o=null}){const t=$(e).get(0);if(!t)return;let s,c=null,a=null,i=null,u=null,d=null;try{const l=await g.get("/sidebar/menu");s=Array.isArray(l?.items)?l.items:null,c=l?.variant||null,a=l?.position||null,i=l?.style||null,u=l?.float||null,d=l?.highlight||null}catch{s=null}(!s||!s.some(l=>l&&(!l.hidden||l.restricted)))&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),s=Ne.slice());let r=[];try{r=await H.get("/api/sidebar/registered-items")||[]}catch{}s=ge(s,X),s=fe(s,r.filter(l=>(l?.item??l)?.url!==X)),s=pe(s),s=be(s,[he()]);const m=await je(n);m.length&&Be(s,m),s=ve(s,n,o),s=ye(s);const y=Ae(s,U(P()),{hidden:Fe(P())});y&&(s=[y,...s]),Me(s);const[v,k]=await Promise.all([_e(r.filter(l=>O(n,l?.item?.permission)),n,o).catch(()=>({})),De(n)]),C=c?` dm-admin-sidebar--${h(c)}`:"";function q(l,w="px"){if(l==null)return l;const p=String(l).trim();return/^\d+(\.\d+)?$/.test(p)?`${p}${w}`:p}let b="";if(i){const l=[];i.fontFamily&&l.push(`font-family: ${i.fontFamily}, sans-serif`),i.fontSize&&l.push(`font-size: ${q(i.fontSize)}`),i.fontWeight&&l.push(`font-weight: ${i.fontWeight}`),i.letterSpacing&&l.push(`letter-spacing: ${q(i.letterSpacing,"em")}`);const w=[];if(l.length&&w.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${l.join("; ")} }`),i.iconSize){const p=q(i.iconSize);w.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${p} !important; height: ${p} !important; }`)}w.length&&(b=`<style data-admin-sidebar-style>${w.join(" ")}</style>`)}const A=$e(n)?xe():"",j=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span><span class="dm-admin-sidebar-title">${h(k)}</span>${A}${ke()}</div>`,B=s.some(l=>l&&l.type==="spacer"&&l.size==="flex")?" dm-admin-sidebar--has-flex-spacer":"",L=`${b}<nav class="dm-admin-sidebar${C}${B}">${j}${s.map(l=>se(l,v)).join("")}</nav>`,_=document.createRange();_.selectNodeContents(t);const I=_.createContextualFragment(L);t.replaceChildren(I),Domma.icons.scan(t),t.querySelector(".dm-admin-sidebar-arrange")?.addEventListener("click",()=>we(t));for(const l of F)W.set(l.url,v[l.url]??null);te(t),ze(t),t.removeAttribute("data-menu-hover"),t.removeAttribute("data-menu-active"),t.style.removeProperty("--dm-menu-highlight"),ue(t,{highlight:d});const f=document.querySelector(".dashboard-wrapper");["position","top","bottom","left","right","zIndex","maxHeight","transform"].forEach(l=>{t.style[l]=""}),t.classList.remove("dm-menu-floating"),f&&(f.style.paddingLeft="",f.style.paddingRight=""),t.__dmGutter=()=>{if(a!=="floating"||!f)return;const l=u&&u.anchor||"TL",w=Number.isFinite(Number(u?.offsetX))?Number(u.offsetX):16,p=t.classList.contains("is-collapsed")?Ce:parseInt(t.style.width,10)||t.getBoundingClientRect().width||260,T=Math.round(p+w*2)+"px";l==="R"||l==="TR"||l==="BR"?f.style.paddingRight=T:f.style.paddingLeft=T},a==="floating"&&de(t,{position:"floating",float:u||{}}),t.querySelectorAll("details").forEach(l=>{l.addEventListener("toggle",function(){const w=this.getAttribute("data-state-key");w&&S.set(w,this.open);const p=this.querySelector(":scope > .sidebar-children");if(p)if(this.open){const T=p.scrollHeight;p.style.maxHeight="0",requestAnimationFrame(()=>{p.style.maxHeight=T+"px",p.addEventListener("transitionend",function le(){p.style.maxHeight="none",p.removeEventListener("transitionend",le)})})}else{const T=p.scrollHeight;p.style.maxHeight=T+"px",requestAnimationFrame(()=>{p.style.maxHeight="0"})}})}),Se(t),Xe(t),t.dataset.routeWired||(t.dataset.routeWired="1",M.subscribe("router:afterChange",()=>t.__dmSetActive?.()));function x(){const l=/^#\/(dashboards|my-dashboard)\b/.test(location.hash)?"#/":location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${l}"]`).addClass("active")}x(),t.__dmSetActive=x}function re(){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}function G(e){return`${location.origin}${location.pathname}${e}`}function R(e,n){const o=P(),t=e(U(o));if(!Ee(o,t)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}t.length&&J(o,!1),n&&E.toast(n,{type:"success",duration:2500}),re()}function Ge(e){if(!J(P(),e)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}E.toast(e?"Frequent hidden. Add a Tool to it from its right-click menu to bring it back.":"Frequent shown.",{type:"success",duration:3500}),re()}function Ke(e){const n=e.dataset.url;if(!n)return[];const o=e.querySelector(".sidebar-text")?.textContent.trim()||n,t=U(P()),s=t.includes(n),c=!!e.closest("details[data-frequent]"),a=t.indexOf(n),i=!s&&t.length>=Z;return[{header:o},{label:"Open",icon:"arrow-right",...Q(n)&&{shortcut:He(Q(n))},action:()=>{location.hash=n}},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(G(n),"_blank","noopener")},{type:"divider"},s?{label:"Remove from Frequent",icon:"star",action:()=>R(u=>Le(u,n),`${o} removed from Frequent.`)}:{label:i?`Frequent is full (${Z})`:"Add to Frequent",icon:"star",disabled:i,action:()=>R(u=>qe(u,n),`${o} added to Frequent.`)},...c?[{label:"Move up",icon:"arrow-up",disabled:a<=0,action:()=>R(u=>Y(u,n,-1))},{label:"Move down",icon:"arrow-down",disabled:a<0||a>=t.length-1,action:()=>R(u=>Y(u,n,1))}]:[],{type:"divider"},{label:"Copy link",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(G(n)),E.toast("Link copied.",{type:"success",duration:2e3})}catch{E.toast(G(n),{type:"info",duration:8e3})}}}]}function Ve(e,n){const o=e.closest("details"),t=e.querySelector(".sidebar-text")?.textContent.trim()||"Folder",s=c=>n.querySelectorAll("details").forEach(a=>{a.open!==c&&(a.open=c)});return[{header:t},{label:o?.open?"Close":"Open",icon:o?.open?"chevron-up":"chevron-down",action:()=>{o&&(o.open=!o.open)}},{label:"Open every folder",icon:"chevrons-down",action:()=>s(!0)},{label:"Close every folder",icon:"chevrons-up",action:()=>s(!1)},...o?.hasAttribute("data-frequent-empty")?[{type:"divider"},{label:"Hide Frequent until I add a Tool",icon:"eye-off",action:()=>Ge(!0)}]:o?.hasAttribute("data-frequent")?[{type:"divider"},{label:"Clear Frequent",icon:"x",action:()=>R(()=>[],"Frequent cleared.")}]:[]]}function Xe(e){e.dataset.ctxWired||typeof E>"u"||!E.contextMenu||(e.dataset.ctxWired="1",E.contextMenu(e,{match:"a.sidebar-link, details > summary",inherit:!1,density:"compact",items:n=>n.matches("summary")?Ve(n,e):Ke(n)}))}
|
|
4
|
+
</details>`}if(u){const y=X(e,s),C=S.get(y)!==!1,B=e.items.map(T=>oe(T,n,y,{inFrequent:!!e.frequent})).join(""),P=e.frequent?' data-frequent title="Your Frequent Tools - right-click any Tool to add or remove it"':"";return`<details data-state-key="${m(y)}"${C?" open":""}${P}>
|
|
5
|
+
<summary${w}${q}>${o} <span class="sidebar-text">${a}</span>${F}</summary>
|
|
6
|
+
<div class="sidebar-children">${B}</div>
|
|
7
|
+
</details>`}return`<a href="${d}" class="sidebar-link" data-url="${d}"${w}${f}${q}>${o} <span class="sidebar-text">${a}</span>${Oe(e,n)}${F}</a>`}async function ze(e=[],n=[],s=null){const t=p=>p.then(b=>Array.isArray(b)?b.length:Array.isArray(b?.entries)?b.entries.length:0).catch(()=>0);A=Ge(e,s);const i=new Set(A.map(p=>p.url)),o={pages:"#/pages",media:"#/media",menus:"#/menus","context-menus":"#/context-menus",theme:"#/theme",collections:"#/collections",forms:"#/forms",views:"#/views",actions:"#/actions",blocks:"#/blocks",components:"#/components",users:"#/users",plugins:"#/plugins",notifications:"#/system/notifications"},a=(p,b)=>O(n,p)&&!i.has(o[p])?b():Promise.resolve(0),[d,u,c,r,f,x,w,k,q,F,y,C,B,P]=await Promise.all([a("pages",()=>t(g.pages.list())),a("media",()=>t(g.media.list())),a("menus",()=>t(g.menus.list())),a("context-menus",()=>t(g.contextMenus.list())),a("theme",()=>g.theme.get().then(p=>(p?.overrides||[]).length).catch(()=>0)),a("collections",()=>t(g.collections.list())),a("forms",()=>t(g.forms.list())),a("views",()=>t(g.views.list())),a("actions",()=>t(g.actions.list())),a("blocks",()=>t(g.blocks.list())),a("components",()=>t(g.components.list())),a("users",()=>t(g.users.list())),a("plugins",()=>t(g.plugins.list())),a("notifications",()=>g.system?.notifications?.unreadCount?.().then(p=>p?.count??0).catch(()=>0)??Promise.resolve(0))]),T={};await Promise.all(A.map(async p=>{const b=await ie(p);b&&(T[p.url]=b)}));const U={"#/pages":d,"#/media":u,"#/menus":c,"#/context-menus":r,"#/theme":f,"#/collections":x,"#/forms":w,"#/views":k,"#/actions":q,"#/blocks":F,"#/components":y,"#/users":C,"#/plugins":B,"#/system/notifications":P},R={};for(const[p,b]of Object.entries(U)){if(i.has(p))continue;const[I,l]=Ue[p]??["item","items"],v=W(b);v&&(R[p]={...v,label:`${v.count} ${v.count===1?I:l}`})}return{...R,...T}}let A=[];const We="dm:sidebar-count",De=6e4;function Ge(e=[],n=null){const s=[],t=i=>{if(i&&!(n&&typeof i.url=="string"&&i.url.startsWith("#/")&&!ue(n,i.url.slice(1)))){i.url&&typeof i.countUrl=="string"&&i.countUrl&&s.push({url:i.url,countUrl:i.countUrl,text:i.text||"",countLabel:i.countLabel||"",inventory:i.inventory===!0});for(const o of[...i.children||[],...i.items||[]])t(o)}};for(const i of e??[])t(i?.item??i);return s}async function ie(e){try{const n=new Date,s=`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,"0")}-${String(n.getDate()).padStart(2,"0")}`,t=e.countUrl+(e.countUrl.includes("?")?"&":"?")+`today=${s}`;return W(await H.get(t),{fallbackLabel:e.countLabel||e.text})}catch{return null}}function ae(e,n,s){const t=[...e.querySelectorAll("a.sidebar-link")].filter(i=>i.dataset.url===n);if(t.length){for(const i of t){const o=i.querySelector(".sidebar-badge[data-count-for]");if(!s||!(s.count>0)&&!s.showZero)o?.remove();else{const a=document.createRange();a.selectNodeContents(i);const d=a.createContextualFragment(te(n,s)).firstElementChild;o?o.replaceWith(d):i.querySelector(".sidebar-text")?.after(d)}}D.set(n,s),ne(e)}}const D=new Map;async function G(e,n=null){const s=n?A.filter(t=>t.url===n):A;await Promise.all(s.map(async t=>{const i=await ie(t),o=D.get(t.url);JSON.stringify(o??null)!==JSON.stringify(i)&&ae(e,t.url,i)}))}function Ke(e){e.dataset.liveCounts||(e.dataset.liveCounts="1",window.addEventListener(We,n=>{const s=n.detail||{};if(s.url)if(s.count!==void 0){const t=A.find(i=>i.url===s.url);ae(e,s.url,W(s,{fallbackLabel:t?.countLabel||t?.text||""}))}else G(e,s.url)}),setInterval(()=>{!document.hidden&&document.body.contains(e)&&G(e)},De),document.addEventListener("visibilitychange",()=>{document.hidden||G(e)}),Ve(e))}function re(e,n,s){const t=(a,d,u)=>{const c=document.createElement(a);return d&&(c.className=d),u!=null&&(c.textContent=String(u)),c};if(!s){e.textContent=n;return}e.appendChild(t("strong","dm-badge-pop-title",s.title||n)),(s.rows?.length||!s.items?.length)&&e.appendChild(t("p","dm-badge-pop-sub",n));const i=Array.isArray(s.rows)?s.rows.slice(0,6):[];if(i.length){const a=t("dl","dm-badge-pop-rows");for(const d of i)a.appendChild(t("dt",null,d.label)),a.appendChild(t("dd",d.tone==="danger"?"is-danger":null,d.value));e.appendChild(a)}const o=Array.isArray(s.items)?s.items.slice(0,6):[];if(o.length){const a=t("ul","dm-badge-pop-items");for(const d of o){const u=t("li",d.tone==="danger"?"is-danger":null);u.appendChild(t("span","dm-badge-pop-text",d.text)),d.meta&&u.appendChild(t("span","dm-badge-pop-meta",d.meta)),a.appendChild(u)}e.appendChild(a)}else s.empty&&e.appendChild(t("p","dm-badge-pop-empty",s.empty))}function Ve(e){let n=null,s=null;const t=()=>e.classList.contains("is-collapsed"),i=()=>{clearTimeout(s),n?.remove(),n=null},o=(c,r)=>{i(),s=setTimeout(()=>{n=document.createElement("div"),n.className="dm-help-pop dm-badge-pop",n.setAttribute("role","tooltip"),r(n),document.body.appendChild(n);const f=c.getBoundingClientRect(),x=n.getBoundingClientRect(),w=Math.min(f.right+10,innerWidth-x.width-8);n.style.left=`${Math.max(8,w)}px`,n.style.top=`${Math.max(8,Math.min(f.top+f.height/2-x.height/2,innerHeight-x.height-8))}px`},150)},a=c=>{const r=c?.dataset.badgeLabel;if(!r)return i();o(c,f=>re(f,r,j.get(c.dataset.countFor)))},d=c=>{const r=c.querySelector(":scope > .sidebar-text")?.textContent.trim()||"",f=c.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");o(c,x=>{const w=f&&j.get(f.dataset.countFor);if(w)return re(x,f.dataset.badgeLabel,{...w,title:w.title||r});if(!f){x.textContent=r;return}const k=document.createElement("strong");k.className="dm-badge-pop-title",k.textContent=r;const q=document.createElement("p");q.className="dm-badge-pop-sub",q.textContent=f.dataset.badgeLabel,x.append(k,q)})},u=c=>{const r=c.closest?.("a.sidebar-link, summary");return r&&e.contains(r)?r:null};e.addEventListener("mouseover",c=>{if(t()){const f=u(c.target);f&&!f.contains(c.relatedTarget)&&d(f);return}const r=c.target.closest?.(".sidebar-badge[data-badge-label]");r&&a(r)}),e.addEventListener("mouseout",c=>{if(t()){const r=u(c.target);r&&!r.contains(c.relatedTarget)&&i();return}c.target.closest?.(".sidebar-badge[data-badge-label]")&&i()}),e.addEventListener("focusin",c=>{const r=c.target.closest?.("a, summary");if(t()&&r&&e.contains(r)&&r.querySelector(":scope > .sidebar-text"))return d(r);const f=r?.querySelector(":scope > .sidebar-badge[data-badge-label]:not([hidden])");f?a(f):i()}),e.addEventListener("focusout",i),e.addEventListener("click",i),window.addEventListener("scroll",i,!0),window.addEventListener("hashchange",i)}async function Xe(e=[]){if(!O(e,"settings"))return"Admin";try{const n=await g.settings.get();return n?.adminBrand?.title||n?.title||"Admin"}catch{return"Admin"}}export async function renderAdminSidebar({mount:e,permissions:n,adminScope:s=null}){const t=$(e).get(0);if(!t)return;const i=He(g,L()).catch(()=>{});let o,a=null,d=null,u=null,c=null,r=null;try{const l=await g.get("/sidebar/menu");o=Array.isArray(l?.items)?l.items:null,a=l?.variant||null,d=l?.position||null,u=l?.style||null,c=l?.float||null,r=l?.highlight||null}catch{o=null}(!o||!o.some(l=>l&&(!l.hidden||l.restricted)))&&(console.warn("[admin-sidebar] No admin-sidebar menu found; using fallback tree"),o=Re.slice());let f=[];try{f=await H.get("/api/sidebar/registered-items")||[]}catch{}o=ye(o,Z),o=he(o,f.filter(l=>(l?.item??l)?.url!==Z)),o=ge(o),o=ve(o,[be()]);const x=await _e(n);x.length&&Ie(o,x),o=$e(o,n,s),o=xe(o),await i;const w=Le(o,z(L()),{hidden:Ee(L())});w&&(o=[w,...o]),Be(o);const[k,q]=await Promise.all([ze(f.filter(l=>O(n,l?.item?.permission)),n,s).catch(()=>({})),Xe(n)]),F=a?` dm-admin-sidebar--${m(a)}`:"";function y(l,v="px"){if(l==null)return l;const h=String(l).trim();return/^\d+(\.\d+)?$/.test(h)?`${h}${v}`:h}let C="";if(u){const l=[];u.fontFamily&&l.push(`font-family: ${u.fontFamily}, sans-serif`),u.fontSize&&l.push(`font-size: ${y(u.fontSize)}`),u.fontWeight&&l.push(`font-weight: ${u.fontWeight}`),u.letterSpacing&&l.push(`letter-spacing: ${y(u.letterSpacing,"em")}`);const v=[];if(l.length&&v.push(`#admin-sidebar .dm-admin-sidebar, #admin-sidebar .dm-admin-sidebar .sidebar-link, #admin-sidebar .dm-admin-sidebar summary, #admin-sidebar .dm-admin-sidebar .sidebar-text { ${l.join("; ")} }`),u.iconSize){const h=y(u.iconSize);v.push(`#admin-sidebar .dm-admin-sidebar [data-icon], #admin-sidebar .dm-admin-sidebar [data-icon] svg { width: ${h} !important; height: ${h} !important; }`)}v.length&&(C=`<style data-admin-sidebar-style>${v.join(" ")}</style>`)}const B=ke(n)?we():"",P=`<div class="dm-admin-sidebar-header"><span data-icon="layout"></span><span class="dm-admin-sidebar-title">${m(q)}</span>${B}${qe()}</div>`,T=o.some(l=>l&&l.type==="spacer"&&l.size==="flex")?" dm-admin-sidebar--has-flex-spacer":"",U=`${C}<nav class="dm-admin-sidebar${F}${T}">${P}${o.map(l=>oe(l,k)).join("")}</nav>`,R=document.createRange();R.selectNodeContents(t);const p=R.createContextualFragment(U);t.replaceChildren(p),Domma.icons.scan(t),t.querySelector(".dm-admin-sidebar-arrange")?.addEventListener("click",()=>Se(t));for(const l of A)D.set(l.url,k[l.url]??null);ne(t),Ke(t),t.removeAttribute("data-menu-hover"),t.removeAttribute("data-menu-active"),t.style.removeProperty("--dm-menu-highlight"),me(t,{highlight:r});const b=document.querySelector(".dashboard-wrapper");["position","top","bottom","left","right","zIndex","maxHeight","transform"].forEach(l=>{t.style[l]=""}),t.classList.remove("dm-menu-floating"),b&&(b.style.paddingLeft="",b.style.paddingRight=""),t.__dmGutter=()=>{if(d!=="floating"||!b)return;const l=c&&c.anchor||"TL",v=Number.isFinite(Number(c?.offsetX))?Number(c.offsetX):16,h=t.classList.contains("is-collapsed")?Ae:parseInt(t.style.width,10)||t.getBoundingClientRect().width||260,N=Math.round(h+v*2)+"px";l==="R"||l==="TR"||l==="BR"?b.style.paddingRight=N:b.style.paddingLeft=N},d==="floating"&&fe(t,{position:"floating",float:c||{}}),t.querySelectorAll("details").forEach(l=>{l.addEventListener("toggle",function(){const v=this.getAttribute("data-state-key");v&&S.set(v,this.open);const h=this.querySelector(":scope > .sidebar-children");if(h)if(this.open){const N=h.scrollHeight;h.style.maxHeight="0",requestAnimationFrame(()=>{h.style.maxHeight=N+"px",h.addEventListener("transitionend",function de(){h.style.maxHeight="none",h.removeEventListener("transitionend",de)})})}else{const N=h.scrollHeight;h.style.maxHeight=N+"px",requestAnimationFrame(()=>{h.style.maxHeight="0"})}})}),Ce(t),Qe(t),t.dataset.routeWired||(t.dataset.routeWired="1",M.subscribe("router:afterChange",()=>t.__dmSetActive?.()));function I(){const l=/^#\/(dashboards|my-dashboard)\b/.test(location.hash)?"#/":location.hash||"#/";$(t).find(".sidebar-link").removeClass("active"),$(t).find(`.sidebar-link[data-url="${l}"]`).addClass("active")}I(),t.__dmSetActive=I}function le(){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}function K(e){return`${location.origin}${location.pathname}${e}`}function _(e,n){const s=L(),t=e(z(s));if(!Ne(s,t)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}t.length&&Q(s,!1),ce(s),n&&E.toast(n,{type:"success",duration:2500}),le()}function ce(e){Me(g,e).catch(()=>E.toast("Frequent is saved in this browser only - the server could not keep it.",{type:"warning"}))}function Ze(e){if(!Q(L(),e)){E.toast("Frequent could not be saved - this browser is blocking storage.",{type:"error"});return}ce(L()),E.toast(e?"Frequent hidden. Add a Tool to it from its right-click menu to bring it back.":"Frequent shown.",{type:"success",duration:3500}),le()}function Ye(e){const n=e.dataset.url;if(!n)return[];const s=e.querySelector(".sidebar-text")?.textContent.trim()||n,t=z(L()),i=t.includes(n),o=!!e.closest("details[data-frequent]"),a=t.indexOf(n),d=!i&&t.length>=Y;return[{header:s},{label:"Open",icon:"arrow-right",...ee(n)&&{shortcut:Pe(ee(n))},action:()=>{location.hash=n}},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(K(n),"_blank","noopener")},{type:"divider"},i?{label:"Remove from Frequent",icon:"star",action:()=>_(u=>Te(u,n),`${s} removed from Frequent.`)}:{label:d?`Frequent is full (${Y})`:"Add to Frequent",icon:"star",disabled:d,action:()=>_(u=>Fe(u,n),`${s} added to Frequent.`)},...o?[{label:"Move up",icon:"arrow-up",disabled:a<=0,action:()=>_(u=>J(u,n,-1))},{label:"Move down",icon:"arrow-down",disabled:a<0||a>=t.length-1,action:()=>_(u=>J(u,n,1))}]:[],{type:"divider"},{label:"Copy link",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(K(n)),E.toast("Link copied.",{type:"success",duration:2e3})}catch{E.toast(K(n),{type:"info",duration:8e3})}}}]}function Je(e,n){const s=e.closest("details"),t=e.querySelector(".sidebar-text")?.textContent.trim()||"Folder",i=o=>n.querySelectorAll("details").forEach(a=>{a.open!==o&&(a.open=o)});return[{header:t},{label:s?.open?"Close":"Open",icon:s?.open?"chevron-up":"chevron-down",action:()=>{s&&(s.open=!s.open)}},{label:"Open every folder",icon:"chevrons-down",action:()=>i(!0)},{label:"Close every folder",icon:"chevrons-up",action:()=>i(!1)},...s?.hasAttribute("data-frequent-empty")?[{type:"divider"},{label:"Hide Frequent until I add a Tool",icon:"eye-off",action:()=>Ze(!0)}]:s?.hasAttribute("data-frequent")?[{type:"divider"},{label:"Clear Frequent",icon:"x",action:()=>_(()=>[],"Frequent cleared.")}]:[]]}function Qe(e){e.dataset.ctxWired||typeof E>"u"||!E.contextMenu||(e.dataset.ctxWired="1",E.contextMenu(e,{match:"a.sidebar-link, details > summary",inherit:!1,density:"compact",items:n=>n.matches("summary")?Je(n,e):Ye(n)}))}
|
package/package.json
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
|
|
7
7
|
import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
|
|
8
|
+
import {getUserById} from '../services/users.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Verify JWT Bearer token. Populates request.user on success.
|
|
11
12
|
*
|
|
13
|
+
* The token does not carry the user's project access scope, so it is read from
|
|
14
|
+
* the user record on every request and set as `request.user.projects` - every
|
|
15
|
+
* `canSeeArtefact()` check depends on it, and without it a scoped user looked
|
|
16
|
+
* unrestricted. Reading it here, not signing it into the token, means a change
|
|
17
|
+
* to someone's access applies to their next request.
|
|
18
|
+
*
|
|
12
19
|
* @param {FastifyRequest} request
|
|
13
20
|
* @param {FastifyReply} reply
|
|
14
21
|
* @returns {Promise<void>}
|
|
@@ -30,6 +37,8 @@ export async function authenticate(request, reply) {
|
|
|
30
37
|
message: 'Invalid or missing authentication token'
|
|
31
38
|
});
|
|
32
39
|
}
|
|
40
|
+
const user = request.user?.id ? await getUserById(request.user.id) : null;
|
|
41
|
+
request.user.projects = Array.isArray(user?.projects) ? user.projects : [];
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/**
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '../../services/previewLinks.js';
|
|
24
24
|
import {getVersionCount} from '../../services/versions.js';
|
|
25
25
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
26
|
+
import {canSeeArtefact, canSeePage} from '../../services/projects.js';
|
|
26
27
|
import {getConfig, saveConfig} from '../../config.js';
|
|
27
28
|
import {relinkItems, relinkMenus} from '../../services/menus.js';
|
|
28
29
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
@@ -113,7 +114,8 @@ export async function pagesRoutes(fastify) {
|
|
|
113
114
|
|
|
114
115
|
fastify.get('/pages/preview-links', canRead, async (request) => {
|
|
115
116
|
const {urlPath} = request.query || {};
|
|
116
|
-
|
|
117
|
+
const links = await listPreviewLinks(urlPath || undefined);
|
|
118
|
+
return {links: await visibleTo(request.user, links, l => l.urlPath)};
|
|
117
119
|
});
|
|
118
120
|
|
|
119
121
|
fastify.post('/pages/preview-links', canUpdate, async (request, reply) => {
|
|
@@ -123,9 +125,11 @@ export async function pagesRoutes(fastify) {
|
|
|
123
125
|
}
|
|
124
126
|
// Refuse to mint for a page that does not exist - otherwise a typo
|
|
125
127
|
// produces a link that 404s for the recipient and looks like a bug.
|
|
126
|
-
|
|
128
|
+
const target = await getPage(urlPath);
|
|
129
|
+
if (!target) {
|
|
127
130
|
return reply.status(404).send({error: 'Page not found'});
|
|
128
131
|
}
|
|
132
|
+
if (!(await canSeePage(request.user, urlPath, target.project))) return denied(reply);
|
|
129
133
|
const link = await createPreviewLink({
|
|
130
134
|
urlPath,
|
|
131
135
|
jwt: fastify.jwt,
|
|
@@ -141,6 +145,8 @@ export async function pagesRoutes(fastify) {
|
|
|
141
145
|
});
|
|
142
146
|
|
|
143
147
|
fastify.delete('/pages/preview-links/:id', canUpdate, async (request, reply) => {
|
|
148
|
+
const link = (await listPreviewLinks()).find(l => l.id === request.params.id);
|
|
149
|
+
if (link && !(await canSeePage(request.user, link.urlPath))) return denied(reply);
|
|
144
150
|
const ok = await revokePreviewLink(request.params.id);
|
|
145
151
|
if (!ok) return reply.status(404).send({error: 'Link not found'});
|
|
146
152
|
return {success: true};
|
|
@@ -148,7 +154,7 @@ export async function pagesRoutes(fastify) {
|
|
|
148
154
|
|
|
149
155
|
// Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
|
|
150
156
|
fastify.get('/pages/tags', canRead, async (request, reply) => {
|
|
151
|
-
const pages = await listPages();
|
|
157
|
+
const pages = (await listPages()).filter(p => inScope(request.user, p));
|
|
152
158
|
const tagSet = new Set();
|
|
153
159
|
pages.forEach(p => {
|
|
154
160
|
if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
|
|
@@ -157,11 +163,12 @@ export async function pagesRoutes(fastify) {
|
|
|
157
163
|
});
|
|
158
164
|
|
|
159
165
|
// The sidebar badge. Static, so it is matched before the /pages/* wildcard below.
|
|
160
|
-
fastify.get('/pages/_count', canRead, async () =>
|
|
166
|
+
fastify.get('/pages/_count', canRead, async (request) =>
|
|
167
|
+
pagesBadge((await listPages()).filter(p => inScope(request.user, p))));
|
|
161
168
|
|
|
162
169
|
// List all pages
|
|
163
170
|
fastify.get('/pages', canRead, async (request, reply) => {
|
|
164
|
-
const pages = await listPages();
|
|
171
|
+
const pages = (await listPages()).filter(p => inScope(request.user, p));
|
|
165
172
|
const counts = await Promise.all(pages.map(p => getVersionCount(p.urlPath)));
|
|
166
173
|
return pages.map((p, i) => ({
|
|
167
174
|
urlPath: p.urlPath,
|
|
@@ -191,6 +198,7 @@ export async function pagesRoutes(fastify) {
|
|
|
191
198
|
const urlPath = '/' + request.params['*'];
|
|
192
199
|
const page = await getPage(urlPath);
|
|
193
200
|
if (!page) return reply.status(404).send({ error: 'Page not found' });
|
|
201
|
+
if (!(await canSeePage(request.user, urlPath, page.project))) return denied(reply);
|
|
194
202
|
return page;
|
|
195
203
|
});
|
|
196
204
|
|
|
@@ -214,6 +222,7 @@ export async function pagesRoutes(fastify) {
|
|
|
214
222
|
});
|
|
215
223
|
}
|
|
216
224
|
|
|
225
|
+
if (!(await canSeePage(request.user, urlPath, frontmatter?.project))) return denied(reply);
|
|
217
226
|
const page = await createPage(urlPath, frontmatter || {}, body || '');
|
|
218
227
|
return reply.status(201).send(page);
|
|
219
228
|
});
|
|
@@ -225,6 +234,10 @@ export async function pagesRoutes(fastify) {
|
|
|
225
234
|
|
|
226
235
|
const existing = await getPage(urlPath);
|
|
227
236
|
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
237
|
+
if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
|
|
238
|
+
// Nor may a save move the page into a project outside the user's scope.
|
|
239
|
+
const project = frontmatter && 'project' in frontmatter ? frontmatter.project : existing.project;
|
|
240
|
+
if (!(await canSeePage(request.user, newUrlPath || urlPath, project))) return denied(reply);
|
|
228
241
|
|
|
229
242
|
if (newUrlPath && newUrlPath !== urlPath) {
|
|
230
243
|
const conflict = await getPage(newUrlPath);
|
|
@@ -249,6 +262,7 @@ export async function pagesRoutes(fastify) {
|
|
|
249
262
|
const urlPath = '/' + request.params['*'];
|
|
250
263
|
const existing = await getPage(urlPath);
|
|
251
264
|
if (!existing) return reply.status(404).send({ error: 'Page not found' });
|
|
265
|
+
if (!(await canSeePage(request.user, urlPath, existing.project))) return denied(reply);
|
|
252
266
|
|
|
253
267
|
await deletePage(urlPath);
|
|
254
268
|
// A share link must not outlive the page it pointed at.
|
|
@@ -257,6 +271,45 @@ export async function pagesRoutes(fastify) {
|
|
|
257
271
|
});
|
|
258
272
|
}
|
|
259
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A listed page's project is already resolved (listPages sets resolvedProject).
|
|
276
|
+
*
|
|
277
|
+
* @param {object} user
|
|
278
|
+
* @param {object} page
|
|
279
|
+
* @returns {boolean}
|
|
280
|
+
*/
|
|
281
|
+
function inScope(user, page) {
|
|
282
|
+
return canSeeArtefact(user, {meta: {project: page.resolvedProject}});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Keep the items whose page `user` may see.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} user
|
|
289
|
+
* @param {object[]} items
|
|
290
|
+
* @param {function(object): string} urlOf
|
|
291
|
+
* @returns {Promise<object[]>}
|
|
292
|
+
*/
|
|
293
|
+
async function visibleTo(user, items, urlOf) {
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const item of items) {
|
|
296
|
+
const page = await getPage(urlOf(item));
|
|
297
|
+
if (await canSeePage(user, urlOf(item), page?.project)) out.push(item);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The refusal every page route gives for a page outside the user's projects -
|
|
304
|
+
* the same wording as the collection routes.
|
|
305
|
+
*
|
|
306
|
+
* @param {FastifyReply} reply
|
|
307
|
+
* @returns {FastifyReply}
|
|
308
|
+
*/
|
|
309
|
+
function denied(reply) {
|
|
310
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
311
|
+
}
|
|
312
|
+
|
|
260
313
|
/**
|
|
261
314
|
* A page moved: re-aim the menu links that pointed at it. Menus replaced
|
|
262
315
|
* config/navigation.json; a site that still has the legacy file gets it rewritten
|
|
@@ -50,6 +50,16 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
50
50
|
const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
|
|
51
51
|
const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
|
|
52
52
|
|
|
53
|
+
// A project outside the user's access scope is not there for them: the same
|
|
54
|
+
// 404 the list's absence implies, as remove-contents already answers.
|
|
55
|
+
const inScope = async (request, reply) => {
|
|
56
|
+
const visible = await listProjectsForUser(request.user);
|
|
57
|
+
if (!visible.some(p => p.slug === request.params.slug)) {
|
|
58
|
+
return reply.status(404).send({error: 'Project not found'});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const scoped = (guard) => ({preHandler: [...guard.preHandler, inScope]});
|
|
62
|
+
|
|
53
63
|
fastify.get('/projects', canRead, async (request) => {
|
|
54
64
|
const list = await listProjectsForUser(request.user);
|
|
55
65
|
if (request.query?.counts !== '1') return list;
|
|
@@ -64,7 +74,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
64
74
|
// Registered before /projects/:slug; `_` is outside the slug alphabet, so no project can shadow it.
|
|
65
75
|
fastify.get('/projects/_count', canRead, async (request) => projectsBadge(await listProjectsForUser(request.user)));
|
|
66
76
|
|
|
67
|
-
fastify.get('/projects/:slug', canRead, async (request, reply) => {
|
|
77
|
+
fastify.get('/projects/:slug', scoped(canRead), async (request, reply) => {
|
|
68
78
|
const project = await getProject(request.params.slug);
|
|
69
79
|
if (!project) return reply.status(404).send({error: 'Project not found'});
|
|
70
80
|
return project;
|
|
@@ -82,7 +92,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
82
92
|
}
|
|
83
93
|
});
|
|
84
94
|
|
|
85
|
-
fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
|
|
95
|
+
fastify.put('/projects/:slug', scoped(canUpdate), async (request, reply) => {
|
|
86
96
|
try {
|
|
87
97
|
const project = await updateProject(request.params.slug, request.body || {});
|
|
88
98
|
await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
|
|
@@ -93,7 +103,7 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
93
103
|
}
|
|
94
104
|
});
|
|
95
105
|
|
|
96
|
-
fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
|
|
106
|
+
fastify.delete('/projects/:slug', scoped(canDelete), async (request, reply) => {
|
|
97
107
|
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
98
108
|
return reply.status(403).send({error: 'Cannot delete the core project'});
|
|
99
109
|
}
|
|
@@ -140,14 +150,14 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
140
150
|
}
|
|
141
151
|
});
|
|
142
152
|
|
|
143
|
-
fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
|
|
153
|
+
fastify.get('/projects/:slug/artefacts', scoped(canRead), async (request, reply) => {
|
|
144
154
|
if (!(await getProject(request.params.slug))) {
|
|
145
155
|
return reply.status(404).send({error: 'Project not found'});
|
|
146
156
|
}
|
|
147
157
|
return getArtefactsForProject(request.params.slug);
|
|
148
158
|
});
|
|
149
159
|
|
|
150
|
-
fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
|
|
160
|
+
fastify.post('/projects/:slug/untag-all', scoped(canDelete), async (request, reply) => {
|
|
151
161
|
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
152
162
|
return reply.status(403).send({error: 'Cannot untag the core project - artefacts would immediately resolve back to it'});
|
|
153
163
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/sidebar/menu - the admin-sidebar menu, filtered for the caller
|
|
5
5
|
* GET /api/sidebar/registered-items - plugin-registered sidebar items, filtered for the caller
|
|
6
|
+
* GET /api/sidebar/frequent - the caller's own Frequent list
|
|
7
|
+
* PUT /api/sidebar/frequent - keep it ({urls, hidden})
|
|
6
8
|
*
|
|
7
9
|
* Both need only a signed-in user, NOT `menus.read`: every user who can reach
|
|
8
10
|
* the admin needs a sidebar, and a role without menu-editing rights (a
|
|
@@ -19,6 +21,7 @@ import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
|
19
21
|
import {filterItemsForUser, getMenu} from '../../services/menus.js';
|
|
20
22
|
import {isDashboardPro} from '../../services/widgets.js';
|
|
21
23
|
import {boardName, readPrefs, readSite} from '../../services/dashboardSettings.js';
|
|
24
|
+
import {frequentProblems, readFrequent, writeFrequent} from '../../services/sidebarFrequent.js';
|
|
22
25
|
|
|
23
26
|
export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
|
|
24
27
|
|
|
@@ -61,4 +64,17 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
61
64
|
? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
|
|
62
65
|
: entry);
|
|
63
66
|
});
|
|
67
|
+
|
|
68
|
+
// Each person's own, so it follows them from browser to browser (sidebarFrequent.js).
|
|
69
|
+
fastify.get('/sidebar/frequent', signedIn, async (request) => readFrequent(request.user));
|
|
70
|
+
|
|
71
|
+
fastify.put('/sidebar/frequent', signedIn, async (request, reply) => {
|
|
72
|
+
const problems = frequentProblems(request.body);
|
|
73
|
+
if (problems.length) return reply.code(400).send({error: problems.join(' ')});
|
|
74
|
+
try {
|
|
75
|
+
return await writeFrequent(request.user, request.body);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return reply.code(400).send({error: err.message});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
64
80
|
}
|
|
@@ -18,11 +18,28 @@ import {
|
|
|
18
18
|
restoreVersion
|
|
19
19
|
} from '../../services/versions.js';
|
|
20
20
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
21
|
+
import {getPage} from '../../services/content.js';
|
|
22
|
+
import {canSeePage} from '../../services/projects.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A page's versions belong to its project: refuse them to a user outside it,
|
|
26
|
+
* as the page routes do. Every route here ends in the page's path (`*`).
|
|
27
|
+
*
|
|
28
|
+
* @param {FastifyRequest} request
|
|
29
|
+
* @param {FastifyReply} reply
|
|
30
|
+
*/
|
|
31
|
+
async function pageInScope(request, reply) {
|
|
32
|
+
const urlPath = '/' + request.params['*'];
|
|
33
|
+
const page = await getPage(urlPath);
|
|
34
|
+
if (!(await canSeePage(request.user, urlPath, page?.project))) {
|
|
35
|
+
return reply.status(403).send({error: 'Access denied for this project'});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
export async function versionsRoutes(fastify) {
|
|
23
|
-
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
24
|
-
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
25
|
-
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
40
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read'), pageInScope]};
|
|
41
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update'), pageInScope]};
|
|
42
|
+
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete'), pageInScope]};
|
|
26
43
|
|
|
27
44
|
// List versions for a page
|
|
28
45
|
fastify.get('/versions/list/*', canRead, async (request, reply) => {
|
|
@@ -405,6 +405,21 @@ export async function getProjectForPage(urlPath, explicitProject) {
|
|
|
405
405
|
return best ?? coreFallback;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Can `user` see the page at `urlPath`? A page's project comes from its front
|
|
410
|
+
* matter or its address (getProjectForPage), then the same rule as every other
|
|
411
|
+
* artefact applies.
|
|
412
|
+
*
|
|
413
|
+
* @param {object} user
|
|
414
|
+
* @param {string} urlPath
|
|
415
|
+
* @param {string|null|undefined} explicitProject - the page's front matter `project`
|
|
416
|
+
* @returns {Promise<boolean>}
|
|
417
|
+
*/
|
|
418
|
+
export async function canSeePage(user, urlPath, explicitProject) {
|
|
419
|
+
const project = await getProjectForPage(urlPath, explicitProject);
|
|
420
|
+
return canSeeArtefact(user, {meta: {project}});
|
|
421
|
+
}
|
|
422
|
+
|
|
408
423
|
/** The per-type buckets every project's artefacts are grouped into. */
|
|
409
424
|
export function emptyGroups() {
|
|
410
425
|
return {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar's Frequent folder, kept per user on the server so it follows the
|
|
3
|
+
* person to every browser and device (admin/js/lib/sidebar-frequent.js keeps
|
|
4
|
+
* a copy in the browser to draw from). content/sidebar/frequent/<user id>.json
|
|
5
|
+
* - per user, so ignored in a site repo like Dashboard Pro's prefs.
|
|
6
|
+
*
|
|
7
|
+
* Only the list is kept here, not what it may open: the sidebar draws Frequent
|
|
8
|
+
* from the user's already-filtered tree, so a url they cannot open never shows.
|
|
9
|
+
*
|
|
10
|
+
* Tested in tests/services/sidebar-frequent.test.js.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
/** As FREQUENT_MAX in admin/js/lib/sidebar-frequent.js. */
|
|
16
|
+
export const FREQUENT_MAX = 12;
|
|
17
|
+
const URL_MAX = 500;
|
|
18
|
+
const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
19
|
+
|
|
20
|
+
/** A list, cleaned: admin hash urls only, once each, at most FREQUENT_MAX. */
|
|
21
|
+
export function normaliseFrequent(input) {
|
|
22
|
+
const p = input && typeof input === 'object' ? input : {};
|
|
23
|
+
const urls = [];
|
|
24
|
+
for (const u of Array.isArray(p.urls) ? p.urls : []) {
|
|
25
|
+
if (typeof u !== 'string' || !u.startsWith('#/') || u.length > URL_MAX || urls.includes(u)) continue;
|
|
26
|
+
urls.push(u);
|
|
27
|
+
if (urls.length >= FREQUENT_MAX) break;
|
|
28
|
+
}
|
|
29
|
+
return {urls, hidden: p.hidden === true};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What is wrong with a body sent to PUT, or [] when nothing is. */
|
|
33
|
+
export function frequentProblems(input) {
|
|
34
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Send {urls, hidden}.'];
|
|
35
|
+
const problems = [];
|
|
36
|
+
if (input.urls !== undefined && !Array.isArray(input.urls)) problems.push('urls must be a list.');
|
|
37
|
+
if (input.hidden !== undefined && typeof input.hidden !== 'boolean') problems.push('hidden must be true or false.');
|
|
38
|
+
return problems;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DIR = () => path.resolve('content', 'sidebar', 'frequent');
|
|
42
|
+
const fileFor = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(DIR(), `${user.id}.json`) : null);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The user's list. `saved` is false until they have kept one here, so the
|
|
46
|
+
* browser knows to bring up a list it kept before Frequent moved to the server.
|
|
47
|
+
*/
|
|
48
|
+
export async function readFrequent(user) {
|
|
49
|
+
const file = fileFor(user);
|
|
50
|
+
let data = null;
|
|
51
|
+
if (file) {
|
|
52
|
+
try { data = JSON.parse(await fs.readFile(file, 'utf8')); } catch { data = null; }
|
|
53
|
+
}
|
|
54
|
+
return {...normaliseFrequent(data), saved: !!data};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function writeFrequent(user, input) {
|
|
58
|
+
const file = fileFor(user);
|
|
59
|
+
if (!file) throw new Error('This account cannot keep a Frequent list.');
|
|
60
|
+
const data = normaliseFrequent(input);
|
|
61
|
+
await fs.mkdir(path.dirname(file), {recursive: true});
|
|
62
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
63
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
64
|
+
await fs.rename(tmp, file);
|
|
65
|
+
return {...data, saved: true};
|
|
66
|
+
}
|
|
@@ -320,6 +320,8 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
320
320
|
// Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
|
|
321
321
|
'/content/dashboard/layouts/',
|
|
322
322
|
'/content/dashboard/prefs/',
|
|
323
|
+
// Each admin's own sidebar Frequent list (keyed by user id).
|
|
324
|
+
'/content/sidebar/frequent/',
|
|
323
325
|
'/config/connections.json*',
|
|
324
326
|
'/config/keys/',
|
|
325
327
|
'/plugins/*/data/*.jsonl',
|