xenu_nntp/lib/nntp/tiny/session.py

594 lines
17 KiB
Python
Raw Normal View History

2024-11-23 22:40:06 -05:00
import re
2024-11-20 21:17:03 -05:00
import enum
import socket
2024-11-23 22:40:06 -05:00
import datetime
import fnmatch
2024-11-25 20:27:14 -05:00
import traceback
2024-11-20 21:17:03 -05:00
2024-11-22 23:57:14 -05:00
from typing import Optional
2024-11-23 22:40:06 -05:00
from nntp.tiny.buffer import LineBuffer, BufferOverflow
2024-11-23 00:20:06 -05:00
from nntp.tiny.db import Database
2024-11-22 23:57:14 -05:00
from nntp.tiny.server import Server, ServerCapability
from nntp.tiny.response import Response, ResponseCode
from nntp.tiny.newsgroup import Newsgroup
from nntp.tiny.message import Message
2024-11-20 21:17:03 -05:00
class SessionState(enum.Flag):
NONE = 0
AUTH_OK = enum.auto()
AUTH_POST = enum.auto()
2024-11-25 20:27:55 -05:00
class MessagePart(enum.Enum):
HEAD = 1
BODY = enum.auto()
WHOLE = enum.auto()
2024-11-22 23:57:14 -05:00
class MessageRange():
__slots__ = 'id', 'min', 'max',
2024-11-25 00:48:56 -05:00
RE_NUM = re.compile(r'^(\d+)$')
RE_RANGE = re.compile(r'^(\d+)-(\d+)$')
RE_RANGE_LOWER = re.compile(r'^(\d+$)-$')
RE_RANGE_UPPER = re.compile(r'^-(\d+$)$')
2024-11-22 23:57:14 -05:00
def __init__(self):
self.id: int = None
self.min: int = None
self.max: int = None
def __str__(self):
if self.id is not None:
return str(self.id)
if self.min is not None and self.max is None:
return "%d-" % (self.min)
elif self.min is not None and self.max is not None:
return "%d-%d" % (self.min, self.max)
elif self.min is None and self.max is not None:
return "-%d" % (self.max)
return "?"
2024-11-23 00:20:06 -05:00
def where(self):
2024-11-22 23:57:14 -05:00
if self.id is not None:
2024-11-23 00:20:06 -05:00
return "id = %d" % (self.id)
2024-11-22 23:57:14 -05:00
if self.min is not None and self.max is None:
2024-11-23 00:20:06 -05:00
return "id >= %d" % (self.min)
2024-11-22 23:57:14 -05:00
elif self.min is not None and self.max is not None:
2024-11-23 00:20:06 -05:00
return "id >= %d and id <= %d" % (self.min, self.max)
2024-11-22 23:57:14 -05:00
elif self.min is None and self.max is not None:
2024-11-23 00:20:06 -05:00
return "id <= %d" % (self.max)
2024-11-22 23:57:14 -05:00
@staticmethod
def parse(r: str):
match = __class__.RE_NUM.match(r)
if match:
obj = __class__()
2024-11-25 21:52:07 -05:00
obj.id = int(match[1])
2024-11-22 23:57:14 -05:00
return obj
match = __class__.RE_RANGE.match(r)
if match:
obj = __class__()
2024-11-25 21:52:07 -05:00
obj.min = int(match[1])
obj.max = int(match[2])
2024-11-22 23:57:14 -05:00
return obj
match = __class__.RE_RANGE_LOWER.match(r)
if match:
obj = __class__()
2024-11-25 21:52:07 -05:00
obj.min = int(match[1])
2024-11-22 23:57:14 -05:00
return obj
match = __class__.RE_RANGE_UPPER.match(r)
if match:
obj = __class__()
2024-11-25 21:52:07 -05:00
obj.max = int(match[1])
2024-11-22 23:57:14 -05:00
return obj
2024-11-20 21:17:03 -05:00
class Session():
2024-11-25 17:17:08 -05:00
NNTP_VERSION = 2
2024-11-20 21:17:03 -05:00
NNTP_CAPABILITIES = [
2024-11-25 00:16:15 -05:00
'VERSION %d' % (NNTP_VERSION),
2024-11-20 21:17:03 -05:00
'READER',
'HDR',
'NEWNEWS',
'LIST ACTIVE NEWSGROUP OVERVIEW.FMT SUBSCRIPTIONS',
'OVER MSGID'
]
2024-11-25 17:17:08 -05:00
RE_SPLIT = re.compile(r'\s+')
2024-11-20 21:17:03 -05:00
def __init__(self, server: Server, sock: socket.socket):
self.server: Server = server
2024-11-23 00:20:06 -05:00
self.db: Database = server.db
2024-11-20 21:17:03 -05:00
self.state: SessionState = SessionState.NONE
self.sock: socket.socket = sock
self.buf: LineBuffer = LineBuffer()
2024-11-25 20:27:55 -05:00
self.newsgroup: Optional[Newsgroup] = None
self.article_id: Optional[int] = None
2024-11-22 23:57:14 -05:00
2024-11-20 21:17:03 -05:00
def readline(self):
return self.buf.readline(self.sock)
def print(self, text: str, end: str="\r\n"):
2024-11-25 15:57:20 -05:00
return self.sock.send(bytes(text + end, 'utf-8'))
2024-11-20 21:17:03 -05:00
def end(self):
return self.print('.')
def respond(self, code: ResponseCode, message: str=None, body=None):
response = Response(code, message, body)
return self.print(str(response))
def _cmd_capabilities(self, *args):
self.respond(ResponseCode.NNTP_CAPABILITIES_FOLLOW)
if self.state & SessionState.AUTH_POST:
self.print('POST')
if self.state & SessionState.AUTH_OK:
self.print('AUTHUSER INFO')
for item in self.NNTP_CAPABILITIES:
self.print(item)
self.end()
2024-11-22 23:57:14 -05:00
def _cmd_group(self, name: str):
if name not in self.server.newsgroups:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_FOUND)
newsgroup = self.server.newsgroups[name]
sql = """
select
count(id),
min(id),
max(id)
from
newsgroup_message
where
newsgroup_id = ?
"""
2024-11-25 00:49:24 -05:00
cr = self.db.execute(sql, (newsgroup.id,))
2024-11-22 23:57:14 -05:00
row = cr.fetchone()
text = "%d %d %d %s" % (
row[0],
row[1],
row[2],
newsgroup.name
)
self.respond(ResponseCode.NNTP_GROUP_LISTING, text)
2024-11-25 20:27:55 -05:00
self.newsgroup = newsgroup
self.article_id = None
2024-11-22 23:57:14 -05:00
return
2024-11-23 00:20:06 -05:00
def _newsgroup_summary(self, newsgroup: Newsgroup) -> str:
sql = """
select
count(id),
min(id),
max(id)
from
newsgroup_message
where
newsgroup_id = ?
"""
cr = self.db.execute(sql, (newsgroup.id))
row = cr.fetchone()
return "%d %d %d %s" % (
row[0],
row[1],
row[2],
newsgroup.name
)
2024-11-22 23:57:14 -05:00
def _cmd_listgroup(self, *args):
newsgroup = self.newsgroup
2024-11-25 17:18:22 -05:00
2024-11-22 23:57:14 -05:00
if len(args) == 0 and newsgroup is None:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_SELECTED)
elif len(args) > 0:
newsgroup = self.server.newsgroups.get(args[0])
if newsgroup is None:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_FOUND)
2024-11-23 00:20:06 -05:00
sql = """
select
id
from
newsgroup_message
where
newsgroup_id = ?
"""
2024-11-22 23:57:14 -05:00
if len(args) > 1:
2024-11-23 00:20:06 -05:00
msgrange = MessageRange.parse(args[1])
2024-11-25 21:52:27 -05:00
sql += " and " + msgrange.where()
2024-11-22 23:57:14 -05:00
2024-11-23 00:22:24 -05:00
summary = self._newsgroup_summary(newsgroup)
cr = self.db.execute(sql, (newsgroup.id))
2024-11-22 23:57:14 -05:00
2024-11-23 00:22:24 -05:00
self.respond(ResponseCode.NNTP_GROUP_LISTING, summary)
2024-11-22 23:57:14 -05:00
for message in cr.each():
self.print(str(message.id))
return self.end()
2024-11-25 15:53:32 -05:00
def _newsgroup_summary(self, newsgroup: Newsgroup):
sql = """
select
min(id),
max(id)
from
newsgroup_message
where
newsgroup_id = ?
"""
cr = self.db.execute(sql, (newsgroup.id,))
row = cr.fetchone()
return {
'low': row[0],
'high': row[1],
'perms': 'n'
}
def print_newsgroup(self, newsgroup: Newsgroup):
summary = self._newsgroup_summary(newsgroup)
return self.print("%s %d %d %s" % (
newsgroup.name,
summary['low'],
summary['high'],
summary['perms']
))
def _cmd_list_newsgroups(self):
self.respond(ResponseCode.NNTP_INFORMATION_FOLLOWS)
for name in self.server.newsgroups:
newsgroup = self.server.newsgroups[name]
self.print_newsgroup(newsgroup)
return self.end()
2024-11-25 17:18:38 -05:00
def _newsgroup_last_active(self, newsgroup: Newsgroup):
sql = """
select
max(created_on)
from
newsgroup_message
where
newsgroup_id = ?
"""
cr = self.db.execute(sql, (newsgroup.id,))
row = cr.fetchone()
if row is None:
return
return datetime.datetime.fromisoformat(row[0])
def _cmd_list_active(self):
now = datetime.datetime.now(datetime.UTC)
self.respond(ResponseCode.NNTP_INFORMATION_FOLLOWS)
for name in self.server.newsgroups:
newsgroup = self.server.newsgroups[name]
last_active = self._newsgroup_last_active(newsgroup)
if now - last_active < datetime.timedelta(days=1):
self.print_newsgroup(newsgroup)
return self.end()
2024-11-25 15:53:32 -05:00
LIST_SUBCOMMANDS = {
'NEWSGROUPS': _cmd_list_newsgroups,
2024-11-25 17:18:38 -05:00
'ACTIVE': _cmd_list_active,
2024-11-25 15:53:32 -05:00
}
2024-11-23 22:40:06 -05:00
def _cmd_list(self, *args):
2024-11-25 15:53:32 -05:00
if len(args) == 0:
return self.respond(ResponseCode.NNTP_SYNTAX_ERROR, "No subcommand provided")
subcmd, *subargs = args
fn = self.LIST_SUBCOMMANDS.get(subcmd.upper())
if fn is None:
return self.respond(ResponseCode.NNTP_COMMAND_UNKNOWN)
return fn(self, *subargs)
2024-11-23 22:40:06 -05:00
RE_DATE_SHORT = re.compile(r'^(\d{2})(\d{2})(\d{2})$')
RE_DATE_LONG = re.compile(r'^(\d{4})(\d{2})(\d{2})$')
RE_TIME = re.compile(r'^(\d{2})(\d{2})(\d{2})$')
def _parse_date_time(self, datestr: str, timestr: str):
yyyy, mm, dd = None, None, None,
hh, MM, ss = None, None, None
2024-11-25 17:18:22 -05:00
2024-11-23 22:40:06 -05:00
match = self.RE_DATE_SHORT.match(datestr)
if match:
yy, mm, dd = map(int, match[1:3])
if yy >= 70:
yyyy = 1900 + yy
else:
yyyy = 2000 + yy
match = self.RE_DATE_LONG.match(datestr)
if match:
yyyy, mm, dd = map(int, match[1:3])
if yyyy is None:
return
match = self.RE_TIME.match(timestr)
if match is None:
return
hh, mm, ss = map(int, match[1:3])
return datetime.datetime(yyyy, mm, dd, hh, MM, ss)
def _cmd_newnews(self, wildmat, datestr, timestr, *args):
gmt = False
if len(args) == 1:
if args[0] == "GMT":
gmt = True
else:
return self.send_response(ResponseCode.NNTP_SYNTAX_ERROR, "Only optional 'GMT' allowed")
elif len(args) > 1:
return self.send_response(ResponseCode.NNTP_SYNTAX_ERROR, "Too many arguments")
timestamp = self._parse_date_time(datestr, timestr)
if timestamp is None:
return self.send_response(ResponseCode.NNTP_SYNTAX_ERROR, "Invalid date or time")
self.respond(ResponseCode.NNTP_ARTICLE_LISTING_ID_FOLLOWS)
sql = """
select message_id from newsgroup_message where newsgroup_id = ? and created_on >= ?
"""
for name in self.server.newsgroups:
if fnmatch.fnmatch(name, wildmat):
newsgroup = self.server.newsgroups[name]
cr = self.db.execute(sql, (newsgroup.id, timestamp.isoformat()))
for row in cr.each():
self.print(row[0])
return self.end()
2024-11-25 00:49:53 -05:00
def _cmd_newgroups(self, wildmat, datestr, timestr, *args):
gmt = False
if len(args) == 1:
if args[0] == "GMT":
gmt = True
else:
2024-11-25 14:25:55 -05:00
return self.respond(ResponseCode.NNTP_SYNTAX_ERROR, "Only optional 'GMT' allowed")
2024-11-25 00:49:53 -05:00
elif len(args) > 1:
2024-11-25 14:25:55 -05:00
return self.respond(ResponseCode.NNTP_SYNTAX_ERROR, "Too many arguments")
2024-11-25 00:49:53 -05:00
2024-11-25 14:25:55 -05:00
self.respond(ResponseCode.NNTP_GROUPS_NEW_FOLLOW)
for name in self.server.newsgroups:
if fnmatch.fnmatch(name, wildmat):
newsgroup = self.server.newsgroups[name]
2024-11-25 15:53:32 -05:00
self.print_newsgroup(newsgroup)
2024-11-25 14:25:55 -05:00
return self.end()
2024-11-25 00:49:53 -05:00
2024-11-25 20:27:55 -05:00
def _message_by_id(self, identifier: str):
if identifier[0] == '<':
2024-11-25 20:34:26 -05:00
return self.db.get(Message, {
2024-11-25 20:27:55 -05:00
'newsgroup_id': self.newsgroup.id,
'message_id': identifier
})
else:
2024-11-25 20:34:26 -05:00
return self.db.get(Message, {
2024-11-25 20:27:55 -05:00
'newsgroup_id': self.newsgroup.id,
'id': int(identifier)
})
def _send_message_headers(self, message: Message):
for name in message.headers:
self.print("%s: %s" % (
name, message.headers[name]
))
2024-11-25 20:34:26 -05:00
def _serve_message(self, part: MessagePart, identifier: Optional[str]=None):
2024-11-25 20:27:55 -05:00
if self.newsgroup is None:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_SELECTED)
message = None
if self.article_id is None:
if identifier is None:
return self.respond(ResponseCode.NNTP_ARTICLE_INVALID_NUMBER)
else:
message = self._message_by_id(identifier)
self.article_id = message.id
else:
if identifier is None:
2024-11-25 20:34:26 -05:00
message = self._message_by_id(str(self.article_id))
2024-11-25 20:27:55 -05:00
else:
message = self._message_by_id(identifier)
self.article_id = message.id
text = "%d %s" % (
message.id,
message.message_id
)
self.respond(ResponseCode.NNTP_ARTICLE_LISTING, text)
if part is MessagePart.HEAD or part is MessagePart.WHOLE:
self._send_message_headers(message)
if part is MessagePart.WHOLE:
self.print('')
if part is MessagePart.BODY or part is MessagePart.WHOLE:
self.print(message.body)
return self.end()
2024-11-25 20:34:26 -05:00
def _cmd_head(self, identifier: Optional[str]=None):
2024-11-25 20:27:55 -05:00
return self._serve_message(MessagePart.HEAD, identifier)
2024-11-25 20:34:26 -05:00
def _cmd_body(self, identifier: Optional[str]=None):
2024-11-25 20:27:55 -05:00
return self._serve_message(MessagePart.BODY, identifier)
2024-11-25 20:34:26 -05:00
def _cmd_article(self, identifier: Optional[str]=None):
2024-11-25 20:27:55 -05:00
return self._serve_message(MessagePart.WHOLE, identifier)
2024-11-25 21:53:08 -05:00
def _send_message_header(self, message: Message, name: str):
return self.print("%d %s" % (
message.id, message.headers.get(name, '')
))
def _cmd_hdr(self, name: str, msg: Optional[str]=None):
if self.newsgroup is None:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_SELECTED)
if msg is None:
if self.article_id is None:
return self.respond(ResponseCode.NNTP_ARTICLE_INVALID_NUMBER)
message = self.db.get(Message, self.article_id)
self.respond(ResponseCode.NNTP_HEADERS_FOLLOW)
self._send_message_header(message, name)
else:
msgrange = MessageRange.parse(msg)
sql = f"select * from {Message.name} where "
sql += " newsgroup_id = ? and " + msgrange.where()
cr = self.db.query_sql(Message, sql, (self.newsgroup.id,))
self.respond(ResponseCode.NNTP_HEADERS_FOLLOW)
for message in cr.each():
self._send_message_header(message, name)
return self.end()
2024-11-25 22:01:49 -05:00
def _cmd_stat(self, identifier: Optional[str]=None):
if self.newsgroup is None:
return self.respond(ResponseCode.NNTP_NEWSGROUP_NOT_SELECTED)
sql = """
select
id, message_id
from
newsgroup_message
where
newsgroup_id = ?
"""
row = None
if identifier is None:
if self.article_id is None:
return self.respond(ResponseCode.NNTP_ARTICLE_INVALID_NUMBER)
identifier = str(self.article_id)
if identifier[0] == '<':
cr = self.db.execute(sql + " and message_id = ?",
(self.newsgroup.id, identifier))
row = cr.fetchone()
if row is None:
return self.respond(ResponseCode.NNTP_ARTICLE_NOT_FOUND_ID)
else:
cr = self.db.execute(sql + " and id = ?",
(self.newsgroup.id, int(identifier)))
row = cr.fetchone()
if row is None:
return self.respond(ResponseCode.NNTP_ARTICLE_NOT_FOUND_NUM)
text = "%d %s" % (row[0], row[1])
if self.article_id is None:
self.article_id = int(row[0])
return self.respond(ResponseCode.NNTP_ARTICLE_STAT_RESPONSE, text)
2024-11-26 10:19:02 -05:00
def _cmd_date(self):
timestamp = datetime.datetime.now(datetime.UTC)
return self.respond(ResponseCode.NNTP_DATE,
timestamp.strftime("%Y%m%d%H%M%S"))
2024-11-25 00:49:53 -05:00
COMMANDS = {
'CAPABILITIES': _cmd_capabilities,
'GROUP': _cmd_group,
'LISTGROUP': _cmd_listgroup,
'LIST': _cmd_list,
'NEWNEWS': _cmd_newnews,
2024-11-25 14:25:55 -05:00
'NEWGROUPS': _cmd_newgroups,
2024-11-25 20:27:55 -05:00
'HEAD': _cmd_head,
'BODY': _cmd_body,
2024-11-25 21:53:08 -05:00
'ARTICLE': _cmd_article,
'HDR': _cmd_hdr,
2024-11-25 22:01:49 -05:00
'STAT': _cmd_stat,
2024-11-26 10:19:02 -05:00
'DATE': _cmd_date,
2024-11-25 00:49:53 -05:00
}
2024-11-20 21:17:03 -05:00
def handle(self):
line = self.readline()
if line == '':
return
2024-11-25 00:49:53 -05:00
tokens = self.RE_SPLIT.split(line.rstrip())
command, *args = tokens
fn = self.COMMANDS.get(command.upper())
if fn is None:
return self.respond(ResponseCode.NNTP_COMMAND_UNKNOWN)
2024-11-25 14:27:58 -05:00
try:
return fn(self, *args)
except TypeError as e:
2024-11-25 20:27:14 -05:00
traceback.print_exception(e)
2024-11-25 14:27:58 -05:00
return self.respond(ResponseCode.NNTP_SYNTAX_ERROR)
except Exception as e:
2024-11-25 20:27:14 -05:00
traceback.print_exception(e)
2024-11-25 14:27:58 -05:00
return self.respond(ResponseCode.NNTP_COMMAND_UNAVAILABLE)