dialecto-rails 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- metadata +119 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module InContext
|
|
3
|
+
# Prepended on the class of the I18n backend (spec-0023 §7.4), outside `Pluralization` and `Fallbacks`, so
|
|
4
|
+
# it sees the final string whichever of them ran. Never raises into the app: any failure returns the
|
|
5
|
+
# unmarked result.
|
|
6
|
+
#
|
|
7
|
+
# In live mode `gate` answers whether this thread is rendering for an editor; while it says no, a lookup
|
|
8
|
+
# takes the plain path and the hook leaves no trace.
|
|
9
|
+
module BackendHook
|
|
10
|
+
PLURAL = :dialecto_rails_plural
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :marking, :logger, :gate
|
|
14
|
+
|
|
15
|
+
def active? = !marking.nil? && (gate.nil? || gate.call)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def translate(locale, key, options = ::I18n::EMPTY_HASH)
|
|
19
|
+
return super unless BackendHook.active?
|
|
20
|
+
|
|
21
|
+
marking = BackendHook.marking
|
|
22
|
+
Thread.current[PLURAL] = nil
|
|
23
|
+
result = super
|
|
24
|
+
begin
|
|
25
|
+
marking.call(locale, key, options, result, Thread.current[PLURAL])
|
|
26
|
+
rescue => error # standard errors only; the hook never breaks a page
|
|
27
|
+
BackendHook.logger&.debug("dialecto-rails: marking skipped (#{error.class}: #{error.message})")
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pluralize(locale, entry, count)
|
|
33
|
+
result = super
|
|
34
|
+
Thread.current[PLURAL] = [entry, result] if entry.is_a?(Hash) && BackendHook.active?
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module InContext
|
|
3
|
+
# In development, lets the overlay's origin through the app's own Content-Security-Policy (spec-0023
|
|
4
|
+
# §7.6): the origin is appended to the directives the policy already defines, or to `default-src` when it
|
|
5
|
+
# defines none of them. Only ever called after the development guard.
|
|
6
|
+
module Csp
|
|
7
|
+
DIRECTIVES = %w[script-src frame-src connect-src img-src style-src font-src].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def allow(policy, origin)
|
|
12
|
+
return unless policy.respond_to?(:directives)
|
|
13
|
+
|
|
14
|
+
directives = policy.directives
|
|
15
|
+
present = DIRECTIVES.select { |name| directives.key?(name) }
|
|
16
|
+
if present.empty?
|
|
17
|
+
add(policy, "default-src", origin) if directives.key?("default-src")
|
|
18
|
+
else
|
|
19
|
+
present.each { |name| add(policy, name, origin) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Live mode: the same idea for one response's header text rather than the app's policy object, and only
|
|
24
|
+
# for frames, since the overlay comes from the site's own origin. `frame-src`, else `child-src`, else
|
|
25
|
+
# `default-src` governs frames; a policy with none of them lets frames through already. The browser
|
|
26
|
+
# enforces every policy, so several (an Array, or values joined by newlines) are widened one by one.
|
|
27
|
+
def widen_frame_src(header, origin)
|
|
28
|
+
return header.map { |policy| widen_frame_src(policy, origin) } if header.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
policies = header.to_s.split("\n")
|
|
31
|
+
return policies.map { |policy| widen_frame_src(policy, origin) }.join("\n") if policies.size > 1
|
|
32
|
+
|
|
33
|
+
directives = header.to_s.split(";").map(&:strip).reject(&:empty?)
|
|
34
|
+
found = %w[frame-src child-src default-src].lazy.filter_map do |name|
|
|
35
|
+
directives.find { |directive| directive.split(/\s+/).first.downcase == name }
|
|
36
|
+
end.first
|
|
37
|
+
return header unless found
|
|
38
|
+
|
|
39
|
+
name, *sources = found.split(/\s+/)
|
|
40
|
+
sources = sources.reject { |source| source.casecmp?("'none'") }
|
|
41
|
+
sources << origin unless sources.include?(origin)
|
|
42
|
+
widened = (["frame-src"] + sources).join(" ")
|
|
43
|
+
return directives.map { |directive| directive.equal?(found) ? widened : directive }.join("; ") if name.casecmp?("frame-src")
|
|
44
|
+
|
|
45
|
+
(directives + [widened]).join("; ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Live mode: the nonce a script needs. A browser takes it from `script-src-elem`, else `script-src`, else
|
|
49
|
+
# `default-src`, and a nonce in `style-src` or elsewhere does not allow a script. `header` is the
|
|
50
|
+
# `content-security-policy` value: one policy, several joined by newlines, or an array of them. The first
|
|
51
|
+
# policy whose governing directive has a nonce wins.
|
|
52
|
+
def script_nonce(header)
|
|
53
|
+
Array(header).flat_map { |value| value.to_s.split("\n") }.each do |policy|
|
|
54
|
+
directives = policy.split(";").map(&:strip).reject(&:empty?)
|
|
55
|
+
governing = %w[script-src-elem script-src default-src].lazy.filter_map do |name|
|
|
56
|
+
directives.find { |directive| directive.split(/\s+/).first.downcase == name }
|
|
57
|
+
end.first
|
|
58
|
+
nonce = governing && governing[/'nonce-([A-Za-z0-9+\/=_-]+)'/, 1]
|
|
59
|
+
return nonce if nonce
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add(policy, name, origin)
|
|
65
|
+
sources = Array(policy.directives[name]).reject { |source| source == "'none'" }
|
|
66
|
+
return if sources.include?(origin)
|
|
67
|
+
|
|
68
|
+
policy.public_send(name.tr("-", "_"), *sources, origin)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "open3"
|
|
3
|
+
require_relative "../version"
|
|
4
|
+
|
|
5
|
+
module DialectoRails
|
|
6
|
+
module InContext
|
|
7
|
+
# The site's context for the sidebar (spec-0016 §4, served at `/__dialecto/context`): `{repo, project,
|
|
8
|
+
# branch, sha, dirty, addon}`, read from git on demand so switching branches needs no restart. A git
|
|
9
|
+
# failure only leaves its field empty. Argument arrays, no shell; each call has a 5 s timeout.
|
|
10
|
+
class GitContext
|
|
11
|
+
SEGMENT = /\A[A-Za-z0-9._-]+\z/
|
|
12
|
+
SHA = /\A[0-9a-f]{40}\z/
|
|
13
|
+
MAX_DIRTY = 200
|
|
14
|
+
TIMEOUT = 5
|
|
15
|
+
CACHE_SECONDS = 1.0
|
|
16
|
+
|
|
17
|
+
def initialize(root:, project: nil, catalogs: "config/locales",
|
|
18
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@root = root.to_s
|
|
20
|
+
@project = project
|
|
21
|
+
@catalogs = catalogs
|
|
22
|
+
@clock = clock
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@cached = nil
|
|
25
|
+
@cached_at = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
now = @clock.call
|
|
31
|
+
return @cached if @cached && now - @cached_at < CACHE_SECONDS
|
|
32
|
+
|
|
33
|
+
@cached_at = now
|
|
34
|
+
@cached = build
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.parse_github_remote(remote)
|
|
39
|
+
remote = remote.to_s.strip
|
|
40
|
+
host, path =
|
|
41
|
+
if remote.include?("://")
|
|
42
|
+
uri = URI.parse(remote) rescue nil
|
|
43
|
+
[uri&.host, uri&.path] if uri && %w[http https ssh git].include?(uri.scheme)
|
|
44
|
+
elsif (match = remote.match(%r{\A[^@\s/:]+@([^:/\s]+):(.+)\z}))
|
|
45
|
+
[match[1], match[2]]
|
|
46
|
+
end
|
|
47
|
+
return nil unless host && path && host.downcase == "github.com"
|
|
48
|
+
|
|
49
|
+
segments = path.delete_prefix("/").chomp("/").delete_suffix(".git").split("/")
|
|
50
|
+
segments.length == 2 && segments.all? { |segment| segment.match?(SEGMENT) } ? segments.join("/") : nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Repo-root-relative paths (sorted, unique, capped) from `git status --porcelain=v1 -z`.
|
|
54
|
+
def self.parse_porcelain_z(output)
|
|
55
|
+
fields = output.to_s.split("\0")
|
|
56
|
+
paths = []
|
|
57
|
+
until fields.empty?
|
|
58
|
+
field = fields.shift
|
|
59
|
+
match = field.match(/\A(.)(.) (.+)\z/m)
|
|
60
|
+
next unless match
|
|
61
|
+
|
|
62
|
+
paths << match[3]
|
|
63
|
+
renamed = "RC".include?(match[1]) || "RC".include?(match[2])
|
|
64
|
+
original = renamed ? fields.shift : nil
|
|
65
|
+
paths << original if original && !original.empty?
|
|
66
|
+
end
|
|
67
|
+
paths.uniq.sort.first(MAX_DIRTY)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build
|
|
73
|
+
branch = git("rev-parse", "--abbrev-ref", "HEAD").to_s.strip
|
|
74
|
+
sha = git("rev-parse", "HEAD").to_s.strip
|
|
75
|
+
{
|
|
76
|
+
repo: self.class.parse_github_remote(git("remote", "get-url", "origin")),
|
|
77
|
+
project: @project,
|
|
78
|
+
branch: ["", "HEAD"].include?(branch) ? nil : branch,
|
|
79
|
+
sha: sha.match?(SHA) ? sha : nil,
|
|
80
|
+
dirty: self.class.parse_porcelain_z(git("status", "--porcelain=v1", "-z", "--untracked-files=all", "--",
|
|
81
|
+
File.expand_path(@catalogs, @root))),
|
|
82
|
+
addon: "rails@#{DialectoRails::VERSION}"
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def git(*args)
|
|
87
|
+
Open3.popen3("git", "-C", @root, *args) do |stdin, stdout, stderr, waiter|
|
|
88
|
+
stdin.close
|
|
89
|
+
stdout.binmode
|
|
90
|
+
reader = Thread.new { stdout.read }
|
|
91
|
+
Thread.new { stderr.read }
|
|
92
|
+
unless waiter.join(TIMEOUT)
|
|
93
|
+
Process.kill("KILL", waiter.pid) rescue nil
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
waiter.value.success? ? reader.value.force_encoding(Encoding::UTF_8) : nil
|
|
97
|
+
end
|
|
98
|
+
rescue SystemCallError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "psych"
|
|
2
|
+
require_relative "../scan/catalogs"
|
|
3
|
+
|
|
4
|
+
module DialectoRails
|
|
5
|
+
module InContext
|
|
6
|
+
# `(locale, key) → domain`, answered from the project's own locale files without running any code. A
|
|
7
|
+
# string whose key isn't here is not marked: that leaves out every gem's locale files, `default:` text and
|
|
8
|
+
# missing translations, none of which Dialecto holds.
|
|
9
|
+
#
|
|
10
|
+
# The domain is the file's path relative to `config/locales/` (spec-0015 §1), the server's bucket. Files
|
|
11
|
+
# are read as a YAML syntax tree (`Psych.parse`, never `load`), so tags and aliases can't execute or expand;
|
|
12
|
+
# a key reached only through a `<<: *alias` merge isn't indexed (the server locks those too).
|
|
13
|
+
class KeyIndex
|
|
14
|
+
RECHECK_MS = 500
|
|
15
|
+
LOCALES_DIR = "config/locales/".freeze
|
|
16
|
+
|
|
17
|
+
def initialize(root:, catalog_paths:, load_path: -> { ::I18n.load_path },
|
|
18
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) })
|
|
19
|
+
@root = real(File.expand_path(root)).chomp("/") + "/"
|
|
20
|
+
@catalogs = Scan::Catalogs.new(catalog_paths)
|
|
21
|
+
@load_path = load_path
|
|
22
|
+
@clock = clock
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@signature = nil
|
|
25
|
+
@checked_at = nil
|
|
26
|
+
@table = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The domain of `key` in `locale`, or nil.
|
|
30
|
+
def domain(locale, key)
|
|
31
|
+
refresh
|
|
32
|
+
@table.dig(locale.to_s, key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def locales
|
|
36
|
+
refresh
|
|
37
|
+
@table.keys
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Catalog files inside the project, in load order (later ones win a duplicate key).
|
|
41
|
+
def files
|
|
42
|
+
@load_path.call.filter_map do |path|
|
|
43
|
+
full = real(File.expand_path(path.to_s))
|
|
44
|
+
next unless full.start_with?(@root) && full.end_with?(".yml", ".yaml")
|
|
45
|
+
|
|
46
|
+
relative = full.delete_prefix(@root)
|
|
47
|
+
relative if @catalogs.match?(relative)
|
|
48
|
+
end.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.domain_for(relative_path)
|
|
52
|
+
at = relative_path.start_with?(LOCALES_DIR) ? 0 : relative_path.index("/" + LOCALES_DIR)
|
|
53
|
+
return relative_path unless at
|
|
54
|
+
|
|
55
|
+
relative_path[(at.zero? ? LOCALES_DIR.length : at + 1 + LOCALES_DIR.length)..]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Symlinks resolved on both sides, so a project reached through one (macOS /var, a linked checkout) still
|
|
61
|
+
# matches the paths Rails puts on the load path.
|
|
62
|
+
def real(path)
|
|
63
|
+
File.realpath(path)
|
|
64
|
+
rescue SystemCallError
|
|
65
|
+
path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def refresh
|
|
69
|
+
now = @clock.call
|
|
70
|
+
return if @checked_at && now - @checked_at < RECHECK_MS
|
|
71
|
+
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@checked_at = now
|
|
74
|
+
paths = files
|
|
75
|
+
signature = paths.map { |path| [path, mtime(path)] }
|
|
76
|
+
next if signature == @signature
|
|
77
|
+
|
|
78
|
+
@signature = signature
|
|
79
|
+
@table = build(paths)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mtime(path)
|
|
84
|
+
File.mtime(File.join(@root, path)).to_f
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build(paths)
|
|
90
|
+
table = Hash.new { |hash, locale| hash[locale] = {} }
|
|
91
|
+
paths.each do |path|
|
|
92
|
+
document = parse(File.join(@root, path))
|
|
93
|
+
root = document&.root
|
|
94
|
+
next unless root.is_a?(Psych::Nodes::Mapping)
|
|
95
|
+
|
|
96
|
+
domain = self.class.domain_for(path)
|
|
97
|
+
pairs(root).each do |locale_node, value|
|
|
98
|
+
next unless locale_node.is_a?(Psych::Nodes::Scalar)
|
|
99
|
+
|
|
100
|
+
walk(value, [], table[locale_node.value], domain)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
table.default_proc = nil
|
|
104
|
+
table
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse(path)
|
|
108
|
+
Psych.parse_file(path)
|
|
109
|
+
rescue Psych::SyntaxError, SystemCallError, EncodingError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pairs(mapping) = mapping.children.each_slice(2).to_a
|
|
114
|
+
|
|
115
|
+
def walk(node, path, table, domain)
|
|
116
|
+
return unless node.is_a?(Psych::Nodes::Mapping)
|
|
117
|
+
|
|
118
|
+
pairs(node).each do |key_node, value|
|
|
119
|
+
next unless key_node.is_a?(Psych::Nodes::Scalar) && key_node.value != "<<"
|
|
120
|
+
|
|
121
|
+
key_path = path + [key_node.value]
|
|
122
|
+
table[key_path.join(".")] = domain
|
|
123
|
+
walk(value, key_path, table, domain)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
5
|
+
module DialectoRails
|
|
6
|
+
module InContext
|
|
7
|
+
# Verifies the launch token Dialecto mints for an editor (spec-0025 §3) with no call to Dialecto:
|
|
8
|
+
#
|
|
9
|
+
# token = base64url(payload) "." base64url(signature)
|
|
10
|
+
# signed = "dialecto-live-edit-v1." base64url(payload)
|
|
11
|
+
#
|
|
12
|
+
# The signature is Ed25519 under the repo's key, and the purpose prefix keeps a signature made for another
|
|
13
|
+
# purpose (a release manifest) from verifying here. Needs the openssl gem 3.0 or later (Ruby 3.2 ships
|
|
14
|
+
# 3.1) on OpenSSL 1.1.1 or later. Pure: the caller passes the key, the site's origin and the time.
|
|
15
|
+
module LaunchToken
|
|
16
|
+
PREFIX = "dialecto-live-edit-v1.".freeze
|
|
17
|
+
BASE64URL = /\A[A-Za-z0-9_-]*\z/
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(:claims, :reason) do
|
|
20
|
+
def ok? = reason.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# `phase` is `:start` when the token first arrives (which also requires `start_by` not to have passed)
|
|
26
|
+
# or `:session` for the cookie on later requests (only `exp` matters). `reason` is one of `:malformed`,
|
|
27
|
+
# `:bad_signature`, `:expired`, `:late`, `:wrong_origin` and `:wrong_repo`.
|
|
28
|
+
#
|
|
29
|
+
# With `repo` ("owner/name") the token's repo claim must match it, ignoring case (`:wrong_repo`): a
|
|
30
|
+
# cheap check against a key pasted from the wrong repo.
|
|
31
|
+
def verify(token, public_key, origin, now, phase = :session, repo: nil)
|
|
32
|
+
return failure(:malformed) unless token.is_a?(String) && public_key.is_a?(String) && public_key.bytesize == 32
|
|
33
|
+
return failure(:malformed) unless origin.is_a?(String) && now.is_a?(Integer)
|
|
34
|
+
|
|
35
|
+
payload_b64, signature_b64, extra = token.split(".", 3)
|
|
36
|
+
return failure(:malformed) if payload_b64.nil? || signature_b64.nil? || extra
|
|
37
|
+
|
|
38
|
+
signature = decode(signature_b64)
|
|
39
|
+
return failure(:malformed) unless signature&.bytesize == 64
|
|
40
|
+
return failure(:bad_signature) unless signed?(payload_b64, signature, public_key)
|
|
41
|
+
|
|
42
|
+
claims = claims(payload_b64)
|
|
43
|
+
return failure(:malformed) unless claims
|
|
44
|
+
|
|
45
|
+
return failure(:expired) if now >= claims["exp"]
|
|
46
|
+
return failure(:late) if phase == :start && now > claims["start_by"]
|
|
47
|
+
return failure(:wrong_origin) unless Config.normalize_origin(claims["origin"]) == origin
|
|
48
|
+
return failure(:wrong_repo) if repo && !(claims["repo"].is_a?(String) && claims["repo"].casecmp?(repo))
|
|
49
|
+
|
|
50
|
+
Result.new(claims, nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def failure(reason) = Result.new(nil, reason)
|
|
54
|
+
|
|
55
|
+
def decode(text)
|
|
56
|
+
return nil unless text.match?(BASE64URL) && text.length % 4 != 1
|
|
57
|
+
|
|
58
|
+
(text.tr("-_", "+/") + "=" * (-text.length % 4)).unpack1("m")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def signed?(payload_b64, signature, public_key)
|
|
62
|
+
OpenSSL::PKey.new_raw_public_key("ED25519", public_key).verify(nil, signature, PREFIX + payload_b64)
|
|
63
|
+
rescue OpenSSL::PKey::PKeyError
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def claims(payload_b64)
|
|
68
|
+
json = decode(payload_b64)
|
|
69
|
+
claims = json && JSON.parse(json.force_encoding(Encoding::UTF_8))
|
|
70
|
+
valid = claims.is_a?(Hash) && claims["v"] == 1 && claims["origin"].is_a?(String) &&
|
|
71
|
+
claims["exp"].is_a?(Integer) && claims["start_by"].is_a?(Integer)
|
|
72
|
+
valid ? claims : nil
|
|
73
|
+
rescue JSON::ParserError, EncodingError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|