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