runlight 0.1.3 → 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 +15 -14
- data/assets/locales.json +5 -5
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +22 -11
- data/lib/runlight/accounts/pages.rb +67 -3
- data/lib/runlight/accounts/web.rb +45 -30
- data/lib/runlight/assistant.rb +4 -2
- data/lib/runlight/core.rb +1 -5
- data/lib/runlight/reports.rb +1 -6
- data/lib/runlight/routes.rb +36 -4
- data/lib/runlight/store/sql_store.rb +1 -1
- data/lib/runlight/version.rb +1 -1
- metadata +3 -2
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])}})();
|
|
@@ -222,6 +222,7 @@ module Runlight
|
|
|
222
222
|
if user["role"] == "owner"
|
|
223
223
|
raise AccountError.new("Only the owner can change their own role, by handing ownership to an admin", "owner_protected")
|
|
224
224
|
end
|
|
225
|
+
raise AccountError.new("An admin needs access to all sites", "role_scope") if !user["site"].nil? && role == "admin"
|
|
225
226
|
raise AccountError.new("Ownership is handed over by the owner", "owner_hand_over") if role == "owner"
|
|
226
227
|
|
|
227
228
|
@store.db.run("UPDATE rl_users SET role = ? WHERE id = ?", [role, id])
|
|
@@ -240,7 +241,7 @@ module Runlight
|
|
|
240
241
|
nxt = find(to)
|
|
241
242
|
raise AccountError.new("Only the owner can hand over ownership", "owner_hand_over") if owner.nil? || owner["role"] != "owner"
|
|
242
243
|
raise AccountError.new("Unknown account", "unknown_account") if nxt.nil?
|
|
243
|
-
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"
|
|
244
245
|
|
|
245
246
|
@store.db.run("UPDATE rl_users SET role = 'owner' WHERE id = ?", [to])
|
|
246
247
|
@store.db.run("UPDATE rl_users SET role = 'admin' WHERE id = ?", [from])
|
|
@@ -266,8 +267,9 @@ module Runlight
|
|
|
266
267
|
|
|
267
268
|
# Makes an account, or sets a new password on an existing one. A new account is the owner when it is the first,
|
|
268
269
|
# and otherwise an admin unless a role is given, since a server has one owner.
|
|
269
|
-
def set_password(email, password, now, role = nil)
|
|
270
|
+
def set_password(email, password, now, role = nil, site = nil)
|
|
270
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))
|
|
271
273
|
address = Js.lower(Js.trim(email))
|
|
272
274
|
raise AccountError.new("Enter an email address", "email_invalid") unless address.match?(EMAIL)
|
|
273
275
|
if Js.length(password) < MIN_PASSWORD
|
|
@@ -291,9 +293,10 @@ module Runlight
|
|
|
291
293
|
"createdAt" => now,
|
|
292
294
|
"twoFactor" => false,
|
|
293
295
|
"recoveryLeft" => 0,
|
|
296
|
+
**(site.nil? ? {} : { "site" => site }),
|
|
294
297
|
}
|
|
295
|
-
@store.db.run("INSERT INTO rl_users (id, email, hash, role, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
296
|
-
[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])
|
|
297
300
|
user
|
|
298
301
|
end
|
|
299
302
|
|
|
@@ -306,9 +309,9 @@ module Runlight
|
|
|
306
309
|
|
|
307
310
|
# Invites someone to join with a role, and returns {"invite" => ..., "code" => ...}, the code for their link.
|
|
308
311
|
# Asking again replaces the earlier invite, so only the newest link works.
|
|
309
|
-
def invite(email, role, invited_by, now)
|
|
312
|
+
def invite(email, role, invited_by, now, site = nil)
|
|
310
313
|
init
|
|
311
|
-
turn { invite_now(email, role, invited_by, now) }
|
|
314
|
+
turn { invite_now(email, role, invited_by, now, site) }
|
|
312
315
|
end
|
|
313
316
|
|
|
314
317
|
# The invite a link's code belongs to, while it still works.
|
|
@@ -337,7 +340,7 @@ module Runlight
|
|
|
337
340
|
next nil if invite.nil?
|
|
338
341
|
|
|
339
342
|
sender = by_email(invite["invitedBy"])
|
|
340
|
-
if sender.nil? || !self.class.may_invite?(sender["role"])
|
|
343
|
+
if sender.nil? || !sender["site"].nil? || !self.class.may_invite?(sender["role"])
|
|
341
344
|
# Left without raising, since an error inside a turn on SQLite would roll this back.
|
|
342
345
|
@store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
|
|
343
346
|
next nil
|
|
@@ -346,7 +349,7 @@ module Runlight
|
|
|
346
349
|
raise AccountError.new("#{invite["email"]} already has an account", "account_exists", { "email" => invite["email"] })
|
|
347
350
|
end
|
|
348
351
|
|
|
349
|
-
made = set_password(invite["email"], password, now, invite["role"])
|
|
352
|
+
made = set_password(invite["email"], password, now, invite["role"], invite["site"])
|
|
350
353
|
@store.db.run("DELETE FROM rl_invites WHERE id = ?", [invite["id"]])
|
|
351
354
|
made
|
|
352
355
|
end
|
|
@@ -451,6 +454,10 @@ module Runlight
|
|
|
451
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, " \
|
|
452
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}",
|
|
453
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
|
|
454
461
|
# A server has one owner. One from before, with several, keeps the first and the rest become admins,
|
|
455
462
|
# who can still do everything but remove the owner. Invites to join as an owner become invites as an admin.
|
|
456
463
|
owners = db.all("SELECT id FROM rl_users WHERE role = 'owner' ORDER BY created_at, id")
|
|
@@ -467,6 +474,7 @@ module Runlight
|
|
|
467
474
|
"email" => r["email"].to_s,
|
|
468
475
|
"hash" => r["hash"].to_s,
|
|
469
476
|
"role" => self.class.role_from(r["role"]),
|
|
477
|
+
**(r["site"].nil? ? {} : { "site" => r["site"].to_s }),
|
|
470
478
|
"createdAt" => Js.number(r["created_at"]),
|
|
471
479
|
"twoFactor" => Js.truthy?(r["totp_secret"]),
|
|
472
480
|
"recoveryLeft" => recovery.length,
|
|
@@ -534,22 +542,24 @@ module Runlight
|
|
|
534
542
|
"id" => r["id"].to_s,
|
|
535
543
|
"email" => r["email"].to_s,
|
|
536
544
|
"role" => self.class.role_from(r["role"]),
|
|
545
|
+
**(r["site"].nil? ? {} : { "site" => r["site"].to_s }),
|
|
537
546
|
"invitedBy" => r["invited_by"].to_s,
|
|
538
547
|
"createdAt" => Js.number(r["created_at"]),
|
|
539
548
|
"expiresAt" => Js.number(r["expires_at"]),
|
|
540
549
|
}
|
|
541
550
|
end
|
|
542
551
|
|
|
543
|
-
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))
|
|
544
554
|
address = Js.lower(Js.trim(email))
|
|
545
555
|
raise AccountError.new("Enter an email address", "email_invalid") unless address.match?(EMAIL)
|
|
546
556
|
raise AccountError.new("#{address} already has an account", "account_exists", { "email" => address }) unless by_email(address).nil?
|
|
547
557
|
|
|
548
558
|
code = Crypto.base64url(Crypto.random_bytes(24))
|
|
549
|
-
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,
|
|
550
560
|
"expiresAt" => now + INVITE_MS }
|
|
551
561
|
@store.db.run("DELETE FROM rl_invites WHERE email = ?", [address])
|
|
552
|
-
@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 (?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
553
563
|
invite["id"],
|
|
554
564
|
invite["email"],
|
|
555
565
|
invite["role"],
|
|
@@ -557,6 +567,7 @@ module Runlight
|
|
|
557
567
|
invite["invitedBy"],
|
|
558
568
|
invite["createdAt"],
|
|
559
569
|
invite["expiresAt"],
|
|
570
|
+
site,
|
|
560
571
|
])
|
|
561
572
|
{ "invite" => invite, "code" => code }
|
|
562
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
|
data/lib/runlight/assistant.rb
CHANGED
|
@@ -203,7 +203,7 @@ module Runlight
|
|
|
203
203
|
end
|
|
204
204
|
|
|
205
205
|
if provider["protocol"] == "anthropic"
|
|
206
|
-
tools = Mcp.tools.map { |t| { "name" => t["name"], "description" => t["description"], "input_schema" => t["inputSchema"] } }
|
|
206
|
+
tools = (Mcp.tools + [Json.decode(File.read(File.expand_path("../../assets/assistant-action.json", __dir__)))]).map { |t| { "name" => t["name"], "description" => t["description"], "input_schema" => t["inputSchema"] } }
|
|
207
207
|
convo = history.dup
|
|
208
208
|
MAX_ROUNDS.times do
|
|
209
209
|
data = post(
|
|
@@ -238,6 +238,7 @@ module Runlight
|
|
|
238
238
|
name = Js.get(call, "name")
|
|
239
239
|
name = "" if name.nil? || name.equal?(UNDEFINED)
|
|
240
240
|
used << name
|
|
241
|
+
return { "reply" => "", "tools" => used[0...-1], "action" => Js.get(call, "input") } if name == "prepare_action"
|
|
241
242
|
out = tool_text(name, Js.get(call, "input"), read_api)
|
|
242
243
|
result = { "type" => "tool_result", "tool_use_id" => Js.get(call, "id"), "content" => out["text"] }
|
|
243
244
|
result["is_error"] = true if out["error"]
|
|
@@ -248,7 +249,7 @@ module Runlight
|
|
|
248
249
|
raise AssistantError.new("The assistant needed too many steps for that question. Try asking something narrower.", "assistant_steps")
|
|
249
250
|
end
|
|
250
251
|
|
|
251
|
-
tools = Mcp.tools.map { |t| { "type" => "function", "function" => { "name" => t["name"], "description" => t["description"], "parameters" => t["inputSchema"] } } }
|
|
252
|
+
tools = (Mcp.tools + [Json.decode(File.read(File.expand_path("../../assets/assistant-action.json", __dir__)))]).map { |t| { "type" => "function", "function" => { "name" => t["name"], "description" => t["description"], "parameters" => t["inputSchema"] } } }
|
|
252
253
|
convo = [{ "role" => "system", "content" => system_prompt(context) }, *history]
|
|
253
254
|
headers = key == "" ? {} : { "authorization" => "Bearer #{key}" }
|
|
254
255
|
MAX_ROUNDS.times do
|
|
@@ -274,6 +275,7 @@ module Runlight
|
|
|
274
275
|
given = Js.get(function, "arguments")
|
|
275
276
|
parsed, args = Js.parse_json(Js.truthy?(given) ? Js.string(given) : "{}")
|
|
276
277
|
args = {} unless parsed
|
|
278
|
+
return { "reply" => "", "tools" => used[0...-1], "action" => args } if name == "prepare_action"
|
|
277
279
|
out = tool_text(name, args, read_api)
|
|
278
280
|
convo << { "role" => "tool", "tool_call_id" => Js.get(call, "id"), "content" => out["text"] }
|
|
279
281
|
end
|
data/lib/runlight/core.rb
CHANGED
|
@@ -1451,11 +1451,7 @@ module Runlight
|
|
|
1451
1451
|
Accounts::Throttle.sweep(@store, now)
|
|
1452
1452
|
# Every site's retention covers any one site's that is still waiting.
|
|
1453
1453
|
@pruning = []
|
|
1454
|
-
|
|
1455
|
-
apply_retention
|
|
1456
|
-
rescue StandardError => e
|
|
1457
|
-
warn("Runlight: could not apply retention #{e.message}")
|
|
1458
|
-
end
|
|
1454
|
+
apply_retention
|
|
1459
1455
|
if now - @optimized_at >= 86_400_000
|
|
1460
1456
|
@optimized_at = now
|
|
1461
1457
|
@store.optimize
|
data/lib/runlight/reports.rb
CHANGED
|
@@ -183,12 +183,7 @@ module Runlight
|
|
|
183
183
|
u = Http::Url.parse(links["dashboard"])
|
|
184
184
|
where = u.nil? ? links["dashboard"] : u.host + u.pathname.sub(%r{/\z}, "")
|
|
185
185
|
at = t.call("email.at", { "where" => where })
|
|
186
|
-
|
|
187
|
-
mark = <<~HTML.chomp
|
|
188
|
-
<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr>
|
|
189
|
-
<td class="rl-mark" width="24" height="24" align="center" style="width:24px;height:24px;background:#111827;border-radius:7px;color:#ffffff;font-size:15px;font-weight:700;line-height:24px;text-align:center;font-family:#{font}">R</td>
|
|
190
|
-
<td class="rl-ink" style="padding-left:8px;font-size:15px;font-weight:700;color:#111827;font-family:#{font}">Runlight</td></tr></table>
|
|
191
|
-
HTML
|
|
186
|
+
mark = "<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\">"
|
|
192
187
|
|
|
193
188
|
footer = t.call("email.footer", { "frequency" => t.call(monthly ? "email.monthly" : "email.weekly"), "site" => site["name"] })
|
|
194
189
|
at_line = esc(t.call("email.at", { "where" => "\0" }))
|
data/lib/runlight/routes.rb
CHANGED
|
@@ -600,6 +600,10 @@ module Runlight
|
|
|
600
600
|
end
|
|
601
601
|
|
|
602
602
|
# Whether this request acts as the owner. "read" is someone signed in who may only read, such as a viewer.
|
|
603
|
+
def session_scope(request)
|
|
604
|
+
@web.signed_in(request) if @web
|
|
605
|
+
end
|
|
606
|
+
|
|
603
607
|
def can_read(request)
|
|
604
608
|
return true if @managed.key?(request)
|
|
605
609
|
|
|
@@ -609,6 +613,8 @@ module Runlight
|
|
|
609
613
|
given = bearer(request)
|
|
610
614
|
return true if authorize.nil? && !@token.nil? && @token != "" && given != "" && constant_time_equal(given, @token)
|
|
611
615
|
|
|
616
|
+
scoped = session_scope(request)
|
|
617
|
+
return false if scoped && !scoped["site"].nil? && Http::Url.new(request.url).pathname.delete_prefix(@base).start_with?("/oauth/")
|
|
612
618
|
answer = authorize.nil? ? @web.access(request) : authorize.call(request)
|
|
613
619
|
# A member changes things like an owner, apart from the few controls admin_only? names.
|
|
614
620
|
@members[request] = true if answer == "member"
|
|
@@ -1248,7 +1254,7 @@ module Runlight
|
|
|
1248
1254
|
"to" => to,
|
|
1249
1255
|
"subject" => t.call("email.test.subject"),
|
|
1250
1256
|
"text" => t.call("email.test.body", { "service" => name }),
|
|
1251
|
-
"html" => "<p style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
|
|
1257
|
+
"html" => "<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 style=\"font-family:sans-serif;font-size:15px\">#{escape_html(t.call("email.test.body", { "service" => name }))}</p>",
|
|
1252
1258
|
})
|
|
1253
1259
|
return json({ "ok" => true })
|
|
1254
1260
|
end
|
|
@@ -1535,6 +1541,27 @@ module Runlight
|
|
|
1535
1541
|
def api(request, path, url)
|
|
1536
1542
|
rl = @rl
|
|
1537
1543
|
method = request.method
|
|
1544
|
+
scoped = session_scope(request)
|
|
1545
|
+
if scoped && !scoped["site"].nil?
|
|
1546
|
+
rl.init
|
|
1547
|
+
site = scoped["site"]
|
|
1548
|
+
return coded("Unknown site", "unknown_site", 404) if rl.site(site).nil?
|
|
1549
|
+
asked = url.search_params.get("site")
|
|
1550
|
+
site_path = path.match(%r{\A/api/sites/([^/]+)\z})
|
|
1551
|
+
return coded("Unknown site", "unknown_site", 404) if (!asked.nil? && asked != site) || (site_path && decode_part(site_path[1]) != site)
|
|
1552
|
+
read = method == "GET" && shared_path?(path)
|
|
1553
|
+
assistant = (path == "/api/assistant" && method == "GET") || (path == "/api/assistant/chat" && method == "POST")
|
|
1554
|
+
manage = scoped["role"] == "member" && manage_path(method, path)
|
|
1555
|
+
return coded("Your account only has access to its assigned site", "site_access", 403) unless read || assistant || manage
|
|
1556
|
+
if path == "/api/assistant/chat"
|
|
1557
|
+
parsed, body = Js.parse_json(request.text)
|
|
1558
|
+
return coded("Unknown site", "unknown_site", 404) unless parsed && Js.object?(body) && Js.get(body, "site") == site
|
|
1559
|
+
end
|
|
1560
|
+
url = Http::Url.new(url.href)
|
|
1561
|
+
query = url.search_params
|
|
1562
|
+
query.set("site", site)
|
|
1563
|
+
url.search_params = query
|
|
1564
|
+
end
|
|
1538
1565
|
# An embedded dashboard reads what a share link shows and nothing else, whoever else the request comes from.
|
|
1539
1566
|
if !request.headers.get(EMBED_HEADER).nil? && !(method == "GET" && shared_path?(path))
|
|
1540
1567
|
return coded("Not available on a shared dashboard", "share_not_available", 403)
|
|
@@ -1987,6 +2014,10 @@ module Runlight
|
|
|
1987
2014
|
id = decode_part(site_match[1])
|
|
1988
2015
|
return coded("Unknown site", "unknown_site", 404) if id.nil?
|
|
1989
2016
|
|
|
2017
|
+
if @web && session_scope(request)
|
|
2018
|
+
refused = @web.confirm_password(request)
|
|
2019
|
+
return refused unless refused.nil?
|
|
2020
|
+
end
|
|
1990
2021
|
begin
|
|
1991
2022
|
rl.delete_site(id)
|
|
1992
2023
|
return json({ "ok" => true })
|
|
@@ -2209,7 +2240,8 @@ module Runlight
|
|
|
2209
2240
|
share_id = request.headers.get(SHARE_HEADER)
|
|
2210
2241
|
shared = nil
|
|
2211
2242
|
# The one site a share or a site's API token may read; nil for every site.
|
|
2212
|
-
|
|
2243
|
+
scoped = session_scope(request)
|
|
2244
|
+
only = scoped && scoped["site"]
|
|
2213
2245
|
embed = request.headers.get(EMBED_HEADER)
|
|
2214
2246
|
if share_id.nil? && !embed.nil?
|
|
2215
2247
|
token = embed_reader(embed)
|
|
@@ -2225,7 +2257,7 @@ module Runlight
|
|
|
2225
2257
|
if access != true
|
|
2226
2258
|
return coded("API tokens can only read", "token_read_only", 403) unless shared_path?(path)
|
|
2227
2259
|
|
|
2228
|
-
only = access["site"] != "" ? access["site"] : nil
|
|
2260
|
+
only = scoped && scoped["site"] || (access["site"] != "" ? access["site"] : nil)
|
|
2229
2261
|
end
|
|
2230
2262
|
else
|
|
2231
2263
|
shared = share_id.match?(SHARE_ID) ? store.share_by_id(share_id) : nil
|
|
@@ -2259,7 +2291,7 @@ module Runlight
|
|
|
2259
2291
|
row
|
|
2260
2292
|
end
|
|
2261
2293
|
# A share never learns how the install is run.
|
|
2262
|
-
return json(shared.nil? ? { "sites" => rows, "managed" => rl.managed_sites } : { "sites" => rows })
|
|
2294
|
+
return json(shared.nil? ? { "sites" => rows, "managed" => !(scoped && scoped["site"]) && rl.managed_sites } : { "sites" => rows })
|
|
2263
2295
|
end
|
|
2264
2296
|
|
|
2265
2297
|
site = if !shared.nil?
|
|
@@ -1295,7 +1295,7 @@ module Runlight
|
|
|
1295
1295
|
)
|
|
1296
1296
|
sources = @db.all(
|
|
1297
1297
|
"SELECT s.source AS value, COUNT(DISTINCT e.visitor) AS visitors FROM rl_events e JOIN rl_sessions s ON s.id = e.session
|
|
1298
|
-
WHERE e.site = ? AND e.ts >= ? AND e.kind IN ('pageview', 'event')
|
|
1298
|
+
WHERE e.site = ? AND e.ts >= ? AND e.kind IN ('pageview', 'event')
|
|
1299
1299
|
GROUP BY s.source ORDER BY visitors DESC, s.source#{text_order} LIMIT 10",
|
|
1300
1300
|
[site, since],
|
|
1301
1301
|
)
|