Class: Shugoi::Rack::Middleware

Inherits:
Object
  • Object
show all
Defined in:
lib/shugoi/rack/middleware.rb

Overview

Middleware Rack principal — parité avec createShugoiMiddleware (module Node).

use Shugoi::Rack::Middleware, site_key: "sg_sk_live_…", secret: ""

Ou via la gem :

use Shugoi, site_key: ""

Instance Method Summary collapse

Constructor Details

#initialize(app, options = {}) ⇒ Middleware

Returns a new instance of Middleware.



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/shugoi/rack/middleware.rb', line 15

def initialize(app, options = {})
  @app = app
  @config = Config.new(options)
  @disk_path_option = options[:disk_path]
  @api_client = ApiClient.new(@config.base_url, debug: @config.debug)
  # Config (whitelist + flags + skipPaths) et render : via internalUrl pour éviter
  # le deadlock (appels serveur→serveur qui repasseraient par le middleware public).
  @api_internal = ApiClient.new(@config.internal_url, debug: @config.debug)
  @guard_cache = GuardCache.new(@api_client)
  @config_cache = ConfigCache.new(@api_internal)
  @token_signer = TokenSigner.new(@config.signing_secret)
  # multi_process → stockage disque du HTML (nécessaire en cluster PM2, parité
  # enableDiskStore(true) du module Node).
  @html_store = HtmlStore.new(disk_path: disk_path)
  @pow = Pow.new(@config.signing_secret, @config.pow_difficulty, @config.pow_ttl_ms)
  @skeleton = SkeletonGenerator.new(@config, @guard_cache, @config_cache, @token_signer)
  @render = RenderHandler.new(@config, @token_signer, @html_store, @config_cache, @pow)
  @core = Core.new(@config, @pow, @api_client, @config_cache)
  @csp = Csp.build(site_key: @config.site_key, api_origin: Csp.origin_of(@config.base_url), extra_directives: @config.extra_directives, split_render: @config.split_render)
end

Instance Method Details

#call(env) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/shugoi/rack/middleware.rb', line 36

def call(env)
  path = env["PATH_INFO"].to_s
  query = parse_query(env["QUERY_STRING"].to_s)
  method = env["REQUEST_METHOD"].to_s.upcase

  # Render endpoint — GET/HEAD uniquement (round 13, parité module Node).
  if path.end_with?("/__shugoi/render")
    return method_not_allowed unless %w[GET HEAD].include?(method)
    return handle_render(env, query)
  end

  # Challenge page — GET/HEAD uniquement.
  if path == "/__sg_challenge" && !%w[GET HEAD].include?(method)
    return method_not_allowed
  end

  # SkipPaths (SSR direct) : on laisse l'app servir la page sans challenge ni skeleton.
  # Parité middleware.ts : le check est AVANT core.evaluate (un skipPath hors allowlist
  # ne doit PAS passer par le challenge PoW ni la protection des assets).
  if @config.auto_inject && @config.site_key
    cfg = @config_cache.fetch(@config.site_key)
    if cfg[:skip_paths].include?(path)
      return @app.call(env)
    end
  end

  ctx = build_ctx(env, query)
  decision = @core.evaluate(ctx)

  if decision
    headers = {}
    headers["content-security-policy"] = @csp if @config.csp_enabled
    h = headers.merge(decision.headers || {})
    h["content-type"] = decision.content_type
    return [decision.status, h, [decision.body]]
  end

  status, resp_headers, body = @app.call(env)

  # Rack 3 exige des noms de headers en minuscules → on normalise.
  resp_headers = resp_headers.each_with_object({}) { |(k, v), acc| acc[k.to_s.downcase] = v }

  # CSP fusionnée avec celle éventuellement posée par l'app (parité middleware.ts).
  if @config.csp_enabled
    existing = resp_headers["content-security-policy"]
    resp_headers["content-security-policy"] = Csp.merge(existing, @csp)
  end

  # PoW validé → pose le cookie __sg_ok (navigations suivantes sans challenge).
  if (proof = query["sg_proof"]) && !decision
    if (ok_cookie = @pow.sg_ok_cookie(proof, ctx[:ip].to_s, ctx[:ua].to_s))
      resp_headers["set-cookie"] = ok_cookie
    end
  end

  is_bot = @core.is_trusted_bot?(ctx[:ua].to_s, ctx[:ip].to_s)

  # L'allowlist skip le split-render aussi (parité Node : `!core.isAllowlisted(path)`).
  return [status, resp_headers, body] unless @config.auto_inject && @config.split_render && !is_bot && !@config.is_allowlisted?(path)

  html = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
  ct = resp_headers["content-type"].to_s
  if html.include?("<html") && (ct.include?("text/html") || ct.empty?)
    begin
      skeleton = inject_guards(html, ctx)
      body = [skeleton]
      # CRITIQUE : le bootcode unicode (code points > 917504) s'encode en UTF-8 sur
      # 4 octets commençant par 0xF3. Sans `charset=utf-8`, le navigateur interprète
      # le body en Latin-1 → les octets 0xF3 deviennent 'ó' → le décodage
      # `codePointAt(0)-917504` produit un code point négatif → RangeError.
      # Forcer UTF-8 est indispensable pour que le skeleton se décode correctement.
      resp_headers["content-type"] = "text/html; charset=utf-8"
    rescue StandardError => e
      warn("[shugoi] inject error: #{e.message}") if @config.debug
    end
  end

  [status, resp_headers, body]
end