runlight 0.1.0 → 0.1.3

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/assets/build.json +4 -4
  3. data/assets/dashboard.css +1 -1
  4. data/assets/dashboard.js +71 -22
  5. data/assets/locales.json +5 -5
  6. data/assets/pick-return.js +1 -0
  7. data/assets/picker.js +1 -1
  8. data/assets/tracker.js +1 -1
  9. data/lib/runlight/accounts/accounts.rb +90 -19
  10. data/lib/runlight/accounts/throttle.rb +123 -38
  11. data/lib/runlight/accounts/web.rb +50 -14
  12. data/lib/runlight/assistant.rb +40 -8
  13. data/lib/runlight/connect.rb +2 -3
  14. data/lib/runlight/core.rb +148 -34
  15. data/lib/runlight/dates.rb +31 -8
  16. data/lib/runlight/db/connect.rb +32 -6
  17. data/lib/runlight/db/db.rb +129 -9
  18. data/lib/runlight/db/prefixed.rb +92 -0
  19. data/lib/runlight/geo.rb +4 -3
  20. data/lib/runlight/http/fetcher.rb +31 -6
  21. data/lib/runlight/http/request.rb +14 -3
  22. data/lib/runlight/importers/csv_visits.rb +1 -1
  23. data/lib/runlight/importers/index.rb +10 -2
  24. data/lib/runlight/importers/visits.rb +17 -6
  25. data/lib/runlight/importers/write.rb +21 -6
  26. data/lib/runlight/links.rb +14 -9
  27. data/lib/runlight/mail/ses.rb +3 -2
  28. data/lib/runlight/mail/smtp.rb +2 -0
  29. data/lib/runlight/mail/smtp_session.rb +22 -1
  30. data/lib/runlight/mail/transports.rb +19 -2
  31. data/lib/runlight/mcp.rb +8 -1
  32. data/lib/runlight/metadata_address_error.rb +13 -0
  33. data/lib/runlight/oauth.rb +85 -18
  34. data/lib/runlight/rack_app.rb +64 -10
  35. data/lib/runlight/redirect_refused_error.rb +14 -0
  36. data/lib/runlight/routes.rb +306 -144
  37. data/lib/runlight/safefetch.rb +133 -1
  38. data/lib/runlight/server/agents.rb +1 -1
  39. data/lib/runlight/server/cli.rb +1 -1
  40. data/lib/runlight/server/config.rb +64 -19
  41. data/lib/runlight/server/standalone.rb +7 -6
  42. data/lib/runlight/store/sql.rb +10 -0
  43. data/lib/runlight/store/sql_store.rb +75 -13
  44. data/lib/runlight/stores.rb +19 -15
  45. data/lib/runlight/tables_busy_error.rb +11 -0
  46. data/lib/runlight/version.rb +1 -1
  47. data/lib/runlight.rb +4 -0
  48. metadata +7 -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 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)&&/[?&]runlight_ticket=/.test(location.search)){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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Runlight
4
6
  module Accounts
5
7
  # Accounts: who may sign in, their password hashes, two-factor, invites, and the signed cookie that keeps them
@@ -32,14 +34,50 @@ module Runlight
32
34
  # A password checked against nothing, so a wrong email takes as long as a wrong password. Made when first needed.
33
35
  @decoy = nil
34
36
 
37
+ # Password checks run at once, in this process, and the most that may wait for one.
38
+ CHECKING = 4
39
+ WAITING = 64
40
+ SLOTS = Mutex.new
41
+ FREED = ConditionVariable.new
42
+ private_constant :SLOTS, :FREED
43
+ @running = 0
44
+ @waiting = 0
45
+
35
46
  class << self
36
47
  attr_accessor :decoy
48
+
49
+ # Runs a password check when one of the few slots is free, so a flood of sign-ins cannot take every core.
50
+ # Past the queue's length it refuses at once, with an AccountError coded "busy".
51
+ def slot
52
+ SLOTS.synchronize do
53
+ if @running >= CHECKING
54
+ raise AccountError.new("Runlight is busy checking other sign-ins. Try again in a moment.", "busy") if @waiting >= WAITING
55
+
56
+ @waiting += 1
57
+ begin
58
+ FREED.wait(SLOTS) while @running >= CHECKING
59
+ ensure
60
+ @waiting -= 1
61
+ end
62
+ end
63
+ @running += 1
64
+ end
65
+ begin
66
+ yield
67
+ ensure
68
+ SLOTS.synchronize do
69
+ @running -= 1
70
+ FREED.signal
71
+ end
72
+ end
73
+ end
37
74
  end
38
75
 
39
76
  def initialize(store, secret)
40
77
  @store = store
41
78
  @secret = secret
42
79
  @ready = false
80
+ @init_lock = Monitor.new
43
81
  end
44
82
 
45
83
  # A stored role read back; anything unknown reads as a viewer, the least it could be.
@@ -47,6 +85,11 @@ module Runlight
47
85
  ROLES.include?(value) ? value : "viewer"
48
86
  end
49
87
 
88
+ # Whether a role may invite people. The owner and admins invite as admin, member, or viewer.
89
+ def self.may_invite?(role)
90
+ role == "owner" || role == "admin"
91
+ end
92
+
50
93
  # Starts turning on two-factor: a new secret, kept aside until a code from it is confirmed.
51
94
  def start_two_factor(id)
52
95
  init
@@ -95,7 +138,8 @@ module Runlight
95
138
  # Checks a six-digit code, or a recovery code, for an account with two-factor on.
96
139
  # A code works once: one already used, or older, is refused, and a recovery code is crossed off.
97
140
  # One check at a time, so two sign-ins at once cannot both use the same code: TypeScript queues them per
98
- # 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.
99
143
  def check_second_factor(id, code, now)
100
144
  init
101
145
  turn { check_second_factor_now(id, code, now) }
@@ -181,6 +225,8 @@ module Runlight
181
225
  raise AccountError.new("Ownership is handed over by the owner", "owner_hand_over") if role == "owner"
182
226
 
183
227
  @store.db.run("UPDATE rl_users SET role = ? WHERE id = ?", [role, id])
228
+ # Someone who may no longer invite takes back the invites they sent, so nobody joins on their word.
229
+ @store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]]) unless self.class.may_invite?(role)
184
230
  user["role"] = role
185
231
  user
186
232
  end
@@ -212,6 +258,7 @@ module Runlight
212
258
  raise AccountError.new("The owner cannot be removed", "owner_protected") if user["role"] == "owner"
213
259
 
214
260
  @store.db.run("DELETE FROM rl_users WHERE id = ?", [id])
261
+ @store.db.run("DELETE FROM rl_invites WHERE invited_by = ?", [user["email"]])
215
262
  @store.set_setting("login-link-used:#{id}", nil)
216
263
  end
217
264
  nil
@@ -280,27 +327,44 @@ module Runlight
280
327
  before.positive?
281
328
  end
282
329
 
283
- # Turns an invite into an account with the password its person chose. The link then stops working.
330
+ # Turns an invite into an account with the password its person chose. The link then stops working. It takes a
331
+ # turn, so it cannot cross a removal of whoever sent it, and the sender must still be someone who may invite.
284
332
  def accept_invite(code, password, now)
285
- invite = invite_by_code(code, now)
286
- raise AccountError.new("This invite has expired or was already used. Ask for a new one.", "invite_gone") if invite.nil?
287
- unless by_email(invite["email"]).nil?
288
- raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
333
+ # The tables first: making them takes the same lock as a turn.
334
+ init
335
+ user = turn do
336
+ invite = invite_by_code(code, now)
337
+ next nil if invite.nil?
338
+
339
+ sender = by_email(invite["invitedBy"])
340
+ if sender.nil? || !self.class.may_invite?(sender["role"])
341
+ # Left without raising, since an error inside a turn on SQLite would roll this back.
342
+ @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
343
+ next nil
344
+ end
345
+ unless by_email(invite["email"]).nil?
346
+ raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
347
+ end
348
+
349
+ made = set_password(invite["email"], password, now, invite["role"])
350
+ @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
351
+ made
289
352
  end
353
+ raise AccountError.new("This invite has expired or was already used. Ask for a new one.", "invite_gone") if user.nil?
290
354
 
291
- user = set_password(invite["email"], password, now, invite["role"])
292
- @store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
293
355
  user
294
356
  end
295
357
 
296
- # The account for an email and password, or nil. Takes the same time either way.
358
+ # The account for an email and password, or nil. Takes the same time either way. Only a few checks run at
359
+ # once; with too many waiting it raises an AccountError coded "busy".
297
360
  def sign_in(email, password)
298
361
  user = by_email(email)
299
362
  if user.nil?
300
- Crypto.check_password(password, self.class.decoy ||= Crypto.hash_password(Crypto.hex(Crypto.random_bytes(16))))
363
+ hash = self.class.decoy ||= Crypto.hash_password(Crypto.hex(Crypto.random_bytes(16)))
364
+ self.class.slot { Crypto.check_password(password, hash) }
301
365
  return nil
302
366
  end
303
- Crypto.check_password(password, user["hash"]) ? user : nil
367
+ self.class.slot { Crypto.check_password(password, user["hash"]) } ? user : nil
304
368
  end
305
369
 
306
370
  # A cookie value naming the user and when it expires, signed with the
@@ -348,15 +412,21 @@ module Runlight
348
412
 
349
413
  private
350
414
 
351
- # Changes to who has an account take turns: on Postgres and MySQL across processes, through the database's
352
- # lock, so two owners demoting each other at once cannot leave none, and a double-clicked invite makes one.
415
+ # Changes to who has an account take turns across threads and processes (the database's lock, or on SQLite a
416
+ # transaction that takes the write lock first), so two owners demoting each other at once cannot leave none,
417
+ # a double-clicked invite makes one, and a code or a sign-in link works once.
353
418
  def turn(&fn)
354
- @store.db.exclusive { fn.call }
419
+ db = @store.db
420
+ db.respond_to?(:turn) ? db.turn { fn.call } : db.exclusive { fn.call }
355
421
  end
356
422
 
357
423
  def init
358
424
  return if @ready
359
425
 
426
+ @init_lock.synchronize { init_now unless @ready }
427
+ end
428
+
429
+ def init_now
360
430
  # Several processes starting at once create the tables one at a time.
361
431
  @store.db.exclusive do
362
432
  db = @store.db
@@ -424,16 +494,17 @@ module Runlight
424
494
  step = !secret.nil? && secret != "" ? Crypto.match_step(secret, digits, now, after) : nil
425
495
  return false if step.nil?
426
496
 
427
- @store.db.run("UPDATE rl_users SET totp_step = ? WHERE id = ?", [step, id])
428
- return true
497
+ # Only if no check since has used this step or a later one, whatever took the turn before this one.
498
+ return @store.db.affected("UPDATE rl_users SET totp_step = ? WHERE id = ? AND (totp_step IS NULL OR totp_step < ?)", [step, id, step]) == 1
429
499
  end
430
- hashes = Js.truthy?(row["totp_recovery"]) ? Json.decode(row["totp_recovery"].to_s) : []
500
+ read = Js.truthy?(row["totp_recovery"]) ? row["totp_recovery"].to_s : nil
501
+ hashes = read.nil? ? [] : Json.decode(read)
431
502
  at = hashes.index(Crypto.recovery_hash(given))
432
503
  return false if at.nil?
433
504
 
434
505
  hashes.delete_at(at)
435
- @store.db.run("UPDATE rl_users SET totp_recovery = ? WHERE id = ?", [Json.encode(hashes), id])
436
- true
506
+ # Only if the codes are still the ones read, so a code crossed off at the same moment elsewhere is not used twice.
507
+ @store.db.affected("UPDATE rl_users SET totp_recovery = ? WHERE id = ? AND totp_recovery = ?", [Json.encode(hashes), id, read]) == 1
437
508
  end
438
509
 
439
510
  def ticket(kind, user, expires)
@@ -3,15 +3,24 @@
3
3
  module Runlight
4
4
  module Accounts
5
5
  # Counts failed sign-ins under a key and refuses more than a few in a while.
6
- # Keys are hashed with a key made on first use, so the counts never hold an
7
- # address or an email as it was given.
6
+ # Keys are hashed with a key made for each UTC day, so the counts never hold
7
+ # an address or an email as it was given.
8
8
  #
9
9
  # TypeScript keeps the counts in its process. Here they live in the database's
10
10
  # settings, as "throttle:<name>:<id>" holding {count, until}, and the hashing
11
- # key as "throttle-key", so every process of an install shares the same counts.
11
+ # key as "throttle-key:<UTC day>", so every process of an install shares the
12
+ # same counts. Some keys are addresses, and nothing derived from one lasts
13
+ # longer than a day: an expired count is deleted when it is read and by the
14
+ # scheduled check (sweep), and a day's hashing key goes once the day is over
15
+ # (yesterday's is kept only long enough to carry a count across midnight).
12
16
  class Throttle
13
- KEY = "throttle-key"
14
- private_constant :KEY
17
+ KEY = "throttle-key:"
18
+ # The one hashing key from before keys were made per day.
19
+ LEGACY_KEY = "throttle-key"
20
+ DAY_MS = 86_400_000
21
+ # How long after midnight yesterday's key carries a count over: the longest window a throttle counts in.
22
+ CARRY_MS = 15 * 60_000
23
+ private_constant :KEY, :LEGACY_KEY, :DAY_MS, :CARRY_MS
15
24
 
16
25
  def initialize(store, name, limit = 10, window_ms = 15 * 60_000)
17
26
  @store = store
@@ -20,8 +29,35 @@ module Runlight
20
29
  @window_ms = window_ms
21
30
  end
22
31
 
32
+ # Deletes every count that has run out and the hashing keys of days gone, for the scheduled check.
33
+ def self.sweep(store, now)
34
+ store.settings_starting_with("throttle:").each do |row|
35
+ entry = Json.try_decode(row["value"])
36
+ until_ms = entry.is_a?(Hash) ? Js.number(entry["until"].nil? ? 0 : entry["until"]) : 0
37
+ store.set_setting(row["key"], nil) unless until_ms.is_a?(Numeric) && until_ms.finite? && until_ms > now
38
+ end
39
+ drop_old_keys(store, now)
40
+ end
41
+
42
+ # The UTC day a time falls on, as YYYY-MM-DD.
43
+ def self.day(now)
44
+ Time.at(now.to_i / 1000).utc.strftime("%Y-%m-%d")
45
+ end
46
+
47
+ # Keeps today's hashing key, and yesterday's only just after midnight; the one from before days goes too.
48
+ def self.drop_old_keys(store, now)
49
+ keep = [day(now)]
50
+ keep << day(now - DAY_MS) if now.to_i % DAY_MS < CARRY_MS
51
+ store.set_setting(LEGACY_KEY, nil) unless store.setting(LEGACY_KEY).nil?
52
+ store.settings_starting_with(KEY).each do |row|
53
+ store.set_setting(row["key"], nil) unless keep.include?(row["key"][KEY.length..])
54
+ end
55
+ end
56
+
23
57
  def blocked(key, now)
24
- blocked_id?(id(key), now)
58
+ today, yesterday = ids(key, now)
59
+ entry = entry(today, now) || (yesterday && entry(yesterday, now))
60
+ blocked_entry?(entry)
25
61
  end
26
62
 
27
63
  # Counts a try before the slow check it guards, so a burst that arrives
@@ -29,43 +65,75 @@ module Runlight
29
65
  # False, counting nothing, when the key is already at its limit. A try
30
66
  # that turns out right is taken back with forgive().
31
67
  def take(key, now)
32
- id = id(key)
33
- return false if blocked_id?(id, now)
34
-
35
- count(id, now)
36
- true
68
+ ids = ids(key, now)
69
+ # Checked and counted in one turn, so tries at once from other threads and processes wait for it.
70
+ turn do
71
+ entry = carry(ids, now)
72
+ next false if blocked_entry?(entry)
73
+
74
+ count(ids[0], entry, now)
75
+ true
76
+ end
37
77
  end
38
78
 
39
79
  # Takes back one counted try, for one that turned out right.
40
- def forgive(key)
41
- id = id(key)
42
- entry = entry(id)
43
- return unless entry && entry["count"].positive?
44
-
45
- entry["count"] -= 1
46
- save(id, entry)
80
+ def forgive(key, now)
81
+ ids = ids(key, now)
82
+ turn do
83
+ entry = carry(ids, now)
84
+ next unless entry && entry["count"].positive?
85
+
86
+ entry["count"] -= 1
87
+ save(ids[0], entry)
88
+ end
89
+ nil
47
90
  end
48
91
 
49
92
  def record_failure(key, now)
50
- count(id(key), now)
93
+ ids = ids(key, now)
94
+ turn { count(ids[0], carry(ids, now), now) }
95
+ nil
51
96
  end
52
97
 
53
- def clear(key)
54
- @store.set_setting("#{prefix}#{id(key)}", nil)
98
+ def clear(key, now)
99
+ ids(key, now).compact.each { |id| @store.set_setting("#{prefix}#{id}", nil) }
100
+ nil
55
101
  end
56
102
 
57
103
  private
58
104
 
59
- def salt
60
- saved = @store.setting(KEY)
105
+ # The day's hashing key, made the first time it is needed (unless make is false, for yesterday's).
106
+ def salt(now, make = true)
107
+ name = "#{KEY}#{self.class.day(now)}"
108
+ saved = @store.setting(name)
61
109
  return saved if saved && saved != ""
110
+ return nil unless make
62
111
 
63
- made = Hashing.random_id(16)
64
- @store.set_setting(KEY, made)
65
- made
112
+ # Made in a turn, so two processes starting at once do not each keep a different one.
113
+ turn do
114
+ saved = @store.setting(name)
115
+ next saved if saved && saved != ""
116
+
117
+ made = Hashing.random_id(16)
118
+ @store.set_setting(name, made)
119
+ self.class.drop_old_keys(@store, now)
120
+ made
121
+ end
122
+ end
123
+
124
+ # One change to the counts at a time, across threads and processes, as the accounts take turns.
125
+ def turn(&block)
126
+ db = @store.db
127
+ db.respond_to?(:turn) ? db.turn { block.call } : db.exclusive { block.call }
66
128
  end
67
129
 
68
- def id(key)
130
+ # The key's id under today's hashing key, and under yesterday's while that is still kept (else nil).
131
+ def ids(key, now)
132
+ yesterday = salt(now - DAY_MS, false)
133
+ [keyed(salt(now), key), yesterday.nil? ? nil : keyed(yesterday, key)]
134
+ end
135
+
136
+ def keyed(salt, key)
69
137
  Crypto.base64url(Crypto.hmac("SHA-256", salt, key))[0, 22]
70
138
  end
71
139
 
@@ -73,10 +141,31 @@ module Runlight
73
141
  "throttle:#{@name}:"
74
142
  end
75
143
 
76
- def entry(id)
144
+ # A count that has not run out, or nil. One that has is deleted as it is read.
145
+ def entry(id, now)
77
146
  saved = @store.setting("#{prefix}#{id}")
78
- entry = saved.nil? ? nil : Json.try_decode(saved)
79
- entry.is_a?(Hash) ? { "count" => int(entry["count"]), "until" => int(entry["until"]) } : nil
147
+ return nil if saved.nil?
148
+
149
+ entry = Json.try_decode(saved)
150
+ entry = entry.is_a?(Hash) ? { "count" => int(entry["count"]), "until" => int(entry["until"]) } : nil
151
+ return entry if entry && entry["until"] > now
152
+
153
+ @store.set_setting("#{prefix}#{id}", nil)
154
+ nil
155
+ end
156
+
157
+ # Today's count, or yesterday's moved under today's id, so a window that crosses midnight goes on.
158
+ def carry(ids, now)
159
+ today, yesterday = ids
160
+ found = entry(today, now)
161
+ return found if found || yesterday.nil?
162
+
163
+ found = entry(yesterday, now)
164
+ return nil if found.nil?
165
+
166
+ @store.set_setting("#{prefix}#{yesterday}", nil)
167
+ save(today, found)
168
+ found
80
169
  end
81
170
 
82
171
  # A saved number, whole, and 0 for anything that is not a number.
@@ -89,16 +178,12 @@ module Runlight
89
178
  @store.set_setting("#{prefix}#{id}", Json.encode(entry))
90
179
  end
91
180
 
92
- def blocked_id?(id, now)
93
- entry = entry(id)
94
- return false if entry.nil? || entry["until"] <= now
95
-
96
- entry["count"] >= @limit
181
+ def blocked_entry?(entry)
182
+ !entry.nil? && entry["count"] >= @limit
97
183
  end
98
184
 
99
- def count(id, now)
100
- entry = entry(id)
101
- if entry.nil? || entry["until"] <= now
185
+ def count(id, entry, now)
186
+ if entry.nil?
102
187
  save(id, { "count" => 1, "until" => now + @window_ms })
103
188
  prune(now)
104
189
  return