ruby-net-text 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
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