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