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 = get_relaying_user(request) 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 # Stored messages should then be processed asynchronously. 131 132 self.page = Page(request, relaying_page) 133 new_user = relaying_user 134 135 # Remember the original message for later processing. 136 137 self.message = message 138 self.new_user = new_user 139 140 # Handle the parsed message. 141 142 self.init_store() 143 self.handle_message(message) 144 145 def handle_message(self, message): 146 147 "Handle the given 'message'." 148 149 # Detect PGP/GPG-encoded payloads. 150 # See: http://tools.ietf.org/html/rfc3156 151 152 # Signed payloads are checked and then passed on for further processing 153 # elsewhere. Verification is the last step in this base implementation, 154 # even if an encrypted-then-signed payload is involved. 155 156 if is_signed(message): 157 self.handle_signed_message(message) 158 159 # Encrypted payloads are decrypted and then sent back into this method 160 # for signature checking as described above. Thus, signed-then-encrypted 161 # payloads are first decrypted and then verified. 162 163 elif is_encrypted(message): 164 self.handle_encrypted_message(message) 165 166 # Reject unsigned and unencrypted payloads. 167 168 else: 169 request = self.request 170 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 171 request.write("Only PGP/GPG-signed payloads are supported.") 172 173 def handle_encrypted_message(self, message): 174 175 "Handle the given encrypted 'message'." 176 177 request = self.request 178 179 homedir = self.get_homedir() 180 if not homedir: 181 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 182 request.write("This site is not configured for this request.") 183 return 184 185 gpg = GPG(homedir) 186 187 try: 188 text = gpg.decryptMessage(message) 189 190 # Reject messages without a declaration. 191 192 except MoinMessageMissingPart: 193 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 194 request.write("There must be a declaration and a content part for encrypted uploads.") 195 return 196 197 # Reject messages without appropriate content. 198 199 except MoinMessageBadContent: 200 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 201 request.write("Encrypted data must be provided as application/octet-stream.") 202 return 203 204 # Reject any undecryptable message. 205 206 except MoinMessageError: 207 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 208 request.write("The message could not be decrypted.") 209 return 210 211 # Log non-fatal errors. 212 213 if gpg.errors: 214 getLogger(__name__).warning(gpg.errors) 215 216 # Handle the embedded message which may itself be a signed message. 217 218 self.handle_message_text(text) 219 220 def handle_signed_message(self, message): 221 222 "Handle the given signed 'message'." 223 224 request = self.request 225 226 # Accept any message whose sender was authenticated by the PGP method. 227 228 if request.user and request.user.valid and request.user.auth_method == "pgp": 229 230 # Handle the embedded message. 231 232 content, signature = getContentAndSignature(message) 233 self.handle_message_content(content) 234 235 # Reject any unverified message. 236 237 else: 238 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 239 request.write("The message could not be verified. " 240 "Maybe this site is not performing authentication using PGP signatures.") 241 242 def handle_message_content(self, content): 243 244 "Handle the given message 'content'." 245 246 request = self.request 247 248 # Interpret the content as one or more updates. 249 250 message = Message() 251 message.handle_message(content) 252 253 # Test any date against the page or message store (if not empty). 254 255 if message.date: 256 store_date = time.gmtime(self.store.mtime()) 257 page_date = time.gmtime(version2timestamp(self.page.mtime_usecs())) 258 259 if len(self.store) > 0: 260 last_date = max(store_date, page_date) 261 else: 262 last_date = page_date 263 264 # Reject messages older than the page date. 265 266 if message.date.to_utc().as_tuple() < last_date: 267 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 268 request.write("The message is too old: %s versus %s." % (message.date, time.strftime("%Y-%m-%d %H:%M:%S", last_date))) 269 return 270 271 # Reject messages without dates if so configured. 272 273 elif getattr(request.cfg, "moinmessage_reject_messages_without_dates", True): 274 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 275 request.write("The message has no date information.") 276 return 277 278 # Handle the message as an object. 279 280 self.handle_message_object(message) 281 282 def get_homedir(self): 283 284 "Locate the GPG home directory." 285 286 request = self.request 287 homedir = get_homedir(self.request) 288 289 if not homedir: 290 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 291 request.write("Encoded data cannot currently be understood. Please notify the site administrator.") 292 293 return homedir 294 295 def can_perform_action(self, action): 296 297 """ 298 Determine whether the user in the request has the necessary privileges 299 to change the current page using a message requesting the given 300 'action'. 301 """ 302 303 for identifier in get_update_actions_for_user(self.request): 304 305 # Expect "action:pagename", rejecting ill-formed identifiers. 306 307 details = identifier.split(":", 1) 308 if len(details) != 2: 309 continue 310 311 # If the action and page name match, return success. 312 313 permitted, pagename = details 314 if permitted.lower() == action.lower() and pagename == self.page.page_name: 315 return True 316 317 return False 318 319 # More specific errors. 320 321 class MoinMessageRecipientError(MoinMessageError): 322 pass 323 324 class MoinMessageNoRecipients(MoinMessageRecipientError): 325 pass 326 327 class MoinMessageUnknownRecipient(MoinMessageRecipientError): 328 pass 329 330 class MoinMessageBadRecipient(MoinMessageRecipientError): 331 pass 332 333 # Utility functions. 334 335 def get_user(request, username): 336 337 "Return the user having the given 'username'." 338 339 uid = user.getUserId(request, username) 340 341 # If the user does not exist, just return None. 342 343 if not uid: 344 return None 345 346 # Otherwise, return the requested user. 347 348 return user.User(request, uid) 349 350 def get_homedir(request): 351 352 "Locate the GPG home directory." 353 354 return getattr(request.cfg, "moinmessage_gpg_homedir") 355 356 def get_signing_users(request): 357 358 "Return a dictionary mapping usernames to signing keys." 359 360 return getWikiDict( 361 getattr(request.cfg, "moinmessage_gpg_signing_users_page", "MoinMessageSigningUserDict"), 362 request) 363 364 def get_relays(request): 365 366 "Return a dictionary mapping relays to URLs." 367 368 return getWikiDict( 369 getattr(request.cfg, "moinmessage_gpg_relays_page", "MoinMessageRelayDict"), 370 request) 371 372 def get_relaying_user(request): 373 374 "Return the relaying username or None if no relaying user is configured." 375 376 return getattr(request.cfg, "moinmessage_gpg_relaying_user") 377 378 def get_recipients(request, main=False, sending=True, fetching=True): 379 380 """ 381 Return the recipients dictionary by first obtaining the page in which it 382 is stored. This page may either be a subpage of the user's home page, if 383 stored on this wiki, or it may be relative to the site root. 384 385 When 'main' is specified and set to a true value, only a dictionary under 386 the site root is consulted. 387 388 When 'sending' or 'fetching' is specified and set to a false value, any 389 recipients of the indicated type will be excluded from the result of this 390 function. 391 392 The name of the subpage is defined by the configuration setting 393 'moinmessage_gpg_recipients_page', which if absent is set to 394 "MoinMessageRecipientsDict". 395 """ 396 397 subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") 398 399 if not main: 400 homepage = get_local_homepage(request, None) 401 recipients = getWikiDict("%s/%s" % (homepage, subpage), request) 402 if recipients: 403 return filter_recipients(recipients, sending, fetching) 404 405 return filter_recipients(getWikiDict(subpage, request), sending, fetching) 406 407 def get_local_homepage(request, username): 408 409 """ 410 Using the 'request', return the homepage for the given 'username' or None if 411 no local homepage exists for the user. If 'username' is None, the current 412 user's homepage is located. 413 """ 414 415 homedetails = getInterwikiHomePage(request, username) 416 417 if homedetails: 418 homewiki, homepage = homedetails 419 if homewiki == "Self": 420 return homepage 421 422 return None 423 424 def get_username_for_fingerprint(request, fingerprint): 425 426 """ 427 Using the 'request', return the username corresponding to the given key 428 'fingerprint' or None if no correspondence is present in the mapping page. 429 """ 430 431 # Since this function must be able to work before any user has been 432 # identified, the wikidict operation uses superuser privileges. 433 434 gpg_users = getWikiDict( 435 getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), 436 request, 437 superuser=True 438 ) 439 440 if gpg_users and gpg_users.has_key(fingerprint): 441 return gpg_users[fingerprint] 442 else: 443 return None 444 445 def get_update_actions_for_user(request): 446 447 """ 448 For the user associated with the 'request', return the permitted actions for 449 the user in the form of a list of "action:pagename" identifiers. 450 """ 451 452 if not request.user or not request.user.valid: 453 return [] 454 455 actions = getWikiDict( 456 getattr(request.cfg, "moinmessage_user_actions_page", "MoinMessageUserActionsDict"), 457 request 458 ) 459 460 username = request.user.name 461 462 if actions and actions.has_key(username): 463 return getIdentifiers(actions[username]) 464 else: 465 return [] 466 467 def get_recipient_details(request, recipient, main=False, fetching=False): 468 469 """ 470 Using the 'request', return a dictionary of details for the specified 471 'recipient'. If no details exist, raise a MoinMessageRecipientError 472 exception. 473 474 When 'main' is specified and set to a true value, only the recipients 475 dictionary under the site root is consulted. 476 477 When 'fetching' is specified and set to a true value, the recipient need 478 not have a "type" or "location" defined, but it must have a "fingerprint" 479 defined. 480 """ 481 482 _ = request.getText 483 484 recipients = get_recipients(request, main) 485 if not recipients: 486 raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") 487 488 recipient_details = recipients.get(recipient) 489 if not recipient_details: 490 raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") 491 492 parameters = parseDictEntry(recipient_details, RECIPIENT_PARAMETERS) 493 494 type = parameters.get("type") 495 location = parameters.get("location") 496 fingerprint = parameters.get("fingerprint") 497 498 if type in (None, "none") and not fetching: 499 raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") 500 501 if type not in (None, "none") and not location: 502 raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") 503 504 if type in ("url", "relay", None, "none") and not fingerprint: 505 raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") 506 507 return parameters 508 509 def filter_recipients(recipients, sending, fetching): 510 511 """ 512 Return a copy of the given 'recipients' dictionary retaining all entries 513 that apply to the given 'sending' and 'fetching' criteria. 514 """ 515 516 result = {} 517 for recipient, details in recipients.items(): 518 parameters = parseDictEntry(details, RECIPIENT_PARAMETERS) 519 520 if not fetching and parameters.get("type") in (None, "none"): 521 continue 522 if not sending and not parameters.get("fingerprint"): 523 continue 524 525 result[recipient] = details 526 527 return result 528 529 # Moin-specific messaging functions. 530 531 def get_signing_identity(request, type): 532 533 """ 534 Using the 'request' and indicated delivery 'type', return the appropriate 535 signing identity for a message, or None if no additional signature is to be 536 performed. 537 """ 538 539 signing_users = get_signing_users(request) 540 signer = signing_users and signing_users.get(request.user.name) 541 542 # Send relayed messages with an extra signature. 543 544 if type == "relay": 545 relaying_user = get_relaying_user(request) 546 547 # Signing with the current identity if no special relaying user is 548 # defined. 549 550 if relaying_user and request.user.name != relaying_user: 551 signer = signing_users and signing_users.get(relaying_user) 552 553 return signer 554 555 return None 556 557 # Access to static Moin content. 558 559 htdocs = None 560 561 def get_htdocs(request): 562 563 "Use the 'request' to find the htdocs directory." 564 565 global htdocs 566 htdocs = getStaticContentDirectory(request) 567 568 if not htdocs: 569 htdocs_in_cfg = getattr(request.cfg, "moinmessage_static_files", None) 570 if htdocs_in_cfg and exists(htdocs_in_cfg): 571 htdocs = htdocs_in_cfg 572 return htdocs 573 574 return htdocs 575 576 # Special message formatters. 577 578 def unquoteWikinameURL(url, charset=config.charset): 579 580 """ 581 The inverse of wikiutil.quoteWikinameURL, returning the page name referenced 582 by the given 'url', with the page name assumed to be encoded using the given 583 'charset' (or default charset if omitted). 584 """ 585 586 return unicode(urllib.unquote(url), encoding=charset) 587 588 def getAttachmentFromURL(url, request): 589 590 """ 591 Return a (full path, attachment filename) tuple for the attachment 592 referenced by the given 'url', using the 'request' to interpret the 593 structure of 'url'. 594 595 If 'url' does not refer to an attachment on this wiki, None is returned. 596 """ 597 598 # Detect static resources. 599 600 htdocs_dir = get_htdocs(request) 601 602 if htdocs_dir: 603 prefix = request.cfg.url_prefix_static 604 605 # Normalise the 606 607 if not prefix.endswith("/"): 608 prefix += "/" 609 610 if url.startswith(prefix): 611 filename = url[len(prefix):] 612 613 # Obtain the resource path. 614 615 path = abspath(join(htdocs_dir, filename)) 616 617 if exists(path): 618 return path, taintfilename(filename) 619 620 # Detect attachments and other resources. 621 622 script = request.getScriptname() 623 624 # Normalise the URL. 625 626 if not script.endswith("/"): 627 script += "/" 628 629 # Reject URLs outside the wiki. 630 631 if not url.startswith(script): 632 return None 633 634 path = url[len(script):].lstrip("/") 635 try: 636 qpagename, qs = path.split("?", 1) 637 except ValueError: 638 qpagename = path 639 qs = None 640 641 pagename = unquoteWikinameURL(qpagename) 642 qs = qs and parseQueryString(qs) or {} 643 644 filename = qs.get("target") or qs.get("drawing") 645 filename = taintfilename(filename) 646 647 # Obtain the attachment path. 648 649 path = AttachFile.getFilename(request, pagename, filename) 650 return path, filename 651 652 class OutgoingHTMLFormatter(text_html.Formatter): 653 654 """ 655 Handle outgoing HTML content by identifying attachments and rewriting their 656 locations. References to bundled attachments are done using RFC 2111: 657 658 https://tools.ietf.org/html/rfc2111 659 660 Messages employing references between parts are meant to comply with RFC 661 2387: 662 663 https://tools.ietf.org/html/rfc2387 664 """ 665 666 def __init__(self, request, **kw): 667 text_html.Formatter.__init__(self, request, **kw) 668 self.attachments = [] 669 670 def add_attachment(self, location): 671 details = getAttachmentFromURL(location, self.request) 672 if details: 673 pos = len(self.attachments) 674 self.attachments.append(details) 675 return "cid:attachment%d" % pos 676 else: 677 return None 678 679 def image(self, src=None, **kw): 680 src = src or kw.get("src") 681 ref = src and self.add_attachment(src) 682 return text_html.Formatter.image(self, ref or src, **kw) 683 684 def transclusion(self, on, **kw): 685 if on: 686 data = kw.get("data") 687 kw["data"] = data and self.add_attachment(data) 688 return text_html.Formatter.transclusion(self, on, **kw) 689 690 # vim: tabstop=4 expandtab shiftwidth=4