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