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. 244 245 if message.date: 246 store_date = time.gmtime(self.store.mtime()) 247 page_date = time.gmtime(version2timestamp(self.page.mtime_usecs())) 248 last_date = max(store_date, page_date) 249 250 # Reject messages older than the page date. 251 252 if message.date.to_utc().as_tuple() < last_date: 253 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 254 request.write("The message is too old: %s versus %s." % (message.date, last_date)) 255 return 256 257 # Reject messages without dates if so configured. 258 259 elif getattr(request.cfg, "moinmessage_reject_messages_without_dates", True): 260 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 261 request.write("The message has no date information.") 262 return 263 264 # Handle the message as an object. 265 266 self.handle_message_object(message) 267 268 def get_homedir(self): 269 270 "Locate the GPG home directory." 271 272 request = self.request 273 homedir = get_homedir(self.request) 274 275 if not homedir: 276 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 277 request.write("Encoded data cannot currently be understood. Please notify the site administrator.") 278 279 return homedir 280 281 def can_perform_action(self, action): 282 283 """ 284 Determine whether the user in the request has the necessary privileges 285 to change the current page using a message requesting the given 286 'action'. 287 """ 288 289 for identifier in get_update_actions_for_user(self.request): 290 291 # Expect "action:pagename", rejecting ill-formed identifiers. 292 293 details = identifier.split(":", 1) 294 if len(details) != 2: 295 continue 296 297 # If the action and page name match, return success. 298 299 permitted, pagename = details 300 if permitted.lower() == action.lower() and pagename == self.page.page_name: 301 return True 302 303 return False 304 305 # More specific errors. 306 307 class MoinMessageRecipientError(MoinMessageError): 308 pass 309 310 class MoinMessageNoRecipients(MoinMessageRecipientError): 311 pass 312 313 class MoinMessageUnknownRecipient(MoinMessageRecipientError): 314 pass 315 316 class MoinMessageBadRecipient(MoinMessageRecipientError): 317 pass 318 319 # Utility functions. 320 321 def get_user(request, username): 322 323 "Return the user having the given 'username'." 324 325 uid = user.getUserId(request, username) 326 327 # If the user does not exist, just return None. 328 329 if not uid: 330 return None 331 332 # Otherwise, return the requested user. 333 334 return user.User(request, uid) 335 336 def get_homedir(request): 337 338 "Locate the GPG home directory." 339 340 return getattr(request.cfg, "moinmessage_gpg_homedir") 341 342 def get_signing_users(request): 343 344 "Return a dictionary mapping usernames to signing keys." 345 346 return getWikiDict( 347 getattr(request.cfg, "moinmessage_gpg_signing_users_page", "MoinMessageSigningUserDict"), 348 request) 349 350 def get_relays(request): 351 352 "Return a dictionary mapping relays to URLs." 353 354 return getWikiDict( 355 getattr(request.cfg, "moinmessage_gpg_relays_page", "MoinMessageRelayDict"), 356 request) 357 358 def get_recipients(request, main=False, sending=True, fetching=True): 359 360 """ 361 Return the recipients dictionary by first obtaining the page in which it 362 is stored. This page may either be a subpage of the user's home page, if 363 stored on this wiki, or it may be relative to the site root. 364 365 When 'main' is specified and set to a true value, only a dictionary under 366 the site root is consulted. 367 368 When 'sending' or 'fetching' is specified and set to a false value, any 369 recipients of the indicated type will be excluded from the result of this 370 function. 371 372 The name of the subpage is defined by the configuration setting 373 'moinmessage_gpg_recipients_page', which if absent is set to 374 "MoinMessageRecipientsDict". 375 """ 376 377 subpage = getattr(request.cfg, "moinmessage_gpg_recipients_page", "MoinMessageRecipientsDict") 378 379 if not main: 380 homepage = get_local_homepage(request, None) 381 recipients = getWikiDict("%s/%s" % (homepage, subpage), request) 382 if recipients: 383 return filter_recipients(recipients, sending, fetching) 384 385 return filter_recipients(getWikiDict(subpage, request), sending, fetching) 386 387 def get_local_homepage(request, username): 388 389 """ 390 Using the 'request', return the homepage for the given 'username' or None if 391 no local homepage exists for the user. If 'username' is None, the current 392 user's homepage is located. 393 """ 394 395 homedetails = getInterwikiHomePage(request, username) 396 397 if homedetails: 398 homewiki, homepage = homedetails 399 if homewiki == "Self": 400 return homepage 401 402 return None 403 404 def get_username_for_fingerprint(request, fingerprint): 405 406 """ 407 Using the 'request', return the username corresponding to the given key 408 'fingerprint' or None if no correspondence is present in the mapping page. 409 """ 410 411 # Since this function must be able to work before any user has been 412 # identified, the wikidict operation uses superuser privileges. 413 414 gpg_users = getWikiDict( 415 getattr(request.cfg, "moinmessage_gpg_users_page", "MoinMessageUserDict"), 416 request, 417 superuser=True 418 ) 419 420 if gpg_users and gpg_users.has_key(fingerprint): 421 return gpg_users[fingerprint] 422 else: 423 return None 424 425 def get_update_actions_for_user(request): 426 427 """ 428 For the user associated with the 'request', return the permitted actions for 429 the user in the form of a list of "action:pagename" identifiers. 430 """ 431 432 if not request.user or not request.user.valid: 433 return [] 434 435 actions = getWikiDict( 436 getattr(request.cfg, "moinmessage_user_actions_page", "MoinMessageUserActionsDict"), 437 request 438 ) 439 440 username = request.user.name 441 442 if actions and actions.has_key(username): 443 return getIdentifiers(actions[username]) 444 else: 445 return [] 446 447 def get_recipient_details(request, recipient, main=False, fetching=False): 448 449 """ 450 Using the 'request', return a dictionary of details for the specified 451 'recipient'. If no details exist, raise a MoinMessageRecipientError 452 exception. 453 454 When 'main' is specified and set to a true value, only the recipients 455 dictionary under the site root is consulted. 456 457 When 'fetching' is specified and set to a true value, the recipient need 458 not have a "type" or "location" defined, but it must have a "fingerprint" 459 defined. 460 """ 461 462 _ = request.getText 463 464 recipients = get_recipients(request, main) 465 if not recipients: 466 raise MoinMessageNoRecipients, _("No recipients page is defined for MoinMessage.") 467 468 recipient_details = recipients.get(recipient) 469 if not recipient_details: 470 raise MoinMessageUnknownRecipient, _("The specified recipient is not present in the list of known contacts.") 471 472 parameters = parseDictEntry(recipient_details, RECIPIENT_PARAMETERS) 473 474 type = parameters.get("type") 475 location = parameters.get("location") 476 fingerprint = parameters.get("fingerprint") 477 478 if type in (None, "none") and not fetching: 479 raise MoinMessageBadRecipient, _("The recipient details are missing a destination type.") 480 481 if type not in (None, "none") and not location: 482 raise MoinMessageBadRecipient, _("The recipient details are missing a location for sent messages.") 483 484 if type in ("url", "relay", None, "none") and not fingerprint: 485 raise MoinMessageBadRecipient, _("The recipient details are missing a fingerprint for sending messages.") 486 487 return parameters 488 489 def filter_recipients(recipients, sending, fetching): 490 491 """ 492 Return a copy of the given 'recipients' dictionary retaining all entries 493 that apply to the given 'sending' and 'fetching' criteria. 494 """ 495 496 result = {} 497 for recipient, details in recipients.items(): 498 parameters = parseDictEntry(details, RECIPIENT_PARAMETERS) 499 500 if not fetching and parameters.get("type") in (None, "none"): 501 continue 502 if not sending and not parameters.get("fingerprint"): 503 continue 504 505 result[recipient] = details 506 507 return result 508 509 # Access to static Moin content. 510 511 htdocs = None 512 513 def get_htdocs(request): 514 515 "Use the 'request' to find the htdocs directory." 516 517 global htdocs 518 htdocs = getStaticContentDirectory(request) 519 520 if not htdocs: 521 htdocs_in_cfg = getattr(request.cfg, "moinmessage_static_files", None) 522 if htdocs_in_cfg and exists(htdocs_in_cfg): 523 htdocs = htdocs_in_cfg 524 return htdocs 525 526 return htdocs 527 528 # Special message formatters. 529 530 def unquoteWikinameURL(url, charset=config.charset): 531 532 """ 533 The inverse of wikiutil.quoteWikinameURL, returning the page name referenced 534 by the given 'url', with the page name assumed to be encoded using the given 535 'charset' (or default charset if omitted). 536 """ 537 538 return unicode(urllib.unquote(url), encoding=charset) 539 540 def getAttachmentFromURL(url, request): 541 542 """ 543 Return a (full path, attachment filename) tuple for the attachment 544 referenced by the given 'url', using the 'request' to interpret the 545 structure of 'url'. 546 547 If 'url' does not refer to an attachment on this wiki, None is returned. 548 """ 549 550 # Detect static resources. 551 552 htdocs_dir = get_htdocs(request) 553 554 if htdocs_dir: 555 prefix = request.cfg.url_prefix_static 556 557 # Normalise the 558 559 if not prefix.endswith("/"): 560 prefix += "/" 561 562 if url.startswith(prefix): 563 filename = url[len(prefix):] 564 565 # Obtain the resource path. 566 567 path = abspath(join(htdocs_dir, filename)) 568 569 if exists(path): 570 return path, taintfilename(filename) 571 572 # Detect attachments and other resources. 573 574 script = request.getScriptname() 575 576 # Normalise the URL. 577 578 if not script.endswith("/"): 579 script += "/" 580 581 # Reject URLs outside the wiki. 582 583 if not url.startswith(script): 584 return None 585 586 path = url[len(script):].lstrip("/") 587 try: 588 qpagename, qs = path.split("?", 1) 589 except ValueError: 590 qpagename = path 591 qs = None 592 593 pagename = unquoteWikinameURL(qpagename) 594 qs = qs and parseQueryString(qs) or {} 595 596 filename = qs.get("target") or qs.get("drawing") 597 filename = taintfilename(filename) 598 599 # Obtain the attachment path. 600 601 path = AttachFile.getFilename(request, pagename, filename) 602 return path, filename 603 604 class OutgoingHTMLFormatter(text_html.Formatter): 605 606 """ 607 Handle outgoing HTML content by identifying attachments and rewriting their 608 locations. References to bundled attachments are done using RFC 2111: 609 610 https://tools.ietf.org/html/rfc2111 611 612 Messages employing references between parts are meant to comply with RFC 613 2387: 614 615 https://tools.ietf.org/html/rfc2387 616 """ 617 618 def __init__(self, request, **kw): 619 text_html.Formatter.__init__(self, request, **kw) 620 self.attachments = [] 621 622 def add_attachment(self, location): 623 details = getAttachmentFromURL(location, self.request) 624 if details: 625 pos = len(self.attachments) 626 self.attachments.append(details) 627 return "cid:attachment%d" % pos 628 else: 629 return None 630 631 def image(self, src=None, **kw): 632 src = src or kw.get("src") 633 ref = src and self.add_attachment(src) 634 return text_html.Formatter.image(self, ref or src, **kw) 635 636 def transclusion(self, on, **kw): 637 if on: 638 data = kw.get("data") 639 kw["data"] = data and self.add_attachment(data) 640 return text_html.Formatter.transclusion(self, on, **kw) 641 642 # vim: tabstop=4 expandtab shiftwidth=4