├── papercut ├── __init__.py ├── cmd │ ├── __init__.py │ ├── config.py │ └── check_health.py ├── storage │ ├── phorum_pgsql_fix.sql │ ├── phorum_mysql_fix.sql │ ├── phpbb_mysql_fix.sql │ ├── mime.py │ ├── mysql_storage.sql │ ├── __init__.py │ ├── strutil.py │ ├── forwarding_proxy.py │ ├── mbox.py │ ├── mysql.py │ ├── maildir.py │ ├── phorum_mysql.py │ └── phorum_pgsql.py ├── auth │ ├── __init__.py │ ├── mysql.py │ ├── phpnuke_phpbb_mysql_users.py │ ├── postnuke_phpbb_mysql_users.py │ ├── phpbb_mysql_users.py │ ├── phorum_pgsql_users.py │ └── phorum_mysql_users.py ├── portable_locker.py ├── papercut_cache.py └── settings.py ├── .gitignore ├── docs ├── draft-ietf-nntpext-base-15.txt ├── README.orig └── draft-ietf-nntpext-tls-nntp-01.txt ├── etc └── papercut │ ├── mbox.yaml │ ├── maildir.yaml │ ├── multimaildir.yaml │ └── phorum_mysql.yaml ├── TODO ├── LICENSE ├── CHANGES ├── README.md ├── bin └── tb2maildir ├── setup.py └── INSTALL.md /papercut/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /papercut/cmd/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.swp 4 | papercut/version.py 5 | -------------------------------------------------------------------------------- /docs/draft-ietf-nntpext-base-15.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrassler/papercut/HEAD/docs/draft-ietf-nntpext-base-15.txt -------------------------------------------------------------------------------- /papercut/storage/phorum_pgsql_fix.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE forums ADD nntp_group_name VARCHAR(30); 2 | ALTER TABLE forums ADD UNIQUE (nntp_group_name); 3 | -------------------------------------------------------------------------------- /papercut/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | # $Id: __init__.py,v 1.1 2002-04-04 23:10:20 jpm Exp $ 3 | -------------------------------------------------------------------------------- /etc/papercut/mbox.yaml: -------------------------------------------------------------------------------- 1 | # This is a simple example configuration for exposing a flat directory full of 2 | # mbox files via NNTP. 3 | mbox_path: '$HOME/Mail' 4 | nntp_hostname: localhost 5 | nntp_port: 1119 6 | storage_backend: mbox 7 | server_type: read-only 8 | log_file: ~/.papercut/logs/papercut.log 9 | -------------------------------------------------------------------------------- /etc/papercut/maildir.yaml: -------------------------------------------------------------------------------- 1 | # This is a simple example configuration for exposing a flat directory full of 2 | # mbox files via NNTP. 3 | maildir_path: '$HOME/Maildir' 4 | nntp_hostname: localhost 5 | nntp_port: 1119 6 | storage_backend: maildir 7 | server_type: read-only 8 | log_file: ~/.papercut/logs/papercut.log 9 | -------------------------------------------------------------------------------- /etc/papercut/multimaildir.yaml: -------------------------------------------------------------------------------- 1 | # This is an example configuration for exposing multiple maildirs via NNTP. 2 | nntp_hostname: localhost 3 | nntp_port: 1119 4 | storage_backend: null 5 | server_type: read-only 6 | log_file: ~/.papercut/logs/papercut.log 7 | hierarchies: 8 | my.groups: 9 | backend: maildir 10 | maildir_path: '$HOME/maildirs/groups' 11 | my.othergroups: 12 | backend: maildir 13 | maildir_path: '$HOME/maildirs/othergroups' 14 | -------------------------------------------------------------------------------- /papercut/cmd/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Johannes Grassler. See the LICENSE file for more information. 2 | import m9dicts 3 | import sys 4 | import yaml 5 | 6 | import papercut.settings 7 | 8 | CONF = papercut.settings.CONF() 9 | 10 | # Dumps merged configuration from one or more sources to standard output. 11 | 12 | def main(): 13 | yaml.dump(m9dicts.convert_to(CONF._config_dict, to_dict=True), 14 | sys.stdout, default_flow_style=False) 15 | 16 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO list: 2 | ---------- 3 | 4 | - Set the self.commands and self.extensions on the storage extensions, as some storages do not support all commands 5 | - MODE STREAM (it means several commands at the same time without waiting for responses) 6 | - Check more the patterns of searching (wildmat) -> backend.format_wildcards() -> Work in progress 7 | - Fork the server to the background automatically (using fork()?) 8 | - Make a command line option to make the server actually run on the foreground (-f option?) 9 | - Add a --verbose flag to replace the current __DEBUG__ flag 10 | -------------------------------------------------------------------------------- /papercut/storage/phorum_mysql_fix.sql: -------------------------------------------------------------------------------- 1 | # 2 | # Please change the values here as appropriate to your 3 | # setup (i.e. table name or size of the 'nntp_group_name' field) 4 | # 5 | # Warning: Do not change the field name to something else than 'nttp_group_name'! 6 | # 7 | ALTER TABLE forums ADD nntp_group_name VARCHAR(30) AFTER id; 8 | ALTER TABLE forums ADD UNIQUE (nntp_group_name); 9 | 10 | # 11 | # After dumping this file into MySQL you will need to manually update the contents 12 | # of the 'nttp_group_name' field to associate a table name / forum with a newsgroup to 13 | # be available on Papercut 14 | # -------------------------------------------------------------------------------- /papercut/storage/phpbb_mysql_fix.sql: -------------------------------------------------------------------------------- 1 | # 2 | # Please change the values here as appropriate to your 3 | # setup (i.e. table name or size of the 'nntp_group_name' field) 4 | # 5 | # Warning: Do not change the field name to something else than 'nttp_group_name'! 6 | # 7 | ALTER TABLE phpbb_forums ADD nntp_group_name VARCHAR(30) AFTER forum_name; 8 | ALTER TABLE phpbb_forums ADD UNIQUE (nntp_group_name); 9 | 10 | # 11 | # After dumping this file into MySQL you will need to manually update the contents 12 | # of the 'nttp_group_name' field to associate a table name / forum with a newsgroup to 13 | # be available on Papercut 14 | # -------------------------------------------------------------------------------- /papercut/cmd/check_health.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2004 Joao Prado Maia. See the LICENSE file for more information. 3 | 4 | import papercut.settings 5 | from nntplib import NNTP 6 | 7 | settings = papercut.settings.CONF() 8 | 9 | def main(): 10 | s = NNTP(settings.nntp_hostname, settings.nntp_port) 11 | resp, groups = s.list() 12 | # check all of the groups, just in case 13 | for group_name, last, first, flag in groups: 14 | resp, count, first, last, name = s.group(group_name) 15 | print "\nGroup", group_name, 'has', count, 'articles, range', first, 'to', last 16 | resp, subs = s.xhdr('subject', first + '-' + last) 17 | for id, sub in subs[-10:]: 18 | print id, sub 19 | s.quit() 20 | -------------------------------------------------------------------------------- /etc/papercut/phorum_mysql.yaml: -------------------------------------------------------------------------------- 1 | # This is the papercut default configuration as found in settings.py. It will 2 | # require a fair amount of effort to create the environment this configuration 3 | # expects (don't bother unless you'd like to add a NNTP gateway to a Phorum 4 | # installation). 5 | PHP_CRYPT_SALT_LENGTH: 2 6 | auth_backend: '' 7 | dbhost: localhost 8 | dbname: phorum 9 | dbpass: anonymous 10 | dbuser: anonymous 11 | forward_host: news.remotedomain.com 12 | log_file: $HOME/.papercut/logs/papercut.log 13 | max_connections: 20 14 | mbox_path: $HOME/.papercut/mboxes/ 15 | merge: merge_dicts_and_lists 16 | nntp_auth: 'no' 17 | nntp_cache: 'no' 18 | nntp_cache_expire: 10800 19 | nntp_cache_path: $HOME/.papercut/cache/ 20 | nntp_hostname: nntp.example.com 21 | nntp_port: 119 22 | nuke_table_prefix: nuke_ 23 | phorum_settings_path: /home/papercut/www/domain.com/phorum_settings/ 24 | phorum_version: 3.3.2a 25 | phpbb_table_prefix: phpbb_ 26 | server_type: read-write 27 | storage_backend: phorum_mysql 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2002 Joao Prado Maia 2 | Copyright (c) 2016 Johannes Grassler 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /papercut/portable_locker.py: -------------------------------------------------------------------------------- 1 | # Note: this was originally from Python Cookbook, which was 2 | # probably taken from ASPN's Python Cookbook 3 | 4 | import os 5 | 6 | # needs win32all to work on Windows 7 | if os.name == 'nt': 8 | import win32con, win32file, pywintypes 9 | LOCK_EX = win32con.LOCKFILE_EXCLUSIVE_LOCK 10 | LOCK_SH = 0 # the default 11 | LOCK_NB = win32con.LOCKFILE_FAIL_IMMEDIATELY 12 | __overlapped = pywintypes.OVERLAPPED( ) 13 | 14 | def lock(fd, flags): 15 | hfile = win32file._get_osfhandle(fd.fileno( )) 16 | win32file.LockFileEx(hfile, flags, 0, 0xffff0000, __overlapped) 17 | 18 | def unlock(fd): 19 | hfile = win32file._get_osfhandle(fd.fileno( )) 20 | win32file.UnlockFileEx(hfile, 0, 0xffff0000, __overlapped) 21 | 22 | elif os.name == 'posix': 23 | import fcntl 24 | LOCK_EX = fcntl.LOCK_EX 25 | LOCK_SH = fcntl.LOCK_SH 26 | LOCK_NB = fcntl.LOCK_NB 27 | 28 | def lock(fd, flags): 29 | fcntl.flock(fd.fileno(), flags) 30 | 31 | def unlock(fd): 32 | fcntl.flock(fd.fileno(), fcntl.LOCK_UN) 33 | 34 | else: 35 | raise RuntimeError("portable_locker only defined for nt and posix platforms") 36 | 37 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.11.0: 2 | 3 | Forked from https://github.com/jpm/papercut (d2552cdeb857270d888a4c1e1014b837b4b11b61 / 0.9.13) 4 | 5 | Major changes: 6 | 7 | * Restructured code tree to be suitable for setuptools packaging 8 | * Setuptools packaging (added setup.py) 9 | * Rewrote settings.py to draw its configuration from one or more YAML based 10 | configuration files and handle command line options 11 | * Added papercut_config command 12 | 13 | Minor changes: 14 | 15 | * Updated settings handling in all files that use it 16 | * Fixed encoding for forwarding_proxy.py. 17 | * Removed unneccessary shebangs. 18 | * Removed cache and logs directories. 19 | * Removed .cvsignore files. 20 | * Added .gitignore for package build artifacts. 21 | * Fixed windows style line breaks (CRLF) and non-unicode characters in various 22 | files 23 | * Robustified mbox path concatenation. 24 | * Added shell environment interpolation to configuration settings where it 25 | makes sense. 26 | * Added example configuration files. 27 | * Updated documentation and copyright information where applicable 28 | * Added change log 29 | * Removed p2p storage plugin (unfinished) 30 | * Removed CVS comments from files 31 | * Made CHANGES the authoritative source for package version 32 | -------------------------------------------------------------------------------- /papercut/auth/mysql.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import papercut.settings 4 | 5 | settings = papercut.settings.CONF() 6 | 7 | class Papercut_Auth: 8 | """ 9 | Authentication backend interface 10 | """ 11 | 12 | def __init__(self): 13 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 14 | self.cursor = self.conn.cursor() 15 | 16 | def is_valid_user(self, username, password): 17 | stmt = """ 18 | SELECT 19 | password 20 | FROM 21 | papercut_groups_auth 22 | WHERE 23 | username='%s' 24 | """ % (username) 25 | num_rows = self.cursor.execute(stmt) 26 | if num_rows == 0 or num_rows is None: 27 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 28 | return 0 29 | db_password = self.cursor.fetchone()[0] 30 | if db_password != password: 31 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 32 | return 0 33 | else: 34 | return 1 35 | 36 | -------------------------------------------------------------------------------- /papercut/storage/mime.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import re 3 | import email 4 | 5 | def get_body(subpart): 6 | doubleline_regexp = re.compile("^\.\.", re.M) 7 | body = [] 8 | found = 0 9 | raw_headers = subpart.split('\r\n') 10 | for line in raw_headers: 11 | if not found and line == '': 12 | found = 1 13 | continue 14 | if found: 15 | body.append(doubleline_regexp.sub(".", line)) 16 | return "\r\n".join(body) 17 | 18 | def get_text_message(msg_string): 19 | msg = email.message_from_string(msg_string) 20 | cnt_type = msg.get_main_type() 21 | if cnt_type == 'text': 22 | # a simple mime based text/plain message (is this even possible?) 23 | body = get_body(msg_string) 24 | elif cnt_type == 'multipart': 25 | # needs to loop thru all parts and get the text version 26 | #print 'several parts here' 27 | text_parts = {} 28 | for part in msg.walk(): 29 | if part.get_main_type() == 'text': 30 | #print 'text based part' 31 | #print part.as_string() 32 | text_parts[part.get_params()[0][0]] = get_body(part.as_string()) 33 | if 'text/plain' in text_parts: 34 | return text_parts['text/plain'] 35 | elif 'text/html' in text_parts: 36 | return text_parts['text/html'] 37 | else: 38 | # not mime based 39 | body = get_body(msg_string) 40 | return body 41 | -------------------------------------------------------------------------------- /papercut/auth/phpnuke_phpbb_mysql_users.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import papercut.settings 4 | import md5 5 | 6 | settings = papercut.settings.CONF() 7 | 8 | class Papercut_Auth: 9 | """ 10 | Authentication backend interface for the nuke port of phpBB (http://www.phpnuke.org) 11 | 12 | This backend module tries to authenticate the users against the nuke_users table. 13 | """ 14 | 15 | def __init__(self): 16 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 17 | self.cursor = self.conn.cursor() 18 | 19 | def is_valid_user(self, username, password): 20 | stmt = """ 21 | SELECT 22 | user_password 23 | FROM 24 | %susers 25 | WHERE 26 | username='%s' 27 | """ % (settings.nuke_table_prefix, username) 28 | num_rows = self.cursor.execute(stmt) 29 | if num_rows == 0 or num_rows is None: 30 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 31 | return 0 32 | db_password = self.cursor.fetchone()[0] 33 | if db_password != md5.new(password).hexdigest(): 34 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 35 | return 0 36 | else: 37 | return 1 38 | 39 | -------------------------------------------------------------------------------- /papercut/auth/postnuke_phpbb_mysql_users.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import papercut.settings 4 | import md5 5 | 6 | settings = papercut.settings.CONF() 7 | 8 | class Papercut_Auth: 9 | """ 10 | Authentication backend interface for the phpBB web message board software (http://www.phpbb.com) when used inside PostNuke. 11 | 12 | This backend module tries to authenticate the users against the phpbb_users table. 13 | """ 14 | 15 | def __init__(self): 16 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 17 | self.cursor = self.conn.cursor() 18 | 19 | def is_valid_user(self, username, password): 20 | stmt = """ 21 | SELECT 22 | pn_pass 23 | FROM 24 | nuke_users 25 | WHERE 26 | pn_uname='%s' 27 | """ % (username) 28 | num_rows = self.cursor.execute(stmt) 29 | if num_rows == 0 or num_rows is None: 30 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 31 | return 0 32 | db_password = self.cursor.fetchone()[0] 33 | if db_password != md5.new(password).hexdigest(): 34 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 35 | return 0 36 | else: 37 | return 1 38 | 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Papercut NNTP Server 2 | 3 | Papercut is a BSD licensed NNTP server written in 100% pure Python. It is 4 | intended to be extensible to the point where people can develop their own 5 | plug-ins to integrate the NNTP protocol into their applications (I will be 6 | happy to request pull requests for new plugins). 7 | 8 | The server is compliant with most of the RFC0977 standards (when they make 9 | sense and are needed) and implements a lot of RFC1036 and RFC2980 extensions to 10 | the NNTP protocol. It was tested against Netscape News, Mozilla News and tin 11 | (under Solaris) and it works properly. This fork was tested against `slrn` and 12 | was found to work. 13 | 14 | This version of papercut is a fork of the original version written by Joao 15 | Prado Maia. The original papercut source is no longer maintained, but you can 16 | still find it at . Changes made by this fork 17 | include setuptools packaging, configuration file handling, multiple backend 18 | storage backend support, and various small fixes and tweaks. 19 | 20 | A note on supported plugins: the introduction of multi backend support involved 21 | extensive changes to `papercut_nntp.py`. These changes were only tested against 22 | the maildir storage plugin since this is the plugin I primarily use. There is a 23 | fair chance I (partially) broke the other plugins in the course of this. I 24 | probably will not get around to fixing/testing them myself, but I am happy to 25 | accept pull requests both against the code itself or in the form of 26 | documentation detailing what works and what does not. 27 | 28 | -- Johannes Grassler 29 | -------------------------------------------------------------------------------- /papercut/auth/phpbb_mysql_users.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import papercut.settings 4 | import md5 5 | 6 | settings = papercut.settings.CONF() 7 | 8 | class Papercut_Auth: 9 | """ 10 | Authentication backend interface for the phpBB web message board software (http://www.phpbb.com) 11 | 12 | This backend module tries to authenticate the users against the phpbb_users table. 13 | 14 | Many thanks to Chip McClure for the work on this file. 15 | """ 16 | 17 | def __init__(self): 18 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 19 | self.cursor = self.conn.cursor() 20 | 21 | def is_valid_user(self, username, password): 22 | stmt = """ 23 | SELECT 24 | user_password 25 | FROM 26 | %susers 27 | WHERE 28 | username='%s' 29 | """ % (settings.phpbb_table_prefix, username) 30 | num_rows = self.cursor.execute(stmt) 31 | if num_rows == 0 or num_rows is None: 32 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 33 | return 0 34 | db_password = self.cursor.fetchone()[0] 35 | if db_password != md5.new(password).hexdigest(): 36 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 37 | return 0 38 | else: 39 | return 1 40 | 41 | -------------------------------------------------------------------------------- /papercut/storage/mysql_storage.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS papercut_groups; 2 | CREATE TABLE papercut_groups ( 3 | id int(10) unsigned NOT NULL auto_increment, 4 | name varchar(50) NOT NULL default '', 5 | active smallint(6) NOT NULL default '0', 6 | description varchar(255) NOT NULL default '', 7 | table_name varchar(50) NOT NULL default '', 8 | PRIMARY KEY (id), 9 | KEY name (name), 10 | KEY active (active) 11 | ) TYPE=MyISAM; 12 | 13 | DROP TABLE IF EXISTS papercut_groups_auth; 14 | CREATE TABLE papercut_groups_auth ( 15 | id int(10) unsigned NOT NULL auto_increment, 16 | sess_id varchar(32) NOT NULL default '', 17 | name varchar(50) NOT NULL default '', 18 | username varchar(50) NOT NULL default '', 19 | password varchar(50) NOT NULL default '', 20 | PRIMARY KEY (id), 21 | KEY name (name), 22 | KEY username (username) 23 | ) TYPE=MyISAM; 24 | 25 | DROP TABLE IF EXISTS papercut_default_table; 26 | CREATE TABLE papercut_default_table ( 27 | id int(10) unsigned NOT NULL default '0', 28 | datestamp datetime NOT NULL default '0000-00-00 00:00:00', 29 | thread int(10) unsigned NOT NULL default '0', 30 | parent int(10) unsigned NOT NULL default '0', 31 | author varchar(255) NOT NULL default '', 32 | subject varchar(255) NOT NULL default '', 33 | message_id varchar(255) NOT NULL default '', 34 | bytes int(10) unsigned NOT NULL default '0', 35 | line_num int(10) unsigned NOT NULL default '0', 36 | host varchar(15) NOT NULL default '', 37 | body text NOT NULL, 38 | PRIMARY KEY (id), 39 | KEY author (author), 40 | KEY datestamp (datestamp), 41 | KEY subject (subject), 42 | KEY thread (thread), 43 | KEY parent (parent) 44 | ) TYPE=MyISAM; 45 | -------------------------------------------------------------------------------- /papercut/storage/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | # $Id: __init__.py,v 1.4 2002-03-26 22:55:00 jpm Exp $ 3 | 4 | # 5 | # Papercut is a pretty dumb (some people might call it smart) server, because it 6 | # doesn't know or care where or how the Usenet articles are stored. The system 7 | # uses the concept of 'backends' to have access to the data being served by the 8 | # Usenet frontend. 9 | # 10 | # The 'Backends' of Papercut are the actual containers of the Usenet articles, 11 | # wherever they might be stored. The initial and proof of concept backend is 12 | # the Phorum (http://phorum.org) one, where the Usenet articles are actually 13 | # Phorum messages. 14 | # 15 | # If you want to create a new backend, please use the phorum_mysql.py file as 16 | # a guide for the implementation. You will need a lot of reading to understand 17 | # the NNTP protocol (i.e. how the NNTP responses should be sent back to the 18 | # user), so look under the 'docs' directory for the RFC documents. 19 | # 20 | # As a side note, Papercut is designed to be a simple as possible, so the actual 21 | # formatting of the responses are usually done on the backend itself. This is 22 | # for a reason - if Papercut had to format the information coming from the 23 | # backends unchanged, it would need to know 'too much', like the inner workings 24 | # of the MySQLdb module on the case of the Phorum backend and so on. 25 | # 26 | # Instead, Papercut expects a formatted return value from most (if not all) 27 | # methods of the backend module. This way we can abstract as much as possible 28 | # the data format of the articles, and have the main server code as simple and 29 | # fast as possible. 30 | # -------------------------------------------------------------------------------- /papercut/auth/phorum_pgsql_users.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | from pyPgSQL import PgSQL 3 | import papercut.settings 4 | import crypt 5 | import md5 6 | 7 | settings = papercut.settings.CONF() 8 | 9 | class Papercut_Auth: 10 | """ 11 | Authentication backend interface for the Phorum web message board software (http://phorum.org) 12 | 13 | This backend module tries to authenticate the users against the forums_auth table, which is 14 | used by Phorum to save its user based information, be it with a moderator level or not. 15 | """ 16 | 17 | def __init__(self): 18 | self.conn = PgSQL.connect(database=settings.dbname, user=settings.dbuser) 19 | self.cursor = self.conn.cursor() 20 | 21 | def is_valid_user(self, username, password): 22 | stmt = """ 23 | SELECT 24 | password 25 | FROM 26 | forums_auth 27 | WHERE 28 | username='%s' 29 | """ % (username) 30 | num_rows = self.cursor.execute(stmt) 31 | if num_rows == 0 or num_rows is None: 32 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 33 | return 0 34 | db_password = self.cursor.fetchone()[0] 35 | # somehow detect the version of phorum being used and guess the encryption type 36 | if len(db_password) == 32: 37 | result = (db_password != md5.new(password).hexdigest()) 38 | else: 39 | result = (db_password != crypt.crypt(password, password[:settings.PHP_CRYPT_SALT_LENGTH])) 40 | if result: 41 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 42 | return 0 43 | else: 44 | return 1 45 | -------------------------------------------------------------------------------- /papercut/auth/phorum_mysql_users.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import papercut.settings 4 | import crypt 5 | import md5 6 | 7 | settings = papercut.settings.CONF() 8 | 9 | class Papercut_Auth: 10 | """ 11 | Authentication backend interface for the Phorum web message board software (http://phorum.org) 12 | 13 | This backend module tries to authenticate the users against the forums_auth table, which is 14 | used by Phorum to save its user based information, be it with a moderator level or not. 15 | """ 16 | 17 | def __init__(self): 18 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 19 | self.cursor = self.conn.cursor() 20 | 21 | def is_valid_user(self, username, password): 22 | stmt = """ 23 | SELECT 24 | password 25 | FROM 26 | forums_auth 27 | WHERE 28 | username='%s' 29 | """ % (username) 30 | num_rows = self.cursor.execute(stmt) 31 | if num_rows == 0 or num_rows is None: 32 | settings.logEvent('Error - Authentication failed for username \'%s\' (user not found)' % (username)) 33 | return 0 34 | db_password = self.cursor.fetchone()[0] 35 | # somehow detect the version of phorum being used and guess the encryption type 36 | if len(db_password) == 32: 37 | result = (db_password != md5.new(password).hexdigest()) 38 | else: 39 | result = (db_password != crypt.crypt(password, password[:settings.PHP_CRYPT_SALT_LENGTH])) 40 | if result: 41 | settings.logEvent('Error - Authentication failed for username \'%s\' (incorrect password)' % (username)) 42 | return 0 43 | else: 44 | return 1 45 | -------------------------------------------------------------------------------- /docs/README.orig: -------------------------------------------------------------------------------- 1 | Maintainers note: This is the original README file as it shipped with Joa Prado 2 | Maia's original source code. 3 | 4 | -------------------- 5 | Papercut NNTP Server 6 | -------------------- 7 | 8 | Papercut is a news server written in 100% pure Python. It is intended to be 9 | extensible to the point where people can develop their own plug-ins and by 10 | that integrate the NNTP protocol to their applications. 11 | 12 | The server is compliant with most of the RFC0977 standards (when they make sense 13 | and are needed) and implements a lot of RFC1036 and RFC2980 extensions to the 14 | NNTP protocol. It was tested against Netscape News, Mozilla News and tin (under 15 | Solaris) and it works properly. 16 | 17 | The original need for this server was to integrate my PHP related web site 18 | forums with an NNTP gateway interface, so people could list and read the 19 | messages posted to the forums on their favorite News reader. The software on 20 | this case was Phorum (http://phorum.org) and the site is PHPBrasil.com 21 | (http://phpbrasil.com). At first it wasn't intended to support message posting, 22 | but it made sense to allow it after seeing how effective the tool was. 23 | 24 | The concept of storage modules was created exactly for this. I would create a Python 25 | class to handle the inner-workins of Phorum and MySQL and if I ever wanted to 26 | integrate the server with another type of software, I would just need to write 27 | a new storage module class. 28 | 29 | Anyway, back to the technical praise. Papercut is multi-threaded on Windows 30 | platforms and forking-based on UNIX platforms and should be reasonably fast 31 | (that means basically: 'it's pretty fast, but don't try serving 1000 connection 32 | at a time). The best thing about the application is that it is very simple to 33 | extend it. 34 | 35 | Papercut is licensed under the BSD license, which means you can sell it or do 36 | whatever you like with it. However, I ask that if you think Papercut is a good 37 | tool and you made a few enhancements to it or even fixed some bugs, please send 38 | me a patch or something. I will appreciate it :) 39 | 40 | -- Joao Prado Maia (jpm@pessoal.org) 41 | 42 | -------------------------------------------------------------------------------- /bin/tb2maildir: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # tb2maildir - converts a thundebird mail account's local storage to a maildir 4 | # 5 | # This is useful for users who have a large amount of emails in Thunderbird and 6 | # want to convert these to maildir format (for archiving or for use by maildir 7 | # based IMAP clients such as offlineimap). Converting Thunderbird's locally 8 | # stored emails in this manner conserves a lot of bandwidth. 9 | # 10 | # Requires mb2md.pl version 3.20: 11 | # 12 | # http://batleth.sapienti-sat.org/projects/mb2md/mb2md-3.20.pl.gz 13 | # 14 | # usage: 15 | # 16 | # source=~/.thunderbird/*.default/ImapMail/mail.example.com dest=~/maildir/example.com tb2maildir 17 | 18 | if [ -z "$dest" ]; then 19 | echo "No 'dest' environment variable found, aborting." 1>&2 20 | exit 1 21 | fi 22 | 23 | if [ -z "$source" ]; then 24 | echo "No 'source' environment variable found, aborting." 1>&2 25 | exit 1 26 | fi 27 | 28 | tmpdir=$(mktemp -d) 29 | 30 | cp -a $source/* $tmpdir 31 | mkdir -p $dest 32 | 33 | find $tmpdir -name '*.msf' | xargs rm 34 | 35 | # Rename mboxes with Thunderbird's weird '-1' suffix 36 | for file in $(find $tmpdir -name '*-1') 37 | do 38 | mv $file $(echo $file | sed 's/-1$//') 39 | done 40 | 41 | # Save inbox to a temporary file (all other folders need to be in INBOX/ to get 42 | # the correct prefix so we need to move it out of the way) 43 | mv "$tmpdir/INBOX" "$tmpdir/INBOX_real" 44 | 45 | for dir in $(find $tmpdir -type d -name '*.sbd' | sort -r) 46 | do 47 | mv $dir $(echo $dir | sed s/\.sbd$//) 48 | done 49 | 50 | mv $tmpdir/INBOX.sbd* $tmpdir/INBOX 51 | rmdir $tmpdir/INBOX.sbd 52 | 53 | mb2md-3.20.pl -s $tmpdir -R -d $dest 54 | mv $dest/.INBOX_real $dest/.INBOX 55 | 56 | # No top level maildir (that's INBOX) 57 | rm -rf $dest/{cur,new,tmp} 58 | 59 | # Remove leading path component (leaving it trips up offlineimap when comparing 60 | # local and remote folders) 61 | for i in $dest/.* 62 | do 63 | case $i in 64 | "$dest/..") 65 | continue 66 | ;; 67 | "$dest/.") 68 | continue 69 | ;; 70 | *) 71 | ;; 72 | esac 73 | mv "$i" "$(echo "$i" | perl -lpe "s#($dest/)\.(.*)#\$1\$2#")" 74 | done 75 | 76 | rm -rf $tmpdir 77 | -------------------------------------------------------------------------------- /papercut/storage/strutil.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import time 3 | import re 4 | 5 | singleline_regexp = re.compile("^\.", re.M) 6 | 7 | def wrap(text, width=78): 8 | """Wraps text at a specified width. 9 | 10 | This is used on the PhorumMail feature, as to emulate completely the 11 | current Phorum behavior when it sends out copies of the posted 12 | articles. 13 | """ 14 | i = 0 15 | while i < len(text): 16 | if i + width + 1 > len(text): 17 | i = len(text) 18 | else: 19 | findnl = text.find('\n', i) 20 | findspc = text.rfind(' ', i, i+width+1) 21 | if findspc != -1: 22 | if findnl != -1 and findnl < findspc: 23 | i = findnl + 1 24 | else: 25 | text = text[:findspc] + '\n' + text[findspc+1:] 26 | i = findspc + 1 27 | else: 28 | findspc = text.find(' ', i) 29 | if findspc != -1: 30 | text = text[:findspc] + '\n' + text[findspc+1:] 31 | i = findspc + 1 32 | return text 33 | 34 | def get_formatted_time(time_tuple): 35 | """Formats the time tuple in a NNTP friendly way. 36 | 37 | Some newsreaders didn't like the date format being sent using leading 38 | zeros on the days, so we needed to hack our own little format. 39 | """ 40 | # days without leading zeros, please 41 | day = int(time.strftime('%d', time_tuple)) 42 | tmp1 = time.strftime('%a,', time_tuple) 43 | tmp2 = time.strftime('%b %Y %H:%M:%S %Z', time_tuple) 44 | return "%s %s %s" % (tmp1, day, tmp2) 45 | 46 | def format_body(text): 47 | """Formats the body of message being sent to the client. 48 | 49 | Since the NNTP protocol uses a single dot on a line to denote the end 50 | of the response, we need to substitute all leading dots on the body of 51 | the message with two dots. 52 | """ 53 | return singleline_regexp.sub("..", text) 54 | 55 | def format_wildcards(pattern): 56 | return pattern.replace('*', '.*').replace('?', '.*') 57 | 58 | def format_wildcards_sql(pattern): 59 | return pattern.replace('*', '%').replace('?', '%') 60 | 61 | def filterchars(text, characters): 62 | '''Reduces string text to the characters found in string characters''' 63 | res = '' 64 | for char in text: 65 | if char in characters: 66 | res += char 67 | 68 | return res 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup, find_packages 3 | from codecs import open 4 | from os import path 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | def transfer_version(): 9 | '''Returns the version from the change log and sets it in the source tree''' 10 | cl = open(path.join(here, 'CHANGES')) 11 | version = cl.readline().split(':')[0] 12 | cl.close() 13 | version_source = open(path.join(here, 'papercut', 'version.py'), 'w') 14 | version_source.write("__VERSION__ = '%s'\n" % version) 15 | version_source.close() 16 | return version 17 | 18 | long_description = ( 19 | "Papercut is a news server written in 100% pure Python. It is intended to be " 20 | "extensible to the point where people can develop their own storage plugins " 21 | "to integrate the NNTP protocol into their applications. Out of the box it " 22 | "comes with maildir and mbox storage, a simple forwarding NNTP proxy and " 23 | "gateway plugins for various web forums. This version of papercut has been " 24 | "forked from the original version found at " 25 | "(no longer actively maintained.") 26 | 27 | setup( 28 | name='papercut', 29 | 30 | version=transfer_version(), 31 | 32 | description='A pure python NNTP server extensible through plugins.', 33 | 34 | long_description=long_description, 35 | 36 | url='https://github.com/jgrassler/papercut', 37 | author='Joao Prado Maia', 38 | author_email='jpm@pessoal.org', 39 | maintainer='Johannes Grassler', 40 | maintainer_email='johannes@btw23.de', 41 | license='BSD', 42 | 43 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 44 | classifiers=[ 45 | 'Development Status :: 4 - Beta', 46 | 'Intended Audience :: End Users/Desktop', 47 | 'Intended Audience :: Developers', 48 | 'Intended Audience :: Information Technology', 49 | 'Intended Audience :: System Administrators', 50 | 'Topic :: Communications :: Usenet News', 51 | 'Topic :: Communications :: Email', 52 | 'Topic :: Communications :: Email Clients (MUA)', 53 | 'Topic :: Communications :: Mailing List Servers', 54 | 'License :: OSI Approved :: BSD License', 55 | 'Programming Language :: Python :: 2.7', 56 | ], 57 | 58 | keywords='nntp usenet gateway mail2news email maildir mbox', 59 | packages=find_packages(), 60 | 61 | install_requires=['mysql-python', # for various web forum storage plugins and MySQL authentication 62 | 'pyaml', # for parsing config files 63 | 'm9dicts' # for deep merging configuration from multiple sources 64 | ], 65 | 66 | entry_points={ 67 | 'console_scripts': [ 68 | 'papercut=papercut.cmd.papercut_nntp:main', 69 | 'papercut_config=papercut.cmd.config:main', 70 | 'papercut_healthcheck=papercut.cmd.check_health:main', 71 | ], 72 | }, 73 | ) 74 | -------------------------------------------------------------------------------- /papercut/papercut_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | 3 | import binascii 4 | import md5 5 | import time 6 | import os 7 | import cPickle 8 | import papercut.portable_locker 9 | import papercut.settings 10 | 11 | settings = papercut.settings.CONF() 12 | 13 | 14 | # methods that need to be cached 15 | cache_methods = ('get_XHDR', 'get_XGTITLE', 'get_LISTGROUP', 16 | 'get_XPAT', 'get_XOVER', 'get_BODY', 17 | 'get_HEAD', 'get_ARTICLE', 'get_STAT', 18 | 'get_LIST') 19 | 20 | 21 | class CallableWrapper: 22 | name = None 23 | thecallable = None 24 | cacheable_methods = () 25 | 26 | def __init__(self, name, thecallable, cacheable_methods): 27 | self.name = name 28 | self.thecallable = thecallable 29 | self.cacheable_methods = cacheable_methods 30 | 31 | def __call__(self, *args, **kwds): 32 | if self.name not in self.cacheable_methods: 33 | return self.thecallable(*args, **kwds) 34 | else: 35 | filename = self._get_filename(*args, **kwds) 36 | if os.path.exists(filename): 37 | # check the expiration 38 | expire, result = self._get_cached_result(filename) 39 | diff = time.time() - expire 40 | if diff > settings.nntp_cache_expire: 41 | # remove the file and run the method again 42 | return self._save_result(filename, *args, **kwds) 43 | else: 44 | return result 45 | else: 46 | return self._save_result(filename, *args, **kwds) 47 | 48 | def _get_cached_result(self, filename): 49 | inf = open(filename, 'rb') 50 | # get a lock on the file 51 | portable_locker.lock(inf, portable_locker.LOCK_SH) 52 | expire = cPickle.load(inf) 53 | result = cPickle.load(inf) 54 | # release the lock 55 | portable_locker.unlock(inf) 56 | inf.close() 57 | return (expire, result) 58 | 59 | def _save_result(self, filename, *args, **kwds): 60 | result = self.thecallable(*args, **kwds) 61 | # save the serialized result in the file 62 | outf = open(filename, 'w') 63 | # file write lock 64 | portable_locker.lock(outf, portable_locker.LOCK_EX) 65 | cPickle.dump(time.time(), outf) 66 | cPickle.dump(result, outf) 67 | # release the lock 68 | portable_locker.unlock(outf) 69 | outf.close() 70 | return result 71 | 72 | def _get_filename(self, *args, **kwds): 73 | arguments = '%s%s%s' % (self.name, args, kwds) 74 | return '%s%s' % (settings.nntp_cache_path, binascii.hexlify(md5.new(arguments).digest())) 75 | 76 | 77 | class Cache: 78 | backend = None 79 | cacheable_methods = () 80 | 81 | def __init__(self, storage_handle, cacheable_methods): 82 | self.backend = storage_handle.Papercut_Storage() 83 | self.cacheable_methods = cacheable_methods 84 | 85 | def __getattr__(self, name): 86 | result = getattr(self.backend, name) 87 | if callable(result): 88 | result = CallableWrapper(name, result, self.cacheable_methods) 89 | return result 90 | 91 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Papercut Installation 2 | 3 | ## Requirements 4 | 5 | * Python 2.7 (note: originally this was 2.2 but as of 0.11.0 there have been a 6 | bunch of changes that were only tested on 2.7.10 and may thus rely on things 7 | not available in the Python 2.2 standard library.) 8 | * pip (for installation) 9 | * Whichever package contains `mysql_config` for your system (libmysqlclient-devel 10 | on OpenSUSE). 11 | * The following python modules (setuptools will take care of installing them): 12 | * mysql-python 13 | * pyaml 14 | * m9dicts 15 | * Optional: 16 | * pyPgSQL: you will need this module for some forum storage backends if the 17 | forum in question is using PostgreSQL. Unfortunately this module 18 | is not available from pypi at the time of this writing 19 | * Database server (only for Phorum/phpBB/PHPNuke and/or MySQL based 20 | authentication) 21 | * Permission to add a new column to one of the Phorum tables (Phorum backend 22 | only) 23 | 24 | 25 | ## Installation 26 | 27 | 1) Clone the git repository (there will be a pypi package once things are a 28 | little more stable) from https://github.com/jgrassler/papercut.git 29 | 30 | git clone https://github.com/jgrassler/papercut.git /tmp/papercut 31 | 32 | 2) Create and activate a virtualenv to it (optional but recommended) 33 | 34 | virtualenv /tmp/pcut; . /tmp/pcut/bin/activate 35 | 36 | 3) Install papercut: 37 | 38 | pip install 39 | 40 | ## Configuration 41 | 42 | Before you can run papercut you will need to create a configuration file. You 43 | can use one of the sample configuration files from the repository's 44 | etc/papercut directory. You can put your papercut configuration into 45 | /etc/papercut/papercut.yaml, ~/.papercut/papercut.yaml or specify your own 46 | location using the --config option. At a minimum you need to configure the following 47 | 48 | * Make sure the directory the `log_file` setting points to exists and is 49 | writable by the user running papercut. 50 | 51 | * Modify the `nntp_hostname` and `nntp_port` variables to your appropriate 52 | server name and port number. (Note: if you want to run Papercut on port 119, 53 | you may need to be root depending on your system) 54 | 55 | * Choose your storage backend and change the `backend_type` setting 56 | accordingly. Depending on backend you will also need to modify some backend 57 | specific settings (see below) 58 | 59 | ### Backend specific settings 60 | 61 | ## mbox 62 | 63 | You will need to point the `mbox_path` setting to a directory containing one or 64 | more mbox files. Papercut will expose these files' newsgroups, one per file. 65 | 66 | Note: This directory must only contain mbox files. No other file types or 67 | subdirectories. 68 | 69 | ## `mysql_phorum` 70 | 71 | * You will need to add a new column under the main forum listing table to 72 | associate the name of a newsgroup to a table name. Since Phorum is totally 73 | dynamic on the number of forums it can create, we need an extra column to 74 | prevent problems. 75 | 76 | ``` 77 | $ cd /tmp/papercut/papercut/storage 78 | $ cd storage 79 | $ less phorum_mysql_fix.sql 80 | [read the information contained in the file] 81 | $ mysql -u username_here -p database_here < phorum_mysql_fix.sql 82 | [password will be requested now] 83 | ``` 84 | 85 | * Now that the new column was created on the main forum listing table, you 86 | will need to edit it and enter the name of the newsgroup that you want for 87 | each forum. 88 | 89 | * After you finish editing the main forum table, you will need to go back to 90 | the papercut configuration file and configure the full path for the Phorum 91 | settings folder. That is, the folder where you keep the `forums.php` 92 | configuration file and all other files that setup the options for each 93 | forum. 94 | 95 | It will usually have `forums.php`, `1.php`, `2.php` and so on. The numbers 96 | on the filenames are actually the forum IDs on the main forum table. In any 97 | case, you will need to change the `phorum_settings_path` setting in to the 98 | full path to this folder. 99 | 100 | * You will also need to configure the version of the installed copy of Phorum so 101 | Papercut can send the correct headers when sending out copies of the posted 102 | articles (also called PhorumMail for the Phorum lovers out there). Set the 103 | `phorum_version` accordingly (i.e. `3.3.2a`). 104 | 105 | If you find any problems with these instructions, or if the instructions didn't 106 | work out for you, please let me know. 107 | -------------------------------------------------------------------------------- /papercut/storage/forwarding_proxy.py: -------------------------------------------------------------------------------- 1 | import nntplib 2 | import re 3 | import time 4 | import StringIO 5 | 6 | # We need setting.forward_host, which is the nntp server we forward to 7 | import papercut.settings 8 | 9 | settings = papercut.settings.CONF() 10 | 11 | # This is an additional backend for Papercut, currently it's more or less proof-of-concept. 12 | # It's a "forwarding proxy", that merely forwards all requests to a "real" NNTP server. 13 | # Just for fun, the post command adds an additional header. 14 | # 15 | # Written by Gerhard Häring (gerhard@bigfoot.de) 16 | 17 | def log(s): 18 | # For debugging, replace with "pass" if this gets stable one day 19 | print s 20 | 21 | class Papercut_Storage: 22 | def __init__(self): 23 | self.nntp = nntplib.NNTP(settings.forward_host) 24 | 25 | def group_exists(self, group_name): 26 | try: 27 | self.nntp.group(group_name) 28 | except nntplib.NNTPTemporaryError, reason: 29 | return 0 30 | return 1 31 | 32 | def get_first_article(self, group_name): 33 | log(">get_first_article") 34 | # Not implemented 35 | return 1 36 | 37 | def get_group_stats(self, container): 38 | # log(">get_group_stats") 39 | # Returns: (total, maximum, minimum) 40 | max, min = container 41 | return (max-min, max, min) 42 | 43 | def get_message_id(self, msg_num, group): 44 | return '<%s@%s>' % (msg_num, group) 45 | 46 | def get_NEWGROUPS(self, ts, group='%'): 47 | log(">get_NEWGROUPS") 48 | date = time.strftime("%y%m%d", ts) 49 | tim = time.strftime("%H%M%S", ts) 50 | response, groups = self.nntp.newgroups(date, tim) 51 | return "\r\n".join(["%s" % k for k in (1,2,3)]) 52 | 53 | def get_NEWNEWS(self, ts, group='*'): 54 | log(">get_NEWNEWS") 55 | articles = [] 56 | articles.append("<%s@%s>" % (id, group)) 57 | if len(articles) == 0: 58 | return '' 59 | else: 60 | return "\r\n".join(articles) 61 | 62 | def get_GROUP(self, group_name): 63 | # Returns: (total, first_id, last_id) 64 | log(">get_GROUP") 65 | response, count, first, last, name = self.nntp.group(group_name) 66 | return (count, first, last) 67 | 68 | def get_LIST(self, username=""): 69 | # Returns: list of (groupname, table) 70 | log(">get_LIST") 71 | response, lst= self.nntp.list() 72 | def convert(x): 73 | return x[0], (int(x[1]), int(x[2])) 74 | lst = map(convert, lst) 75 | return lst 76 | 77 | def get_STAT(self, group_name, id): 78 | log(">get_STAT") 79 | try: 80 | resp, nr, id = self.nntp.stat(id) 81 | return nr 82 | except nntplib.NNTPTemporaryError, reason: 83 | return None 84 | 85 | def get_ARTICLE(self, group_name, id): 86 | log(">get_ARTICLE") 87 | resp, nr, id, headerlines = self.nntp.head(id) 88 | resp, nr, id, articlelines = self.nntp.article(id) 89 | dobreak = 0 90 | while 1: 91 | if articlelines[0] == "": 92 | dobreak = 1 93 | del articlelines[0] 94 | if dobreak: 95 | break 96 | return ("\r\n".join(headerlines), "\n".join(articlelines)) 97 | 98 | def get_LAST(self, group_name, current_id): 99 | log(">get_LAST") 100 | # Not implemented 101 | return None 102 | 103 | def get_NEXT(self, group_name, current_id): 104 | log(">get_NEXT") 105 | # Not implemented 106 | return None 107 | 108 | def get_HEAD(self, group_name, id): 109 | log(">get_HEAD") 110 | resp, nr, mid, headerlines = self.nntp.head(id) 111 | return "\r\n".join(headerlines) 112 | 113 | def get_BODY(self, group_name, id): 114 | log(">get_BODY") 115 | resp, nr, mid, bodylines = self.nntp.body(id) 116 | return "\r\n".join(bodylines) 117 | 118 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 119 | # subject\tauthor\tdate\tmessage-id\treferences\tbyte count\tline count\r\n 120 | log(">get_XOVER") 121 | xov = list(self.nntp.xover(start_id, end_id)[1]) 122 | nxov = [] 123 | for entry in xov: 124 | entry = list(entry) 125 | entry[5] = "\n".join(entry[5]) 126 | nxov.append("\t".join(entry)) 127 | return "\r\n".join(nxov) 128 | 129 | def get_LIST_ACTIVE(self, pat): 130 | log(">get_LIST_ACTIVE") 131 | resp, list = self.nntp.longcmd('LIST ACTIVE %s' % pat) 132 | return list 133 | 134 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 135 | log(">get_XPAT") 136 | return None 137 | 138 | def get_LISTGROUP(self, group_name=""): 139 | log(">get_LISTGROUP") 140 | ids = [] 141 | self.nntp.putcmd("LISTGROUP %s" % group_name) 142 | while 1: 143 | curline = self.nntp.getline() 144 | if curline == ".": 145 | break 146 | ids.append(curline) 147 | return "\r\n".join(ids) 148 | 149 | def get_XGTITLE(self, pattern="*"): 150 | log(">get_XGTITLE") 151 | resp, result = self.nntp.xgtitle(pattern) 152 | return "\r\n".join(["%s %s" % (group, title) for group, title in result]) 153 | 154 | def get_XHDR(self, group_name, header, style, range): 155 | log(">get_XHDR") 156 | if style == "range": 157 | range = "-".join(range) 158 | resp, result = self.nntp.xhdr(header, range) 159 | result = map(lambda x: x[1], result) 160 | return "\r\n".join(result) 161 | 162 | def do_POST(self, group_name, lines, ip_address, username=''): 163 | log(">do_POST") 164 | while lines.find("\r") > 0: 165 | lines = lines.replace("\r", "") 166 | lns = lines.split("\n") 167 | counter = 0 168 | for l in lns: 169 | if l == "": 170 | lns.insert(counter, "X-Modified-By: Papercut's forwarding backend") 171 | break 172 | counter +=1 173 | lines = "\n".join(lns) 174 | # we need to send an actual file 175 | f = StringIO.StringIO(lines) 176 | result = self.nntp.post(f) 177 | return result 178 | -------------------------------------------------------------------------------- /papercut/storage/mbox.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | # $Id: mbox.py,v 1.7 2004-08-01 01:51:48 jpm Exp $ 3 | 4 | import os 5 | import mailbox 6 | import string 7 | 8 | import papercut.settings 9 | import papercut.storage.strutil as strutil 10 | 11 | settings = papercut.settings.CONF() 12 | 13 | class Papercut_Storage: 14 | """ 15 | Storage backend interface for mbox files 16 | """ 17 | mbox_dir = '' 18 | 19 | def __init__(self): 20 | self.mbox_dir = settings.mbox_path 21 | 22 | def get_mailbox(self, filename): 23 | return mailbox.PortableUnixMailbox(open(os.path.join(self.mbox_dir, filename))) 24 | 25 | def get_file_list(self): 26 | return os.listdir(self.mbox_dir) 27 | 28 | def get_group_list(self): 29 | groups = self.get_file_list() 30 | return ["papercut.mbox.%s" % k for k in groups] 31 | 32 | def group_exists(self, group_name): 33 | groups = self.get_group_list() 34 | found = False 35 | for name in groups: 36 | # group names are supposed to be case insensitive 37 | if string.lower(name) == string.lower(group_name): 38 | found = True 39 | break 40 | return found 41 | 42 | def get_first_article(self, group_name): 43 | return 1 44 | 45 | def get_group_stats(self, filename): 46 | total, max, min = self.get_mbox_stats(filename) 47 | return (total, min, max, filename) 48 | 49 | def get_mbox_stats(self, filename): 50 | mbox = self.get_mailbox(filename) 51 | dir(mbox) 52 | cnt = 0 53 | while mbox.next(): 54 | cnt = cnt + 1 55 | return (cnt-1, cnt, 1) 56 | 57 | def get_message_id(self, msg_num, group): 58 | return '<%s@%s>' % (msg_num, group) 59 | 60 | def get_NEWGROUPS(self, ts, group='%'): 61 | # XXX: eventually add some code in here to get the mboxes newer than the given timestamp 62 | return None 63 | 64 | def get_NEWNEWS(self, ts, group='*'): 65 | return '' 66 | 67 | def get_GROUP(self, group_name): 68 | result = self.get_mbox_stats(group_name.replace('papercut.mbox.', '')) 69 | return (result[0], result[2], result[1]) 70 | 71 | def get_LIST(self, username=""): 72 | result = self.get_file_list() 73 | if len(result) == 0: 74 | return "" 75 | else: 76 | groups = [] 77 | for mbox in result: 78 | total, maximum, minimum = self.get_mbox_stats(mbox) 79 | if settings.server_type == 'read-only': 80 | groups.append("papercut.mbox.%s %s %s n" % (mbox, maximum, minimum)) 81 | else: 82 | groups.append("papercut.mbox.%s %s %s y" % (mbox, maximum, minimum)) 83 | return "\r\n".join(groups) 84 | 85 | def get_STAT(self, group_name, id): 86 | # check if the message exists 87 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 88 | i = 0 89 | while mbox.next(): 90 | if i == int(id): 91 | return True 92 | i = i + 1 93 | return False 94 | 95 | def get_ARTICLE(self, group_name, id): 96 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 97 | i = 0 98 | while 1: 99 | msg = mbox.next() 100 | if msg is None: 101 | return None 102 | if i == int(id): 103 | return ("\r\n".join(["%s" % string.strip(k) for k in msg.headers]), msg.fp.read()) 104 | i = i + 1 105 | 106 | def get_LAST(self, group_name, current_id): 107 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 108 | if current_id == 1: 109 | return None 110 | else: 111 | i = 0 112 | while 1: 113 | msg = mbox.next() 114 | if msg is None: 115 | return None 116 | if (i+1) == current_id: 117 | return i 118 | i = i + 1 119 | 120 | def get_NEXT(self, group_name, current_id): 121 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 122 | print repr(current_id) 123 | i = 0 124 | while 1: 125 | msg = mbox.next() 126 | if msg is None: 127 | return None 128 | if i > current_id: 129 | return i 130 | i = i + 1 131 | 132 | def get_message(self, group_name, id): 133 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 134 | i = 0 135 | while 1: 136 | msg = mbox.next() 137 | if msg is None: 138 | return None 139 | if i == int(id): 140 | return msg 141 | i = i + 1 142 | 143 | def get_HEAD(self, group_name, id): 144 | msg = self.get_message(group_name, id) 145 | headers = [] 146 | headers.append("Path: %s" % (settings.nntp_hostname)) 147 | headers.append("From: %s" % (msg.get('from'))) 148 | headers.append("Newsgroups: %s" % (group_name)) 149 | headers.append("Date: %s" % (msg.get('date'))) 150 | headers.append("Subject: %s" % (msg.get('subject'))) 151 | headers.append("Message-ID: <%s@%s>" % (id, group_name)) 152 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, id)) 153 | return "\r\n".join(headers) 154 | 155 | def get_BODY(self, group_name, id): 156 | msg = self.get_message(group_name, id) 157 | if msg is None: 158 | return None 159 | else: 160 | return strutil.format_body(msg.fp.read()) 161 | 162 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 163 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 164 | # don't count the first message 165 | mbox.next() 166 | i = 1 167 | overviews = [] 168 | while 1: 169 | msg = mbox.next() 170 | if msg is None: 171 | break 172 | author = msg.get('from') 173 | formatted_time = msg.get('date') 174 | message_id = msg.get('message-id') 175 | line_count = len(msg.fp.read().split('\n')) 176 | xref = 'Xref: %s %s:%s' % (settings.nntp_hostname, group_name, i) 177 | if msg.get('in-reply-to') is not None: 178 | reference = msg.get('in-reply-to') 179 | else: 180 | reference = "" 181 | # message_number subject author date message_id reference bytes lines xref 182 | overviews.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" % (i, msg.get('subject'), author, formatted_time, message_id, reference, len(strutil.format_body(msg.fp.read())), line_count, xref)) 183 | i = i + 1 184 | return "\r\n".join(overviews) 185 | 186 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 187 | # no support for this right now 188 | return None 189 | 190 | def get_LISTGROUP(self, group_name): 191 | mbox = self.get_mailbox(group_name.replace('papercut.mbox.', '')) 192 | # don't count the first message 193 | mbox.next() 194 | i = 0 195 | ids = [] 196 | while 1: 197 | msg = mbox.next() 198 | if msg is None: 199 | break 200 | i = i + 1 201 | ids.append(i) 202 | return "\r\n".join(ids) 203 | 204 | def get_XGTITLE(self, pattern=None): 205 | # no support for this right now 206 | return None 207 | 208 | def get_XHDR(self, group_name, header, style, range): 209 | # no support for this right now 210 | return None 211 | 212 | def do_POST(self, group_name, lines, ip_address, username=''): 213 | # let's make the mbox storage always read-only for now 214 | return None 215 | -------------------------------------------------------------------------------- /papercut/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | # Copyright (c) 2016 Johannes Grassler. See the LICENSE file for more information. 3 | 4 | from __future__ import print_function 5 | 6 | import argparse 7 | import m9dicts 8 | import os 9 | import sys 10 | import time 11 | import yaml 12 | 13 | # This module handles papercut's configuration files and command line options 14 | # and is the canonical source of papercut's file basend and command line 15 | # configuration. For configuration files it will take one of two approaches: 16 | # 17 | # 1) Absent a '--config' command line option it will attempt to load its 18 | # configuration from the following configuration files: 19 | # 20 | # * /etc/papercut/papercut.yaml 21 | # * ~/papercut/papercut.yaml 22 | # 23 | # Configuration settings from ~/papercut/papercut.yaml will override 24 | # settings from /etc/papercut/papercut.yaml if they differ. 25 | # 26 | # 2) If there is a '--config' command line option it will load its 27 | # its configuration from the file specified as its argument. Both 28 | # /etc/papercut/papercut.yaml and ~/papercut/papercut.yaml will be 29 | # ignored in this case. 30 | # 31 | # In both cases any missing configuration settings will be set to the 32 | # defaults defined in this module. 33 | # 34 | # Configuration file settings are exposed through the CONF() method in global 35 | # name space. Command line options are exposed through the OPTS() in global 36 | # name space. CONF() returns the configuration file as a dictionary mirroring 37 | # the configuration files' YAML data structure. OPTS() returns the result of 38 | # ArgumentParser.parse_args(). 39 | 40 | 41 | # Default configuration to fall back on. 42 | CONFIG_DEFAULT = { 43 | ## General configuration ## 44 | 45 | # Maximum number of concurrent connections 46 | 'max_connections': 20, 47 | # Server log file (you can use shell environment variables) 48 | 'log_file': "/var/log/papercut.log", 49 | # Host name to bind to (will also be used in NNTP responses and headers) 50 | 'nntp_hostname': 'nntp.example.com', 51 | # Port to listen on 52 | 'nntp_port': 119, 53 | # Type of server ('read-only' or 'read-write') 54 | 'server_type': 'read-write', 55 | 56 | ## Authentication settings ## 57 | # Does the server need authentication ? ('yes' or 'no') 58 | 'nntp_auth': 'no', 59 | # Authentication backend that Papercut will use to authenticate the users. 60 | # Must be set if nntp_auth is 'yes'. Valid choices: mysql, 61 | # phorum_mysql_users, phorum_pgsql_users, phpbb_mysql_users, 62 | # phpnuke_phpbb_mysql_users, postnuke_phpbb_mysql_users, 63 | 'auth_backend': '', 64 | # ONLY needed for phorum_mysql_users auth module 65 | 'PHP_CRYPT_SALT_LENGTH': 2, 66 | 67 | ## Cache settings ## 68 | # Whether to enable the cache system (may need a lot of diskspace). Valid 69 | # choices are 'yes' or 'no'. 70 | 'nntp_cache': 'no', 71 | # Cache expiration interval (in seconds) 72 | 'nntp_cache_expire': 60 * 60 * 3, 73 | # Path to the directory where the cache should be kept (you can use shell 74 | # environment variables) 75 | 'nntp_cache_path': '/var/cache/papercut', 76 | 77 | ## Storage module configuration ## 78 | 79 | # Backend that Papercut will use to get (and store) the actual articles' content 80 | 'storage_backend': "phorum_mysql", 81 | 82 | ## Storage module specific settings ## 83 | # TODO: move these to their own name spaces 84 | # [forwarding_proxy] upstream NNTP server to retrieve articles from/post articles to 85 | 'forward_host': 'news.remotedomain.com', 86 | # [phorum_{mysql,pgsql}] full path to the directory where the Phorum configuration files are stored 87 | 'phorum_settings_path': "/home/papercut/www/domain.com/phorum_settings/", 88 | # [phorum_{mysql,pgsql}] the version for the installed copy of Phorum 89 | 'phorum_version': "3.3.2a", 90 | # [phorum_mysql] database connection settings 91 | 'dbhost': "localhost", 92 | 'dbname': "phorum", 93 | 'dbuser': "anonymous", 94 | 'dbpass': "anonymous", 95 | # [phpbb_mysql, phpnuke_phpbb_mysql] the prefix for the phpBB tables. Set to 96 | # 'nuke_bb' if you are using phpbb together with PHPNuke. 97 | 'phpbb_table_prefix': "phpbb_", 98 | #[phpnuke_phpbb_mysql] the prefix for the PHPNuke tables. 99 | 'nuke_table_prefix': "nuke_", 100 | # [mbox] directory where mbox files are located (you can use shell 101 | # environment variables) 102 | 'mbox_path': "$HOME/.papercut/mboxes/", 103 | # [maildir] directory where maildirs are located (you can use shell 104 | # environment variables) 105 | 'maildir_path': "$HOME/Maildir", 106 | 107 | # Hierarchy specific configuration (dict). This can be used to create 108 | # hierarchies such as my.hierarchy where your groups, e.g. 109 | # my.hierarchy.agroup, my.hierarchy.bgroup appear. For backend plugins that 110 | # support this (currently just maildir) multiple instances of the same 111 | # backend (each with its dedicated configuration) will be created. 112 | 113 | 'hierarchies': None, 114 | } 115 | 116 | # Keys that may contain a path to interpolate environment variables into. 117 | 118 | PATH_KEYS = { 119 | 'log_file': 1, 120 | 'nntp_cache_path': 1, 121 | 'mbox_path': 1, 122 | 'maildir_path': 1, 123 | } 124 | 125 | # Will hold the sole authoritative instance of the Config class below. 126 | 127 | CONFIG = None 128 | 129 | def CONF(): 130 | '''Helper function for convenient access to configuration''' 131 | if CONFIG is None: 132 | CONF = Config() 133 | return CONF.config 134 | 135 | def OPTS(): 136 | '''Helper function for convenient access to command line options''' 137 | if configuration is None: 138 | configuration = Config() 139 | return CONF.opts 140 | 141 | 142 | class ConfigurationWrapper: 143 | '''Turns configuration dictionary's top level keys into object attributes for easier handling''' 144 | def __init__(self, config): 145 | self.__dict__.update(config) 146 | self._config_dict = config 147 | 148 | def logEvent(self, msg): 149 | f = open(CONF().log_file, "a") 150 | f.write("[%s] %s\n" % (time.strftime("%a %b %d %H:%M:%S %Y", time.gmtime()), msg)) 151 | f.close() 152 | 153 | 154 | class Config: 155 | def __init__(self): 156 | self.opts = self.parse_opts() 157 | config_files = [ '/etc/papercut/papercut.yaml', os.path.expanduser('~/.papercut/papercut.yaml') ] 158 | if self.opts.config: 159 | config_files = [] 160 | for c in self.opts.config: 161 | if os.path.exists(c): 162 | config_files.append(c) 163 | else: 164 | print("WARNING: configuration file %s: no such file or directory" % c, file=sys.stderr) 165 | 166 | configs = [CONFIG_DEFAULT] 167 | 168 | for f in config_files: 169 | c = self.read_config(f) 170 | configs.append(m9dicts.make(c)) 171 | 172 | cfg_merged = self.merge_configs(configs) 173 | cfg_merged = self.path_keys(cfg_merged) 174 | self.config = ConfigurationWrapper(cfg_merged) 175 | self.check_config() 176 | 177 | 178 | def parse_opts(self): 179 | '''Parses command line options and returns them as a dict''' 180 | opts = None 181 | # Distinguish between papercut and papercut_healthcheck 182 | if os.path.basename(sys.argv[0]) == 'papercut': 183 | opts = argparse.ArgumentParser( 184 | description='%s - Papercut NNTP server' % sys.argv[0]) 185 | opts.add_argument('-c', '--config', default=None, action='append', 186 | help="Load configuration from this file (may be specified multiple times)") 187 | 188 | if os.path.basename(sys.argv[0]) == 'papercut_config': 189 | opts = argparse.ArgumentParser( 190 | description='%s - Dump merged papercut configuration on stdout' % sys.argv[0]) 191 | opts.add_argument('-c', '--config', default=None, action='append', 192 | help="Load configuration from this file (may be specified multiple times)") 193 | 194 | if os.path.basename(sys.argv[0]) == 'papercut_healthcheck': 195 | opts = argparse.ArgumentParser( 196 | description='%s - Health check for Papercut NNTP server' % sys.argv[0]) 197 | opts.add_argument('-c', '--config', default=None, action='append', 198 | help="Load configuration from this file (may be specified multiple times)") 199 | 200 | return opts.parse_args() 201 | 202 | 203 | def read_config(self, source): 204 | '''Reads configuration from file source''' 205 | try: 206 | f = open(source) 207 | except IOError as e: 208 | return {} 209 | return yaml.safe_load(f) 210 | 211 | 212 | def path_keys(self, conf): 213 | '''Interpolates environment and home directory into values that may contain paths''' 214 | for key in conf: 215 | if isinstance(conf[key], dict): 216 | conf[key] = self.path_keys(conf[key]) 217 | continue 218 | if PATH_KEYS.has_key(key): 219 | conf[key] = os.path.expandvars(conf[key]) 220 | conf[key] = os.path.expanduser(conf[key]) 221 | return conf 222 | 223 | 224 | def check_config(self): 225 | '''Performs some sanity checks on a configuration dict and automatically fix some problems''' 226 | 227 | if self.config.storage_backend is None: 228 | backend_found = None 229 | 230 | # hierarchies with illegal names 231 | bad_hierarchies = [] 232 | 233 | try: 234 | for h in self.config.hierarchies: 235 | if h.startswith('papercut'): 236 | bad_hierarchies.append(h) 237 | if self.config.hierarchies[h].has_key('backend'): 238 | backend_found = True 239 | except TypeError: 240 | pass 241 | 242 | if len(bad_hierarchies) != 0: 243 | for h in bad_hierarchies: 244 | print('Illegal hierarchy name: %s (papercut* is reserved for global storage plugins)' % h, 245 | file=sys.stderr) 246 | sys.exit(1) 247 | if backend_found is None: 248 | sys.exit('No global or hierarchy specific storage backends found. ' + 249 | 'Please configure at least one storage backend.') 250 | 251 | # check for the appropriate options 252 | if self.config.nntp_auth == 'yes' and cfg.auth_backend == '': 253 | sys.exit("Please configure the 'nntp_auth' and 'auth_backend' options correctly") 254 | 255 | # check for the trailing slash 256 | if self.config.phorum_settings_path[-1] != '/': 257 | self.config.phorum_settings_path = cfg.phorum_settings_path + '/' 258 | 259 | def merge_configs(self, configs): 260 | '''Merges a list of configuration dicts into one final configuration dict''' 261 | cfg = m9dicts.make() 262 | 263 | for config in configs: 264 | cfg.update(config, merge=m9dicts.MS_DICTS_AND_LISTS) 265 | 266 | return cfg 267 | 268 | 269 | # helper function to log information 270 | # TODO: Move this somewhere else 271 | -------------------------------------------------------------------------------- /papercut/storage/mysql.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import time 4 | import re 5 | 6 | import papercut.settings 7 | import papercut.storage.mime as mime 8 | import papercut.storage.strutil as strutil 9 | 10 | settings = papercut.settings.CONF() 11 | 12 | # we don't need to compile the regexps everytime.. 13 | singleline_regexp = re.compile("^\.", re.M) 14 | from_regexp = re.compile("^From:(.*)", re.M) 15 | subject_regexp = re.compile("^Subject:(.*)", re.M) 16 | references_regexp = re.compile("^References:(.*)<(.*)>", re.M) 17 | 18 | class Papercut_Storage: 19 | """ 20 | Storage Backend interface for saving the article information in a MySQL database. 21 | 22 | This is not a storage to implement a web board -> nntp gateway, but a standalone nntp server. 23 | """ 24 | 25 | def __init__(self): 26 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 27 | self.cursor = self.conn.cursor() 28 | 29 | def quote_string(self, text): 30 | """Quotes strings the MySQL way.""" 31 | return text.replace("'", "\\'") 32 | 33 | def get_body(self, lines): 34 | pass 35 | 36 | def get_header(self, lines): 37 | pass 38 | 39 | def group_exists(self, group_name): 40 | stmt = """ 41 | SELECT 42 | COUNT(*) AS check 43 | FROM 44 | papercut_groups 45 | WHERE 46 | LOWER(name)=LOWER('%s')""" % (group_name) 47 | self.cursor.execute(stmt) 48 | return self.cursor.fetchone()[0] 49 | 50 | def article_exists(self, group_name, style, range): 51 | table_name = self.get_table_name(group_name) 52 | stmt = """ 53 | SELECT 54 | COUNT(*) AS check 55 | FROM 56 | %s 57 | WHERE 58 | """ % (table_name) 59 | if style == 'range': 60 | stmt = "%s id > %s" % (stmt, range[0]) 61 | if len(range) == 2: 62 | stmt = "%s AND id < %s" % (stmt, range[1]) 63 | else: 64 | stmt = "%s id = %s" % (stmt, range[0]) 65 | self.cursor.execute(stmt) 66 | return self.cursor.fetchone()[0] 67 | 68 | def get_first_article(self, group_name): 69 | table_name = self.get_table_name(group_name) 70 | stmt = """ 71 | SELECT 72 | IF(MIN(id) IS NULL, 0, MIN(id)) AS first_article 73 | FROM 74 | %s""" % (table_name) 75 | num_rows = self.cursor.execute(stmt) 76 | return self.cursor.fetchone()[0] 77 | 78 | def get_group_stats(self, group_name): 79 | total, max, min = self.get_table_stats(self.get_table_name(group_name)) 80 | return (total, min, max, group_name) 81 | 82 | def get_table_stats(self, table_name): 83 | stmt = """ 84 | SELECT 85 | COUNT(id) AS total, 86 | IF(MAX(id) IS NULL, 0, MAX(id)) AS maximum, 87 | IF(MIN(id) IS NULL, 0, MIN(id)) AS minimum 88 | FROM 89 | %s""" % (table_name) 90 | num_rows = self.cursor.execute(stmt) 91 | return self.cursor.fetchone() 92 | 93 | def get_table_name(self, group_name): 94 | stmt = """ 95 | SELECT 96 | table_name 97 | FROM 98 | papercut_groups 99 | WHERE 100 | name='%s'""" % (group_name.replace('*', '%')) 101 | self.cursor.execute(stmt) 102 | return self.cursor.fetchone()[0] 103 | 104 | def get_message_id(self, msg_num, group): 105 | return '<%s@%s>' % (msg_num, group) 106 | 107 | def get_NEWGROUPS(self, ts, group='%'): 108 | return None 109 | 110 | def get_NEWNEWS(self, ts, group='*'): 111 | stmt = """ 112 | SELECT 113 | name, 114 | table_name 115 | FROM 116 | papercut_groups 117 | WHERE 118 | name='%s' 119 | ORDER BY 120 | name ASC""" % (group_name.replace('*', '%')) 121 | self.cursor.execute(stmt) 122 | result = list(self.cursor.fetchall()) 123 | articles = [] 124 | for group, table in result: 125 | stmt = """ 126 | SELECT 127 | id 128 | FROM 129 | %s 130 | WHERE 131 | UNIX_TIMESTAMP(datestamp) >= %s""" % (table, ts) 132 | num_rows = self.cursor.execute(stmt) 133 | if num_rows == 0: 134 | continue 135 | ids = list(self.cursor.fetchall()) 136 | for id in ids: 137 | articles.append("<%s@%s>" % (id, group)) 138 | if len(articles) == 0: 139 | return '' 140 | else: 141 | return "\r\n".join(articles) 142 | 143 | def get_GROUP(self, group_name): 144 | table_name = self.get_table_name(group_name) 145 | result = self.get_table_stats(table_name) 146 | return (result[0], result[2], result[1]) 147 | 148 | def get_LIST(self, username=""): 149 | stmt = """ 150 | SELECT 151 | name, 152 | table_name 153 | FROM 154 | papercut_groups 155 | WHERE 156 | LENGTH(name) > 0 157 | ORDER BY 158 | name ASC""" 159 | self.cursor.execute(stmt) 160 | result = list(self.cursor.fetchall()) 161 | if len(result) == 0: 162 | return "" 163 | else: 164 | lists = [] 165 | for group_name, table in result: 166 | total, maximum, minimum = self.get_table_stats(table) 167 | if settings.server_type == 'read-only': 168 | lists.append("%s %s %s n" % (group_name, maximum, minimum)) 169 | else: 170 | lists.append("%s %s %s y" % (group_name, maximum, minimum)) 171 | return "\r\n".join(lists) 172 | 173 | def get_STAT(self, group_name, id): 174 | table_name = self.get_table_name(group_name) 175 | stmt = """ 176 | SELECT 177 | id 178 | FROM 179 | %s 180 | WHERE 181 | id=%s""" % (table_name, id) 182 | return self.cursor.execute(stmt) 183 | 184 | def get_ARTICLE(self, group_name, id): 185 | table_name = self.get_table_name(group_name) 186 | stmt = """ 187 | SELECT 188 | id, 189 | author, 190 | subject, 191 | UNIX_TIMESTAMP(datestamp) AS datestamp, 192 | body, 193 | parent 194 | FROM 195 | %s 196 | WHERE 197 | id=%s""" % (table_name, id) 198 | num_rows = self.cursor.execute(stmt) 199 | if num_rows == 0: 200 | return None 201 | result = list(self.cursor.fetchone()) 202 | headers = [] 203 | headers.append("Path: %s" % (settings.nntp_hostname)) 204 | headers.append("From: %s" % (result[1])) 205 | headers.append("Newsgroups: %s" % (group_name)) 206 | headers.append("Date: %s" % (strutil.get_formatted_time(time.localtime(result[3])))) 207 | headers.append("Subject: %s" % (result[2])) 208 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 209 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 210 | if result[5] != 0: 211 | headers.append("References: <%s@%s>" % (result[5], group_name)) 212 | return ("\r\n".join(headers), strutil.format_body(result[4])) 213 | 214 | def get_LAST(self, group_name, current_id): 215 | table_name = self.get_table_name(group_name) 216 | stmt = """ 217 | SELECT 218 | id 219 | FROM 220 | %s 221 | WHERE 222 | id < %s 223 | ORDER BY 224 | id DESC 225 | LIMIT 0, 1""" % (table_name, current_id) 226 | num_rows = self.cursor.execute(stmt) 227 | if num_rows == 0: 228 | return None 229 | return self.cursor.fetchone()[0] 230 | 231 | def get_NEXT(self, group_name, current_id): 232 | table_name = self.get_table_name(group_name) 233 | stmt = """ 234 | SELECT 235 | id 236 | FROM 237 | %s 238 | WHERE 239 | id > %s 240 | ORDER BY 241 | id ASC 242 | LIMIT 0, 1""" % (table_name, current_id) 243 | num_rows = self.cursor.execute(stmt) 244 | if num_rows == 0: 245 | return None 246 | return self.cursor.fetchone()[0] 247 | 248 | def get_HEAD(self, group_name, id): 249 | table_name = self.get_table_name(group_name) 250 | stmt = """ 251 | SELECT 252 | id, 253 | author, 254 | subject, 255 | UNIX_TIMESTAMP(datestamp) AS datestamp, 256 | parent 257 | FROM 258 | %s 259 | WHERE 260 | id=%s""" % (table_name, id) 261 | num_rows = self.cursor.execute(stmt) 262 | if num_rows == 0: 263 | return None 264 | result = list(self.cursor.fetchone()) 265 | headers = [] 266 | headers.append("Path: %s" % (settings.nntp_hostname)) 267 | headers.append("From: %s" % (result[1])) 268 | headers.append("Newsgroups: %s" % (group_name)) 269 | headers.append("Date: %s" % (strutil.get_formatted_time(time.localtime(result[3])))) 270 | headers.append("Subject: %s" % (result[2])) 271 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 272 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 273 | if result[4] != 0: 274 | headers.append("References: <%s@%s>" % (result[4], group_name)) 275 | return "\r\n".join(headers) 276 | 277 | def get_BODY(self, group_name, id): 278 | table_name = self.get_table_name(group_name) 279 | stmt = """ 280 | SELECT 281 | body 282 | FROM 283 | %s 284 | WHERE 285 | id=%s""" % (table_name, id) 286 | num_rows = self.cursor.execute(stmt) 287 | if num_rows == 0: 288 | return None 289 | else: 290 | return strutil.format_body(self.cursor.fetchone()[0]) 291 | 292 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 293 | table_name = self.get_table_name(group_name) 294 | stmt = """ 295 | SELECT 296 | id, 297 | parent, 298 | author, 299 | subject, 300 | UNIX_TIMESTAMP(datestamp) AS datestamp, 301 | body, 302 | line_num, 303 | bytes 304 | FROM 305 | %s 306 | WHERE 307 | id >= %s""" % (table_name, start_id) 308 | if end_id != 'ggg': 309 | stmt = "%s AND id <= %s" % (stmt, end_id) 310 | self.cursor.execute(stmt) 311 | result = list(self.cursor.fetchall()) 312 | overviews = [] 313 | for row in result: 314 | message_id = "<%s@%s>" % (row[0], group_name) 315 | xref = 'Xref: %s %s:%s' % (settings.nntp_hostname, group_name, row[0]) 316 | if row[1] != 0: 317 | reference = "<%s@%s>" % (row[1], group_name) 318 | else: 319 | reference = "" 320 | # message_number subject author date message_id reference bytes lines xref 321 | overviews.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" % (row[0], row[3], row[2], strutil.get_formatted_time(time.localtime(row[4])), message_id, reference, row[7], row[6], xref)) 322 | return "\r\n".join(overviews) 323 | 324 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 325 | table_name = self.get_table_name(group_name) 326 | stmt = """ 327 | SELECT 328 | id, 329 | parent, 330 | author, 331 | subject, 332 | UNIX_TIMESTAMP(datestamp) AS datestamp, 333 | bytes, 334 | line_num 335 | FROM 336 | %s 337 | WHERE 338 | id >= %s AND""" % (table_name, header, strutil.format_wildcards(pattern), start_id) 339 | if header.upper() == 'SUBJECT': 340 | stmt = "%s AND subject REGEXP '%s'" % (stmt, strutil.format_wildcards(pattern)) 341 | elif header.upper() == 'FROM': 342 | stmt = "%s AND (author REGEXP '%s' OR email REGEXP '%s')" % (stmt, strutil.format_wildcards(pattern), strutil.format_wildcards(pattern)) 343 | elif header.upper() == 'DATE': 344 | stmt = "%s AND %s" % (stmt, pattern) 345 | if end_id != 'ggg': 346 | stmt = "%s AND id <= %s" % (stmt, end_id) 347 | num_rows = self.cursor.execute(stmt) 348 | if num_rows == 0: 349 | return None 350 | result = list(self.cursor.fetchall()) 351 | hdrs = [] 352 | for row in result: 353 | if header.upper() == 'SUBJECT': 354 | hdrs.append('%s %s' % (row[0], row[3])) 355 | elif header.upper() == 'FROM': 356 | hdrs.append('%s %s' % (row[0], row[2])) 357 | elif header.upper() == 'DATE': 358 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[4])))) 359 | elif header.upper() == 'MESSAGE-ID': 360 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 361 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 362 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 363 | elif header.upper() == 'BYTES': 364 | hdrs.append('%s %s' % (row[0], row[5])) 365 | elif header.upper() == 'LINES': 366 | hdrs.append('%s %s' % (row[0], row[6])) 367 | elif header.upper() == 'XREF': 368 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 369 | if len(hdrs) == 0: 370 | return "" 371 | else: 372 | return "\r\n".join(hdrs) 373 | 374 | def get_LISTGROUP(self, group_name): 375 | table_name = self.get_table_name(group_name) 376 | stmt = """ 377 | SELECT 378 | id 379 | FROM 380 | %s 381 | ORDER BY 382 | id ASC""" % (table_name) 383 | self.cursor.execute(stmt) 384 | result = list(self.cursor.fetchall()) 385 | return "\r\n".join(["%s" % k for k in result]) 386 | 387 | def get_XGTITLE(self, pattern=None): 388 | stmt = """ 389 | SELECT 390 | name, 391 | description 392 | FROM 393 | papercut_groups 394 | WHERE 395 | LENGTH(name) > 0""" 396 | if pattern != None: 397 | stmt = stmt + """ AND 398 | name REGEXP '%s'""" % (strutil.format_wildcards(pattern)) 399 | stmt = stmt + """ 400 | ORDER BY 401 | name ASC""" 402 | self.cursor.execute(stmt) 403 | result = list(self.cursor.fetchall()) 404 | return "\r\n".join(["%s %s" % (k, v) for k, v in result]) 405 | 406 | def get_XHDR(self, group_name, header, style, range): 407 | table_name = self.get_table_name(group_name) 408 | stmt = """ 409 | SELECT 410 | id, 411 | parent, 412 | author, 413 | subject, 414 | UNIX_TIMESTAMP(datestamp) AS datestamp, 415 | bytes, 416 | line_num 417 | FROM 418 | %s 419 | WHERE 420 | """ % (table_name) 421 | if style == 'range': 422 | stmt = '%s id >= %s' % (stmt, range[0]) 423 | if len(range) == 2: 424 | stmt = '%s AND id <= %s' % (stmt, range[1]) 425 | else: 426 | stmt = '%s id = %s' % (stmt, range[0]) 427 | if self.cursor.execute(stmt) == 0: 428 | return None 429 | result = self.cursor.fetchall() 430 | hdrs = [] 431 | for row in result: 432 | if header.upper() == 'SUBJECT': 433 | hdrs.append('%s %s' % (row[0], row[3])) 434 | elif header.upper() == 'FROM': 435 | hdrs.append('%s %s' % (row[0], row[2])) 436 | elif header.upper() == 'DATE': 437 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[4])))) 438 | elif header.upper() == 'MESSAGE-ID': 439 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 440 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 441 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 442 | elif header.upper() == 'BYTES': 443 | hdrs.append('%s %s' % (row[0], row[6])) 444 | elif header.upper() == 'LINES': 445 | hdrs.append('%s %s' % (row[0], row[7])) 446 | elif header.upper() == 'XREF': 447 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 448 | if len(hdrs) == 0: 449 | return "" 450 | else: 451 | return "\r\n".join(hdrs) 452 | 453 | def do_POST(self, group_name, body, ip_address, username=''): 454 | table_name = self.get_table_name(group_name) 455 | author = from_regexp.search(body, 0).groups()[0].strip() 456 | subject = subject_regexp.search(body, 0).groups()[0].strip() 457 | if body.find('References') != -1: 458 | references = references_regexp.search(body, 0).groups() 459 | parent_id, void = references[-1].strip().split('@') 460 | stmt = """ 461 | SELECT 462 | IF(MAX(id) IS NULL, 1, MAX(id)+1) AS next_id 463 | FROM 464 | %s""" % (table_name) 465 | num_rows = self.cursor.execute(stmt) 466 | if num_rows == 0: 467 | new_id = 1 468 | else: 469 | new_id = self.cursor.fetchone()[0] 470 | stmt = """ 471 | SELECT 472 | id, 473 | thread 474 | FROM 475 | %s 476 | WHERE 477 | id=%s 478 | GROUP BY 479 | id""" % (table_name, parent_id) 480 | num_rows = self.cursor.execute(stmt) 481 | if num_rows == 0: 482 | return None 483 | parent_id, thread_id = self.cursor.fetchone() 484 | else: 485 | stmt = """ 486 | SELECT 487 | IF(MAX(id) IS NULL, 1, MAX(id)+1) AS next_id 488 | FROM 489 | %s""" % (table_name) 490 | self.cursor.execute(stmt) 491 | new_id = self.cursor.fetchone()[0] 492 | parent_id = 0 493 | thread_id = new_id 494 | body = mime.get_body(body) 495 | stmt = """ 496 | INSERT INTO 497 | %s 498 | ( 499 | id, 500 | datestamp, 501 | thread, 502 | parent, 503 | author, 504 | subject, 505 | host, 506 | body, 507 | bytes, 508 | line_num 509 | ) VALUES ( 510 | %s, 511 | NOW(), 512 | %s, 513 | %s, 514 | '%s', 515 | '%s', 516 | '%s', 517 | '%s', 518 | %s, 519 | %s 520 | ) 521 | """ % (table_name, new_id, thread_id, parent_id, self.quote_string(author), self.quote_string(subject), ip_address, self.quote_string(body), len(body), len(body.split('\n'))) 522 | if not self.cursor.execute(stmt): 523 | return None 524 | else: 525 | return 1 526 | -------------------------------------------------------------------------------- /papercut/storage/maildir.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2004 Scott Parish, Joao Prado Maia 2 | # See the LICENSE file for more information. 3 | 4 | # 5 | # Maildir backend for papercut 6 | # 7 | # Notes: 8 | # 9 | # Currently the numeric message ids are based off the number of 10 | # files in that group's directy. This means that if you change 11 | # a file name, or delete a file you are going to change ids, which 12 | # in turn is going to confuse nntp clients! 13 | # 14 | # To add a new group: 15 | # mkdir -p /home/papercut/maildir/my.new.group/{new,cur,tmp} 16 | # 17 | 18 | import dircache 19 | from fnmatch import fnmatch 20 | import glob 21 | import os 22 | import mailbox 23 | import rfc822 24 | import socket 25 | import string 26 | import time 27 | import StringIO 28 | 29 | from stat import ST_MTIME 30 | 31 | import papercut.storage.strutil as strutil 32 | import papercut.settings 33 | 34 | settings = papercut.settings.CONF() 35 | 36 | 37 | def maildir_date_cmp(a, b): 38 | """compare maildir file names 'a' and 'b' for sort()""" 39 | a = os.path.basename(a) 40 | b = os.path.basename(b) 41 | # Extract portion before first dot (timestamp) 42 | a = a[: a.find(".")] 43 | b = b[: b.find(".")] 44 | # Sanitize timestamp for cases where two files happen to have the same time 45 | # stamp and had non-digits added to distinguish them (in the case where 46 | # this problem cropped up there was '_0' and '_1' appended at the end of 47 | # the time stamp.). 48 | a = strutil.filterchars(a, string.digits) 49 | b = strutil.filterchars(b, string.digits) 50 | return cmp(int(a), int(b)) 51 | 52 | 53 | def new_to_cur(groupdir): 54 | for f in os.listdir(os.path.join(groupdir, 'new')): 55 | ofp = os.path.join(groupdir, 'new', f) 56 | nfp = os.path.join(groupdir, 'cur', f + ":2,") 57 | try: 58 | os.rename(ofp, nfp) 59 | except OSError: 60 | # This may have been moved already, with dircache not knowing about 61 | # it, yet. 62 | print("DEBUG: ofp = %s: %s" % (ofp, os.path.exists(ofp)) ) 63 | print("DEBUG: nfp = %s: %s" % (nfp, os.path.exists(nfp)) ) 64 | pass 65 | 66 | 67 | class HeaderCache: 68 | ''' 69 | Caches the message headers returned by the XOVER command and indexes them by 70 | file name, message ID and article number. 71 | ''' 72 | 73 | def __init__(self, path): 74 | self.path = path 75 | self.cache = {} # Message cache 76 | self.dircache = {} # Group directory caches 77 | self.midindex = {} # Global message ID index 78 | 79 | # Only attempt to read/create caches if caching is enabled 80 | for group in dircache.listdir(self.path): 81 | self.create_cache(group) 82 | 83 | def _idtofile(self, group, articleid): 84 | '''Converts an group/article ID to a file name''' 85 | articledir = os.path.join(self.path, group, 'cur') 86 | articles = self.dircache[group] 87 | return os.path.join(self.path, articledir, articles[articleid-1]) 88 | 89 | def message_byid(self, group, articleid): 90 | ''' 91 | Retrieve an article by article ID in its group or None if the article ID is 92 | not valid. 93 | ''' 94 | 95 | filename = self._idtofile(group, articleid) 96 | 97 | try: 98 | return self.cache[filename] 99 | except IndexError: 100 | return None 101 | 102 | 103 | def message_bymid(self, message_id): 104 | ''' 105 | Return the metadata dict corresponding to a message ID or None if the 106 | message ID is unknown. 107 | ''' 108 | 109 | try: 110 | return self.cache[self.midindex[message_id]] 111 | except KeyError: 112 | return None 113 | 114 | 115 | def create_cache(self, group): 116 | '''Create an entirely new cache for a group (in memory and on disk)''' 117 | groupdir = os.path.join(self.path, group) 118 | new_to_cur(groupdir) 119 | curdir = os.path.join(groupdir, 'cur') 120 | 121 | self.refresh_dircache(group) 122 | 123 | for message in self.dircache[group]: 124 | filename = os.path.join(curdir, message) 125 | self.refresh_article(filename, group, curdir) 126 | 127 | 128 | def refresh_dircache(self, group): 129 | ''' 130 | Refresh internal directory cache for a group. We need to keep this cache 131 | since the dircache one performs a stat() on the directory for each 132 | invocation which makes it disastrously slow in a place like message_byid() 133 | that may be called thousands of times when processing a XOVER. 134 | ''' 135 | # Sanitize group name to prevent directory traversal 136 | group.replace('..', '') 137 | 138 | groupdir = os.path.join(self.path, group) 139 | 140 | # Abort if there is no such group 141 | if not os.path.exists(groupdir): 142 | return 143 | 144 | new_to_cur(groupdir) 145 | curdir = os.path.join(groupdir, 'cur') 146 | 147 | if not self.dircache.has_key(group): 148 | self.dircache[group] = [] 149 | 150 | # Keep a copy of old cache for cleanup of stale entries 151 | oldcache = self.dircache[group] 152 | 153 | self.dircache[group] = dircache.listdir(curdir) 154 | self.dircache[group].sort(maildir_date_cmp) 155 | 156 | # Iterate over both the old and new cache to process new entries and clean 157 | # up old ones 158 | for i in range(0, max(len(self.dircache), len(oldcache))): 159 | # Create new entries 160 | try: 161 | filename = os.path.join(curdir, self.dircache[group][i]) 162 | if not self.cache.has_key(filename): 163 | self.refresh_article(filename, group, curdir) 164 | except IndexError: 165 | # Either or self.dircache may be shorter, causing IndexError. We can 166 | # safely ignore these. 167 | pass 168 | 169 | # Get rid of stale entries 170 | try: 171 | filename = os.path.join(curdir, oldcache[i]) 172 | oldmid = self.cache[filename]['headers']['message-id'] 173 | if not os.path.exists(filename): 174 | self.cache.pop(filename) 175 | self.midindex.pop(oldmid) 176 | except IndexError: 177 | # Either or self.dircache may be shorter, causing IndexError. We can 178 | # safely ignore these. 179 | pass 180 | 181 | 182 | def refresh_article(self, filename, group, curdir): 183 | ret = self.read_message(filename, group) 184 | mid = ret['headers']['message-id'] 185 | 186 | self.cache[filename] = ret 187 | self.midindex[mid] = filename 188 | 189 | def read_message(self, filename, group): 190 | '''Reads an RFC822 message and creates a data structure containing selected metadata''' 191 | f = open(filename) 192 | 193 | # Count lines and bytes 194 | l = f.read().split('\n') 195 | lines = len(l) 196 | message_bytes = 0 197 | for line in l: 198 | message_bytes += len(line) 199 | message_bytes += lines # newlines are bytes, too 200 | 201 | f.seek(0) 202 | 203 | m = rfc822.Message(f) 204 | 205 | # Create in-memory data structure with readline() support to minimize I/O 206 | headers = StringIO.StringIO(''.join(m.headers)) 207 | m = rfc822.Message(headers) 208 | 209 | f.close() 210 | 211 | mid = m.getheader('message-id') 212 | 213 | # Sometimes messages may not have a Message-ID: header. Technically this 214 | # should not happen. If it is missing anyway, generate a message ID from 215 | # the file name for messages that lack one. 216 | if mid is None: 217 | basename = os.path.basename(filename) 218 | try: 219 | hostname = basename.split('.')[2].split(',')[0] 220 | except IndexError: 221 | hostname = papercut 222 | 223 | # strip host name from filename 224 | basename = basename.replace(hostname, '') 225 | 226 | # Remove all nonalphanumeric characters 227 | basename = strutil.filterchars(basename, string.letters + string.digits) 228 | 229 | mid = basename + '@' + hostname 230 | 231 | metadata = { 232 | 'filename': filename, 233 | 'timestamp': time.time(), 234 | 'lines': lines, 235 | 'bytes': message_bytes, 236 | 'group': group, 237 | 'headers': { 238 | 'date': m.getheader('date'), 239 | 'from': m.getheader('from'), 240 | 'message-id': mid, 241 | 'subject': m.getheader('subject'), 242 | 'references': m.getheader('references'), 243 | } 244 | 245 | } 246 | 247 | # Make sure no headers are None and remove embedded newlines 248 | for header in metadata['headers']: 249 | h = metadata['headers'] 250 | if h[header] is None: 251 | h[header] = '' 252 | else: 253 | h[header] = h[header].replace('\n', '') 254 | metadata['headers'] = h 255 | 256 | return metadata 257 | 258 | 259 | def read_cache(self, group): 260 | '''Reads cache for a group from disk and populates in-memory data structures''' 261 | # TODO: Implement this if it turns out to be neccessary at some stage. 262 | open(os.path.join(self.path, group)) 263 | 264 | def write_cache(group): 265 | '''Write in-memory cache for a group to disk''' 266 | # TODO: Implement this if it turns out to be neccessary at some stage. 267 | 268 | 269 | class Papercut_Storage: 270 | """ 271 | Storage backend interface for mbox files 272 | """ 273 | _proc_post_count = 0 274 | 275 | # Capabilities of this storage backend 276 | capabilities = { 277 | 'message-id': True, # Regular message IDs supported (For ARTICLE, HEAD, STAT) 278 | } 279 | 280 | def __init__(self, group_prefix="papercut.maildir.", local_settings={}): 281 | self.maildir_path = settings.maildir_path 282 | self.group_prefix = group_prefix 283 | 284 | # Override global settings with hierarchy specific ones 285 | self.__dict__.update(local_settings) 286 | 287 | self.cache = HeaderCache(self.maildir_path) 288 | 289 | def _get_group_dir(self, group): 290 | return os.path.join(self.maildir_path, group) 291 | 292 | 293 | def _groupname2group(self, group_name): 294 | return group_name.replace(self.group_prefix, '', 1).strip('.') 295 | 296 | 297 | def _group2groupname(self, group): 298 | return self.group_prefix + group 299 | 300 | 301 | def _new_to_cur(self, group): 302 | new_to_cur(self._get_group_dir(group)) 303 | self.cache.refresh_dircache(group) 304 | 305 | def get_groupname_list(self): 306 | groups = dircache.listdir(self.maildir_path) 307 | group_list = [] 308 | for group in groups: 309 | group_list.append("%s.%s" % (self.group_prefix, group)) 310 | return group_list 311 | 312 | 313 | def get_group_article_list(self, group): 314 | self._new_to_cur(group) 315 | self.cache.refresh_dircache(group) 316 | try: 317 | articles = self.cache.dircache[group] 318 | return articles 319 | except KeyError: 320 | return [] 321 | 322 | 323 | def get_group_article_count(self, group): 324 | self._new_to_cur(group) 325 | self.cache.refresh_dircache(group) 326 | articles = self.cache.dircache[group] 327 | return len(articles) 328 | 329 | 330 | def group_exists(self, group_name): 331 | groupnames = self.get_groupname_list() 332 | found = False 333 | 334 | for name in groupnames: 335 | # group names are supposed to be case insensitive 336 | if string.lower(name) == string.lower(group_name): 337 | found = True 338 | break 339 | 340 | return found 341 | 342 | 343 | def get_first_article(self, group_name): 344 | return 1 345 | 346 | 347 | def get_group_stats(self, group_name): 348 | group = self._groupname2group(group_name) 349 | total, max, min = self.get_maildir_stats(group) 350 | return (total, min, max, group) 351 | 352 | 353 | def get_maildir_stats(self, group_name): 354 | cnt = len(self.get_group_article_list(group_name)) 355 | return cnt, cnt, 1 356 | 357 | 358 | def get_article_number(self, mid): 359 | ''' 360 | Converts Message ID to group/article number tuple 361 | ''' 362 | 363 | msg = self.cache.message_bymid(mid) 364 | 365 | if not msg: 366 | return [None, -1] 367 | 368 | group = msg['group'] 369 | article = os.path.basename(msg['filename']) 370 | self.cache.refresh_dircache(group) 371 | try: 372 | article_id = self.cache.dircache[group].index(article) 373 | return [group, article_id] 374 | except ValueError: 375 | # Article has been deleted, but we can at least return the group it 376 | # used to be in. 377 | return [group, -1] 378 | 379 | 380 | def get_message_id(self, msg_num, group_name): 381 | ''' 382 | Converts group/article number to message ID 383 | ''' 384 | try: 385 | msg_num = int(msg_num) 386 | except ValueError: 387 | # Non-numeric, so it's probably a message ID already. 388 | return msg_num 389 | group = self._groupname2group(group_name) 390 | msg = self.cache.message_byid(group, msg_num) 391 | try: 392 | return msg['headers']['message-id'] 393 | except KeyError: 394 | return None 395 | 396 | 397 | def get_NEWGROUPS(self, ts, group='%'): 398 | return None 399 | 400 | 401 | def get_NEWNEWS(self, ts, group='*'): 402 | groups = [] 403 | for token in group.split(','): 404 | # This will still break some wildcards (e.g. *foo.bar 405 | # if the prefix is foo.bar.baz) but it will at least work for 406 | # full group names. 407 | token = self._groupname2group(token) 408 | glob_target = os.path.join(self.maildir_path, token) 409 | groups.extend(glob.glob(glob_target)) 410 | 411 | # Nonexistent groups and/or patterns that do not match 412 | if len(groups) == 0: 413 | return '' 414 | 415 | mids = [] 416 | res = [] 417 | for group in groups: 418 | groupdir = os.path.join(self.maildir_path, group, "cur") 419 | 420 | # Make sure we have an up-to-date cache for retrieving message IDs 421 | self.cache.refresh_dircache(group) 422 | 423 | articles = self.cache.dircache[group] 424 | 425 | for article in articles: 426 | apath = os.path.join(groupdir, article) 427 | if os.path.getmtime(apath) < ts: 428 | continue 429 | try: 430 | res.append(self.cache.cache[apath]['headers']['message-id']) 431 | except KeyError: 432 | pass 433 | 434 | if len(res) == 0: 435 | return '' 436 | else: 437 | return "\r\n".join(res) 438 | 439 | 440 | def get_GROUP(self, group_name): 441 | group = self._groupname2group(group_name) 442 | result = self.get_maildir_stats(group) 443 | return (result[0], result[2], result[1]) 444 | 445 | 446 | def get_LIST(self, username=""): 447 | result = self.get_groupname_list() 448 | 449 | if len(result) == 0: 450 | return "" 451 | 452 | else: 453 | groups = [] 454 | mutable = ('y', 'n')[settings.server_type == 'read-only'] 455 | 456 | for group_name in result: 457 | group = self._groupname2group(group_name) 458 | total, maximum, minimum = self.get_maildir_stats(group) 459 | groups.append("%s %s %s %s" % (group_name, maximum, 460 | minimum, mutable)) 461 | return "\r\n".join(groups) 462 | 463 | 464 | def get_STAT(self, group_name, id): 465 | # check if the message exists 466 | try: 467 | id = int(id) 468 | group = self._groupname2group(group_name) 469 | return id <= self.get_group_article_count(group) 470 | except ValueError: 471 | # Treat non-numeric ID as Message-ID 472 | msg = self.cache.message_bymid(id) 473 | if msg: 474 | group = msg['group'] 475 | self.cache.refresh_dircache(group) 476 | try: 477 | article_id = self.cache.dircache[group].index(os.path.basename(msg['filename'])) 478 | except ValueError: 479 | return False 480 | return article_id <= len(self.cache.dircache[group]) 481 | else: 482 | return False 483 | 484 | 485 | 486 | def get_message(self, group_name, id): 487 | group = self._groupname2group(group_name) 488 | 489 | filename = '' 490 | 491 | try: 492 | id = int(id) 493 | try: 494 | article = self.get_group_article_list(group)[id - 1] 495 | filename = os.path.join(self.maildir_path, group, "cur", article) 496 | except IndexError: 497 | return None 498 | except ValueError: 499 | # Treat non-numeric ID as Message-ID 500 | try: 501 | filename = self.cache.message_bymid(id.strip())['filename'] 502 | except TypeError: 503 | # message_bymid() returned None 504 | return None 505 | 506 | try: 507 | return rfc822.Message(open(filename)) 508 | except IOError: 509 | return None 510 | 511 | 512 | 513 | def get_ARTICLE(self, group_name, id): 514 | msg = self.get_message(group_name, id) 515 | if not msg: 516 | return None 517 | return ("\r\n".join(["%s" % string.strip(k) for k in msg.headers]), msg.fp.read()) 518 | 519 | def _sanitize_id(self, article_id): 520 | try: 521 | article_id = int(article_id) 522 | return article_id 523 | except ValueError: 524 | # non-numeric ID is garbage 525 | return None 526 | 527 | def get_LAST(self, group_name, current_id): 528 | current_id = self._sanitize_id(current_id) 529 | if not current_id: 530 | return None 531 | if current_id <= 1: 532 | return None 533 | return current_id - 1 534 | 535 | 536 | def get_NEXT(self, group_name, current_id): 537 | current_id = self._sanitize_id(current_id) 538 | if not current_id: 539 | return None 540 | group = self._groupname2group(group_name) 541 | if current_id >= self.get_group_article_count(group): 542 | return None 543 | return current_id + 1 544 | 545 | 546 | def get_HEAD(self, group_name, id): 547 | msg = self.get_message(group_name, id) 548 | headers = [] 549 | headers.append("Path: %s" % (settings.nntp_hostname)) 550 | headers.append("From: %s" % (msg.get('from'))) 551 | headers.append("Newsgroups: %s" % (group_name)) 552 | headers.append("Date: %s" % (msg.get('date'))) 553 | headers.append("Subject: %s" % (msg.get('subject'))) 554 | headers.append("Message-ID: <%s@%s>" % (id, group_name)) 555 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, 556 | group_name, id)) 557 | return "\r\n".join(headers) 558 | 559 | 560 | def get_BODY(self, group_name, id): 561 | msg = self.get_message(group_name, id) 562 | if msg is None: 563 | return None 564 | else: 565 | return strutil.format_body(msg.fp.read()) 566 | 567 | 568 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 569 | group = self._groupname2group(group_name) 570 | start_id = int(start_id) 571 | if end_id == 'ggg': 572 | end_id = self.get_group_article_count(group) 573 | else: 574 | end_id = int(end_id) 575 | 576 | overviews = [] 577 | 578 | # Refresh directory cache to get a reasonably current view 579 | self.cache.refresh_dircache(group) 580 | 581 | # Adjust end ID downwards if it is out of range 582 | if end_id >= len(self.cache.dircache[group]): 583 | end_id = len(self.cache.dircache[group]) - 1 584 | 585 | for id in range(start_id, end_id + 1): 586 | msg = self.cache.message_byid(self._groupname2group(group_name), id) 587 | 588 | if msg is None: 589 | break 590 | 591 | author = msg['headers']['from'] 592 | formatted_time = msg['headers']['date'] 593 | message_id = msg['headers']['message-id'] 594 | line_count = msg['lines'] 595 | xref = 'Xref: %s %s:%d' % (settings.nntp_hostname, group_name, id) 596 | 597 | subject = msg['headers']['subject'] 598 | reference = msg['headers']['references'] 599 | msg_bytes = msg['bytes'] 600 | # message_number subject author date 601 | # message_id reference bytes lines xref 602 | 603 | overviews.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" % \ 604 | (id, subject, author, 605 | formatted_time, message_id, reference, 606 | msg_bytes, 607 | line_count, xref)) 608 | 609 | return "\r\n".join(overviews) 610 | 611 | 612 | # UNTESTED 613 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 614 | group = self._groupname2group(group_name) 615 | header = header.upper() 616 | start_id = int(start_id) 617 | if end_id == 'ggg': 618 | end_id = self.get_group_article_count(group) 619 | else: 620 | end_id = int(end_id) 621 | 622 | hdrs = [] 623 | for id in range(start_id, end_id + 1): 624 | 625 | if header == 'MESSAGE-ID': 626 | msg_id = self.get_message_id(id, group_name) 627 | if fnmatch(msg_id, pattern): 628 | hdrs.append('%d %s' % (id, msg_id)) 629 | continue 630 | elif header == 'XREF': 631 | xref = '%s %s:%d' % (settings.nntp_hostname, group_name, id) 632 | if fnmatch(xref, pattern): 633 | hdrs.append('%d %s' % (id, xref)) 634 | continue 635 | 636 | msg = self.get_message(group_name, id) 637 | if header == 'BYTES': 638 | msg.fp.seek(0, 2) 639 | bytes = msg.fp.tell() 640 | if fnmatch(str(bytes), pattern): 641 | hdrs.append('%d %d' % (id, bytes)) 642 | elif header == 'LINES': 643 | lines = len(msg.fp.readlines()) 644 | if fnmatch(str(lines), pattern): 645 | hdrs.append('%d %d' % (id, lines)) 646 | else: 647 | hdr = msg.get(header) 648 | if hdr and fnmatch(hdr, pattern): 649 | hdrs.append('%d %s' % (id, hdr)) 650 | 651 | if len(hdrs): 652 | return "\r\n".join(hdrs) 653 | else: 654 | return "" 655 | 656 | 657 | def get_LISTGROUP(self, group_name): 658 | group = self._groupname2group(group_name) 659 | self.cache.refresh_dircache(group) 660 | ids = range(1, len(self.cache.dircache[group]) + 1) 661 | ids = [str(id) for id in ids] 662 | return "\r\n".join(ids) 663 | 664 | def get_XGTITLE(self, pattern=None): 665 | # XXX no support for this right now 666 | return '' 667 | 668 | 669 | def get_XHDR(self, group_name, header, style, ranges): 670 | group = self._groupname2group(group_name) 671 | header = header.upper() 672 | 673 | if style == 'range': 674 | if len(ranges) == 2: 675 | range_end = int(ranges[1]) 676 | else: 677 | range_end = self.get_group_article_count(group) 678 | ids = range(int(ranges[0]), range_end + 1) 679 | else: 680 | ids = [ranges] 681 | 682 | hdrs = [] 683 | for id in ids: 684 | mid = self.get_message_id(id, group_name) 685 | meta = self.cache.message_bymid(mid) 686 | 687 | if meta is None: 688 | # Message ID unknown 689 | return "" 690 | 691 | msg = self.get_message(group_name, id) 692 | 693 | if header == 'MESSAGE-ID': 694 | hdrs.append('%d %s' % \ 695 | (id, self.get_message_id(id, group_name))) 696 | continue 697 | elif header == 'XREF': 698 | hdrs.append('%d %s %s:%d' % (id, settings.nntp_hostname, 699 | group_name, id)) 700 | continue 701 | 702 | if header == 'BYTES': 703 | hdrs.append('%d %d' % (id, meta['bytes'])) 704 | elif header == 'LINES': 705 | hdrs.append('%d %d' % (id, meta['lines'])) 706 | else: 707 | hdr = msg.get(header) 708 | if hdr: 709 | hdrs.append('%s %s' % (id, hdr)) 710 | 711 | if len(hdrs) == 0: 712 | return "" 713 | else: 714 | return "\r\n".join(hdrs) 715 | 716 | 717 | def do_POST(self, group_name, body, ip_address, username=''): 718 | self._proc_post_count += 1 719 | count = self._proc_post_count 720 | 721 | ts = [int(x) for x in str(time.time()).split(".")] 722 | file = "%d.M%dP%dQ%d.%s" % (ts[0], ts[1], os.getpid(), 723 | count, socket.gethostname()) 724 | group = self._groupname2group(group_name) 725 | groupdir = self._get_group_dir(group) 726 | tfpath = os.path.join(self.maildir_path, groupdir, "tmp", file) 727 | nfpath = os.path.join(self.maildir_path, groupdir, "new", file) 728 | 729 | fd = open(tfpath, 'w') 730 | fd.write(body) 731 | fd.close 732 | 733 | os.rename(tfpath, nfpath) 734 | self.cache.refresh_dircache(group) 735 | return 1 736 | 737 | -------------------------------------------------------------------------------- /docs/draft-ietf-nntpext-tls-nntp-01.txt: -------------------------------------------------------------------------------- 1 | Network Working Group J. Vinocur 2 | INTERNET DRAFT Cornell University 3 | Document: draft-ietf-nntpext-tls-nntp-01.txt C. Newman 4 | Sun Microsystems 5 | October 2003 6 | 7 | 8 | Using TLS with NNTP 9 | 10 | 11 | Status of this memo 12 | 13 | This document is an Internet-Draft and is in full conformance with 14 | all provisions of Section 10 of RFC 2026. 15 | 16 | Internet-Drafts are working documents of the Internet Engineering 17 | Task Force (IETF), its areas, and its working groups. Note that 18 | other groups may also distribute working documents as 19 | Internet-Drafts. 20 | 21 | Internet-Drafts are draft documents valid for a maximum of six 22 | months and may be updated, replaced, or obsoleted by other 23 | documents at any time. It is inappropriate to use Internet-Drafts 24 | as reference material or to cite them other than as "work in 25 | progress." 26 | 27 | The list of current Internet-Drafts can be accessed at 28 | http://www.ietf.org/ietf/1id-abstracts.html. 29 | 30 | The list of Internet-Draft Shadow Directories can be accessed at 31 | http://www.ietf.org/shadow.html. 32 | 33 | Copyright Notice 34 | 35 | Copyright (C) The Internet Society (2002). All Rights Reserved. 36 | 37 | Abstract 38 | 39 | This memo defines an extension to the Network News Transport 40 | Protocol [NNTP] to provide connection-based encryption (via 41 | Transport Layer Security [TLS]). The primary goal is to provide 42 | encryption for single-link confidentiality purposes, but data 43 | integrity and (optional) certificate-based peer entity 44 | authentication are also possible. 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Vinocur & Newman Expires April 2004 [Page 1] 53 | 54 | Internet Draft TLS for NNTP October 2003 55 | 56 | 57 | Table of Contents 58 | 59 | 0. Changes from Previous Version ............................ 2 60 | 1. Introduction ............................................. 3 61 | 1.1. Conventions Used in this Document ................... 3 62 | 2. Advertising Capabilities with the Extensions Mechanism ... 3 63 | 3. STARTTLS Command ......................................... 4 64 | 3.1. Usage ............................................... 4 65 | 3.2. Description ......................................... 4 66 | 3.2.1. Processing After the STARTTLS Command .......... 5 67 | 3.2.2. Result of the STARTTLS Command ................. 6 68 | 3.3. Examples ............................................ 7 69 | 4. Augmented BNF Syntax for STARTTLS ........................ 8 70 | 5. Security Considerations .................................. 8 71 | 6. Acknowledgements ......................................... 10 72 | 7. Normative References ..................................... 10 73 | 8. Informative References ................................... 10 74 | 9. Authors' Addresses ....................................... 11 75 | 76 | 0. Changes from Previous Version 77 | 78 | New: 79 | o Text needed to comply with extensions framework guidelines: 80 | - Allows 483 to be returned for most commands 81 | - No pipelining 82 | - Not impacted by MODE READER 83 | o Examples section 84 | 85 | Changed: 86 | o Welcome banner is *not* reissued after STARTTLS 87 | o STARTTLS on an already-secure link gives 502 (not 580) 88 | o Failed negotiation gives 580 on the reestablished insecure link 89 | o Removed MULTIDOMAIN, need is resolved by RFC 3546 (a SHOULD) 90 | o Removed definition of 483, which is now included in base spec 91 | o Use HDR instead of PAT in the LIST EXTENSIONS example 92 | 93 | Clarified: 94 | o When the capability can be advertised 95 | o The specifc octet where encrypted session begins 96 | 97 | Other: 98 | o Reformatting to match base spec style 99 | o Assorted updates of phrasing and typographical varieties 100 | o Updated several references per new versions of documents 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Vinocur & Newman Expires April 2004 [Page 2] 109 | 110 | Internet Draft TLS for NNTP October 2003 111 | 112 | 113 | 1. Introduction 114 | 115 | Historically, unencrypted NNTP [NNTP] connections were satisfactory 116 | for most purposes. However, sending passwords unencrypted over the 117 | network is no longer appropriate, and sometimes strong encryption 118 | is desired for the entire connection. 119 | 120 | The STARTTLS extension provides a way to use the popular TLS [TLS] 121 | service with the existing NNTP protocol. The current 122 | (unstandardized) use of TLS for NNTP is most commonly on a 123 | dedicated TCP port; this practice is discouraged for the reasons 124 | documented in section 7 of "Using TLS with IMAP, POP3 and ACAP" 125 | [TLS-IMAPPOP]. Therefore, this specification formalizes and 126 | extends the STARTTLS command already in occasional use by the 127 | installed base. 128 | 129 | 1.1. Conventions Used in this Document 130 | 131 | The key words "REQUIRED", "MUST", "MUST NOT", "SHOULD", "SHOULD 132 | NOT", "MAY", and "OPTIONAL" in this document are to be interpreted 133 | as described in "Key words for use in RFCs to Indicate Requirement 134 | Levels" [KEYWORDS]. 135 | 136 | Terms related to authentication are defined in "On Internet 137 | Authentication" [AUTH]. 138 | 139 | This document assumes you are familiar with NNTP [NNTP] and TLS 140 | [TLS]. 141 | 142 | In the examples, commands from the client are indicated with [C], 143 | and responses from the server are indicated with [S]. 144 | 145 | 2. Advertising Capabilities with the Extensions Mechanism 146 | 147 | The LIST EXTENSIONS command, documented in section 8 of [NNTP], 148 | provides a mechanism for clients to discover what extensions are 149 | available. 150 | 151 | A server supporting the STARTTLS command as defined in section 4 152 | will advertise the "STARTTLS" capability in response to the LIST 153 | EXTENSIONS command. However, this capability is not advertised 154 | after successful authentication [NNTP-AUTH], nor is it advertised 155 | once a TLS layer is active (see section 4.2.2). This capability 156 | may be advertised both before and after any use of MODE READER, 157 | with the same semantics. 158 | 159 | As the STARTTLS command is related to security, cached results of 160 | LIST EXTENSIONS from a previous session MUST NOT be used, as per 161 | 162 | 163 | 164 | Vinocur & Newman Expires April 2004 [Page 3] 165 | 166 | Internet Draft TLS for NNTP October 2003 167 | 168 | 169 | section 11.6 of [NNTP]. 170 | 171 | Example: 172 | [C] LIST EXTENSIONS 173 | [S] 202 Extensions supported: 174 | [S] OVER 175 | [S] HDR 176 | [S] LISTGROUP 177 | [S] STARTTLS 178 | [S] . 179 | 180 | Note that the STARTTLS command constitutes a mode change and thus 181 | clients MUST wait for completion prior to sending additional 182 | commands. 183 | 184 | 3. STARTTLS Command 185 | 186 | 3.1. Usage 187 | 188 | This command MUST NOT be pipelined. 189 | 190 | Syntax 191 | STARTTLS 192 | 193 | Responses 194 | 382 Continue with TLS negotiation 195 | 403 TLS temporarily not available 196 | 501 Command not supported or command syntax error 197 | 502 Command unavailable [1] 198 | 580 TLS negotiation failed 199 | 200 | [1] If a TLS layer is already active, or authentication has 201 | occurred, STARTTLS is not a valid command (see sections 4.2 and 202 | 4.2.2). 203 | 204 | Clients MUST support other response codes by processing them based 205 | on the first digit. However, the server MUST NOT return 483 in 206 | response to STARTTLS. (See section 3.2.1 of [NNTP].) 207 | 208 | 3.2. Description 209 | 210 | A client issues the STARTTLS command to request negotiation of TLS. 211 | The client MUST NOT send any additional commands on the socket 212 | until after it has received the server response to the command; 213 | this command MUST NOT be pipelined as per section 3.2 of [NNTP]. 214 | The STARTTLS command is usually used to request session encryption, 215 | although it can be used for client certificate authentication. 216 | 217 | 218 | 219 | 220 | Vinocur & Newman Expires April 2004 [Page 4] 221 | 222 | Internet Draft TLS for NNTP October 2003 223 | 224 | 225 | An NNTP server MAY require the client to perform a TLS negotiation 226 | before accepting any commands. In this case, the server SHOULD 227 | return the 483 encryption-required response code to every command 228 | other than HELP, LIST EXTENSIONS, QUIT, and any commands that 229 | establish encryption, such as STARTTLS; the server MUST NOT return 230 | 483 in response to these commands. Additionally, the client MAY 231 | decide to establish a security layer without first receiving a 483 232 | response. 233 | 234 | If the client receives a failure response to STARTTLS, the client 235 | must decide whether or not to continue the NNTP session. Such a 236 | decision is based on local policy. For instance, if TLS was being 237 | used for client authentication, the client might try to continue 238 | the session, in case the server allows it to do so even with no 239 | authentication. However, if TLS was being negotiated for 240 | encryption, a client that gets a failure response needs to decide 241 | whether to continue without TLS encryption, to wait and try again 242 | later, or to give up and notify the user of the error. 243 | 244 | After receiving a 382 response to a STARTTLS command, the client 245 | MUST start the TLS negotiation before giving any other NNTP 246 | commands. The TLS negotiation begins with the first octet 247 | following the CRLF of the 382 response. If, after having issued 248 | the STARTTLS command, the client finds out that some failure 249 | prevents it from actually starting a TLS handshake, then it SHOULD 250 | immediately close the connection. 251 | 252 | Servers MUST be able to understand backwards-compatible TLS Client 253 | Hello messages (provided that client_version is TLS 1.0 or later), 254 | and clients MAY use backwards-compatible Client Hello messages. 255 | Neither clients or servers are required to actually support Client 256 | Hello messages for anything other than TLS 1.0. However, the TLS 257 | extension for Server Name Indication [TLS-EXT] SHOULD be 258 | implemented by all clients; it also SHOULD be implemented by any 259 | server implementing STARTTLS that is known by multiple names 260 | (otherwise it is not possible for a server with several hostnames 261 | to present the correct certificate to the client). 262 | 263 | Although current use of TLS most often involves the dedication of 264 | port 563 for NNTP over TLS, the continued use of TLS on a separate 265 | port is discouraged for the reasons documented in section 7 of 266 | "Using TLS with IMAP, POP3 and ACAP" [TLS-IMAPPOP]. 267 | 268 | 3.2.1. Processing After the STARTTLS Command 269 | 270 | After the TLS handshake has been completed successfully, both 271 | parties MUST immediately decide whether or not to continue based on 272 | the authentication and privacy achieved. The NNTP client and 273 | 274 | 275 | 276 | Vinocur & Newman Expires April 2004 [Page 5] 277 | 278 | Internet Draft TLS for NNTP October 2003 279 | 280 | 281 | server may decide to move ahead even if the TLS negotiation ended 282 | with no authentication and/or no privacy because NNTP services are 283 | often performed without authentication or privacy, but some NNTP 284 | clients or servers may want to continue only if a particular level 285 | of authentication and/or privacy was achieved. 286 | 287 | If the NNTP client decides that the level of authentication or 288 | privacy is not high enough for it to continue, it SHOULD issue a 289 | QUIT command immediately after the TLS negotiation is complete. If 290 | the NNTP server decides that the level of authentication or privacy 291 | is not high enough for it to continue, it SHOULD do at least one of 292 | (1) close the connection, being aware that the client may interpret 293 | this behavior as a network problem and immediately reconnect and 294 | issue the same command sequence, or (2) keep the connection open 295 | and reply to NNTP commands from the client with the 483 response 296 | code (with a possible text string such as "Command refused due to 297 | lack of security"), however this behavior may tie up resources 298 | unacceptably. 299 | 300 | The decision of whether or not to believe the authenticity of the 301 | other party in a TLS negotiation is a local matter. However, some 302 | general rules for the decisions are: 303 | 304 | o The client MAY check that the identity presented in the server's 305 | certificate matches the intended server hostname or domain. 306 | This check is not required (and may fail in the absence of the 307 | TLS server_name extension [TLS-EXT], as described above), but if 308 | it is implemented and the match fails, the client SHOULD either 309 | request explicit user confirmation, or terminate the connection 310 | but allow the user to disable the check in the future. 311 | o Generally an NNTP server would want to accept any verifiable 312 | certificate from a client, however authentication can be done 313 | using the client certificate (perhaps in combination with the 314 | SASL EXTERNAL mechanism [NNTP-AUTH], although an implementation 315 | supporting STARTTLS is not required to support SASL in general 316 | or that mechanism in particular). The server MAY use 317 | information about the client certificate for identification of 318 | connections or posted articles (either in its logs or directly 319 | in posted articles). 320 | 321 | 3.2.2. Result of the STARTTLS Command 322 | 323 | If the TLS handshake fails in such a way that recovery is possible, 324 | the server will send a 580 response (without encryption), beginning 325 | with the first post-handshake octet. 326 | 327 | Upon successful completion of the TLS handshake, the NNTP protocol 328 | is reset to the initial state (the state in NNTP directly after the 329 | 330 | 331 | 332 | Vinocur & Newman Expires April 2004 [Page 6] 333 | 334 | Internet Draft TLS for NNTP October 2003 335 | 336 | 337 | connection is established). The server MUST discard any knowledge 338 | obtained from the client, such as the current newsgroup and article 339 | number, that was not obtained from the TLS negotiation itself; 340 | immediately after the TLS handshake, the server MUST NOT issue a 341 | welcome banner and MUST be prepared to accept commands from the 342 | client. The client MUST discard any knowledge obtained from the 343 | server, such as the list of NNTP service extensions, which was not 344 | obtained from the TLS negotiation itself. 345 | 346 | The extensions returned in response to a LIST EXTENSIONS command 347 | received after the TLS handshake MAY be different than the list 348 | returned before the TLS handshake. For example, an NNTP server 349 | supporting SASL [NNTP-AUTH] might not want to advertise support for 350 | a particular mechanism unless a client has sent an appropriate 351 | client certificate during a TLS handshake. 352 | 353 | Both the client and the server MUST know if there is a TLS session 354 | active. A client MUST NOT attempt to start a TLS session if a TLS 355 | session is already active. A server MUST NOT return the STARTTLS 356 | extension in response to a LIST EXTENSIONS command received after a 357 | TLS handshake has completed, and a server MUST respond with a 502 358 | response code if a STARTTLS command is received while a TLS session 359 | is already active. 360 | 361 | 3.3. Examples 362 | 363 | Example of a client being prompted to use encryption and 364 | negotiating it successfully (showing the removal of STARTTLS from 365 | the extensions list once a TLS layer is active), followed by an 366 | (inappropriate) attempt by the client to initiate another TLS 367 | negotiation: 368 | [C] LIST EXTENSIONS 369 | [S] 202 Extensions supported: 370 | [S] STARTTLS 371 | [S] OVER 372 | [S] . 373 | [C] GROUP local.confidential 374 | [S] 483 Encryption or stronger authentication required 375 | [C] STARTTLS 376 | [S] 382 Continue with TLS negotiation 377 | [TLS negotiation occurs here] 378 | [Following successful negotiation, traffic is via the TLS layer] 379 | [C] LIST EXTENSIONS 380 | [S] 202 Extensions supported: 381 | [S] OVER 382 | [S] . 383 | [C] STARTTLS 384 | [S] 502 STARTTLS not allowed with active TLS layer 385 | 386 | 387 | 388 | Vinocur & Newman Expires April 2004 [Page 7] 389 | 390 | Internet Draft TLS for NNTP October 2003 391 | 392 | 393 | Example of a request to begin TLS negotiation declined by the 394 | server: 395 | [C] STARTTLS 396 | [S] 403 TLS temporarily not available 397 | 398 | 4. Augmented BNF Syntax for STARTTLS 399 | 400 | This amends the formal syntax for NNTP [NNTP] to add the STARTTLS 401 | command. The syntax is defined using ABNF [ABNF], including the 402 | core rules from section 6 of [ABNF]. 403 | 404 | command /= starttls-command 405 | starttls-command = "STARTTLS" *WSP CRLF 406 | ; WSP and CRLF are defined in sec. 13 of [NNTP] 407 | 408 | 5. Security Considerations 409 | 410 | In general, the security considerations of the TLS protocol [TLS] 411 | and any implemented extensions [TLS-EXT] are applicable here; only 412 | the most important are highlighted specifically below. Also, this 413 | extension is not intended to cure the security considerations 414 | described in section 14 of [NNTP]; those considerations remain 415 | relevant to any NNTP implementation. 416 | 417 | Use of STARTTLS cannot protect protocol exchanges conducted prior 418 | to authentication. For this reason, the LIST EXTENSIONS command 419 | SHOULD be re-issued after successful negotiation of a security 420 | layer, and other protocol state SHOULD be re-negotiated as well. 421 | 422 | It should be noted that NNTP is not an end-to-end mechanism. Thus, 423 | if an NNTP client/server pair decide to add TLS privacy, they are 424 | securing the transport only for that link. Further, because 425 | delivery of a single piece of news may go between more than two 426 | NNTP servers, adding TLS privacy to one pair of servers does not 427 | mean that the entire NNTP chain has been made private. Further, 428 | just because an NNTP server can authenticate an NNTP client, it 429 | does not mean that the articles from the NNTP client were 430 | authenticated by the NNTP client when the client received them. 431 | 432 | Both the NNTP client and server must check the result of the TLS 433 | negotiation to see whether an acceptable degree of authentication 434 | and privacy was achieved. Ignoring this step completely 435 | invalidates using TLS for security. The decision about whether 436 | acceptable authentication or privacy was achieved is made locally, 437 | is implementation-dependent, and is beyond the scope of this 438 | document. 439 | 440 | The NNTP client and server should note carefully the result of the 441 | 442 | 443 | 444 | Vinocur & Newman Expires April 2004 [Page 8] 445 | 446 | Internet Draft TLS for NNTP October 2003 447 | 448 | 449 | TLS negotiation. If the negotiation results in no privacy, or if 450 | it results in privacy using algorithms or key lengths that are 451 | deemed not strong enough, or if the authentication is not good 452 | enough for either party, the client may choose to end the NNTP 453 | session with an immediate QUIT command, or the server may choose 454 | not to accept any more NNTP commands. 455 | 456 | The client and server should also be aware that the TLS protocol 457 | permits privacy and security capabilities to be renegotiated mid- 458 | connection (see section 7.4.1 of [TLS]). For example, one of the 459 | parties may desire minimal encryption after any authentication 460 | steps have been performed. This underscores the fact that security 461 | is not present simply because TLS has been negotiated; the nature 462 | of the established security layer must be considered. 463 | 464 | A man-in-the-middle attack can be launched by deleting the 382 465 | response from the server. This would cause the client not to try to 466 | start a TLS session. Another man-in-the-middle attack is to allow 467 | the server to announce its STARTTLS capability, but to alter the 468 | client's request to start TLS and the server's response. An NNTP 469 | client can partially protect against these attacks by recording the 470 | fact that a particular NNTP server offers TLS during one session 471 | and generating an alarm if it does not appear in the LIST 472 | EXTENSIONS response for a later session (of course, the STARTTLS 473 | extension would not be listed after a security layer is in place). 474 | 475 | If the TLS negotiation fails or if the client receives a 483 476 | response, the client has to decide what to do next. The client has 477 | to choose among three main options: to go ahead with the rest of 478 | the NNTP session, to retry TLS at a later time, or to give up and 479 | postpone newsreading activity. If a failure or error occurs, the 480 | client can assume that the server may be able to negotiate TLS in 481 | the future, and should try to negotiate TLS in a later session. 482 | However, if the client and server were only using TLS for 483 | authentication and no previous 480 response was received, the 484 | client may want to proceed with the NNTP session, in case some of 485 | the operations the client wanted to perform are accepted by the 486 | server even if the client is unauthenticated. 487 | 488 | Before the TLS handshake has begun, any protocol interactions are 489 | performed in the clear and may be modified by an active attacker. 490 | For this reason, clients and servers MUST discard any sensitive 491 | knowledge obtained prior to the start of the TLS handshake upon 492 | completion of the TLS handshake. 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | Vinocur & Newman Expires April 2004 [Page 9] 501 | 502 | Internet Draft TLS for NNTP October 2003 503 | 504 | 505 | 6. Acknowledgements 506 | 507 | A significant amount of the STARTTLS text was lifted from RFC 3207 508 | by Paul Hoffman. 509 | 510 | Special acknowledgement goes also to the people who commented 511 | privately on intermediate revisions of this document, as well as 512 | the members of the IETF NNTP Working Group for continual insight in 513 | discussion. 514 | 515 | 7. Normative References 516 | 517 | [ABNF] Crocker, D., Overell, P., "Augmented BNF for Syntax 518 | Specifications: ABNF", RFC 2234, November 1997. 519 | 520 | [AUTH] Haller, N., Atkinson, R., "On Internet Authentication", RFC 1704, 521 | October 1994. 522 | 523 | [KEYWORDS] Bradner, S., "Key words for use in RFCs to Indicate 524 | Requirement Levels", RFC 2119, March 1997. 525 | 526 | [NNTP] Feather, C., "Network News Transport Protocol" 527 | (draft-ietf-nntpext-base-20.txt). 528 | 529 | [SMTP] Klensin, J., "Simple Mail Transport Protocol", RFC 2821, April 530 | 2001. 531 | 532 | [TLS] Dierks, T., Allen, C., "The TLS Protocol Version 1.0", RFC 2246, 533 | January 1999. 534 | 535 | [TLS-EXT] Blake-Wilson, S., Nystrom, M., Hopwood, D., Mikkelsen, J., 536 | Wright, T., "Transport Layer Security (TLS) Extensions", RFC 3546, June 537 | 2003. 538 | 539 | [TLS-IMAPPOP] Newman, C., "Using TLS with IMAP, POP3 and ACAP", RFC 540 | 2595, June 1999. 541 | 542 | 8. Informative References 543 | 544 | [HTTP] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., Masinter, 545 | L., Leach, P., Berners-Lee, T., "Hypertext Transfer Protocol -- 546 | HTTP/1.1", RFC 2616, June 1999. 547 | 548 | [NNTP-AUTH] Vinocur, J., Newman, C., "NNTP Extension for 549 | Authentication", Work in Progress. 550 | 551 | 552 | 553 | 554 | 555 | 556 | Vinocur & Newman Expires April 2004 [Page 10] 557 | 558 | Internet Draft TLS for NNTP October 2003 559 | 560 | 561 | 9. Authors' Addresses 562 | 563 | Jeffrey M. Vinocur 564 | Department of Computer Science 565 | Upson Hall 566 | Cornell University 567 | Ithaca, NY 14853 568 | 569 | EMail: vinocur@cs.cornell.edu 570 | 571 | 572 | Chris Newman 573 | Sun Microsystems 574 | 1050 Lakes Drive, Suite 250 575 | West Covina, CA 91790 576 | 577 | EMail: cnewman@iplanet.com 578 | 579 | Full Copyright Statement 580 | 581 | Copyright (C) The Internet Society (2002). All Rights Reserved. 582 | 583 | This document and translations of it may be copied and furnished to 584 | others, and derivative works that comment on or otherwise explain 585 | it or assist in its implementation may be prepared, copied, 586 | published and distributed, in whole or in part, without restriction 587 | of any kind, provided that the above copyright notice and this 588 | paragraph are included on all such copies and derivative works. 589 | However, this document itself may not be modified in any way, such 590 | as by removing the copyright notice or references to the Internet 591 | Society or other Internet organizations, except as needed for the 592 | purpose of developing Internet standards in which case the 593 | procedures for copyrights defined in the Internet Standards process 594 | must be followed, or as required to translate it into languages 595 | other than English. 596 | 597 | The limited permissions granted above are perpetual and will not be 598 | revoked by the Internet Society or its successors or assigns. 599 | 600 | This document and the information contained herein is provided on 601 | an "AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET 602 | ENGINEERING TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR 603 | IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF 604 | THE INFORMATION HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED 605 | WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. 606 | 607 | 608 | 609 | 610 | 611 | 612 | Vinocur & Newman Expires April 2004 [Page 11] 613 | 614 | 615 | -------------------------------------------------------------------------------- /papercut/storage/phorum_mysql.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | import MySQLdb 3 | import time 4 | from mimify import mime_encode_header, mime_decode_header 5 | import re 6 | import smtplib 7 | import md5 8 | 9 | import papercut.storage.mime as mime 10 | import papercut.settings 11 | import papercut.storage.strutil as strutil 12 | from papercut.version import __VERSION__ 13 | 14 | settings = papercut.settings.CONF() 15 | 16 | # patch by Andreas Wegmann to fix the handling of unusual encodings of messages 17 | q_quote_multiline = re.compile("=\?(.*?)\?[qQ]\?(.*?)\?=.*?=\?\\1\?[qQ]\?(.*?)\?=", re.M | re.S) 18 | # we don't need to compile the regexps everytime.. 19 | doubleline_regexp = re.compile("^\.\.", re.M) 20 | singleline_regexp = re.compile("^\.", re.M) 21 | from_regexp = re.compile("^From:(.*)<(.*)>", re.M) 22 | subject_regexp = re.compile("^Subject:(.*)", re.M) 23 | references_regexp = re.compile("^References:(.*)<(.*)>", re.M) 24 | lines_regexp = re.compile("^Lines:(.*)", re.M) 25 | # phorum configuration files related regexps 26 | moderator_regexp = re.compile("(.*)PHORUM\['ForumModeration'\](.*)='(.*)';", re.M) 27 | url_regexp = re.compile("(.*)PHORUM\['forum_url'\](.*)='(.*)';", re.M) 28 | admin_regexp = re.compile("(.*)PHORUM\['admin_url'\](.*)='(.*)';", re.M) 29 | server_regexp = re.compile("(.*)PHORUM\['forum_url'\](.*)='(.*)http://(.*)/(.*)';", re.M) 30 | mail_code_regexp = re.compile("(.*)PHORUM\['PhorumMailCode'\](.*)=(.*)'(.*)';", re.M) 31 | 32 | class Papercut_Storage: 33 | """ 34 | Storage Backend interface for the Phorum web message board software (http://phorum.org) 35 | 36 | This is the interface for Phorum running on a MySQL database. For more information 37 | on the structure of the 'storage' package, please refer to the __init__.py 38 | available on the 'storage' sub-directory. 39 | """ 40 | 41 | def __init__(self): 42 | self.conn = MySQLdb.connect(host=settings.dbhost, db=settings.dbname, user=settings.dbuser, passwd=settings.dbpass) 43 | self.cursor = self.conn.cursor() 44 | 45 | def get_message_body(self, headers): 46 | """Parses and returns the most appropriate message body possible. 47 | 48 | The function tries to extract the plaintext version of a MIME based 49 | message, and if it is not available then it returns the html version. 50 | """ 51 | return mime.get_text_message(headers) 52 | 53 | def quote_string(self, text): 54 | """Quotes strings the MySQL way.""" 55 | return text.replace("'", "\\'") 56 | 57 | def group_exists(self, group_name): 58 | stmt = """ 59 | SELECT 60 | COUNT(*) AS total 61 | FROM 62 | forums 63 | WHERE 64 | LOWER(nntp_group_name)=LOWER('%s')""" % (group_name) 65 | self.cursor.execute(stmt) 66 | return self.cursor.fetchone()[0] 67 | 68 | def article_exists(self, group_name, style, range): 69 | table_name = self.get_table_name(group_name) 70 | stmt = """ 71 | SELECT 72 | COUNT(*) AS total 73 | FROM 74 | %s 75 | WHERE 76 | approved='Y'""" % (table_name) 77 | if style == 'range': 78 | stmt = "%s AND id > %s" % (stmt, range[0]) 79 | if len(range) == 2: 80 | stmt = "%s AND id < %s" % (stmt, range[1]) 81 | else: 82 | stmt = "%s AND id = %s" % (stmt, range[0]) 83 | self.cursor.execute(stmt) 84 | return self.cursor.fetchone()[0] 85 | 86 | def get_first_article(self, group_name): 87 | table_name = self.get_table_name(group_name) 88 | stmt = """ 89 | SELECT 90 | IF(MIN(id) IS NULL, 0, MIN(id)) AS first_article 91 | FROM 92 | %s 93 | WHERE 94 | approved='Y'""" % (table_name) 95 | num_rows = self.cursor.execute(stmt) 96 | return self.cursor.fetchone()[0] 97 | 98 | def get_group_stats(self, group_name): 99 | total, max, min = self.get_table_stats(self.get_table_name(group_name)) 100 | return (total, min, max, group_name) 101 | 102 | def get_table_stats(self, table_name): 103 | stmt = """ 104 | SELECT 105 | COUNT(id) AS total, 106 | IF(MAX(id) IS NULL, 0, MAX(id)) AS maximum, 107 | IF(MIN(id) IS NULL, 0, MIN(id)) AS minimum 108 | FROM 109 | %s 110 | WHERE 111 | approved='Y'""" % (table_name) 112 | num_rows = self.cursor.execute(stmt) 113 | return self.cursor.fetchone() 114 | 115 | def get_table_name(self, group_name): 116 | stmt = """ 117 | SELECT 118 | table_name 119 | FROM 120 | forums 121 | WHERE 122 | nntp_group_name='%s'""" % (group_name.replace('*', '%')) 123 | self.cursor.execute(stmt) 124 | return self.cursor.fetchone()[0] 125 | 126 | def get_message_id(self, msg_num, group): 127 | return '<%s@%s>' % (msg_num, group) 128 | 129 | def get_notification_emails(self, forum_id): 130 | # open the configuration file 131 | fp = open("%s%s.php" % (settings.phorum_settings_path, forum_id), "r") 132 | content = fp.read() 133 | fp.close() 134 | # get the value of the configuration variable 135 | recipients = [] 136 | mod_code = moderator_regexp.search(content, 0).groups() 137 | if mod_code[2] == 'r' or mod_code[2] == 'a': 138 | # get the moderator emails from the forum_auth table 139 | stmt = """ 140 | SELECT 141 | email 142 | FROM 143 | forums_auth, 144 | forums_moderators 145 | WHERE 146 | user_id=id AND 147 | forum_id=%s""" % (forum_id) 148 | self.cursor.execute(stmt) 149 | result = list(self.cursor.fetchall()) 150 | for row in result: 151 | recipients.append(row[0]) 152 | return recipients 153 | 154 | def send_notifications(self, group_name, msg_id, thread_id, parent_id, msg_author, msg_email, msg_subject, msg_body): 155 | msg_tpl = """From: Phorum <%(recipient)s> 156 | To: %(recipient)s 157 | Subject: Moderate for %(forum_name)s at %(phorum_server_hostname)s Message: %(msg_id)s. 158 | 159 | Subject: %(msg_subject)s 160 | Author: %(msg_author)s 161 | Message: %(phorum_url)s/read.php?f=%(forum_id)s&i=%(msg_id)s&t=%(thread_id)s&admview=1 162 | 163 | %(msg_body)s 164 | 165 | To delete this message use this URL: 166 | %(phorum_admin_url)s?page=easyadmin&action=del&type=quick&id=%(msg_id)s&num=1&thread=%(thread_id)s 167 | 168 | To edit this message use this URL: 169 | %(phorum_admin_url)s?page=edit&srcpage=easyadmin&id=%(msg_id)s&num=1&mythread=%(thread_id)s 170 | 171 | """ 172 | # get the forum_id for this group_name 173 | stmt = """ 174 | SELECT 175 | id, 176 | name 177 | FROM 178 | forums 179 | WHERE 180 | nntp_group_name='%s'""" % (group_name) 181 | self.cursor.execute(stmt) 182 | forum_id, forum_name = self.cursor.fetchone() 183 | # open the main configuration file 184 | fp = open("%sforums.php" % (settings.phorum_settings_path), "r") 185 | content = fp.read() 186 | fp.close() 187 | # regexps to get the content from the phorum configuration files 188 | phorum_url = url_regexp.search(content, 0).groups()[2] 189 | phorum_admin_url = admin_regexp.search(content, 0).groups()[2] 190 | phorum_server_hostname = server_regexp.search(content, 0).groups()[3] 191 | # connect to the SMTP server 192 | smtp = smtplib.SMTP('localhost') 193 | emails = self.get_notification_emails(forum_id) 194 | for recipient in emails: 195 | current_msg = msg_tpl % vars() 196 | smtp.sendmail("Phorum <%s>" % (recipient), recipient, current_msg) 197 | 198 | # XXX: Coding blind here. I really don't know much about how Phorum works with 199 | # XXX: sending forum postings as emails, but it's here. Let's call this a 200 | # XXX: temporary implementation. Should work fine, I guess. 201 | phorum_mail_code = mail_code_regexp.search(content, 0).groups()[3] 202 | notification_mail_tpl = """Message-ID: <%(random_msgid)s@%(phorum_server_hostname)s> 203 | From: %(msg_author)s %(msg_email)s 204 | Subject: %(msg_subject)s 205 | To: %(forum_name)s <%(email_list)s> 206 | Return-Path: <%(email_return)s> 207 | Reply-To: %(email_return)s 208 | X-Phorum-%(phorum_mail_code)s-Version: Phorum %(phorum_version)s 209 | X-Phorum-%(phorum_mail_code)s-Forum: %(forum_name)s 210 | X-Phorum-%(phorum_mail_code)s-Thread: %(thread_id)s 211 | X-Phorum-%(phorum_mail_code)s-Parent: %(parent_id)s 212 | 213 | This message was sent from: %(forum_name)s. 214 | <%(phorum_url)s/read.php?f=%(forum_id)s&i=%(msg_id)s&t=%(thread_id)s> 215 | ---------------------------------------------------------------- 216 | 217 | %(msg_body)s 218 | 219 | ---------------------------------------------------------------- 220 | Sent using Papercut version %(__VERSION__)s 221 | """ 222 | stmt = """ 223 | SELECT 224 | email_list, 225 | email_return 226 | FROM 227 | forums 228 | WHERE 229 | LENGTH(email_list) > 0 AND 230 | id=%s""" % (forum_id) 231 | num_rows = self.cursor.execute(stmt) 232 | if num_rows == 1: 233 | email_list, email_return = self.cursor.fetchone() 234 | msg_body = strutil.wrap(msg_body) 235 | if len(msg_email) > 0: 236 | msg_email = '<%s>' % msg_email 237 | else: 238 | msg_email = '' 239 | random_msgid = md5.new(str(time.clock())).hexdigest() 240 | phorum_version = settings.phorum_version 241 | current_msg = notification_mail_tpl % vars() 242 | smtp.sendmail('%s %s' % (msg_author, msg_email), email_list, current_msg) 243 | smtp.quit() 244 | 245 | def get_NEWGROUPS(self, ts, group='%'): 246 | # since phorum doesn't record when each forum was created, we have no way of knowing this... 247 | return None 248 | 249 | def get_NEWNEWS(self, ts, group='*'): 250 | stmt = """ 251 | SELECT 252 | nntp_group_name, 253 | table_name 254 | FROM 255 | forums 256 | WHERE 257 | nntp_group_name='%s' 258 | ORDER BY 259 | nntp_group_name ASC""" % (group_name.replace('*', '%')) 260 | self.cursor.execute(stmt) 261 | result = list(self.cursor.fetchall()) 262 | articles = [] 263 | for group, table in result: 264 | stmt = """ 265 | SELECT 266 | id 267 | FROM 268 | %s 269 | WHERE 270 | approved='Y' AND 271 | UNIX_TIMESTAMP(datestamp) >= %s""" % (table, ts) 272 | num_rows = self.cursor.execute(stmt) 273 | if num_rows == 0: 274 | continue 275 | ids = list(self.cursor.fetchall()) 276 | for id in ids: 277 | articles.append("<%s@%s>" % (id, group)) 278 | if len(articles) == 0: 279 | return '' 280 | else: 281 | return "\r\n".join(articles) 282 | 283 | def get_GROUP(self, group_name): 284 | table_name = self.get_table_name(group_name) 285 | result = self.get_table_stats(table_name) 286 | return (result[0], result[2], result[1]) 287 | 288 | def get_LIST(self, username=""): 289 | stmt = """ 290 | SELECT 291 | nntp_group_name, 292 | table_name 293 | FROM 294 | forums 295 | WHERE 296 | LENGTH(nntp_group_name) > 0 297 | ORDER BY 298 | nntp_group_name ASC""" 299 | self.cursor.execute(stmt) 300 | result = list(self.cursor.fetchall()) 301 | if len(result) == 0: 302 | return "" 303 | else: 304 | lists = [] 305 | for group_name, table in result: 306 | total, maximum, minimum = self.get_table_stats(table) 307 | if settings.server_type == 'read-only': 308 | lists.append("%s %s %s n" % (group_name, maximum, minimum)) 309 | else: 310 | lists.append("%s %s %s y" % (group_name, maximum, minimum)) 311 | return "\r\n".join(lists) 312 | 313 | def get_STAT(self, group_name, id): 314 | table_name = self.get_table_name(group_name) 315 | stmt = """ 316 | SELECT 317 | id 318 | FROM 319 | %s 320 | WHERE 321 | approved='Y' AND 322 | id=%s""" % (table_name, id) 323 | return self.cursor.execute(stmt) 324 | 325 | def get_ARTICLE(self, group_name, id): 326 | table_name = self.get_table_name(group_name) 327 | stmt = """ 328 | SELECT 329 | A.id, 330 | author, 331 | email, 332 | subject, 333 | UNIX_TIMESTAMP(datestamp) AS datestamp, 334 | body, 335 | parent 336 | FROM 337 | %s A, 338 | %s_bodies B 339 | WHERE 340 | A.approved='Y' AND 341 | A.id=B.id AND 342 | A.id=%s""" % (table_name, table_name, id) 343 | num_rows = self.cursor.execute(stmt) 344 | if num_rows == 0: 345 | return None 346 | result = list(self.cursor.fetchone()) 347 | if len(result[2]) == 0: 348 | author = result[1] 349 | else: 350 | author = "%s <%s>" % (result[1], result[2]) 351 | formatted_time = strutil.get_formatted_time(time.localtime(result[4])) 352 | headers = [] 353 | headers.append("Path: %s" % (settings.nntp_hostname)) 354 | headers.append("From: %s" % (author)) 355 | headers.append("Newsgroups: %s" % (group_name)) 356 | headers.append("Date: %s" % (formatted_time)) 357 | headers.append("Subject: %s" % (result[3])) 358 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 359 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 360 | if result[6] != 0: 361 | headers.append("References: <%s@%s>" % (result[6], group_name)) 362 | return ("\r\n".join(headers), strutil.format_body(result[5])) 363 | 364 | def get_LAST(self, group_name, current_id): 365 | table_name = self.get_table_name(group_name) 366 | stmt = """ 367 | SELECT 368 | id 369 | FROM 370 | %s 371 | WHERE 372 | approved='Y' AND 373 | id < %s 374 | ORDER BY 375 | id DESC 376 | LIMIT 0, 1""" % (table_name, current_id) 377 | num_rows = self.cursor.execute(stmt) 378 | if num_rows == 0: 379 | return None 380 | return self.cursor.fetchone()[0] 381 | 382 | def get_NEXT(self, group_name, current_id): 383 | table_name = self.get_table_name(group_name) 384 | stmt = """ 385 | SELECT 386 | id 387 | FROM 388 | %s 389 | WHERE 390 | approved='Y' AND 391 | id > %s 392 | ORDER BY 393 | id ASC 394 | LIMIT 0, 1""" % (table_name, current_id) 395 | num_rows = self.cursor.execute(stmt) 396 | if num_rows == 0: 397 | return None 398 | return self.cursor.fetchone()[0] 399 | 400 | def get_HEAD(self, group_name, id): 401 | table_name = self.get_table_name(group_name) 402 | stmt = """ 403 | SELECT 404 | id, 405 | author, 406 | email, 407 | subject, 408 | UNIX_TIMESTAMP(datestamp) AS datestamp, 409 | parent 410 | FROM 411 | %s 412 | WHERE 413 | approved='Y' AND 414 | id=%s""" % (table_name, id) 415 | num_rows = self.cursor.execute(stmt) 416 | if num_rows == 0: 417 | return None 418 | result = list(self.cursor.fetchone()) 419 | if len(result[2]) == 0: 420 | author = result[1] 421 | else: 422 | author = "%s <%s>" % (result[1], result[2]) 423 | formatted_time = strutil.get_formatted_time(time.localtime(result[4])) 424 | headers = [] 425 | headers.append("Path: %s" % (settings.nntp_hostname)) 426 | headers.append("From: %s" % (author)) 427 | headers.append("Newsgroups: %s" % (group_name)) 428 | headers.append("Date: %s" % (formatted_time)) 429 | headers.append("Subject: %s" % (result[3])) 430 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 431 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 432 | if result[5] != 0: 433 | headers.append("References: <%s@%s>" % (result[5], group_name)) 434 | return "\r\n".join(headers) 435 | 436 | def get_BODY(self, group_name, id): 437 | table_name = self.get_table_name(group_name) 438 | stmt = """ 439 | SELECT 440 | B.body 441 | FROM 442 | %s A, 443 | %s_bodies B 444 | WHERE 445 | A.id=B.id AND 446 | A.approved='Y' AND 447 | B.id=%s""" % (table_name, table_name, id) 448 | num_rows = self.cursor.execute(stmt) 449 | if num_rows == 0: 450 | return None 451 | else: 452 | return strutil.format_body(self.cursor.fetchone()[0]) 453 | 454 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 455 | table_name = self.get_table_name(group_name) 456 | stmt = """ 457 | SELECT 458 | A.id, 459 | parent, 460 | author, 461 | email, 462 | subject, 463 | UNIX_TIMESTAMP(datestamp) AS datestamp, 464 | B.body 465 | FROM 466 | %s A, 467 | %s_bodies B 468 | WHERE 469 | A.approved='Y' AND 470 | A.id=B.id AND 471 | A.id >= %s""" % (table_name, table_name, start_id) 472 | if end_id != 'ggg': 473 | stmt = "%s AND A.id <= %s" % (stmt, end_id) 474 | self.cursor.execute(stmt) 475 | result = list(self.cursor.fetchall()) 476 | overviews = [] 477 | for row in result: 478 | if row[3] == '': 479 | author = row[2] 480 | else: 481 | author = "%s <%s>" % (row[2], row[3]) 482 | formatted_time = strutil.get_formatted_time(time.localtime(row[5])) 483 | message_id = "<%s@%s>" % (row[0], group_name) 484 | line_count = len(row[6].split('\n')) 485 | xref = 'Xref: %s %s:%s' % (settings.nntp_hostname, group_name, row[0]) 486 | if row[1] != 0: 487 | reference = "<%s@%s>" % (row[1], group_name) 488 | else: 489 | reference = "" 490 | # message_number subject author date message_id reference bytes lines xref 491 | overviews.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" % (row[0], row[4], author, formatted_time, message_id, reference, len(strutil.format_body(row[6])), line_count, xref)) 492 | return "\r\n".join(overviews) 493 | 494 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 495 | # XXX: need to actually check for the header values being passed as 496 | # XXX: not all header names map to column names on the tables 497 | table_name = self.get_table_name(group_name) 498 | stmt = """ 499 | SELECT 500 | A.id, 501 | parent, 502 | author, 503 | email, 504 | subject, 505 | UNIX_TIMESTAMP(datestamp) AS datestamp, 506 | B.body 507 | FROM 508 | %s A, 509 | %s_bodies B 510 | WHERE 511 | A.approved='Y' AND 512 | %s REGEXP '%s' AND 513 | A.id = B.id AND 514 | A.id >= %s""" % (table_name, table_name, header, strutil.format_wildcards(pattern), start_id) 515 | if end_id != 'ggg': 516 | stmt = "%s AND A.id <= %s" % (stmt, end_id) 517 | num_rows = self.cursor.execute(stmt) 518 | if num_rows == 0: 519 | return None 520 | result = list(self.cursor.fetchall()) 521 | hdrs = [] 522 | for row in result: 523 | if header.upper() == 'SUBJECT': 524 | hdrs.append('%s %s' % (row[0], row[4])) 525 | elif header.upper() == 'FROM': 526 | # XXX: totally broken with empty values for the email address 527 | hdrs.append('%s %s <%s>' % (row[0], row[2], row[3])) 528 | elif header.upper() == 'DATE': 529 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[5])))) 530 | elif header.upper() == 'MESSAGE-ID': 531 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 532 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 533 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 534 | elif header.upper() == 'BYTES': 535 | hdrs.append('%s %s' % (row[0], len(row[6]))) 536 | elif header.upper() == 'LINES': 537 | hdrs.append('%s %s' % (row[0], len(row[6].split('\n')))) 538 | elif header.upper() == 'XREF': 539 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 540 | if len(hdrs) == 0: 541 | return "" 542 | else: 543 | return "\r\n".join(hdrs) 544 | 545 | def get_LISTGROUP(self, group_name): 546 | table_name = self.get_table_name(group_name) 547 | stmt = """ 548 | SELECT 549 | id 550 | FROM 551 | %s 552 | WHERE 553 | approved='Y' 554 | ORDER BY 555 | id ASC""" % (table_name) 556 | self.cursor.execute(stmt) 557 | result = list(self.cursor.fetchall()) 558 | return "\r\n".join(["%s" % k for k in result]) 559 | 560 | def get_XGTITLE(self, pattern=None): 561 | stmt = """ 562 | SELECT 563 | nntp_group_name, 564 | description 565 | FROM 566 | forums 567 | WHERE 568 | LENGTH(nntp_group_name) > 0""" 569 | if pattern != None: 570 | stmt = stmt + """ AND 571 | nntp_group_name REGEXP '%s'""" % (strutil.format_wildcards(pattern)) 572 | stmt = stmt + """ 573 | ORDER BY 574 | nntp_group_name ASC""" 575 | self.cursor.execute(stmt) 576 | result = list(self.cursor.fetchall()) 577 | return "\r\n".join(["%s %s" % (k, v) for k, v in result]) 578 | 579 | def get_XHDR(self, group_name, header, style, range): 580 | table_name = self.get_table_name(group_name) 581 | stmt = """ 582 | SELECT 583 | A.id, 584 | parent, 585 | author, 586 | email, 587 | subject, 588 | UNIX_TIMESTAMP(datestamp) AS datestamp, 589 | B.body 590 | FROM 591 | %s A, 592 | %s_bodies B 593 | WHERE 594 | A.approved='Y' AND 595 | A.id = B.id AND """ % (table_name, table_name) 596 | if style == 'range': 597 | stmt = '%s A.id >= %s' % (stmt, range[0]) 598 | if len(range) == 2: 599 | stmt = '%s AND A.id <= %s' % (stmt, range[1]) 600 | else: 601 | stmt = '%s A.id = %s' % (stmt, range[0]) 602 | if self.cursor.execute(stmt) == 0: 603 | return None 604 | result = self.cursor.fetchall() 605 | hdrs = [] 606 | for row in result: 607 | if header.upper() == 'SUBJECT': 608 | hdrs.append('%s %s' % (row[0], row[4])) 609 | elif header.upper() == 'FROM': 610 | hdrs.append('%s %s <%s>' % (row[0], row[2], row[3])) 611 | elif header.upper() == 'DATE': 612 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[5])))) 613 | elif header.upper() == 'MESSAGE-ID': 614 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 615 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 616 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 617 | elif header.upper() == 'BYTES': 618 | hdrs.append('%s %s' % (row[0], len(row[6]))) 619 | elif header.upper() == 'LINES': 620 | hdrs.append('%s %s' % (row[0], len(row[6].split('\n')))) 621 | elif header.upper() == 'XREF': 622 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 623 | if len(hdrs) == 0: 624 | return "" 625 | else: 626 | return "\r\n".join(hdrs) 627 | 628 | def do_POST(self, group_name, lines, ip_address, username=''): 629 | table_name = self.get_table_name(group_name) 630 | body = self.get_message_body(lines) 631 | author, email = from_regexp.search(lines, 0).groups() 632 | subject = subject_regexp.search(lines, 0).groups()[0].strip() 633 | # patch by Andreas Wegmann to fix the handling of unusual encodings of messages 634 | lines = mime_decode_header(re.sub(q_quote_multiline, "=?\\1?Q?\\2\\3?=", lines)) 635 | if lines.find('References') != -1: 636 | # get the 'modifystamp' value from the parent (if any) 637 | references = references_regexp.search(lines, 0).groups() 638 | parent_id, void = references[-1].strip().split('@') 639 | stmt = """ 640 | SELECT 641 | IF(MAX(id) IS NULL, 1, MAX(id)+1) AS next_id 642 | FROM 643 | %s""" % (table_name) 644 | num_rows = self.cursor.execute(stmt) 645 | if num_rows == 0: 646 | new_id = 1 647 | else: 648 | new_id = self.cursor.fetchone()[0] 649 | stmt = """ 650 | SELECT 651 | id, 652 | thread, 653 | modifystamp 654 | FROM 655 | %s 656 | WHERE 657 | approved='Y' AND 658 | id=%s 659 | GROUP BY 660 | id""" % (table_name, parent_id) 661 | num_rows = self.cursor.execute(stmt) 662 | if num_rows == 0: 663 | return None 664 | parent_id, thread_id, modifystamp = self.cursor.fetchone() 665 | else: 666 | stmt = """ 667 | SELECT 668 | IF(MAX(id) IS NULL, 1, MAX(id)+1) AS next_id, 669 | UNIX_TIMESTAMP() 670 | FROM 671 | %s""" % (table_name) 672 | self.cursor.execute(stmt) 673 | new_id, modifystamp = self.cursor.fetchone() 674 | parent_id = 0 675 | thread_id = new_id 676 | stmt = """ 677 | INSERT INTO 678 | %s 679 | ( 680 | id, 681 | datestamp, 682 | thread, 683 | parent, 684 | author, 685 | subject, 686 | email, 687 | host, 688 | email_reply, 689 | approved, 690 | msgid, 691 | modifystamp, 692 | userid 693 | ) VALUES ( 694 | %s, 695 | NOW(), 696 | %s, 697 | %s, 698 | '%s', 699 | '%s', 700 | '%s', 701 | '%s', 702 | 'N', 703 | 'Y', 704 | '', 705 | %s, 706 | 0 707 | ) 708 | """ % (table_name, new_id, thread_id, parent_id, self.quote_string(author.strip()), self.quote_string(subject), self.quote_string(email), ip_address, modifystamp) 709 | if not self.cursor.execute(stmt): 710 | return None 711 | else: 712 | # insert into the '*_bodies' table 713 | stmt = """ 714 | INSERT INTO 715 | %s_bodies 716 | ( 717 | id, 718 | body, 719 | thread 720 | ) VALUES ( 721 | %s, 722 | '%s', 723 | %s 724 | )""" % (table_name, new_id, self.quote_string(body), thread_id) 725 | if not self.cursor.execute(stmt): 726 | # delete from 'table_name' before returning.. 727 | stmt = """ 728 | DELETE FROM 729 | %s 730 | WHERE 731 | id=%s""" % (table_name, new_id) 732 | self.cursor.execute(stmt) 733 | return None 734 | else: 735 | # alert forum moderators 736 | self.send_notifications(group_name, new_id, thread_id, parent_id, author.strip(), email, subject, body) 737 | return 1 738 | -------------------------------------------------------------------------------- /papercut/storage/phorum_pgsql.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2002 Joao Prado Maia. See the LICENSE file for more information. 2 | from pyPgSQL import PgSQL 3 | import time 4 | from mimify import mime_encode_header, mime_decode_header 5 | import re 6 | import smtplib 7 | import md5 8 | 9 | import papercut.storage.mime as mime 10 | import papercut.settings 11 | import papercut.storage.strutil as strutil 12 | from papercut.version import __VERSION__ 13 | 14 | settings = papercut.settings.CONF() 15 | 16 | # patch by Andreas Wegmann to fix the handling of unusual encodings of messages 17 | q_quote_multiline = re.compile("=\?(.*?)\?[qQ]\?(.*?)\?=.*?=\?\\1\?[qQ]\?(.*?)\?=", re.M | re.S) 18 | 19 | # we don't need to compile the regexps everytime.. 20 | doubleline_regexp = re.compile("^\.\.", re.M) 21 | singleline_regexp = re.compile("^\.", re.M) 22 | from_regexp = re.compile("^From:(.*)<(.*)>", re.M) 23 | subject_regexp = re.compile("^Subject:(.*)", re.M) 24 | references_regexp = re.compile("^References:(.*)<(.*)>", re.M) 25 | lines_regexp = re.compile("^Lines:(.*)", re.M) 26 | # phorum configuration files related regexps 27 | moderator_regexp = re.compile("(.*)PHORUM\['ForumModeration'\](.*)='(.*)';", re.M) 28 | url_regexp = re.compile("(.*)PHORUM\['forum_url'\](.*)='(.*)';", re.M) 29 | admin_regexp = re.compile("(.*)PHORUM\['admin_url'\](.*)='(.*)';", re.M) 30 | server_regexp = re.compile("(.*)PHORUM\['forum_url'\](.*)='(.*)http://(.*)/(.*)';", re.M) 31 | mail_code_regexp = re.compile("(.*)PHORUM\['PhorumMailCode'\](.*)=(.*)'(.*)';", re.M) 32 | 33 | class Papercut_Storage: 34 | """ 35 | Storage Backend interface for the Phorum web message board software (http://phorum.org) 36 | 37 | This is the interface for Phorum running on a PostgreSQL database. For more information 38 | on the structure of the 'storage' package, please refer to the __init__.py 39 | available on the 'storage' sub-directory. 40 | """ 41 | 42 | def __init__(self): 43 | self.conn = PgSQL.connect(host=settings.dbhost, database=settings.dbname, user=settings.dbuser, password=settings.dbpass) 44 | self.cursor = self.conn.cursor() 45 | 46 | def get_message_body(self, headers): 47 | """Parses and returns the most appropriate message body possible. 48 | 49 | The function tries to extract the plaintext version of a MIME based 50 | message, and if it is not available then it returns the html version. 51 | """ 52 | return mime.get_text_message(headers) 53 | 54 | def group_exists(self, group_name): 55 | stmt = """ 56 | SELECT 57 | COUNT(*) AS total 58 | FROM 59 | forums 60 | WHERE 61 | LOWER(nntp_group_name)=LOWER('%s')""" % (group_name) 62 | self.cursor.execute(stmt) 63 | return self.cursor.fetchone()[0] 64 | 65 | def article_exists(self, group_name, style, range): 66 | table_name = self.get_table_name(group_name) 67 | stmt = """ 68 | SELECT 69 | COUNT(*) AS total 70 | FROM 71 | %s 72 | WHERE 73 | approved='Y'""" % (table_name) 74 | if style == 'range': 75 | stmt = "%s AND id > %s" % (stmt, range[0]) 76 | if len(range) == 2: 77 | stmt = "%s AND id < %s" % (stmt, range[1]) 78 | else: 79 | stmt = "%s AND id = %s" % (stmt, range[0]) 80 | self.cursor.execute(stmt) 81 | return self.cursor.fetchone()[0] 82 | 83 | def get_first_article(self, group_name): 84 | table_name = self.get_table_name(group_name) 85 | stmt = """ 86 | SELECT 87 | MIN(id) AS first_article 88 | FROM 89 | %s 90 | WHERE 91 | approved='Y'""" % (table_name) 92 | self.cursor.execute(stmt) 93 | minimum = self.cursor.fetchone()[0] 94 | if minimum is None: 95 | return 0 96 | else: 97 | return minimum 98 | 99 | def get_group_stats(self, group_name): 100 | total, max, min = self.get_table_stats(self.get_table_name(group_name)) 101 | return (total, min, max, group_name) 102 | 103 | def get_table_stats(self, table_name): 104 | stmt = """ 105 | SELECT 106 | COUNT(id) AS total, 107 | MAX(id) AS maximum, 108 | MIN(id) AS minimum 109 | FROM 110 | %s 111 | WHERE 112 | approved='Y'""" % (table_name) 113 | self.cursor.execute(stmt) 114 | total, maximum, minimum = self.cursor.fetchone() 115 | if maximum is None: 116 | maximum = 0 117 | if minimum is None: 118 | minimum = 0 119 | return (total, maximum, minimum) 120 | 121 | def get_table_name(self, group_name): 122 | stmt = """ 123 | SELECT 124 | table_name 125 | FROM 126 | forums 127 | WHERE 128 | nntp_group_name LIKE '%s'""" % (group_name.replace('*', '%')) 129 | self.cursor.execute(stmt) 130 | return self.cursor.fetchone()[0] 131 | 132 | def get_message_id(self, msg_num, group): 133 | return '<%s@%s>' % (msg_num, group) 134 | 135 | def get_notification_emails(self, forum_id): 136 | # open the configuration file 137 | fp = open("%s%s.php" % (settings.phorum_settings_path, forum_id), "r") 138 | content = fp.read() 139 | fp.close() 140 | # get the value of the configuration variable 141 | recipients = [] 142 | mod_code = moderator_regexp.search(content, 0).groups() 143 | if mod_code[2] == 'r' or mod_code[2] == 'a': 144 | # get the moderator emails from the forum_auth table 145 | stmt = """ 146 | SELECT 147 | email 148 | FROM 149 | forums_auth, 150 | forums_moderators 151 | WHERE 152 | user_id=id AND 153 | forum_id=%s""" % (forum_id) 154 | self.cursor.execute(stmt) 155 | result = list(self.cursor.fetchall()) 156 | for row in result: 157 | recipients.append(row[0].strip()) 158 | return recipients 159 | 160 | def send_notifications(self, group_name, msg_id, thread_id, parent_id, msg_author, msg_email, msg_subject, msg_body): 161 | msg_tpl = """From: Phorum <%(recipient)s> 162 | To: %(recipient)s 163 | Subject: Moderate for %(forum_name)s at %(phorum_server_hostname)s Message: %(msg_id)s. 164 | 165 | Subject: %(msg_subject)s 166 | Author: %(msg_author)s 167 | Message: %(phorum_url)s/read.php?f=%(forum_id)s&i=%(msg_id)s&t=%(thread_id)s&admview=1 168 | 169 | %(msg_body)s 170 | 171 | To delete this message use this URL: 172 | %(phorum_admin_url)s?page=easyadmin&action=del&type=quick&id=%(msg_id)s&num=1&thread=%(thread_id)s 173 | 174 | To edit this message use this URL: 175 | %(phorum_admin_url)s?page=edit&srcpage=easyadmin&id=%(msg_id)s&num=1&mythread=%(thread_id)s 176 | 177 | """ 178 | # get the forum_id for this group_name 179 | stmt = """ 180 | SELECT 181 | id, 182 | name 183 | FROM 184 | forums 185 | WHERE 186 | nntp_group_name='%s'""" % (group_name) 187 | self.cursor.execute(stmt) 188 | forum_id, forum_name = self.cursor.fetchone() 189 | forum_name.strip() 190 | # open the main configuration file 191 | fp = open("%sforums.php" % (settings.phorum_settings_path), "r") 192 | content = fp.read() 193 | fp.close() 194 | # regexps to get the content from the phorum configuration files 195 | phorum_url = url_regexp.search(content, 0).groups()[2] 196 | phorum_admin_url = admin_regexp.search(content, 0).groups()[2] 197 | phorum_server_hostname = server_regexp.search(content, 0).groups()[3] 198 | # connect to the SMTP server 199 | smtp = smtplib.SMTP('localhost') 200 | emails = self.get_notification_emails(forum_id) 201 | for recipient in emails: 202 | current_msg = msg_tpl % vars() 203 | smtp.sendmail("Phorum <%s>" % (recipient), recipient, current_msg) 204 | 205 | # XXX: Coding blind here. I really don't know much about how Phorum works with 206 | # XXX: sending forum postings as emails, but it's here. Let's call this a 207 | # XXX: temporary implementation. Should work fine, I guess. 208 | phorum_mail_code = mail_code_regexp.search(content, 0).groups()[3] 209 | notification_mail_tpl = """Message-ID: <%(random_msgid)s@%(phorum_server_hostname)s> 210 | From: %(msg_author)s %(msg_email)s 211 | Subject: %(msg_subject)s 212 | To: %(forum_name)s <%(email_list)s> 213 | Return-Path: <%(email_return)s> 214 | Reply-To: %(email_return)s 215 | X-Phorum-%(phorum_mail_code)s-Version: Phorum %(phorum_version)s 216 | X-Phorum-%(phorum_mail_code)s-Forum: %(forum_name)s 217 | X-Phorum-%(phorum_mail_code)s-Thread: %(thread_id)s 218 | X-Phorum-%(phorum_mail_code)s-Parent: %(parent_id)s 219 | 220 | This message was sent from: %(forum_name)s. 221 | <%(phorum_url)s/read.php?f=%(forum_id)s&i=%(msg_id)s&t=%(thread_id)s> 222 | ---------------------------------------------------------------- 223 | 224 | %(msg_body)s 225 | 226 | ---------------------------------------------------------------- 227 | Sent using Papercut version %(__VERSION__)s 228 | """ 229 | stmt = """ 230 | SELECT 231 | email_list, 232 | email_return 233 | FROM 234 | forums 235 | WHERE 236 | LENGTH(email_list) > 0 AND 237 | id=%s""" % (forum_id) 238 | num_rows = self.cursor.execute(stmt) 239 | if num_rows == 1: 240 | email_list, email_return = self.cursor.fetchone() 241 | msg_body = strutil.wrap(msg_body) 242 | if len(msg_email) > 0: 243 | msg_email = '<%s>' % msg_email 244 | else: 245 | msg_email = '' 246 | random_msgid = md5.new(str(time.clock())).hexdigest() 247 | phorum_version = settings.phorum_version 248 | current_msg = notification_mail_tpl % vars() 249 | smtp.sendmail('%s %s' % (msg_author, msg_email), email_list, current_msg) 250 | smtp.quit() 251 | 252 | def get_NEWGROUPS(self, ts, group='%'): 253 | # since phorum doesn't record when each forum was created, we have no way of knowing this... 254 | return None 255 | 256 | def get_NEWNEWS(self, ts, group='*'): 257 | stmt = """ 258 | SELECT 259 | nntp_group_name, 260 | table_name 261 | FROM 262 | forums 263 | WHERE 264 | nntp_group_name='%s' 265 | ORDER BY 266 | nntp_group_name ASC""" % (group_name.replace('*', '%')) 267 | self.cursor.execute(stmt) 268 | result = list(self.cursor.fetchall()) 269 | articles = [] 270 | for group, table in result: 271 | stmt = """ 272 | SELECT 273 | id 274 | FROM 275 | %s 276 | WHERE 277 | approved='Y' AND 278 | DATE_PART('epoch', datestamp) >= %s""" % (table, ts) 279 | num_rows = self.cursor.execute(stmt) 280 | if num_rows == 0: 281 | continue 282 | ids = list(self.cursor.fetchall()) 283 | for id in ids: 284 | articles.append("<%s@%s>" % (id, group)) 285 | if len(articles) == 0: 286 | return '' 287 | else: 288 | return "\r\n".join(articles) 289 | 290 | def get_GROUP(self, group_name): 291 | table_name = self.get_table_name(group_name) 292 | result = self.get_table_stats(table_name) 293 | return (result[0], result[2], result[1]) 294 | 295 | def get_LIST(self, username=""): 296 | stmt = """ 297 | SELECT 298 | nntp_group_name, 299 | table_name 300 | FROM 301 | forums 302 | WHERE 303 | LENGTH(nntp_group_name) > 0 304 | ORDER BY 305 | nntp_group_name ASC""" 306 | self.cursor.execute(stmt) 307 | result = list(self.cursor.fetchall()) 308 | if len(result) == 0: 309 | return "" 310 | else: 311 | lists = [] 312 | for group_name, table in result: 313 | total, maximum, minimum = self.get_table_stats(table) 314 | if settings.server_type == 'read-only': 315 | lists.append("%s %s %s n" % (group_name, maximum, minimum)) 316 | else: 317 | lists.append("%s %s %s y" % (group_name, maximum, minimum)) 318 | return "\r\n".join(lists) 319 | 320 | def get_STAT(self, group_name, id): 321 | table_name = self.get_table_name(group_name) 322 | stmt = """ 323 | SELECT 324 | id 325 | FROM 326 | %s 327 | WHERE 328 | approved='Y' AND 329 | id=%s""" % (table_name, id) 330 | return self.cursor.execute(stmt) 331 | 332 | def get_ARTICLE(self, group_name, id): 333 | table_name = self.get_table_name(group_name) 334 | stmt = """ 335 | SELECT 336 | A.id, 337 | author, 338 | email, 339 | subject, 340 | DATE_PART('epoch', datestamp) AS datestamp, 341 | body, 342 | parent 343 | FROM 344 | %s A, 345 | %s_bodies B 346 | WHERE 347 | A.approved='Y' AND 348 | A.id=B.id AND 349 | A.id=%s""" % (table_name, table_name, id) 350 | num_rows = self.cursor.execute(stmt) 351 | if num_rows == 0: 352 | return None 353 | result = list(self.cursor.fetchone()) 354 | if len(result[2]) == 0: 355 | author = result[1].strip() 356 | else: 357 | author = "%s <%s>" % (result[1].strip(), result[2].strip()) 358 | formatted_time = strutil.get_formatted_time(time.localtime(result[4])) 359 | headers = [] 360 | headers.append("Path: %s" % (settings.nntp_hostname)) 361 | headers.append("From: %s" % (author)) 362 | headers.append("Newsgroups: %s" % (group_name)) 363 | headers.append("Date: %s" % (formatted_time)) 364 | headers.append("Subject: %s" % (result[3].strip())) 365 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 366 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 367 | if result[6] != 0: 368 | headers.append("References: <%s@%s>" % (result[6], group_name)) 369 | return ("\r\n".join(headers), strutil.format_body(result[5])) 370 | 371 | def get_LAST(self, group_name, current_id): 372 | table_name = self.get_table_name(group_name) 373 | stmt = """ 374 | SELECT 375 | id 376 | FROM 377 | %s 378 | WHERE 379 | approved='Y' AND 380 | id < %s 381 | ORDER BY 382 | id DESC 383 | LIMIT 1, 0""" % (table_name, current_id) 384 | num_rows = self.cursor.execute(stmt) 385 | if num_rows == 0: 386 | return None 387 | return self.cursor.fetchone()[0] 388 | 389 | def get_NEXT(self, group_name, current_id): 390 | table_name = self.get_table_name(group_name) 391 | stmt = """ 392 | SELECT 393 | id 394 | FROM 395 | %s 396 | WHERE 397 | approved='Y' AND 398 | id > %s 399 | ORDER BY 400 | id ASC 401 | LIMIT 1, 0""" % (table_name, current_id) 402 | num_rows = self.cursor.execute(stmt) 403 | if num_rows == 0: 404 | return None 405 | return self.cursor.fetchone()[0] 406 | 407 | def get_HEAD(self, group_name, id): 408 | table_name = self.get_table_name(group_name) 409 | stmt = """ 410 | SELECT 411 | id, 412 | author, 413 | email, 414 | subject, 415 | DATE_PART('epoch', datestamp) AS datestamp, 416 | parent 417 | FROM 418 | %s 419 | WHERE 420 | approved='Y' AND 421 | id=%s""" % (table_name, id) 422 | num_rows = self.cursor.execute(stmt) 423 | if num_rows == 0: 424 | return None 425 | result = list(self.cursor.fetchone()) 426 | if len(result[2]) == 0: 427 | author = result[1].strip() 428 | else: 429 | author = "%s <%s>" % (result[1].strip(), result[2].strip()) 430 | formatted_time = strutil.get_formatted_time(time.localtime(result[4])) 431 | headers = [] 432 | headers.append("Path: %s" % (settings.nntp_hostname)) 433 | headers.append("From: %s" % (author)) 434 | headers.append("Newsgroups: %s" % (group_name)) 435 | headers.append("Date: %s" % (formatted_time)) 436 | headers.append("Subject: %s" % (result[3].strip())) 437 | headers.append("Message-ID: <%s@%s>" % (result[0], group_name)) 438 | headers.append("Xref: %s %s:%s" % (settings.nntp_hostname, group_name, result[0])) 439 | if result[5] != 0: 440 | headers.append("References: <%s@%s>" % (result[5], group_name)) 441 | return "\r\n".join(headers) 442 | 443 | def get_BODY(self, group_name, id): 444 | table_name = self.get_table_name(group_name) 445 | stmt = """ 446 | SELECT 447 | B.body 448 | FROM 449 | %s A, 450 | %s_bodies B 451 | WHERE 452 | A.id=B.id AND 453 | A.approved='Y' AND 454 | B.id=%s""" % (table_name, table_name, id) 455 | num_rows = self.cursor.execute(stmt) 456 | if num_rows == 0: 457 | return None 458 | else: 459 | return strutil.format_body(self.cursor.fetchone()[0]) 460 | 461 | def get_XOVER(self, group_name, start_id, end_id='ggg'): 462 | table_name = self.get_table_name(group_name) 463 | stmt = """ 464 | SELECT 465 | A.id, 466 | parent, 467 | author, 468 | email, 469 | subject, 470 | DATE_PART('epoch', datestamp) AS datestamp, 471 | B.body 472 | FROM 473 | %s A, 474 | %s_bodies B 475 | WHERE 476 | A.approved='Y' AND 477 | A.id=B.id AND 478 | A.id >= %s""" % (table_name, table_name, start_id) 479 | if end_id != 'ggg': 480 | stmt = "%s AND A.id <= %s" % (stmt, end_id) 481 | self.cursor.execute(stmt) 482 | result = list(self.cursor.fetchall()) 483 | overviews = [] 484 | for row in result: 485 | if row[3] == '': 486 | author = row[2].strip() 487 | else: 488 | author = "%s <%s>" % (row[2].strip(), row[3].strip()) 489 | formatted_time = strutil.get_formatted_time(time.localtime(row[5])) 490 | message_id = "<%s@%s>" % (row[0], group_name) 491 | line_count = len(row[6].split('\n')) 492 | xref = 'Xref: %s %s:%s' % (settings.nntp_hostname, group_name, row[0]) 493 | if row[1] != 0: 494 | reference = "<%s@%s>" % (row[1], group_name) 495 | else: 496 | reference = "" 497 | # message_number subject author date message_id reference bytes lines xref 498 | overviews.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" % (row[0], row[4].strip(), author, formatted_time, message_id, reference, len(strutil.format_body(row[6])), line_count, xref)) 499 | return "\r\n".join(overviews) 500 | 501 | def get_XPAT(self, group_name, header, pattern, start_id, end_id='ggg'): 502 | # XXX: need to actually check for the header values being passed as 503 | # XXX: not all header names map to column names on the tables 504 | table_name = self.get_table_name(group_name) 505 | stmt = """ 506 | SELECT 507 | A.id, 508 | parent, 509 | author, 510 | email, 511 | subject, 512 | DATE_PART('epoch', datestamp) AS datestamp, 513 | B.body 514 | FROM 515 | %s A, 516 | %s_bodies B 517 | WHERE 518 | A.approved='Y' AND 519 | %s LIKE '%s' AND 520 | A.id = B.id AND 521 | A.id >= %s""" % (table_name, table_name, header, strutil.format_wildcards_sql(pattern), start_id) 522 | if end_id != 'ggg': 523 | stmt = "%s AND A.id <= %s" % (stmt, end_id) 524 | num_rows = self.cursor.execute(stmt) 525 | if num_rows == 0: 526 | return None 527 | result = list(self.cursor.fetchall()) 528 | hdrs = [] 529 | for row in result: 530 | if header.upper() == 'SUBJECT': 531 | hdrs.append('%s %s' % (row[0], row[4].strip())) 532 | elif header.upper() == 'FROM': 533 | # XXX: totally broken with empty values for the email address 534 | hdrs.append('%s %s <%s>' % (row[0], row[2].strip(), row[3].strip())) 535 | elif header.upper() == 'DATE': 536 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[5])))) 537 | elif header.upper() == 'MESSAGE-ID': 538 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 539 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 540 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 541 | elif header.upper() == 'BYTES': 542 | hdrs.append('%s %s' % (row[0], len(row[6]))) 543 | elif header.upper() == 'LINES': 544 | hdrs.append('%s %s' % (row[0], len(row[6].split('\n')))) 545 | elif header.upper() == 'XREF': 546 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 547 | if len(hdrs) == 0: 548 | return "" 549 | else: 550 | return "\r\n".join(hdrs) 551 | 552 | def get_LISTGROUP(self, group_name): 553 | table_name = self.get_table_name(group_name) 554 | stmt = """ 555 | SELECT 556 | id 557 | FROM 558 | %s 559 | WHERE 560 | approved='Y' 561 | ORDER BY 562 | id ASC""" % (table_name) 563 | self.cursor.execute(stmt) 564 | result = list(self.cursor.fetchall()) 565 | return "\r\n".join(["%s" % k for k in result]) 566 | 567 | def get_XGTITLE(self, pattern=None): 568 | stmt = """ 569 | SELECT 570 | nntp_group_name, 571 | description 572 | FROM 573 | forums 574 | WHERE 575 | LENGTH(nntp_group_name) > 0""" 576 | if pattern != None: 577 | stmt = stmt + """ AND 578 | nntp_group_name LIKE '%s'""" % (strutil.format_wildcards_sql(pattern)) 579 | stmt = stmt + """ 580 | ORDER BY 581 | nntp_group_name ASC""" 582 | self.cursor.execute(stmt) 583 | result = list(self.cursor.fetchall()) 584 | return "\r\n".join(["%s %s" % (k, v) for k, v in result]) 585 | 586 | def get_XHDR(self, group_name, header, style, range): 587 | table_name = self.get_table_name(group_name) 588 | stmt = """ 589 | SELECT 590 | A.id, 591 | parent, 592 | author, 593 | email, 594 | subject, 595 | DATE_PART('epoch', datestamp) AS datestamp, 596 | B.body 597 | FROM 598 | %s A, 599 | %s_bodies B 600 | WHERE 601 | A.approved='Y' AND 602 | A.id = B.id AND """ % (table_name, table_name) 603 | if style == 'range': 604 | stmt = '%s A.id >= %s' % (stmt, range[0]) 605 | if len(range) == 2: 606 | stmt = '%s AND A.id <= %s' % (stmt, range[1]) 607 | else: 608 | stmt = '%s A.id = %s' % (stmt, range[0]) 609 | if self.cursor.execute(stmt) == 0: 610 | return None 611 | result = self.cursor.fetchall() 612 | hdrs = [] 613 | for row in result: 614 | if header.upper() == 'SUBJECT': 615 | hdrs.append('%s %s' % (row[0], row[4].strip())) 616 | elif header.upper() == 'FROM': 617 | hdrs.append('%s %s <%s>' % (row[0], row[2].strip(), row[3].strip())) 618 | elif header.upper() == 'DATE': 619 | hdrs.append('%s %s' % (row[0], strutil.get_formatted_time(time.localtime(result[5])))) 620 | elif header.upper() == 'MESSAGE-ID': 621 | hdrs.append('%s <%s@%s>' % (row[0], row[0], group_name)) 622 | elif (header.upper() == 'REFERENCES') and (row[1] != 0): 623 | hdrs.append('%s <%s@%s>' % (row[0], row[1], group_name)) 624 | elif header.upper() == 'BYTES': 625 | hdrs.append('%s %s' % (row[0], len(row[6]))) 626 | elif header.upper() == 'LINES': 627 | hdrs.append('%s %s' % (row[0], len(row[6].split('\n')))) 628 | elif header.upper() == 'XREF': 629 | hdrs.append('%s %s %s:%s' % (row[0], settings.nntp_hostname, group_name, row[0])) 630 | if len(hdrs) == 0: 631 | return "" 632 | else: 633 | return "\r\n".join(hdrs) 634 | 635 | def do_POST(self, group_name, lines, ip_address, username=''): 636 | table_name = self.get_table_name(group_name) 637 | body = self.get_message_body(lines) 638 | author, email = from_regexp.search(lines, 0).groups() 639 | subject = subject_regexp.search(lines, 0).groups()[0].strip() 640 | # patch by Andreas Wegmann to fix the handling of unusual encodings of messages 641 | lines = mime_decode_header(re.sub(q_quote_multiline, "=?\\1?Q?\\2\\3?=", lines)) 642 | if lines.find('References') != -1: 643 | # get the 'modifystamp' value from the parent (if any) 644 | references = references_regexp.search(lines, 0).groups() 645 | parent_id, void = references[-1].strip().split('@') 646 | stmt = """ 647 | SELECT 648 | MAX(id) AS next_id 649 | FROM 650 | %s""" % (table_name) 651 | num_rows = self.cursor.execute(stmt) 652 | if num_rows == 0: 653 | new_id = 1 654 | else: 655 | new_id = self.cursor.fetchone()[0] 656 | if new_id is None: 657 | new_id = 1 658 | else: 659 | new_id = new_id + 1 660 | stmt = """ 661 | SELECT 662 | id, 663 | thread, 664 | modifystamp 665 | FROM 666 | %s 667 | WHERE 668 | approved='Y' AND 669 | id=%s 670 | GROUP BY 671 | id""" % (table_name, parent_id) 672 | num_rows = self.cursor.execute(stmt) 673 | if num_rows == 0: 674 | return None 675 | parent_id, thread_id, modifystamp = self.cursor.fetchone() 676 | else: 677 | stmt = """ 678 | SELECT 679 | MAX(id) AS next_id, 680 | DATE_PART('epoch', CURRENT_TIMESTAMP()) 681 | FROM 682 | %s""" % (table_name) 683 | self.cursor.execute(stmt) 684 | new_id, modifystamp = self.cursor.fetchone() 685 | if new_id is None: 686 | new_id = 1 687 | else: 688 | new_id = new_id + 1 689 | modifystamp = int(modifystamp) 690 | parent_id = 0 691 | thread_id = new_id 692 | stmt = """ 693 | INSERT INTO 694 | """ + table_name + """ 695 | ( 696 | id, 697 | datestamp, 698 | thread, 699 | parent, 700 | author, 701 | subject, 702 | email, 703 | host, 704 | email_reply, 705 | approved, 706 | msgid, 707 | modifystamp, 708 | userid 709 | ) VALUES ( 710 | %s, 711 | NOW(), 712 | %s, 713 | %s, 714 | '%s', 715 | '%s', 716 | '%s', 717 | '%s', 718 | 'N', 719 | 'Y', 720 | '', 721 | %s, 722 | 0 723 | ) 724 | """ 725 | if not self.cursor.execute(stmt, (new_id, thread_id, parent_id, author.strip(), subject.strip(), email.strip(), ip_address, modifystamp,)): 726 | return None 727 | else: 728 | # insert into the '*_bodies' table 729 | stmt = """ 730 | INSERT INTO 731 | """ + table_name + """_bodies 732 | ( 733 | id, 734 | body, 735 | thread 736 | ) VALUES ( 737 | %s, 738 | '%s', 739 | %s 740 | )""" 741 | if not self.cursor.execute(stmt, (new_id, body, thread_id,)): 742 | # delete from 'table_name' before returning.. 743 | stmt = """ 744 | DELETE FROM 745 | %s 746 | WHERE 747 | id=%s""" % (table_name, new_id) 748 | self.cursor.execute(stmt) 749 | return None 750 | else: 751 | # alert forum moderators 752 | self.send_notifications(group_name, new_id, thread_id, parent_id, author.strip(), email.strip(), subject.strip(), body) 753 | return 1 754 | --------------------------------------------------------------------------------