ruby-net-text 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|