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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +71 -22
- 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 +90 -19
- data/lib/runlight/accounts/throttle.rb +123 -38
- data/lib/runlight/accounts/web.rb +50 -14
- data/lib/runlight/assistant.rb +40 -8
- data/lib/runlight/connect.rb +2 -3
- data/lib/runlight/core.rb +148 -34
- data/lib/runlight/dates.rb +31 -8
- data/lib/runlight/db/connect.rb +32 -6
- data/lib/runlight/db/db.rb +129 -9
- data/lib/runlight/db/prefixed.rb +92 -0
- data/lib/runlight/geo.rb +4 -3
- data/lib/runlight/http/fetcher.rb +31 -6
- data/lib/runlight/http/request.rb +14 -3
- data/lib/runlight/importers/csv_visits.rb +1 -1
- data/lib/runlight/importers/index.rb +10 -2
- data/lib/runlight/importers/visits.rb +17 -6
- data/lib/runlight/importers/write.rb +21 -6
- data/lib/runlight/links.rb +14 -9
- data/lib/runlight/mail/ses.rb +3 -2
- data/lib/runlight/mail/smtp.rb +2 -0
- data/lib/runlight/mail/smtp_session.rb +22 -1
- data/lib/runlight/mail/transports.rb +19 -2
- data/lib/runlight/mcp.rb +8 -1
- data/lib/runlight/metadata_address_error.rb +13 -0
- data/lib/runlight/oauth.rb +85 -18
- data/lib/runlight/rack_app.rb +64 -10
- data/lib/runlight/redirect_refused_error.rb +14 -0
- data/lib/runlight/routes.rb +306 -144
- data/lib/runlight/safefetch.rb +133 -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])}})();
|
|
@@ -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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
# lock, so two owners demoting each other at once cannot leave none,
|
|
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
|
|
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
|
-
|
|
428
|
-
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
|
|
429
499
|
end
|
|
430
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
93
|
-
entry
|
|
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
|
-
|
|
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
|