niouz 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/niouz +45 -0
- data/lib/niouz.rb +63 -0
- data/lib/niouz/article.rb +82 -0
- data/lib/niouz/newsgroup.rb +114 -0
- data/lib/niouz/server.rb +252 -0
- data/lib/niouz/storage.rb +82 -0
- metadata +69 -0
data/bin/niouz
ADDED
@@ -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
|
data/lib/niouz.rb
ADDED
@@ -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
|
data/lib/niouz/server.rb
ADDED
@@ -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
|
+
|