diamant 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 +14 -0
- data/bin/diamant +89 -0
- data/lib/diamant.rb +110 -0
- data/lib/diamant/cert_generator.rb +66 -0
- data/lib/diamant/mimetype.rb +44 -0
- data/lib/diamant/version.rb +5 -0
- metadata +192 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a987ba57250c314c052adfa5f913be3312999154e64aa346df39e7c318c41f39
|
4
|
+
data.tar.gz: 052f61456c18fdfe8241f3333e81a1c8ab85d7a8fcd8fb4f671179793e5ce1ee
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 32ba003a7ce3c3f40f498ad51c7de1a4a292d1f0f13a89855c288ea08555cdfcf5d0bf6ecd39cc08d98097662675af96aa4f74c102f667e7edd19e984b7561e8
|
7
|
+
data.tar.gz: 3521229067830e6f91cca8bb0732ae70a2ac9a7cc04072a11b2513b0e1e5b9494300e5f722c4bbdba25c8e651401f068ce30f240acb2b6b161b30ae1a215ea95
|
data/LICENSE
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
2
|
+
Version 2, December 2004
|
3
|
+
|
4
|
+
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
5
|
+
|
6
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
7
|
+
copies of this license document, and changing it is allowed as long
|
8
|
+
as the name is changed.
|
9
|
+
|
10
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
11
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
12
|
+
|
13
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
14
|
+
|
data/bin/diamant
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'diamant'
|
5
|
+
require 'diamant/cert_generator'
|
6
|
+
require 'optparse'
|
7
|
+
|
8
|
+
options = { hostname: '127.0.0.1', port: 1965 }
|
9
|
+
OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength
|
10
|
+
parser.banner = "Usage: #{parser.program_name} [options] [ command [ arg ] ]"
|
11
|
+
|
12
|
+
parser.separator ''
|
13
|
+
|
14
|
+
parser.separator 'Commands'
|
15
|
+
|
16
|
+
parser.separator <<~COMMANDSHELP
|
17
|
+
serve [ public_path ] Serves static files from the given public_path.
|
18
|
+
(defaults to ./public_gmi)
|
19
|
+
|
20
|
+
generate_tls_cert [ hostname ]
|
21
|
+
Generates a new self-signed certificate for the
|
22
|
+
given hostname and its related private key.
|
23
|
+
(defaults to localhost)
|
24
|
+
|
25
|
+
If no command is given, or if the command is not recognized, the given
|
26
|
+
string is taken as a public_path to serve from. That is:
|
27
|
+
|
28
|
+
#{parser.program_name} ~/my_gemini_capsule
|
29
|
+
|
30
|
+
is the same thing than:
|
31
|
+
|
32
|
+
#{parser.program_name} serve ~/my_gemini_capsule
|
33
|
+
COMMANDSHELP
|
34
|
+
|
35
|
+
parser.separator ''
|
36
|
+
|
37
|
+
parser.separator 'Options'
|
38
|
+
|
39
|
+
parser.on('-h', '--help',
|
40
|
+
'Show this help message and quit.') do
|
41
|
+
puts parser.help
|
42
|
+
exit
|
43
|
+
end
|
44
|
+
|
45
|
+
parser.on('-v', '--version',
|
46
|
+
"Show #{parser.program_name} version and quit.") do
|
47
|
+
puts Diamant::VERSION
|
48
|
+
exit
|
49
|
+
end
|
50
|
+
|
51
|
+
parser.on('-b', '--bind HOST',
|
52
|
+
'Hostname to bind to (127.0.0.1, 0.0.0.0, ::1...).',
|
53
|
+
'(defaults to 127.0.0.1)') do |o|
|
54
|
+
options[:bind] = o
|
55
|
+
end
|
56
|
+
|
57
|
+
parser.on('-p', '--port PORT',
|
58
|
+
'Define the TCP port to bind to.',
|
59
|
+
'(defaults to 1965)') do |o|
|
60
|
+
options[:port] = o.to_i
|
61
|
+
end
|
62
|
+
|
63
|
+
parser.on('--cert CERT',
|
64
|
+
'Path to the TLS certificate to use.',
|
65
|
+
'(defaults to cert.pem)') do |o|
|
66
|
+
options[:cert] = o
|
67
|
+
end
|
68
|
+
|
69
|
+
parser.on('--pkey PKEY',
|
70
|
+
'Path to the TLS private key to use.',
|
71
|
+
'(defaults to key.rsa)') do |o|
|
72
|
+
options[:pkey] = o
|
73
|
+
end
|
74
|
+
end.parse!
|
75
|
+
|
76
|
+
command = ARGV.shift
|
77
|
+
case command
|
78
|
+
when 'generate_tls_cert'
|
79
|
+
Diamant::CertGenerator.new(ARGV[0]).write
|
80
|
+
exit
|
81
|
+
when 'serve'
|
82
|
+
options[:public_path] = ARGV[0]
|
83
|
+
else
|
84
|
+
options[:public_path] = command
|
85
|
+
end
|
86
|
+
|
87
|
+
options[:public_path] ||= './public_gmi'
|
88
|
+
|
89
|
+
Diamant::Server.new(options).start
|
data/lib/diamant.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'socket'
|
5
|
+
require 'English'
|
6
|
+
require 'openssl'
|
7
|
+
require 'fileutils'
|
8
|
+
|
9
|
+
require 'net/gemini/request'
|
10
|
+
require 'uri/gemini'
|
11
|
+
|
12
|
+
require 'diamant/version'
|
13
|
+
require 'diamant/mimetype'
|
14
|
+
|
15
|
+
module Diamant
|
16
|
+
# Runs the server request/answer loop.
|
17
|
+
class Server
|
18
|
+
def initialize(opts = {})
|
19
|
+
@port = opts[:port] || 1965
|
20
|
+
@bind = opts[:bind] || '127.0.0.1'
|
21
|
+
init_logger
|
22
|
+
init_server_paths(opts)
|
23
|
+
end
|
24
|
+
|
25
|
+
def start
|
26
|
+
tcp_serv = TCPServer.new @bind, @port
|
27
|
+
ssl_serv = OpenSSL::SSL::SSLServer.new tcp_serv, ssl_context
|
28
|
+
loop do
|
29
|
+
Thread.new(ssl_serv.accept) do |client|
|
30
|
+
handle_client(client)
|
31
|
+
client.close
|
32
|
+
end
|
33
|
+
rescue Interrupt
|
34
|
+
break
|
35
|
+
end
|
36
|
+
ensure
|
37
|
+
ssl_serv&.shutdown
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def handle_client(client)
|
43
|
+
begin
|
44
|
+
r = Net::GeminiRequest.read_new(client)
|
45
|
+
rescue Net::GeminiBadRequest
|
46
|
+
client.puts "59\r\n"
|
47
|
+
return
|
48
|
+
end
|
49
|
+
answer = route(r.path)
|
50
|
+
@logger.info "#{answer[0]} - #{r.uri}"
|
51
|
+
answer.each do |line|
|
52
|
+
client.puts "#{line}\r\n"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def build_response(route)
|
57
|
+
info = Diamant::MimeType.new(route)
|
58
|
+
answer = IO.readlines route, chomp: true
|
59
|
+
answer.prepend "20 #{info.content_type}"
|
60
|
+
rescue Diamant::MimeError
|
61
|
+
['50 Not a supported file!']
|
62
|
+
end
|
63
|
+
|
64
|
+
def route(path)
|
65
|
+
# In any case, remove the / prefix
|
66
|
+
route = File.expand_path path.delete_prefix('/'), Dir.pwd
|
67
|
+
# We better should use some sort of chroot...
|
68
|
+
unless route.start_with?(Dir.pwd)
|
69
|
+
@logger.warn "Bad attempt to get something out of public_dir: #{route}"
|
70
|
+
return ['51 Not found!']
|
71
|
+
end
|
72
|
+
route << '/index.gmi' if File.directory?(route)
|
73
|
+
return ['51 Not found!'] unless File.exist?(route)
|
74
|
+
build_response route
|
75
|
+
end
|
76
|
+
|
77
|
+
def ssl_context
|
78
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
79
|
+
ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
80
|
+
ssl_context.add_certificate @cert, @pkey
|
81
|
+
ssl_context
|
82
|
+
end
|
83
|
+
|
84
|
+
def init_logger
|
85
|
+
$stdout.sync = true
|
86
|
+
@logger = Logger.new($stdout)
|
87
|
+
@logger.datetime_format = '%Y-%m-%d %H:%M:%S'
|
88
|
+
@logger.formatter = proc do |severity, datetime, _, msg|
|
89
|
+
"[#{datetime}] #{severity}: #{msg}\n"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def check_option_path_exist(option, default)
|
94
|
+
path = File.expand_path(option || default)
|
95
|
+
return path if File.exist?(path)
|
96
|
+
raise ArgumentError, "#{path} does not exist!"
|
97
|
+
end
|
98
|
+
|
99
|
+
def init_server_paths(opts = {})
|
100
|
+
cert_file = check_option_path_exist(opts[:cert], 'cert.pem')
|
101
|
+
@cert = OpenSSL::X509::Certificate.new File.read(cert_file)
|
102
|
+
key_file = check_option_path_exist(opts[:pkey], 'key.rsa')
|
103
|
+
@pkey = OpenSSL::PKey::RSA.new File.read(key_file)
|
104
|
+
public_path = check_option_path_exist(
|
105
|
+
opts[:public_path], './public_gmi'
|
106
|
+
)
|
107
|
+
Dir.chdir(public_path)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
|
5
|
+
module Diamant
|
6
|
+
# Creates a new self-signed certificate and its related RSA private key,
|
7
|
+
# suitable to be used as certificate for the Gemini network protocol.
|
8
|
+
#
|
9
|
+
# This Generator is not intended to advance use as it offers no
|
10
|
+
# configuration at all. It use the following options:
|
11
|
+
#
|
12
|
+
# - 4096 bits RSA key
|
13
|
+
# - 1 year validity
|
14
|
+
# - self signed certificate
|
15
|
+
#
|
16
|
+
class CertGenerator
|
17
|
+
def initialize(subject = 'localhost')
|
18
|
+
@subject = OpenSSL::X509::Name.parse "/CN=#{subject}"
|
19
|
+
@key = OpenSSL::PKey::RSA.new 4096
|
20
|
+
init_cert
|
21
|
+
add_extensions
|
22
|
+
@cert.sign @key, OpenSSL::Digest.new('SHA256')
|
23
|
+
end
|
24
|
+
|
25
|
+
def write
|
26
|
+
IO.write('key.rsa', @key.to_pem)
|
27
|
+
File.chmod(0o400, 'key.rsa')
|
28
|
+
IO.write('cert.pem', @cert.to_pem)
|
29
|
+
File.chmod(0o644, 'cert.pem')
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def init_cert
|
35
|
+
@cert = OpenSSL::X509::Certificate.new
|
36
|
+
@cert.version = 3
|
37
|
+
@cert.serial = 0x0
|
38
|
+
@cert.issuer = @subject
|
39
|
+
@cert.subject = @subject
|
40
|
+
@cert.public_key = @key.public_key
|
41
|
+
@cert.not_before = Time.now
|
42
|
+
# 1 years validity
|
43
|
+
@cert.not_after = @cert.not_before + 1 * 365 * 24 * 60 * 60
|
44
|
+
@cert
|
45
|
+
end
|
46
|
+
|
47
|
+
def add_extension_to_cert(ext_factory, name, value, critical: false)
|
48
|
+
@cert.add_extension(
|
49
|
+
ext_factory.create_extension(name, value, critical)
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def add_extensions
|
54
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
55
|
+
ef.subject_certificate = @cert
|
56
|
+
ef.issuer_certificate = @cert
|
57
|
+
add_extension_to_cert(
|
58
|
+
ef, 'basicConstraints', 'CA:TRUE', critical: true
|
59
|
+
)
|
60
|
+
add_extension_to_cert(ef, 'subjectKeyIdentifier', 'hash')
|
61
|
+
add_extension_to_cert(
|
62
|
+
ef, 'authorityKeyIdentifier', 'keyid:always,issuer:always'
|
63
|
+
)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Diamant
|
4
|
+
class MimeError < StandardError; end
|
5
|
+
|
6
|
+
# Helper to understand what mimetype has a given file
|
7
|
+
class MimeType
|
8
|
+
attr_reader :extension, :content_type
|
9
|
+
|
10
|
+
MIMETYPES = {
|
11
|
+
'.gmi' => 'text/gemini',
|
12
|
+
'.txt' => 'text/plain',
|
13
|
+
'.md' => 'text/markdown',
|
14
|
+
'.org' => 'text/org',
|
15
|
+
'.png' => 'image/png',
|
16
|
+
'.jpg' => 'image/jpeg',
|
17
|
+
'.jpeg' => 'image/jpeg',
|
18
|
+
'.gif' => 'image/gif'
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
def initialize(path)
|
22
|
+
@path = path
|
23
|
+
extract_info
|
24
|
+
end
|
25
|
+
|
26
|
+
def supported?
|
27
|
+
@extension == '' || MIMETYPES.has_key?(@extension)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def extract_info
|
33
|
+
@extension = File.extname @path
|
34
|
+
raise MimeError, "#{@path} format is not supported!" unless supported?
|
35
|
+
if @extension == ''
|
36
|
+
# Weird... but we'll try the simple way
|
37
|
+
@content_type = 'text/plain'
|
38
|
+
else
|
39
|
+
# Any other supported extension
|
40
|
+
@content_type = MIMETYPES[@extension]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
metadata
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: diamant
|
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-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ruby-net-text
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.0.2
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.0.2
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: byebug
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '11.1'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '11.1'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pry
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.13'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.13'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry-doc
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.1'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.1'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.9'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.9'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rubocop
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.3'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.3'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rubocop-performance
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1.9'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1.9'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop-rspec
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '2.0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '2.0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: simplecov
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0.19'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0.19'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: yard
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0.9'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0.9'
|
153
|
+
description: |
|
154
|
+
Diamant is a server for the Gemini network protocol. it
|
155
|
+
can only serve static files. Internally, it uses the OpenSSL library to
|
156
|
+
handle the TLS sessions, and threads to handle concurrent requests.
|
157
|
+
email: etienne@depar.is
|
158
|
+
executables:
|
159
|
+
- diamant
|
160
|
+
extensions: []
|
161
|
+
extra_rdoc_files: []
|
162
|
+
files:
|
163
|
+
- LICENSE
|
164
|
+
- bin/diamant
|
165
|
+
- lib/diamant.rb
|
166
|
+
- lib/diamant/cert_generator.rb
|
167
|
+
- lib/diamant/mimetype.rb
|
168
|
+
- lib/diamant/version.rb
|
169
|
+
homepage: https://git.umaneti.net/diamant/about/
|
170
|
+
licenses:
|
171
|
+
- WTFPL
|
172
|
+
metadata: {}
|
173
|
+
post_install_message:
|
174
|
+
rdoc_options: []
|
175
|
+
require_paths:
|
176
|
+
- lib
|
177
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
178
|
+
requirements:
|
179
|
+
- - ">="
|
180
|
+
- !ruby/object:Gem::Version
|
181
|
+
version: '2.7'
|
182
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
183
|
+
requirements:
|
184
|
+
- - ">="
|
185
|
+
- !ruby/object:Gem::Version
|
186
|
+
version: '0'
|
187
|
+
requirements: []
|
188
|
+
rubygems_version: 3.1.4
|
189
|
+
signing_key:
|
190
|
+
specification_version: 4
|
191
|
+
summary: A simple Gemini server for static files.
|
192
|
+
test_files: []
|