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.
- 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
|
+
|