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