paul@29 | 1 | # -*- coding: iso-8859-1 -*- |
paul@29 | 2 | """ |
paul@29 | 3 | MoinMoin - MoinMessageSupport library |
paul@29 | 4 | |
paul@29 | 5 | @copyright: 2012, 2013 by Paul Boddie <paul@boddie.org.uk> |
paul@29 | 6 | @license: GNU GPL (v2 or later), see COPYING.txt for details. |
paul@29 | 7 | """ |
paul@29 | 8 | |
paul@29 | 9 | from MoinMoin.Page import Page |
paul@29 | 10 | from MoinMoin.log import getLogger |
paul@29 | 11 | from MoinMoin.user import User |
paul@30 | 12 | from MoinMoin import wikiutil |
paul@83 | 13 | from MoinSupport import getHeader, getMetadata, getWikiDict, writeHeaders, \ |
paul@83 | 14 | parseDictEntry |
paul@73 | 15 | from ItemSupport import ItemStore |
paul@36 | 16 | from MoinMessage import GPG, Message, MoinMessageError, \ |
paul@56 | 17 | MoinMessageMissingPart, MoinMessageBadContent, \ |
paul@36 | 18 | is_signed, is_encrypted, getContentAndSignature |
paul@29 | 19 | from email.parser import Parser |
paul@30 | 20 | import time |
paul@29 | 21 | |
paul@29 | 22 | try: |
paul@29 | 23 | from cStringIO import StringIO |
paul@29 | 24 | except ImportError: |
paul@29 | 25 | from StringIO import StringIO |
paul@29 | 26 | |
paul@29 | 27 | Dependencies = ['pages'] |
paul@29 | 28 | |
paul@29 | 29 | class MoinMessageAction: |
paul@29 | 30 | |
paul@29 | 31 | "Common message handling support for actions." |
paul@29 | 32 | |
paul@29 | 33 | def __init__(self, pagename, request): |
paul@29 | 34 | |
paul@29 | 35 | """ |
paul@29 | 36 | On the page with the given 'pagename', use the given 'request' when |
paul@29 | 37 | reading posted messages, modifying the Wiki. |
paul@29 | 38 | """ |
paul@29 | 39 | |
paul@29 | 40 | self.pagename = pagename |
paul@29 | 41 | self.request = request |
paul@29 | 42 | self.page = Page(request, pagename) |
paul@83 | 43 | self.init_store() |
paul@83 | 44 | |
paul@83 | 45 | def init_store(self): |
paul@29 | 46 | self.store = ItemStore(self.page, "messages", "message-locks") |
paul@29 | 47 | |
paul@29 | 48 | def do_action(self): |
paul@29 | 49 | request = self.request |
paul@29 | 50 | content_length = getHeader(request, "Content-Length", "HTTP") |
paul@29 | 51 | if content_length: |
paul@29 | 52 | content_length = int(content_length) |
paul@29 | 53 | |
paul@29 | 54 | self.handle_message_text(request.read(content_length)) |
paul@29 | 55 | |
paul@29 | 56 | def handle_message_text(self, message_text): |
paul@29 | 57 | |
paul@29 | 58 | "Handle the given 'message_text'." |
paul@29 | 59 | |
paul@83 | 60 | request = self.request |
paul@29 | 61 | message = Parser().parse(StringIO(message_text)) |
paul@83 | 62 | |
paul@83 | 63 | # Detect any indicated recipient and change the target page, if |
paul@83 | 64 | # appropriate. |
paul@83 | 65 | |
paul@83 | 66 | if message.has_key("To"): |
paul@83 | 67 | try: |
paul@83 | 68 | parameters = get_recipient_details(request, message["To"], main=True) |
paul@83 | 69 | except MoinMessageRecipientError, exc: |
paul@83 | 70 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@83 | 71 | request.write("The recipient indicated in the message is not known to this site. " |
paul@83 | 72 | "Details: %s" % exc.message) |
paul@83 | 73 | return |
paul@83 | 74 | else: |
paul@83 | 75 | if parameters["type"] == "page": |
paul@83 | 76 | self.page = Page(request, parameters["location"]) |
paul@83 | 77 | self.init_store() |
paul@83 | 78 | |
paul@83 | 79 | # NOTE: Support "url". |
paul@83 | 80 | |
paul@83 | 81 | # Handle the parsed message. |
paul@83 | 82 | |
paul@29 | 83 | self.handle_message(message) |
paul@29 | 84 | |
paul@29 | 85 | def handle_message(self, message): |
paul@29 | 86 | |
paul@29 | 87 | "Handle the given 'message'." |
paul@29 | 88 | |
paul@29 | 89 | # Detect PGP/GPG-encoded payloads. |
paul@29 | 90 | # See: http://tools.ietf.org/html/rfc3156 |
paul@29 | 91 | |
paul@64 | 92 | # Signed payloads are checked and then passed on for further processing |
paul@64 | 93 | # elsewhere. Verification is the last step in this base implementation, |
paul@64 | 94 | # even if an encrypted-then-signed payload is involved. |
paul@64 | 95 | |
paul@33 | 96 | if is_signed(message): |
paul@29 | 97 | self.handle_signed_message(message) |
paul@64 | 98 | |
paul@64 | 99 | # Encrypted payloads are decrypted and then sent back into this method |
paul@64 | 100 | # for signature checking as described above. Thus, signed-then-encrypted |
paul@64 | 101 | # payloads are first decrypted and then verified. |
paul@64 | 102 | |
paul@33 | 103 | elif is_encrypted(message): |
paul@29 | 104 | self.handle_encrypted_message(message) |
paul@29 | 105 | |
paul@33 | 106 | # Reject unsigned and unencrypted payloads. |
paul@29 | 107 | |
paul@29 | 108 | else: |
paul@33 | 109 | request = self.request |
paul@29 | 110 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@29 | 111 | request.write("Only PGP/GPG-signed payloads are supported.") |
paul@29 | 112 | |
paul@29 | 113 | def handle_encrypted_message(self, message): |
paul@29 | 114 | |
paul@29 | 115 | "Handle the given encrypted 'message'." |
paul@29 | 116 | |
paul@29 | 117 | request = self.request |
paul@29 | 118 | |
paul@29 | 119 | homedir = self.get_homedir() |
paul@29 | 120 | if not homedir: |
paul@29 | 121 | return |
paul@29 | 122 | |
paul@29 | 123 | gpg = GPG(homedir) |
paul@29 | 124 | |
paul@33 | 125 | try: |
paul@33 | 126 | text = gpg.decryptMessage(message) |
paul@29 | 127 | |
paul@33 | 128 | # Reject messages without a declaration. |
paul@29 | 129 | |
paul@33 | 130 | except MoinMessageMissingPart: |
paul@33 | 131 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@33 | 132 | request.write("There must be a declaration and a content part for encrypted uploads.") |
paul@33 | 133 | return |
paul@33 | 134 | |
paul@33 | 135 | # Reject messages without appropriate content. |
paul@29 | 136 | |
paul@33 | 137 | except MoinMessageBadContent: |
paul@33 | 138 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@33 | 139 | request.write("Encrypted data must be provided as application/octet-stream.") |
paul@33 | 140 | return |
paul@29 | 141 | |
paul@33 | 142 | # Reject any unencryptable message. |
paul@29 | 143 | |
paul@29 | 144 | except MoinMessageError: |
paul@29 | 145 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@29 | 146 | request.write("The message could not be decrypted.") |
paul@33 | 147 | return |
paul@33 | 148 | |
paul@33 | 149 | # Log non-fatal errors. |
paul@33 | 150 | |
paul@33 | 151 | if gpg.errors: |
paul@33 | 152 | getLogger(__name__).warning(gpg.errors) |
paul@33 | 153 | |
paul@33 | 154 | # Handle the embedded message which may itself be a signed message. |
paul@33 | 155 | |
paul@33 | 156 | self.handle_message_text(text) |
paul@29 | 157 | |
paul@29 | 158 | def handle_signed_message(self, message): |
paul@29 | 159 | |
paul@29 | 160 | "Handle the given signed 'message'." |
paul@29 | 161 | |
paul@29 | 162 | request = self.request |
paul@29 | 163 | |
paul@36 | 164 | # Accept any message whose sender was authenticated by the PGP method. |
paul@33 | 165 | |
paul@36 | 166 | if request.user and request.user.valid and request.user.auth_method == "pgp": |
paul@33 | 167 | |
paul@36 | 168 | # Handle the embedded message. |
paul@29 | 169 | |
paul@36 | 170 | content, signature = getContentAndSignature(message) |
paul@36 | 171 | self.handle_message_content(content) |
paul@29 | 172 | |
paul@33 | 173 | # Reject any unverified message. |
paul@29 | 174 | |
paul@36 | 175 | else: |
paul@29 | 176 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@39 | 177 | request.write("The message could not be verified. " |
paul@39 | 178 | "Maybe this site is not performing authentication using PGP signatures.") |
paul@29 | 179 | |
paul@29 | 180 | def handle_message_content(self, content): |
paul@29 | 181 | |
paul@29 | 182 | "Handle the given message 'content'." |
paul@29 | 183 | |
paul@30 | 184 | request = self.request |
paul@30 | 185 | |
paul@30 | 186 | # Interpret the content as one or more updates. |
paul@30 | 187 | |
paul@30 | 188 | message = Message() |
paul@30 | 189 | message.handle_message(content) |
paul@30 | 190 | |
paul@30 | 191 | # Test any date against the page or message store. |
paul@30 | 192 | |
paul@30 | 193 | if message.date: |
paul@30 | 194 | store_date = time.gmtime(self.store.mtime()) |
paul@30 | 195 | page_date = time.gmtime(wikiutil.version2timestamp(self.page.mtime_usecs())) |
paul@30 | 196 | last_date = max(store_date, page_date) |
paul@30 | 197 | |
paul@30 | 198 | # Reject messages older than the page date. |
paul@30 | 199 | |
paul@30 | 200 | if message.date < last_date: |
paul@30 | 201 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@30 | 202 | request.write("The message is too old: %s versus %s." % (message.date, last_date)) |
paul@30 | 203 | return |
paul@30 | 204 | |
paul@30 | 205 | # Reject messages without dates if so configured. |
paul@30 | 206 | |
paul@30 | 207 | elif getattr(request.cfg, "moinmessage_reject_messages_without_dates", True): |
paul@30 | 208 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@30 | 209 | request.write("The message has no date information.") |
paul@30 | 210 | return |
paul@30 | 211 | |
paul@30 | 212 | # Handle the message as an object. |
paul@30 | 213 | |
paul@30 | 214 | self.handle_message_object(message) |
paul@29 | 215 | |
paul@29 | 216 | def get_homedir(self): |
paul@29 | 217 | |
paul@29 | 218 | "Locate the GPG home directory." |
paul@29 | 219 | |
paul@34 | 220 | request = self.request |
paul@34 | 221 | homedir = get_homedir(self.request) |
paul@34 | 222 | |
paul@29 | 223 | if not homedir: |
paul@29 | 224 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@29 | 225 | request.write("Encoded data cannot currently be understood. Please notify the site administrator.") |
paul@34 | 226 | |
paul@29 | 227 | return homedir |
paul@29 | 228 | |
paul@83 | 229 | class MoinMessageRecipientError(MoinMessageError): |
paul@83 | 230 | pass |
paul@83 | 231 | |
paul@83 | 232 | class MoinMessageNoRecipients(MoinMessageRecipientError): |
paul@83 | 233 | pass |
paul@83 | 234 | |
paul@83 | 235 | class MoinMessageUnknownRecipient(MoinMessageRecipientError): |
paul@83 | 236 | pass |
paul@83 | 237 | |
paul@83 | 238 | class MoinMessageBadRecipient(MoinMessageRecipientError): |
paul@83 | 239 | pass |
paul@83 | 240 | |
paul@34 | 241 | def get_homedir(request): |
paul@34 | 242 | |
paul@34 | 243 | "Locate the GPG home directory." |
paul@34 | 244 | |
paul@34 | 245 | return getattr(request.cfg, "moinmessage_gpg_homedir") |
paul@34 | 246 | |
paul@60 | 247 | def get_signing_users(request): |
paul@60 | 248 | |
paul@60 | 249 | "Return a dictionary mapping usernames to signing keys." |
paul@60 | 250 | |
paul@60 | 251 | return getWikiDict( |
paul@60 | 252 | getattr(request.cfg, "moinmessage_gpg_signing_users_page", "MoinMessageSigningUserDict"), |
paul@60 | 253 | request) |
paul@60 | 254 | |
paul@68 | 255 | def get_relays(request): |
paul@68 | 256 | |
paul@68 | 257 | "Return a dictionary mapping relays to URLs." |
paul@68 | 258 | |
paul@68 | 259 | return getWikiDict( |
paul@68 | 260 | getattr(request.cfg, "moinmessage_gpg_relays_page", "MoinMessageRelayDict"), |
paul@68 | 261 | request) |
paul@68 | 262 | |
paul@83 | 263 | def get_recipients(request, main=False): |
paul@60 | 264 | |
paul@60 | 265 | """ |
paul@60 | 266 | Return the recipients dictionary by first obtaining the page in which it |
paul@60 | 267 | is stored. This page may either be a subpage of the user's home page, if |
paul@60 | 268 | stored on this wiki, or it may be relative to the site root. |
paul@60 | 269 | |
paul@83 | 270 | When 'main' is specified and set to a true value, only a dictionary under |
paul@83 | 271 | the site root is consulted. |
paul@83 | 272 | |
paul@60 | 273 | The name of the subpage is defined by the configuration setting |
paul@60 | 274 | 'moinmessage_gpg_recipients_page', which if absent is set to |
paul@60 | 275 | "MoinMessageRecipientsDict". |
paul@60 | 276 | """ |
paul@60 | 277 | |
paul@60 | 278 | subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") |
paul@83 | 279 | |
paul@83 | 280 | if not main: |
paul@83 | 281 | homedetails = wikiutil.getInterwikiHomePage(request) |
paul@60 | 282 | |
paul@83 | 283 | if homedetails: |
paul@83 | 284 | homewiki, homepage = homedetails |
paul@83 | 285 | if homewiki == "Self": |
paul@83 | 286 | recipients = getWikiDict("%s/%s" % (homepage, subpage), request) |
paul@83 | 287 | if recipients: |
paul@83 | 288 | return recipients |
paul@60 | 289 | |
paul@60 | 290 | return getWikiDict(subpage, request) |
paul@60 | 291 | |
paul@80 | 292 | def get_username_for_fingerprint(request, fingerprint): |
paul@80 | 293 | |
paul@80 | 294 | """ |
paul@80 | 295 | Using the 'request', return the username corresponding to the given key |
paul@80 | 296 | 'fingerprint' or None if no correspondence is present in the mapping page. |
paul@80 | 297 | """ |
paul@80 | 298 | |
paul@80 | 299 | gpg_users = getWikiDict( |
paul@80 | 300 | getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), |
paul@80 | 301 | request, |
paul@80 | 302 | superuser=True # disable user test because we have no user yet |
paul@80 | 303 | ) |
paul@80 | 304 | |
paul@80 | 305 | # With a user mapping and a fingerprint corresponding to a known |
paul@80 | 306 | # user, temporarily switch user in order to make the edit. |
paul@80 | 307 | |
paul@80 | 308 | if gpg_users and gpg_users.has_key(fingerprint): |
paul@80 | 309 | return gpg_users[fingerprint] |
paul@80 | 310 | else: |
paul@80 | 311 | return None |
paul@80 | 312 | |
paul@83 | 313 | def get_recipient_details(request, recipient, main=False): |
paul@83 | 314 | |
paul@83 | 315 | """ |
paul@83 | 316 | Using the 'request', return a dictionary of details for the specified |
paul@83 | 317 | 'recipient'. If no details exist, raise a MoinMessageRecipientError |
paul@83 | 318 | exception. |
paul@83 | 319 | |
paul@83 | 320 | When 'main' is specified and set to a true value, only the recipients |
paul@83 | 321 | dictionary under the site root is consulted. |
paul@83 | 322 | """ |
paul@83 | 323 | |
paul@83 | 324 | _ = request.getText |
paul@83 | 325 | |
paul@83 | 326 | recipients = get_recipients(request, main) |
paul@83 | 327 | if not recipients: |
paul@83 | 328 | raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") |
paul@83 | 329 | |
paul@83 | 330 | recipient_details = recipients.get(recipient) |
paul@83 | 331 | if not recipient_details: |
paul@83 | 332 | raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") |
paul@83 | 333 | |
paul@83 | 334 | parameters = parseDictEntry(recipient_details, ("type", "location", "fingerprint",)) |
paul@83 | 335 | |
paul@83 | 336 | if not parameters.has_key("type"): |
paul@83 | 337 | raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") |
paul@83 | 338 | |
paul@83 | 339 | if not parameters.has_key("location"): |
paul@83 | 340 | raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") |
paul@83 | 341 | |
paul@83 | 342 | if parameters["type"] in ("url", "relay") and not parameters.has_key("fingerprint"): |
paul@83 | 343 | raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") |
paul@83 | 344 | |
paul@83 | 345 | return parameters |
paul@83 | 346 | |
paul@29 | 347 | # vim: tabstop=4 expandtab shiftwidth=4 |