MoinMessage

MoinMessage.py

73:039b4e617b8e
2013-11-06 Paul Boddie Obtain ItemStore from ItemSupport.
     1 # -*- coding: iso-8859-1 -*-     2 """     3     MoinMoin - MoinMessage library     4      5     @copyright: 2012, 2013 by Paul Boddie <paul@boddie.org.uk>     6     @license: GNU GPL (v2 or later), see COPYING.txt for details.     7 """     8      9 from email import message_from_string    10 from email.encoders import encode_noop    11 from email.mime.multipart import MIMEMultipart    12 from email.mime.application import MIMEApplication    13 from email.mime.base import MIMEBase    14 from email.utils import formatdate, parsedate    15 from subprocess import Popen, PIPE    16 from tempfile import mkstemp    17 from urlparse import urlsplit    18 import httplib    19 import os    20     21 def is_collection(message):    22     return message.get("Update-Type") == "collection"    23     24 def to_replace(message):    25     return message.get("Update-Action") == "replace"    26     27 def to_store(message):    28     return message.get("Update-Action") == "store"    29     30 class Message:    31     32     "An update message."    33     34     def __init__(self, text=None):    35         self.date = None    36         self.updates = []    37         if text:    38             self.parse_text(text)    39     40     def init_date(self, message):    41     42         "Obtain the date of the given 'message'."    43     44         if message.has_key("Date"):    45             self.date = parsedate(message["Date"])    46         else:    47             self.date = None    48     49     def parse_text(self, text):    50     51         "Parse the given 'text' as a message."    52     53         self.handle_message(message_from_string(text))    54     55     def handle_message(self, message):    56     57         "Handle the given 'message', recording the separate updates."    58     59         self.init_date(message)    60     61         # The message either consists of a collection of updates.    62     63         if message.is_multipart() and is_collection(message):    64             for part in message.get_payload():    65                 self.updates.append(part)    66     67         # Or the message is a single update.    68     69         else:    70             self.updates.append(message)    71     72     def add_updates(self, parts):    73     74         """    75         Add the given 'parts' to a message.    76         """    77     78         for part in updates:    79             self.add_update(part)    80     81     def add_update(self, part):    82     83         """    84         Add an update 'part' to a message.    85         """    86     87         self.updates.append(part)    88     89     def get_update(self, alternatives):    90     91         """    92         Return a suitable multipart object containing the supplied    93         'alternatives'.    94         """    95     96         part = MIMEMultipart()    97         for alternative in alternatives:    98             part.attach(alternative)    99         return part   100    101     def get_payload(self, timestamped=True):   102    103         """   104         Get the multipart payload for the message. If the 'timestamped'   105         parameter is omitted or set to a true value, the payload will be given a   106         date header set to the current date and time that can be used to assess   107         the validity of a message and to determine whether it has already been   108         received by a recipient.   109         """   110    111         if len(self.updates) == 1:   112             message = self.updates[0]   113         else:   114             message = MIMEMultipart()   115             message.add_header("Update-Type", "collection")   116             for update in self.updates:   117                 message.attach(update)   118    119         if timestamped:   120             timestamp(message)   121             self.init_date(message)   122    123         return message   124    125 class Mailbox:   126    127     "A collection of messages within a multipart message."   128    129     def __init__(self, text=None):   130         self.messages = []   131         if text:   132             self.parse_text(text)   133    134     def parse_text(self, text):   135    136         "Parse the given 'text' as a mailbox."   137    138         message = message_from_string(text)   139    140         if message.is_multipart():   141             for part in message.get_payload():   142                 self.messages.append(part)   143         else:   144             self.messages.append(message)   145    146     def add_message(self, message):   147    148         "Add the given 'message' to the mailbox."   149    150         self.messages.append(message)   151    152     def get_payload(self):   153    154         "Get the multipart payload for the mailbox."   155    156         mailbox = MIMEMultipart()   157         for message in self.messages:   158             mailbox.attach(message)   159    160         return mailbox   161    162 class MoinMessageError(Exception):   163     pass   164    165 class MoinMessageDecodingError(Exception):   166     pass   167    168 class MoinMessageMissingPart(MoinMessageDecodingError):   169     pass   170    171 class MoinMessageBadContent(MoinMessageDecodingError):   172     pass   173    174 class GPG:   175    176     "A wrapper around the gpg command using a particular configuration."   177    178     def __init__(self, homedir=None):   179         self.conf_args = []   180    181         if homedir:   182             self.conf_args += ["--homedir", homedir]   183    184         self.errors = None   185    186     def run(self, args, text=None):   187    188         """   189         Invoke gpg with the given 'args', supplying the given 'text' to the   190         command directly or, if 'text' is omitted, using a file provided as part   191         of the 'args' if appropriate.   192    193         Failure to complete the operation will result in a MoinMessageError   194         being raised.   195         """   196    197         cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE)   198    199         # Attempt to write input to the command and to read output from the   200         # command.   201    202         text, self.errors = cmd.communicate(text)   203    204         # Test for a zero result.   205    206         if not cmd.returncode:   207             return text   208         else:   209             raise MoinMessageError, self.errors   210    211     def verifyMessageText(self, signature, content):   212    213         "Using the given 'signature', verify the given message 'content'."   214    215         # Write the detached signature and content to files.   216    217         signature_fd, signature_filename = mkstemp()   218         content_fd, content_filename = mkstemp()   219    220         try:   221             signature_fp = os.fdopen(signature_fd, "w")   222             content_fp = os.fdopen(content_fd, "w")   223             try:   224                 signature_fp.write(signature)   225                 content_fp.write(content)   226             finally:   227                 signature_fp.close()   228                 content_fp.close()   229    230             # Verify the message text.   231    232             text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename])   233    234             # Return the details of the signing key.   235    236             identity = None   237             fingerprint = None   238    239             for line in text.split("\n"):   240                 try:   241                     prefix, msgtype, digest, details = line.strip().split(" ", 3)   242                 except ValueError:   243                     continue   244    245                 # Return the fingerprint and identity details.   246    247                 if msgtype == "GOODSIG":   248                     identity = details   249                 elif msgtype == "VALIDSIG":   250                     fingerprint = digest   251    252                 if identity and fingerprint:   253                     return fingerprint, identity   254    255             return None   256    257         finally:   258             os.remove(signature_filename)   259             os.remove(content_filename)   260    261     def verifyMessage(self, message):   262    263         """   264         Verify the given RFC 3156 'message', returning a tuple of the form   265         (fingerprint, identity, content).   266         """   267    268         content, signature = getContentAndSignature(message)   269    270         # Verify the message format.   271    272         if signature.get_content_type() != "application/pgp-signature":   273             raise MoinMessageBadContent   274    275         # Verify the message.   276    277         fingerprint, identity = self.verifyMessageText(signature.get_payload(), content.as_string())   278         return fingerprint, identity, content   279    280     def signMessage(self, message, keyid):   281    282         """   283         Return a signed version of 'message' using the given 'keyid'.   284         """   285    286         text = message.as_string()   287         signature = self.run(["--armor", "-u", keyid, "--detach-sig"], text)   288    289         # Make the container for the message.   290    291         signed_message = MIMEMultipart("signed", protocol="application/pgp-signature")   292         signed_message.attach(message)   293    294         signature_part = MIMEBase("application", "pgp-signature")   295         signature_part.set_payload(signature)   296         signed_message.attach(signature_part)   297    298         return signed_message   299    300     def decryptMessageText(self, message):   301    302         "Return a decrypted version of 'message'."   303    304         return self.run(["--decrypt"], message)   305    306     def decryptMessage(self, message):   307    308         """   309         Decrypt the given RFC 3156 'message', returning the message text.   310         """   311    312         try:   313             declaration, content = message.get_payload()   314         except ValueError:   315             raise MoinMessageMissingPart   316    317         # Verify the message format.   318    319         if content.get_content_type() != "application/octet-stream":   320             raise MoinMessageBadContent   321    322         # Return the decrypted message text.   323    324         return self.decryptMessageText(content.get_payload())   325    326     def encryptMessage(self, message, keyid):   327    328         """   329         Return an encrypted version of 'message' using the given 'keyid'.   330         """   331    332         text = message.as_string()   333         encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text)   334    335         # Make the container for the message.   336    337         encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted")   338    339         # For encrypted content, add the declaration and content.   340    341         declaration = MIMEBase("application", "pgp-encrypted")   342         declaration.set_payload("Version: 1")   343         encrypted_message.attach(declaration)   344    345         content = MIMEApplication(encrypted, "octet-stream", encode_noop)   346         encrypted_message.attach(content)   347    348         return encrypted_message   349    350 # Message decoding functions.   351    352 # Detect PGP/GPG-encoded payloads.   353 # See: http://tools.ietf.org/html/rfc3156   354    355 def is_signed(message):   356     mimetype = message.get_content_type()   357     encoding = message.get_content_charset()   358    359     return mimetype == "multipart/signed" and \   360         message.get_param("protocol") == "application/pgp-signature"   361    362 def is_encrypted(message):   363     mimetype = message.get_content_type()   364     encoding = message.get_content_charset()   365    366     return mimetype == "multipart/encrypted" and \   367         message.get_param("protocol") == "application/pgp-encrypted"   368    369 def getContentAndSignature(message):   370    371     """   372     Return the content and signature parts of the given RFC 3156 'message'.   373    374     NOTE: RFC 3156 states that signed messages should employ a detached   375     NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures   376     NOTE: instead of "BEGIN PGP SIGNATURE".   377     NOTE: The "micalg" parameter is currently not supported.   378     """   379    380     try:   381         content, signature = message.get_payload()   382         return content, signature   383     except ValueError:   384         raise MoinMessageMissingPart   385    386 # Communications functions.   387    388 def timestamp(message):   389    390     """   391     Timestamp the given 'message' so that its validity can be assessed by the   392     recipient.   393     """   394    395     datestr = formatdate()   396    397     if not message.has_key("Date"):   398         message.add_header("Date", datestr)   399     else:   400         message["Date"] = datestr   401    402 def sendMessage(message, url, method="PUT"):   403    404     """   405     Send 'message' to the given 'url' using the given 'method' (using PUT as the   406     default if omitted).   407     """   408    409     scheme, host, port, path = parseURL(url)   410     text = message.as_string()   411    412     if scheme == "http":   413         cls = httplib.HTTPConnection   414     elif scheme == "https":   415         cls = httplib.HTTPSConnection   416     else:   417         raise MoinMessageError, "Communications protocol not supported: %s" % scheme   418    419     req = cls(host, port)   420     req.request(method, path, text)   421     resp = req.getresponse()   422    423     if resp.status >= 400:   424         raise MoinMessageError, "Message sending failed: %s" % resp.status   425    426     return resp.read()   427    428 def parseURL(url):   429    430     "Return the scheme, host, port and path for the given 'url'."   431    432     scheme, host_port, path, query, fragment = urlsplit(url)   433     host_port = host_port.split(":")   434    435     if query:   436         path += "?" + query   437    438     if len(host_port) > 1:   439         host = host_port[0]   440         port = int(host_port[1])   441     else:   442         host = host_port[0]   443         port = 80   444    445     return scheme, host, port, path   446    447 # vim: tabstop=4 expandtab shiftwidth=4