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