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