1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - MoinMessageSupport library 4 5 @copyright: 2012, 2013, 2014 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 from MoinMoin import config, user 10 from MoinMoin.Page import Page 11 from MoinMoin.action import AttachFile 12 from MoinMoin.formatter import text_html 13 from MoinMoin.log import getLogger 14 from MoinMoin.user import User 15 from MoinMoin.wikiutil import parseQueryString, taintfilename, \ 16 version2timestamp, getInterwikiHomePage 17 18 from MoinMessage import GPG, Message, MoinMessageError, \ 19 MoinMessageMissingPart, MoinMessageBadContent, \ 20 is_signed, is_encrypted, getContentAndSignature 21 from MoinSupport import getHeader, getMetadata, getWikiDict, writeHeaders, \ 22 parseDictEntry, getStaticContentDirectory 23 from ItemSupport import ItemStore 24 from TokenSupport import getIdentifiers 25 26 from email.parser import Parser 27 from os.path import abspath, exists, join 28 import time 29 import urllib 30 31 RECIPIENT_PARAMETERS = ("type", "location", "fingerprint") 32 33 class MoinMessageAction: 34 35 "Common message handling support for actions." 36 37 def __init__(self, pagename, request): 38 39 """ 40 On the page with the given 'pagename', use the given 'request' when 41 reading posted messages, modifying the Wiki. 42 """ 43 44 self.pagename = pagename 45 self.request = request 46 self.page = Page(request, pagename) 47 self.message = None 48 self.new_user = None 49 50 def init_store(self): 51 self.store = ItemStore(self.page, "messages", "message-locks") 52 53 def do_as_user(self, new_user, fn, args): 54 55 "As 'new_user', perform 'fn' using the given 'args'." 56 57 request = self.request 58 59 # Switch to the relaying user if necessary. 60 61 if new_user: 62 user = request.user 63 request.user = get_user(request, new_user) or user 64 65 # Handle the content. 66 67 try: 68 fn(*args) 69 finally: 70 if new_user: 71 request.user = user 72 73 def do_action(self): 74 request = self.request 75 content_length = getHeader(request, "Content-Length", "HTTP") 76 if content_length: 77 content_length = int(content_length) 78 79 self.handle_message_text(request.read(content_length)) 80 81 def handle_message_text(self, message_text): 82 83 "Handle the given 'message_text'." 84 85 request = self.request 86 message = Parser().parsestr(message_text) 87 88 # Detect any indicated recipient and change the target page, if 89 # appropriate. 90 91 new_user = None 92 93 if message.has_key("To"): 94 try: 95 parameters = get_recipient_details(request, message["To"], main=True) 96 except MoinMessageRecipientError, exc: 97 98 # Reject missing recipients if being strict and not relying only 99 # on signatures and user actions. 100 101 if getattr(request, "moinmessage_reject_missing_global_recipients", False): 102 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 103 request.write("The recipient indicated in the message is not known to this site. " 104 "Details: %s" % exc.message) 105 return 106 else: 107 # Recipients with pages can have their messages redirected to 108 # those pages. 109 110 if parameters["type"] == "page": 111 self.page = Page(request, parameters["location"]) 112 113 # Recipients with URLs can have their messages forwarded. 114 115 # Recipients accessible via relays have their messages 116 # forwarded. 117 118 elif parameters["type"] in ("url", "relay"): 119 120 # Get the relaying user page and select it. 121 122 relaying_user = getattr(self.request.cfg, "moinmessage_gpg_relaying_user") 123 relaying_page = relaying_user and get_local_homepage(request, relaying_user) 124 125 if not relaying_page: 126 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 127 request.write("This site is not able to forward the message to the recipient.") 128 return 129 130 self.page = Page(request, relaying_page) 131 new_user = relaying_user 132 133 # The stored messages should then be processed 134 # asynchronously. 135 136 # NOTE: An action should be able to process stored messages, 137 # NOTE: by invoking code that is also used asynchronously. 138 139 # Remember the original message for later processing. 140 141 self.message = message 142 self.new_user = new_user 143 144 # Handle the parsed message. 145 146 self.init_store() 147 self.handle_message(message) 148 149 def handle_message(self, message): 150 151 "Handle the given 'message'." 152 153 # Detect PGP/GPG-encoded payloads. 154 # See: http://tools.ietf.org/html/rfc3156 155 156 # Signed payloads are checked and then passed on for further processing 157 # elsewhere. Verification is the last step in this base implementation, 158 # even if an encrypted-then-signed payload is involved. 159 160 if is_signed(message): 161 self.handle_signed_message(message) 162 163 # Encrypted payloads are decrypted and then sent back into this method 164 # for signature checking as described above. Thus, signed-then-encrypted 165 # payloads are first decrypted and then verified. 166 167 elif is_encrypted(message): 168 self.handle_encrypted_message(message) 169 170 # Reject unsigned and unencrypted payloads. 171 172 else: 173 request = self.request 174 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 175 request.write("Only PGP/GPG-signed payloads are supported.") 176 177 def handle_encrypted_message(self, message): 178 179 "Handle the given encrypted 'message'." 180 181 request = self.request 182 183 homedir = self.get_homedir() 184 if not homedir: 185 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 186 request.write("This site is not configured for this request.") 187 return 188 189 gpg = GPG(homedir) 190 191 try: 192 text = gpg.decryptMessage(message) 193 194 # Reject messages without a declaration. 195 196 except MoinMessageMissingPart: 197 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 198 request.write("There must be a declaration and a content part for encrypted uploads.") 199 return 200 201 # Reject messages without appropriate content. 202 203 except MoinMessageBadContent: 204 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 205 request.write("Encrypted data must be provided as application/octet-stream.") 206 return 207 208 # Reject any undecryptable message. 209 210 except MoinMessageError: 211 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 212 request.write("The message could not be decrypted.") 213 return 214 215 # Log non-fatal errors. 216 217 if gpg.errors: 218 getLogger(__name__).warning(gpg.errors) 219 220 # Handle the embedded message which may itself be a signed message. 221 222 self.handle_message_text(text) 223 224 def handle_signed_message(self, message): 225 226 "Handle the given signed 'message'." 227 228 request = self.request 229 230 # Accept any message whose sender was authenticated by the PGP method. 231 232 if request.user and request.user.valid and request.user.auth_method == "pgp": 233 234 # Handle the embedded message. 235 236 content, signature = getContentAndSignature(message) 237 self.handle_message_content(content) 238 239 # Reject any unverified message. 240 241 else: 242 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 243 request.write("The message could not be verified. " 244 "Maybe this site is not performing authentication using PGP signatures.") 245 246 def handle_message_content(self, content): 247 248 "Handle the given message 'content'." 249 250 request = self.request 251 252 # Interpret the content as one or more updates. 253 254 message = Message() 255 message.handle_message(content) 256 257 # Test any date against the page or message store (if not empty). 258 259 if message.date: 260 store_date = time.gmtime(self.store.mtime()) 261 page_date = time.gmtime(version2timestamp(self.page.mtime_usecs())) 262 263 if len(self.store) > 0: 264 last_date = max(store_date, page_date) 265 else: 266 last_date = page_date 267 268 # Reject messages older than the page date. 269 270 if message.date.to_utc().as_tuple() < last_date: 271 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 272 request.write("The message is too old: %s versus %s." % (message.date, last_date)) 273 return 274 275 # Reject messages without dates if so configured. 276 277 elif getattr(request.cfg, "moinmessage_reject_messages_without_dates", True): 278 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 279 request.write("The message has no date information.") 280 return 281 282 # Handle the message as an object. 283 284 self.handle_message_object(message) 285 286 def get_homedir(self): 287 288 "Locate the GPG home directory." 289 290 request = self.request 291 homedir = get_homedir(self.request) 292 293 if not homedir: 294 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 295 request.write("Encoded data cannot currently be understood. Please notify the site administrator.") 296 297 return homedir 298 299 def can_perform_action(self, action): 300 301 """ 302 Determine whether the user in the request has the necessary privileges 303 to change the current page using a message requesting the given 304 'action'. 305 """ 306 307 for identifier in get_update_actions_for_user(self.request): 308 309 # Expect "action:pagename", rejecting ill-formed identifiers. 310 311 details = identifier.split(":", 1) 312 if len(details) != 2: 313 continue 314 315 # If the action and page name match, return success. 316 317 permitted, pagename = details 318 if permitted.lower() == action.lower() and pagename == self.page.page_name: 319 return True 320 321 return False 322 323 # More specific errors. 324 325 class MoinMessageRecipientError(MoinMessageError): 326 pass 327 328 class MoinMessageNoRecipients(MoinMessageRecipientError): 329 pass 330 331 class MoinMessageUnknownRecipient(MoinMessageRecipientError): 332 pass 333 334 class MoinMessageBadRecipient(MoinMessageRecipientError): 335 pass 336 337 # Utility functions. 338 339 def get_user(request, username): 340 341 "Return the user having the given 'username'." 342 343 uid = user.getUserId(request, username) 344 345 # If the user does not exist, just return None. 346 347 if not uid: 348 return None 349 350 # Otherwise, return the requested user. 351 352 return user.User(request, uid) 353 354 def get_homedir(request): 355 356 "Locate the GPG home directory." 357 358 return getattr(request.cfg, "moinmessage_gpg_homedir") 359 360 def get_signing_users(request): 361 362 "Return a dictionary mapping usernames to signing keys." 363 364 return getWikiDict( 365 getattr(request.cfg, "moinmessage_gpg_signing_users_page", "MoinMessageSigningUserDict"), 366 request) 367 368 def get_relays(request): 369 370 "Return a dictionary mapping relays to URLs." 371 372 return getWikiDict( 373 getattr(request.cfg, "moinmessage_gpg_relays_page", "MoinMessageRelayDict"), 374 request) 375 376 def get_recipients(request, main=False, sending=True, fetching=True): 377 378 """ 379 Return the recipients dictionary by first obtaining the page in which it 380 is stored. This page may either be a subpage of the user's home page, if 381 stored on this wiki, or it may be relative to the site root. 382 383 When 'main' is specified and set to a true value, only a dictionary under 384 the site root is consulted. 385 386 When 'sending' or 'fetching' is specified and set to a false value, any 387 recipients of the indicated type will be excluded from the result of this 388 function. 389 390 The name of the subpage is defined by the configuration setting 391 'moinmessage_gpg_recipients_page', which if absent is set to 392 "MoinMessageRecipientsDict". 393 """ 394 395 subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") 396 397 if not main: 398 homepage = get_local_homepage(request, None) 399 recipients = getWikiDict("%s/%s" % (homepage, subpage), request) 400 if recipients: 401 return filter_recipients(recipients, sending, fetching) 402 403 return filter_recipients(getWikiDict(subpage, request), sending, fetching) 404 405 def get_local_homepage(request, username): 406 407 """ 408 Using the 'request', return the homepage for the given 'username' or None if 409 no local homepage exists for the user. If 'username' is None, the current 410 user's homepage is located. 411 """ 412 413 homedetails = getInterwikiHomePage(request, username) 414 415 if homedetails: 416 homewiki, homepage = homedetails 417 if homewiki == "Self": 418 return homepage 419 420 return None 421 422 def get_username_for_fingerprint(request, fingerprint): 423 424 """ 425 Using the 'request', return the username corresponding to the given key 426 'fingerprint' or None if no correspondence is present in the mapping page. 427 """ 428 429 # Since this function must be able to work before any user has been 430 # identified, the wikidict operation uses superuser privileges. 431 432 gpg_users = getWikiDict( 433 getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), 434 request, 435 superuser=True 436 ) 437 438 if gpg_users and gpg_users.has_key(fingerprint): 439 return gpg_users[fingerprint] 440 else: 441 return None 442 443 def get_update_actions_for_user(request): 444 445 """ 446 For the user associated with the 'request', return the permitted actions for 447 the user in the form of a list of "action:pagename" identifiers. 448 """ 449 450 if not request.user or not request.user.valid: 451 return [] 452 453 actions = getWikiDict( 454 getattr(request.cfg, "moinmessage_user_actions_page", "MoinMessageUserActionsDict"), 455 request 456 ) 457 458 username = request.user.name 459 460 if actions and actions.has_key(username): 461 return getIdentifiers(actions[username]) 462 else: 463 return [] 464 465 def get_recipient_details(request, recipient, main=False, fetching=False): 466 467 """ 468 Using the 'request', return a dictionary of details for the specified 469 'recipient'. If no details exist, raise a MoinMessageRecipientError 470 exception. 471 472 When 'main' is specified and set to a true value, only the recipients 473 dictionary under the site root is consulted. 474 475 When 'fetching' is specified and set to a true value, the recipient need 476 not have a "type" or "location" defined, but it must have a "fingerprint" 477 defined. 478 """ 479 480 _ = request.getText 481 482 recipients = get_recipients(request, main) 483 if not recipients: 484 raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") 485 486 recipient_details = recipients.get(recipient) 487 if not recipient_details: 488 raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") 489 490 parameters = parseDictEntry(recipient_details, RECIPIENT_PARAMETERS) 491 492 type = parameters.get("type") 493 location = parameters.get("location") 494 fingerprint = parameters.get("fingerprint") 495 496 if type in (None, "none") and not fetching: 497 raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") 498 499 if type not in (None, "none") and not location: 500 raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") 501 502 if type in ("url", "relay", None, "none") and not fingerprint: 503 raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") 504 505 return parameters 506 507 def filter_recipients(recipients, sending, fetching): 508 509 """ 510 Return a copy of the given 'recipients' dictionary retaining all entries 511 that apply to the given 'sending' and 'fetching' criteria. 512 """ 513 514 result = {} 515 for recipient, details in recipients.items(): 516 parameters = parseDictEntry(details, RECIPIENT_PARAMETERS) 517 518 if not fetching and parameters.get("type") in (None, "none"): 519 continue 520 if not sending and not parameters.get("fingerprint"): 521 continue 522 523 result[recipient] = details 524 525 return result 526 527 # Access to static Moin content. 528 529 htdocs = None 530 531 def get_htdocs(request): 532 533 "Use the 'request' to find the htdocs directory." 534 535 global htdocs 536 htdocs = getStaticContentDirectory(request) 537 538 if not htdocs: 539 htdocs_in_cfg = getattr(request.cfg, "moinmessage_static_files", None) 540 if htdocs_in_cfg and exists(htdocs_in_cfg): 541 htdocs = htdocs_in_cfg 542 return htdocs 543 544 return htdocs 545 546 # Special message formatters. 547 548 def unquoteWikinameURL(url, charset=config.charset): 549 550 """ 551 The inverse of wikiutil.quoteWikinameURL, returning the page name referenced 552 by the given 'url', with the page name assumed to be encoded using the given 553 'charset' (or default charset if omitted). 554 """ 555 556 return unicode(urllib.unquote(url), encoding=charset) 557 558 def getAttachmentFromURL(url, request): 559 560 """ 561 Return a (full path, attachment filename) tuple for the attachment 562 referenced by the given 'url', using the 'request' to interpret the 563 structure of 'url'. 564 565 If 'url' does not refer to an attachment on this wiki, None is returned. 566 """ 567 568 # Detect static resources. 569 570 htdocs_dir = get_htdocs(request) 571 572 if htdocs_dir: 573 prefix = request.cfg.url_prefix_static 574 575 # Normalise the 576 577 if not prefix.endswith("/"): 578 prefix += "/" 579 580 if url.startswith(prefix): 581 filename = url[len(prefix):] 582 583 # Obtain the resource path. 584 585 path = abspath(join(htdocs_dir, filename)) 586 587 if exists(path): 588 return path, taintfilename(filename) 589 590 # Detect attachments and other resources. 591 592 script = request.getScriptname() 593 594 # Normalise the URL. 595 596 if not script.endswith("/"): 597 script += "/" 598 599 # Reject URLs outside the wiki. 600 601 if not url.startswith(script): 602 return None 603 604 path = url[len(script):].lstrip("/") 605 try: 606 qpagename, qs = path.split("?", 1) 607 except ValueError: 608 qpagename = path 609 qs = None 610 611 pagename = unquoteWikinameURL(qpagename) 612 qs = qs and parseQueryString(qs) or {} 613 614 filename = qs.get("target") or qs.get("drawing") 615 filename = taintfilename(filename) 616 617 # Obtain the attachment path. 618 619 path = AttachFile.getFilename(request, pagename, filename) 620 return path, filename 621 622 class OutgoingHTMLFormatter(text_html.Formatter): 623 624 """ 625 Handle outgoing HTML content by identifying attachments and rewriting their 626 locations. References to bundled attachments are done using RFC 2111: 627 628 https://tools.ietf.org/html/rfc2111 629 630 Messages employing references between parts are meant to comply with RFC 631 2387: 632 633 https://tools.ietf.org/html/rfc2387 634 """ 635 636 def __init__(self, request, **kw): 637 text_html.Formatter.__init__(self, request, **kw) 638 self.attachments = [] 639 640 def add_attachment(self, location): 641 details = getAttachmentFromURL(location, self.request) 642 if details: 643 pos = len(self.attachments) 644 self.attachments.append(details) 645 return "cid:attachment%d" % pos 646 else: 647 return None 648 649 def image(self, src=None, **kw): 650 src = src or kw.get("src") 651 ref = src and self.add_attachment(src) 652 return text_html.Formatter.image(self, ref or src, **kw) 653 654 def transclusion(self, on, **kw): 655 if on: 656 data = kw.get("data") 657 kw["data"] = data and self.add_attachment(data) 658 return text_html.Formatter.transclusion(self, on, **kw) 659 660 # vim: tabstop=4 expandtab shiftwidth=4