niouz 0.6.0

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