shugoi 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7493ead28b354d7ba84019aaddae0419bf0481c4c12ac492218be2eed29d1fd
4
+ data.tar.gz: ea6965023bf27f8a66a039c794ab8137307b4662b77615ea1622f6b5d7548ee4
5
+ SHA512:
6
+ metadata.gz: 59e8d7d5443fe2f6417753959365fc31650bc984c438905be06714cf491ab05f32c2fbc4b02cca82a83dabdfab32636bc976a3847781299f55c9eccb45d73df1
7
+ data.tar.gz: a8caed7dff122fe4da8909e19701447140f543659b0721b3325c869643657a667943159157a13bfc8a8cc3fdbbdb5fd33ba890e7f12bbe541aced4c3c1499712
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shugoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # Shugoi Ruby (Rails)
2
+
3
+ Protection anti-abus Shugoi pour Ruby on Rails — **parité complète avec le module Node `shugoi`**.
4
+
5
+ Bloque les bots, scrapers et clients headless avec :
6
+ - **Pre-flight PoW challenge** (307 + tableau ASCII pour curl/view-source, JS de résolution inline pour les navigateurs)
7
+ - **Machine fingerprinting** (guard-detect) + whitelist
8
+ - **Render-grant HMAC** (lié au siteKey + token + IP + TTL) — anti-bypass "token-only"
9
+ - **Split-render** : skeleton unicode injecté dans la page, remplacé par le contenu réel après vérification
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem "shugoi"
16
+ ```
17
+
18
+ ## Configuration (Rails)
19
+
20
+ ```ruby
21
+ # config/initializers/shugoi.rb
22
+ Shugoi.configure do |config|
23
+ config.site_key = "sg_sk_live_…"
24
+ config.secret = "…" # secret du site (signature des tokens)
25
+ config.debug = Rails.env.development?
26
+ # config.base_url = "https://shugoi.com/api/v1"
27
+ # config.allowlist = ["/api", "/legal"]
28
+ end
29
+ ```
30
+
31
+ Le railtie monte automatiquement le middleware sur l'app Rails.
32
+
33
+ ## Usage Rack pur
34
+
35
+ ```ruby
36
+ # config.ru
37
+ require "shugoi"
38
+ use Shugoi, site_key: "sg_sk_live_…", signing_secret: "…"
39
+ run MyApp
40
+ ```
41
+
42
+ ## Options
43
+
44
+ | Option | Défaut | Description |
45
+ |---|---|---|
46
+ | `site_key` | — | Votre siteKey Shugoi (requis) |
47
+ | `secret` | — | Secret du site |
48
+ | `signing_secret` | `secret` | Secret HMAC de signature |
49
+ | `base_url` | `https://shugoi.com/api/v1` | API Shugoi |
50
+ | `allowlist` | `["/api", "/legal"]` | Chemins sans protection |
51
+ | `auto_inject` | `true` | Injecte les guards dans le HTML |
52
+ | `split_render` | `true` | Skeleton + render |
53
+ | `restricted_access` | `false` | Page d'accès restreint |
54
+ | `pow_difficulty` | `10` | Bits du proof-of-work |
55
+ | `debug` | `false` | Logs console |
56
+
57
+ ## Tests
58
+
59
+ ```bash
60
+ bundle install
61
+ bundle exec rspec
62
+ ```
63
+
64
+ ## Parité avec le module Node
65
+
66
+ Le flux réplique exactement `shugoi` 0.3.6 (npm) :
67
+ - `core.evaluate` → challenge 307 / `/__sg_challenge` / blocage headless
68
+ - `injectGuardScripts` → token signé + skeleton unicode
69
+ - `renderResponseData` → vérification grant + token, service du HTML stocké
70
+ - `verifyRenderGrant` → format `base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")`
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Shugoi
8
+ # Client HTTP vers l'API Shugoi (parité avec fetch dans le module Node).
9
+ class ApiClient
10
+ TIMEOUT = 5.0
11
+
12
+ def initialize(base_url, debug: false)
13
+ @base_url = base_url.to_s
14
+ @debug = debug
15
+ end
16
+
17
+ # GET /whitelist?key=... → { whitelistedMachines, detectionFlags, skipPaths }
18
+ def fetch_whitelist(site_key)
19
+ json = get("/whitelist", key: site_key)
20
+ {
21
+ whitelist: json["whitelistedMachines"] || [],
22
+ flags: json["detectionFlags"] || json["flags"] || {},
23
+ skip_paths: json["skipPaths"] || []
24
+ }
25
+ rescue StandardError
26
+ { whitelist: [], flags: {}, skip_paths: [] }
27
+ end
28
+
29
+ # GET /guard-detect?key=...&raw=1&cb=...&sig=... → texte du guard
30
+ def fetch_guard_detect(site_key, secret = nil)
31
+ cb = Utils.now_ms
32
+ query = { key: site_key, raw: 1, cb: cb.to_s }
33
+ query[:sig] = Utils.hmac_hex(secret, cb.to_s) if secret && !secret.empty?
34
+ body = get_raw("/guard-detect", query)
35
+ body
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # POST /event — enregistrement d'un événement (block, headless…)
41
+ def post_event(site_key, reason, machine_id = "")
42
+ payload = { siteKey: site_key, reason: reason, machineId: machine_id }
43
+ post_json("/event", payload)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ # POST /validate-key — valide la clé
49
+ def validate_key(site_key, secret)
50
+ payload = { siteKey: site_key, secret: secret }
51
+ json = post_json("/validate-key", payload)
52
+ json
53
+ rescue StandardError
54
+ { "valid" => false, "reason" => "network" }
55
+ end
56
+
57
+ private
58
+
59
+ def get(path, params)
60
+ uri = build_uri(path, params)
61
+ log("GET #{uri}")
62
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
63
+ http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
64
+ end
65
+ JSON.parse(res.body)
66
+ end
67
+
68
+ def get_raw(path, params)
69
+ uri = build_uri(path, params)
70
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
71
+ http.get(uri.request_uri, "User-Agent" => "shugoi-ruby/#{VERSION}")
72
+ end
73
+ res.body.to_s
74
+ end
75
+
76
+ def post_json(path, payload)
77
+ uri = build_uri(path)
78
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "User-Agent" => "shugoi-ruby/#{VERSION}")
79
+ req.body = JSON.generate(payload)
80
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
81
+ http.request(req)
82
+ end
83
+ JSON.parse(res.body)
84
+ end
85
+
86
+ def build_uri(path, params = {})
87
+ uri = URI.join(@base_url, path)
88
+ uri.query = URI.encode_www_form(params) unless params.empty?
89
+ uri
90
+ end
91
+
92
+ def log(msg)
93
+ warn("[shugoi] #{msg}") if @debug
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Options du middleware — parité avec ShugoiCoreOptions (module Node).
5
+ class Config
6
+ DEFAULTS = {
7
+ site_key: nil,
8
+ secret: nil,
9
+ signing_secret: nil,
10
+ allowlist: ["/api", "/legal"],
11
+ headless_patterns: Shugoi::DEFAULT_HEADLESS_PATTERNS,
12
+ bot_whitelist: Shugoi::DEFAULT_BOT_WHITELIST,
13
+ base_url: "https://shugoi.com/api/v1",
14
+ internal_url: nil,
15
+ debug: false,
16
+ auto_inject: true,
17
+ restricted_access: false,
18
+ extra_directives: {},
19
+ csp: true,
20
+ block_status: 403,
21
+ locale: nil,
22
+ block_page: nil,
23
+ split_render: true,
24
+ multi_process: false,
25
+ verify_bots: true,
26
+ pow_difficulty: 10,
27
+ pow_ttl_ms: 120_000
28
+ }.freeze
29
+
30
+ attr_reader :options
31
+
32
+ def initialize(options = {})
33
+ @options = DEFAULTS.merge(symbolize(options))
34
+ raise ConfigError, "site_key is required" if @options[:site_key].nil?
35
+ end
36
+
37
+ def site_key = @options[:site_key]
38
+ def secret = @options[:secret]
39
+ def signing_secret = @options[:signing_secret] || @options[:secret]
40
+ def allowlist = @options[:allowlist]
41
+ def headless_patterns = @options[:headless_patterns]
42
+ def bot_whitelist = @options[:bot_whitelist]
43
+ def base_url = @options[:base_url]
44
+ def internal_url = @options[:internal_url] || @options[:base_url]
45
+ def debug = @options[:debug]
46
+ def auto_inject = @options[:auto_inject]
47
+ def restricted_access = @options[:restricted_access]
48
+ def extra_directives = @options[:extra_directives]
49
+ def csp_enabled = @options[:csp]
50
+ def block_status = @options[:block_status]
51
+ def locale = @options[:locale]
52
+ def block_page = @options[:block_page]
53
+ def split_render = @options[:split_render]
54
+ def multi_process = @options[:multi_process]
55
+ def verify_bots = @options[:verify_bots]
56
+ def pow_difficulty = @options[:pow_difficulty]
57
+ def pow_ttl_ms = @options[:pow_ttl_ms]
58
+
59
+ def is_allowlisted?(path)
60
+ allowlist.any? { |p| path == p || path.start_with?("#{p}/") }
61
+ end
62
+
63
+ def is_whitelisted_bot?(ua)
64
+ bot_whitelist.any? { |p| p.match?(ua) }
65
+ end
66
+
67
+ def is_headless?(ua)
68
+ headless_patterns.any? { |p| p.match?(ua) }
69
+ end
70
+
71
+ private
72
+
73
+ def symbolize(h)
74
+ h.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Cache mémoire de la config (whitelist + flags + skipPaths).
5
+ # Parité avec _configCache (render.ts) : TTL 30s + stale-refresh.
6
+ class ConfigCache
7
+ TTL_MS = 30_000
8
+ STALE_MAX_MS = 600_000
9
+
10
+ def initialize(api_client)
11
+ @api_client = api_client
12
+ @mutex = Mutex.new
13
+ @whitelist = []
14
+ @flags = {}
15
+ @skip_paths = []
16
+ @fetched_at = 0
17
+ end
18
+
19
+ def fetch(site_key)
20
+ @mutex.synchronize do
21
+ if @fetched_at.zero? || (Utils.now_ms - @fetched_at > STALE_MAX_MS)
22
+ refresh(site_key)
23
+ elsif Utils.now_ms - @fetched_at > TTL_MS
24
+ Thread.new { refresh(site_key) }.abort_on_exception = false
25
+ end
26
+ { whitelist: @whitelist, flags: @flags, skip_paths: @skip_paths }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def refresh(site_key)
33
+ data = @api_client.fetch_whitelist(site_key)
34
+ @whitelist = data[:whitelist]
35
+ @flags = data[:flags]
36
+ @skip_paths = data[:skip_paths]
37
+ @fetched_at = Utils.now_ms
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ BLOCK_PAGE = [
5
+ "+---------------------------------------------+",
6
+ "| BLOCKED BY SHUGOI |",
7
+ "+---------------------------------------------+",
8
+ "| Bots, scrapers and headless clients |",
9
+ "| are blocked by Shugoi protection. |",
10
+ "| |",
11
+ "| Use a standard browser to access |",
12
+ "| this site. |",
13
+ "| |",
14
+ "| - web: https://shugoi.com - |",
15
+ "+---------------------------------------------+"
16
+ ].join("\n") + "\n"
17
+
18
+ DEFAULT_HEADLESS_PATTERNS = [
19
+ /^curl/i, /^wget/i, /^python/i, /^Go-http-client/i, /^Java\//,
20
+ /HTTPie/i, /^node-fetch/i, /axios/i, /^okhttp/i, /^scrapy/i,
21
+ /PowerShell/i, /WinHttp/i
22
+ ].freeze
23
+
24
+ DEFAULT_BOT_WHITELIST = [
25
+ /Googlebot/i, /Bingbot/i, /Slurp/i, /DuckDuckBot/i, /YandexBot/i, /Applebot/i,
26
+ /facebookexternalhit/i, /Twitterbot/i, /LinkedInBot/i, /Discordbot/i, /Slackbot/i,
27
+ /WhatsApp/i, /TelegramBot/i
28
+ ].freeze
29
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Évaluation d'une requête : pre-flight PoW challenge, /__sg_challenge, blocage headless.
5
+ # Parité avec core.ts (module Node).
6
+ class Core
7
+ # Résultat de blocage renvoyé au middleware.
8
+ Decision = Struct.new(:status, :content_type, :body, :headers, keyword_init: true)
9
+
10
+ def initialize(config, pow, api_client)
11
+ @config = config
12
+ @pow = pow
13
+ @api_client = api_client
14
+ @validation_valid = false
15
+ @validation_failed = false
16
+ @validation_warned_at = 0
17
+ end
18
+
19
+ # @param ctx [Hash] { path:, ua:, ip:, host:, accept_language:, sec_fetch_dest:, sec_fetch_mode:, sg_proof:, forwarded_prefix: }
20
+ # @return [Decision, nil] nil = laisser passer
21
+ def evaluate(ctx)
22
+ path = ctx[:path].to_s
23
+ return nil if @config.is_allowlisted?(path)
24
+
25
+ # Route du challenge JS (le navigateur arrive ici après le 307).
26
+ if path == "/__sg_challenge"
27
+ return challenge_page
28
+ end
29
+
30
+ # Pre-flight PoW challenge (anti-curl/view-source).
31
+ return nil if path.include?("/__shugoi/") || path.start_with?("/api/")
32
+ ua = ctx[:ua].to_s
33
+
34
+ if ua.match?(/Mozilla/i) && !@config.signing_secret.to_s.empty?
35
+ proof = ctx[:sg_proof].to_s
36
+ unless @pow.valid?(proof)
37
+ return pow_challenge_307(ctx)
38
+ end
39
+ end
40
+
41
+ # Blocage headless (UA).
42
+ if ua.match?(/Mozilla/i) == false && @config.is_headless?(ua)
43
+ @api_client.post_event(@config.site_key, "headless", "")
44
+ return Decision.new(status: @config.block_status, content_type: "text/plain", body: BLOCK_PAGE, headers: {})
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ # Page challenge (tableau en commentaire + JS PoW inline).
51
+ def challenge_page
52
+ js = <<~JS
53
+ (function(){
54
+ var P=new URLSearchParams(location.search);
55
+ var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'10',10), path=P.get('path')||'/';
56
+ var enc=new TextEncoder();
57
+ function bits(d){var l=0;for(var i=0;i<d.length;i++){var b=parseInt(d[i],16);if(b===0){l+=4;continue}var s=b.toString(2),z=0;while(z<s.length&&s[z]==='0')z++;l+=z;break}return l}
58
+ var n=0;
59
+ function step(){
60
+ crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
61
+ var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join('');
62
+ if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+n.toString(16);location.replace(base+q)}
63
+ else{n++;if(n<300000)step()}
64
+ }).catch(function(){location.reload()});
65
+ }
66
+ step();
67
+ })();
68
+ JS
69
+ html = "<!--\n#{BLOCK_PAGE}-->\n<script>#{js}</script>"
70
+ Decision.new(status: 200, content_type: "text/html", body: html, headers: {})
71
+ end
72
+
73
+ private
74
+
75
+ # 307 vers le challenge : body = tableau ASCII seul (curl le voit tel quel).
76
+ def pow_challenge_307(ctx)
77
+ ts = Utils.now_sec
78
+ salt = Utils.hmac_hex(@config.signing_secret, ts.to_s)
79
+ prefix = ctx[:forwarded_prefix].to_s
80
+ prefix = "" if prefix == "/"
81
+ prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
82
+ path = ctx[:path].to_s
83
+ path = "/#{path}" unless path.start_with?("/")
84
+ chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
85
+ Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => chal_url })
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ class Error < StandardError; end
5
+ class ConfigError < Error; end
6
+ class InvalidSignature < Error; end
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Shugoi
6
+ # Cache mémoire des guards (guard-detect) fetchés depuis l'API.
7
+ # Parité avec _guardCaches (render.ts).
8
+ class GuardCache
9
+ TTL_MS = 300_000
10
+
11
+ def initialize(api_client)
12
+ @api_client = api_client
13
+ @detect = nil
14
+ @fetched_at = 0
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def detect
19
+ @detect
20
+ end
21
+
22
+ def ensure_ready(site_key, secret = nil)
23
+ return if @detect && Utils.now_ms - @fetched_at < TTL_MS
24
+
25
+ @mutex.synchronize do
26
+ return if @detect && Utils.now_ms - @fetched_at < TTL_MS
27
+ code = @api_client.fetch_guard_detect(site_key, secret)
28
+ @detect = code && !code.empty? ? code : 'console.error("Shugoi guard-detect unavailable")'
29
+ @fetched_at = Utils.now_ms
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Stockage mémoire (et disque optionnel) du HTML rendu, lié au token.
5
+ # Parité avec _memoryStore/_siteCache (render.ts).
6
+ class HtmlStore
7
+ TOKEN_TTL_MS = 120_000
8
+ MAX_ENTRIES = 5000
9
+ MAX_TOTAL_BYTES = 64 * 1024 * 1024
10
+ MAX_TOKEN_READS = 1
11
+
12
+ Entry = Struct.new(:html, :expires_at, :reads)
13
+
14
+ def initialize(disk_path: nil)
15
+ @entries = {}
16
+ @site_cache = {}
17
+ @total_bytes = 0
18
+ @disk_path = disk_path
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def store(token, html, site_key)
23
+ @mutex.synchronize do
24
+ evict_expired
25
+ size = html.bytesize
26
+ while (@entries.size >= MAX_ENTRIES || @total_bytes + size > MAX_TOTAL_BYTES) && !@entries.empty?
27
+ drop(@entries.keys.first)
28
+ end
29
+ @entries[token] = Entry.new(html, Utils.now_ms + TOKEN_TTL_MS, 0)
30
+ @site_cache[site_key] = html
31
+ @total_bytes += size
32
+ write_disk(token, html)
33
+ end
34
+ end
35
+
36
+ # @return [String, nil] html si présent et lisible
37
+ def read(token)
38
+ @mutex.synchronize do
39
+ entry = @entries[token]
40
+ return nil if entry.nil?
41
+ return drop_and_nil(token) if Utils.now_ms > entry.expires_at
42
+
43
+ entry.reads += 1
44
+ entry.html
45
+ end
46
+ end
47
+
48
+ def site_html(site_key)
49
+ @site_cache[site_key]
50
+ end
51
+
52
+ def drop(token)
53
+ @mutex.synchronize do
54
+ entry = @entries.delete(token)
55
+ @total_bytes -= entry.html.bytesize if entry
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def evict_expired
62
+ @entries.each_key do |token|
63
+ entry = @entries[token]
64
+ drop(token) if Utils.now_ms > entry.expires_at
65
+ end
66
+ end
67
+
68
+ def drop_and_nil(token)
69
+ drop(token)
70
+ nil
71
+ end
72
+
73
+ def write_disk(token, html)
74
+ return unless @disk_path
75
+ path = File.join(@disk_path, Utils.sha256_hex(token))
76
+ File.write(path, html, mode: "w")
77
+ rescue StandardError
78
+ nil
79
+ end
80
+ end
81
+ end
data/lib/shugoi/pow.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Proof-of-work anti-curl.
5
+ # Parité avec core.ts (module Node) :
6
+ # salt = HMAC(secret, ts)
7
+ # proof = "ts:nonce" où SHA256(salt:nonce) a >= POW_DIFFICULTY bits à zéro en tête.
8
+ class Pow
9
+ def initialize(signing_secret, difficulty = 10, ttl_ms = 120_000)
10
+ @secret = signing_secret.to_s
11
+ @difficulty = difficulty
12
+ @ttl_ms = ttl_ms
13
+ end
14
+
15
+ # Génère le challenge à injecter (window.__sg_pow).
16
+ def challenge
17
+ ts = Utils.now_sec
18
+ { ts: ts, salt: salt(ts), difficulty: @difficulty }
19
+ end
20
+
21
+ # Vérifie un proof "ts:nonce".
22
+ def valid?(proof)
23
+ return false if proof.to_s.empty? || @secret.empty?
24
+ ts_str, solution = proof.to_s.split(":", 2)
25
+ return false if ts_str.nil? || solution.nil?
26
+
27
+ ts = ts_str.to_i
28
+ return false if ts.zero?
29
+ return false if (Utils.now_ms - ts * 1000).abs > @ttl_ms
30
+
31
+ digest = Utils.sha256_hex("#{salt(ts_str)}:#{solution}")
32
+ Utils.leading_zero_bits(digest) >= @difficulty
33
+ end
34
+
35
+ private
36
+
37
+ def salt(ts)
38
+ Utils.hmac_hex(@secret, ts.to_s)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "json"
5
+
6
+ module Shugoi
7
+ module Rack
8
+ # Middleware Rack principal — parité avec createShugoiMiddleware (module Node).
9
+ #
10
+ # use Shugoi::Rack::Middleware, site_key: "sg_sk_live_…", secret: "…"
11
+ #
12
+ # Ou via la gem :
13
+ # use Shugoi, site_key: "…"
14
+ class Middleware
15
+ def initialize(app, options = {})
16
+ @app = app
17
+ @config = Config.new(options)
18
+ @api_client = ApiClient.new(@config.base_url, debug: @config.debug)
19
+ @guard_cache = GuardCache.new(@api_client)
20
+ @config_cache = ConfigCache.new(@api_client)
21
+ @token_signer = TokenSigner.new(@config.signing_secret)
22
+ @html_store = HtmlStore.new(disk_path: options[:disk_path])
23
+ @pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms)
24
+ @skeleton = SkeletonGenerator.new(@config, @guard_cache, @config_cache, @token_signer)
25
+ @render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache)
26
+ @core = Core.new(@config, @pow, @api_client)
27
+ end
28
+
29
+ def call(env)
30
+ path = env["PATH_INFO"].to_s
31
+ query = parse_query(env["QUERY_STRING"].to_s)
32
+
33
+ # Render endpoint.
34
+ if path.end_with?("/__shugoi/render")
35
+ return handle_render(env, query)
36
+ end
37
+
38
+ # CSP.
39
+ headers = {}
40
+ headers["content-security-policy"] = csp_header if @config.csp_enabled
41
+
42
+ ctx = build_ctx(env, query)
43
+ decision = @core.evaluate(ctx)
44
+
45
+ if decision
46
+ h = headers.merge(decision.headers || {})
47
+ h["content-type"] = decision.content_type
48
+ return [decision.status, h, [decision.body]]
49
+ end
50
+
51
+ # SkipPaths (SSR direct) : on laisse l'app servir la page sans challenge ni skeleton.
52
+ if @config.auto_inject && @config.site_key
53
+ cfg = @config_cache.fetch(@config.site_key)
54
+ if cfg[:skip_paths].include?(path)
55
+ return @app.call(env)
56
+ end
57
+ end
58
+
59
+ status, resp_headers, body = @app.call(env)
60
+
61
+ # Rack 3 exige des noms de headers en minuscules → on normalise.
62
+ resp_headers = resp_headers.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v }
63
+
64
+ # L'allowlist skip le split-render aussi (parité Node : `!core.isAllowlisted(path)`).
65
+ return [status, resp_headers, body] unless @config.auto_inject && @config.split_render && !@config.is_allowlisted?(path)
66
+
67
+ html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
68
+ ct = resp_headers["content-type"].to_s
69
+ if html.include?("<html") && (ct.include?("text/html") || ct.empty?)
70
+ begin
71
+ skeleton = inject_guards(html, ctx)
72
+ body = [skeleton]
73
+ rescue StandardError => e
74
+ warn("[shugoi] inject error: #{e.message}") if @config.debug
75
+ end
76
+ end
77
+
78
+ [status, resp_headers, body]
79
+ end
80
+
81
+ private
82
+
83
+ def build_ctx(env, query)
84
+ ua = env["HTTP_USER_AGENT"].to_s
85
+ ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
86
+ ip = env["REMOTE_ADDR"].to_s if ip.empty?
87
+ {
88
+ path: env["PATH_INFO"].to_s,
89
+ ua: ua,
90
+ ip: ip,
91
+ host: env["HTTP_HOST"].to_s,
92
+ accept_language: env["HTTP_ACCEPT_LANGUAGE"],
93
+ sec_fetch_dest: env["HTTP_SEC_FETCH_DEST"],
94
+ sec_fetch_mode: env["HTTP_SEC_FETCH_MODE"],
95
+ sg_proof: query["sg_proof"],
96
+ forwarded_prefix: env["HTTP_X_FORWARDED_PREFIX"]
97
+ }
98
+ end
99
+
100
+ def handle_render(env, query)
101
+ token = query["token"].to_s
102
+ mid = query["mid"].to_s
103
+ grant = query["grant"].to_s
104
+ ip = (env["HTTP_X_FORWARDED_FOR"].to_s.split(",")[0] || "").strip
105
+ ip = env["REMOTE_ADDR"].to_s if ip.empty?
106
+ data = @render.render_data(token, mid, grant, ip)
107
+ body = JSON.generate(data)
108
+ [200, { "content-type" => "application/json", "cache-control" => "no-store" }, [body]]
109
+ end
110
+
111
+ def inject_guards(html, ctx)
112
+ ts = Utils.now_ms
113
+ signed = @token_signer.sign(@config.site_key, ts)
114
+ render_url = "./__shugoi/render"
115
+
116
+ # Injecte window.__sg_disableRestrictedAccess si nécessaire.
117
+ config_script = ""
118
+ unless @config.restricted_access
119
+ config_script = "<script>window.__sg_disableRestrictedAccess=true</script>"
120
+ end
121
+
122
+ injected = html
123
+ if (i = injected.index("</head>"))
124
+ injected = injected[0...i] + config_script + injected[i..]
125
+ elsif (m = injected.match(/<body[^>]*>/))
126
+ at = injected.index(m[0]) + m[0].length
127
+ injected = injected[0...at] + config_script + injected[at..]
128
+ else
129
+ injected = config_script + injected
130
+ end
131
+
132
+ @html_store.store(signed, injected, @config.site_key)
133
+ @skeleton.generate(@config.site_key, signed, @config.base_url, render_url)
134
+ end
135
+
136
+ def csp_header
137
+ base = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://shugoi.com; " \
138
+ "connect-src 'self' https://shugoi.com; style-src 'self' 'unsafe-inline' https://shugoi.com; " \
139
+ "font-src 'self' https://shugoi.com data:; img-src 'self' https://shugoi.com data: blob:; " \
140
+ "frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
141
+ base
142
+ end
143
+
144
+ def parse_query(qs)
145
+ ::Rack::Utils.parse_nested_query(qs)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ module Rails
5
+ # Configuration globale pour Rails (équivalent de ShugoiCoreOptions).
6
+ class Configuration
7
+ attr_accessor :site_key, :secret, :signing_secret, :allowlist,
8
+ :headless_patterns, :bot_whitelist, :base_url, :internal_url,
9
+ :debug, :auto_inject, :restricted_access, :extra_directives,
10
+ :csp, :block_status, :locale, :block_page, :split_render,
11
+ :multi_process, :verify_bots, :pow_difficulty, :pow_ttl_ms
12
+
13
+ def initialize
14
+ @site_key = ENV["SHUGOI_SITE_KEY"]
15
+ @secret = ENV["SHUGOI_SECRET"]
16
+ @signing_secret = ENV["SHUGOI_SIGNING_SECRET"] || ENV["SHUGOKI_SIGNING_SECRET"]
17
+ @allowlist = ["/api", "/legal"]
18
+ @headless_patterns = Shugoi::DEFAULT_HEADLESS_PATTERNS
19
+ @bot_whitelist = Shugoi::DEFAULT_BOT_WHITELIST
20
+ @base_url = ENV["SHUGOI_BASE_URL"] || "https://shugoi.com/api/v1"
21
+ @internal_url = nil
22
+ @debug = false
23
+ @auto_inject = true
24
+ @restricted_access = false
25
+ @extra_directives = {}
26
+ @csp = true
27
+ @block_status = 403
28
+ @locale = nil
29
+ @block_page = nil
30
+ @split_render = true
31
+ @multi_process = false
32
+ @verify_bots = true
33
+ @pow_difficulty = 10
34
+ @pow_ttl_ms = 120_000
35
+ end
36
+
37
+ def to_options
38
+ {
39
+ site_key: @site_key,
40
+ secret: @secret,
41
+ signing_secret: @signing_secret,
42
+ allowlist: @allowlist,
43
+ headless_patterns: @headless_patterns,
44
+ bot_whitelist: @bot_whitelist,
45
+ base_url: @base_url,
46
+ internal_url: @internal_url,
47
+ debug: @debug,
48
+ auto_inject: @auto_inject,
49
+ restricted_access: @restricted_access,
50
+ extra_directives: @extra_directives,
51
+ csp: @csp,
52
+ block_status: @block_status,
53
+ locale: @locale,
54
+ block_page: @block_page,
55
+ split_render: @split_render,
56
+ multi_process: @multi_process,
57
+ verify_bots: @verify_bots,
58
+ pow_difficulty: @pow_difficulty,
59
+ pow_ttl_ms: @pow_ttl_ms
60
+ }.reject { |_, v| v.nil? }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ module Rails
5
+ # Intégration Rails : monte le middleware et expose la configuration.
6
+ #
7
+ # # config/initializers/shugoi.rb
8
+ # Shugoi.configure do |config|
9
+ # config.site_key = "sg_sk_live_…"
10
+ # config.secret = "…"
11
+ # config.debug = Rails.env.development?
12
+ # end
13
+ class Railtie < ::Rails::Railtie
14
+ initializer "shugoi.middleware" do |app|
15
+ app.middleware.use Shugoi::Rack::Middleware, Shugoi.config.to_options
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Traite la requête /__shugoi/render : vérifie token + grant, sert le HTML stocké.
5
+ # Parité avec renderResponseData + handleRender (render.ts).
6
+ class RenderHandler
7
+ def initialize(config, token_signer, html_store, config_cache)
8
+ @config = config
9
+ @token_signer = token_signer
10
+ @html_store = html_store
11
+ @config_cache = config_cache
12
+ end
13
+
14
+ # @param token [String] token render
15
+ # @param mid [String] machineId
16
+ # @param grant [String] render-grant
17
+ # @param ip [String]
18
+ # @return [Hash] { html: … } ou { error: "not_found" }
19
+ def render_data(token, mid, grant, ip)
20
+ return { error: "not_found" } if token.to_s.empty? || token.length < 16 || token.length > 300
21
+
22
+ # Le token doit appartenir à CE siteKey (CRITIQUE 1 §7bis).
23
+ tok_site_key = token.split(":")[0]
24
+ return { error: "not_found" } if tok_site_key != @config.site_key
25
+
26
+ # Expiration du token.
27
+ tok_ts = token.split(":")[1].to_i
28
+ return { error: "not_found" } if !tok_ts.zero? && Utils.now_ms - tok_ts > HtmlStore::TOKEN_TTL_MS
29
+
30
+ # Anti-bypass token-only : grant valide requis.
31
+ return { error: "not_found" } unless @token_signer.verify_render_grant(mid, grant, token, ip, @config.site_key)
32
+
33
+ content_replace_on = content_replace_flag?(token)
34
+ html = @html_store.read(token)
35
+ return { html: html } if html
36
+
37
+ # Fallback content-replace OFF : renvoie le HTML du site.
38
+ unless content_replace_on
39
+ site_html = @html_store.site_html(tok_site_key)
40
+ return { html: site_html } if site_html
41
+ end
42
+
43
+ return { error: "not_found" } unless @token_signer.verify_token(token)
44
+
45
+ { error: "not_found" }
46
+ end
47
+
48
+ private
49
+
50
+ def content_replace_flag?(token)
51
+ site_key = token.split(":")[0]
52
+ flags = @config_cache.fetch(site_key)[:flags]
53
+ flags["enableContentReplacementCheck"] == true
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Génère le skeleton HTML (bootcode unicode) injecté dans la page.
5
+ # Parité avec generateSkeleton (render.ts) — mêmes fragments JS, même encodage unicode.
6
+ class SkeletonGenerator
7
+ def initialize(config, guard_cache, config_cache, token_signer)
8
+ @config = config
9
+ @guard_cache = guard_cache
10
+ @config_cache = config_cache
11
+ @token_signer = token_signer
12
+ end
13
+
14
+ # @return [String] HTML du skeleton (<script>…eval([...])…</script>)
15
+ def generate(site_key, token, base_url, render_url = "./__shugoi/render", locale = "en")
16
+ @guard_cache.ensure_ready(site_key, @config.signing_secret)
17
+ cfg_data = @config_cache.fetch(site_key)
18
+ flags = cfg_data[:flags]
19
+ detect = @guard_cache.detect
20
+
21
+ fragments = []
22
+ fragments << "window.__sg_siteKey=#{json(site_key)}"
23
+ fragments << "window.__sg_baseUrl=#{json(base_url)}"
24
+ fragments << "window.__sg_config=#{json(flags)}"
25
+ fragments << "try{if((location.search||'').indexOf('sg_proof=')>=0){var _qs=location.search.replace(/[?&]sg_proof=[^&]*/,'');var _cu=location.pathname+(_qs?_qs:'')+location.hash;history.replaceState(null,'',_cu)}}catch(e){}"
26
+
27
+ pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms).challenge
28
+ fragments << "window.__sg_pow=#{json(pow)}"
29
+ fragments << "window.__sg_serverTime=#{Utils.now_ms}"
30
+ fragments << "window.__sg_clockts=#{Utils.now_ms}"
31
+ fragments << "window.__sg_disableRestrictedAccess=true" unless @config.restricted_access
32
+ fragments << "try{#{detect}}catch(e){window.__sg_blocked=true}" if detect
33
+
34
+ # __sg_showBlock (page de blocage néobrutaliste)
35
+ fragments << show_block_fragment
36
+
37
+ fragments << "var t=\"#{token}\""
38
+ fragments << "window.__sg_token=\"#{token}\""
39
+ fragments << "var k=\"#{site_key}\""
40
+ fragments << "var b=\"#{base_url}\""
41
+ fragments << "var r=\"#{render_url}\""
42
+
43
+ # rd(p,n) : remplacement du document par le contenu rendu.
44
+ fragments << rd_fragment
45
+
46
+ fragments << "_gw(function(){rd(r+\"?token=\"+t,0);setTimeout(_sgCl,1500)})"
47
+ fragments << cleanup_fragment
48
+
49
+ combined = fragments.join(";")
50
+ enc = Utils.unicode_encode(combined)
51
+ decoded_call = "[...'#{enc}'].map(x=>String.fromCodePoint(x.codePointAt(0)-917504)).join('')"
52
+ "<script>eval(#{decoded_call})</script>"
53
+ end
54
+
55
+ private
56
+
57
+ def json(obj)
58
+ JSON.generate(obj)
59
+ end
60
+
61
+ def show_block_fragment
62
+ css = "body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;align-items:center;justify-content:center;padding:1.2rem}#c{max-width:460px;width:100%;background:#fff;border:4px solid #000;border-radius:28px 6px 32px 10px;box-shadow:12px 12px 0 #000;padding:3rem 2.4rem 2.8rem;text-align:center}#c .bdg{display:inline-block;border:2px solid #000;border-radius:10px 2px 14px 4px;padding:.3rem .9rem;font-size:.6rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#E87090;margin-bottom:1.4rem}#c h2{font-family:'Alex Brush',Georgia,'Times New Roman',serif;font-size:2.2rem;color:#E87090;font-weight:400;margin:0 auto .6rem}#c p.desc{font-size:.9rem;color:#555;line-height:1.8;max-width:380px;margin:0 auto}#c p.ft{font-size:.55rem;color:#E87090;margin-top:1.8rem}"
63
+ "window.__sg_showBlock=function(msg,title,badge){var h=\"<head><meta charset=UTF-8><meta name=viewport content=width=device-width,initial-scale=1><style>#{css}</style></head><body><div id=c><img src=https://shugoi.com/favicon-block.png class=l><img src=https://shugoi.com/brand-block.png class=b><div class=bdg>\"+(badge||\"\")+\"</div><h2>\"+(title||\"\")+\"</h2><p class=desc>\"+(msg||\"\")+\"</p><p class=ft>\"+location.hostname+\" \\u00b7 Shugoi</p></div></body>\";document.documentElement.innerHTML=h}"
64
+ end
65
+
66
+ def rd_fragment
67
+ "var _gw=function(cb){if(window.__sg_guardsReady||window.__sg_blocked)cb();else setTimeout(function(){_gw(cb)},100)};" \
68
+ "function rd(p,n){if(window.__sg_blocked)return;if(!document.body)return setTimeout(function(){rd(p,n)},50);" \
69
+ "if(n>6){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\");return}" \
70
+ "var _g=(window.__sg_grant||\"\");if(_g){p=p+(\"&grant=\"+encodeURIComponent(_g))}" \
71
+ "var _m=(window.__sg_detectMid||window.__sg_mid||\"\");if(_m){p=p+(\"&mid=\"+encodeURIComponent(_m))}" \
72
+ "fetch(p).then(function(x){return x.json()}).then(function(d){if(window.__sg_blocked)return;" \
73
+ "if(!document.body)return setTimeout(function(){rd(p,n+1)},50);" \
74
+ "if(d.html){document.open(\"text/html\");document.write(d.html);document.close();window.scrollTo(0,0)}" \
75
+ "if(d.blocked){window.__sg_showBlock&&window.__sg_showBlock(d.message,d.title)}" \
76
+ "if(d.error){if((window.__sg_config||{}).enableContentReplacementCheck===true)window.__sg_showBlock&&window.__sg_showBlock(\"\",\"\",\"\")}" \
77
+ "else if(!d.html&&!d.blocked){setTimeout(function(){rd(p,n+1)},300)}})" \
78
+ ".catch(function(){setTimeout(function(){rd(p,n+1)},300)})}"
79
+ end
80
+
81
+ def cleanup_fragment
82
+ "function _sgCl(){try{for(var _i in window){if(_i.indexOf(\"__sg\")===0){window[_i]=null;delete window[_i]}}" \
83
+ "window._sgLogCP=function(){};window.midHex=function(){};window.rd=function(){};window._gw=function(){};" \
84
+ "window.applyDecision=function(){};window._D=function(){};window.z=function(f){return f()}}catch(_e){}}"
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ # Signature des tokens render + vérification des render-grants.
5
+ # Parité exacte avec le module Node :
6
+ # token : siteKey:timestamp:nonce:sig (sig = HMAC(secret, "siteKey:ts:nonce"))
7
+ # grant : base36(ts):HMAC(secret, "render-grant:siteKey:mid:token:ip:ts")
8
+ class TokenSigner
9
+ GRANT_TTL_MS = 120_000
10
+
11
+ def initialize(secret)
12
+ @secret = secret.to_s
13
+ end
14
+
15
+ # @return [String] token signé, ou "" si pas de secret
16
+ def sign(site_key, timestamp = Utils.now_ms)
17
+ return "" if @secret.empty?
18
+ nonce = SecureRandom.hex(8)
19
+ payload = [site_key, timestamp.to_i, nonce].join(":")
20
+ sig = Utils.hmac_hex(@secret, payload)
21
+ "#{payload}:#{sig}"
22
+ end
23
+
24
+ # Vérifie un render-grant émis par le wlc serveur.
25
+ # @param mid [String] machineId (64 hex)
26
+ # @param grant [String] grant brut "base36ts:sig"
27
+ # @param token [String] token render lié
28
+ # @param ip [String] IP du client
29
+ # @param expected_site_key [String] siteKey attendu (celui du middleware)
30
+ def verify_render_grant(mid, grant, token, ip, expected_site_key)
31
+ return true if @secret.empty? # fail-safe : pas de secret → pas de vérification
32
+ return false if grant.to_s.empty? || mid.to_s.empty?
33
+ return false unless mid.to_s.match?(/\A[a-f0-9]{64}\z/)
34
+
35
+ ts_str, sig = grant.to_s.split(":", 2)
36
+ return false if ts_str.nil? || sig.nil?
37
+
38
+ ts = Utils.base36_decode(ts_str)
39
+ return false if ts.zero? && ts_str != "0"
40
+ return false if Utils.now_ms - (ts * 1000) > GRANT_TTL_MS
41
+ return false if expected_site_key.to_s.empty?
42
+
43
+ payload = "render-grant:#{[expected_site_key, mid, token.to_s, ip.to_s, ts_str].join(':')}"
44
+ expected = Utils.hmac_hex(@secret, payload)
45
+ Utils.secure_equals(sig, expected)
46
+ end
47
+
48
+ # Vérifie un token render complet (siteKey:ts:nonce:sig).
49
+ def verify_token(token)
50
+ parts = token.to_s.split(":")
51
+ return false unless parts.length == 4 && parts[3].length == 64
52
+ return false if @secret.empty?
53
+
54
+ site_key, timestamp, nonce, sig = parts
55
+ ts = timestamp.to_i
56
+ return false if ts.zero?
57
+ return false if Utils.now_ms - ts > 120_000 # TOKEN_TTL
58
+
59
+ payload = [site_key, timestamp, nonce].join(":")
60
+ expected = Utils.hmac_hex(@secret, payload)
61
+ Utils.secure_equals(sig, expected)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+ require "json"
6
+
7
+ module Shugoi
8
+ module Utils
9
+ module_function
10
+
11
+ # Comparaison constant-time de deux hex strings.
12
+ def secure_equals(a, b)
13
+ return false unless a.is_a?(String) && b.is_a?(String)
14
+ return false unless a.bytesize == b.bytesize
15
+ OpenSSL.fixed_length_secure_compare(a, b)
16
+ end
17
+
18
+ def hmac_hex(secret, payload)
19
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload.to_s)
20
+ end
21
+
22
+ def sha256_hex(str)
23
+ OpenSSL::Digest::SHA256.hexdigest(str.to_s)
24
+ end
25
+
26
+ def base36_encode(num)
27
+ return "0" if num.zero?
28
+ alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
29
+ out = +""
30
+ n = num
31
+ while n.positive?
32
+ n, r = n.divmod(36)
33
+ out.prepend(alphabet[r])
34
+ end
35
+ out
36
+ end
37
+
38
+ def base36_decode(str)
39
+ str.to_s.to_i(36)
40
+ end
41
+
42
+ # Nombre de bits à zéro en tête du digest hex (parité avec la fonction JS `bits`).
43
+ def leading_zero_bits(hex_digest)
44
+ leading = 0
45
+ hex_digest.each_char do |c|
46
+ nib = c.to_i(16)
47
+ if nib.zero?
48
+ leading += 4
49
+ next
50
+ end
51
+ leading += nib.to_s(2).match(/^0*/)[0].length
52
+ break
53
+ end
54
+ leading
55
+ end
56
+
57
+ def now_ms
58
+ (Time.now.to_f * 1000).to_i
59
+ end
60
+
61
+ def now_sec
62
+ Time.now.to_i
63
+ end
64
+
65
+ # Encodage unicode (parité Node : chaque caractère décalé de +917504).
66
+ def unicode_encode(code)
67
+ code.each_codepoint.map { |cp| (917504 + cp).chr(Encoding::UTF_8) }.join
68
+ end
69
+
70
+ def escape_json_string(s)
71
+ JSON.generate(s.to_s)[1..-2]
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shugoi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/shugoi.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shugoi/version"
4
+ require_relative "shugoi/errors"
5
+ require_relative "shugoi/constants"
6
+ require_relative "shugoi/config"
7
+ require_relative "shugoi/utils"
8
+ require_relative "shugoi/token_signer"
9
+ require_relative "shugoi/pow"
10
+ require_relative "shugoi/api_client"
11
+ require_relative "shugoi/guard_cache"
12
+ require_relative "shugoi/config_cache"
13
+ require_relative "shugoi/html_store"
14
+ require_relative "shugoi/skeleton_generator"
15
+ require_relative "shugoi/render_handler"
16
+ require_relative "shugoi/core"
17
+ require_relative "shugoi/rack/middleware"
18
+ require_relative "shugoi/rails/configuration"
19
+ require_relative "shugoi/rails/railtie" if defined?(::Rails::Railtie)
20
+
21
+ module Shugoi
22
+ def self.new(app, options = {})
23
+ Shugoi::Rack::Middleware.new(app, options)
24
+ end
25
+
26
+ class << self
27
+ def config
28
+ @config ||= Shugoi::Rails::Configuration.new
29
+ end
30
+
31
+ def configure
32
+ yield(config)
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shugoi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shugoi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: concurrent-ruby
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ description: Rack middleware + Rails integration for Shugoi. Blocks bots, scrapers
55
+ and headless clients with a proof-of-work challenge, machine fingerprinting and
56
+ split-render, mirroring the Node.js module.
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - lib/shugoi.rb
64
+ - lib/shugoi/api_client.rb
65
+ - lib/shugoi/config.rb
66
+ - lib/shugoi/config_cache.rb
67
+ - lib/shugoi/constants.rb
68
+ - lib/shugoi/core.rb
69
+ - lib/shugoi/errors.rb
70
+ - lib/shugoi/guard_cache.rb
71
+ - lib/shugoi/html_store.rb
72
+ - lib/shugoi/pow.rb
73
+ - lib/shugoi/rack/middleware.rb
74
+ - lib/shugoi/rails/configuration.rb
75
+ - lib/shugoi/rails/railtie.rb
76
+ - lib/shugoi/render_handler.rb
77
+ - lib/shugoi/skeleton_generator.rb
78
+ - lib/shugoi/token_signer.rb
79
+ - lib/shugoi/utils.rb
80
+ - lib/shugoi/version.rb
81
+ homepage: https://shugoi.com
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ homepage_uri: https://shugoi.com
86
+ source_code_uri: https://github.com/RoxasYTB/shugoi-npm
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.7
102
+ specification_version: 4
103
+ summary: Shugoi anti-abuse protection for Ruby on Rails
104
+ test_files: []