niouz 0.6.0

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.
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # niouz -- A small, simple NNTP server suitable to set up
3
+ # private newsgroups for an intranet or workgroup.
4
+ #
5
+ # Homepage:: http://github.com/pcdavid/niouz
6
+ # Author:: Pierre-Charles David (mailto:pcdavid@pcdavid.net)
7
+ # Copyright:: Copyright (c) 2003, 2004, 2009 Pierre-Charles David
8
+ # License:: GPL v2 (www.gnu.org/copyleft/gpl.html)
9
+
10
+ $:.unshift File.join(File.dirname(__FILE__), %w[.. lib])
11
+
12
+ require 'niouz'
13
+ require 'optparse'
14
+ require 'ostruct'
15
+
16
+ options = OpenStruct.new
17
+ options.port = Niouz::NNTPServer::DEFAULT_PORT
18
+ options.root = Dir.pwd
19
+
20
+ OptionParser.new do |opts|
21
+ opts.banner = "Usage: #$0 [options]"
22
+
23
+ opts.on('-r', '--root [DIRECTORY]',
24
+ "Use the specified root location instead of the current directory") do |root|
25
+ options.root = root
26
+ end
27
+
28
+ opts.on('-p', '--port [NUM]', Integer,
29
+ "Use the specified port instead of the default (#{options.port})") do |port|
30
+ options.port = port
31
+ end
32
+
33
+ opts.on_tail('-h', '--help', 'Show this message') do
34
+ puts opts
35
+ exit
36
+ end
37
+
38
+ end.parse!
39
+
40
+ Dir.chdir(options.root) do
41
+ server = Niouz::NNTPServer.new(options.port)
42
+ server.store = Niouz::Storage.new(Dir.pwd)
43
+ server.start(-1)
44
+ server.join
45
+ end
@@ -0,0 +1,63 @@
1
+ # -*- ruby -*-
2
+ require 'socket'
3
+ require 'thread'
4
+ require 'time'
5
+ begin
6
+ require 'md5'
7
+ rescue LoadError => e
8
+ require 'digest/md5'
9
+ MD5 = Digest::MD5
10
+ end
11
+ require 'gserver'
12
+
13
+ module Niouz
14
+ PROG_NAME = 'niouz'
15
+ PROG_VERSION = '0.5'
16
+
17
+ # Format of the overview "database", as an ordered list of header
18
+ # names. See RCF 2980, Sections 2.1.7 ("LIST OVERVIEW.FMT") & 2.8
19
+ # ("XOVER").
20
+ OVERVIEW_FMT = [
21
+ 'Subject', 'From', 'Date', 'Message-ID', 'References', 'Bytes', 'Lines'
22
+ ]
23
+
24
+ # Parses the headers of a mail or news article formatted using the
25
+ # RFC822 format. This function does not interpret the headers values,
26
+ # but considers them free-form text. Headers are returned in a Hash
27
+ # mapping header names to values. Ordering is lost. Continuation lines
28
+ # are supported. An exception is raised if a header is given multiple
29
+ # definitions, or if the format does not follow RFC822. Parsing stops
30
+ # when encountering the end of +input+ or an empty line.
31
+ def self.parse_rfc822_header(input)
32
+ headers = Hash.new
33
+ previous = nil
34
+ input.each_line do |line|
35
+ line = line.chomp
36
+ break if line.empty? # Stop at first blank line
37
+ case line
38
+ when /^([^: \t]+):\s+(.*)$/
39
+ raise "Multiple definitions of header '#{$1}'." if headers.has_key?($1)
40
+ headers[previous = $1] = $2
41
+ when /^\s+(.*)$/
42
+ if not previous.nil? and headers.has_key?(previous)
43
+ headers[previous] << "\n" + $1.lstrip
44
+ else
45
+ raise "Invalid header continuation."
46
+ end
47
+ else
48
+ raise "Invalid header format."
49
+ end
50
+ end
51
+ return headers.empty? ? nil : headers
52
+ end
53
+
54
+ # Utility to parse dates
55
+ def self.parse_date(aString)
56
+ return Time.rfc822(aString) rescue Time.parse(aString)
57
+ end
58
+ end
59
+
60
+ require 'niouz/article'
61
+ require 'niouz/newsgroup'
62
+ require 'niouz/storage'
63
+ require 'niouz/server'
@@ -0,0 +1,82 @@
1
+ module Niouz
2
+ # Represents a news article stored as a simple text file in RFC822
3
+ # format. Only the minimum information is kept in memory by instances
4
+ # of this class:
5
+ # * the message-id (+Message-ID+ header)
6
+ # * the names of the newsgroups it is posted to (+Newsgroups+ header)
7
+ # * the date it was posted (+Date+ header)
8
+ # * overview data, generated on creation (see OVERVIEW_FMT)
9
+ #
10
+ # The rest (full header and body) are re-read from the file
11
+ # each time it is requested.
12
+ #
13
+ # None of the methods in this class ever modify the content
14
+ # of the file storing the article or the state of the instances
15
+ # once created. Thread-safe.
16
+ class Article
17
+ # Creates a new Article from the content of file +fname+.
18
+ def initialize(fname)
19
+ @file = fname
20
+ headers = File.open(fname) { |file| Niouz.parse_rfc822_header(file) }
21
+ @mid = headers['Message-ID']
22
+ @newsgroups = headers['Newsgroups'].split(/\s*,\s*/)
23
+ @date = Niouz.parse_date(headers['Date'])
24
+ # +Bytes+ and +Lines+ headers are required by the default
25
+ # overview format, but they are not generated by all clients.
26
+ # Only used for overview generation.
27
+ headers['Bytes'] ||= File.size(fname).to_s
28
+ headers['Lines'] ||= File.readlines(fname).length.to_s
29
+ @overview = OVERVIEW_FMT.collect do |h|
30
+ headers[h] ? headers[h].gsub(/(\r\n|\n\r|\n|\t)/, ' ') : nil
31
+ end.join("\t")
32
+ end
33
+
34
+ # The message identifer.
35
+ attr_reader :mid
36
+
37
+ # The list of newsgroups (names) this article is in.
38
+ attr_reader :newsgroups
39
+
40
+ # Overview of this article (see OVERVIEW_FMT).
41
+ attr_reader :overview
42
+
43
+ # Tests whether this Article already existed at the given time.
44
+ def existed_at?(aTime)
45
+ return @date >= aTime
46
+ end
47
+
48
+ # Returns the head of the article, i.e. the content of the
49
+ # associated file up to the first empty line.
50
+ def head
51
+ header = ''
52
+ File.open(@file).each_line do |line|
53
+ break if line.chomp.empty?
54
+ header << line
55
+ end
56
+ return header
57
+ end
58
+
59
+ # Returns the body of the article, i.e. the content of the
60
+ # associated file starting from the first empty line.
61
+ def body
62
+ lines = ''
63
+ in_head = true
64
+ File.open(@file).each_line do |line|
65
+ in_head = false if in_head and line.chomp.empty?
66
+ lines << line unless in_head
67
+ end
68
+ return lines
69
+ end
70
+
71
+ # Returns the full content of the article, head and body. This is
72
+ # simply the verbatim content of the associated file.
73
+ def content
74
+ return IO.read(@file)
75
+ end
76
+
77
+ def matches_groups?(groups_specs) # TODO
78
+ # See description of NEWNEWS command in RFC 977.
79
+ return true
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,114 @@
1
+ module Niouz
2
+ # Represents a newsgroup, i.e. a numbered sequence of Articles,
3
+ # identified by a name. Note that article are numbered starting from
4
+ # 1.
5
+ #
6
+ # This class does not read or write anything from the disk.
7
+ # Thread-safe (I think).
8
+ class Newsgroup
9
+ # Creates a new, empty Newsgroup.
10
+ # [+name+] the name of the Newsgroup (e.g. "comp.lang.ruby").
11
+ # [+created+] the Time the newsgroup was created (posted).
12
+ # [+description+] a short description of the newsgroup subject.
13
+ def initialize(name, creation, description)
14
+ @name, @creation, @description = name, creation, description
15
+ @articles = Array.new
16
+ @first, @last = 0, 0
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ attr_reader :name, :description
21
+
22
+ def sync
23
+ return @lock.synchronize { yield }
24
+ end
25
+
26
+ private :sync
27
+
28
+ # Returns the index of the first article (lowest numbered) in this
29
+ # group. Note that articles are indexed starting from 1, and a
30
+ # return value of 0 means the newsgroup is empty.
31
+ def first
32
+ return sync { @first }
33
+ end
34
+
35
+ # Returns the index of the last article (highest numbered) in this
36
+ # group. Note that articles are indexed starting from 1, and a
37
+ # return value of 0 means the newsgroup is empty.
38
+ def last
39
+ return sync { @last }
40
+ end
41
+
42
+ # Returns a string describing the state of this newsgroup,
43
+ # as expected by the +LIST+ and +NEWSGROUPS+ commands.
44
+ def metadata
45
+ return sync { "#@name #@last #@first y" }
46
+ end
47
+
48
+ # Tests whether this Newsgroup already existed at the given time.
49
+ def existed_at?(aTime)
50
+ return @creation >= aTime
51
+ end
52
+
53
+ # Returns an Article by number.
54
+ def [](nb)
55
+ return sync { @articles[nb - 1] }
56
+ end
57
+
58
+ # Adds a new Article to this newsgroup.
59
+ def add(article)
60
+ sync {
61
+ @articles << article
62
+ @first = 1
63
+ @last += 1
64
+ }
65
+ end
66
+
67
+ # Tests whether this newsgroup has an article numbered +nb+.
68
+ def has_article?(nb)
69
+ return sync { not @articles[nb - 1].nil? }
70
+ end
71
+
72
+ # Returns an estimation of the number of articles in this newsgroup.
73
+ def size_estimation
74
+ return sync { @last - @first + 1 }
75
+ end
76
+
77
+ # Returns the smallest valid article number strictly superior to
78
+ # +from+, or nil if there is none.
79
+ def next_article(from)
80
+ sync {
81
+ current = from + 1
82
+ while current <= @last
83
+ break if @articles[current - 1]
84
+ current += 1
85
+ end
86
+ (current > @last) ? nil : current
87
+ }
88
+ end
89
+
90
+ # Returns the greatest valid article number strictly inferior to
91
+ # +from+, or nil if there is none.
92
+ def previous_article(from)
93
+ sync {
94
+ current = from - 1
95
+ while current >= @first
96
+ break if @articles[current - 1]
97
+ current -= 1
98
+ end
99
+ (current < @first) ? nil : current
100
+ }
101
+ end
102
+
103
+ def matches_distribs?(distribs) # TODO
104
+ if distribs.nil? or distribs.empty?
105
+ return true
106
+ else
107
+ distribs.each do |dist|
108
+ return true if name[0..dist.length] == dist
109
+ end
110
+ return false
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,252 @@
1
+ # -*- ruby -*-
2
+
3
+ module Niouz
4
+ # The main entry point of the server. Creates a new NNTPSession for
5
+ # each client.
6
+ class NNTPServer < GServer
7
+ DEFAULT_PORT = 119
8
+
9
+ # The grousp/articles store to serve.
10
+ attr_accessor :store
11
+
12
+ def initialize(port = DEFAULT_PORT, host = GServer::DEFAULT_HOST)
13
+ super(port, host, Float::MAX, $stderr, true)
14
+ end
15
+
16
+ def serve(sock)
17
+ NNTPSession.new(sock, @store).serve
18
+ end
19
+ end
20
+
21
+ # An individual NNTP session with a client.
22
+ class NNTPSession
23
+ def initialize(socket, storage)
24
+ @socket, @storage = socket, storage
25
+ @group = nil
26
+ @article = nil
27
+ end
28
+
29
+ def close
30
+ @socket.close
31
+ end
32
+
33
+ # Sends a single-line response to the client
34
+ def putline(line)
35
+ @socket.write("#{line.chomp}\r\n")
36
+ end
37
+
38
+ # Sends a multi-line response (for example an article body)
39
+ # to the client.
40
+ def putlongresp(content)
41
+ content.each_line do |line|
42
+ putline line.sub(/^\./, '..')
43
+ end
44
+ putline '.'
45
+ end
46
+
47
+ # Reads a single line from the client and returns it.
48
+ def getline
49
+ return @socket.gets
50
+ end
51
+
52
+ # Reads a multi-line message from a client (normally an
53
+ # article being posted).
54
+ def getarticle
55
+ lines = []
56
+ while true
57
+ line, char = '', nil
58
+ while char != "\n"
59
+ line << (char = @socket.recv(1))
60
+ end
61
+ line.chomp!
62
+ break if line == '.'
63
+ line = line[1..-1] if line.to_s[0...2] == '..'
64
+ lines << line
65
+ end
66
+ return lines.join("\n")
67
+ end
68
+
69
+ def select_group(name)
70
+ if @storage.has_group?(name)
71
+ @group = @storage.group(name)
72
+ @article = @group.first
73
+ return "211 %d %d %d %s" % [@group.size_estimation,
74
+ @group.first,
75
+ @group.last,
76
+ @group.name] # FIXME: sync
77
+ else
78
+ return '411 no such news group'
79
+ end
80
+ end
81
+
82
+ def move_article_pointer(direction)
83
+ if @group.nil?
84
+ return '412 no newsgroup selected'
85
+ elsif @article.nil?
86
+ return '420 no current article has been selected'
87
+ else
88
+ # HACK: depends on method names
89
+ article = @group.send((direction.to_s + '_article').intern, @article)
90
+ if article
91
+ @article = article
92
+ mid = @group[@article].mid
93
+ return "223 #@article #{mid} article retrieved: request text separately"
94
+ else
95
+ return "422 no #{direction} article in this newsgroup"
96
+ end
97
+ end
98
+ end
99
+
100
+ def parse_pairs(str)
101
+ return [ str[0...2].to_i, str[2...4].to_i, str[4...6].to_i ]
102
+ end
103
+
104
+ def read_time(date, time, gmt)
105
+ year, month, day = parse_pairs(date)
106
+ year += ( year > 50 ) ? 1900 : 2000
107
+ hour, min, sec = parse_pairs(time)
108
+ if gmt =~ /GMT/i
109
+ return Time.gm(year, month, day, hour, min, sec)
110
+ else
111
+ return Time.local(year, month, day, hour, min, sec)
112
+ end
113
+ end
114
+
115
+ def send_article_part(article, nb, part)
116
+ code, method = case part
117
+ when /ARTICLE/i then [ '220', :content ]
118
+ when /HEAD/i then [ '221', :head ]
119
+ when /BODY/i then [ '222', :body ]
120
+ when /STAT/i then [ '223', nil ]
121
+ end
122
+ putline "#{code} #{nb} #{article.mid} article retrieved"
123
+ putlongresp article.send(method) if method
124
+ end
125
+
126
+ def overview(n, article)
127
+ return n.to_s + "\t" + article.overview
128
+ end
129
+
130
+ def serve
131
+ putline "200 server ready (#{PROG_NAME} -- #{PROG_VERSION})"
132
+ while (request = getline)
133
+ case request.strip
134
+ when /^GROUP\s+(.+)$/i then putline select_group($1)
135
+ when /^NEXT$/i then putline move_article_pointer(:next)
136
+ when /^LAST$/i then putline move_article_pointer(:previous)
137
+ when /^MODE\s+READER/i then putline '200 reader status acknowledged'
138
+ when /^SLAVE$/i then putline '202 slave status acknowledged'
139
+ when /^IHAVE\s*/i then putline '435 article not wanted - do not send it'
140
+ when /^DATE$/i
141
+ putline '111 ' + Time.now.gmtime.strftime("%Y%m%d%H%M%S")
142
+ when /^HELP$/i
143
+ putline "100 help text follows"
144
+ putline "."
145
+
146
+ when /^LIST$/i
147
+ putline "215 list of newsgroups follows"
148
+ @storage.each_group { |group| putline group.metadata }
149
+ putline "."
150
+
151
+ when /^LIST\s+OVERVIEW\.FMT$/i
152
+ if OVERVIEW_FMT
153
+ putline '215 order of fields in overview database'
154
+ OVERVIEW_FMT.each { |header| putline header + ':' }
155
+ putline "."
156
+ else
157
+ putline '503 program error, function not performed'
158
+ end
159
+
160
+ when /^XOVER(\s+\d+)?(-)?(\d+)?$/i
161
+ if @group.nil?
162
+ putline '412 no news group currently selected'
163
+ else
164
+ if not $1 then articles = [ @article ]
165
+ elsif not $2 then articles = [ $1.to_i ]
166
+ else
167
+ last = ($3 ? $3.to_i : @group.last)
168
+ articles = ($1.to_i .. last).select { |n| @group.has_article?(n) }
169
+ end
170
+ if articles.compact.empty? or articles == [ 0 ]
171
+ putline '420 no article(s) selected'
172
+ else
173
+ putline '224 Overview information follows'
174
+ articles.each do |nb|
175
+ putline(nb.to_s + "\t" + @group[nb].overview)
176
+ end
177
+ putline '.'
178
+ end
179
+ end
180
+
181
+ when /^NEWGROUPS\s+(\d{6})\s+(\d{6})(\s+GMT)?(\s+<.+>)?$/i
182
+ time = read_time($1, $2, $3)
183
+ distribs = ( $4 ? $4.strip.delete('<> ').split(/,/) : nil )
184
+ putline "231 list of new newsgroups follows"
185
+ @storage.each_group do |group|
186
+ if group.existed_at?(time) and group.matches_distribs?(distribs)
187
+ putline group.metadata
188
+ end
189
+ end
190
+ putline "."
191
+
192
+ when /^NEWNEWS\s+(.*)\s+(\d{6})\s+(\d{6})(\s+GMT)?\s+(<.+>)?$/i
193
+ groups = $1.split(/\s*,\s*/)
194
+ time = read_time($2, $3, $4)
195
+ distribs = ( $5 ? $5.strip.delete('<> ').split(/,/) : nil )
196
+ putline "230 list of new articles by message-id follows"
197
+ @storage.each_article do |article|
198
+ if article.existed_at?(time) and article.matches_groups?(groups) and
199
+ @storage.groups_of(article).any? { |g| g.matches_distribs?(distribs) }
200
+ putline article.mid.sub(/^\./, '..')
201
+ end
202
+ end
203
+ putline "."
204
+
205
+ when /^(ARTICLE|HEAD|BODY|STAT)\s+<(.*)>$/i
206
+ article = @storage.article($2)
207
+ if article.nil?
208
+ putline "430 no such article found"
209
+ else
210
+ send_article_part(article, nil, $1)
211
+ end
212
+
213
+ when /^(ARTICLE|HEAD|BODY|STAT)(\s+\d+)?$/i
214
+ nb = ($2 ? $2.to_i : @article )
215
+ if @group.nil?
216
+ putline '412 no newsgroup has been selected'
217
+ elsif not @group.has_article?(nb)
218
+ putline '423 no such article number in this group'
219
+ else
220
+ article = @group[@article = nb]
221
+ send_article_part(article, @article, $1)
222
+ end
223
+
224
+ when /^POST$/i # Article posting
225
+ putline '340 Send article to be posted'
226
+ article = getarticle
227
+ head = Niouz.parse_rfc822_header(article)
228
+ if not head.has_key?('Message-ID')
229
+ article = "Message-ID: #{@storage.gen_uid}\n" + article
230
+ end
231
+ if not head.has_key?('Date')
232
+ article = "Date: #{Time.now}\n" + article
233
+ end
234
+ if @storage.create_article(article)
235
+ putline '240 Article received ok'
236
+ else
237
+ putline '441 Posting failed'
238
+ end
239
+
240
+ when /^QUIT$/i # Session end
241
+ putline "205 closing connection - goodbye!"
242
+ close
243
+ return
244
+
245
+ else
246
+ putline "500 command not supported"
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ end
@@ -0,0 +1,82 @@
1
+ module Niouz
2
+ # This class manages the "database" of groups and articles.
3
+ class Storage
4
+ def initialize(dir)
5
+ File.open(File.join(dir, 'newsgroups')) do |file|
6
+ @groups = load_groups(file)
7
+ end
8
+ @pool = File.join(dir, 'articles')
9
+ @last_file_id = 0
10
+ @lock = Mutex.new
11
+ @articles = Hash.new
12
+ Dir.foreach(@pool) do |fname|
13
+ next if fname[0] == ?.
14
+ @last_file_id = [ @last_file_id, fname.to_i ].max
15
+ register_article(fname)
16
+ end
17
+ end
18
+
19
+ # Parses the newsgroups description file.
20
+ def load_groups(input)
21
+ groups = Hash.new
22
+ while g = Niouz.parse_rfc822_header(input)
23
+ date = Niouz.parse_date(g['Date-Created'])
24
+ groups[g['Name']] = Newsgroup.new(g['Name'], date, g['Description'])
25
+ end
26
+ return groups
27
+ end
28
+
29
+ def register_article(fname)
30
+ art = Article.new(File.join(@pool, fname))
31
+ @articles[art.mid] = art
32
+ art.newsgroups.each do |gname|
33
+ @groups[gname].add(art) if has_group?(gname)
34
+ end
35
+ end
36
+
37
+ private :register_article, :load_groups
38
+
39
+ def group(name)
40
+ return @groups[name]
41
+ end
42
+
43
+ def has_group?(name)
44
+ return @groups.has_key?(name)
45
+ end
46
+
47
+ def each_group
48
+ @groups.each_value {|grp| yield(grp) }
49
+ end
50
+
51
+ def article(mid)
52
+ return @lock.synchronize { @articles[mid] }
53
+ end
54
+
55
+ def groups_of(article)
56
+ return article.groups.collect { |name| @groups[name] }
57
+ end
58
+
59
+ def each_article
60
+ articles = @lock.synchronize { @articles.dup }
61
+ articles.each { |art| yield(art) }
62
+ end
63
+
64
+ def create_article(content)
65
+ begin
66
+ @lock.synchronize {
67
+ @last_file_id += 1;
68
+ fname = "%06d" % [ @last_file_id ]
69
+ File.open(File.join(@pool, fname), "w") { |f| f.write(content) }
70
+ register_article(fname)
71
+ }
72
+ return true
73
+ rescue
74
+ return false
75
+ end
76
+ end
77
+
78
+ def gen_uid
79
+ return "<" + MD5.hexdigest(Time.now.to_s) + "@" + Socket.gethostname + ">"
80
+ end
81
+ end
82
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: niouz
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 6
8
+ - 0
9
+ version: 0.6.0
10
+ platform: ruby
11
+ authors:
12
+ - Pierre-Charles David
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-12-20 00:00:00 +01:00
18
+ default_executable:
19
+ dependencies: []
20
+
21
+ description: niouz is a small, simple NNTP server suitable to set up private newsgroups for an intranet or workgroup.
22
+ email: pcdavid@gmail.com
23
+ executables:
24
+ - niouz
25
+ extensions: []
26
+
27
+ extra_rdoc_files: []
28
+
29
+ files:
30
+ - lib/niouz/article.rb
31
+ - lib/niouz/newsgroup.rb
32
+ - lib/niouz/server.rb
33
+ - lib/niouz/storage.rb
34
+ - lib/niouz.rb
35
+ - bin/niouz
36
+ has_rdoc: true
37
+ homepage: http://github.com/pcdavid/niouz
38
+ licenses: []
39
+
40
+ post_install_message:
41
+ rdoc_options: []
42
+
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ none: false
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ segments:
51
+ - 0
52
+ version: "0"
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ none: false
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ requirements: []
62
+
63
+ rubyforge_project:
64
+ rubygems_version: 1.3.7
65
+ signing_key:
66
+ specification_version: 3
67
+ summary: A small NNTP server..
68
+ test_files: []
69
+