File: jar.rb

package info (click to toggle)
ruby-cookiejar 0.3.3-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, bullseye, buster, sid, stretch
  • size: 184 kB
  • ctags: 56
  • sloc: ruby: 1,179; makefile: 2
file content (314 lines) | stat: -rw-r--r-- 10,703 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
require 'cookiejar/cookie'

module CookieJar
  # A cookie store for client side usage.
  # - Enforces cookie validity rules
  # - Returns just the cookies valid for a given URI
  # - Handles expiration of cookies
  # - Allows for persistence of cookie data (with or without session)
  #
  #--
  #
  # Internal format:
  #
  # Internally, the data structure is a set of nested hashes.
  # Domain Level:
  # At the domain level, the hashes are of individual domains,
  # down-cased and without any leading period. For instance, imagine cookies
  # for .foo.com, .bar.com, and .auth.bar.com:
  #
  #   {
  #     "foo.com"      : (host data),
  #     "bar.com"      : (host data),
  #     "auth.bar.com" : (host data)
  #   }
  #
  # Lookups are done both for the matching entry, and for an entry without
  # the first segment up to the dot, ie. for /^\.?[^\.]+\.(.*)$/.
  # A lookup of auth.bar.com would match both bar.com and
  # auth.bar.com, but not entries for com or www.auth.bar.com.
  #
  # Host Level:
  # Entries are in an hash, with keys of the path and values of a hash of
  # cookie names to cookie object
  #
  #   {
  #     "/" : {"session" : (Cookie object), "cart_id" : (Cookie object)}
  #     "/protected" : {"authentication" : (Cookie Object)}
  #   }
  #
  # Paths are given a straight prefix string comparison to match.
  # Further filters <secure, http only, ports> are not represented in this
  # heirarchy.
  #
  # Cookies returned are ordered solely by specificity (length) of the
  # path.
  class Jar
    # Create a new empty Jar
    def initialize
      @domains = {}
    end

    # Given a request URI and a literal Set-Cookie header value, attempt to
    # add the cookie(s) to the cookie store.
    #
    # @param [String, URI] request_uri the resource returning the header
    # @param [String] cookie_header_value the contents of the Set-Cookie
    # @return [Cookie] which was created and stored
    # @raise [InvalidCookieError] if the cookie header did not validate
    def set_cookie(request_uri, cookie_header_values)
      cookie_header_values.split(/, (?=[\w]+=)/).each do |cookie_header_value|
        cookie = Cookie.from_set_cookie request_uri, cookie_header_value
        add_cookie cookie
      end
    end

    # Given a request URI and a literal Set-Cookie2 header value, attempt to
    # add the cookie to the cookie store.
    #
    # @param [String, URI] request_uri the resource returning the header
    # @param [String] cookie_header_value the contents of the Set-Cookie2
    # @return [Cookie] which was created and stored
    # @raise [InvalidCookieError] if the cookie header did not validate
    def set_cookie2(request_uri, cookie_header_value)
      cookie = Cookie.from_set_cookie2 request_uri, cookie_header_value
      add_cookie cookie
    end

    # Given a request URI and some HTTP headers, attempt to add the cookie(s)
    # (from Set-Cookie or Set-Cookie2 headers) to the cookie store. If a
    # cookie is defined (by equivalent name, domain, and path) via Set-Cookie
    # and Set-Cookie2, the Set-Cookie version is ignored.
    #
    # @param [String, URI] request_uri the resource returning the header
    # @param [Hash<String,[String,Array<String>]>] http_headers a Hash
    #   which may have a key of "Set-Cookie" or "Set-Cookie2", and values of
    #   either strings or arrays of strings
    # @return [Array<Cookie>,nil] the cookies created, or nil if none found.
    # @raise [InvalidCookieError] if one of the cookie headers contained
    #   invalid formatting or data
    def set_cookies_from_headers(request_uri, http_headers)
      set_cookie_key = http_headers.keys.detect { |k| /\ASet-Cookie\Z/i.match k }
      cookies = gather_header_values http_headers[set_cookie_key] do |value|
        begin
          Cookie.from_set_cookie request_uri, value
        rescue InvalidCookieError
        end
      end

      set_cookie2_key = http_headers.keys.detect { |k| /\ASet-Cookie2\Z/i.match k }
      cookies += gather_header_values(http_headers[set_cookie2_key]) do |value|
        begin
          Cookie.from_set_cookie2 request_uri, value
        rescue InvalidCookieError
        end
      end

      # build the list of cookies, using a Jar. Since Set-Cookie2 values
      # come second, they will replace the Set-Cookie versions.
      jar = Jar.new
      cookies.each do |cookie|
        jar.add_cookie cookie
      end
      cookies = jar.to_a

      # now add them all to our own store.
      cookies.each do |cookie|
        add_cookie cookie
      end
      cookies
    end

    # Add a pre-existing cookie object to the jar.
    #
    # @param [Cookie] cookie a pre-existing cookie object
    # @return [Cookie] the cookie added to the store
    def add_cookie(cookie)
      domain_paths = find_or_add_domain_for_cookie cookie
      add_cookie_to_path domain_paths, cookie
      cookie
    end

    # Return an array of all cookie objects in the jar
    #
    # @return [Array<Cookie>] all cookies. Includes any expired cookies
    # which have not yet been removed with expire_cookies
    def to_a
      result = []
      @domains.values.each do |paths|
        paths.values.each do |cookies|
          cookies.values.inject result, :<<
        end
      end
      result
    end

    # Return a JSON 'object' for the various data values. Allows for
    # persistence of the cookie information
    #
    # @param [Array] a options controlling output JSON text
    #   (usually a State and a depth)
    # @return [String] JSON representation of object data
    def to_json(*a)
      {
        'json_class' => self.class.name,
        'cookies' => to_a.to_json(*a)
      }.to_json(*a)
    end

    # Create a new Jar from a JSON-backed hash
    #
    # @param o [Hash] the expanded JSON object
    # @return [CookieJar] a new CookieJar instance
    def self.json_create(o)
      o = JSON.parse(o) if o.is_a? String
      o = o['cookies'] if o.is_a? Hash
      cookies = o.inject([]) do |result, cookie_json|
        result << (Cookie.json_create cookie_json)
      end
      from_a cookies
    end

    # Create a new Jar from an array of Cookie objects. Expired cookies
    # will still be added to the archive, and conflicting cookies will
    # be overwritten by the last cookie in the array.
    #
    # @param [Array<Cookie>] cookies array of cookie objects
    # @return [CookieJar] a new CookieJar instance
    def self.from_a(cookies)
      jar = new
      cookies.each do |cookie|
        jar.add_cookie cookie
      end
      jar
    end

    # Look through the jar for any cookies which have passed their expiration
    # date, or session cookies from a previous session
    #
    # @param session [Boolean] whether session cookies should be expired,
    #   or just cookies past their expiration date.
    def expire_cookies(session = false)
      @domains.delete_if do |_domain, paths|
        paths.delete_if do |_path, cookies|
          cookies.delete_if do |_cookie_name, cookie|
            cookie.expired? || (session && cookie.session?)
          end
          cookies.empty?
        end
        paths.empty?
      end
    end

    # Given a request URI, return a sorted list of Cookie objects. Cookies
    # will be in order per RFC 2965 - sorted by longest path length, but
    # otherwise unordered.
    #
    # @param [String, URI] request_uri the address the HTTP request will be
    #   sent to. This must be a full URI, i.e. must include the protocol,
    #   if you pass digi.ninja it will fail to find the domain, you must pass
    #   http://digi.ninja
    # @param [Hash] opts options controlling returned cookies
    # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be
    #   ignored if true
    # @return [Array<Cookie>] cookies which should be sent in the HTTP request
    def get_cookies(request_uri, opts = {})
      uri = to_uri request_uri
      hosts = Cookie.compute_search_domains uri

      return [] if hosts.nil?

      path = if uri.path == ''
               '/'
             else
               uri.path
      end

      results = []
      hosts.each do |host|
        domain = find_domain host
        domain.each do |apath, cookies|
          next unless path.start_with? apath
          results += cookies.values.select do |cookie|
            cookie.should_send? uri, opts[:script]
          end
        end
      end
      # Sort by path length, longest first
      results.sort do |lhs, rhs|
        rhs.path.length <=> lhs.path.length
      end
    end

    # Given a request URI, return a string Cookie header.Cookies will be in
    # order per RFC 2965 - sorted by longest path length, but otherwise
    # unordered.
    #
    # @param [String, URI] request_uri the address the HTTP request will be
    #   sent to
    # @param [Hash] opts options controlling returned cookies
    # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be
    #   ignored if true
    # @return String value of the Cookie header which should be sent on the
    #   HTTP request
    def get_cookie_header(request_uri, opts = {})
      cookies = get_cookies request_uri, opts
      ver = [[], []]
      cookies.each do |cookie|
        ver[cookie.version] << cookie
      end
      if ver[1].empty?
        # can do a netscape-style cookie header, relish the opportunity
        cookies.map(&:to_s).join ';'
      else
        # build a RFC 2965-style cookie header. Split the cookies into
        # version 0 and 1 groups so that we can reuse the '$Version' header
        result = ''
        unless ver[0].empty?
          result << '$Version=0;'
          result << ver[0].map do |cookie|
            (cookie.to_s 1, false)
          end.join(';')
          # separate version 0 and 1 with a comma
          result << ','
        end
        result << '$Version=1;'
        ver[1].map do |cookie|
          result << (cookie.to_s 1, false)
        end
        result
      end
    end

    protected

    def gather_header_values(http_header_value, &_block)
      result = []
      if http_header_value.is_a? Array
        http_header_value.each do |value|
          result << yield(value)
        end
      elsif http_header_value.is_a? String
        result << yield(http_header_value)
      end
      result.compact
    end

    def to_uri(request_uri)
      (request_uri.is_a? URI) ? request_uri : (URI.parse request_uri)
    end

    def find_domain(host)
      @domains[host] || {}
    end

    def find_or_add_domain_for_cookie(cookie)
      @domains[cookie.domain] ||= {}
    end

    def add_cookie_to_path(paths, cookie)
      path_entry = (paths[cookie.path] ||= {})
      path_entry[cookie.name] = cookie
    end
  end
end