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.new_user = None 48 49 def init_store(self): 50 self.store = ItemStore(self.page, "messages", "message-locks") 51 52 def do_as_user(self, new_user, fn, args): 53 54 "As 'new_user', perform 'fn' using the given 'args'." 55 56 request = self.request 57 58 # Switch to the relaying user if necessary. 59 60 if new_user: 61 user = request.user 62 request.user = get_user(request, new_user) or user 63 64 # Handle the content. 65 66 try: 67 fn(*args) 68 finally: 69 if new_user: 70 request.user = user 71 72 def do_action(self): 73 request = self.request 74 content_length = getHeader(request, "Content-Length", "HTTP") 75 if content_length: 76 content_length = int(content_length) 77 78 self.handle_message_text(request.read(content_length)) 79 80 def handle_message_text(self, message_text): 81 82 "Handle the given 'message_text'." 83 84 request = self.request 85 message = Parser().parsestr(message_text) 86 87 # Detect any indicated recipient and change the target page, if 88 # appropriate. 89 90 new_user = None 91 92 if message.has_key("To"): 93 try: 94 parameters = get_recipient_details(request, message["To"], main=True) 95 except MoinMessageRecipientError, exc: 96 97 # Reject missing recipients if being strict and not relying only 98 # on signatures and user actions. 99 100 if getattr(request, "moinmessage_reject_missing_global_recipients", False): 101 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 102 request.write("The recipient indicated in the message is not known to this site. " 103 "Details: %s" % exc.message) 104 return 105 else: 106 # Recipients with pages can have their messages redirected to 107 # those pages. 108 109 if parameters["type"] == "page": 110 self.page = Page(request, parameters["location"]) 111 112 # Recipients with URLs can have their messages forwarded. 113 114 # Recipients accessible via relays have their messages 115 # forwarded. 116 117 elif parameters["type"] in ("url", "relay"): 118 119 # Get the relaying user page and select it. 120 121 relaying_user = getattr(self.request.cfg, "moinmessage_gpg_relaying_user") 122 relaying_page = relaying_user and get_local_homepage(request, relaying_user) 123 124 if not relaying_page: 125 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 126 request.write("This site is not able to forward the message to the recipient.") 127 return 128 129 self.page = Page(request, relaying_page) 130 new_user = relaying_user 131 132 # The stored messages should then be processed 133 # asynchronously. 134 135 # NOTE: An action should be able to process stored messages, 136 # NOTE: by invoking code that is also used asynchronously. 137 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, 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_recipients(request, main=False, sending=True, fetching=True): 373 374 """ 375 Return the recipients dictionary by first obtaining the page in which it 376 is stored. This page may either be a subpage of the user's home page, if 377 stored on this wiki, or it may be relative to the site root. 378 379 When 'main' is specified and set to a true value, only a dictionary under 380 the site root is consulted. 381 382 When 'sending' or 'fetching' is specified and set to a false value, any 383 recipients of the indicated type will be excluded from the result of this 384 function. 385 386 The name of the subpage is defined by the configuration setting 387 'moinmessage_gpg_recipients_page', which if absent is set to 388 "MoinMessageRecipientsDict". 389 """ 390 391 subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") 392 393 if not main: 394 homepage = get_local_homepage(request, None) 395 recipients = getWikiDict("%s/%s" % (homepage, subpage), request) 396 if recipients: 397 return filter_recipients(recipients, sending, fetching) 398 399 return filter_recipients(getWikiDict(subpage, request), sending, fetching) 400 401 def get_local_homepage(request, username): 402 403 """ 404 Using the 'request', return the homepage for the given 'username' or None if 405 no local homepage exists for the user. If 'username' is None, the current 406 user's homepage is located. 407 """ 408 409 homedetails = getInterwikiHomePage(request, username) 410 411 if homedetails: 412 homewiki, homepage = homedetails 413 if homewiki == "Self": 414 return homepage 415 416 return None 417 418 def get_username_for_fingerprint(request, fingerprint): 419 420 """ 421 Using the 'request', return the username corresponding to the given key 422 'fingerprint' or None if no correspondence is present in the mapping page. 423 """ 424 425 # Since this function must be able to work before any user has been 426 # identified, the wikidict operation uses superuser privileges. 427 428 gpg_users = getWikiDict( 429 getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), 430 request, 431 superuser=True 432 ) 433 434 if gpg_users and gpg_users.has_key(fingerprint): 435 return gpg_users[fingerprint] 436 else: 437 return None 438 439 def get_update_actions_for_user(request): 440 441 """ 442 For the user associated with the 'request', return the permitted actions for 443 the user in the form of a list of "action:pagename" identifiers. 444 """ 445 446 if not request.user or not request.user.valid: 447 return [] 448 449 actions = getWikiDict( 450 getattr(request.cfg, "moinmessage_user_actions_page", "MoinMessageUserActionsDict"), 451 request 452 ) 453 454 username = request.user.name 455 456 if actions and actions.has_key(username): 457 return getIdentifiers(actions[username]) 458 else: 459 return [] 460 461 def get_recipient_details(request, recipient, main=False, fetching=False): 462 463 """ 464 Using the 'request', return a dictionary of details for the specified 465 'recipient'. If no details exist, raise a MoinMessageRecipientError 466 exception. 467 468 When 'main' is specified and set to a true value, only the recipients 469 dictionary under the site root is consulted. 470 471 When 'fetching' is specified and set to a true value, the recipient need 472 not have a "type" or "location" defined, but it must have a "fingerprint" 473 defined. 474 """ 475 476 _ = request.getText 477 478 recipients = get_recipients(request, main) 479 if not recipients: 480 raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") 481 482 recipient_details = recipients.get(recipient) 483 if not recipient_details: 484 raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") 485 486 parameters = parseDictEntry(recipient_details, RECIPIENT_PARAMETERS) 487 488 type = parameters.get("type") 489 location = parameters.get("location") 490 fingerprint = parameters.get("fingerprint") 491 492 if type in (None, "none") and not fetching: 493 raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") 494 495 if type not in (None, "none") and not location: 496 raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") 497 498 if type in ("url", "relay", None, "none") and not fingerprint: 499 raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") 500 501 return parameters 502 503 def filter_recipients(recipients, sending, fetching): 504 505 """ 506 Return a copy of the given 'recipients' dictionary retaining all entries 507 that apply to the given 'sending' and 'fetching' criteria. 508 """ 509 510 result = {} 511 for recipient, details in recipients.items(): 512 parameters = parseDictEntry(details, RECIPIENT_PARAMETERS) 513 514 if not fetching and parameters.get("type") in (None, "none"): 515 continue 516 if not sending and not parameters.get("fingerprint"): 517 continue 518 519 result[recipient] = details 520 521 return result 522 523 # Access to static Moin content. 524 525 htdocs = None 526 527 def get_htdocs(request): 528 529 "Use the 'request' to find the htdocs directory." 530 531 global htdocs 532 htdocs = getStaticContentDirectory(request) 533 534 if not htdocs: 535 htdocs_in_cfg = getattr(request.cfg, "moinmessage_static_files", None) 536 if htdocs_in_cfg and exists(htdocs_in_cfg): 537 htdocs = htdocs_in_cfg 538 return htdocs 539 540 return htdocs 541 542 # Special message formatters. 543 544 def unquoteWikinameURL(url, charset=config.charset): 545 546 """ 547 The inverse of wikiutil.quoteWikinameURL, returning the page name referenced 548 by the given 'url', with the page name assumed to be encoded using the given 549 'charset' (or default charset if omitted). 550 """ 551 552 return unicode(urllib.unquote(url), encoding=charset) 553 554 def getAttachmentFromURL(url, request): 555 556 """ 557 Return a (full path, attachment filename) tuple for the attachment 558 referenced by the given 'url', using the 'request' to interpret the 559 structure of 'url'. 560 561 If 'url' does not refer to an attachment on this wiki, None is returned. 562 """ 563 564 # Detect static resources. 565 566 htdocs_dir = get_htdocs(request) 567 568 if htdocs_dir: 569 prefix = request.cfg.url_prefix_static 570 571 # Normalise the 572 573 if not prefix.endswith("/"): 574 prefix += "/" 575 576 if url.startswith(prefix): 577 filename = url[len(prefix):] 578 579 # Obtain the resource path. 580 581 path = abspath(join(htdocs_dir, filename)) 582 583 if exists(path): 584 return path, taintfilename(filename) 585 586 # Detect attachments and other resources. 587 588 script = request.getScriptname() 589 590 # Normalise the URL. 591 592 if not script.endswith("/"): 593 script += "/" 594 595 # Reject URLs outside the wiki. 596 597 if not url.startswith(script): 598 return None 599 600 path = url[len(script):].lstrip("/") 601 try: 602 qpagename, qs = path.split("?", 1) 603 except ValueError: 604 qpagename = path 605 qs = None 606 607 pagename = unquoteWikinameURL(qpagename) 608 qs = qs and parseQueryString(qs) or {} 609 610 filename = qs.get("target") or qs.get("drawing") 611 filename = taintfilename(filename) 612 613 # Obtain the attachment path. 614 615 path = AttachFile.getFilename(request, pagename, filename) 616 return path, filename 617 618 class OutgoingHTMLFormatter(text_html.Formatter): 619 620 """ 621 Handle outgoing HTML content by identifying attachments and rewriting their 622 locations. References to bundled attachments are done using RFC 2111: 623 624 https://tools.ietf.org/html/rfc2111 625 626 Messages employing references between parts are meant to comply with RFC 627 2387: 628 629 https://tools.ietf.org/html/rfc2387 630 """ 631 632 def __init__(self, request, **kw): 633 text_html.Formatter.__init__(self, request, **kw) 634 self.attachments = [] 635 636 def add_attachment(self, location): 637 details = getAttachmentFromURL(location, self.request) 638 if details: 639 pos = len(self.attachments) 640 self.attachments.append(details) 641 return "cid:attachment%d" % pos 642 else: 643 return None 644 645 def image(self, src=None, **kw): 646 src = src or kw.get("src") 647 ref = src and self.add_attachment(src) 648 return text_html.Formatter.image(self, ref or src, **kw) 649 650 def transclusion(self, on, **kw): 651 if on: 652 data = kw.get("data") 653 kw["data"] = data and self.add_attachment(data) 654 return text_html.Formatter.transclusion(self, on, **kw) 655 656 # vim: tabstop=4 expandtab shiftwidth=4