ruby-net-text 0.0.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 948586ccc73c2f1c60ae689e9f9a04bdfe7598affd2300501f8490a511f582c3
4
- data.tar.gz: 0ae1eb4e3b191ffe5d506f15d981d7cb325479f9cf7fafcc9f7de63e7f13a8fe
3
+ metadata.gz: 2610c775fb8178c5c035a7ffbb9176ba679a15fcd061fed7e192b3d83b609727
4
+ data.tar.gz: 386863cda1e5682d05bc75a52da55ae4d972393db7efa38fed7d259b44f9e717
5
5
  SHA512:
6
- metadata.gz: 5bae93436e2d8b7985fe39e26d3bf067713e4fbf46759bc91b641bd6200e9ea8dab676868c8f89a6e89679986ceb2c96f54c7db591e28494e18d7ac5ee0a5343
7
- data.tar.gz: 55621ad4d9bf7ea94ab1529e85acac2c279d097234eb85156027ef91fcca466bf90ab8d2cb0e3a312032fdb0248d198da5c8f8cdf11e436e2df88e5d7977758f
6
+ metadata.gz: 8c7663972f81f9f93e02bf76bccfaa1b158b4034259617e94856d27fb63aa5368def164135cea3318c355e3e137ae0c6b852b96194c52db9180aecd318a8d3e1
7
+ data.tar.gz: 61bff20043c1866c3075312a87f9a68dc7d7aa7fd869d8962672dc2ce6eeed9ad065d6ca0bc8330bc94a41fa041933a7bf0da20c0871826d805a0febf2d8dcb2
data/README.md CHANGED
@@ -1,19 +1,47 @@
1
- # Gemini, Gopher, and Finger support for Net::* and URI::*
1
+ # Finger, Gemini, Gopher and Nex support for Net::* and URI::*
2
2
 
3
3
  [![Support using Liberapay](https://img.shields.io/badge/Liberapay-Support_me-yellow?logo=liberapay)](https://liberapay.com/milouse/donate)
4
4
  [![Support using Flattr](https://img.shields.io/badge/Flattr-Support_me-brightgreen?logo=flattr)](https://flattr.com/@milouse)
5
5
  [![Support using Paypal](https://img.shields.io/badge/Paypal-Support_me-00457C?logo=paypal&labelColor=lightgray)](https://paypal.me/milouse)
6
6
 
7
7
  [![Gem](https://img.shields.io/gem/v/ruby-net-text)](https://rubygems.org/gems/ruby-net-text)
8
- [![Documentation](https://img.shields.io/badge/Documentation-ruby--net--text-CC342D?logo=rubygems)](https://www.rubydoc.info/gems/ruby-net-text/Net/Gemini)
8
+ [![Documentation](https://img.shields.io/badge/Documentation-ruby--net--text-CC342D?logo=rubygems)](https://www.rubydoc.info/gems/ruby-net-text/)
9
9
 
10
10
  This project aims to add connectors to well known internet text protocols
11
11
  through the standard `Net::*` and `URI::*` ruby module namespaces.
12
12
 
13
+ ## News
14
+
15
+ ### Version 0.0.9 gemini breaking changes
16
+
17
+ This new version changes the Gemini namespace. Everything is now under the
18
+ same `Net::Gemini` namespace. If you just used this gem as per the
19
+ documentation, nothing changes for you. However, if you were using some hidden
20
+ part of the Gemini API, you will probably have to make some changes.
21
+
22
+ Here are all the changes:
23
+
24
+ | Old names | New names |
25
+ |------------------------|--------------------------------------------------------------------------|
26
+ | Net::GeminiRequest | Net::Gemini::Request (still 'net/gemini/request') |
27
+ | Net::GeminiBadRequest | Net::Gemini::BadRequest (require 'net/gemini/error') |
28
+ | Net::GeminiResponse | Net::Gemini::Response (still 'net/gemini/response') |
29
+ | Net::GeminiBadResponse | Net::Gemini::BadResponse (require 'net/gemini/error') |
30
+ | Net::GeminiError | Net::Gemini::Error (require 'net/gemini/error') |
31
+ | Net::Gemini.new | Net::Gemini::Client.new (directly required as part of 'net/gemini') |
32
+ | Gemini::ReflowText | Net::Text::Reflow (no more expected to be included, but directly called) |
33
+ | Gemini::GmiParser | - (directly integrated into Net::Gemini::Response) |
34
+ | Gemini::SSL | - (directly integrated into Net::Gemini::Client) |
35
+
13
36
  ## Documentation
14
37
 
15
38
  The code is self-documented and you can browse it on rubydoc.info:
16
39
 
40
+ ### Finger
41
+
42
+ - [URI::Finger](https://www.rubydoc.info/gems/ruby-net-text/URI/Finger)
43
+ - [Net::Finger](https://www.rubydoc.info/gems/ruby-net-text/Net/Finger)
44
+
17
45
  ### Gemini
18
46
 
19
47
  - [URI::Gemini](https://www.rubydoc.info/gems/ruby-net-text/URI/Gemini)
@@ -24,16 +52,16 @@ The code is self-documented and you can browse it on rubydoc.info:
24
52
  - [URI::Gopher](https://www.rubydoc.info/gems/ruby-net-text/URI/Gopher)
25
53
  - [Net::Gopher](https://www.rubydoc.info/gems/ruby-net-text/Net/Gopher)
26
54
 
27
- ### Finger
55
+ ### Nex
28
56
 
29
- - [URI::Finger](https://www.rubydoc.info/gems/ruby-net-text/URI/Finger)
30
- - [Net::Finger](https://www.rubydoc.info/gems/ruby-net-text/Net/Finger)
57
+ - [URI::Nex](https://www.rubydoc.info/gems/ruby-net-text/URI/Nex)
58
+ - [Net::Nex](https://www.rubydoc.info/gems/ruby-net-text/Net/Nex)
31
59
 
32
60
  ## Helpers
33
61
 
34
62
  This repository also includes 2 little helpers:
35
63
 
36
- - `bin/heraut`: a toy client for Gemini, Gopher and Finger. Give it a URI and
37
- it will output the remote file.
64
+ - `bin/heraut`: a toy client for Finger, Gemini, Gopher and Nex. Give it a URI
65
+ and it will output the remote file.
38
66
  - `bin/test_thread.rb`: a toy performance test script to run against a Gemini
39
67
  server
data/lib/net/finger.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../uri/finger'
4
- require_relative 'generic'
4
+ require_relative 'text/generic'
5
5
 
6
6
  module Net
7
7
  # == A Finger client API for Ruby.
@@ -28,9 +28,7 @@ module Net
28
28
  # uri = URI('finger://skyjake.fi/jaakko')
29
29
  # Net::Finger.get(uri) # => String
30
30
  #
31
- class Finger
32
- extend TextGeneric
33
-
31
+ class Finger < Text::Generic
34
32
  def self.get(string_or_uri)
35
33
  uri = build_uri string_or_uri, URI::Finger
36
34
  request uri, uri.name.to_s
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Gemini
5
+ # Reopen Client class to add specific private method to handle SSL
6
+ # connection
7
+ class Client
8
+ private
9
+
10
+ def ssl_check_existing(new_cert, cert_file)
11
+ raw = File.read cert_file
12
+ saved_one = OpenSSL::X509::Certificate.new raw
13
+ return true if saved_one == new_cert
14
+
15
+ # TODO: offer some kind of recuperation
16
+ warn "#{cert_file} does not match the current host cert!"
17
+ false
18
+ end
19
+
20
+ def ssl_verify_cb(cert)
21
+ identity_check = OpenSSL::SSL.verify_certificate_identity(cert, @host)
22
+ return false unless identity_check
23
+
24
+ cert_file = File.expand_path("#{@certs_path}/#{@host}.pem")
25
+ return ssl_check_existing(cert, cert_file) if File.exist?(cert_file)
26
+
27
+ FileUtils.mkdir_p(File.expand_path(@certs_path))
28
+ File.open(cert_file, 'wb') { |f| f.print cert.to_pem }
29
+ true
30
+ end
31
+
32
+ def ssl_context
33
+ ssl_context = OpenSSL::SSL::SSLContext.new
34
+ ssl_context.set_params(
35
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
36
+ min_version: OpenSSL::SSL::TLS1_2_VERSION,
37
+ ca_file: '/etc/ssl/certs/ca-certificates.crt',
38
+ verify_hostname: true
39
+ )
40
+ ssl_context.verify_callback = lambda do |preverify_ok, store_context|
41
+ return true if preverify_ok
42
+
43
+ ssl_verify_cb store_context.current_cert
44
+ end
45
+ ssl_context
46
+ end
47
+
48
+ def init_sockets
49
+ socket = TCPSocket.new(@host, @port)
50
+ @ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
51
+ # Close underlying TCP socket with SSL socket
52
+ @ssl_socket.sync_close = true
53
+ @ssl_socket.hostname = @host # SNI
54
+ @ssl_socket.connect
55
+ end
56
+
57
+ # Closes the SSL and TCP connections.
58
+ def finish
59
+ @ssl_socket.close
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'request'
5
+ require_relative 'response'
6
+ require_relative '../text/generic'
7
+
8
+ module Net
9
+ module Gemini # rubocop:disable Style/Documentation
10
+ # An example client to fetch resources hosted on Gemini network.
11
+ class Client
12
+ attr_writer :certs_path
13
+
14
+ def initialize(host, port)
15
+ @host = host
16
+ @port = port
17
+ @certs_path = '~/.cache/gemini/certs'
18
+ end
19
+
20
+ # This method can raise an OpenSSL::SSL::SSLError
21
+ def request!(uri)
22
+ init_sockets
23
+ req = Request.new uri
24
+ req.write @ssl_socket
25
+ res = Response.read_new(@ssl_socket)
26
+ res.uri = uri
27
+ res.reading_body(@ssl_socket)
28
+ end
29
+
30
+ def request(uri)
31
+ request! uri
32
+ rescue OpenSSL::SSL::SSLError => e
33
+ msg = format(
34
+ 'SSLError: %<cause>s',
35
+ cause: e.message.sub(/.*state=error: (.+)\Z/, '\1')
36
+ )
37
+ Response.new('59', msg)
38
+ ensure
39
+ # Stop remaining connection, even if they should be already cut
40
+ # by the server
41
+ finish
42
+ end
43
+
44
+ def fetch(uri, limit = 5)
45
+ raise Error, 'Too many Gemini redirects' if limit.zero?
46
+
47
+ r = request(uri)
48
+ return r unless r.status[0] == '3'
49
+
50
+ begin
51
+ uri = handle_redirect(r)
52
+ rescue ArgumentError, URI::InvalidURIError
53
+ return r
54
+ end
55
+ warn "Redirect to #{uri}" if $VERBOSE
56
+ fetch(uri, limit - 1)
57
+ end
58
+
59
+ private
60
+
61
+ def handle_redirect(response)
62
+ uri = response.uri
63
+ old_url = uri.to_s
64
+ new_uri = URI(response.meta)
65
+ uri.merge!(new_uri)
66
+ raise Error, "Redirect loop on #{uri}" if uri.to_s == old_url
67
+
68
+ @host = uri.host
69
+ @port = uri.port
70
+ uri
71
+ end
72
+ end
73
+
74
+ def self.start(host_or_uri, port = nil, &block)
75
+ if host_or_uri.is_a? URI::Gemini
76
+ host = host_or_uri.host
77
+ port = host_or_uri.port
78
+ else
79
+ host = host_or_uri
80
+ end
81
+ gem = Client.new(host, port)
82
+ return gem unless block
83
+
84
+ yield gem
85
+ end
86
+
87
+ def self.get_response(uri)
88
+ start(uri.host, uri.port) { |gem| gem.fetch(uri) }
89
+ end
90
+
91
+ def self.get(string_or_uri)
92
+ uri = Net::Text::Generic.build_uri string_or_uri, URI::Gemini
93
+ get_response(uri).body
94
+ end
95
+ end
96
+ end
97
+
98
+ require_relative 'client/ssl'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Gemini
5
+ class Error < StandardError; end
6
+ class BadRequest < Error; end
7
+ class BadResponse < Error; end
8
+ end
9
+ end
@@ -2,48 +2,52 @@
2
2
 
3
3
  require 'English'
4
4
 
5
- require 'uri/gemini'
5
+ require_relative '../../uri/gemini'
6
+ require_relative 'error'
6
7
 
7
8
  module Net
8
- class GeminiBadRequest < StandardError; end
9
-
10
- #
11
- # The syntax of Gemini Requests are defined in the Gemini
12
- # specification, section 2.
13
- #
14
- # @see https://gemini.circumlunar.space/docs/specification.html
15
- #
16
- class GeminiRequest
17
- attr_reader :uri
18
-
19
- def initialize(uri_or_str)
20
- # In any case, make some sanity check over this uri-like think
21
- url = uri_or_str.to_s
22
- if url.length > 1024
23
- raise GeminiBadRequest, "Request too long: #{url.dump}"
9
+ module Gemini
10
+ class BadRequest < Error; end
11
+
12
+ #
13
+ # The syntax of Gemini Requests are defined in the Gemini
14
+ # specification, section 2.
15
+ #
16
+ # @see https://geminiprotocol.net/docs/protocol-specification.html
17
+ #
18
+ class Request
19
+ attr_reader :uri
20
+
21
+ def initialize(uri_or_str)
22
+ # In any case, make some sanity check over this uri-like think
23
+ url = uri_or_str.to_s
24
+ raise BadRequest, "Request too long: #{url.dump}" if url.length > 1024
25
+
26
+ @uri = URI(url)
27
+ return if uri.is_a? URI::Gemini
28
+
29
+ raise BadRequest, "Not a Gemini URI: #{url.dump}"
24
30
  end
25
- @uri = URI(url)
26
- return if uri.is_a? URI::Gemini
27
- raise GeminiBadRequest, "Not a Gemini URI: #{url.dump}"
28
- end
29
31
 
30
- def path
31
- @uri.path
32
- end
32
+ def path
33
+ @uri.path
34
+ end
33
35
 
34
- def write(sock)
35
- sock.puts "#{@uri}\r\n"
36
- end
36
+ def write(sock)
37
+ sock.puts "#{@uri}\r\n"
38
+ end
37
39
 
38
- class << self
39
- def read_new(sock)
40
- # Read up to 1026 bytes:
41
- # - 1024 bytes max for the URL
42
- # - 2 bytes for <CR><LF>
43
- str = sock.gets($INPUT_RECORD_SEPARATOR, 1026)
44
- m = /\A(.*)\r\n\z/.match(str)
45
- raise GeminiBadRequest, "Malformed request: #{str&.dump}" if m.nil?
46
- new(m[1])
40
+ class << self
41
+ def read_new(sock)
42
+ # Read up to 1026 bytes:
43
+ # - 1024 bytes max for the URL
44
+ # - 2 bytes for <CR><LF>
45
+ str = sock.gets($INPUT_RECORD_SEPARATOR, 1026)
46
+ m = /\A(.*)\r\n\z/.match(str)
47
+ raise BadRequest, "Malformed request: #{str&.dump}" if m.nil?
48
+
49
+ new(m[1])
50
+ end
47
51
  end
48
52
  end
49
53
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Gemini
5
+ # Reopen Response class to add specific private method to parse
6
+ # text/gemini documents.
7
+ class Response
8
+ private
9
+
10
+ def received_mime(raw_meta)
11
+ meta_data = raw_meta.map { |m| m.split('=') }
12
+ mime = { lang: nil, charset: 'utf-8', format: nil }
13
+ new_mime = meta_data.filter_map do |opt|
14
+ key = opt[0].downcase.to_sym
15
+ next unless mime.has_key? key
16
+
17
+ [key, opt[1].downcase]
18
+ end
19
+ mime.merge new_mime.to_h
20
+ end
21
+
22
+ def parse_meta
23
+ header = { status: @status, meta: @meta, mimetype: nil }
24
+ return header unless body_permitted?
25
+
26
+ raw_meta = meta.split(';').map(&:strip)
27
+ header[:mimetype] = raw_meta.shift
28
+ return header unless raw_meta.any?
29
+
30
+ header.merge received_mime(raw_meta)
31
+ end
32
+
33
+ def parse_preformatted_block(line, buf)
34
+ cur_block = { meta: line[3..].chomp, content: '' }
35
+ while (line = buf.gets)
36
+ if line.start_with?('```')
37
+ @preformatted_blocks << cur_block
38
+ break
39
+ end
40
+ cur_block[:content] += line
41
+ end
42
+ end
43
+
44
+ def parse_link(line)
45
+ m = line.strip.match(/\A=>\s*([^\s]+)(?:\s*(.+))?\z/)
46
+ return if m.nil?
47
+
48
+ begin
49
+ uri = URI(m[1])
50
+ rescue URI::InvalidURIError
51
+ return
52
+ end
53
+ uri = @uri.merge(uri) if @uri && uri.is_a?(URI::Generic)
54
+ @links << { uri: uri, label: m[2]&.chomp }
55
+ end
56
+
57
+ def parse_body
58
+ buf = StringIO.new(@body)
59
+ while (line = buf.gets)
60
+ if line.start_with?('```')
61
+ parse_preformatted_block(line, buf)
62
+ elsif line.start_with?('=>')
63
+ parse_link(line)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,103 +3,118 @@
3
3
  require 'English'
4
4
  require 'stringio'
5
5
 
6
- require_relative 'gmi_parser'
7
- require_relative 'reflow_text'
6
+ require_relative 'error'
7
+ require_relative '../text/reflow'
8
8
 
9
9
  module Net
10
- class GeminiBadResponse < StandardError; end
11
-
12
- #
13
- # The syntax of Gemini Responses are defined in the Gemini
14
- # specification, section 3.
15
- #
16
- # @see https://gemini.circumlunar.space/docs/specification.html
17
- #
18
- class GeminiResponse
19
- # The Gemini response <STATUS> string.
10
+ module Gemini
20
11
  #
21
- # For example, '20'.
22
- attr_reader :status
23
-
24
- # The Gemini response <META> message sent by the server as a string.
12
+ # The syntax of Gemini Responses are defined in the Gemini
13
+ # specification, section 3.
25
14
  #
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
15
+ # @see https://geminiprotocol.net/docs/protocol-specification.html
16
+ #
17
+ # See {Net::Gemini} documentation to see how to interract with a
18
+ # Response.
19
+ #
20
+ class Response
21
+ # @return [String] The Gemini response <STATUS> string.
22
+ # @example '20'
23
+ attr_reader :status
24
+
25
+ # @return [String] The Gemini response <META> message sent by the server.
26
+ # @example 'text/gemini'
27
+ attr_reader :meta
28
+
29
+ # @return [Hash] The Gemini response <META>.
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
+ # @return [Array<String>] All links found on a Gemini response of MIME
39
+ # text/gemini
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
51
 
52
- def body_permitted?
53
- @status && @status[0] == '2'
54
- end
52
+ def body_permitted?
53
+ @status && @status[0] == '2'
54
+ end
55
+
56
+ def reading_body(sock)
57
+ return self unless body_permitted?
55
58
 
56
- def reading_body(sock)
57
- return self unless body_permitted?
58
- raw_body = []
59
- while (line = sock.gets)
60
- raw_body << line
59
+ raw_body = []
60
+ sock.each_line { raw_body << _1 }
61
+ @body = encode_body(raw_body.join)
62
+ return self unless @header[:mimetype] == 'text/gemini'
63
+
64
+ parse_body
65
+ self
61
66
  end
62
- @body = encode_body(raw_body.join)
63
- return self unless @header[:mimetype] == 'text/gemini'
64
- parse_body
65
- self
66
- end
67
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
68
+ # Return the response body (i.e. the requested document content).
69
+ #
70
+ # @param reflow_at [Integer] The column at which body content must be
71
+ # reflowed. Default is -1, which means "do not reflow".
72
+ # @return [String] the body content
73
+ def body(reflow_at: -1)
74
+ return '' if @body.nil? # Maybe not ready?
75
+
76
+ unless reflow_at.is_a? Integer
77
+ raise(
78
+ ArgumentError, "reflow_at must be Integer, #{reflow_at.class} given"
79
+ )
80
+ end
81
+
82
+ return @body if reflow_at <= 0 || @header[:format] == 'fixed'
73
83
 
74
- class << self
75
- def read_new(sock)
76
- # Read up to 1029 bytes:
77
- # - 3 bytes for code and space separator
78
- # - 1024 bytes max for the message
79
- # - 2 bytes for <CR><LF>
80
- str = sock.gets($INPUT_RECORD_SEPARATOR, 1029)
81
- m = /\A([1-6]\d) (.*)\r\n\z/.match(str)
82
- raise GeminiBadResponse, "wrong status line: #{str.dump}" if m.nil?
83
- new(*m.captures)
84
+ Net::Text::Reflow.format_body(@body, reflow_at)
84
85
  end
85
- end
86
86
 
87
- private
87
+ class << self
88
+ def read_new(sock)
89
+ # Read up to 1029 bytes:
90
+ # - 3 bytes for code and space separator
91
+ # - 1024 bytes max for the message
92
+ # - 2 bytes for <CR><LF>
93
+ str = sock.gets($INPUT_RECORD_SEPARATOR, 1029)
94
+ m = /\A([1-6]\d) (.*)\r\n\z/.match(str)
95
+ raise BadResponse, "wrong status line: #{str.dump}" if m.nil?
96
+
97
+ new(*m.captures)
98
+ end
99
+ end
88
100
 
89
- def encode_body(body)
90
- return body unless @header[:mimetype].start_with?('text/')
91
- if @header[:charset] && @header[:charset] != 'utf-8'
92
- # If body use another charset than utf-8, we need first to
93
- # declare the raw byte string as using this chasret
94
- body.force_encoding(@header[:charset])
95
- # Then we can safely try to convert it to utf-8
96
- return body.encode('utf-8')
101
+ private
102
+
103
+ def encode_body(body)
104
+ return body unless @header[:mimetype].start_with?('text/')
105
+
106
+ if @header[:charset] && @header[:charset] != 'utf-8'
107
+ # If body use another charset than utf-8, we need first to
108
+ # declare the raw byte string as using this chasret
109
+ body.force_encoding(@header[:charset])
110
+ # Then we can safely try to convert it to utf-8
111
+ return body.encode('utf-8')
112
+ end
113
+ # Just declare that the body uses utf-8
114
+ body.force_encoding('utf-8')
97
115
  end
98
- # Just declare that the body uses utf-8
99
- body.force_encoding('utf-8')
100
116
  end
101
-
102
- include ::Gemini::GmiParser
103
- include ::Gemini::ReflowText
104
117
  end
105
118
  end
119
+
120
+ require_relative 'response/parser'
data/lib/net/gemini.rb CHANGED
@@ -6,19 +6,12 @@ require 'socket'
6
6
  require 'openssl'
7
7
  require 'fileutils'
8
8
 
9
- require 'uri/gemini'
10
- require_relative 'gemini/request'
11
- require_relative 'gemini/response'
12
- require_relative 'gemini/ssl'
13
-
14
9
  module Net
15
- class GeminiError < StandardError; end
16
-
17
10
  # == A Gemini client API for Ruby.
18
11
  #
19
12
  # Net::Gemini provides a library which can be used to build Gemini
20
13
  # user-agents.
21
- # @see https://gemini.circumlunar.space/docs/specification.html
14
+ # @see gemini://geminiprotocol.net/docs/specification.gmi
22
15
  #
23
16
  # Net::Gemini is designed to work closely with URI.
24
17
  #
@@ -36,8 +29,7 @@ module Net
36
29
  #
37
30
  # === GET by URI
38
31
  #
39
- # uri = URI('gemini://gemini.circumlunar.space/')
40
- # Net::Gemini.get(uri) # => String
32
+ # Net::Gemini.get('gemini://gemini.circumlunar.space/') # => String
41
33
  #
42
34
  # === GET with Dynamic Parameters
43
35
  #
@@ -66,12 +58,12 @@ module Net
66
58
  #
67
59
  # # Body
68
60
  # puts res.body if res.body_permitted?
69
- # puts res.body(flowed: 85)
61
+ # puts res.body(reflow_at: 85)
70
62
  #
71
63
  # === Following Redirection
72
64
  #
73
- # The {#fetch} method, contrary to the {#request} one will try to
74
- # automatically resolves redirection, leading you to the final
65
+ # The {Client#fetch} method, contrary to the {Client#request} one will try
66
+ # to automatically resolves redirection, leading you to the final
75
67
  # destination.
76
68
  #
77
69
  # u = URI('gemini://exemple.com/redirect')
@@ -88,82 +80,7 @@ module Net
88
80
  # puts "#{res.status} - #{res.meta}" # => '20 - text/gemini;'
89
81
  # puts res.uri.to_s # => 'gemini://exemple.com/final/dest'
90
82
  #
91
- class Gemini
92
- attr_writer :certs_path
93
-
94
- def initialize(host, port)
95
- @host = host
96
- @port = port
97
- @certs_path = '~/.cache/gemini/certs'
98
- end
99
-
100
- def request(uri)
101
- init_sockets
102
- req = GeminiRequest.new uri
103
- req.write @ssl_socket
104
- res = GeminiResponse.read_new(@ssl_socket)
105
- res.uri = uri
106
- res.reading_body(@ssl_socket)
107
- rescue OpenSSL::SSL::SSLError => e
108
- msg = format(
109
- 'SSLError: %<cause>s',
110
- cause: e.message.sub(/.*state=error: (.+)\Z/, '\1')
111
- )
112
- GeminiResponse.new('59', msg)
113
- ensure
114
- # Stop remaining connection, even if they should be already cut
115
- # by the server
116
- finish
117
- end
118
-
119
- def fetch(uri, limit = 5)
120
- raise GeminiError, 'Too many Gemini redirects' if limit.zero?
121
- r = request(uri)
122
- return r unless r.status[0] == '3'
123
- begin
124
- uri = handle_redirect(r)
125
- rescue ArgumentError, URI::InvalidURIError
126
- return r
127
- end
128
- warn "Redirect to #{uri}" if $VERBOSE
129
- fetch(uri, limit - 1)
130
- end
131
-
132
- class << self
133
- def start(host_or_uri, port = nil)
134
- if host_or_uri.is_a? URI::Gemini
135
- host = host_or_uri.host
136
- port = host_or_uri.port
137
- else
138
- host = host_or_uri
139
- end
140
- gem = new(host, port)
141
- return yield(gem) if block_given?
142
- gem
143
- end
144
-
145
- def get_response(uri)
146
- start(uri.host, uri.port) { |gem| gem.fetch(uri) }
147
- end
148
-
149
- def get(uri)
150
- get_response(uri).body
151
- end
152
- end
153
-
154
- private
155
-
156
- def handle_redirect(response)
157
- uri = response.uri
158
- old_url = uri.to_s
159
- new_uri = URI(response.meta)
160
- uri.merge!(new_uri)
161
- raise GeminiError, "Redirect loop on #{uri}" if uri.to_s == old_url
162
- @host = uri.host
163
- @port = uri.port
164
- uri
165
- end
166
-
167
- include ::Gemini::SSL
168
- end
83
+ module Gemini; end
169
84
  end
85
+
86
+ require_relative 'gemini/client'
data/lib/net/gopher.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../uri/gopher'
4
- require_relative 'generic'
4
+ require_relative 'text/generic'
5
5
 
6
6
  module Net
7
7
  # == A Gopher client API for Ruby.
@@ -28,9 +28,7 @@ module Net
28
28
  # uri = URI('gopher://thelambdalab.xyz/1/projects/elpher/')
29
29
  # Net::Gopher.get(uri) # => String
30
30
  #
31
- class Gopher
32
- extend TextGeneric
33
-
31
+ class Gopher < Text::Generic
34
32
  def self.get(string_or_uri)
35
33
  uri = build_uri string_or_uri, URI::Gopher
36
34
  request uri, "#{uri.selector}\r\n"
data/lib/net/nex.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../uri/nex'
4
+ require_relative 'text/generic'
5
+
6
+ module Net
7
+ # == A Nex client API for Ruby.
8
+ #
9
+ # Net::Nex provides a library which can be used to build Nex
10
+ # user-agents.
11
+ #
12
+ # Net::Nex is designed to work closely with URI.
13
+ #
14
+ # == Simple Examples
15
+ #
16
+ # All examples assume you have loaded Net::Nex with:
17
+ #
18
+ # require 'net/nex'
19
+ #
20
+ # This will also require 'uri' so you don't need to require it
21
+ # separately.
22
+ #
23
+ # The Net::Nex methods in the following section do not persist
24
+ # connections.
25
+ #
26
+ # === GET by URI
27
+ #
28
+ # uri = URI('nex://nightfall.city/nex/info/')
29
+ # Net::Nex.get(uri) # => String
30
+ #
31
+ class Nex < Text::Generic
32
+ def self.get(string_or_uri)
33
+ uri = build_uri string_or_uri, URI::Nex
34
+ request uri, "#{uri.path}\r\n"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module Net
6
+ module Text
7
+ # Generic interface to be used by specific network protocol implementation.
8
+ class Generic
9
+ def self.request(uri, query)
10
+ sock = TCPSocket.new(uri.host, uri.port)
11
+ sock.puts query
12
+ sock.read
13
+ ensure
14
+ # Stop remaining connection, even if they should be already cut
15
+ # by the server
16
+ sock&.close
17
+ end
18
+
19
+ def self.build_uri(string_or_uri, uri_class)
20
+ string_or_uri = URI(string_or_uri) if string_or_uri.is_a?(String)
21
+ return string_or_uri if string_or_uri.is_a?(uri_class)
22
+
23
+ raise ArgumentError, "uri is not a String, nor an #{uri_class.name}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Text
5
+ # Contains helper methods to correctly display texts with long lines.
6
+ #
7
+ # This module expect given text to be Gemtext inspired (i.e. links
8
+ # prefixed with => and ``` delimitting code blocks).
9
+ module Reflow
10
+ def self.reflow_line_prefix(line)
11
+ m = line.match(/\A([*#>]+ )/)
12
+ return '' unless m
13
+ # Each quote line should begin with the quote mark
14
+ return m[1] if m[1].start_with?('>')
15
+
16
+ ' ' * m[1].length
17
+ end
18
+
19
+ def self.reflow_text_line(line, mono_block_open, length)
20
+ line.strip!
21
+ if mono_block_open || line.start_with?('=>') || line.length < length
22
+ return [line]
23
+ end
24
+
25
+ output = []
26
+ prefix = reflow_line_prefix(line)
27
+ limit_chars = ['-', '­', ' '].freeze
28
+ while line.length > length
29
+ cut_line = line[0...length]
30
+ cut_index = limit_chars.map { cut_line.rindex(_1) || -1 }.max
31
+ break if cut_index.zero? # Better do nothing for now
32
+
33
+ output << line[0...cut_index]
34
+ line = prefix + line[cut_index + 1..]
35
+ end
36
+ output << line
37
+ end
38
+
39
+ def self.format_body(body, length)
40
+ new_body = []
41
+ mono_block_open = false
42
+ body.each_line do |line|
43
+ if line.start_with?('```')
44
+ mono_block_open = !mono_block_open
45
+ # Don't include code block toggle lines
46
+ next
47
+ end
48
+ new_body += reflow_text_line(line, mono_block_open, length)
49
+ end
50
+ new_body.join("\n")
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/uri/finger.rb CHANGED
@@ -14,13 +14,15 @@ module URI # :nodoc:
14
14
  DEFAULT_PORT = 79
15
15
 
16
16
  # An Array of the available components for URI::Finger.
17
- COMPONENT = [:scheme, :userinfo, :host, :port, :path].freeze
17
+ COMPONENT = %i[scheme userinfo host port path].freeze
18
18
 
19
19
  def name
20
20
  # Utilitary method to extract a name to query, either from the userinfo
21
21
  # part or from the path.
22
22
  return @user if @user
23
+
23
24
  return '' unless @path
25
+
24
26
  # Remove leading /
25
27
  @path[1..] || ''
26
28
  end
data/lib/uri/gemini.rb CHANGED
@@ -7,15 +7,14 @@ module URI # :nodoc:
7
7
  # The syntax of Gemini URIs is defined in the Gemini specification,
8
8
  # section 1.2.
9
9
  #
10
- # @see https://gemini.circumlunar.space/docs/specification.html
10
+ # @see https://geminiprotocol.net/docs/protocol-specification.html
11
11
  #
12
12
  class Gemini < HTTP
13
13
  # A Default port of 1965 for URI::Gemini.
14
14
  DEFAULT_PORT = 1965
15
15
 
16
16
  # An Array of the available components for URI::Gemini.
17
- COMPONENT = [:scheme, :host, :port,
18
- :path, :query, :fragment].freeze
17
+ COMPONENT = %i[scheme host port path query fragment].freeze
19
18
  end
20
19
 
21
20
  if respond_to? :register_scheme
data/lib/uri/gopher.rb CHANGED
@@ -14,22 +14,24 @@ module URI # :nodoc:
14
14
  DEFAULT_PORT = 70
15
15
 
16
16
  # An Array of the available components for URI::Gopher.
17
- COMPONENT = [:scheme, :host, :port, :path].freeze
17
+ COMPONENT = %i[scheme host port path].freeze
18
18
 
19
19
  def selector
20
20
  return @selector if defined? @selector
21
+
21
22
  @selector = extract_gopher_path_elements[1]
22
23
  end
23
24
 
24
25
  def item_type
25
26
  return @item_type if defined? @item_type
27
+
26
28
  @item_type = extract_gopher_path_elements[0]
27
29
  end
28
30
 
29
31
  private
30
32
 
31
33
  def extract_gopher_path_elements
32
- m = /\A\/([0-9+dfghisA-LT])(\/.*)?\Z/.match(@path)
34
+ m = %r{\A/([0-9+dfghisA-LT])(/.*)?\Z}.match(@path)
33
35
  m.captures
34
36
  end
35
37
  end
data/lib/uri/nex.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module URI # :nodoc:
6
+ #
7
+ # The syntax of Nex URIs is defined in the Nex specification,
8
+ # section 1.2.
9
+ #
10
+ # @see nex://nightfall.city/nex/info/specification.txt
11
+ #
12
+ class Nex < HTTP
13
+ # A Default port of 1900 for URI::Nex.
14
+ DEFAULT_PORT = 1900
15
+
16
+ # An Array of the available components for URI::Nex.
17
+ COMPONENT = %i[scheme host port path].freeze
18
+ end
19
+
20
+ if respond_to? :register_scheme
21
+ # Introduced somewhere in ruby 3.0.x
22
+ register_scheme 'NEX', Nex
23
+ else
24
+ @@schemes['NEX'] = Nex
25
+ end
26
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-net-text
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Étienne Deparis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-18 00:00:00.000000000 Z
11
+ date: 2024-07-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: etienne@depar.is
@@ -20,16 +20,20 @@ files:
20
20
  - README.md
21
21
  - lib/net/finger.rb
22
22
  - lib/net/gemini.rb
23
- - lib/net/gemini/gmi_parser.rb
24
- - lib/net/gemini/reflow_text.rb
23
+ - lib/net/gemini/client.rb
24
+ - lib/net/gemini/client/ssl.rb
25
+ - lib/net/gemini/error.rb
25
26
  - lib/net/gemini/request.rb
26
27
  - lib/net/gemini/response.rb
27
- - lib/net/gemini/ssl.rb
28
- - lib/net/generic.rb
28
+ - lib/net/gemini/response/parser.rb
29
29
  - lib/net/gopher.rb
30
+ - lib/net/nex.rb
31
+ - lib/net/text/generic.rb
32
+ - lib/net/text/reflow.rb
30
33
  - lib/uri/finger.rb
31
34
  - lib/uri/gemini.rb
32
35
  - lib/uri/gopher.rb
36
+ - lib/uri/nex.rb
33
37
  homepage: https://git.umaneti.net/ruby-net-text/
34
38
  licenses:
35
39
  - MIT
@@ -38,7 +42,13 @@ metadata:
38
42
  source_code_uri: https://git.umaneti.net/ruby-net-text/
39
43
  documentation_uri: https://www.rubydoc.info/gems/ruby-net-text
40
44
  funding_uri: https://liberapay.com/milouse
41
- post_install_message:
45
+ post_install_message: |+
46
+ The version 0.0.9 introduces some breaking changes in Gemini support.
47
+ If you were using its internal API instead of just using the documented
48
+ methods, please refer to the README to know more about the changes.
49
+
50
+ https://git.umaneti.net/ruby-net-text/about/
51
+
42
52
  rdoc_options: []
43
53
  require_paths:
44
54
  - lib
@@ -53,8 +63,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
63
  - !ruby/object:Gem::Version
54
64
  version: '0'
55
65
  requirements: []
56
- rubygems_version: 3.4.10
66
+ rubygems_version: 3.5.15
57
67
  signing_key:
58
68
  specification_version: 4
59
- summary: Gemini, Gopher, and Finger support for Net::* and URI::*
69
+ summary: Finger, Gemini, Gopher and Nex support for Net::* and URI::*
60
70
  test_files: []
71
+ ...
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gemini
4
- # Contains specific method to parse text/gemini documents.
5
- module GmiParser
6
- private
7
-
8
- def parse_meta
9
- header = { status: @status, meta: @meta, mimetype: nil }
10
- return header unless body_permitted?
11
- mime = { lang: nil, charset: 'utf-8', format: nil }
12
- raw_meta = meta.split(';').map(&:strip)
13
- header[:mimetype] = raw_meta.shift
14
- return header unless raw_meta.any?
15
- raw_meta.map { |m| m.split('=') }.each do |opt|
16
- key = opt[0].downcase.to_sym
17
- next unless mime.has_key? key
18
- mime[key] = opt[1].downcase
19
- end
20
- header.merge(mime)
21
- end
22
-
23
- def parse_preformatted_block(line, buf)
24
- cur_block = { meta: line[3..].chomp, content: '' }
25
- while (line = buf.gets)
26
- if line.start_with?('```')
27
- @preformatted_blocks << cur_block
28
- break
29
- end
30
- cur_block[:content] += line
31
- end
32
- end
33
-
34
- def parse_link(line)
35
- m = line.strip.match(/\A=>\s*([^\s]+)(?:\s*(.+))?\z/)
36
- return if m.nil?
37
- begin
38
- uri = URI(m[1])
39
- rescue URI::InvalidURIError
40
- return
41
- end
42
- uri = @uri.merge(uri) if @uri && uri.is_a?(URI::Generic)
43
- @links << { uri: uri, label: m[2]&.chomp }
44
- end
45
-
46
- def parse_body
47
- buf = StringIO.new(@body)
48
- while (line = buf.gets)
49
- if line.start_with?('```')
50
- parse_preformatted_block(line, buf)
51
- elsif line.start_with?('=>')
52
- parse_link(line)
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gemini
4
- # Contains specific method to correctly display Gemini texts
5
- module ReflowText
6
- private
7
-
8
- def reflow_line_cut_index(line)
9
- possible_cut = [
10
- line.rindex(' ') || 0,
11
- line.rindex('­') || 0,
12
- line.rindex('-') || 0
13
- ].sort
14
- possible_cut.reverse!
15
- possible_cut[0]
16
- end
17
-
18
- def reflow_line_prefix(line)
19
- m = line.match(/\A([*#>]+ )/)
20
- return '' unless m
21
- # Each quote line should begin with the quote mark
22
- return m[1] if m[1].start_with?('>')
23
- ' ' * m[1].length
24
- end
25
-
26
- def reflow_text_line(line, mono_block_open, length)
27
- line.strip!
28
- if mono_block_open || line.start_with?('=>') || line.length < length
29
- return [line]
30
- end
31
- output = []
32
- prefix = reflow_line_prefix(line)
33
- while line.length > length
34
- cut_line = line[0...length]
35
- cut_index = reflow_line_cut_index(cut_line)
36
- break if cut_index.zero? # Better do nothing for now
37
- output << line[0...cut_index]
38
- line = prefix + line[cut_index + 1..]
39
- end
40
- output << line
41
- end
42
-
43
- def reformat_body(length)
44
- unless length.is_a? Integer
45
- raise ArgumentError, "Length must be Integer, #{length} given"
46
- end
47
- return @body if length.zero?
48
- new_body = []
49
- mono_block_open = false
50
- buf = StringIO.new(@body)
51
- while (line = buf.gets)
52
- if line.start_with?('```')
53
- mono_block_open = !mono_block_open
54
- # Don't include code block toggle lines
55
- next
56
- end
57
- new_body += reflow_text_line(line, mono_block_open, length)
58
- end
59
- new_body.join("\n")
60
- end
61
- end
62
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gemini
4
- # Contains specific method to handle SSL connection
5
- module SSL
6
- private
7
-
8
- def ssl_check_existing(new_cert, cert_file)
9
- raw = File.read cert_file
10
- saved_one = OpenSSL::X509::Certificate.new raw
11
- return true if saved_one == new_cert
12
- # TODO: offer some kind of recuperation
13
- warn "#{cert_file} does not match the current host cert!"
14
- false
15
- end
16
-
17
- def ssl_verify_cb(cert)
18
- return false unless OpenSSL::SSL.verify_certificate_identity(cert, @host)
19
- cert_file = File.expand_path("#{@certs_path}/#{@host}.pem")
20
- return ssl_check_existing(cert, cert_file) if File.exist?(cert_file)
21
- FileUtils.mkdir_p(File.expand_path(@certs_path))
22
- File.open(cert_file, 'wb') { |f| f.print cert.to_pem }
23
- true
24
- end
25
-
26
- def ssl_context
27
- ssl_context = OpenSSL::SSL::SSLContext.new
28
- ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
29
- ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
30
- ssl_context.verify_hostname = true
31
- ssl_context.ca_file = '/etc/ssl/certs/ca-certificates.crt'
32
- ssl_context.verify_callback = lambda do |preverify_ok, store_context|
33
- return true if preverify_ok
34
- ssl_verify_cb store_context.current_cert
35
- end
36
- ssl_context
37
- end
38
-
39
- def init_sockets
40
- socket = TCPSocket.new(@host, @port)
41
- @ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
42
- # Close underlying TCP socket with SSL socket
43
- @ssl_socket.sync_close = true
44
- @ssl_socket.hostname = @host # SNI
45
- @ssl_socket.connect
46
- end
47
-
48
- # Closes the SSL and TCP connections.
49
- def finish
50
- @ssl_socket.close
51
- end
52
- end
53
- end
data/lib/net/generic.rb DELETED
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'socket'
4
-
5
- # Generic interface to be used by specific network protocol implementation.
6
- module TextGeneric
7
- private
8
-
9
- def build_uri(string_or_uri, uri_class)
10
- string_or_uri = URI(string_or_uri) if string_or_uri.is_a?(String)
11
- return string_or_uri if string_or_uri.is_a?(uri_class)
12
- raise ArgumentError, "uri is not a String, nor an #{uri_class.name}"
13
- end
14
-
15
- def request(uri, query)
16
- sock = TCPSocket.new(uri.host, uri.port)
17
- sock.puts query
18
- sock.read
19
- ensure
20
- # Stop remaining connection, even if they should be already cut
21
- # by the server
22
- sock&.close
23
- end
24
- end