runlight 0.1.1 → 0.1.6

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/assets/assistant-action.json +1 -0
  3. data/assets/build.json +4 -4
  4. data/assets/dashboard.css +1 -1
  5. data/assets/dashboard.js +65 -23
  6. data/assets/locales.json +5 -5
  7. data/assets/pick-return.js +1 -0
  8. data/assets/picker.js +1 -1
  9. data/assets/tracker.js +1 -1
  10. data/lib/runlight/accounts/accounts.rb +30 -17
  11. data/lib/runlight/accounts/pages.rb +67 -3
  12. data/lib/runlight/accounts/web.rb +45 -30
  13. data/lib/runlight/assistant.rb +41 -18
  14. data/lib/runlight/core.rb +126 -39
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +27 -4
  17. data/lib/runlight/db/db.rb +52 -4
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +27 -5
  21. data/lib/runlight/mail/ses.rb +3 -2
  22. data/lib/runlight/mail/transports.rb +19 -2
  23. data/lib/runlight/metadata_address_error.rb +13 -0
  24. data/lib/runlight/oauth.rb +83 -15
  25. data/lib/runlight/rack_app.rb +11 -4
  26. data/lib/runlight/redirect_refused_error.rb +14 -0
  27. data/lib/runlight/reports.rb +1 -6
  28. data/lib/runlight/routes.rb +244 -96
  29. data/lib/runlight/safefetch.rb +98 -1
  30. data/lib/runlight/server/agents.rb +1 -1
  31. data/lib/runlight/server/cli.rb +1 -1
  32. data/lib/runlight/server/config.rb +64 -19
  33. data/lib/runlight/server/standalone.rb +7 -6
  34. data/lib/runlight/store/sql.rb +10 -0
  35. data/lib/runlight/store/sql_store.rb +76 -14
  36. data/lib/runlight/stores.rb +19 -15
  37. data/lib/runlight/tables_busy_error.rb +11 -0
  38. data/lib/runlight/version.rb +1 -1
  39. data/lib/runlight.rb +4 -0
  40. metadata +8 -2
@@ -0,0 +1 @@
1
+ (function(){var a=window,r=document,i=location.hash.slice(1);history.replaceState(null,"",location.pathname);var t={};i.split("&").forEach(function(e){var n=e.indexOf("=");try{n>0&&(t[e.slice(0,n)]=decodeURIComponent(e.slice(n+1)))}catch(s){}});var o=null;try{o=localStorage.getItem("runlight-pick")}catch(e){}var c=/^[a-f0-9]{32}$/.test(t.n||"")&&t.n===o&&typeof t.s=="string"&&!!a.BroadcastChannel;if(c){try{localStorage.removeItem("runlight-pick")}catch(e){}var l=new BroadcastChannel("runlight-pick");l.postMessage({runlight:"pick",nonce:t.n,selector:t.s,href:t.h||"",text:t.t||""}),l.close(),setTimeout(function(){a.close()},900)}r.getElementById(c?"done":"gone").hidden=!1})();
data/assets/picker.js CHANGED
@@ -1 +1 @@
1
- (function(){var l=window,a=document,g=l.opener;if(g){var d="__RUNLIGHT_PICK_TARGET__";if(/^https?:\/\//.test(d)){var x=JSON.parse("__RUNLIGHT_PICK_HOSTS__");if(!(x.length&&x.indexOf(location.hostname.toLowerCase().replace(/^www\./,""))<0)){var m={en:["Click what you want to count","Use this","Pick again","Cancel","Matches {n} on this page","Sent to Runlight. You can close this tab."],fr:["Cliquez sur ce que vous voulez compter","Utiliser","Choisir \xE0 nouveau","Annuler","{n} sur cette page","Envoy\xE9 \xE0 Runlight. Vous pouvez fermer cet onglet."],es:["Haz clic en lo que quieres contar","Usar esto","Elegir otra vez","Cancelar","{n} en esta p\xE1gina","Enviado a Runlight. Puedes cerrar esta pesta\xF1a."],de:["Klicke auf das, was gez\xE4hlt werden soll","Verwenden","Neu w\xE4hlen","Abbrechen","{n} auf dieser Seite","An Runlight gesendet. Du kannst diesen Tab schlie\xDFen."],pt:["Clique no que quer contar","Usar","Escolher de novo","Cancelar","{n} nesta p\xE1gina","Enviado ao Runlight. Pode fechar esta aba."]},T=(/[?&]runlight_lang=([a-z]{2})/.exec(location.search)||[])[1],s=m[T]||m.en,b=function(t){return/^[a-zA-Z][\w-]{1,40}$/.test(t)&&!/\d{3,}|^(css|sc|jsx|svelte|astro)-|__\w{5,}/.test(t)},w=function(t){return l.CSS&&CSS.escape?CSS.escape(t):t.replace(/[^\w-]/g,"\\$&")},C=function(t){try{return a.querySelectorAll(t).length===1}catch(e){return!1}},A=function(t){for(var e=[],n=t;n&&n.nodeType===1&&n!==a.documentElement;n=n.parentElement){if(n.id&&b(n.id)){e.unshift("#"+w(n.id));break}var r=n.tagName.toLowerCase(),v=n.getAttribute("data-runlight")||n.getAttribute("data-testid");if(v)r+="["+(n.hasAttribute("data-runlight")?"data-runlight":"data-testid")+'="'+v.replace(/"/g,'\\"')+'"]';else{var q=Array.prototype.filter.call(n.classList,b).slice(0,2);q.length&&(r+="."+q.map(w).join("."))}e.unshift(r);var E=e.join(" > ");if(C(E)||e.length>=4)return E;var _=n.parentElement;if(_){var k=Array.prototype.filter.call(_.children,function(z){return z.tagName===n.tagName});if(k.length>1&&(e[0]+=":nth-of-type("+(k.indexOf(n)+1)+")"),C(e.join(" > ")))return e.join(" > ")}}return e.join(" > ")},o=a.createElement("div");o.style.cssText="position:fixed;inset:0;z-index:2147483647;pointer-events:none";var u=o.attachShadow?o.attachShadow({mode:"open"}):o;u.innerHTML="<style>.box{position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.1);border-radius:4px;pointer-events:none;transition:all .06s}.bar{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;max-width:min(720px,calc(100vw - 32px));padding:10px 12px 10px 16px;border-radius:12px;background:#111;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.3);pointer-events:auto}.msg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}code{font:12px ui-monospace,monospace;color:#93c5fd}button{height:32px;padding:0 12px;border:0;border-radius:8px;font:inherit;cursor:pointer;background:#333;color:#fff}button.go{background:#fff;color:#111}</style><div class='box' hidden></div><div class='bar'><span class='msg'></span><span class='acts'></span></div>",a.documentElement.appendChild(o);var f=u.querySelector(".box"),c=u.querySelector(".msg"),p=u.querySelector(".acts"),i=null,h=function(t,e,n){var r=a.createElement("button");r.textContent=t,e&&(r.className=e),r.addEventListener("click",n),p.appendChild(r)},y=function(t){if(!t)return f.hidden=!0;var e=t.getBoundingClientRect();f.hidden=!1,f.style.cssText+=";left:"+(e.left-2)+"px;top:"+(e.top-2)+"px;width:"+(e.width+4)+"px;height:"+(e.height+4)+"px"},S=function(){i=null,c.innerHTML="<span></span> \xB7 <code></code>",c.querySelector("span").textContent=s[0],c.querySelector("code").textContent=d.replace(/^https?:\/\//,""),p.textContent="",h(s[3],"",function(){l.close()})},L=function(t){var e=A(t),n=t.closest("a[href]"),r=0;try{r=a.querySelectorAll(e).length}catch(v){}i={selector:e,href:n?n.href:"",text:(t.textContent||"").trim().slice(0,80)},c.innerHTML="<code></code> \xB7 "+s[4].replace("{n}",r),c.querySelector("code").textContent=e,p.textContent="",h(s[2],"",S),h(s[1],"go",function(){g.postMessage({runlight:"pick",selector:i.selector,href:i.href,text:i.text},d),c.textContent=s[5],p.textContent="",setTimeout(function(){l.close()},900)})};a.addEventListener("mouseover",function(t){i||y(t.target)},!0),["click","mousedown","mouseup","pointerdown","submit"].forEach(function(t){a.addEventListener(t,function(e){e.composedPath&&e.composedPath()[0]&&o.contains&&e.composedPath().indexOf(o)>=0||(e.preventDefault(),e.stopPropagation(),t==="click"&&!i&&(y(e.target),L(e.target)))},!0)}),S()}}}})();
1
+ (function(){var p=window,o=document,d="__RUNLIGHT_PICK_TARGET__";if(/^https?:\/\//.test(d)){var g="__RUNLIGHT_PICK_RETURN__",x="__RUNLIGHT_PICK_NONCE__",m=g.indexOf(d+"/")===0&&/^[a-f0-9]{32}$/.test(x),b=p.opener;if(!(!m&&!b)){var C=JSON.parse("__RUNLIGHT_PICK_HOSTS__");if(!(C.length&&C.indexOf(location.hostname.toLowerCase().replace(/^www\./,""))<0)){var w={en:["Click what you want to count","Use this","Pick again","Cancel","Matches {n} on this page","Sent to Runlight. You can close this tab."],fr:["Cliquez sur ce que vous voulez compter","Utiliser","Choisir \xE0 nouveau","Annuler","{n} sur cette page","Envoy\xE9 \xE0 Runlight. Vous pouvez fermer cet onglet."],es:["Haz clic en lo que quieres contar","Usar esto","Elegir otra vez","Cancelar","{n} en esta p\xE1gina","Enviado a Runlight. Puedes cerrar esta pesta\xF1a."],de:["Klicke auf das, was gez\xE4hlt werden soll","Verwenden","Neu w\xE4hlen","Abbrechen","{n} auf dieser Seite","An Runlight gesendet. Du kannst diesen Tab schlie\xDFen."],pt:["Clique no que quer contar","Usar","Escolher de novo","Cancelar","{n} nesta p\xE1gina","Enviado ao Runlight. Pode fechar esta aba."]},N=(/[?&]runlight_lang=([a-z]{2})/.exec(location.search)||[])[1],c=w[N]||w.en,_=function(t){return/^[a-zA-Z][\w-]{1,40}$/.test(t)&&!/\d{3,}|^(css|sc|jsx|svelte|astro)-|__\w{5,}/.test(t)},y=function(t){return p.CSS&&CSS.escape?CSS.escape(t):t.replace(/[^\w-]/g,"\\$&")},S=function(t){try{return o.querySelectorAll(t).length===1}catch(e){return!1}},A=function(t){for(var e=[],n=t;n&&n.nodeType===1&&n!==o.documentElement;n=n.parentElement){if(n.id&&_(n.id)){e.unshift("#"+y(n.id));break}var r=n.tagName.toLowerCase(),i=n.getAttribute("data-runlight")||n.getAttribute("data-testid");if(i)r+="["+(n.hasAttribute("data-runlight")?"data-runlight":"data-testid")+'="'+i.replace(/"/g,'\\"')+'"]';else{var q=Array.prototype.filter.call(n.classList,_).slice(0,2);q.length&&(r+="."+q.map(y).join("."))}e.unshift(r);var R=e.join(" > ");if(S(R)||e.length>=4)return R;var T=n.parentElement;if(T){var L=Array.prototype.filter.call(T.children,function(I){return I.tagName===n.tagName});if(L.length>1&&(e[0]+=":nth-of-type("+(L.indexOf(n)+1)+")"),S(e.join(" > ")))return e.join(" > ")}}return e.join(" > ")},s=o.createElement("div");s.style.cssText="position:fixed;inset:0;z-index:2147483647;pointer-events:none";var u=s.attachShadow?s.attachShadow({mode:"open"}):s;u.innerHTML="<style>.box{position:fixed;border:2px solid #2563eb;background:rgba(37,99,235,.1);border-radius:4px;pointer-events:none;transition:all .06s}.bar{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;max-width:min(720px,calc(100vw - 32px));padding:10px 12px 10px 16px;border-radius:12px;background:#111;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.3);pointer-events:auto}.msg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}code{font:12px ui-monospace,monospace;color:#93c5fd}button{height:32px;padding:0 12px;border:0;border-radius:8px;font:inherit;cursor:pointer;background:#333;color:#fff}button.go{background:#fff;color:#111}</style><div class='box' hidden></div><div class='bar'><span class='msg'></span><span class='acts'></span></div>",o.documentElement.appendChild(s);var h=u.querySelector(".box"),l=u.querySelector(".msg"),f=u.querySelector(".acts"),a=null,v=function(t,e,n){var r=o.createElement("button");r.textContent=t,e&&(r.className=e),r.addEventListener("click",n),f.appendChild(r)},E=function(t){if(!t)return h.hidden=!0;var e=t.getBoundingClientRect();h.hidden=!1,h.style.cssText+=";left:"+(e.left-2)+"px;top:"+(e.top-2)+"px;width:"+(e.width+4)+"px;height:"+(e.height+4)+"px"},k=function(){a=null,l.innerHTML="<span></span> \xB7 <code></code>",l.querySelector("span").textContent=c[0],l.querySelector("code").textContent=d.replace(/^https?:\/\//,""),f.textContent="",v(c[3],"",function(){p.close()})},U=function(t){var e=A(t),n=t.closest("a[href]"),r=0;try{r=o.querySelectorAll(e).length}catch(i){}a={selector:e,href:n?n.href:"",text:(t.textContent||"").trim().slice(0,80)},l.innerHTML="<code></code> \xB7 "+c[4].replace("{n}",r),l.querySelector("code").textContent=e,f.textContent="",v(c[2],"",k),v(c[1],"go",function(){if(l.textContent=c[5],f.textContent="",m){var i=o.createElement("a");i.rel="noreferrer",i.href=g+"#n="+x+"&s="+encodeURIComponent(a.selector)+"&h="+encodeURIComponent(a.href)+"&t="+encodeURIComponent(a.text),u.appendChild(i),i.click();return}b.postMessage({runlight:"pick",selector:a.selector,href:a.href,text:a.text},d),setTimeout(function(){p.close()},900)})};o.addEventListener("mouseover",function(t){a||E(t.target)},!0),["click","mousedown","mouseup","pointerdown","submit"].forEach(function(t){o.addEventListener(t,function(e){e.composedPath&&e.composedPath()[0]&&s.contains&&e.composedPath().indexOf(s)>=0||(e.preventDefault(),e.stopPropagation(),t==="click"&&!a&&(E(e.target),U(e.target)))},!0)}),k()}}}})();
data/assets/tracker.js CHANGED
@@ -1 +1 @@
1
- (function(){var a=window,n=document,u=navigator,p=n.currentScript;if(p){var o=function(e){return p.getAttribute("data-"+e)},R=p.src.replace(/s\.js(\?.*)?$/,"e"),b=o("site"),U=o("hash")!==null,I=o("manual")!==null,$=function(e){return new RegExp("^"+e.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")+"$")},C=(o("exclude")||"").split(",").filter(Boolean).map(function(e){return $(e.trim())}),A=!1,H=a.runlight&&a.runlight.q||[],m="__RUNLIGHT_RULES__",T=typeof m=="object"&&(m[b]||m[location.hostname.replace(/^www\./,"")]||m["*"])||[];if(/[?&]runlight=pick\b/.test(location.search)&&a.opener){var M=n.createElement("script");M.src=p.src.replace(/s\.js(\?.*)?$/,"pick.js")+location.search,n.head.appendChild(M),a.runlight=function(){};return}var N=!1;try{var O=/[?&]runlight=(ignore|track)\b/.exec(location.search);O&&(O[1]==="ignore"?localStorage.setItem("runlight_ignore","1"):localStorage.removeItem("runlight_ignore")),N=localStorage.getItem("runlight_ignore")==="1"}catch(e){}if(u.webdriver||N||o("dnt")!==null&&(u.doNotTrack==="1"||a.doNotTrack==="1")){a.runlight=function(){};return}var k=function(e,t){if(!A){var r={k:e,u:w};b&&(r.s=b);for(var i in t)r[i]=t[i];var s=JSON.stringify(r);u.sendBeacon&&u.sendBeacon(R,s)||a.fetch&&fetch(R,{method:"POST",body:s,keepalive:!0}).catch(function(){})}},G=function(){var e=new Uint8Array(8);return(a.crypto||{}).getRandomValues?crypto.getRandomValues(e):e.forEach(function(t,r){e[r]=Math.random()*256}),Array.prototype.map.call(e,function(t){return(t+256).toString(36).slice(-2)}).join("").slice(0,12)},z=o("404")!==null,w="",v="",f=0,d=0,y=0,J=function(){return n.visibilityState==="visible"&&n.hasFocus()},B=function(){d&&(f+=Date.now()-d),d=0},c=function(){!d&&J()&&(d=Date.now())},E=function(){B(),v&&f>=1e3&&(k("engagement",{i:v,e:f,d:y}),f=0)},F=function(){var e=n.documentElement,t=Math.max(e.scrollHeight,n.body?n.body.scrollHeight:0),r=(a.scrollY||e.scrollTop)+a.innerHeight,i=t>0?Math.min(100,Math.round(r/t*100)):100;i>y&&(y=i)},P=function(){var e=location;return e.protocol+"//"+e.host+e.pathname+e.search+(U?e.hash:"")},h=function(e){var t=typeof e=="string"?new URL(e,location.href).href:P();if(t!==w){var r=w||n.referrer;E(),w=t;var i=new URL(t).pathname;A=C.some(function(s){return s.test(i)}),v=G(),f=0,y=0,F(),c(),k("pageview",{i:v,r,t:n.title,w:screen.width,h:screen.height,l:u.language}),z&&l("404",{path:location.pathname}),z=!1}},l=function(e,t){typeof e!="string"||!e||k("event",{i:v,n:e,p:t&&typeof t=="object"?t:void 0})},D=function(e){var t=history[e];history[e]=function(){var r=t.apply(this,arguments);return h(),r}};I||(D("pushState"),D("replaceState"),a.addEventListener("popstate",h),U&&a.addEventListener("hashchange",h)),n.addEventListener("visibilitychange",function(){n.visibilityState==="hidden"?E():c()}),a.addEventListener("pagehide",E),a.addEventListener("blur",B),a.addEventListener("focus",c),a.addEventListener("scroll",function(){F(),c()},{passive:!0}),n.addEventListener("pointerdown",c,!0),n.addEventListener("keydown",c,!0);var Y=/\.(pdf|zip|gz|tgz|dmg|pkg|exe|msi|apk|docx?|xlsx?|pptx?|csv|txt|rtf|epub|mp3|wav|mp4|mov|avi|mkv|psd|ai|svg|key|numbers|pages)$/i;n.addEventListener("click",function(e){var t=e.target;if(!(!t||!t.closest)){for(var r=t.closest("a[href]"),i=0;i<T.length;i++)try{var s=T[i],S=s[1];(s[0]==="s"?t.closest(S):r&&$(S).test(S.charAt(0)==="/"?r.pathname:r.href))&&l(s[2])}catch(q){}var g=t.closest("[data-runlight]");if(g){for(var V={},x=0;x<g.attributes.length;x++){var _=g.attributes[x];_.name.indexOf("data-runlight-")===0&&(V[_.name.slice(14)]=_.value)}l(g.getAttribute("data-runlight"),V)}if(!(!r||g)){var j=r.href;/^https?:/.test(j)&&(o("downloads")!=="false"&&Y.test(r.pathname)?l("File download",{url:j}):o("outbound")!=="false"&&r.host!==location.host&&l("Outbound link",{url:j}))}}},!0),a.runlight=l,l.pageview=h,I||h();for(var L=0;L<H.length;L++)l.apply(null,H[L])}})();
1
+ (function(){var n=window,a=document,u=navigator,p=a.currentScript;if(p){var o=function(e){return p.getAttribute("data-"+e)},R=p.src.replace(/s\.js(\?.*)?$/,"e"),k=o("site"),U=o("hash")!==null,I=o("manual")!==null,$=o("before-send"),A=function(e){return new RegExp("^"+e.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")+"$")},G=(o("exclude")||"").split(",").filter(Boolean).map(function(e){return A(e.trim())}),H=!1,O=n.runlight&&n.runlight.q||[],m="__RUNLIGHT_RULES__",T=typeof m=="object"&&(m[k]||m[location.hostname.replace(/^www\./,"")]||m["*"])||[];if(/[?&]runlight=pick\b/.test(location.search)&&/[?&]runlight_ticket=/.test(location.search)){var M=a.createElement("script");M.src=p.src.replace(/s\.js(\?.*)?$/,"pick.js")+location.search,a.head.appendChild(M),n.runlight=function(){};return}var N=!1;try{var z=/[?&]runlight=(ignore|track)\b/.exec(location.search);z&&(z[1]==="ignore"?localStorage.setItem("runlight_ignore","1"):localStorage.removeItem("runlight_ignore")),N=localStorage.getItem("runlight_ignore")==="1"}catch(e){}if(u.webdriver||N||o("dnt")!==null&&(u.doNotTrack==="1"||n.doNotTrack==="1")){n.runlight=function(){};return}var E=function(e,t){if(!H){var r={k:e,u:b};k&&(r.s=k);for(var i in t)r[i]=t[i];if($!==null)try{if(r=n[$](r),!r||r.constructor!==Object)return}catch(y){return}var s=JSON.stringify(r);u.sendBeacon&&u.sendBeacon(R,s)||n.fetch&&fetch(R,{method:"POST",body:s,keepalive:!0}).catch(function(){})}},J=function(){var e=new Uint8Array(6);return(n.crypto||{}).getRandomValues?crypto.getRandomValues(e):e.forEach(function(t,r){e[r]=Math.random()*256}),Array.from(e,function(t){return(t+256).toString(36).slice(-2)}).join("")},B=o("404")!==null,b="",v="",f=0,d=0,w=0,P=function(){return a.visibilityState==="visible"&&a.hasFocus()},F=function(){d&&(f+=Date.now()-d),d=0},c=function(){!d&&P()&&(d=Date.now())},S=function(){F(),v&&f>=1e3&&(E("engagement",{i:v,e:f,d:w}),f=0)},D=function(){var e=a.documentElement,t=Math.max(e.scrollHeight,a.body?a.body.scrollHeight:0),r=(n.scrollY||e.scrollTop)+n.innerHeight,i=t>0?Math.min(100,Math.round(r/t*100)):100;i>w&&(w=i)},Y=function(){var e=location;return e.protocol+"//"+e.host+e.pathname+e.search+(U?e.hash:"")},h=function(e){var t=typeof e=="string"?new URL(e,location.href).href:Y();if(t!==b){var r=b||a.referrer;S(),b=t;var i=new URL(t).pathname;H=G.some(function(s){return s.test(i)}),v=J(),f=0,w=0,D(),c(),E("pageview",{i:v,r,t:a.title,w:screen.width,h:screen.height,l:u.language}),B&&l("404",{path:location.pathname}),B=!1}},l=function(e,t){typeof e!="string"||!e||E("event",{i:v,n:e,p:t&&typeof t=="object"?t:void 0})},V=function(e){var t=history[e];history[e]=function(){var r=t.apply(this,arguments);return h(),r}};I||(V("pushState"),V("replaceState"),n.addEventListener("popstate",h),U&&n.addEventListener("hashchange",h)),a.addEventListener("visibilitychange",function(){a.visibilityState==="hidden"?S():c()}),n.addEventListener("pagehide",S),n.addEventListener("blur",F),n.addEventListener("focus",c),n.addEventListener("scroll",function(){D(),c()},{passive:!0}),a.addEventListener("pointerdown",c,!0),a.addEventListener("keydown",c,!0);var q=/\.(pdf|zip|gz|tgz|dmg|pkg|exe|msi|apk|docx?|xlsx?|pptx?|csv|txt|rtf|epub|mp3|wav|mp4|mov|avi|mkv|psd|ai|svg|key|numbers|pages)$/i;a.addEventListener("click",function(e){var t=e.target;if(!(!t||!t.closest)){for(var r=t.closest("a[href]"),i=0;i<T.length;i++)try{var s=T[i],y=s[1];(s[0]==="s"?t.closest(y):r&&A(y).test(y.charAt(0)==="/"?r.pathname:r.href))&&l(s[2])}catch(K){}var g=t.closest("[data-runlight]");if(g){for(var C={},x=0;x<g.attributes.length;x++){var _=g.attributes[x];_.name.indexOf("data-runlight-")===0&&(C[_.name.slice(14)]=_.value)}l(g.getAttribute("data-runlight"),C)}if(!(!r||g)){var j=r.href;/^https?:/.test(j)&&(o("downloads")!=="false"&&q.test(r.pathname)?l("File download",{url:j}):o("outbound")!=="false"&&r.host!==location.host&&l("Outbound link",{url:j}))}}},!0),n.runlight=l,l.pageview=h,I||h();for(var L=0;L<O.length;L++)l.apply(null,O[L])}})();
@@ -138,7 +138,8 @@ module Runlight
138
138
  # Checks a six-digit code, or a recovery code, for an account with two-factor on.
139
139
  # A code works once: one already used, or older, is refused, and a recovery code is crossed off.
140
140
  # One check at a time, so two sign-ins at once cannot both use the same code: TypeScript queues them per
141
- # account in its process, and here the database's lock is taken, which holds across processes too.
141
+ # account in its process, and here the database's lock is taken, which holds across processes too. The
142
+ # update that uses a code is also conditional, so the database has the last word either way.
142
143
  def check_second_factor(id, code, now)
143
144
  init
144
145
  turn { check_second_factor_now(id, code, now) }
@@ -221,6 +222,7 @@ module Runlight
221
222
  if user["role"] == "owner"
222
223
  raise AccountError.new("Only the owner can change their own role, by handing ownership to an admin", "owner_protected")
223
224
  end
225
+ raise AccountError.new("An admin needs access to all sites", "role_scope") if !user["site"].nil? && role == "admin"
224
226
  raise AccountError.new("Ownership is handed over by the owner", "owner_hand_over") if role == "owner"
225
227
 
226
228
  @store.db.run("UPDATE rl_users SET role = ? WHERE id = ?", [role, id])
@@ -239,7 +241,7 @@ module Runlight
239
241
  nxt = find(to)
240
242
  raise AccountError.new("Only the owner can hand over ownership", "owner_hand_over") if owner.nil? || owner["role"] != "owner"
241
243
  raise AccountError.new("Unknown account", "unknown_account") if nxt.nil?
242
- raise AccountError.new("Make them an admin first", "owner_needs_admin") if nxt["role"] != "admin"
244
+ raise AccountError.new("Make them an admin first", "owner_needs_admin") if !nxt["site"].nil? || nxt["role"] != "admin"
243
245
 
244
246
  @store.db.run("UPDATE rl_users SET role = 'owner' WHERE id = ?", [to])
245
247
  @store.db.run("UPDATE rl_users SET role = 'admin' WHERE id = ?", [from])
@@ -265,8 +267,9 @@ module Runlight
265
267
 
266
268
  # Makes an account, or sets a new password on an existing one. A new account is the owner when it is the first,
267
269
  # and otherwise an admin unless a role is given, since a server has one owner.
268
- def set_password(email, password, now, role = nil)
270
+ def set_password(email, password, now, role = nil, site = nil)
269
271
  init
272
+ raise AccountError.new("Choose member or viewer for one site", "role_scope") if !site.nil? && (site.empty? || !%w[member viewer].include?(role))
270
273
  address = Js.lower(Js.trim(email))
271
274
  raise AccountError.new("Enter an email address", "email_invalid") unless address.match?(EMAIL)
272
275
  if Js.length(password) < MIN_PASSWORD
@@ -290,9 +293,10 @@ module Runlight
290
293
  "createdAt" => now,
291
294
  "twoFactor" => false,
292
295
  "recoveryLeft" => 0,
296
+ **(site.nil? ? {} : { "site" => site }),
293
297
  }
294
- @store.db.run("INSERT INTO rl_users (id, email, hash, role, created_at) VALUES (?, ?, ?, ?, ?)",
295
- [user["id"], user["email"], user["hash"], user["role"], user["createdAt"]])
298
+ @store.db.run("INSERT INTO rl_users (id, email, hash, role, created_at, site) VALUES (?, ?, ?, ?, ?, ?)",
299
+ [user["id"], user["email"], user["hash"], user["role"], user["createdAt"], site])
296
300
  user
297
301
  end
298
302
 
@@ -305,9 +309,9 @@ module Runlight
305
309
 
306
310
  # Invites someone to join with a role, and returns {"invite" => ..., "code" => ...}, the code for their link.
307
311
  # Asking again replaces the earlier invite, so only the newest link works.
308
- def invite(email, role, invited_by, now)
312
+ def invite(email, role, invited_by, now, site = nil)
309
313
  init
310
- turn { invite_now(email, role, invited_by, now) }
314
+ turn { invite_now(email, role, invited_by, now, site) }
311
315
  end
312
316
 
313
317
  # The invite a link's code belongs to, while it still works.
@@ -336,7 +340,7 @@ module Runlight
336
340
  next nil if invite.nil?
337
341
 
338
342
  sender = by_email(invite["invitedBy"])
339
- if sender.nil? || !self.class.may_invite?(sender["role"])
343
+ if sender.nil? || !sender["site"].nil? || !self.class.may_invite?(sender["role"])
340
344
  # Left without raising, since an error inside a turn on SQLite would roll this back.
341
345
  @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
342
346
  next nil
@@ -345,7 +349,7 @@ module Runlight
345
349
  raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
346
350
  end
347
351
 
348
- made = set_password(invite["email"], password, now, invite["role"])
352
+ made = set_password(invite["email"], password, now, invite["role"], invite["site"])
349
353
  @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
350
354
  made
351
355
  end
@@ -450,6 +454,10 @@ module Runlight
450
454
  "CREATE TABLE IF NOT EXISTS rl_invites (id #{str.call(100)} PRIMARY KEY, email #{str.call(320)} NOT NULL UNIQUE, role #{str.call(20)} NOT NULL, " \
451
455
  "code_hash #{str.call(128)} NOT NULL UNIQUE, invited_by #{str.call(100)} NOT NULL, created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL)#{table}",
452
456
  )
457
+ %w[rl_users rl_invites].each do |name|
458
+ fields = db.dialect == "sqlite" ? db.all("PRAGMA table_info(#{name})") : db.all("SELECT column_name AS name FROM information_schema.columns WHERE table_name = '#{name}' AND table_schema = #{my ? 'DATABASE()' : 'current_schema()'}")
459
+ db.run("ALTER TABLE #{name} ADD COLUMN site TEXT") unless fields.any? { |c| c["name"] == "site" }
460
+ end
453
461
  # A server has one owner. One from before, with several, keeps the first and the rest become admins,
454
462
  # who can still do everything but remove the owner. Invites to join as an owner become invites as an admin.
455
463
  owners = db.all("SELECT id FROM rl_users WHERE role = 'owner' ORDER BY created_at, id")
@@ -466,6 +474,7 @@ module Runlight
466
474
  "email" => r["email"].to_s,
467
475
  "hash" => r["hash"].to_s,
468
476
  "role" => self.class.role_from(r["role"]),
477
+ **(r["site"].nil? ? {} : { "site" => r["site"].to_s }),
469
478
  "createdAt" => Js.number(r["created_at"]),
470
479
  "twoFactor" => Js.truthy?(r["totp_secret"]),
471
480
  "recoveryLeft" => recovery.length,
@@ -493,16 +502,17 @@ module Runlight
493
502
  step = !secret.nil? && secret != "" ? Crypto.match_step(secret, digits, now, after) : nil
494
503
  return false if step.nil?
495
504
 
496
- @store.db.run("UPDATE rl_users SET totp_step = ? WHERE id = ?", [step, id])
497
- return true
505
+ # Only if no check since has used this step or a later one, whatever took the turn before this one.
506
+ return @store.db.affected("UPDATE rl_users SET totp_step = ? WHERE id = ? AND (totp_step IS NULL OR totp_step < ?)", [step, id, step]) == 1
498
507
  end
499
- hashes = Js.truthy?(row["totp_recovery"]) ? Json.decode(row["totp_recovery"].to_s) : []
508
+ read = Js.truthy?(row["totp_recovery"]) ? row["totp_recovery"].to_s : nil
509
+ hashes = read.nil? ? [] : Json.decode(read)
500
510
  at = hashes.index(Crypto.recovery_hash(given))
501
511
  return false if at.nil?
502
512
 
503
513
  hashes.delete_at(at)
504
- @store.db.run("UPDATE rl_users SET totp_recovery = ? WHERE id = ?", [Json.encode(hashes), id])
505
- true
514
+ # Only if the codes are still the ones read, so a code crossed off at the same moment elsewhere is not used twice.
515
+ @store.db.affected("UPDATE rl_users SET totp_recovery = ? WHERE id = ? AND totp_recovery = ?", [Json.encode(hashes), id, read]) == 1
506
516
  end
507
517
 
508
518
  def ticket(kind, user, expires)
@@ -532,22 +542,24 @@ module Runlight
532
542
  "id" => r["id"].to_s,
533
543
  "email" => r["email"].to_s,
534
544
  "role" => self.class.role_from(r["role"]),
545
+ **(r["site"].nil? ? {} : { "site" => r["site"].to_s }),
535
546
  "invitedBy" => r["invited_by"].to_s,
536
547
  "createdAt" => Js.number(r["created_at"]),
537
548
  "expiresAt" => Js.number(r["expires_at"]),
538
549
  }
539
550
  end
540
551
 
541
- def invite_now(email, role, invited_by, now)
552
+ def invite_now(email, role, invited_by, now, site = nil)
553
+ raise AccountError.new("Choose member or viewer for one site", "role_scope") if !site.nil? && (site.empty? || !%w[member viewer].include?(role))
542
554
  address = Js.lower(Js.trim(email))
543
555
  raise AccountError.new("Enter an email address", "email_invalid") unless address.match?(EMAIL)
544
556
  raise AccountError.new("#{address} already has an account", "account_exists", { "email" => address }) unless by_email(address).nil?
545
557
 
546
558
  code = Crypto.base64url(Crypto.random_bytes(24))
547
- invite = { "id" => Crypto.hex(Crypto.random_bytes(12)), "email" => address, "role" => role, "invitedBy" => invited_by, "createdAt" => now,
559
+ invite = { **(site.nil? ? {} : { "site" => site }), "id" => Crypto.hex(Crypto.random_bytes(12)), "email" => address, "role" => role, "invitedBy" => invited_by, "createdAt" => now,
548
560
  "expiresAt" => now + INVITE_MS }
549
561
  @store.db.run("DELETE FROM rl_invites WHERE email = ?", [address])
550
- @store.db.run("INSERT INTO rl_invites (id, email, role, code_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
562
+ @store.db.run("INSERT INTO rl_invites (id, email, role, code_hash, invited_by, created_at, expires_at, site) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
551
563
  invite["id"],
552
564
  invite["email"],
553
565
  invite["role"],
@@ -555,6 +567,7 @@ module Runlight
555
567
  invite["invitedBy"],
556
568
  invite["createdAt"],
557
569
  invite["expiresAt"],
570
+ site,
558
571
  ])
559
572
  { "invite" => invite, "code" => code }
560
573
  end
@@ -37,6 +37,14 @@ module Runlight
37
37
  .theme:hover { color: var(--ink); }
38
38
  .theme svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
39
39
  .theme svg:not(.on) { display: none; }
40
+ .strength { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 12px; color: var(--muted); }
41
+ .strength[hidden] { display: none; }
42
+ .strength-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; width: 120px; }
43
+ .strength-bar span { height: 4px; border-radius: 2px; background: var(--line); }
44
+ .strength[data-score="0"] .strength-label, .strength[data-score="1"] .strength-label { color: var(--bad); }
45
+ .strength[data-score="1"] .on { background: var(--bad); }
46
+ .strength[data-score="2"] .on { background: #b7791f; }
47
+ .strength[data-score="3"] .on, .strength[data-score="4"] .on { background: #22a065; }
40
48
  .secret { position: relative; display: block; margin-top: 6px; }
41
49
  .secret input { margin-top: 0; padding-right: 44px; }
42
50
  .eye { position: absolute; top: 50%; right: 5px; display: grid; place-items: center; width: 32px; height: 30px; margin: 0; padding: 0; border: 0; border-radius: 6px; background: none; color: var(--muted); transform: translateY(-50%); cursor: pointer; }
@@ -48,6 +56,37 @@ module Runlight
48
56
  # Applies the saved theme before the page paints, then runs the switch and Cmd+Shift+D (Ctrl+Shift+D).
49
57
  AUTH_JS = <<~'JS'.chomp
50
58
  (() => {
59
+ const COMMON = ["password", "passw0rd", "123456", "qwerty", "azerty", "letmein", "welcome", "admin", "runlight", "iloveyou", "abc123", "monkey", "dragon", "football", "baseball", "sunshine", "princess", "shadow", "master"];
60
+ function strength(password, avoid = []) {
61
+ if (password.length < 10) return 0;
62
+ let pool = 0;
63
+ if (/[a-z]/.test(password)) pool += 26;
64
+ if (/[A-Z]/.test(password)) pool += 26;
65
+ if (/[0-9]/.test(password)) pool += 10;
66
+ if (/[^A-Za-z0-9]/.test(password)) pool += 33;
67
+ // Each character only counts once it adds something new: a pattern repeating ("abab", "Aa1!Aa1!",
68
+ // "aaaa") adds nothing, and a doubled letter or a run like "abc" adds little.
69
+ let effective = 0;
70
+ for (let i = 0; i < password.length; i++) {
71
+ const c = password.charCodeAt(i);
72
+ const prev = password.charCodeAt(i - 1);
73
+ let pattern = false;
74
+ for (let k = 1; k <= 4 && !pattern; k++) pattern = i - 1 - k >= 0 && password[i] === password[i - k] && password[i - 1] === password[i - 1 - k];
75
+ const repeat = i > 0 && c === prev;
76
+ const run = i > 1 && c - prev === prev - password.charCodeAt(i - 2) && Math.abs(c - prev) === 1;
77
+ effective += pattern ? 0 : repeat || run ? 0.25 : 1;
78
+ }
79
+ let bits = effective * Math.log2(Math.max(pool, 10));
80
+ const lower = password.toLowerCase();
81
+ for (const word of [...COMMON, ...avoid.map((a) => a.toLowerCase()).filter((a) => a.length >= 3)]) {
82
+ // Every time it appears, so "jonjonjon" costs three times.
83
+ bits -= (lower.split(word).length - 1) * word.length * 3;
84
+ }
85
+ if (bits < 40) return 1;
86
+ if (bits < 60) return 2;
87
+ if (bits < 80) return 3;
88
+ return 4;
89
+ }
51
90
  const KEY = "runlight_theme";
52
91
  const root = document.documentElement;
53
92
  const read = () => { try { const v = localStorage.getItem(KEY); return v === "dark" || v === "system" ? v : "light"; } catch { return "light"; } };
@@ -85,6 +124,30 @@ module Runlight
85
124
  eye.addEventListener("click", () => set(input.type === "password"));
86
125
  wrap.append(eye);
87
126
  }
127
+ const password = document.querySelector('input[name="password"][autocomplete="new-password"]');
128
+ if (password) {
129
+ const meter = document.createElement("span");
130
+ meter.className = "strength";
131
+ meter.id = "password-strength";
132
+ meter.setAttribute("aria-live", "polite");
133
+ meter.innerHTML = '<span class="strength-bar" aria-hidden="true"><span></span><span></span><span></span><span></span></span><span class="strength-label"></span>';
134
+ password.closest(".secret").after(meter);
135
+ password.setAttribute("aria-describedby", meter.id);
136
+ const email = document.querySelector('input[name="email"]');
137
+ const update = () => {
138
+ const address = email?.value || "";
139
+ const score = strength(password.value, [address, address.split("@")[0]]);
140
+ meter.hidden = !password.value;
141
+ meter.dataset.score = String(score);
142
+ meter.querySelector(".strength-label").textContent = ["Too short", "Weak", "Fair", "Good", "Strong"][score];
143
+ meter.querySelectorAll(".strength-bar span").forEach((bar, i) => bar.classList.toggle("on", score > i));
144
+ };
145
+ password.addEventListener("input", update);
146
+ password.addEventListener("change", update);
147
+ email?.addEventListener("input", update);
148
+ window.addEventListener("pageshow", update);
149
+ update();
150
+ }
88
151
  show();
89
152
  document.querySelector(".theme")?.addEventListener("click", () => set({ light: "dark", dark: "system", system: "light" }[read()]));
90
153
  });
@@ -178,7 +241,8 @@ module Runlight
178
241
  end
179
242
 
180
243
  # What someone invited with a role can do, after "as".
181
- def role_text(role)
244
+ def role_text(role, site = nil)
245
+ return role == "viewer" ? "a viewer, who can read the stats for #{site} only" : "a member, who can change the settings of #{site} only" unless site.nil?
182
246
  return "a viewer, who can read every site's stats" if role == "viewer"
183
247
  return "a member, who can change the settings of every site" if role == "member"
184
248
 
@@ -190,7 +254,7 @@ module Runlight
190
254
  b = esc(base)
191
255
  page(base, "Join Runlight", <<~HTML.chomp)
192
256
  <h1>Join Runlight</h1>
193
- <p>You were invited to #{esc(opts["host"])} as #{role_text(opts["role"])}. Choose a password to finish.</p>
257
+ <p>You were invited to #{esc(opts["host"])} as #{esc(role_text(opts["role"], opts["site"]))}. Choose a password to finish.</p>
194
258
  #{error(opts["error"])}
195
259
  <form method="post" action="#{b}/invite">
196
260
  <input type="hidden" name="code" value="#{esc(opts["code"])}">
@@ -221,7 +285,7 @@ module Runlight
221
285
  end
222
286
  page(base, "Create your account", <<~HTML.chomp)
223
287
  <h1>Create your account</h1>
224
- <p>This account owns the dashboard. You can invite more people later in Settings, People.</p>
288
+ <p>This account owns the dashboard. You can invite more people later in Settings, Team Members.</p>
225
289
  #{error(opts["error"])}
226
290
  <form method="post" action="#{b}/setup">
227
291
  #{field}
@@ -120,7 +120,9 @@ module Runlight
120
120
  # Notes who made a token. A viewer makes no tokens; someone removed or made a viewer since allowing an app
121
121
  # gets none for it, and false takes the token back.
122
122
  def token_made(token, by)
123
- role = @accounts.by_id(by)&.fetch("role", nil)
123
+ user = @accounts.by_id(by)
124
+ return false if user.nil? || !user["site"].nil?
125
+ role = user["role"]
124
126
  return false if role.nil? || role == "viewer"
125
127
 
126
128
  @store.set_setting("#{MADE_BY}#{token["id"]}", by)
@@ -148,6 +150,14 @@ module Runlight
148
150
  nil
149
151
  end
150
152
 
153
+ def confirm_password(request)
154
+ user = signed_in(request)
155
+ return coded("Sign in first", "sign_in", 401) if user.nil?
156
+ input = body(request)
157
+ return coded("Send JSON", "send_json", 415) if input.nil?
158
+ recheck(user, input, "password", ["Your password is not right", "password_wrong"])
159
+ end
160
+
151
161
  private
152
162
 
153
163
  def now
@@ -202,11 +212,11 @@ module Runlight
202
212
  end
203
213
 
204
214
  def person(u)
205
- { "id" => u["id"], "email" => u["email"], "role" => u["role"], "createdAt" => u["createdAt"], "twoFactor" => u["twoFactor"], "recoveryLeft" => u["recoveryLeft"] }
215
+ { **(u["site"].nil? ? {} : { "site" => u["site"] }), "id" => u["id"], "email" => u["email"], "role" => u["role"], "createdAt" => u["createdAt"], "twoFactor" => u["twoFactor"], "recoveryLeft" => u["recoveryLeft"] }
206
216
  end
207
217
 
208
218
  def invite_view(i)
209
- { "id" => i["id"], "email" => i["email"], "role" => i["role"], "invitedBy" => i["invitedBy"], "createdAt" => i["createdAt"], "expiresAt" => i["expiresAt"] }
219
+ { **(i["site"].nil? ? {} : { "site" => i["site"] }), "id" => i["id"], "email" => i["email"], "role" => i["role"], "invitedBy" => i["invitedBy"], "createdAt" => i["createdAt"], "expiresAt" => i["expiresAt"] }
210
220
  end
211
221
 
212
222
  # The media type of a request's body, as a cross-site form cannot send application/json.
@@ -270,7 +280,7 @@ module Runlight
270
280
  origin = home_origin || Http::Url.new(request.url).origin
271
281
  link = "#{origin}#{@base}/invite?code=#{code}"
272
282
  host = Http::Url.new(origin).host
273
- what = Pages.role_text(invite["role"])
283
+ what = Pages.role_text(invite["role"], invite["site"])
274
284
  return { "link" => link, "emailed" => false } unless @rl.mail_settings
275
285
 
276
286
  begin
@@ -278,8 +288,8 @@ module Runlight
278
288
  "to" => invite["email"],
279
289
  "subject" => "#{invite["invitedBy"]} invited you to Runlight",
280
290
  "text" => "#{invite["invitedBy"]} invited you to the Runlight at #{host} as #{what}.\n\nChoose a password to join:\n#{link}\n\nThe link works for seven days.\n",
281
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><p>" \
282
- "#{esc(invite["invitedBy"])} invited you to the Runlight at #{esc(host)} as #{what}.</p><p><a href=\"#{esc(link)}\" " \
291
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\"><p>" \
292
+ "#{esc(invite["invitedBy"])} invited you to the Runlight at #{esc(host)} as #{esc(what)}.</p><p><a href=\"#{esc(link)}\" " \
283
293
  "style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600\">" \
284
294
  "Choose a password and join</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for seven days. If you were not expecting this, " \
285
295
  "you can ignore it.</p></div>",
@@ -316,7 +326,7 @@ module Runlight
316
326
  "subject" => "Sign in to Runlight",
317
327
  "text" => "Someone, most likely you, signed in to Runlight at #{host} with your password while your account was held up by too many failed tries.\n\n" \
318
328
  "Sign in with this link within fifteen minutes:\n#{link}\n\nIf this was not you, change your password, since someone knows it.\n",
319
- "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\">" \
329
+ "html" => "<div style=\"font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#111827;max-width:480px\"><img src=\"https://runlight.sh/icon-192.png\" width=\"48\" height=\"48\" alt=\"Runlight\" style=\"display:block;width:48px;height:48px;margin-bottom:24px;border:0;border-radius:10px\">" \
320
330
  "<p>Someone, most likely you, signed in to Runlight at #{esc(host)} with your password while your account was held up by too many failed tries.</p>" \
321
331
  "<p><a href=\"#{esc(link)}\" style=\"display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;" \
322
332
  "text-decoration:none;font-weight:600\">Sign in</a></p><p style=\"color:#6b7280;font-size:13px\">The link works for fifteen minutes. " \
@@ -424,7 +434,7 @@ module Runlight
424
434
  invite = @accounts.invite_by_code(code, now)
425
435
  return html(Pages.invite_gone_page(base), 410) if invite.nil?
426
436
 
427
- return html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "host" => url.host }))
437
+ return html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host }))
428
438
  end
429
439
  if method == "POST"
430
440
  form = Http::SearchParams.new(request.text)
@@ -433,7 +443,7 @@ module Runlight
433
443
  return html(Pages.invite_gone_page(base), 410) if invite.nil?
434
444
 
435
445
  again = lambda do |error|
436
- html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "host" => url.host, "error" => error }), 400)
446
+ html(Pages.invite_page(base, { "code" => code, "email" => invite["email"], "role" => invite["role"], "site" => invite["site"], "host" => url.host, "error" => error }), 400)
437
447
  end
438
448
  return again.call("The two passwords are not the same.") if (form.get("password") || "") != (form.get("again") || "")
439
449
 
@@ -510,6 +520,21 @@ module Runlight
510
520
  html(login_page.call({ "error" => e.message, "email" => email }), 503, { "retry-after" => "5" })
511
521
  end
512
522
 
523
+ def recheck(user, input, name, wrong)
524
+ return coded("Too many tries. Wait fifteen minutes and try again.", "too_many_tries", 429) unless @rechecks.take(user["id"], now)
525
+ begin
526
+ right = @accounts.sign_in(user["email"], field(input, name))
527
+ rescue AccountError => e
528
+ raise unless e.code == "busy"
529
+
530
+ return coded("Runlight is busy checking other sign-ins. Try again in a moment.", "busy", 503)
531
+ end
532
+ return coded(wrong[0], wrong[1], 400) if right.nil?
533
+
534
+ @rechecks.forgive(user["id"], now)
535
+ nil
536
+ end
537
+
513
538
  # Your own account, and for the owner and admins, everyone else's.
514
539
  def api(request, path)
515
540
  user = signed_in(request)
@@ -522,26 +547,12 @@ module Runlight
522
547
  return coded("Send JSON", "send_json", 415) if method == "POST" && media_type(request) != "application/json"
523
548
  return reply({ "account" => person(user) }) if path == "/api/account" && method == "GET"
524
549
 
525
- recheck = lambda do |input, name, wrong|
526
- return coded("Too many tries. Wait fifteen minutes and try again.", "too_many_tries", 429) unless @rechecks.take(user["id"], now)
527
- begin
528
- right = @accounts.sign_in(user["email"], field(input, name))
529
- rescue AccountError => e
530
- raise unless e.code == "busy"
531
-
532
- return coded("Runlight is busy checking other sign-ins. Try again in a moment.", "busy", 503)
533
- end
534
- return coded(wrong[0], wrong[1], 400) if right.nil?
535
-
536
- @rechecks.forgive(user["id"], now)
537
- nil
538
- end
539
550
  fresh = ->(updated) { { "set-cookie" => session_cookie(request, @accounts.session_for(updated, now), Accounts::SESSION_MS / 1000) } }
540
551
  if path == "/api/account/password" && method == "POST"
541
552
  input = body(request)
542
553
  return coded("Send JSON", "send_json", 415) if input.nil?
543
554
 
544
- refused = recheck.call(input, "current", ["Your current password is not right", "password_current_wrong"])
555
+ refused = recheck(user, input, "current", ["Your current password is not right", "password_current_wrong"])
545
556
  return refused unless refused.nil?
546
557
 
547
558
  begin
@@ -573,7 +584,7 @@ module Runlight
573
584
  updated = @accounts.by_id(user["id"])
574
585
  return reply({ "recovery" => codes }, 200, fresh.call(updated))
575
586
  end
576
- refused = recheck.call(input, "password", ["Your password is not right", "password_wrong"])
587
+ refused = recheck(user, input, "password", ["Your password is not right", "password_wrong"])
577
588
  return refused unless refused.nil?
578
589
 
579
590
  if action == "/start"
@@ -594,7 +605,7 @@ module Runlight
594
605
  end
595
606
  return coded("Not found", "not_found", 404)
596
607
  end
597
- return coded("Only the owner or an admin can manage people", "people_owner", 403) if user["role"] != "owner" && user["role"] != "admin"
608
+ return coded("Only the owner or an admin can manage people", "people_owner", 403) if !user["site"].nil? || (user["role"] != "owner" && user["role"] != "admin")
598
609
 
599
610
  # The owner or an admin can turn off someone else's two-factor, for a coworker who lost both phone and recovery
600
611
  # codes, though never the owner's. It asks for their password like every other two-factor change, and their own
@@ -605,7 +616,7 @@ module Runlight
605
616
  input = body(request)
606
617
  return coded("Send JSON", "send_json", 415) if input.nil?
607
618
 
608
- refused = recheck.call(input, "password", ["Your password is not right", "password_wrong"])
619
+ refused = recheck(user, input, "password", ["Your password is not right", "password_wrong"])
609
620
  return refused unless refused.nil?
610
621
 
611
622
  target = @accounts.by_id(reset[1])
@@ -622,7 +633,7 @@ module Runlight
622
633
  input = body(request)
623
634
  return coded("Send JSON", "send_json", 415) if input.nil?
624
635
 
625
- refused = recheck.call(input, "password", ["Your password is not right", "password_wrong"])
636
+ refused = recheck(user, input, "password", ["Your password is not right", "password_wrong"])
626
637
  return refused unless refused.nil?
627
638
 
628
639
  begin
@@ -648,7 +659,11 @@ module Runlight
648
659
  return coded("#{email} already has an account", "account_exists", 409, { "email" => email }) unless @accounts.by_email(email).nil?
649
660
 
650
661
  begin
651
- made = @accounts.invite(email, role, user["email"], now)
662
+ @rl.init
663
+ site = input["allSites"] == true ? nil : input["site"]
664
+ return coded("Choose a site or explicitly invite to all sites", "invite_site", 400) if input["allSites"] != true && (!site.is_a?(String) || site.empty?)
665
+ return coded("Unknown site", "unknown_site", 404) if !site.nil? && @rl.site(site).nil?
666
+ made = @accounts.invite(email, role, user["email"], now, site)
652
667
  return reply({ "invite" => invite_view(made["invite"]) }.merge(send_invite(request, made["invite"], made["code"])), 201)
653
668
  rescue AccountError => e
654
669
  return coded(e.message, e.code, 400, e.params)
@@ -664,7 +679,7 @@ module Runlight
664
679
  return coded("Unknown invite", "unknown_invite", 404) if old.nil?
665
680
 
666
681
  # A new link replaces the old one, which stops working.
667
- made = @accounts.invite(old["email"], old["role"], user["email"], now)
682
+ made = @accounts.invite(old["email"], old["role"], user["email"], now, old["site"])
668
683
  return reply({ "invite" => invite_view(made["invite"]) }.merge(send_invite(request, made["invite"], made["code"])))
669
684
  end
670
685
  end