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.
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)&&/[?&]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
+ (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, People.</p>
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
- role = @accounts.by_id(by)&.fetch("role", nil)
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.call(input, "current", ["Your current password is not right", "password_current_wrong"])
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.call(input, "password", ["Your password is not right", "password_wrong"])
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.call(input, "password", ["Your password is not right", "password_wrong"])
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.call(input, "password", ["Your password is not right", "password_wrong"])
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
- made = @accounts.invite(email, role, user["email"], now)
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
@@ -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
- begin
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
@@ -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
- # The Runlight mark in table cells: mail apps block SVG and most inline images.
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" }))
@@ -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
- only = nil
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') AND s.source <> ''
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
  )