runlight 0.1.7 → 0.1.8

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 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])}})();
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"),k=o("site"),U=o("hash")!==null,I=o("manual")!==null,T=o("before-send"),$=function(e){return new RegExp("^"+e.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")+"$")},J=(o("exclude")||"").split(",").filter(Boolean).map(function(e){return $(e.trim())}),A=!1,H=a.runlight&&a.runlight.q||[],m="__RUNLIGHT_RULES__",O=typeof m=="object"&&(m[k]||m[location.hostname.replace(/^www\./,"")]||m["*"])||[],M=/[?&]runlight=pick\b/.test(location.search)&&/[?&]runlight_ticket=/.test(location.search);if(M){var N=n.createElement("script");N.src=p.src.replace(/s\.js(\?.*)?$/,"pick.js")+location.search,n.head.appendChild(N)}var z=!1;try{var B=/[?&]runlight=(ignore|track)\b/.exec(location.search);B&&(B[1]==="ignore"?localStorage.setItem("runlight_ignore","1"):localStorage.removeItem("runlight_ignore")),z=localStorage.getItem("runlight_ignore")==="1"}catch(e){}var P=M||u.webdriver||z||o("dnt")!==null&&(u.doNotTrack==="1"||a.doNotTrack==="1"),E=function(e,t){if(!(A||P)){var r={k:e,u:b};k&&(r.s=k);for(var i in t)r[i]=t[i];if(T!==null)try{if(r=a[T](r),!r||r.constructor!==Object)return}catch(y){return}var s=JSON.stringify(r);u.sendBeacon&&u.sendBeacon(R,s)||a.fetch&&fetch(R,{method:"POST",body:s,keepalive:!0}).catch(function(){})}},Y=function(){var e=new Uint8Array(6);return(a.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("")},F=o("404")!==null,b="",v="",f=0,d=0,w=0,q=function(){return n.visibilityState==="visible"&&n.hasFocus()},D=function(){d&&(f+=Date.now()-d),d=0},c=function(){!d&&q()&&(d=Date.now())},S=function(){D(),v&&f>=1e3&&(E("engagement",{i:v,e:f,d:w}),f=0)},V=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>w&&(w=i)},K=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:K();if(t!==b){var r=b||n.referrer;S(),b=t;var i=new URL(t).pathname;A=J.some(function(s){return s.test(i)}),v=Y(),f=0,w=0,V(),c(),E("pageview",{i:v,r,t:n.title,w:screen.width,h:screen.height,l:u.language}),F&&l("404",{path:location.pathname}),F=!1}},l=function(e,t){typeof e!="string"||!e||E("event",{i:v,n:e,p:t&&typeof t=="object"?t:void 0})},C=function(e){var t=history[e];history[e]=function(){var r=t.apply(this,arguments);return setTimeout(h),r}};I||(C("pushState"),C("replaceState"),a.addEventListener("popstate",h),U&&a.addEventListener("hashchange",h)),n.addEventListener("visibilitychange",function(){n.visibilityState==="hidden"?S():c()}),a.addEventListener("pagehide",S),a.addEventListener("blur",D),a.addEventListener("focus",c),a.addEventListener("scroll",function(){V(),c()},{passive:!0}),n.addEventListener("pointerdown",c,!0),n.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;n.addEventListener("click",function(e){var t=e.target;if(!(!t||!t.closest)){for(var r=t.closest("a[href]"),i=0;i<O.length;i++)try{var s=O[i],y=s[1];(s[0]==="s"?t.closest(y):r&&$(y).test(y.charAt(0)==="/"?r.pathname:r.href))&&l(s[2])}catch(W){}var g=t.closest("[data-runlight]");if(g){for(var G={},x=0;x<g.attributes.length;x++){var _=g.attributes[x];_.name.indexOf("data-runlight-")===0&&(G[_.name.slice(14)]=_.value)}l(g.getAttribute("data-runlight"),G)}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),a.runlight=l,l.pageview=h,I||h();for(var L=0;L<H.length;L++)l.apply(null,H[L])}})();
@@ -151,21 +151,9 @@ module Runlight
151
151
  ticket("pending", user, now + (5 * 60_000))
152
152
  end
153
153
 
154
- # A ticket that looks and acts like pending_for's, except that no code ever
155
- # passes with it. A wrong password gets one once an account with
156
- # two-factor has had too many, so the answer never tells a right password.
157
- def decoy_for(user, now)
158
- ticket("decoy", user, now + (5 * 60_000))
159
- end
160
-
161
- # The account a code-step ticket names, and whether a right code may sign in with it:
162
- # {"user" => user, "real" => bool}, or nil.
154
+ # The account a code-step ticket names, or nil.
163
155
  def from_pending(value, now)
164
- user = from_ticket("pending", value, now)
165
- return { "user" => user, "real" => true } unless user.nil?
166
-
167
- decoy = from_ticket("decoy", value, now)
168
- decoy.nil? ? nil : { "user" => decoy, "real" => false }
156
+ from_ticket("pending", value, now)
169
157
  end
170
158
 
171
159
  # A ticket for a sign-in link sent by email, for fifteen minutes. A new password withdraws it.
@@ -249,6 +237,20 @@ module Runlight
249
237
  nil
250
238
  end
251
239
 
240
+ # Removes the accounts and invitations kept to a site that was deleted. A site made later is often given the
241
+ # same id, from its domain, and they must not find their way into it.
242
+ def forget_site(site)
243
+ init
244
+ turn do
245
+ list.select { |u| u["site"] == site }.each do |user|
246
+ @store.db.run("DELETE FROM rl_users WHERE id = ?", [user["id"]])
247
+ @store.set_setting("login-link-used:#{user["id"]}", nil)
248
+ end
249
+ @store.db.run("DELETE FROM rl_invites WHERE site = ?", [site])
250
+ end
251
+ nil
252
+ end
253
+
252
254
  # Removes an account. The owner cannot be removed.
253
255
  def remove(id)
254
256
  # The tables first: making them takes the same lock as a turn.
@@ -367,7 +369,13 @@ module Runlight
367
369
  self.class.slot { Crypto.check_password(password, hash) }
368
370
  return nil
369
371
  end
370
- self.class.slot { Crypto.check_password(password, user["hash"]) } ? user : nil
372
+ return nil unless self.class.slot { Crypto.check_password(password, user["hash"]) }
373
+ return user unless Crypto.weaker_than_now?(user["hash"])
374
+
375
+ # A hash from before the cost rose is made again now that the password is here, unless it changed meanwhile.
376
+ hash = self.class.slot { Crypto.hash_password(password) }
377
+ @store.db.run("UPDATE rl_users SET hash = ? WHERE id = ? AND hash = ?", [hash, user["id"], user["hash"]])
378
+ by_id(user["id"]) || user
371
379
  end
372
380
 
373
381
  # A cookie value naming the user and when it expires, signed with the
@@ -12,10 +12,12 @@ module Runlight
12
12
  # TOTP, the otpauth address, recovery codes, and the signature on session cookies) are here too, as
13
13
  # functions of their inputs alone, so the accounts class can call them.
14
14
  module Crypto
15
- SCRYPT_N = 16_384
16
- SCRYPT_R = 8
17
- SCRYPT_P = 1
18
- private_constant :SCRYPT_N, :SCRYPT_R, :SCRYPT_P
15
+ # What a new hash costs: one of OWASP's scrypt settings, 32 MiB a check. Each hash records its own cost,
16
+ # so this can rise again.
17
+ SCRYPT = { n: 32_768, r: 8, p: 3 }.freeze
18
+ # What a hash written as scrypt$salt$key costs: the setting before hashes recorded theirs.
19
+ SCRYPT_BEFORE = { n: 16_384, r: 8, p: 1 }.freeze
20
+ private_constant :SCRYPT, :SCRYPT_BEFORE
19
21
 
20
22
  # As many PBKDF2 rounds as Cloudflare Workers allow, the strictest runtime Runlight runs on.
21
23
  PBKDF2_ROUNDS = 100_000
@@ -71,10 +73,10 @@ module Runlight
71
73
  same_bytes(a, b)
72
74
  end
73
75
 
74
- # A password hash, in the scrypt form the standalone server has always written.
76
+ # A password hash, scrypt$N$r$p$salt$key: it records its cost, which can rise later.
75
77
  def hash_password(password)
76
78
  salt = random_bytes(16)
77
- "scrypt$#{base64url(salt)}$#{base64url(scrypt(password, salt, 32))}"
79
+ "scrypt$#{SCRYPT[:n]}$#{SCRYPT[:r]}$#{SCRYPT[:p]}$#{base64url(salt)}$#{base64url(scrypt(password, salt, 32, SCRYPT))}"
78
80
  end
79
81
 
80
82
  # Whether a password matches a hash, scrypt or PBKDF2. A stored key under MIN_KEY_BYTES is refused,
@@ -88,12 +90,15 @@ module Runlight
88
90
  rescue ArgumentError
89
91
  nil
90
92
  end
91
- if parts[0] == "scrypt" && parts.length == 3
92
- expected = decode.call(parts[2])
93
- salt = decode.call(parts[1])
93
+ if parts[0] == "scrypt" && [3, 6].include?(parts.length)
94
+ cost = scrypt_cost(parts)
95
+ return false if cost.nil?
96
+
97
+ expected = decode.call(parts[-1])
98
+ salt = decode.call(parts[-2])
94
99
  return false if expected.nil? || salt.nil? || expected.bytesize < MIN_KEY_BYTES
95
100
 
96
- return same_bytes(scrypt(password, salt, expected.bytesize), expected)
101
+ return same_bytes(scrypt(password, salt, expected.bytesize, cost), expected)
97
102
  end
98
103
  if parts[0] == "pbkdf2" && parts.length == 4
99
104
  rounds = Js.number(parts[1])
@@ -108,8 +113,31 @@ module Runlight
108
113
  false
109
114
  end
110
115
 
111
- def scrypt(password, salt, length)
112
- length.zero? ? "".b : Scrypt.derive(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P, length)
116
+ # Whether a stored hash costs less than a new one would, so the password it matched is hashed again: an
117
+ # scrypt hash with a lower cost, or a PBKDF2 one.
118
+ def weaker_than_now?(stored)
119
+ parts = stored.split("$", -1)
120
+ return true if parts[0] == "pbkdf2"
121
+ return false if parts[0] != "scrypt"
122
+
123
+ cost = scrypt_cost(parts)
124
+ !cost.nil? && cost[:n] * cost[:r] * cost[:p] < SCRYPT[:n] * SCRYPT[:r] * SCRYPT[:p]
125
+ end
126
+
127
+ # A scrypt hash's cost: recorded in it (scrypt$N$r$p$salt$key), or the old setting (scrypt$salt$key).
128
+ # Nil for one out of bounds, so a stored hash cannot ask for a huge amount of memory or time.
129
+ def scrypt_cost(parts)
130
+ return SCRYPT_BEFORE if parts.length == 3
131
+
132
+ n, r, p = parts[1, 3].map { |part| Js.number(part) }
133
+ whole = ->(v, max) { v.is_a?(Integer) && v >= 1 && v <= max }
134
+ return nil unless whole.call(n, 1 << 20) && n >= 2 && (n & (n - 1)).zero? && whole.call(r, 32) && whole.call(p, 16)
135
+
136
+ { n: n, r: r, p: p }
137
+ end
138
+
139
+ def scrypt(password, salt, length, cost)
140
+ length.zero? ? "".b : Scrypt.derive(password, salt, cost[:n], cost[:r], cost[:p], length)
113
141
  end
114
142
 
115
143
  def pbkdf2(password, salt, rounds, length)
@@ -251,7 +279,7 @@ module Runlight
251
279
  base64url(hmac("SHA-256", secret, "#{body}.#{hash}"))
252
280
  end
253
281
 
254
- private_class_method :scrypt, :pbkdf2, :same_bytes, :seal_key, :decode_utf8
282
+ private_class_method :scrypt, :scrypt_cost, :pbkdf2, :same_bytes, :seal_key, :decode_utf8
255
283
  end
256
284
  end
257
285
  end
@@ -54,7 +54,7 @@ module Runlight
54
54
  CSS
55
55
 
56
56
  # Applies the saved theme before the page paints, then runs the switch and Cmd+Shift+D (Ctrl+Shift+D).
57
- AUTH_JS = <<~'JS'.chomp
57
+ AUTH_JS_SOURCE = <<~'JS'.chomp
58
58
  (() => {
59
59
  const COMMON = ["password", "passw0rd", "123456", "qwerty", "azerty", "letmein", "welcome", "admin", "runlight", "iloveyou", "abc123", "monkey", "dragon", "football", "baseball", "sunshine", "princess", "shadow", "master"];
60
60
  function strength(password, avoid = []) {
@@ -157,11 +157,11 @@ module Runlight
157
157
  })();
158
158
  JS
159
159
 
160
- THEME_BUTTON = '<button type="button" class="theme" aria-label="Switch theme"><svg data-choice="light" viewBox="0 0 16 16" aria-hidden="true">' \
161
- '<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/></svg>' \
162
- '<svg data-choice="dark" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5a5.5 5.5 0 1 0 7 7z"/></svg>' \
163
- '<svg data-choice="system" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8.5" rx="1.5"/><path d="M5.5 14h5M8 11.5V14"/></svg></button>'
164
- private_constant :THEME_BUTTON
160
+ THEME_ICONS = '<svg data-choice="light" viewBox="0 0 16 16" aria-hidden="true">' \
161
+ '<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/></svg>' \
162
+ '<svg data-choice="dark" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5a5.5 5.5 0 1 0 7 7z"/></svg>' \
163
+ '<svg data-choice="system" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8.5" rx="1.5"/><path d="M5.5 14h5M8 11.5V14"/></svg>'
164
+ private_constant :THEME_ICONS, :AUTH_JS_SOURCE
165
165
 
166
166
  ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.freeze
167
167
  private_constant :ESCAPES
@@ -177,11 +177,31 @@ module Runlight
177
177
  Version.build["icon"].to_s
178
178
  end
179
179
 
180
- def page(base, title, body)
180
+ # The script, with its words in a language the dashboard speaks. English is served as auth.js, the others as
181
+ # auth.js?lang=fr and so on.
182
+ def auth_js(lang = "en")
183
+ translator = Messages.translator(lang)
184
+ t = translator["t"]
185
+ q = ->(key) { Json.encode(t.call(key)) }
186
+ theme = translator["lang"] == "en" ? '"Theme: " + c' : "c === \"dark\" ? #{q.call("auth.themeDark")} : #{q.call("auth.themeLight")}"
187
+ strength = (0..4).map { |n| q.call("account.strength#{n}") }.join(", ")
188
+ AUTH_JS_SOURCE
189
+ .sub('button.title = c === "system" ? "Theme: device setting" : "Theme: " + c;',
190
+ "button.title = c === \"system\" ? #{q.call("auth.themeSystem")} : #{theme};")
191
+ .sub('eye.setAttribute("aria-label", shown ? "Hide password" : "Show password");',
192
+ "eye.setAttribute(\"aria-label\", shown ? #{q.call("auth.hidePassword")} : #{q.call("auth.showPassword")});")
193
+ .sub('eye.title = shown ? "Hide password" : "Show password";',
194
+ "eye.title = shown ? #{q.call("auth.hidePassword")} : #{q.call("auth.showPassword")};")
195
+ .sub('["Too short", "Weak", "Fair", "Good", "Strong"][score]', "[#{strength}][score]")
196
+ end
197
+
198
+ def page(base, lang, title, body)
181
199
  b = esc(base)
200
+ translator = Messages.translator(lang)
201
+ code = translator["lang"]
182
202
  <<~HTML
183
203
  <!doctype html>
184
- <html lang="en">
204
+ <html lang="#{code}">
185
205
  <head>
186
206
  <meta charset="utf-8">
187
207
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -189,14 +209,14 @@ module Runlight
189
209
  <title>#{esc(title)} | Runlight</title>
190
210
  <link rel="icon" href="#{icon}">
191
211
  <link rel="stylesheet" href="#{b}/auth.css">
192
- <script src="#{b}/auth.js"></script>
212
+ <script src="#{b}/auth.js#{code == "en" ? "" : "?lang=#{code}"}"></script>
193
213
  </head>
194
214
  <body>
195
215
  <main>
196
216
  <p class="brand">#{MARK}Runlight</p>
197
217
  #{body}
198
218
  </main>
199
- #{THEME_BUTTON}
219
+ <button type="button" class="theme" aria-label="#{esc(translator["t"].call("auth.switchTheme"))}">#{THEME_ICONS}</button>
200
220
  </body>
201
221
  </html>
202
222
  HTML
@@ -207,115 +227,134 @@ module Runlight
207
227
  end
208
228
 
209
229
  # opts: "error", "email", "next" (all optional), and "forgot".
210
- def login_page(base, opts)
230
+ def login_page(base, opts, lang = "en")
211
231
  b = esc(base)
212
- page(base, "Sign in", <<~HTML.chomp)
213
- <h1>Sign in</h1>
214
- <p>Sign in to see your sites.</p>
232
+ translator = Messages.translator(lang)
233
+ t = translator["t"]
234
+ h = translator["html"]
235
+ link = "<a href=\"#{esc(opts["forgot"])}\" target=\"_blank\" rel=\"noopener\">#{h.call("auth.forgotLink")}</a>"
236
+ page(base, lang, t.call("auth.signIn"), <<~HTML.chomp)
237
+ <h1>#{h.call("auth.signIn")}</h1>
238
+ <p>#{h.call("auth.signInIntro")}</p>
215
239
  #{error(opts["error"])}
216
240
  <form method="post" action="#{b}/login">
217
241
  <input type="hidden" name="next" value="#{esc(opts["next"] || "#{base}/")}">
218
- <label>Email<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
219
- <label>Password<input type="password" name="password" autocomplete="current-password" required></label>
220
- <button type="submit">Sign in</button>
242
+ <label>#{h.call("auth.email")}<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
243
+ <label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="current-password" required></label>
244
+ <button type="submit">#{h.call("auth.signIn")}</button>
221
245
  </form>
222
- <p class="hint">If you have forgotten your password, <a href="#{esc(opts["forgot"])}" target="_blank" rel="noopener">the docs say how to set a new one</a>.</p>
246
+ <p class="hint">#{h.call("auth.forgot", { "link" => link })}</p>
223
247
  HTML
224
248
  end
225
249
 
226
250
  # The second step of signing in, for an account with two-factor on. opts: "pending", "next", and "error".
227
- def code_page(base, opts)
251
+ def code_page(base, opts, lang = "en")
228
252
  b = esc(base)
229
- page(base, "Enter your code", <<~HTML.chomp)
230
- <h1>Enter your code</h1>
231
- <p>Open your authenticator app and enter the six-digit code for Runlight.</p>
253
+ translator = Messages.translator(lang)
254
+ h = translator["html"]
255
+ page(base, lang, translator["t"].call("auth.codeTitle"), <<~HTML.chomp)
256
+ <h1>#{h.call("auth.codeTitle")}</h1>
257
+ <p>#{h.call("auth.codeIntro")}</p>
232
258
  #{error(opts["error"])}
233
259
  <form method="post" action="#{b}/login/code">
234
260
  <input type="hidden" name="pending" value="#{esc(opts["pending"])}">
235
261
  <input type="hidden" name="next" value="#{esc(opts["next"])}">
236
- <label>Code<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="12" required autofocus></label>
237
- <button type="submit">Sign in</button>
262
+ <label>#{h.call("auth.code")}<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="12" required autofocus></label>
263
+ <button type="submit">#{h.call("auth.signIn")}</button>
238
264
  </form>
239
- <p class="hint">Lost your phone? Enter one of your recovery codes instead. Each works once.</p>
265
+ <p class="hint">#{h.call("auth.codeHint")}</p>
240
266
  HTML
241
267
  end
242
268
 
243
269
  # What someone invited with a role can do, after "as".
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?
246
- return "a viewer, who can read every site's stats" if role == "viewer"
247
- return "a member, who can change the settings of every site" if role == "member"
270
+ def role_text(role, site = nil, lang = "en")
271
+ t = Messages.translator(lang)["t"]
272
+ return t.call(role == "viewer" ? "auth.roleViewerSite" : "auth.roleMemberSite", { "site" => site }) unless site.nil?
273
+ return t.call("auth.roleViewer") if role == "viewer"
274
+ return t.call("auth.roleMember") if role == "member"
248
275
 
249
- "an admin, who can change everything and manage people"
276
+ t.call("auth.roleAdmin")
250
277
  end
251
278
 
252
279
  # Where an invited person chooses a password and joins. opts: "code", "email", "role", "host", and "error".
253
- def invite_page(base, opts)
280
+ def invite_page(base, opts, lang = "en")
254
281
  b = esc(base)
255
- page(base, "Join Runlight", <<~HTML.chomp)
256
- <h1>Join Runlight</h1>
257
- <p>You were invited to #{esc(opts["host"])} as #{esc(role_text(opts["role"], opts["site"]))}. Choose a password to finish.</p>
282
+ translator = Messages.translator(lang)
283
+ h = translator["html"]
284
+ page(base, lang, translator["t"].call("auth.joinTitle"), <<~HTML.chomp)
285
+ <h1>#{h.call("auth.joinTitle")}</h1>
286
+ <p>#{h.call("auth.joinIntro", { "host" => esc(opts["host"]), "role" => esc(role_text(opts["role"], opts["site"], lang)) })}</p>
258
287
  #{error(opts["error"])}
259
288
  <form method="post" action="#{b}/invite">
260
289
  <input type="hidden" name="code" value="#{esc(opts["code"])}">
261
- <label>Email<input type="email" value="#{esc(opts["email"])}" disabled></label>
262
- <label>Password<input type="password" name="password" autocomplete="new-password" minlength="10" required autofocus></label>
263
- <label>Password again<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
264
- <button type="submit">Join</button>
290
+ <label>#{h.call("auth.email")}<input type="email" value="#{esc(opts["email"])}" disabled></label>
291
+ <label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="new-password" minlength="10" required autofocus></label>
292
+ <label>#{h.call("auth.passwordAgain")}<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
293
+ <button type="submit">#{h.call("auth.join")}</button>
265
294
  </form>
266
- <p class="hint">Use at least ten characters.</p>
295
+ <p class="hint">#{h.call("auth.passwordHint")}</p>
267
296
  HTML
268
297
  end
269
298
 
270
- def invite_gone_page(base)
271
- page(base, "This invite no longer works", <<~HTML.chomp)
272
- <h1>This invite no longer works</h1>
273
- <p>It has expired or was already used. Ask whoever invited you to send a new one.</p>
299
+ def invite_gone_page(base, lang = "en")
300
+ translator = Messages.translator(lang)
301
+ h = translator["html"]
302
+ page(base, lang, translator["t"].call("auth.inviteGoneTitle"), <<~HTML.chomp)
303
+ <h1>#{h.call("auth.inviteGoneTitle")}</h1>
304
+ <p>#{h.call("auth.inviteGone")}</p>
274
305
  HTML
275
306
  end
276
307
 
277
308
  # opts: "code", and optionally "error", "email", and "askCode".
278
- def setup_page(base, opts)
309
+ def setup_page(base, opts, lang = "en")
279
310
  b = esc(base)
311
+ translator = Messages.translator(lang)
312
+ h = translator["html"]
280
313
  code = esc(opts["code"])
281
314
  field = if Js.truthy?(opts["askCode"])
282
- "<label>Your RUNLIGHT_TOKEN<input type=\"password\" name=\"code\" autocomplete=\"off\" required value=\"#{code}\"></label>"
315
+ "<label>#{h.call("auth.setupToken")}<input type=\"password\" name=\"code\" autocomplete=\"off\" required value=\"#{code}\"></label>"
283
316
  else
284
317
  "<input type=\"hidden\" name=\"code\" value=\"#{code}\">"
285
318
  end
286
- page(base, "Create your account", <<~HTML.chomp)
287
- <h1>Create your account</h1>
288
- <p>This account owns the dashboard. You can invite more people later in Settings, Team Members.</p>
319
+ page(base, lang, translator["t"].call("auth.setupTitle"), <<~HTML.chomp)
320
+ <h1>#{h.call("auth.setupTitle")}</h1>
321
+ <p>#{h.call("auth.setupIntro")}</p>
289
322
  #{error(opts["error"])}
290
323
  <form method="post" action="#{b}/setup">
291
324
  #{field}
292
- <label>Email<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
293
- <label>Password<input type="password" name="password" autocomplete="new-password" minlength="10" required></label>
294
- <label>Password again<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
295
- <button type="submit">Create account</button>
325
+ <label>#{h.call("auth.email")}<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
326
+ <label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="new-password" minlength="10" required></label>
327
+ <label>#{h.call("auth.passwordAgain")}<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
328
+ <button type="submit">#{h.call("auth.createAccount")}</button>
296
329
  </form>
297
- <p class="hint">Use at least ten characters.</p>
330
+ <p class="hint">#{h.call("auth.passwordHint")}</p>
298
331
  HTML
299
332
  end
300
333
 
301
- # For a server with no account yet, which prints a setup link with a one-time code in its log.
302
- def setup_locked_page(base, where = nil)
303
- where ||= "printed in the server's log when it started"
304
- page(base, "Finish setting up", <<~HTML.chomp)
305
- <h1>Finish setting up</h1>
306
- <p>Runlight has no account yet. Open the setup link #{where}, which carries a one-time code, to create the first account.</p>
334
+ # For a server with no account yet, which prints a setup link with a one-time code in its log. `file` says the
335
+ # standalone server keeps it in setup.txt in its data folder instead.
336
+ def setup_locked_page(base, file = false, lang = "en")
337
+ translator = Messages.translator(lang)
338
+ h = translator["html"]
339
+ text = file ? h.call("auth.setupLockedFile", { "command" => "<code>runlight setup</code>" }) : h.call("auth.setupLocked")
340
+ page(base, lang, translator["t"].call("auth.finishTitle"), <<~HTML.chomp)
341
+ <h1>#{h.call("auth.finishTitle")}</h1>
342
+ <p>#{text}</p>
307
343
  HTML
308
344
  end
309
345
 
310
346
  # For an app with accounts on but nothing to prove the first account with.
311
- def setup_needs_token_page(base)
312
- page(base, "Finish setting up", <<~HTML.chomp)
313
- <h1>Finish setting up</h1>
314
- <p>Runlight has no account yet. Set RUNLIGHT_TOKEN, or pass token to routes(), and open this page again. Creating the first account asks for it, so only whoever runs the app can.</p>
347
+ def setup_needs_token_page(base, lang = "en")
348
+ translator = Messages.translator(lang)
349
+ h = translator["html"]
350
+ page(base, lang, translator["t"].call("auth.finishTitle"), <<~HTML.chomp)
351
+ <h1>#{h.call("auth.finishTitle")}</h1>
352
+ <p>#{h.call("auth.setupNeedsToken")}</p>
315
353
  HTML
316
354
  end
317
355
 
318
356
  private_class_method :esc, :icon, :page, :error
357
+ AUTH_JS = auth_js("en")
319
358
  end
320
359
  end
321
360
  end