ruby-net-text 0.0.8 → 0.0.9

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: 866ba253d1dbbc5b2e9a8eba1f6bf4813c6f3b163ce3278d36c4932be80c3714
4
+ data.tar.gz: 16e0059f252a8d88c8c04667e1b779e65eadae7c1c6da17aba4487fdac459070
5
5
  SHA512:
6
- metadata.gz: 5bae93436e2d8b7985fe39e26d3bf067713e4fbf46759bc91b641bd6200e9ea8dab676868c8f89a6e89679986ceb2c96f54c7db591e28494e18d7ac5ee0a5343
7
- data.tar.gz: 55621ad4d9bf7ea94ab1529e85acac2c279d097234eb85156027ef91fcca466bf90ab8d2cb0e3a312032fdb0248d198da5c8f8cdf11e436e2df88e5d7977758f
6
+ metadata.gz: 4643771216d1ee0c494a5e368037fbfcfcf303a290dfa2260af3cb9125cbdbeaad6d4fc7ee3836abb914e5c5f731d4f351fb1e4e19193751b07bfd31a0385abe
7
+ data.tar.gz: 1625097ffa76cc274460e64987fca75cb43db9963d4401e434a796b4aa7effdbbbda9e787001597183ce4d8e91db15f0c0c8f5f8945b725485aef218b314ab4d
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,60 @@
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
+ FileUtils.mkdir_p(File.expand_path(@certs_path))
27
+ File.open(cert_file, 'wb') { |f| f.print cert.to_pem }
28
+ true
29
+ end
30
+
31
+ def ssl_context
32
+ ssl_context = OpenSSL::SSL::SSLContext.new
33
+ ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
34
+ ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
35
+ ssl_context.verify_hostname = true
36
+ ssl_context.ca_file = '/etc/ssl/certs/ca-certificates.crt'
37
+ ssl_context.verify_callback = lambda do |preverify_ok, store_context|
38
+ return true if preverify_ok
39
+
40
+ ssl_verify_cb store_context.current_cert
41
+ end
42
+ ssl_context
43
+ end
44
+
45
+ def init_sockets
46
+ socket = TCPSocket.new(@host, @port)
47
+ @ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
48
+ # Close underlying TCP socket with SSL socket
49
+ @ssl_socket.sync_close = true
50
+ @ssl_socket.hostname = @host # SNI
51
+ @ssl_socket.connect
52
+ end
53
+
54
+ # Closes the SSL and TCP connections.
55
+ def finish
56
+ @ssl_socket.close
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'request'
5
+ require_relative 'response'
6
+
7
+ module Net
8
+ module Gemini
9
+ class Client
10
+ attr_writer :certs_path
11
+
12
+ def initialize(host, port)
13
+ @host = host
14
+ @port = port
15
+ @certs_path = '~/.cache/gemini/certs'
16
+ end
17
+
18
+ def request(uri)
19
+ init_sockets
20
+ req = Request.new uri
21
+ req.write @ssl_socket
22
+ res = Response.read_new(@ssl_socket)
23
+ res.uri = uri
24
+ res.reading_body(@ssl_socket)
25
+ rescue OpenSSL::SSL::SSLError => e
26
+ msg = format(
27
+ 'SSLError: %<cause>s',
28
+ cause: e.message.sub(/.*state=error: (.+)\Z/, '\1')
29
+ )
30
+ Response.new('59', msg)
31
+ ensure
32
+ # Stop remaining connection, even if they should be already cut
33
+ # by the server
34
+ finish
35
+ end
36
+
37
+ def fetch(uri, limit = 5)
38
+ raise Error, 'Too many Gemini redirects' if limit.zero?
39
+ r = request(uri)
40
+ return r unless r.status[0] == '3'
41
+ begin
42
+ uri = handle_redirect(r)
43
+ rescue ArgumentError, URI::InvalidURIError
44
+ return r
45
+ end
46
+ warn "Redirect to #{uri}" if $VERBOSE
47
+ fetch(uri, limit - 1)
48
+ end
49
+
50
+ private
51
+
52
+ def handle_redirect(response)
53
+ uri = response.uri
54
+ old_url = uri.to_s
55
+ new_uri = URI(response.meta)
56
+ uri.merge!(new_uri)
57
+ raise Error, "Redirect loop on #{uri}" if uri.to_s == old_url
58
+ @host = uri.host
59
+ @port = uri.port
60
+ uri
61
+ end
62
+ end
63
+
64
+ def self.start(host_or_uri, port = nil, &block)
65
+ if host_or_uri.is_a? URI::Gemini
66
+ host = host_or_uri.host
67
+ port = host_or_uri.port
68
+ else
69
+ host = host_or_uri
70
+ end
71
+ gem = Client.new(host, port)
72
+ return gem unless block
73
+
74
+ yield gem
75
+ end
76
+
77
+ def self.get_response(uri)
78
+ start(uri.host, uri.port) { |gem| gem.fetch(uri) }
79
+ end
80
+
81
+ def self.get(uri)
82
+ get_response(uri).body
83
+ end
84
+ end
85
+ end
86
+
87
+ 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,51 @@
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://gemini.circumlunar.space/docs/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
+ if url.length > 1024
25
+ raise BadRequest, "Request too long: #{url.dump}"
26
+ end
27
+ @uri = URI(url)
28
+ return if uri.is_a? URI::Gemini
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
+ new(m[1])
49
+ end
47
50
  end
48
51
  end
49
52
  end
@@ -0,0 +1,60 @@
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 parse_meta
11
+ header = { status: @status, meta: @meta, mimetype: nil }
12
+ return header unless body_permitted?
13
+ mime = { lang: nil, charset: 'utf-8', format: nil }
14
+ raw_meta = meta.split(';').map(&:strip)
15
+ header[:mimetype] = raw_meta.shift
16
+ return header unless raw_meta.any?
17
+ raw_meta.map { |m| m.split('=') }.each do |opt|
18
+ key = opt[0].downcase.to_sym
19
+ next unless mime.has_key? key
20
+ mime[key] = opt[1].downcase
21
+ end
22
+ header.merge(mime)
23
+ end
24
+
25
+ def parse_preformatted_block(line, buf)
26
+ cur_block = { meta: line[3..].chomp, content: '' }
27
+ while (line = buf.gets)
28
+ if line.start_with?('```')
29
+ @preformatted_blocks << cur_block
30
+ break
31
+ end
32
+ cur_block[:content] += line
33
+ end
34
+ end
35
+
36
+ def parse_link(line)
37
+ m = line.strip.match(/\A=>\s*([^\s]+)(?:\s*(.+))?\z/)
38
+ return if m.nil?
39
+ begin
40
+ uri = URI(m[1])
41
+ rescue URI::InvalidURIError
42
+ return
43
+ end
44
+ uri = @uri.merge(uri) if @uri && uri.is_a?(URI::Generic)
45
+ @links << { uri: uri, label: m[2]&.chomp }
46
+ end
47
+
48
+ def parse_body
49
+ buf = StringIO.new(@body)
50
+ while (line = buf.gets)
51
+ if line.start_with?('```')
52
+ parse_preformatted_block(line, buf)
53
+ elsif line.start_with?('=>')
54
+ parse_link(line)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -3,103 +3,109 @@
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://gemini.circumlunar.space/docs/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
55
 
56
- def reading_body(sock)
57
- return self unless body_permitted?
58
- raw_body = []
59
- while (line = sock.gets)
60
- raw_body << line
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
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
+ return @body if reflow_at < 0 || @header[:format] == 'fixed'
73
76
 
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)
77
+ Net::Text::Reflow.format_body(@body, reflow_at)
84
78
  end
85
- end
86
79
 
87
- private
80
+ class << self
81
+ def read_new(sock)
82
+ # Read up to 1029 bytes:
83
+ # - 3 bytes for code and space separator
84
+ # - 1024 bytes max for the message
85
+ # - 2 bytes for <CR><LF>
86
+ str = sock.gets($INPUT_RECORD_SEPARATOR, 1029)
87
+ m = /\A([1-6]\d) (.*)\r\n\z/.match(str)
88
+ raise BadResponse, "wrong status line: #{str.dump}" if m.nil?
89
+ new(*m.captures)
90
+ end
91
+ end
88
92
 
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')
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')
97
106
  end
98
- # Just declare that the body uses utf-8
99
- body.force_encoding('utf-8')
100
107
  end
101
-
102
- include ::Gemini::GmiParser
103
- include ::Gemini::ReflowText
104
108
  end
105
109
  end
110
+
111
+ require_relative 'response/parser'
data/lib/net/gemini.rb CHANGED
@@ -6,14 +6,7 @@ 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
@@ -66,12 +59,12 @@ module Net
66
59
  #
67
60
  # # Body
68
61
  # puts res.body if res.body_permitted?
69
- # puts res.body(flowed: 85)
62
+ # puts res.body(reflow_at: 85)
70
63
  #
71
64
  # === Following Redirection
72
65
  #
73
- # The {#fetch} method, contrary to the {#request} one will try to
74
- # automatically resolves redirection, leading you to the final
66
+ # The {Client#fetch} method, contrary to the {Client#request} one will try
67
+ # to automatically resolves redirection, leading you to the final
75
68
  # destination.
76
69
  #
77
70
  # u = URI('gemini://exemple.com/redirect')
@@ -88,82 +81,7 @@ module Net
88
81
  # puts "#{res.status} - #{res.meta}" # => '20 - text/gemini;'
89
82
  # puts res.uri.to_s # => 'gemini://exemple.com/final/dest'
90
83
  #
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
84
+ module Gemini; end
169
85
  end
86
+
87
+ 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,26 @@
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
+ raise ArgumentError, "uri is not a String, nor an #{uri_class.name}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,58 @@
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
+ ' ' * m[1].length
16
+ end
17
+
18
+ def self.reflow_text_line(line, mono_block_open, length)
19
+ line.strip!
20
+ if mono_block_open || line.start_with?('=>') || line.length < length
21
+ return [line]
22
+ end
23
+ output = []
24
+ prefix = reflow_line_prefix(line)
25
+ limit_chars = ['-', '­', ' '].freeze
26
+ while line.length > length
27
+ cut_line = line[0...length]
28
+ cut_index = limit_chars.map { cut_line.rindex(_1) || -1 }.max
29
+ break if cut_index.zero? # Better do nothing for now
30
+
31
+ output << line[0...cut_index]
32
+ line = prefix + line[cut_index + 1..]
33
+ end
34
+ output << line
35
+ end
36
+
37
+ def self.format_body(body, length)
38
+ unless length.is_a? Integer
39
+ raise ArgumentError, "Length must be Integer, #{length.class} given"
40
+ end
41
+ return body if length.zero?
42
+
43
+ new_body = []
44
+ mono_block_open = false
45
+ buf = StringIO.new(body)
46
+ while (line = buf.gets)
47
+ if line.start_with?('```')
48
+ mono_block_open = !mono_block_open
49
+ # Don't include code block toggle lines
50
+ next
51
+ end
52
+ new_body += reflow_text_line(line, mono_block_open, length)
53
+ end
54
+ new_body.join("\n")
55
+ end
56
+ end
57
+ end
58
+ 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 https://gemini.circumlunar.space/docs/specification.html
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 = [: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.0.9
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: 2023-10-20 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.4.20
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