1# Copyright (C) 1998-2018 by the Free Software Foundation, Inc.
2#
3# This program is free software; you can redistribute it and/or
4# modify it under the terms of the GNU General Public License
5# as published by the Free Software Foundation; either version 2
6# of the License, or (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16# USA.
17
18
19"""Library for program-based construction of an HTML documents.
20
21Encapsulate HTML formatting directives in classes that act as containers
22for python and, recursively, for nested HTML formatting objects.
23"""
24
25
26# Eventually could abstract down to HtmlItem, which outputs an arbitrary html
27# object given start / end tags, valid options, and a value.  Ug, objects
28# shouldn't be adding their own newlines.  The next object should.
29
30
31import types
32
33from Mailman import mm_cfg
34from Mailman import Utils
35from Mailman.i18n import _, get_translation
36
37from Mailman.CSRFcheck import csrf_token
38
39SPACE = ' '
40EMPTYSTRING = ''
41NL = '\n'
42
43
44
45# Format an arbitrary object.
46def HTMLFormatObject(item, indent):
47    "Return a presentation of an object, invoking their Format method if any."
48    if type(item) == type(''):
49        return item
50    elif not hasattr(item, "Format"):
51        return `item`
52    else:
53        return item.Format(indent)
54
55def CaseInsensitiveKeyedDict(d):
56    result = {}
57    for (k,v) in d.items():
58        result[k.lower()] = v
59    return result
60
61# Given references to two dictionaries, copy the second dictionary into the
62# first one.
63def DictMerge(destination, fresh_dict):
64    for (key, value) in fresh_dict.items():
65        destination[key] = value
66
67class Table:
68    def __init__(self, **table_opts):
69        self.cells = []
70        self.cell_info = {}
71        self.row_info = {}
72        self.opts = table_opts
73
74    def AddOptions(self, opts):
75        DictMerge(self.opts, opts)
76
77    # Sets all of the cells.  It writes over whatever cells you had there
78    # previously.
79
80    def SetAllCells(self, cells):
81        self.cells = cells
82
83    # Add a new blank row at the end
84    def NewRow(self):
85        self.cells.append([])
86
87    # Add a new blank cell at the end
88    def NewCell(self):
89        self.cells[-1].append('')
90
91    def AddRow(self, row):
92        self.cells.append(row)
93
94    def AddCell(self, cell):
95        self.cells[-1].append(cell)
96
97    def AddCellInfo(self, row, col, **kws):
98        kws = CaseInsensitiveKeyedDict(kws)
99        if not self.cell_info.has_key(row):
100            self.cell_info[row] = { col : kws }
101        elif self.cell_info[row].has_key(col):
102            DictMerge(self.cell_info[row], kws)
103        else:
104            self.cell_info[row][col] = kws
105
106    def AddRowInfo(self, row, **kws):
107        kws = CaseInsensitiveKeyedDict(kws)
108        if not self.row_info.has_key(row):
109            self.row_info[row] = kws
110        else:
111            DictMerge(self.row_info[row], kws)
112
113    # What's the index for the row we just put in?
114    def GetCurrentRowIndex(self):
115        return len(self.cells)-1
116
117    # What's the index for the col we just put in?
118    def GetCurrentCellIndex(self):
119        return len(self.cells[-1])-1
120
121    def ExtractCellInfo(self, info):
122        valid_mods = ['align', 'valign', 'nowrap', 'rowspan', 'colspan',
123                      'bgcolor']
124        output = ''
125
126        for (key, val) in info.items():
127            if not key in valid_mods:
128                continue
129            if key == 'nowrap':
130                output = output + ' NOWRAP'
131                continue
132            else:
133                output = output + ' %s="%s"' % (key.upper(), val)
134
135        return output
136
137    def ExtractRowInfo(self, info):
138        valid_mods = ['align', 'valign', 'bgcolor']
139        output = ''
140
141        for (key, val) in info.items():
142            if not key in valid_mods:
143                continue
144            output = output + ' %s="%s"' % (key.upper(), val)
145
146        return output
147
148    def ExtractTableInfo(self, info):
149        valid_mods = ['align', 'width', 'border', 'cellspacing', 'cellpadding',
150                      'bgcolor']
151
152        output = ''
153
154        for (key, val) in info.items():
155            if not key in valid_mods:
156                continue
157            if key == 'border' and val == None:
158                output = output + ' BORDER'
159                continue
160            else:
161                output = output + ' %s="%s"' % (key.upper(), val)
162
163        return output
164
165    def FormatCell(self, row, col, indent):
166        try:
167            my_info = self.cell_info[row][col]
168        except:
169            my_info = None
170
171        output = '\n' + ' '*indent + '<td'
172        if my_info:
173            output = output + self.ExtractCellInfo(my_info)
174        item = self.cells[row][col]
175        item_format = HTMLFormatObject(item, indent+4)
176        output = '%s>%s</td>' % (output, item_format)
177        return output
178
179    def FormatRow(self, row, indent):
180        try:
181            my_info = self.row_info[row]
182        except:
183            my_info = None
184
185        output = '\n' + ' '*indent + '<tr'
186        if my_info:
187            output = output + self.ExtractRowInfo(my_info)
188        output = output + '>'
189
190        for i in range(len(self.cells[row])):
191            output = output + self.FormatCell(row, i, indent + 2)
192
193        output = output + '\n' + ' '*indent + '</tr>'
194
195        return output
196
197    def Format(self, indent=0):
198        output = '\n' + ' '*indent + '<table'
199        output = output + self.ExtractTableInfo(self.opts)
200        output = output + '>'
201
202        for i in range(len(self.cells)):
203            output = output + self.FormatRow(i, indent + 2)
204
205        output = output + '\n' + ' '*indent + '</table>\n'
206
207        return output
208
209
210class Link:
211    def __init__(self, href, text, target=None):
212        self.href = href
213        self.text = text
214        self.target = target
215
216    def Format(self, indent=0):
217        texpr = ""
218        if self.target != None:
219            texpr = ' target="%s"' % self.target
220        return '<a href="%s"%s>%s</a>' % (HTMLFormatObject(self.href, indent),
221                                          texpr,
222                                          HTMLFormatObject(self.text, indent))
223
224class FontSize:
225    """FontSize is being deprecated - use FontAttr(..., size="...") instead."""
226    def __init__(self, size, *items):
227        self.items = list(items)
228        self.size = size
229
230    def Format(self, indent=0):
231        output = '<font size="%s">' % self.size
232        for item in self.items:
233            output = output + HTMLFormatObject(item, indent)
234        output = output + '</font>'
235        return output
236
237class FontAttr:
238    """Present arbitrary font attributes."""
239    def __init__(self, *items, **kw):
240        self.items = list(items)
241        self.attrs = kw
242
243    def Format(self, indent=0):
244        seq = []
245        for k, v in self.attrs.items():
246            seq.append('%s="%s"' % (k, v))
247        output = '<font %s>' % SPACE.join(seq)
248        for item in self.items:
249            output = output + HTMLFormatObject(item, indent)
250        output = output + '</font>'
251        return output
252
253
254class Container:
255    def __init__(self, *items):
256        if not items:
257            self.items = []
258        else:
259            self.items = items
260
261    def AddItem(self, obj):
262        self.items.append(obj)
263
264    def Format(self, indent=0):
265        output = []
266        for item in self.items:
267            output.append(HTMLFormatObject(item, indent))
268        return EMPTYSTRING.join(output)
269
270
271class Label(Container):
272    align = 'right'
273
274    def __init__(self, *items):
275        Container.__init__(self, *items)
276
277    def Format(self, indent=0):
278        return ('<div align="%s">' % self.align) + \
279               Container.Format(self, indent) + \
280               '</div>'
281
282
283# My own standard document template.  YMMV.
284# something more abstract would be more work to use...
285
286class Document(Container):
287    title = None
288    language = None
289    bgcolor = mm_cfg.WEB_BG_COLOR
290    suppress_head = 0
291
292    def set_language(self, lang=None):
293        self.language = lang
294
295    def set_bgcolor(self, color):
296        self.bgcolor = color
297
298    def SetTitle(self, title):
299        self.title = title
300
301    def Format(self, indent=0, **kws):
302        charset = 'us-ascii'
303        if self.language and Utils.IsLanguage(self.language):
304            charset = Utils.GetCharSet(self.language)
305        output = ['Content-Type: text/html; charset=%s\n' % charset]
306        if not self.suppress_head:
307            kws.setdefault('bgcolor', self.bgcolor)
308            tab = ' ' * indent
309            output.extend([tab,
310                           '<HTML>',
311                           '<HEAD>'
312                           ])
313            if mm_cfg.IMAGE_LOGOS:
314                output.append('<LINK REL="SHORTCUT ICON" HREF="%s">' %
315                              (mm_cfg.IMAGE_LOGOS + mm_cfg.SHORTCUT_ICON))
316            # Hit all the bases
317            output.append('<META http-equiv="Content-Type" '
318                          'content="text/html; charset=%s">' % charset)
319            if self.title:
320                output.append('%s<TITLE>%s</TITLE>' % (tab, self.title))
321            # Add CSS to visually hide some labeling text but allow screen
322            # readers to read it.
323            output.append("""\
324<style type="text/css">
325    div.hidden
326        {position:absolute;
327        left:-10000px;
328        top:auto;
329        width:1px;
330        height:1px;
331        overflow:hidden;}
332</style>
333""")
334            if mm_cfg.WEB_HEAD_ADD:
335                output.append(mm_cfg.WEB_HEAD_ADD)
336            output.append('%s</HEAD>' % tab)
337            quals = []
338            # Default link colors
339            if mm_cfg.WEB_VLINK_COLOR:
340                kws.setdefault('vlink', mm_cfg.WEB_VLINK_COLOR)
341            if mm_cfg.WEB_ALINK_COLOR:
342                kws.setdefault('alink', mm_cfg.WEB_ALINK_COLOR)
343            if mm_cfg.WEB_LINK_COLOR:
344                kws.setdefault('link', mm_cfg.WEB_LINK_COLOR)
345            for k, v in kws.items():
346                quals.append('%s="%s"' % (k, v))
347            output.append('%s<BODY %s' % (tab, SPACE.join(quals)))
348            # Language direction
349            direction = Utils.GetDirection(self.language)
350            output.append('dir="%s">' % direction)
351        # Always do this...
352        output.append(Container.Format(self, indent))
353        if not self.suppress_head:
354            output.append('%s</BODY>' % tab)
355            output.append('%s</HTML>' % tab)
356        return NL.join(output)
357
358    def addError(self, errmsg, tag=None):
359        if tag is None:
360            tag = _('Error: ')
361        self.AddItem(Header(3, Bold(FontAttr(
362            _(tag), color=mm_cfg.WEB_ERROR_COLOR, size='+2')).Format() +
363                            Italic(errmsg).Format()))
364
365
366class HeadlessDocument(Document):
367    """Document without head section, for templates that provide their own."""
368    suppress_head = 1
369
370
371class StdContainer(Container):
372    def Format(self, indent=0):
373        # If I don't start a new I ignore indent
374        output = '<%s>' % self.tag
375        output = output + Container.Format(self, indent)
376        output = '%s</%s>' % (output, self.tag)
377        return output
378
379
380class QuotedContainer(Container):
381    def Format(self, indent=0):
382        # If I don't start a new I ignore indent
383        output = '<%s>%s</%s>' % (
384            self.tag,
385            Utils.websafe(Container.Format(self, indent)),
386            self.tag)
387        return output
388
389class Header(StdContainer):
390    def __init__(self, num, *items):
391        self.items = items
392        self.tag = 'h%d' % num
393
394class Address(StdContainer):
395    tag = 'address'
396
397class Underline(StdContainer):
398    tag = 'u'
399
400class Bold(StdContainer):
401    tag = 'strong'
402
403class Italic(StdContainer):
404    tag = 'em'
405
406class Preformatted(QuotedContainer):
407    tag = 'pre'
408
409class Subscript(StdContainer):
410    tag = 'sub'
411
412class Superscript(StdContainer):
413    tag = 'sup'
414
415class Strikeout(StdContainer):
416    tag = 'strike'
417
418class Center(StdContainer):
419    tag = 'center'
420
421class Form(Container):
422    def __init__(self, action='', method='POST', encoding=None,
423                       mlist=None, contexts=None, user=None, *items):
424        apply(Container.__init__, (self,) +  items)
425        self.action = action
426        self.method = method
427        self.encoding = encoding
428        self.mlist = mlist
429        self.contexts = contexts
430        self.user = user
431
432    def set_action(self, action):
433        self.action = action
434
435    def Format(self, indent=0):
436        spaces = ' ' * indent
437        encoding = ''
438        if self.encoding:
439            encoding = 'enctype="%s"' % self.encoding
440        output = '\n%s<FORM action="%s" method="%s" %s>\n' % (
441            spaces, self.action, self.method, encoding)
442        if self.mlist:
443            output = output + \
444                '<input type="hidden" name="csrf_token" value="%s">\n' \
445                % csrf_token(self.mlist, self.contexts, self.user)
446        output = output + Container.Format(self, indent+2)
447        output = '%s\n%s</FORM>\n' % (output, spaces)
448        return output
449
450
451class InputObj:
452    def __init__(self, name, ty, value, checked, **kws):
453        self.name = name
454        self.type = ty
455        self.value = value
456        self.checked = checked
457        self.kws = kws
458
459    def Format(self, indent=0):
460        charset = get_translation().charset() or 'us-ascii'
461        output = ['<INPUT name="%s" type="%s" value="%s"' %
462                  (self.name, self.type, self.value)]
463        for item in self.kws.items():
464            output.append('%s="%s"' % item)
465        if self.checked:
466            output.append('CHECKED')
467        output.append('>')
468        ret = SPACE.join(output)
469        if self.type == 'TEXT' and isinstance(ret, unicode):
470            ret = ret.encode(charset, 'xmlcharrefreplace')
471        return ret
472
473
474class SubmitButton(InputObj):
475    def __init__(self, name, button_text):
476        InputObj.__init__(self, name, "SUBMIT", button_text, checked=0)
477
478class PasswordBox(InputObj):
479    def __init__(self, name, value='', size=mm_cfg.TEXTFIELDWIDTH):
480        InputObj.__init__(self, name, "PASSWORD", value, checked=0, size=size)
481
482class TextBox(InputObj):
483    def __init__(self, name, value='', size=mm_cfg.TEXTFIELDWIDTH):
484        if isinstance(value, str):
485            safevalue = Utils.websafe(value)
486        else:
487            safevalue = value
488        InputObj.__init__(self, name, "TEXT", safevalue, checked=0, size=size)
489
490class Hidden(InputObj):
491    def __init__(self, name, value=''):
492        InputObj.__init__(self, name, 'HIDDEN', value, checked=0)
493
494class TextArea:
495    def __init__(self, name, text='', rows=None, cols=None, wrap='soft',
496                 readonly=0):
497        if isinstance(text, str):
498            # Double escape HTML entities in non-readonly areas.
499            doubleescape = not readonly
500            safetext = Utils.websafe(text, doubleescape)
501        else:
502            safetext = text
503        self.name = name
504        self.text = safetext
505        self.rows = rows
506        self.cols = cols
507        self.wrap = wrap
508        self.readonly = readonly
509
510    def Format(self, indent=0):
511        charset = get_translation().charset() or 'us-ascii'
512        output = '<TEXTAREA NAME=%s' % self.name
513        if self.rows:
514            output += ' ROWS=%s' % self.rows
515        if self.cols:
516            output += ' COLS=%s' % self.cols
517        if self.wrap:
518            output += ' WRAP=%s' % self.wrap
519        if self.readonly:
520            output += ' READONLY'
521        output += '>%s</TEXTAREA>' % self.text
522        if isinstance(output, unicode):
523            output = output.encode(charset, 'xmlcharrefreplace')
524        return output
525
526class FileUpload(InputObj):
527    def __init__(self, name, rows=None, cols=None, **kws):
528        apply(InputObj.__init__, (self, name, 'FILE', '', 0), kws)
529
530class RadioButton(InputObj):
531    def __init__(self, name, value, checked=0, **kws):
532        apply(InputObj.__init__, (self, name, 'RADIO', value, checked), kws)
533
534class CheckBox(InputObj):
535    def __init__(self, name, value, checked=0, **kws):
536        apply(InputObj.__init__, (self, name, "CHECKBOX", value, checked), kws)
537
538class VerticalSpacer:
539    def __init__(self, size=10):
540        self.size = size
541    def Format(self, indent=0):
542        output = '<spacer type="vertical" height="%d">' % self.size
543        return output
544
545class WidgetArray:
546    Widget = None
547
548    def __init__(self, name, button_names, checked, horizontal, values):
549        self.name = name
550        self.button_names = button_names
551        self.checked = checked
552        self.horizontal = horizontal
553        self.values = values
554        assert len(values) == len(button_names)
555        # Don't assert `checked' because for RadioButtons it is a scalar while
556        # for CheckedBoxes it is a vector.  Subclasses will assert length.
557
558    def ischecked(self, i):
559        raise NotImplemented
560
561    def Format(self, indent=0):
562        t = Table(cellspacing=5)
563        items = []
564        for i, name, value in zip(range(len(self.button_names)),
565                                  self.button_names,
566                                  self.values):
567            ischecked = (self.ischecked(i))
568            item = ('<label>' +
569                    self.Widget(self.name, value, ischecked).Format() +
570                    name + '</label>')
571            items.append(item)
572            if not self.horizontal:
573                t.AddRow(items)
574                items = []
575        if self.horizontal:
576            t.AddRow(items)
577        return t.Format(indent)
578
579class RadioButtonArray(WidgetArray):
580    Widget = RadioButton
581
582    def __init__(self, name, button_names, checked=None, horizontal=1,
583                 values=None):
584        if values is None:
585            values = range(len(button_names))
586        # BAW: assert checked is a scalar...
587        WidgetArray.__init__(self, name, button_names, checked, horizontal,
588                             values)
589
590    def ischecked(self, i):
591        return self.checked == i
592
593class CheckBoxArray(WidgetArray):
594    Widget = CheckBox
595
596    def __init__(self, name, button_names, checked=None, horizontal=0,
597                 values=None):
598        if checked is None:
599            checked = [0] * len(button_names)
600        else:
601            assert len(checked) == len(button_names)
602        if values is None:
603            values = range(len(button_names))
604        WidgetArray.__init__(self, name, button_names, checked, horizontal,
605                             values)
606
607    def ischecked(self, i):
608        return self.checked[i]
609
610class UnorderedList(Container):
611    def Format(self, indent=0):
612        spaces = ' ' * indent
613        output = '\n%s<ul>\n' % spaces
614        for item in self.items:
615            output = output + '%s<li>%s\n' % \
616                     (spaces, HTMLFormatObject(item, indent + 2))
617        output = output + '%s</ul>\n' % spaces
618        return output
619
620class OrderedList(Container):
621    def Format(self, indent=0):
622        spaces = ' ' * indent
623        output = '\n%s<ol>\n' % spaces
624        for item in self.items:
625            output = output + '%s<li>%s\n' % \
626                     (spaces, HTMLFormatObject(item, indent + 2))
627        output = output + '%s</ol>\n' % spaces
628        return output
629
630class DefinitionList(Container):
631    def Format(self, indent=0):
632        spaces = ' ' * indent
633        output = '\n%s<dl>\n' % spaces
634        for dt, dd in self.items:
635            output = output + '%s<dt>%s\n<dd>%s\n' % \
636                     (spaces, HTMLFormatObject(dt, indent+2),
637                      HTMLFormatObject(dd, indent+2))
638        output = output + '%s</dl>\n' % spaces
639        return output
640
641
642
643# Logo constants
644#
645# These are the URLs which the image logos link to.  The Mailman home page now
646# points at the gnu.org site instead of the www.list.org mirror.
647#
648from mm_cfg import MAILMAN_URL
649PYTHON_URL  = 'http://www.python.org/'
650GNU_URL     = 'http://www.gnu.org/'
651FREEBSD_URL = 'http://www.freebsd.org/'
652
653# The names of the image logo files.  These are concatentated onto
654# mm_cfg.IMAGE_LOGOS (not urljoined).
655DELIVERED_BY = 'mailman.jpg'
656PYTHON_POWERED = 'PythonPowered.png'
657GNU_HEAD = 'gnu-head-tiny.jpg'
658FREEBSD_POWERED = 'powerlogo.png'
659
660
661def MailmanLogo():
662    t = Table(border=0, width='100%')
663    if mm_cfg.IMAGE_LOGOS:
664        def logo(file):
665            return mm_cfg.IMAGE_LOGOS + file
666        mmlink = '<img src="%s" alt="Delivered by Mailman" border=0>' \
667                 '<br>version %s' % (logo(DELIVERED_BY), mm_cfg.VERSION)
668        pylink = '<img src="%s" alt="Python Powered" border=0>' % \
669                 logo(PYTHON_POWERED)
670        freebsdlink = '<img src="%s" alt="Powered by FreeBSD" border=0>' % \
671                 logo(FREEBSD_POWERED)
672        t.AddRow([mmlink, pylink, freebsdlink])
673    else:
674        # use only textual links
675        version = mm_cfg.VERSION
676        mmlink = Link(MAILMAN_URL,
677                      _('Delivered by Mailman<br>version %(version)s'))
678        pylink = Link(PYTHON_URL, _('Python Powered'))
679        freebsdlink = Link(FREEBSD_URL, "Powered by FreeBSD")
680        t.AddRow([mmlink, pylink, freebsdlink])
681    return t
682
683
684class SelectOptions:
685   def __init__(self, varname, values, legend,
686                selected=0, size=1, multiple=None):
687      self.varname  = varname
688      self.values   = values
689      self.legend   = legend
690      self.size     = size
691      self.multiple = multiple
692      # we convert any type to tuple, commas are needed
693      if not multiple:
694         if type(selected) == types.IntType:
695             self.selected = (selected,)
696         elif type(selected) == types.TupleType:
697             self.selected = (selected[0],)
698         elif type(selected) == types.ListType:
699             self.selected = (selected[0],)
700         else:
701             self.selected = (0,)
702
703   def Format(self, indent=0):
704      spaces = " " * indent
705      items  = min( len(self.values), len(self.legend) )
706
707      # jcrey: If there is no argument, we return nothing to avoid errors
708      if items == 0:
709          return ""
710
711      text = "\n" + spaces + "<Select name=\"%s\"" % self.varname
712      if self.size > 1:
713          text = text + " size=%d" % self.size
714      if self.multiple:
715          text = text + " multiple"
716      text = text + ">\n"
717
718      for i in range(items):
719          if i in self.selected:
720              checked = " Selected"
721          else:
722              checked = ""
723
724          opt = " <option value=\"%s\"%s> %s </option>" % (
725              self.values[i], checked, self.legend[i])
726          text = text + spaces + opt + "\n"
727
728      return text + spaces + '</Select>'
729