ruby-net-text 0.0.1

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 95abe1fa0498d496b10d7517d9dc00fcd958091e93c75cb3f86d3274667bf245
4
+ data.tar.gz: 92855326685c71635f9a3f0c45178112925ecbd2e30027c187d473b3ddc61e07
5
+ SHA512:
6
+ metadata.gz: 7342a84385b98b84f7dc74ad546c78d58f943412da84fb247c3eff4b0affcce5ca9fa446ea527de5f32b983ee86589a57a217ad2debab57c9ede7cda0855d7d8
7
+ data.tar.gz: 17e634cc19ff48ef976db87c85155dc618a37c3e24d8dc2edd443eae647602aa6d7d9880dbb6968f289d4ad2c6a47b990ae7622abacb9d61788b023a960894bc
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Étienne Deparis
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.
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is derived from "net/http.rb".
4
+
5
+ require 'socket'
6
+ require 'openssl'
7
+ require 'fileutils'
8
+
9
+ require 'uri/gemini'
10
+ require_relative 'gemini/response'
11
+
12
+ module Net
13
+ class GeminiError < StandardError; end
14
+
15
+ # == A Gemini client API for Ruby.
16
+ #
17
+ # Net::Gemini provides a rich library which can be used to build
18
+ # Gemini user-agents.
19
+ # @see https://gemini.circumlunar.space/docs/specification.html
20
+ #
21
+ # Net::Gemini is designed to work closely with URI.
22
+ #
23
+ # == Simple Examples
24
+ #
25
+ # All examples assume you have loaded Net::Gemini with:
26
+ #
27
+ # require 'net/gemini'
28
+ #
29
+ # This will also require 'uri' so you don't need to require it
30
+ # separately.
31
+ #
32
+ # The Net::Gemini methods in the following section do not persist
33
+ # connections.
34
+ #
35
+ # === GET by URI
36
+ #
37
+ # uri = URI('gemini://example.com/index.html?count=10')
38
+ # Net::Gemini.get(uri) # => String
39
+ #
40
+ # === GET with Dynamic Parameters
41
+ #
42
+ # uri = URI('gemini://example.com/index.html')
43
+ # params = { :limit => 10, :page => 3 }
44
+ # uri.query = URI.encode_www_form(params)
45
+ #
46
+ # res = Net::Gemini.get_response(uri)
47
+ # puts res.body if res.body_permitted?
48
+ #
49
+ # === Response Data
50
+ #
51
+ # res = Net::Gemini.get_response(URI('gemini://exemple.com/home'))
52
+ #
53
+ # # Status
54
+ # puts res.status # => '20'
55
+ # puts res.meta # => 'text/gemini; charset=UTF-8; lang=en'
56
+ #
57
+ # # Headers
58
+ # puts res.header.inspect
59
+ # # => { status: '20', meta: 'text/gemini; charset=UTF-8',
60
+ # mimetype: 'text/gemini', lang: 'en',
61
+ # charset: 'utf-8', format: nil }
62
+ #
63
+ # The lang, charset and format headers will only be provided in case
64
+ # of `text/*` mimetype, and only if body for 2* status codes.
65
+ #
66
+ # # Body
67
+ # puts res.body if res.body_permitted?
68
+ # puts res.body(flowed: 85)
69
+ #
70
+ # === Following Redirection
71
+ #
72
+ # The {#fetch} method, contrary to the {#request} one will try to
73
+ # automatically resolves redirection, leading you to the final
74
+ # destination.
75
+ #
76
+ # u = URI('gemini://exemple.com/redirect')
77
+ # res = Net::Gemini.start(u.host, u.port) do |g|
78
+ # g.request(u)
79
+ # end
80
+ # puts "#{res.status} - #{res.meta}" # => '30 final/dest'
81
+ # puts res.uri.to_s # => 'gemini://exemple.com/redirect'
82
+ #
83
+ # u = URI('gemini://exemple.com/redirect')
84
+ # res = Net::Gemini.start(u.host, u.port) do |g|
85
+ # g.fetch(u)
86
+ # end
87
+ # puts "#{res.status} - #{res.meta}" # => '20 - text/gemini;'
88
+ # puts res.uri.to_s # => 'gemini://exemple.com/final/dest'
89
+ #
90
+ class Gemini
91
+ attr_writer :certs_path
92
+
93
+ def initialize(host, port)
94
+ @host = host
95
+ @port = port
96
+ @certs_path = '~/.cache/gemini/certs'
97
+ end
98
+
99
+ def request(uri)
100
+ init_sockets
101
+ @ssl_socket.puts "#{uri}\r\n"
102
+ r = GeminiResponse.read_new(@ssl_socket)
103
+ r.uri = uri
104
+ r.reading_body(@ssl_socket)
105
+ ensure
106
+ # Stop remaining connection, even if they should be already cut
107
+ # by the server
108
+ finish
109
+ end
110
+
111
+ def fetch(uri, limit = 5)
112
+ raise GeminiError, 'Too many Gemini redirects' if limit.zero?
113
+ r = request(uri)
114
+ return r unless r.status[0] == '3'
115
+ old_url = uri.to_s
116
+ begin
117
+ new_uri = URI(r.meta)
118
+ uri.merge!(new_uri)
119
+ rescue ArgumentError, URI::InvalidURIError
120
+ return r
121
+ end
122
+ raise GeminiError, "Redirect loop on #{uri}" if uri.to_s == old_url
123
+ warn "Redirect to #{uri}" if $VERBOSE
124
+ fetch(uri, limit - 1)
125
+ end
126
+
127
+ class << self
128
+ def start(host_or_uri, port = nil)
129
+ if host_or_uri.is_a? URI::Gemini
130
+ host = host_or_uri.host
131
+ port = host_or_uri.port
132
+ else
133
+ host = host_or_uri
134
+ end
135
+ gem = new(host, port)
136
+ return yield(gem) if block_given?
137
+ gem
138
+ end
139
+
140
+ def get_response(uri)
141
+ start(uri.host, uri.port) { |gem| gem.fetch(uri) }
142
+ end
143
+
144
+ def get(uri)
145
+ get_response(uri).body
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def ssl_check_existing(new_cert, cert_file)
152
+ raw = File.read cert_file
153
+ saved_one = OpenSSL::X509::Certificate.new raw
154
+ return true if saved_one == new_cert
155
+ # TODO: offer some kind of recuperation
156
+ warn "#{cert_file} does not match the current host cert!"
157
+ false
158
+ end
159
+
160
+ def ssl_verify_cb(cert)
161
+ domain = cert.subject.to_s.sub(/^\/CN=/, '')
162
+ return false if domain != @host
163
+ cert_file = File.expand_path("#{@certs_path}/#{domain}.pem")
164
+ return ssl_check_existing(cert, cert_file) if File.exist?(cert_file)
165
+ FileUtils.mkdir_p(File.expand_path(@certs_path))
166
+ File.open(cert_file, 'wb') { |f| f.print cert.to_pem }
167
+ true
168
+ end
169
+
170
+ def ssl_context
171
+ ssl_context = OpenSSL::SSL::SSLContext.new
172
+ ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
173
+ ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
174
+ ssl_context.verify_hostname = true
175
+ ssl_context.ca_file = '/etc/ssl/certs/ca-certificates.crt'
176
+ ssl_context.verify_callback = lambda do |preverify_ok, store_context|
177
+ return true if preverify_ok
178
+ ssl_verify_cb store_context.current_cert
179
+ end
180
+ ssl_context
181
+ end
182
+
183
+ def init_sockets
184
+ @socket = TCPSocket.new(@host, @port)
185
+ @ssl_socket = OpenSSL::SSL::SSLSocket.new(@socket, ssl_context)
186
+ # Close underlying TCP socket with SSL socket
187
+ @ssl_socket.sync_close = true
188
+ @ssl_socket.hostname = @host # SNI
189
+ @ssl_socket.connect
190
+ end
191
+
192
+ # Closes the SSL and TCP connections.
193
+ def finish
194
+ @ssl_socket.close
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'stringio'
5
+
6
+ require_relative 'gmi_parser'
7
+ require_relative 'reflow_text'
8
+
9
+ module Net
10
+ class GeminiBadResponse < StandardError; end
11
+
12
+ #
13
+ # The syntax of Gemini Responses are defined in the Gemini
14
+ # specification[1], section 3.
15
+ #
16
+ # [1] https://gemini.circumlunar.space/docs/specification.html
17
+ #
18
+ class GeminiResponse
19
+ # The Gemini response <STATUS> string.
20
+ #
21
+ # For example, '20'.
22
+ attr_reader :status
23
+
24
+ # The Gemini response <META> message sent by the server as a string.
25
+ #
26
+ # For example, 'text/gemini'.
27
+ attr_reader :meta
28
+
29
+ # The Gemini response <META> as a qualified Hash.
30
+ attr_reader :header
31
+
32
+ # The Gemini response main content as a string.
33
+ attr_writer :body
34
+
35
+ # The URI related to this response as an URI object.
36
+ attr_accessor :uri
37
+
38
+ # All links found on a Gemini response of MIME text/gemini, as an
39
+ # array.
40
+ attr_reader :links
41
+
42
+ def initialize(status = nil, meta = nil)
43
+ @status = status
44
+ @meta = meta
45
+ @header = parse_meta
46
+ @uri = nil
47
+ @body = nil
48
+ @links = []
49
+ @preformatted_blocks = []
50
+ end
51
+
52
+ def body_permitted?
53
+ @status && @status[0] == '2'
54
+ end
55
+
56
+ def reading_body(sock)
57
+ return self unless body_permitted?
58
+ raw_body = []
59
+ while (line = sock.gets)
60
+ raw_body << line
61
+ end
62
+ @body = encode_body(raw_body.join)
63
+ return self unless @header[:mimetype] == 'text/gemini'
64
+ parse_body
65
+ self
66
+ end
67
+
68
+ def body(flowed: nil)
69
+ return '' if @body.nil? # Maybe not ready?
70
+ return @body if flowed.nil? || @header[:format] == 'fixed'
71
+ reformat_body(flowed)
72
+ end
73
+
74
+ class << self
75
+ def read_new(sock)
76
+ code, msg = read_status_line(sock)
77
+ new(code, msg)
78
+ end
79
+
80
+ private
81
+
82
+ def read_status_line(sock)
83
+ # Read up to 1027 bytes:
84
+ # - 3 bytes for code and space separator
85
+ # - 1024 bytes max for the message
86
+ str = sock.gets($INPUT_RECORD_SEPARATOR, 1027)
87
+ m = /\A([1-6]\d) (.*)\r\n\z/.match(str)
88
+ raise GeminiBadResponse, "wrong status line: #{str.dump}" if m.nil?
89
+ m.captures
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def encode_body(body)
96
+ return body unless @header[:mimetype].start_with?('text/')
97
+ if @header[:charset] && @header[:charset] != 'utf-8'
98
+ # If body use another charset than utf-8, we need first to
99
+ # declare the raw byte string as using this chasret
100
+ body.force_encoding(@header[:charset])
101
+ # Then we can safely try to convert it to utf-8
102
+ return body.encode('utf-8')
103
+ end
104
+ # Just declare that the body uses utf-8
105
+ body.force_encoding('utf-8')
106
+ end
107
+
108
+ include ::Gemini::GmiParser
109
+ include ::Gemini::ReflowText
110
+ end
111
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module URI # :nodoc:
6
+ #
7
+ # The syntax of Finger URIs is defined in the Finger specification,
8
+ # section 2.3.
9
+ #
10
+ # @see https://tools.ietf.org/html/rfc1288#section-2.3
11
+ #
12
+ class Finger < HTTP
13
+ # A Default port of 79 for URI::Finger.
14
+ DEFAULT_PORT = 79
15
+
16
+ # An Array of the available components for URI::Finger.
17
+ COMPONENT = [:scheme, :userinfo, :host, :port].freeze
18
+ end
19
+
20
+ @@schemes['FINGER'] = Finger
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module URI # :nodoc:
6
+ #
7
+ # The syntax of Gemini URIs is defined in the Gemini specification,
8
+ # section 1.2.
9
+ #
10
+ # @see https://gemini.circumlunar.space/docs/specification.html
11
+ #
12
+ class Gemini < HTTP
13
+ # A Default port of 1965 for URI::Gemini.
14
+ DEFAULT_PORT = 1965
15
+
16
+ # An Array of the available components for URI::Gemini.
17
+ COMPONENT = [:scheme, :host, :port,
18
+ :path, :query, :fragment].freeze
19
+ end
20
+
21
+ @@schemes['GEMINI'] = Gemini
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module URI # :nodoc:
6
+ #
7
+ # The syntax of Gopher URIs is defined in the Gopher URI Scheme
8
+ # specification.
9
+ #
10
+ # @see https://www.rfc-editor.org/rfc/rfc4266.html
11
+ #
12
+ class Gopher < HTTP
13
+ # A Default port of 70 for URI::Gopher.
14
+ DEFAULT_PORT = 70
15
+
16
+ # An Array of the available components for URI::Gopher.
17
+ COMPONENT = [:scheme, :host, :port, :path].freeze
18
+ end
19
+
20
+ @@schemes['GOPHER'] = Gopher
21
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-net-text
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Étienne Deparis
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-11-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email: etienne@depar.is
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - lib/net/gemini.rb
21
+ - lib/net/gemini/response.rb
22
+ - lib/uri/finger.rb
23
+ - lib/uri/gemini.rb
24
+ - lib/uri/gopher.rb
25
+ homepage: https://git.umaneti.net/ruby-net-text/
26
+ licenses:
27
+ - MIT
28
+ metadata: {}
29
+ post_install_message:
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '2.7'
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 3.1.4
45
+ signing_key:
46
+ specification_version: 4
47
+ summary: Gemini, Gopher, and Finger support for Net::* and URI::*
48
+ test_files: []