1#! @PYTHON@ 2# 3# Copyright (C) 2006-2018 by the Free Software Foundation, Inc. 4# 5# This program is free software; you can redistribute it and/or 6# modify it under the terms of the GNU General Public License 7# as published by the Free Software Foundation; either version 2 8# of the License, or (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program; if not, write to the Free Software 17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 18# USA. 19 20"""Export an XML representation of a mailing list.""" 21 22import os 23import sys 24import base64 25import codecs 26import datetime 27import optparse 28 29from xml.sax.saxutils import escape 30 31import paths 32from Mailman import Defaults 33from Mailman import Errors 34from Mailman import MemberAdaptor 35from Mailman import Utils 36from Mailman import mm_cfg 37from Mailman.MailList import MailList 38from Mailman.i18n import C_ 39 40__i18n_templates__ = True 41 42SPACE = ' ' 43DOLLAR_STRINGS = ('msg_header', 'msg_footer', 44 'digest_header', 'digest_footer', 45 'autoresponse_postings_text', 46 'autoresponse_admin_text', 47 'autoresponse_request_text') 48SALT_LENGTH = 4 # bytes 49 50TYPES = { 51 mm_cfg.Toggle : 'bool', 52 mm_cfg.Radio : 'radio', 53 mm_cfg.String : 'string', 54 mm_cfg.Text : 'text', 55 mm_cfg.Email : 'email', 56 mm_cfg.EmailList : 'email_list', 57 mm_cfg.Host : 'host', 58 mm_cfg.Number : 'number', 59 mm_cfg.FileUpload : 'upload', 60 mm_cfg.Select : 'select', 61 mm_cfg.Topics : 'topics', 62 mm_cfg.Checkbox : 'checkbox', 63 mm_cfg.EmailListEx : 'email_list_ex', 64 mm_cfg.HeaderFilter : 'header_filter', 65 } 66 67 68 69 70class Indenter: 71 def __init__(self, fp, indentwidth=4): 72 self._fp = fp 73 self._indent = 0 74 self._width = indentwidth 75 76 def indent(self): 77 self._indent += 1 78 79 def dedent(self): 80 self._indent -= 1 81 assert self._indent >= 0 82 83 def write(self, s): 84 if s <> '\n': 85 self._fp.write(self._indent * self._width * ' ') 86 self._fp.write(s) 87 88 89 90class XMLDumper(object): 91 def __init__(self, fp): 92 self._fp = Indenter(fp) 93 self._tagbuffer = None 94 self._stack = [] 95 96 def _makeattrs(self, tagattrs): 97 # The attribute values might contain angle brackets. They might also 98 # be None. 99 attrs = [] 100 for k, v in tagattrs.items(): 101 if v is None: 102 v = '' 103 else: 104 v = escape(str(v)) 105 attrs.append('%s="%s"' % (k, v)) 106 return SPACE.join(attrs) 107 108 def _flush(self, more=True): 109 if not self._tagbuffer: 110 return 111 name, attributes = self._tagbuffer 112 self._tagbuffer = None 113 if attributes: 114 attrstr = ' ' + self._makeattrs(attributes) 115 else: 116 attrstr = '' 117 if more: 118 print >> self._fp, '<%s%s>' % (name, attrstr) 119 self._fp.indent() 120 self._stack.append(name) 121 else: 122 print >> self._fp, '<%s%s/>' % (name, attrstr) 123 124 # Use this method when you know you have sub-elements. 125 def _push_element(self, _name, **_tagattrs): 126 self._flush() 127 self._tagbuffer = (_name, _tagattrs) 128 129 def _pop_element(self, _name): 130 buffered = bool(self._tagbuffer) 131 self._flush(more=False) 132 if not buffered: 133 name = self._stack.pop() 134 assert name == _name, 'got: %s, expected: %s' % (_name, name) 135 self._fp.dedent() 136 print >> self._fp, '</%s>' % name 137 138 # Use this method when you do not have sub-elements 139 def _element(self, _name, _value=None, **_attributes): 140 self._flush() 141 if _attributes: 142 attrs = ' ' + self._makeattrs(_attributes) 143 else: 144 attrs = '' 145 if _value is None: 146 print >> self._fp, '<%s%s/>' % (_name, attrs) 147 else: 148 value = escape(unicode(_value)) 149 print >> self._fp, '<%s%s>%s</%s>' % (_name, attrs, value, _name) 150 151 def _do_list_categories(self, mlist, k, subcat=None): 152 is_converted = bool(getattr(mlist, 'use_dollar_strings', False)) 153 info = mlist.GetConfigInfo(k, subcat) 154 label, gui = mlist.GetConfigCategories()[k] 155 if info is None: 156 return 157 for data in info[1:]: 158 if not isinstance(data, tuple): 159 continue 160 varname = data[0] 161 # Variable could be volatile 162 if varname.startswith('_'): 163 continue 164 vtype = data[1] 165 # Munge the value based on its type 166 value = None 167 if hasattr(gui, 'getValue'): 168 value = gui.getValue(mlist, vtype, varname, data[2]) 169 if value is None: 170 value = getattr(mlist, varname) 171 # Do %-string to $-string conversions if the list hasn't already 172 # been converted. 173 if varname == 'use_dollar_strings': 174 continue 175 if not is_converted and varname in DOLLAR_STRINGS: 176 value = Utils.to_dollar(value) 177 widget_type = TYPES[vtype] 178 if isinstance(value, list): 179 self._push_element('option', name=varname, type=widget_type) 180 for v in value: 181 self._element('value', v) 182 self._pop_element('option') 183 else: 184 self._element('option', value, name=varname, type=widget_type) 185 186 def _dump_list(self, mlist, password_scheme): 187 # Write list configuration values 188 self._push_element('list', name=mlist._internal_name) 189 self._push_element('configuration') 190 self._element('option', 191 mlist.preferred_language, 192 name='preferred_language', 193 type='string') 194 self._element('option', 195 mlist.password, 196 name='password', 197 type='string') 198 for k in mm_cfg.ADMIN_CATEGORIES: 199 subcats = mlist.GetConfigSubCategories(k) 200 if subcats is None: 201 self._do_list_categories(mlist, k) 202 else: 203 for subcat in [t[0] for t in subcats]: 204 self._do_list_categories(mlist, k, subcat) 205 self._pop_element('configuration') 206 # Write membership 207 self._push_element('roster') 208 digesters = set(mlist.getDigestMemberKeys()) 209 for member in sorted(mlist.getMembers()): 210 attrs = dict(id=member) 211 cased = mlist.getMemberCPAddress(member) 212 if cased <> member: 213 attrs['original'] = cased 214 self._push_element('member', **attrs) 215 self._element('realname', mlist.getMemberName(member)) 216 self._element('password', 217 password_scheme(mlist.getMemberPassword(member))) 218 self._element('language', mlist.getMemberLanguage(member)) 219 # Delivery status, combined with the type of delivery 220 attrs = {} 221 status = mlist.getDeliveryStatus(member) 222 if status == MemberAdaptor.ENABLED: 223 attrs['status'] = 'enabled' 224 else: 225 attrs['status'] = 'disabled' 226 attrs['reason'] = {MemberAdaptor.BYUSER : 'byuser', 227 MemberAdaptor.BYADMIN : 'byadmin', 228 MemberAdaptor.BYBOUNCE : 'bybounce', 229 }.get(mlist.getDeliveryStatus(member), 230 'unknown') 231 if member in digesters: 232 if mlist.getMemberOption(member, mm_cfg.DisableMime): 233 attrs['delivery'] = 'plain' 234 else: 235 attrs['delivery'] = 'mime' 236 else: 237 attrs['delivery'] = 'regular' 238 changed = mlist.getDeliveryStatusChangeTime(member) 239 if changed: 240 when = datetime.datetime.fromtimestamp(changed) 241 attrs['changed'] = when.isoformat() 242 self._element('delivery', **attrs) 243 for option, flag in Defaults.OPTINFO.items(): 244 # Digest/Regular delivery flag must be handled separately 245 if option in ('digest', 'plain'): 246 continue 247 value = mlist.getMemberOption(member, flag) 248 self._element(option, value) 249 topics = mlist.getMemberTopics(member) 250 if not topics: 251 self._element('topics') 252 else: 253 self._push_element('topics') 254 for topic in topics: 255 self._element('topic', topic) 256 self._pop_element('topics') 257 self._pop_element('member') 258 self._pop_element('roster') 259 self._pop_element('list') 260 261 def dump(self, listnames, password_scheme): 262 print >> self._fp, '<?xml version="1.0" encoding="UTF-8"?>' 263 self._push_element('mailman', **{ 264 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 265 'xsi:noNamespaceSchemaLocation': 'ssi-1.0.xsd', 266 }) 267 for listname in sorted(listnames): 268 try: 269 mlist = MailList(listname, lock=False) 270 except Errors.MMUnknownListError: 271 print >> sys.stderr, C_('No such list: %(listname)s') 272 continue 273 self._dump_list(mlist, password_scheme) 274 self._pop_element('mailman') 275 276 def close(self): 277 while self._stack: 278 self._pop_element() 279 280 281 282def no_password(password): 283 return '{NONE}' 284 285 286def plaintext_password(password): 287 return '{PLAIN}' + password 288 289 290def sha_password(password): 291 h = Utils.sha_new(password) 292 return '{SHA}' + base64.b64encode(h.digest()) 293 294 295def ssha_password(password): 296 salt = os.urandom(SALT_LENGTH) 297 h = Utils.sha_new(password) 298 h.update(salt) 299 return '{SSHA}' + base64.b64encode(h.digest() + salt) 300 301 302SCHEMES = { 303 'none' : no_password, 304 'plain' : plaintext_password, 305 'sha' : sha_password, 306 } 307 308try: 309 os.urandom(1) 310except NotImplementedError: 311 pass 312else: 313 SCHEMES['ssha'] = ssha_password 314 315 316 317def parseargs(): 318 parser = optparse.OptionParser(version=mm_cfg.VERSION, 319 usage=C_("""\ 320%%prog [options] 321 322Export the configuration and members of a mailing list in XML format.""")) 323 parser.add_option('-o', '--outputfile', 324 metavar='FILENAME', default=None, type='string', 325 help=C_("""\ 326Output XML to FILENAME. If not given, or if FILENAME is '-', standard out is 327used.""")) 328 parser.add_option('-p', '--password-scheme', 329 default='none', type='string', help=C_("""\ 330Specify the RFC 2307 style hashing scheme for passwords included in the 331output. Use -P to get a list of supported schemes, which are 332case-insensitive.""")) 333 parser.add_option('-P', '--list-hash-schemes', 334 default=False, action='store_true', help=C_("""\ 335List the supported password hashing schemes and exit. The scheme labels are 336case-insensitive.""")) 337 parser.add_option('-l', '--listname', 338 default=[], action='append', type='string', 339 metavar='LISTNAME', dest='listnames', help=C_("""\ 340The list to include in the output. If not given, then all mailing lists are 341included in the XML output. Multiple -l flags may be given.""")) 342 opts, args = parser.parse_args() 343 if args: 344 parser.print_help() 345 parser.error(C_('Unexpected arguments')) 346 if opts.list_hash_schemes: 347 for label in SCHEMES: 348 print label.upper() 349 sys.exit(0) 350 if opts.password_scheme.lower() not in SCHEMES: 351 parser.error(C_('Invalid password scheme')) 352 return parser, opts, args 353 354 355 356def main(): 357 parser, opts, args = parseargs() 358 359 if opts.outputfile in (None, '-'): 360 # This will fail if there are characters in the output incompatible 361 # with stdout. 362 fp = sys.stdout 363 else: 364 fp = codecs.open(opts.outputfile, 'w', 'utf-8') 365 366 try: 367 dumper = XMLDumper(fp) 368 if opts.listnames: 369 listnames = opts.listnames 370 else: 371 listnames = Utils.list_names() 372 dumper.dump(listnames, SCHEMES[opts.password_scheme.lower()]) 373 dumper.close() 374 finally: 375 if fp is not sys.stdout: 376 fp.close() 377 378 379 380if __name__ == '__main__': 381 main() 382