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