I've finally managed to put together some patches for the admin cgi.
Changes included are:
* single login for access to list-specific administrative pages, as
well as for access to making all changes, except changing the
administrator password. this uses cookie authentication with a
configurable lifetime (changeable in
mm_defaults.ADMIN_COOKIE_LIFE). In order to get this to function, I
had to change maillist.MailList.GetScriptURL to allways return full,
absolute urls instead of relative ones.
* membership management section for mass subscription now does
error checking for the addresses entered and reports what addresses
were successfully subscribed and which ones weren't and why.
* membership management section has an added section where list
members are shown in a table with checkboxes available for setting
their options or unsubscribing them. There is a related
configuration variable in mm_defaults.ADMIN_MEMBER_CHUNKSIZE which
will cause lists with more members than that value to only display
the first of that many members (sorted) and to display an index for
accessing the remaining chunks of members.
this patch should be applied against mailman-1.0b3 with the patches
for subscription confirmation applied. it probably would work without
the confirmation patches, but i haven't tried it and won't recommend
it.
scott
Index: cgi/admin
===================================================================
RCS file: /usr/local/cvsroot/mailman/cgi/admin,v
retrieving revision 1.2
diff -r1.2 admin
12,13c12,15
< import os, cgi, string, crypt, types
< import mm_utils, maillist, mm_cfg, mm_err
---
> sys.path.append('.')
> import os, cgi, string, crypt, types, time
> import mm_utils, maillist, mm_cfg, mm_err, mm_mailcmd
> import Cookie
30a33,82
> LOGIN_PAGE = """
> <html>
> <head>
> <title>%(listname)s Administrative Authentication</title>
> </head>
> <body bgcolor="#ffffff">
> <FORM METHOD=POST ACTION="%(path)s">
> %(message)s
> <TABLE WIDTH="100%%" BORDER="0" CELLSPACING="4" CELLPADDING="5">
> <TR>
> <TD COLSPAN="2" WIDTH="100%%" BGCOLOR="#99CCFF" ALIGN="CENTER">
> <B><FONT COLOR="#000000" SIZE="+1">%(listname)s Administrative
> Authentication</FONT></B>
> </TD>
> </TR>
> <tr>
> <TD> <div ALIGN="Right"> List Administrative Password: </div> </TD>
> <TD> <INPUT TYPE=password NAME=adminpw SIZE=30></TD>
> </tr>
> <tr>
> <td colspan=2 align=middle> <INPUT TYPE=SUBMIT name="request_login">
> </td>
> </tr>
> </TABLE>
> </FORM>
> """
>
> # " <- icky emacs font-lock bug workaround
>
> def isAuthenticated(list, password=None):
> if password is not None: # explicit login
> try:
> list.ConfirmAdminPassword(password)
> except mm_err.MMBadPasswordError:
> AddErrorMessage(doc, 'Error: Incorrect admin password.')
> return 0
> c = Cookie.Cookie()
> c[list_name] = list.password # its crypted so this should be ok
> c[list_name]['expires'] = mm_cfg.ADMIN_COOKIE_LIFE
> c[list_name]["path"] = "/mailman/" + list.GetScriptURL("admin")
> print c # Output the cookie
>
> return 1
> if os.environ.has_key('HTTP_COOKIE'):
> c = Cookie.Cookie( os.environ['HTTP_COOKIE'] )
> if c.has_key(list_name):
> return (c[list_name].value == list.password)
> return 0
>
>
36c88
< global list_name, list_info
---
> global list_name, list_info, doc
48,50c100,101
<
< list_name = string.lower(list_info[0])
<
---
> else:
> list_name = string.lower(list_info[0])
52d102
<
65c115
<
---
>
68c118
<
---
> global cgi_data
70,73c120,138
< if len(cgi_data.keys()):
< if cgi_data.has_key('VARHELP'):
< FormatOptionHelp(doc, cgi_data['VARHELP'].value, lst)
< print doc.Format(bgcolor="#ffffff")
---
> is_auth = 0
> if cgi_data.has_key("adminpw"):
> is_auth = isAuthenticated(lst, cgi_data["adminpw"].value)
> message = FontAttr("Sorry, wrong password. Try again.",
> color="ff5060", size="+1").Format()
> else:
> is_auth = isAuthenticated(lst)
> message = ""
> if not is_auth:
> print "Content-type: text/html\n\n"
> print LOGIN_PAGE % ({"listname": list_name,
> "path": os.environ.get("REQUEST_URI", "/mailman/admin"),
> "message": message})
> return
>
> if len(cgi_data.keys()):
> if cgi_data.has_key('VARHELP'):
> FormatOptionHelp(doc, cgi_data['VARHELP'].value, lst)
> print doc.Format(bgcolor="#ffffff")
75,79c140
< if not cgi_data.has_key('adminpw'):
< AddErrorMessage(doc,
< 'Error: You must supply the admin password to'
< ' change options.')
< else:
---
> if (cgi_data.has_key('bounce_matching_headers')):
81,87c142,147
< lst.ConfirmAdminPassword(cgi_data['adminpw'].value)
< ChangeOptions(lst, category, cgi_data, doc)
< # Yuck. This shouldn't need to be here.
< if not lst.digestable and not lst.nondigestable:
< lst.nondigestable = 1
< except mm_err.MMBadPasswordError:
< AddErrorMessage(doc, 'Error: Incorrect admin password.')
---
> pairs = lst.parse_matching_header_opt()
> except mm_err.MMBadConfigError, line:
> AddErrorMessage(doc,
> 'Warning: bad matching-header line'
> ' (does it have the colon?)<ul> %s </ul>',
> line)
100,110c160,161
<
< if len(cgi_data.keys()):
< if (cgi_data.has_key('bounce_matching_headers')):
< try:
< pairs = lst.parse_matching_header_opt()
< except mm_err.MMBadConfigError, line:
< AddErrorMessage(doc,
< 'Warning: bad matching-header line'
< ' (does it have the colon?)<ul> %s </ul>',
< line)
<
---
> if len(cgi_data.keys()):
> ChangeOptions(lst, category, cgi_data, doc)
220d270
< url = lst.GetScriptURL('admin')
225c275,276
< these_links.AddItem(Link(os.path.join(url, k), v))
---
> these_links.AddItem(Link("%s/%s" % (lst.GetScriptURL('admin'),k),
> v))
234c285,286
< form = Form(os.path.join(lst.GetScriptURL('admin'), category))
---
> form = Form("%s/%s" % (lst.GetScriptURL('admin'),
> category_suffix))
425,427d476
< header.AddRow([Bold("Subscribe and Unsubscribe Members")])
< header.AddCellInfo(max(header.GetCurrentRowIndex(), 0), 0,
< colspan=2, bgcolor ="#FFF0D0")
429,444c478,540
<
< container.AddItem('<h3>Mass Subscribe Members</h3>')
< container.AddItem('<h4>Enter one email address per line</h4>')
< container.AddItem(TextArea(name='subscribees', rows=20,cols=60,wrap=None))
<
< container.AddItem('<h3>To Unsubscribe Members...</h3>')
< container.AddItem("""
< To unsubscribe members you must use your admin password in place of the
< user's password on the user's edit-options page. Visit their
< edit-options page (via the <a href="%s">roster</a> page) and do the
< unsubscribe procedure, providing the admin password instead of the
< user's password.
< <p>(Note that you can, alternately, set the subscriber's no-delivery
< option to inhibit delivery of their messages, if you want to only
< temporarily disable their delivery.)<p>"""
< % lst.GetScriptURL('roster'))
---
> user_table = Table(width="90%")
> user_table.AddRow([Center(Header(4, "Membership List"))])
> user_table.AddCellInfo(user_table.GetCurrentRowIndex(),
> user_table.GetCurrentCellIndex(),
> bgcolor="#cccccc", colspan=8)
>
> members = {}
> digests = {}
> for member in lst.members:
> members[member] = 1
> for member in lst.digest_members:
> digests[member] = 1
> all = lst.members + lst.digest_members
> if len(all) > mm_cfg.ADMIN_MEMBER_CHUNKSIZE:
> chunks = mm_utils.chunkify(all)
> if not cgi_data.has_key("chunk"):
> chunk = 0
> else:
> chunk = string.atoi(cgi_data["chunk"].value)
> all = chunks[chunk]
> footer = ("<p><em>To View other sections, "
> "click on the appropriate range listed below</em>")
> chunk_indices = range(len(chunks))
> chunk_indices.remove(chunk)
> buttons = []
> pi = os.environ["PATH_INFO"]
> for ci in chunk_indices:
> start, end = chunks[ci][0], chunks[ci][-1]
> buttons.append("<a href=/mailman/admin%s?chunk=%d> from %s to %s </a>" % \
> ( pi, ci, start, end))
> buttons = apply(UnorderedList, tuple(buttons))
> footer = footer + buttons.Format() + "<p>"
> else:
> all.sort()
> footer = "<p>"
> for member in all:
> cells = [member + "<input type=hidden name=user value=%s>" % (member),
> "subscribed " +CheckBox(member + "_subscribed", "on", 1).Format(),
> ]
> if members.get(member):
> cells.append("digest " + CheckBox(member + "_digest", "off", 0).Format())
> else:
> cells.append("digest " + CheckBox(member + "_digest", "on", 1).Format())
> for opt in ("hide", "nomail", "ack", "norcv", "plain"):
> if lst.GetUserOption(member, mm_mailcmd.option_info[opt]):
> value = "on"
> checked = 1
> else:
> value = "off"
> checked = 0
> box = CheckBox("%s_%s" % (member, opt), value, checked)
> cells.append("%s %s" % (opt, box.Format()))
> user_table.AddRow(cells)
> container.AddItem(Center(user_table))
> container.AddItem(footer)
> t = Table(width="90%")
> t.AddRow([Center(Header(4, "Mass Subscribe Members"))])
> t.AddCellInfo(t.GetCurrentRowIndex(),
> t.GetCurrentCellIndex(),
> bgcolor="#cccccc", colspan=8)
> container.AddItem(Center(t))
> container.AddItem(Center(TextArea(name='subscribees', rows=10,cols=60,wrap=None)))
> container.AddItem(Center("<em> Enter One address per line</em><p>"))
448,471c544,561
< password_submit = Table(bgcolor="#99cccc",
< border=0, cellspacing=0, cellpadding=2)
< password_submit.AddRow([Center(Bold('To Submit Your Changes'))])
< password_submit.AddCellInfo(password_submit.GetCurrentRowIndex(), 0,
< colspan=2)
< password_submit.AddRow(['<div ALIGN="right">Enter the administrator '
< 'password:</div>',
< PasswordBox('adminpw')])
< password_submit.AddRow(['<div ALIGN="right">And...</div>',
< Bold(SubmitButton('submit', 'Submit Changes'))])
< change_pw_table = Table(bgcolor="#cccccc", border=0,
< cellspacing=0, cellpadding=2)
< change_pw_table.AddRow([Bold(Center('To Change The Administrator'
< ' Password'))])
< change_pw_table.AddCellInfo(change_pw_table.GetCurrentRowIndex(), 0,
< colspan=2)
< change_pw_table.AddRow(['<div ALIGN="right">Enter the new '
< 'password:</div>',
< PasswordBox('newpw')])
< change_pw_table.AddRow(['<div ALIGN="right">And also confirm it:</div>',
< PasswordBox('confirmpw')])
<
< password_stuff = Table(bgcolor="#99cccc")
< password_stuff.AddRow([password_submit, change_pw_table])
---
> submit = Table(bgcolor="#99ccff",
> border=0, cellspacing=0, cellpadding=2, width="100%")
> submit.AddRow([Bold(SubmitButton('submit', 'Submit Your Changes'))])
> submit.AddCellInfo(submit.GetCurrentRowIndex(), 0, align="middle")
> change_pw_table = Table(bgcolor="#99cccc", border=0,
> cellspacing=0, cellpadding=2, width="90%")
> change_pw_table.AddRow([Bold(Center('To Change The Administrator Password')),
> '<div ALIGN="right"> Enter the new password: </div>',
> PasswordBox('newpw'),])
> change_pw_table.AddCellInfo(0, 0, align="middle", colspan=2)
> change_pw_table.AddRow(['<div ALIGN="right"> Enter the current password </div>',
> PasswordBox('adminpw'),
> '<div ALIGN="right">Again to confirm it:</div>',
> PasswordBox('confirmpw')])
> password_stuff = Container()
> password_stuff.AddItem(change_pw_table)
> password_stuff.AddItem("<p>")
> password_stuff.AddItem(submit)
555c645,676
< if category != 'members':
---
> confirmed = 0
> if cgi_info.has_key('newpw'):
> if cgi_info.has_key('confirmpw'):
> if cgi_info.has_key('adminpw'):
> try:
> lst.ConfirmAdminPassword(cgi_info['adminpw'].value)
> confirmed = 1
> except mm_err.MMBadPasswordError:
> m = "Error: incorrect administrator password"
> document.AddItem(Header(3, Italic(FontAttr(m, color="ff5060"))))
> confirmed = 0
> if confirmed:
> new = cgi_info['newpw'].value
> confirm = cgi_info['confirmpw'].value
> if new == confirm:
> lst.password = crypt.crypt(new, mm_utils.GetRandomSeed())
> dirty = 1
> else:
> m = 'Error: Passwords did not match.'
> document.AddItem(Header(3, Italic(FontAttr(m, color="ff5060"))))
>
> else:
> m = 'Error: You must type in your new password twice.'
> document.AddItem(
> Header(3, Italic(FontAttr(m, color="ff5060"))))
> #
> # for some reason, the login page mangles important values for the list
> # such as .real_name so we only process these changes if the category
> # is not "members" and the request is not from the login page
> # -scott 19980515
> #
> if category != 'members' and not cgi_info.has_key("request_login"):
572a694
> print "property: ", property, "value: ", value
574a697,699
> #
> # mass subscription processing for members category
> #
577,578c702,713
< names = string.split(name_text, '\r\n')
< for new_name in names:
---
> name_text = string.replace(name_text, '\r', '')
> names = string.split(name_text, '\n')
> if '' in names:
> names.remove('')
> subscribe_success = []
> subscribe_errors = []
> for new_name in map(string.strip,names):
> digest = 0
> if not lst.digestable:
> digest = 0
> if not lst.nondigestable:
> digest = 1
580,593c715,747
< #FIXME: The admin needs to be able to specify subscribe options
< lst.AddMember(new_name, (mm_utils.GetRandomSeed() +
< mm_utils.GetRandomSeed()))
< dirty = 1
< #FIXME: Give some sort of an indication of which names didn't work,
< # and why they didn't work...
< except:
< pass
< if cgi_info.has_key('newpw'):
< if cgi_info.has_key('confirmpw'):
< new = cgi_info['newpw'].value
< confirm = cgi_info['confirmpw'].value
< if new == confirm:
< lst.password = crypt.crypt(new, mm_utils.GetRandomSeed())
---
> lst.ApprovedAddMember(new_name, (mm_utils.GetRandomSeed() +
> mm_utils.GetRandomSeed()), digest)
> subscribe_success.append(new_name)
> except mm_err.MMAlreadyAMember:
> subscribe_errors.append((new_name, 'Already a member'))
>
> except mm_err.MMBadEmailError:
> subscribe_errors.append((new_name, "Bad/Invalid email address"))
> except mm_err.MMHostileAddress:
> subscribe_errors.append((new_name, "Hostile Address (illegal characters)"))
> if subscribe_success:
> document.AddItem(Header(5, "Successfully Subscribed:"))
> document.AddItem(apply(UnorderedList, tuple((subscribe_success))))
> document.AddItem("<p>")
> if subscribe_errors:
> document.AddItem(Header(5, "Error Subscribing:"))
> items = map(lambda x: "%s -- %s" % (x[0], x[1]), subscribe_errors)
> document.AddItem(apply(UnorderedList, tuple((items))))
> document.AddItem("<p>")
> #
> # do the user options for members category
> #
> if cgi_info.has_key('user'):
> user = cgi_info["user"]
> if type(user) is type([]):
> users = []
> for ui in range(len(user)):
> users.append(user[ui].value)
> else:
> users = [user.value]
> for user in users:
> if not cgi_info.has_key('%s_subscribed' % (user)):
> lst.DeleteMember(user)
595,598c749,771
< else:
< m = 'Error: Passwords did not match.'
< document.AddItem(
< Header(3, Italic(FontAttr(m, color="ff5060"))))
---
> continue
> if not cgi_info.has_key("%s_digest" % (user)):
> if user in lst.digest_members:
> list.digest_members.remove(user)
> dirty = 1
> if user not in lst.members:
> lst.members.append(user)
> dirty = 1
> else:
> if user not in lst.digest_members:
> lst.digest_members.append(user)
> dirty = 1
> if user in lst.members:
> lst.members.remove(user)
> dirty = 1
>
> for opt in ("hide", "nomail", "ack", "norcv", "plain"):
> if cgi_info.has_key("%s_%s" % (user, opt)):
> lst.SetUserOption(user, mm_mailcmd.option_info[opt], 1)
> dirty = 1
> else:
> lst.SetUserOption(user, mm_mailcmd.option_info[opt], 0)
> dirty = 1
600,603d772
< else:
< m = 'Error: You must type in your new password twice.'
< document.AddItem(
< Header(3, Italic(FontAttr(m, color="ff5060"))))
622a792
>
Index: cgi/private
===================================================================
RCS file: /usr/local/cvsroot/mailman/cgi/private,v
retrieving revision 1.1.1.1
diff -r1.1.1.1 private
62a63
> # "
68,75c69,76
< (?: / (?: \d{4} q \d\. )? # Match "/", and, optionally, 1998q1."
< ( [^/]* ) /? # The SIG name
< /[^/]*$ # The trailing 12345.html portion
< ) | (?:
< / ( [^/.]* ) # Match matrix-sig
< (?:\.html)? # Optionally match .html
< /? # Optionally match a trailing slash
< $ # Must match to end of string
---
> (?: / (?: \d{4} q \d\. )? # Match "/", and, optionally, 1998q1.
> ( [^/]* ) /? # The SIG name
> /[^/]*$ # The trailing 12345.html portion
> ) | (?:
> / ( [^/.]* ) # Match matrix-sig
> (?:\.html)? # Optionally match .html
> /? # Optionally match a trailing slash
> $ # Must match to end of string
87,93d87
< #for i in ['/matrix-sig.html', '/1998q1.c++-sig/index.html',
< # '/1998q1.string-sig/foobar.html',
< # '/psa-members.html']:
< # print i, `getListName(i)`
< #sys.exit(0)
<
< ## sys.exit(0)
Index: modules/htmlformat.py
===================================================================
RCS file: /usr/local/cvsroot/mailman/modules/htmlformat.py,v
retrieving revision 1.1.1.1
diff -r1.1.1.1 htmlformat.py
391a392,396
> class CheckBox(InputObj):
> def __init__(self, name, value, checked=0, **kws):
> apply(InputObj.__init__, (self, name, "CHECKBOX", value, checked), kws)
>
>
446a452
>
Index: modules/maillist.py
===================================================================
RCS file: /usr/local/cvsroot/mailman/modules/maillist.py,v
retrieving revision 1.3
diff -r1.3 maillist.py
60,61c60,65
< return os.path.join(self.web_page_url, '%s/%s' %
< (script_name, self._internal_name))
---
> if self.web_page_url:
> prefix = self.web_page_url
> else:
> prefix = mm_cfg.DEFAULT_URL
> return os.path.join(prefix, '%s/%s' % (script_name,
> self._internal_name))
566a571,572
> if not mm_utils.ValidEmail(name):
> raise mm_err.MMBadEmailError
Index: modules/mm_defaults.py
===================================================================
RCS file: /usr/local/cvsroot/mailman/modules/mm_defaults.py,v
retrieving revision 1.3
diff -r1.3 mm_defaults.py
156a157,168
>
> #
> # how long the cookie authorizing administrative
> # changes via the admin cgi lasts
> #
> ADMIN_COOKIE_LIFE = 60 * 20 # 20 minutes
>
> #
> # how many members to display at a time
> # on the admin cgi to unsubscribe them or change their options
> #
> ADMIN_MEMBER_CHUNKSIZE = 10
Index: modules/mm_utils.py
===================================================================
RCS file: /usr/local/cvsroot/mailman/modules/mm_utils.py,v
retrieving revision 1.2
diff -r1.2 mm_utils.py
390a391,404
>
> def chunkify(members, chunksize=mm_cfg.ADMIN_MEMBER_CHUNKSIZE):
> """
> return a list of lists of members
> """
> members.sort()
> res = []
> while 1:
> if not members:
> break
> chunk = members[:chunksize]
> res.append(chunk)
> members = members[chunksize:]
> return res