paul@29 | 1 | # -*- coding: iso-8859-1 -*- |
paul@29 | 2 | """ |
paul@29 | 3 | MoinMoin - MoinMessageSupport library |
paul@29 | 4 | |
paul@98 | 5 | @copyright: 2012, 2013, 2014 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@85 | 16 | from TokenSupport import getIdentifiers |
paul@36 | 17 | from MoinMessage import GPG, Message, MoinMessageError, \ |
paul@56 | 18 | MoinMessageMissingPart, MoinMessageBadContent, \ |
paul@98 | 19 | is_signed, is_encrypted, getContentAndSignature, \ |
paul@98 | 20 | getOriginalContent |
paul@29 | 21 | from email.parser import Parser |
paul@30 | 22 | import time |
paul@29 | 23 | |
paul@29 | 24 | Dependencies = ['pages'] |
paul@29 | 25 | |
paul@29 | 26 | class MoinMessageAction: |
paul@29 | 27 | |
paul@29 | 28 | "Common message handling support for actions." |
paul@29 | 29 | |
paul@29 | 30 | def __init__(self, pagename, request): |
paul@29 | 31 | |
paul@29 | 32 | """ |
paul@29 | 33 | On the page with the given 'pagename', use the given 'request' when |
paul@29 | 34 | reading posted messages, modifying the Wiki. |
paul@29 | 35 | """ |
paul@29 | 36 | |
paul@29 | 37 | self.pagename = pagename |
paul@29 | 38 | self.request = request |
paul@29 | 39 | self.page = Page(request, pagename) |
paul@83 | 40 | self.init_store() |
paul@83 | 41 | |
paul@83 | 42 | def init_store(self): |
paul@29 | 43 | self.store = ItemStore(self.page, "messages", "message-locks") |
paul@29 | 44 | |
paul@29 | 45 | def do_action(self): |
paul@29 | 46 | request = self.request |
paul@29 | 47 | content_length = getHeader(request, "Content-Length", "HTTP") |
paul@29 | 48 | if content_length: |
paul@29 | 49 | content_length = int(content_length) |
paul@29 | 50 | |
paul@29 | 51 | self.handle_message_text(request.read(content_length)) |
paul@29 | 52 | |
paul@29 | 53 | def handle_message_text(self, message_text): |
paul@29 | 54 | |
paul@29 | 55 | "Handle the given 'message_text'." |
paul@29 | 56 | |
paul@83 | 57 | request = self.request |
paul@91 | 58 | message = Parser().parsestr(message_text) |
paul@83 | 59 | |
paul@83 | 60 | # Detect any indicated recipient and change the target page, if |
paul@83 | 61 | # appropriate. |
paul@83 | 62 | |
paul@83 | 63 | if message.has_key("To"): |
paul@83 | 64 | try: |
paul@83 | 65 | parameters = get_recipient_details(request, message["To"], main=True) |
paul@83 | 66 | except MoinMessageRecipientError, exc: |
paul@83 | 67 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@83 | 68 | request.write("The recipient indicated in the message is not known to this site. " |
paul@83 | 69 | "Details: %s" % exc.message) |
paul@83 | 70 | return |
paul@83 | 71 | else: |
paul@83 | 72 | if parameters["type"] == "page": |
paul@83 | 73 | self.page = Page(request, parameters["location"]) |
paul@83 | 74 | self.init_store() |
paul@83 | 75 | |
paul@83 | 76 | # NOTE: Support "url". |
paul@83 | 77 | |
paul@83 | 78 | # Handle the parsed message. |
paul@83 | 79 | |
paul@29 | 80 | self.handle_message(message) |
paul@29 | 81 | |
paul@29 | 82 | def handle_message(self, message): |
paul@29 | 83 | |
paul@29 | 84 | "Handle the given 'message'." |
paul@29 | 85 | |
paul@29 | 86 | # Detect PGP/GPG-encoded payloads. |
paul@29 | 87 | # See: http://tools.ietf.org/html/rfc3156 |
paul@29 | 88 | |
paul@64 | 89 | # Signed payloads are checked and then passed on for further processing |
paul@64 | 90 | # elsewhere. Verification is the last step in this base implementation, |
paul@64 | 91 | # even if an encrypted-then-signed payload is involved. |
paul@64 | 92 | |
paul@33 | 93 | if is_signed(message): |
paul@29 | 94 | self.handle_signed_message(message) |
paul@64 | 95 | |
paul@64 | 96 | # Encrypted payloads are decrypted and then sent back into this method |
paul@64 | 97 | # for signature checking as described above. Thus, signed-then-encrypted |
paul@64 | 98 | # payloads are first decrypted and then verified. |
paul@64 | 99 | |
paul@33 | 100 | elif is_encrypted(message): |
paul@29 | 101 | self.handle_encrypted_message(message) |
paul@29 | 102 | |
paul@33 | 103 | # Reject unsigned and unencrypted payloads. |
paul@29 | 104 | |
paul@29 | 105 | else: |
paul@33 | 106 | request = self.request |
paul@29 | 107 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@29 | 108 | request.write("Only PGP/GPG-signed payloads are supported.") |
paul@29 | 109 | |
paul@29 | 110 | def handle_encrypted_message(self, message): |
paul@29 | 111 | |
paul@29 | 112 | "Handle the given encrypted 'message'." |
paul@29 | 113 | |
paul@29 | 114 | request = self.request |
paul@29 | 115 | |
paul@29 | 116 | homedir = self.get_homedir() |
paul@29 | 117 | if not homedir: |
paul@84 | 118 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@84 | 119 | request.write("This site is not configured for this request.") |
paul@29 | 120 | return |
paul@29 | 121 | |
paul@29 | 122 | gpg = GPG(homedir) |
paul@29 | 123 | |
paul@33 | 124 | try: |
paul@33 | 125 | text = gpg.decryptMessage(message) |
paul@29 | 126 | |
paul@33 | 127 | # Reject messages without a declaration. |
paul@29 | 128 | |
paul@33 | 129 | except MoinMessageMissingPart: |
paul@33 | 130 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@33 | 131 | request.write("There must be a declaration and a content part for encrypted uploads.") |
paul@33 | 132 | return |
paul@33 | 133 | |
paul@33 | 134 | # Reject messages without appropriate content. |
paul@29 | 135 | |
paul@33 | 136 | except MoinMessageBadContent: |
paul@33 | 137 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@33 | 138 | request.write("Encrypted data must be provided as application/octet-stream.") |
paul@33 | 139 | return |
paul@29 | 140 | |
paul@33 | 141 | # Reject any unencryptable message. |
paul@29 | 142 | |
paul@29 | 143 | except MoinMessageError: |
paul@29 | 144 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@29 | 145 | request.write("The message could not be decrypted.") |
paul@33 | 146 | return |
paul@33 | 147 | |
paul@33 | 148 | # Log non-fatal errors. |
paul@33 | 149 | |
paul@33 | 150 | if gpg.errors: |
paul@33 | 151 | getLogger(__name__).warning(gpg.errors) |
paul@33 | 152 | |
paul@33 | 153 | # Handle the embedded message which may itself be a signed message. |
paul@33 | 154 | |
paul@33 | 155 | self.handle_message_text(text) |
paul@29 | 156 | |
paul@29 | 157 | def handle_signed_message(self, message): |
paul@29 | 158 | |
paul@29 | 159 | "Handle the given signed 'message'." |
paul@29 | 160 | |
paul@29 | 161 | request = self.request |
paul@29 | 162 | |
paul@36 | 163 | # Accept any message whose sender was authenticated by the PGP method. |
paul@33 | 164 | |
paul@36 | 165 | if request.user and request.user.valid and request.user.auth_method == "pgp": |
paul@33 | 166 | |
paul@36 | 167 | # Handle the embedded message. |
paul@29 | 168 | |
paul@36 | 169 | content, signature = getContentAndSignature(message) |
paul@98 | 170 | self.handle_message_content(getOriginalContent(content)) |
paul@29 | 171 | |
paul@33 | 172 | # Reject any unverified message. |
paul@29 | 173 | |
paul@36 | 174 | else: |
paul@29 | 175 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@39 | 176 | request.write("The message could not be verified. " |
paul@39 | 177 | "Maybe this site is not performing authentication using PGP signatures.") |
paul@29 | 178 | |
paul@29 | 179 | def handle_message_content(self, content): |
paul@29 | 180 | |
paul@29 | 181 | "Handle the given message 'content'." |
paul@29 | 182 | |
paul@30 | 183 | request = self.request |
paul@30 | 184 | |
paul@30 | 185 | # Interpret the content as one or more updates. |
paul@30 | 186 | |
paul@30 | 187 | message = Message() |
paul@30 | 188 | message.handle_message(content) |
paul@30 | 189 | |
paul@30 | 190 | # Test any date against the page or message store. |
paul@30 | 191 | |
paul@30 | 192 | if message.date: |
paul@30 | 193 | store_date = time.gmtime(self.store.mtime()) |
paul@30 | 194 | page_date = time.gmtime(wikiutil.version2timestamp(self.page.mtime_usecs())) |
paul@30 | 195 | last_date = max(store_date, page_date) |
paul@30 | 196 | |
paul@30 | 197 | # Reject messages older than the page date. |
paul@30 | 198 | |
paul@103 | 199 | if message.date.to_utc().as_tuple() < last_date: |
paul@30 | 200 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@30 | 201 | request.write("The message is too old: %s versus %s." % (message.date, last_date)) |
paul@30 | 202 | return |
paul@30 | 203 | |
paul@30 | 204 | # Reject messages without dates if so configured. |
paul@30 | 205 | |
paul@30 | 206 | elif getattr(request.cfg, "moinmessage_reject_messages_without_dates", True): |
paul@30 | 207 | writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") |
paul@30 | 208 | request.write("The message has no date information.") |
paul@30 | 209 | return |
paul@30 | 210 | |
paul@30 | 211 | # Handle the message as an object. |
paul@30 | 212 | |
paul@30 | 213 | self.handle_message_object(message) |
paul@29 | 214 | |
paul@29 | 215 | def get_homedir(self): |
paul@29 | 216 | |
paul@29 | 217 | "Locate the GPG home directory." |
paul@29 | 218 | |
paul@34 | 219 | request = self.request |
paul@34 | 220 | homedir = get_homedir(self.request) |
paul@34 | 221 | |
paul@29 | 222 | if not homedir: |
paul@29 | 223 | writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") |
paul@29 | 224 | request.write("Encoded data cannot currently be understood. Please notify the site administrator.") |
paul@34 | 225 | |
paul@29 | 226 | return homedir |
paul@29 | 227 | |
paul@85 | 228 | def can_perform_action(self, action): |
paul@85 | 229 | |
paul@85 | 230 | """ |
paul@85 | 231 | Determine whether the user in the request has the necessary privileges |
paul@85 | 232 | to change the current page using a message requesting the given |
paul@85 | 233 | 'action'. |
paul@85 | 234 | """ |
paul@85 | 235 | |
paul@85 | 236 | for identifier in get_update_actions_for_user(self.request): |
paul@85 | 237 | |
paul@85 | 238 | # Expect "action:pagename", rejecting ill-formed identifiers. |
paul@85 | 239 | |
paul@85 | 240 | details = identifier.split(":", 1) |
paul@85 | 241 | if len(details) != 2: |
paul@85 | 242 | continue |
paul@85 | 243 | |
paul@85 | 244 | # If the action and page name match, return success. |
paul@85 | 245 | |
paul@85 | 246 | permitted, pagename = details |
paul@85 | 247 | if permitted.lower() == action.lower() and pagename == self.page.page_name: |
paul@85 | 248 | return True |
paul@85 | 249 | |
paul@85 | 250 | return False |
paul@85 | 251 | |
paul@85 | 252 | # More specific errors. |
paul@85 | 253 | |
paul@83 | 254 | class MoinMessageRecipientError(MoinMessageError): |
paul@83 | 255 | pass |
paul@83 | 256 | |
paul@83 | 257 | class MoinMessageNoRecipients(MoinMessageRecipientError): |
paul@83 | 258 | pass |
paul@83 | 259 | |
paul@83 | 260 | class MoinMessageUnknownRecipient(MoinMessageRecipientError): |
paul@83 | 261 | pass |
paul@83 | 262 | |
paul@83 | 263 | class MoinMessageBadRecipient(MoinMessageRecipientError): |
paul@83 | 264 | pass |
paul@83 | 265 | |
paul@85 | 266 | # Utility functions. |
paul@85 | 267 | |
paul@34 | 268 | def get_homedir(request): |
paul@34 | 269 | |
paul@34 | 270 | "Locate the GPG home directory." |
paul@34 | 271 | |
paul@34 | 272 | return getattr(request.cfg, "moinmessage_gpg_homedir") |
paul@34 | 273 | |
paul@60 | 274 | def get_signing_users(request): |
paul@60 | 275 | |
paul@60 | 276 | "Return a dictionary mapping usernames to signing keys." |
paul@60 | 277 | |
paul@60 | 278 | return getWikiDict( |
paul@60 | 279 | getattr(request.cfg, "moinmessage_gpg_signing_users_page", "MoinMessageSigningUserDict"), |
paul@60 | 280 | request) |
paul@60 | 281 | |
paul@68 | 282 | def get_relays(request): |
paul@68 | 283 | |
paul@68 | 284 | "Return a dictionary mapping relays to URLs." |
paul@68 | 285 | |
paul@68 | 286 | return getWikiDict( |
paul@68 | 287 | getattr(request.cfg, "moinmessage_gpg_relays_page", "MoinMessageRelayDict"), |
paul@68 | 288 | request) |
paul@68 | 289 | |
paul@84 | 290 | def get_recipients(request, main=False, sending=True, fetching=True): |
paul@60 | 291 | |
paul@60 | 292 | """ |
paul@60 | 293 | Return the recipients dictionary by first obtaining the page in which it |
paul@60 | 294 | is stored. This page may either be a subpage of the user's home page, if |
paul@60 | 295 | stored on this wiki, or it may be relative to the site root. |
paul@60 | 296 | |
paul@83 | 297 | When 'main' is specified and set to a true value, only a dictionary under |
paul@83 | 298 | the site root is consulted. |
paul@83 | 299 | |
paul@84 | 300 | When 'sending' or 'fetching' is specified and set to a false value, any |
paul@84 | 301 | recipients of the indicated type will be excluded from the result of this |
paul@84 | 302 | function. |
paul@84 | 303 | |
paul@60 | 304 | The name of the subpage is defined by the configuration setting |
paul@60 | 305 | 'moinmessage_gpg_recipients_page', which if absent is set to |
paul@60 | 306 | "MoinMessageRecipientsDict". |
paul@60 | 307 | """ |
paul@60 | 308 | |
paul@60 | 309 | subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") |
paul@83 | 310 | |
paul@83 | 311 | if not main: |
paul@83 | 312 | homedetails = wikiutil.getInterwikiHomePage(request) |
paul@60 | 313 | |
paul@83 | 314 | if homedetails: |
paul@83 | 315 | homewiki, homepage = homedetails |
paul@83 | 316 | if homewiki == "Self": |
paul@83 | 317 | recipients = getWikiDict("%s/%s" % (homepage, subpage), request) |
paul@83 | 318 | if recipients: |
paul@84 | 319 | return filter_recipients(recipients, sending, fetching) |
paul@60 | 320 | |
paul@84 | 321 | return filter_recipients(getWikiDict(subpage, request), sending, fetching) |
paul@60 | 322 | |
paul@80 | 323 | def get_username_for_fingerprint(request, fingerprint): |
paul@80 | 324 | |
paul@80 | 325 | """ |
paul@80 | 326 | Using the 'request', return the username corresponding to the given key |
paul@80 | 327 | 'fingerprint' or None if no correspondence is present in the mapping page. |
paul@80 | 328 | """ |
paul@80 | 329 | |
paul@85 | 330 | # Since this function must be able to work before any user has been |
paul@85 | 331 | # identified, the wikidict operation uses superuser privileges. |
paul@85 | 332 | |
paul@80 | 333 | gpg_users = getWikiDict( |
paul@80 | 334 | getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), |
paul@80 | 335 | request, |
paul@85 | 336 | superuser=True |
paul@80 | 337 | ) |
paul@80 | 338 | |
paul@80 | 339 | if gpg_users and gpg_users.has_key(fingerprint): |
paul@80 | 340 | return gpg_users[fingerprint] |
paul@80 | 341 | else: |
paul@80 | 342 | return None |
paul@80 | 343 | |
paul@85 | 344 | def get_update_actions_for_user(request): |
paul@85 | 345 | |
paul@85 | 346 | """ |
paul@85 | 347 | For the user associated with the 'request', return the permitted actions for |
paul@85 | 348 | the user in the form of |
paul@85 | 349 | """ |
paul@85 | 350 | |
paul@85 | 351 | if not request.user or not request.user.valid: |
paul@85 | 352 | return [] |
paul@85 | 353 | |
paul@85 | 354 | actions = getWikiDict( |
paul@85 | 355 | getattr(request.cfg, "moinmessage_user_actions_page", "MoinMessageUserActionsDict"), |
paul@85 | 356 | request |
paul@85 | 357 | ) |
paul@85 | 358 | |
paul@85 | 359 | username = request.user.name |
paul@85 | 360 | |
paul@85 | 361 | if actions and actions.has_key(username): |
paul@85 | 362 | return getIdentifiers(actions[username]) |
paul@85 | 363 | else: |
paul@85 | 364 | return [] |
paul@85 | 365 | |
paul@84 | 366 | def get_recipient_details(request, recipient, main=False, fetching=False): |
paul@83 | 367 | |
paul@83 | 368 | """ |
paul@83 | 369 | Using the 'request', return a dictionary of details for the specified |
paul@83 | 370 | 'recipient'. If no details exist, raise a MoinMessageRecipientError |
paul@83 | 371 | exception. |
paul@83 | 372 | |
paul@83 | 373 | When 'main' is specified and set to a true value, only the recipients |
paul@83 | 374 | dictionary under the site root is consulted. |
paul@84 | 375 | |
paul@84 | 376 | When 'fetching' is specified and set to a true value, the recipient need |
paul@84 | 377 | not have a "type" or "location" defined, but it must have a "fingerprint" |
paul@84 | 378 | defined. |
paul@83 | 379 | """ |
paul@83 | 380 | |
paul@83 | 381 | _ = request.getText |
paul@83 | 382 | |
paul@83 | 383 | recipients = get_recipients(request, main) |
paul@83 | 384 | if not recipients: |
paul@83 | 385 | raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") |
paul@83 | 386 | |
paul@83 | 387 | recipient_details = recipients.get(recipient) |
paul@83 | 388 | if not recipient_details: |
paul@83 | 389 | raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") |
paul@83 | 390 | |
paul@83 | 391 | parameters = parseDictEntry(recipient_details, ("type", "location", "fingerprint",)) |
paul@83 | 392 | |
paul@84 | 393 | type = parameters.get("type") |
paul@84 | 394 | location = parameters.get("location") |
paul@84 | 395 | fingerprint = parameters.get("fingerprint") |
paul@84 | 396 | |
paul@84 | 397 | if type in (None, "none") and not fetching: |
paul@83 | 398 | raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") |
paul@83 | 399 | |
paul@84 | 400 | if type not in (None, "none") and not location: |
paul@83 | 401 | raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") |
paul@83 | 402 | |
paul@84 | 403 | if type in ("url", "relay", None, "none") and not fingerprint: |
paul@83 | 404 | raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") |
paul@83 | 405 | |
paul@83 | 406 | return parameters |
paul@83 | 407 | |
paul@84 | 408 | def filter_recipients(recipients, sending, fetching): |
paul@84 | 409 | |
paul@84 | 410 | """ |
paul@84 | 411 | Return a copy of the given 'recipients' dictionary retaining all entries |
paul@84 | 412 | that apply to the given 'sending' and 'fetching' criteria. |
paul@84 | 413 | """ |
paul@84 | 414 | |
paul@84 | 415 | result = {} |
paul@84 | 416 | for recipient, parameters in recipients.items(): |
paul@84 | 417 | if not fetching and parameters.get("type") in (None, "none"): |
paul@84 | 418 | continue |
paul@84 | 419 | if not sending and not parameters.get("fingerprint"): |
paul@84 | 420 | continue |
paul@84 | 421 | result[recipient] = parameters |
paul@84 | 422 | return result |
paul@84 | 423 | |
paul@29 | 424 | # vim: tabstop=4 expandtab shiftwidth=4 |