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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +64 -23
- data/assets/locales.json +5 -5
- data/assets/pick-return.js +1 -0
- data/assets/picker.js +1 -1
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +8 -6
- data/lib/runlight/assistant.rb +37 -16
- data/lib/runlight/core.rb +125 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +27 -4
- data/lib/runlight/db/db.rb +52 -4
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +27 -5
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +83 -15
- data/lib/runlight/rack_app.rb +11 -4
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +208 -92
- data/lib/runlight/safefetch.rb +98 -1
- data/lib/runlight/server/agents.rb +1 -1
- data/lib/runlight/server/cli.rb +1 -1
- data/lib/runlight/server/config.rb +64 -19
- data/lib/runlight/server/standalone.rb +7 -6
- data/lib/runlight/store/sql.rb +10 -0
- data/lib/runlight/store/sql_store.rb +75 -13
- data/lib/runlight/stores.rb +19 -15
- data/lib/runlight/tables_busy_error.rb +11 -0
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight.rb +4 -0
- 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
|
|
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)
|
|
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
|
-
|
|
497
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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)
|
data/lib/runlight/assistant.rb
CHANGED
|
@@ -85,34 +85,52 @@ module Runlight
|
|
|
85
85
|
message.is_a?(String) ? message : ""
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
#
|
|
89
|
-
# private network is allowed, as the person setting it up means it to be.
|
|
90
|
-
def
|
|
91
|
-
host
|
|
92
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
28
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
return nil if
|
|
216
|
+
app = @app_mailer
|
|
217
|
+
return nil if app.nil?
|
|
170
218
|
|
|
171
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
-
|
|
571
|
+
else
|
|
572
|
+
@store.set_site_overrides(id, nxt)
|
|
573
|
+
@overrides[id] = nxt
|
|
485
574
|
end
|
|
486
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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"],
|
data/lib/runlight/dates.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
288
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/runlight/db/connect.rb
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
|