runlight 0.1.1 → 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.
@@ -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])}})();
@@ -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) }
@@ -493,16 +494,17 @@ module Runlight
493
494
  step = !secret.nil? && secret != "" ? Crypto.match_step(secret, digits, now, after) : nil
494
495
  return false if step.nil?
495
496
 
496
- @store.db.run("UPDATE rl_users SET totp_step = ? WHERE id = ?", [step, id])
497
- 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
498
499
  end
499
- 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)
500
502
  at = hashes.index(Crypto.recovery_hash(given))
501
503
  return false if at.nil?
502
504
 
503
505
  hashes.delete_at(at)
504
- @store.db.run("UPDATE rl_users SET totp_recovery = ? WHERE id = ?", [Json.encode(hashes), id])
505
- 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
506
508
  end
507
509
 
508
510
  def ticket(kind, user, expires)
@@ -85,34 +85,52 @@ module Runlight
85
85
  message.is_a?(String) ? message : ""
86
86
  end
87
87
 
88
- # Refuses a service at a link-local or cloud metadata address. One on this computer (Ollama, LM Studio) or a
89
- # private network is allowed, as the person setting it up means it to be.
90
- def refuse_metadata(url)
91
- host = Http::Url.new(url).hostname
92
- return unless Safefetch.metadata_host?(host)
88
+ # A service at a link-local or cloud metadata address, refused there or at a redirect. One on this computer
89
+ # (Ollama, LM Studio) or a private network is allowed, as the person setting it up means it to be.
90
+ def refused(error)
91
+ AssistantError.new(error.message, "address_refused", { "host" => error.host })
92
+ end
93
+
94
+ # A service that sent the request to another origin, which would have carried the key, and the question with it.
95
+ def redirected(error)
96
+ AssistantError.new(error.message, "redirect_refused", { "host" => error.host, "to" => error.to })
97
+ end
98
+
99
+ # The most of a model's answer, or its list of models, read; past it the answer counts as unreadable. One byte
100
+ # more is asked of the fetcher, which hands back what it read, so that an answer past the limit is seen as such.
101
+ MAX_ANSWER_BYTES = 4 * 1024 * 1024
93
102
 
94
- raise AssistantError.new("#{host} is a link-local or cloud metadata address, which Runlight never connects to", "address_refused", { "host" => host })
103
+ # The answer's JSON, or nil when it is not JSON or runs past MAX_ANSWER_BYTES.
104
+ def read_answer(answer)
105
+ parsed, data = Js.parse_json(Body.read_text_capped(answer, MAX_ANSWER_BYTES))
106
+ parsed ? data : nil
107
+ rescue Http::BodyTooLong
108
+ nil
95
109
  end
96
110
 
97
111
  def post(fetcher, url, headers, body, deadline, now, cancelled)
98
112
  in_time(deadline, now, cancelled)
99
- refuse_metadata(url)
100
113
  left = deadline - now.call
101
114
  begin
102
- answer = fetcher.fetch(url, {
115
+ answer = Safefetch.service_fetch(url, {
103
116
  "method" => "POST",
104
117
  "headers" => { "content-type" => "application/json" }.merge(headers),
105
118
  "body" => Json.encode(body),
106
119
  "timeoutMs" => [90_000, left].min,
107
- })
120
+ "maxBytes" => MAX_ANSWER_BYTES + 1,
121
+ "truncate" => true,
122
+ }, fetcher)
123
+ rescue MetadataAddressError => e
124
+ raise refused(e)
125
+ rescue RedirectRefusedError => e
126
+ raise redirected(e)
108
127
  rescue StandardError => e
109
128
  host = Http::Url.new(url).host
110
129
  raise AssistantError.new("Could not reach #{host}: it took too long to answer", "assistant_timeout", { "host" => host }) if e.is_a?(Http::FetchError) && e.timed_out?
111
130
 
112
131
  raise AssistantError.new("Could not reach #{host}: the connection failed", "unreachable", { "host" => host })
113
132
  end
114
- parsed, data = Js.parse_json(answer.text)
115
- data = nil unless parsed
133
+ data = read_answer(answer)
116
134
  unless answer.ok?
117
135
  message = service_message(data)
118
136
  host = Http::Url.new(url).host
@@ -303,15 +321,18 @@ module Runlight
303
321
  elsif key != "" then { "authorization" => "Bearer #{key}" }
304
322
  else {}
305
323
  end
306
- refuse_metadata(base)
307
324
  begin
308
- answer = fetcher.fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000 })
325
+ answer = Safefetch.service_fetch("#{base}/models#{provider["protocol"] == "anthropic" ? "?limit=100" : ""}", { "headers" => headers, "timeoutMs" => 20_000, "maxBytes" => MAX_ANSWER_BYTES + 1, "truncate" => true },
326
+ fetcher)
327
+ rescue MetadataAddressError => e
328
+ raise refused(e)
329
+ rescue RedirectRefusedError => e
330
+ raise redirected(e)
309
331
  rescue StandardError
310
332
  host = Http::Url.new(base).host
311
333
  raise AssistantError.new("Could not reach #{host}", "unreachable", { "host" => host })
312
334
  end
313
- parsed, data = Js.parse_json(answer.text)
314
- data = nil unless parsed
335
+ data = read_answer(answer)
315
336
  unless answer.ok?
316
337
  message = service_message(data)
317
338
  host = Http::Url.new(base).host
@@ -389,6 +410,6 @@ module Runlight
389
410
  end
390
411
 
391
412
  private_class_method :provider, :system_prompt, :in_time, :service_message, :post, :tool_text, :clock, :first_message, :unreadable, :blank?,
392
- :locale_compare, :collation_key
413
+ :locale_compare, :collation_key, :refused
393
414
  end
394
415
  end
data/lib/runlight/core.rb CHANGED
@@ -24,8 +24,16 @@ module Runlight
24
24
  # in front of. Left unset, it warns once when a request comes straight from a public address with none of
25
25
  # those headers.
26
26
  # - linkPath: where short links on the app's own domain live, as `{linkPath}/{slug}`. Default "/go".
27
- # - mail: the mail service for email reports, in code (a Transports config plus `from` and `fromName`).
28
- # When set, the dashboard shows it and cannot change it. Otherwise it is set up in Settings.
27
+ # - mail: how email reports, invites, and sign-in links are sent. Either a mail service in code (a Transports
28
+ # config plus `from` and `fromName`), which the dashboard shows and cannot change, or a mailer of the app's
29
+ # own, such as a CMS's: `{ send: ->(message) { ... }, from:, from_name:, via: }`. The app's mailer is a
30
+ # default: a service saved in the dashboard takes its place, and removing that service goes back to it.
31
+ # `via` names it there, such as "WordPress (WP Mail SMTP)". The message has to, from, fromName (when there
32
+ # is one), subject, html, text, and headers; `send` raises to say it failed. Without either, the service is
33
+ # set up in Settings.
34
+ # - mailServices: the ids of the mail services Settings offers and saves, such as every service but "smtp" in
35
+ # an app that cannot keep connections of its own open. Default every service. A service saved earlier that
36
+ # is not in the list counts as none, so the app's mailer, if there is one, sends instead.
29
37
  # - secret: encrypts the keys kept in the database: the mail service's, the AI Assistant's, and the tokens
30
38
  # for connected installs. Default the RUNLIGHT_SECRET environment variable, then RUNLIGHT_TOKEN.
31
39
  # - rateLimit: tracker requests allowed per visitor address per minute. Default 120, which a real visitor
@@ -61,7 +69,9 @@ module Runlight
61
69
  # On a database that caps statements per request (Cloudflare D1), fewer days a check, about 30 statements.
62
70
  METERED_ROLLUP_BATCH = 4
63
71
  ROLLUP_DELAY_MS = 2 * 3_600_000
64
- private_constant :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS
72
+ # The longest a tracker hit waits for the tables while another process creates or upgrades them.
73
+ TRACKER_WAIT_MS = 10_000
74
+ private_constant :ROLLUP_VERSION, :ROLLUP_BATCH, :REMOTE_MAX_BYTES, :METERED_ROLLUP_BATCH, :ROLLUP_DELAY_MS, :TRACKER_WAIT_MS
65
75
 
66
76
  BUSY = /timeout exceeded when trying to connect|connection timeout|no MySQL connection was free|SQLITE_BUSY|database is locked/i
67
77
  private_constant :BUSY
@@ -78,7 +88,12 @@ module Runlight
78
88
  # Whether a connected install may be on this machine, at http://localhost or http://127.0.0.1.
79
89
  :local_installs,
80
90
  # Every outgoing request goes through it.
81
- :fetcher
91
+ :fetcher,
92
+ # The app's own mailer, used while no service is saved in the dashboard: { "send", "from",
93
+ # "fromName" (when there is one), "via" }. Nil when there is none.
94
+ :app_mailer,
95
+ # The mail services Settings offers and saves: every one, unless `mailServices` names fewer.
96
+ :mail_services
82
97
 
83
98
  # Where routes() serves the dashboard and API, which a link domain leaves alone, as a list without
84
99
  # repeats. Middleware often runs apart from the routes, where none were made, so the default "/runlight"
@@ -121,7 +136,30 @@ module Runlight
121
136
  @fetcher = options["fetcher"] || Http::NetFetcher.new
122
137
  @links = Links.new(self)
123
138
  @link_path = "/#{(options["linkPath"].nil? ? "/go" : options["linkPath"]).to_s.gsub(%r{\A/+|/+\z}, "")}"
124
- @mail_in_code = options["mail"]
139
+ mail = options["mail"]
140
+ if mail.is_a?(Hash) && mail["send"].respond_to?(:call)
141
+ unless Js.string(mail["from"].nil? ? "" : mail["from"]).match?(EMAIL)
142
+ raise ArgumentError, "Runlight: the app's mailer needs a from address, like reports@example.com"
143
+ end
144
+
145
+ @app_mailer = { "send" => mail["send"], "from" => mail["from"].to_s }
146
+ @app_mailer["fromName"] = mail["fromName"].to_s unless mail["fromName"].nil? || mail["fromName"].to_s == ""
147
+ @app_mailer["via"] = Js.string(mail["via"].nil? ? "" : mail["via"])
148
+ @app_mailer.freeze
149
+ @mail_in_code = nil
150
+ else
151
+ @app_mailer = nil
152
+ @mail_in_code = mail
153
+ end
154
+ offered = options["mailServices"]&.then { |ids| Array(ids).map(&:to_s) }
155
+ offered&.each do |id|
156
+ unless Mail::Transports::SERVICES.any? { |s| s["id"] == id }
157
+ raise ArgumentError, "Runlight: mail_services names \"#{id}\", which is not a mail service"
158
+ end
159
+ end
160
+ @mail_services = if offered.nil? then Mail::Transports::SERVICES
161
+ else Mail::Transports::SERVICES.select { |s| offered.include?(s["id"]) }.freeze
162
+ end
125
163
  @local_installs = options["localInstalls"] ? true : false
126
164
  @secret = if options.key?("secret") && !options["secret"].nil?
127
165
  options["secret"].to_s
@@ -136,6 +174,9 @@ module Runlight
136
174
  @ready = false
137
175
  # init runs once however many threads ask at once; reentrant, as init may ask for itself.
138
176
  @init_lock = Monitor.new
177
+ # The thread running init for tracker hits, which wait for it a moment at most.
178
+ @init_thread = nil
179
+ @init_thread_lock = Mutex.new
139
180
  @checking = false
140
181
  # When planner statistics were last gathered.
141
182
  @optimized_at = 0
@@ -157,18 +198,27 @@ module Runlight
157
198
  @clock.call.to_i
158
199
  end
159
200
 
160
- # The mail service: from code, or as saved in the dashboard. Nil when there is none.
201
+ # The mail Runlight sends with: a service from code, or as saved in the
202
+ # dashboard, or else the app's own mailer, as service "app" with its
203
+ # `via`. Nil when there is none.
161
204
  def mail_settings
162
205
  return @mail_in_code.merge("source" => "code") unless @mail_in_code.nil?
163
206
 
164
207
  init
165
208
  sealed = @store.setting("mail")
166
- return nil if sealed.nil? || sealed == ""
209
+ opened = sealed.nil? || sealed == "" ? nil : Mail::Secret.unseal(sealed, @secret)
210
+ saved = opened.nil? || opened == "" ? nil : Json.decode(opened)
211
+ # A service no longer offered counts as none, so it is never sent through.
212
+ if saved && (@mail_services.equal?(Mail::Transports::SERVICES) || @mail_services.any? { |s| s["id"] == saved["service"] })
213
+ return saved.merge("source" => "dashboard")
214
+ end
167
215
 
168
- opened = Mail::Secret.unseal(sealed, @secret)
169
- return nil if opened.nil? || opened == ""
216
+ app = @app_mailer
217
+ return nil if app.nil?
170
218
 
171
- Json.decode(opened).merge("source" => "dashboard")
219
+ settings = { "service" => "app", "from" => app["from"] }
220
+ settings["fromName"] = app["fromName"] if app.key?("fromName")
221
+ settings.merge("via" => app["via"], "source" => "app")
172
222
  end
173
223
 
174
224
  # Saves the mail service from the dashboard. A secret field left blank
@@ -180,9 +230,13 @@ module Runlight
180
230
  @store.set_setting("mail", nil)
181
231
  return
182
232
  end
183
- before = mail_settings || {}
233
+ current = mail_settings
234
+ before = current && current["source"] == "dashboard" ? current : {}
184
235
  service = Mail::Transports::SERVICES.find { |s| s["id"] == input["service"] }
185
236
  raise Mail::MailError.new("Pick a mail service", "mail_service", {}) if service.nil?
237
+ unless @mail_services.include?(service)
238
+ raise Mail::MailError.new("#{service["name"]} is not offered here. Pick another mail service", "mail_service", { "service" => service["id"] })
239
+ end
186
240
 
187
241
  settings = { "service" => service["id"] }
188
242
  service["fields"].each do |f|
@@ -222,14 +276,26 @@ module Runlight
222
276
  @store.set_setting("mail", Mail::Secret.seal(Json.encode(config), @secret))
223
277
  end
224
278
 
225
- # Sends one email through the mail service: { "to", "subject", "html", "text", "headers" (optional) }.
279
+ # Sends one email through the mail service, or the app's own mailer when no service is saved: { "to",
280
+ # "subject", "html", "text", "headers" (optional) }.
226
281
  def send_mail(message)
227
282
  settings = mail_settings
228
283
  raise Mail::MailError.new("Set up a mail service first", "mail_unset", {}) if settings.nil?
229
284
 
230
285
  full = message.merge("from" => settings["from"])
231
286
  full["fromName"] = settings["fromName"] unless settings["fromName"].nil?
232
- Mail::Transports.deliver(settings, full, @fetcher, now)
287
+ return Mail::Transports.deliver(settings, full, @fetcher, now) unless settings["source"] == "app"
288
+
289
+ begin
290
+ @app_mailer["send"].call(full.merge("headers" => message["headers"] || {}))
291
+ rescue Mail::MailError
292
+ raise
293
+ rescue StandardError => e
294
+ detail = e.message
295
+ via = settings["via"]
296
+ raise Mail::MailError.new("#{via} could not send it#{": #{detail}" unless detail == ""}", "mail_app_failed",
297
+ { "via" => via, "detail" => detail })
298
+ end
233
299
  end
234
300
 
235
301
  # Sends every report that is due: last week's on Monday from 8am, last
@@ -286,6 +352,21 @@ module Runlight
286
352
  @init_lock.synchronize { init_now unless @ready }
287
353
  end
288
354
 
355
+ # Whether init finished within `ms`; false while it is still waiting, such as on another process creating
356
+ # the tables, and it goes on in a thread of its own for later requests. Its errors are raised.
357
+ private def ready_within(ms)
358
+ return true if @ready
359
+
360
+ runner = @init_thread_lock.synchronize do
361
+ # One that failed is started again, as init is by the next request after a failure.
362
+ @init_thread = nil unless @init_thread&.alive? || @init_thread&.status == false
363
+ @init_thread ||= Thread.new { init }.tap { |t| t.report_on_exception = false }
364
+ end
365
+ return false if runner.join(ms / 1000.0).nil?
366
+
367
+ true
368
+ end
369
+
289
370
  # Whether init has run, so middleware can leave the app's own requests alone until it has.
290
371
  def ready?
291
372
  @ready
@@ -451,9 +532,11 @@ module Runlight
451
532
  nil
452
533
  end
453
534
 
454
- # Changes a site's name or timezone from the dashboard. Stored apart from
535
+ # Changes a site's name, timezone, or retention from the dashboard. Stored apart from
455
536
  # the settings in code, which keep being written on every start. A managed
456
537
  # site has no settings in code, so its changes, hostnames too, go to its row.
538
+ # Every field is checked before anything changes, since a new timezone clears the
539
+ # built days and a shorter retention deletes visits, and pruning starts only once saved.
457
540
  # A key left out of `patch` is left alone, as TS's undefined is.
458
541
  def update_site(id, patch)
459
542
  patch = Options.normalize(patch)
@@ -468,6 +551,7 @@ module Runlight
468
551
 
469
552
  nxt["name"] = name
470
553
  end
554
+ timezone = nil
471
555
  if given?(patch, "timezone")
472
556
  timezone = Js.string(patch["timezone"])
473
557
  unless Dates.timezone?(timezone)
@@ -475,16 +559,20 @@ module Runlight
475
559
  end
476
560
 
477
561
  nxt["timezone"] = timezone
478
- zone_changed(id, timezone) if timezone != site(id)&.[]("timezone")
479
562
  end
563
+ if @managed_sites && given?(patch, "hostnames") && !@remotes.key?(id)
564
+ nxt["hostnames"] = hostnames_for(patch["hostnames"], id)
565
+ end
566
+ check_retention(id, patch["retentionMonths"]) if given?(patch, "retentionMonths")
567
+ zone_changed(id, timezone) if !timezone.nil? && timezone != site(id)&.[]("timezone")
480
568
  if @managed_sites
481
- nxt["hostnames"] = hostnames_for(patch["hostnames"], id) if given?(patch, "hostnames") && !@remotes.key?(id)
482
569
  @store.upsert_site(nxt, now)
483
570
  @configured = @configured.map { |site| site["id"] == id ? nxt : site }
484
- return site(id)
571
+ else
572
+ @store.set_site_overrides(id, nxt)
573
+ @overrides[id] = nxt
485
574
  end
486
- @store.set_site_overrides(id, nxt)
487
- @overrides[id] = nxt
575
+ set_retention(id, patch["retentionMonths"]) if given?(patch, "retentionMonths")
488
576
  site(id)
489
577
  end
490
578
 
@@ -497,13 +585,7 @@ module Runlight
497
585
  # Sets how many months of visits a site keeps. Deleting a long history takes a while, so it runs in
498
586
  # pieces in idle(), after the answer, with tracking going on between them, as TS runs it after answering.
499
587
  def set_retention(site, months)
500
- raise SettingsError.new("Unknown site", "unknown_site") if site(site).nil? || @remotes.key?(site)
501
-
502
- unless months.nil? || (months.is_a?(Numeric) && RETENTION_MONTHS.include?(months))
503
- list = RETENTION_MONTHS.join(", ")
504
- raise SettingsError.new("Keep visits for #{list} months, or forever", "retention_bad", { "months" => list })
505
- end
506
-
588
+ check_retention(site, months)
507
589
  @store.set_setting("retention:#{site}", months.nil? ? nil : Json.number(months))
508
590
  @pruning << site
509
591
  nil
@@ -731,6 +813,10 @@ module Runlight
731
813
  return if !Ua.ai_agent(ua).nil? || Ua.bot?(ua)
732
814
  return if !@limit.nil? && !@limit.allow(client_ip(request, context))
733
815
 
816
+ # While another process creates or upgrades the tables (an index on a big table takes a while), a hit
817
+ # waits a moment at most and is then dropped, rather than holding its request until that is done.
818
+ return unless ready_within(TRACKER_WAIT_MS)
819
+
734
820
  # A database too busy to take the hit right now (every pooled connection held by long reports, or
735
821
  # another process writing the SQLite file) gets it a little later, at the time it arrived.
736
822
  at = now
@@ -758,7 +844,8 @@ module Runlight
758
844
  def link_handler
759
845
  lambda do |request, context = {}|
760
846
  path = Http::Url.new(request.url).pathname
761
- slug = path.start_with?("#{@link_path}/") ? decode(path[(@link_path.length + 1)..]) : ""
847
+ # A slug with broken escapes is one no link has.
848
+ slug = path.start_with?("#{@link_path}/") ? Js.decode_uri_component(path[(@link_path.length + 1)..]) || "" : ""
762
849
  found = slug != "" && !slug.include?("/") ? redirect(request, slug, "", context) : nil
763
850
  found || not_found
764
851
  end
@@ -785,7 +872,7 @@ module Runlight
785
872
  (@route_bases.empty? ? ["/runlight"] : @route_bases).each do |base|
786
873
  return nil if base != "/" && (url.pathname == base || url.pathname.start_with?("#{base}/"))
787
874
  end
788
- slug = decode(url.pathname[1..].to_s)
875
+ slug = Js.decode_uri_component(url.pathname[1..].to_s) || ""
789
876
  found = slug != "" && !slug.include?("/") ? redirect(request, slug, host, context) : nil
790
877
  found || not_found
791
878
  end
@@ -954,11 +1041,6 @@ module Runlight
954
1041
  Http::Response.new("Not found", status: 404, headers: { "content-type" => "text/plain; charset=utf-8" })
955
1042
  end
956
1043
 
957
- # decodeURIComponent, raising where it throws a URIError.
958
- def decode(text)
959
- Js.decode_uri_component(text) || raise(ArgumentError, "URI malformed")
960
- end
961
-
962
1044
  # True for a database that could not take a statement just now and may a moment later.
963
1045
  def busy?(error)
964
1046
  error.message.match?(BUSY)
@@ -976,6 +1058,15 @@ module Runlight
976
1058
  end
977
1059
 
978
1060
  # Checks a list of hostnames for a managed site: at least one, each a domain, none taken.
1061
+ def check_retention(site, months)
1062
+ raise SettingsError.new("Unknown site", "unknown_site") if site(site).nil? || @remotes.key?(site)
1063
+
1064
+ unless months.nil? || (months.is_a?(Numeric) && RETENTION_MONTHS.include?(months))
1065
+ list = RETENTION_MONTHS.join(", ")
1066
+ raise SettingsError.new("Keep visits for #{list} months, or forever", "retention_bad", { "months" => list })
1067
+ end
1068
+ end
1069
+
979
1070
  def hostnames_for(input, except = nil)
980
1071
  items = input.is_a?(Array) ? input : Js.string(input.nil? ? "" : input).split(/[#{Js::SPACE},]+/)
981
1072
  hostnames = []
@@ -1281,7 +1372,7 @@ module Runlight
1281
1372
  "mobile" => request.headers.get("sec-ch-ua-mobile"),
1282
1373
  "platform" => request.headers.get("sec-ch-ua-platform"),
1283
1374
  }, client["screenWidth"])
1284
- location = Geo.locate(request.headers, ip, @geo)
1375
+ location = Geo.locate(request.headers, ip, @geo, Js.truthy?(@trust_proxy))
1285
1376
  row = {
1286
1377
  "id" => session["id"],
1287
1378
  "site" => site["id"],
@@ -19,8 +19,10 @@ module Runlight
19
19
  COMPARE_MODES = %w[previous year custom off].freeze
20
20
 
21
21
  MAX_BUCKETS = 1000
22
- # A month of hours. Longer hourly ranges are cut off rather than refused.
22
+ # A month of hours. A longer range is counted by the day instead.
23
23
  MAX_HOURS = 744
24
+ # The next coarser interval, for a range too long for its own.
25
+ COARSER = { "hour" => "day", "day" => "week", "week" => "month" }.freeze
24
26
 
25
27
  # Names Intl reads as another zone where TZInfo has no name, or reads it differently.
26
28
  ALIASES = {
@@ -47,7 +49,7 @@ module Runlight
47
49
  }.freeze
48
50
 
49
51
  OFFSET = /\A([+\-]|−)([01][0-9]|2[0-3])(?::?([0-5][0-9]))?\z/
50
- private_constant :MAX_BUCKETS, :MAX_HOURS, :ALIASES, :OFFSET
52
+ private_constant :MAX_BUCKETS, :MAX_HOURS, :COARSER, :ALIASES, :OFFSET
51
53
 
52
54
  # A zone: a TZInfo::Timezone, or the seconds of a fixed offset; false where Intl throws a RangeError.
53
55
  @zones = {}
@@ -284,8 +286,26 @@ module Runlight
284
286
  end
285
287
 
286
288
  interval = INTERVALS.include?(input["interval"]) ? input["interval"] : default_interval(from_date, to_date)
287
- { "from" => start_of(from_date, timezone), "to" => start_of(add_days(to_date, 1), timezone), "fromDate" => from_date,
288
- "toDate" => to_date, "interval" => interval }
289
+ from_ms = start_of(from_date, timezone)
290
+ to_ms = start_of(add_days(to_date, 1), timezone)
291
+ # An interval too fine for the range moves up to the next one that fits, and the range says which it is.
292
+ # Past 1,000 months there is none.
293
+ while bucket_count(from_ms, to_ms, from_date, to_date, interval) > (interval == "hour" ? MAX_HOURS : MAX_BUCKETS)
294
+ interval = COARSER[interval]
295
+ return nil if interval.nil?
296
+ end
297
+ { "from" => from_ms, "to" => to_ms, "fromDate" => from_date, "toDate" => to_date, "interval" => interval }
298
+ end
299
+
300
+ # How many chart buckets a range has at an interval, the first and last counted even when partial.
301
+ def bucket_count(from_ms, to_ms, from_date, to_date, interval)
302
+ return (to_ms - from_ms).fdiv(3_600_000).ceil if interval == "hour"
303
+ return days_between(from_date, to_date) + 1 if interval == "day"
304
+ return tdiv(days_between(add_days(from_date, -weekday(from_date)), to_date), 7) + 1 if interval == "week"
305
+
306
+ fy, fm = split(from_date)
307
+ ty, tm = split(to_date)
308
+ ((ty - fy) * 12) + (tm - fm) + 1
289
309
  end
290
310
 
291
311
  def add_years(date, years)
@@ -323,12 +343,15 @@ module Runlight
323
343
  "toDate" => to_date, "interval" => range["interval"] }
324
344
  end
325
345
 
326
- # Chart buckets covering a range, each starting on a local boundary.
346
+ # Chart buckets covering a range, each starting on a local boundary and ending at the next, so only the
347
+ # first and last can be partial. resolve_range picks an interval that fits; a range given here with more
348
+ # buckets than that, such as a longer comparison, is cut off at a boundary, never merged into its last bucket.
327
349
  def buckets(range, timezone)
328
350
  starts = []
351
+ # One start past the limit, so the last bucket kept still ends where the next would begin.
329
352
  if range["interval"] == "hour"
330
353
  t = range["from"]
331
- while t < range["to"] && starts.length < MAX_HOURS
354
+ while t < range["to"] && starts.length <= MAX_HOURS
332
355
  starts << t
333
356
  t += 3_600_000
334
357
  end
@@ -339,7 +362,7 @@ module Runlight
339
362
  elsif range["interval"] == "month"
340
363
  date = "#{date[0, 8]}01"
341
364
  end
342
- while date <= range["toDate"] && starts.length < MAX_BUCKETS
365
+ while date <= range["toDate"] && starts.length <= MAX_BUCKETS
343
366
  starts << start_of(date, timezone)
344
367
  date = case range["interval"]
345
368
  when "day" then add_days(date, 1)
@@ -348,7 +371,7 @@ module Runlight
348
371
  end
349
372
  end
350
373
  end
351
- starts.each_with_index.map do |start, i|
374
+ starts.first(range["interval"] == "hour" ? MAX_HOURS : MAX_BUCKETS).each_with_index.map do |start, i|
352
375
  { "start" => [start, range["from"]].max, "end" => [starts[i + 1] || range["to"], range["to"]].min }
353
376
  end
354
377
  end
@@ -31,9 +31,15 @@ module Runlight
31
31
  def sqlite(path)
32
32
  path = path.to_s
33
33
  memory = path == ":memory:" || path.empty?
34
- # A file in a folder that is not there yet gets the folder, as a fresh app's data/ often is.
35
- FileUtils.mkdir_p(File.dirname(path)) if !memory && !path.start_with?("file:") && !File.directory?(File.dirname(path))
36
- config = { adapter: "sqlite3", database: memory ? ":memory:" : path, timeout: 5000,
34
+ file = !memory && !path.start_with?("file:")
35
+ # A file in a folder that is not there yet gets the folder, as a fresh app's data/ often is, for its
36
+ # owner and group alone, as the file is.
37
+ if file && !File.directory?(File.dirname(path))
38
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o770)
39
+ owner_and_group(File.dirname(path), 0o770)
40
+ end
41
+ made(path) if file && !File.exist?(path)
42
+ config = { adapter: "sqlite3", database: memory ? ":memory:" : path, timeout: Database::BUSY_TIMEOUT_MS,
37
43
  # One connection for a database in memory, which every connection would otherwise make anew.
38
44
  pool: memory ? 1 : 5 }
39
45
  Database.new(Pools.make(config), owned: true, dialect: "sqlite")
@@ -57,7 +63,7 @@ module Runlight
57
63
  Database.new(Pools.make(config.compact), owned: true, dialect: "postgres")
58
64
  end
59
65
 
60
- # MySQL 8.4 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
66
+ # MySQL 8.0.20 or MariaDB 11.4 and later from a URL like mysql://user:pass@host:3306/db (or mariadb://),
61
67
  # through the trilogy gem when it is installed, else mysql2. The session is utf8mb4 with the server's own
62
68
  # SQL mode plus IGNORE_SPACE, as mysql2 opens it for the TypeScript driver (its connect flags ask for
63
69
  # that, which neither Ruby driver can, so it is set as the session's mode), less NO_BACKSLASH_ESCAPES, as
@@ -93,6 +99,23 @@ module Runlight
93
99
  end
94
100
  end
95
101
 
102
+ # A database file made here is for its owner and group alone (0660), whatever the umask: the visits in it
103
+ # are nobody else's to read, and a scheduled job running as another user of the same group can still
104
+ # write to it. It is made empty, which SQLite takes as a new database, before anything opens it, so the
105
+ # -wal and -shm files SQLite makes beside it later take the same mode.
106
+ def made(path)
107
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o660) { nil }
108
+ owner_and_group(path, 0o660)
109
+ rescue SystemCallError
110
+ # Made by another process meanwhile, or a folder this one may not write to, which opening reports.
111
+ end
112
+
113
+ def owner_and_group(path, mode)
114
+ File.chmod(mode, path)
115
+ rescue SystemCallError, NotImplementedError
116
+ # Not a file this process may change, or a system with no such modes.
117
+ end
118
+
96
119
  def quote_name(name, quote)
97
120
  "#{quote}#{name.to_s.gsub(quote, quote * 2)}#{quote}"
98
121
  end