1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - PostMessage Action 4 5 @copyright: 2012 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 from MoinMoin.PageEditor import PageEditor 10 from MoinMoin.log import getLogger 11 from MoinSupport import * 12 from email.parser import Parser 13 from subprocess import Popen, PIPE 14 from tempfile import mkstemp 15 import os 16 17 try: 18 from cStringIO import StringIO 19 except ImportError: 20 from StringIO import StringIO 21 22 Dependencies = ['pages'] 23 24 class PostMessage: 25 26 "A posted message handler." 27 28 def __init__(self, pagename, request): 29 30 """ 31 On the page with the given 'pagename', use the given 'request' when 32 reading posted messages, modifying the Wiki. 33 """ 34 35 self.pagename = pagename 36 self.request = request 37 self.page = Page(request, pagename) 38 39 def do_action(self): 40 request = self.request 41 content_length = getHeader(request, "Content-Length", "HTTP") 42 if content_length: 43 content_length = int(content_length) 44 45 self.handle_message_text(request.read(content_length)) 46 47 def handle_message_text(self, message_text): 48 49 "Handle the given 'message_text'." 50 51 message = Parser().parse(StringIO(message_text)) 52 self.handle_message(message) 53 54 def handle_message(self, message): 55 56 "Handle the given 'message'." 57 58 request = self.request 59 mimetype = message.get_content_type() 60 encoding = message.get_content_charset() 61 62 # Detect PGP/GPG-encoded payloads. 63 # See: http://tools.ietf.org/html/rfc3156 64 65 if mimetype == "multipart/signed" and \ 66 message.get_param("protocol") == "application/pgp-signature": 67 68 self.handle_signed_message(message) 69 70 elif mimetype == "multipart/encrypted" and \ 71 message.get_param("protocol") == "application/pgp-encrypted": 72 73 self.handle_encrypted_message(message) 74 75 # Reject unsigned payloads. 76 77 else: 78 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 79 request.write("Only PGP/GPG-signed payloads are supported.") 80 81 def handle_encrypted_message(self, message): 82 83 "Handle the given encrypted 'message'." 84 85 request = self.request 86 87 try: 88 declaration, content = message.get_payload() 89 except ValueError: 90 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 91 request.write("There must be a declaration and a content part for encrypted uploads.") 92 return 93 94 # Verify the message format. 95 96 if content.get_content_type() != "application/octet-stream": 97 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 98 request.write("Encrypted data must be provided as application/octet-stream.") 99 return 100 101 homedir = self.get_homedir() 102 if not homedir: 103 return 104 105 cmd = Popen(["gpg", "--homedir", homedir, "--decrypt"], 106 stdin=PIPE, stdout=PIPE, stderr=PIPE) 107 108 cmd.stdin.write(content.get_payload()) 109 cmd.stdin.close() 110 111 errors = cmd.stderr.read() 112 if errors: 113 getLogger(__name__).warning(errors) 114 115 # Handle the embedded message. 116 117 try: 118 # Get the decrypted message text. 119 120 text = cmd.stdout.read() 121 122 # With a zero return code, accept the message. 123 124 if not cmd.wait(): 125 self.handle_message_text(text) 126 127 # Otherwise, reject the unverified message. 128 129 else: 130 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 131 request.write("The message could not be decrypted.") 132 133 finally: 134 cmd.stdout.close() 135 cmd.stderr.close() 136 137 def handle_signed_message(self, message): 138 139 "Handle the given signed 'message'." 140 141 request = self.request 142 143 # NOTE: RFC 3156 states that signed messages should employ a detached 144 # NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 145 # NOTE: instead of "BEGIN PGP SIGNATURE". 146 # NOTE: The "micalg" parameter is currently not supported. 147 148 try: 149 content, signature = message.get_payload() 150 except ValueError: 151 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 152 request.write("There must be a content part and a signature for signed uploads.") 153 return 154 155 # Verify the message format. 156 157 if signature.get_content_type() != "application/pgp-signature": 158 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 159 request.write("Signature data must be provided in the second part as application/pgp-signature.") 160 return 161 162 homedir = self.get_homedir() 163 if not homedir: 164 return 165 166 # Write the detached signature and content to files. 167 168 signature_fd, signature_filename = mkstemp() 169 content_fd, content_filename = mkstemp() 170 try: 171 signature_fp = os.fdopen(signature_fd, "w") 172 content_fp = os.fdopen(content_fd, "w") 173 try: 174 signature_fp.write(signature.get_payload()) 175 content_fp.write(content.as_string()) 176 finally: 177 signature_fp.close() 178 content_fp.close() 179 180 # Verify the message text. 181 182 cmd = Popen(["gpg", "--homedir", homedir, "--verify", signature_filename, content_filename], 183 stderr=PIPE) 184 185 errors = cmd.stderr.read() 186 if errors: 187 getLogger(__name__).warning(errors) 188 189 # Handle the embedded message. 190 191 try: 192 # With a zero return code, accept the message. 193 194 if not cmd.wait(): 195 self.handle_message_content(content) 196 197 # Otherwise, reject the unverified message. 198 199 else: 200 writeHeaders(request, "text/plain", getMetadata(self.page), "403 Forbidden") 201 request.write("The message could not be verified.") 202 203 finally: 204 cmd.stderr.close() 205 206 finally: 207 os.remove(signature_filename) 208 os.remove(content_filename) 209 210 def handle_message_content(self, message): 211 212 "Handle the given 'message'." 213 214 request = self.request 215 216 # Handle a single part. 217 218 if not message.is_multipart(): 219 self.handle_message_parts([message], to_replace(message)) 220 221 # Handle multiple parts. 222 223 # This can be a collection of updates, with each update potentially being a 224 # collection of alternative representations. 225 226 elif is_collection(message): 227 for part in message.get_payload(): 228 if part.is_multipart(): 229 self.handle_message_parts(part.get_payload(), to_replace(part)) 230 else: 231 self.handle_message_parts([part], to_replace(part)) 232 233 # Or it can be a collection of alternative representations for a single 234 # update. 235 236 else: 237 self.handle_message_parts(message.get_payload(), to_replace(message)) 238 239 # Default output. 240 241 writeHeaders(request, "text/plain", getMetadata(self.page), "204 No Content") 242 243 def handle_message_parts(self, parts, replace): 244 245 """ 246 Handle the given message 'parts', replacing the page content if 247 'replace' is set to a true value. 248 """ 249 250 # NOTE: Should either choose preferred content types or somehow retain them 251 # NOTE: all but present one at a time. 252 253 body = [] 254 255 for part in parts: 256 mimetype = part.get_content_type() 257 encoding = part.get_content_charset() 258 if mimetype == "text/moin": 259 body.append(part.get_payload()) 260 if replace: 261 break 262 263 if not replace: 264 body.append(self.page.get_raw_body()) 265 266 page_editor = PageEditor(self.request, self.pagename) 267 page_editor.saveText("\n\n".join(body), 0) 268 269 def get_homedir(self): 270 271 "Locate the GPG home directory." 272 273 homedir = getattr(self.request.cfg, "postmessage_gpg_homedir") 274 if not homedir: 275 writeHeaders(request, "text/plain", getMetadata(self.page), "415 Unsupported Media Type") 276 request.write("Encoded data cannot currently be understood. Please notify the site administrator.") 277 return homedir 278 279 def is_collection(message): 280 return message.get("Update-Type") == "collection" 281 282 def to_replace(message): 283 return message.get("Update-Action") == "replace" 284 285 # Action function. 286 287 def execute(pagename, request): 288 PostMessage(pagename, request).do_action() 289 290 # vim: tabstop=4 expandtab shiftwidth=4