view roundup/cgi/TAL/talgettext.py @ 8411:ef1ea918b07a reauth-confirm_id

feat(security): Add user confirmation/reauth for sensitive changes Auditors can raise Reauth(reason) exception to require the user to enter a token (e.g. account password) to verify the user is performing the change. Naming is subject to change. actions.py: New ReauthAction class handler and verifyPassword() method for overriding if needed. client.py: Handle Reauth exception by calling Client:reauth() method. Default client:reauth method. Add 'reauth' action declaration. exceptions.py: Define and document Reauth exception as a subclass of RoundupCGIException. templating.py: Define method utils.embed_form_fields(). The original form making a change to the database has a lot of form fields. These need to be resubmitted to Roundup as part of the form submission that verifies the user's password. This method turns all non file form fields into type=hidden inputs. It escapes the names and values to prevent XSS. For file form fields, it base64 encodes the contents and puts them in hidden pre blocks. The pre blocks have data attributes for the filename, filetype and the original field name. (Note the original field name is not used.) This stops the file content data (maybe binary e.g. jpegs) from breaking the html page. The reauth template runs JavaScript that turns the encoded data inside the pre tags back into a file. Then it adds a multiple file input control to the page and attaches all the files to it. This file input is submitted with the rest of the fields. _generic.reauth.html (multiple tracker templates): Generates a form with id=reauth_form to: display any message from the Reauth exception to the user (e.g. why user is asked to auth). get the user's password submit the form embed all the form data that triggered the reauth recreate any file data that was submitted as part of the form and generate a new file input to push the data to the back end It has the JavaScript routine (as an IIFE) that regenerates a file input without user intervention. All the TAL based tracker templates use the same form. There is also one for the jinja2 template. The JavaScript for both is the same. reference.txt: document embed_form_fields utility method. upgrading.txt: initial upgrading docs. TODO: Finalize naming. I am leaning toward ConfirmID rather than Reauth. Still looking for a standard name for this workflow. Externalize the javascript in _generic.reauth.html to a seperate file and use utils.readfile() to embed it or change the script to load it from a @@file url. Clean up upgrading.txt with just steps to implement and less feature detail/internals. Document internals/troubleshooting in reference.txt. Add tests using live server.
author John Rouillard <rouilj@ieee.org>
date Mon, 11 Aug 2025 14:01:12 -0400
parents f2fade4552c5
children 9c3ec0a5c7fc
line wrap: on
line source

#!/usr/bin/env python
##############################################################################
#
# Copyright (c) 2002 Zope Corporation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
# Modifications for Roundup:
# 1. commented out ITALES references
# 2. escape quotes and line feeds in msgids
# 3. don't collect empty msgids

"""Program to extract internationalization markup from Page Templates.

Once you have marked up a Page Template file with i18n: namespace tags, use
this program to extract GNU gettext .po file entries.

Usage: talgettext.py [options] files
Options:
    -h / --help
        Print this message and exit.
    -o / --output <file>
        Output the translation .po file to <file>.
    -u / --update <file>
        Update the existing translation <file> with any new translation strings
        found.
"""

from __future__ import print_function
import sys
import time
import getopt
import traceback

from roundup import __version__
from roundup.cgi.TAL.HTMLTALParser import HTMLTALParser
from roundup.cgi.TAL.TALInterpreter import TALInterpreter
from roundup.cgi.TAL.DummyEngine import DummyEngine
#from ITALES import ITALESEngine
from roundup.cgi.TAL.TALDefs import TALESError

pot_header = '''\
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\\n"
"POT-Creation-Date: %(time)s\\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
"Language-Team: LANGUAGE <LL@li.org>\\n"
"MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=CHARSET\\n"
"Content-Transfer-Encoding: ENCODING\\n"
"Generated-By: talgettext.py %(version)s\\n"
'''

NLSTR = '"\n"'

def usage(code, msg=''):
    # Python 2.1 required
    print(__doc__, file=sys.stderr)
    if msg:
        print(msg, file=sys.stderr)
    sys.exit(code)


class POTALInterpreter(TALInterpreter):
    def translate(self, msgid, default, i18ndict=None, obj=None):
        # XXX is this right?
        if i18ndict is None:
            i18ndict = {}
        if obj:
            i18ndict.update(obj)
        # XXX Mmmh, it seems that sometimes the msgid is None; is that really
        # possible?
        if msgid is None:
            return None
        # XXX We need to pass in one of context or target_language
        return self.engine.translate(msgid, self.i18nContext.domain, i18ndict,
                                     position=self.position, default=default)


class POEngine(DummyEngine):
    #__implements__ = ITALESEngine

    def __init__(self, macros=None):
        self.catalog = {}
        DummyEngine.__init__(self, macros)

    def evaluate(*args):
        return '' # who cares

    def evaluatePathOrVar(*args):
        return '' # who cares

    def evaluateSequence(self, expr):
        return (0,) # dummy

    def evaluateBoolean(self, expr):
        return True # dummy

    def translate(self, msgid, domain=None, mapping=None, default=None,
                  # XXX position is not part of the ITALESEngine
                  #     interface
                  position=None):

        if not msgid: return 'x'

        if domain not in self.catalog:
            self.catalog[domain] = {}
        domain = self.catalog[domain]

        if msgid not in domain:
            domain[msgid] = []
        domain[msgid].append((self.file, position))
        return 'x'


class UpdatePOEngine(POEngine):
    """A slightly-less braindead POEngine which supports loading an existing
    .po file first."""

    def __init__ (self, macros=None, filename=None):
        POEngine.__init__(self, macros)

        self._filename = filename
        self._loadFile()
        self.base = self.catalog
        self.catalog = {}

    def __add(self, id, s, fuzzy):
        "Add a non-fuzzy translation to the dictionary."
        if not fuzzy and str:
            # check for multi-line values and munge them appropriately
            if '\n' in s:
                lines = s.rstrip().split('\n')
                s = NLSTR.join(lines)
            self.catalog[id] = s

    def _loadFile(self):
        # shamelessly cribbed from Python's Tools/i18n/msgfmt.py
        # 25-Mar-2003 Nathan R. Yergler (nathan@zope.org)
        # 14-Apr-2003 Hacked by Barry Warsaw (barry@zope.com)

        ID = 1
        STR = 2

        try:
            lines = open(self._filename).readlines()
        except IOError as msg:
            print(msg, file=sys.stderr)
            sys.exit(1)

        section = None
        fuzzy = False

        # Parse the catalog
        lno = 0
        for l in lines:
            lno += True
            # If we get a comment line after a msgstr, this is a new entry
            if l[0] == '#' and section == STR:
                self.__add(msgid, msgstr, fuzzy)
                section = None
                fuzzy = False
            # Record a fuzzy mark
            if l[:2] == '#,' and l.find('fuzzy'):
                fuzzy = True
            # Skip comments
            if l[0] == '#':
                continue
            # Now we are in a msgid section, output previous section
            if l.startswith('msgid'):
                if section == STR:
                    self.__add(msgid, msgstr, fuzzy)
                section = ID
                l = l[5:]
                msgid = msgstr = ''
            # Now we are in a msgstr section
            elif l.startswith('msgstr'):
                section = STR
                l = l[6:]
            # Skip empty lines
            if not l.strip():
                continue
            # XXX: Does this always follow Python escape semantics?
            l = eval(l)
            if section == ID:
                msgid += l
            elif section == STR:
                msgstr += '%s\n' % l
            else:
                print('Syntax error on %s:%d' % (infile, lno),
                      'before:', file=sys.stderr)
                print(l, file=sys.stderr)
                sys.exit(1)
        # Add last entry
        if section == STR:
            self.__add(msgid, msgstr, fuzzy)

    def evaluate(self, expression):
        try:
            return POEngine.evaluate(self, expression)
        except TALESError:
            pass

    def evaluatePathOrVar(self, expr):
        return 'who cares'

    def translate(self, msgid, domain=None, mapping=None, default=None,
                  position=None):
        if msgid not in self.base:
            POEngine.translate(self, msgid, domain, mapping, default, position)
        return 'x'


def main():
    try:
        opts, args = getopt.getopt(
            sys.argv[1:],
            'ho:u:',
            ['help', 'output=', 'update='])
    except getopt.error as msg:
        usage(1, msg)

    outfile = None
    engine = None
    update_mode = False
    for opt, arg in opts:
        if opt in ('-h', '--help'):
            usage(0)
        elif opt in ('-o', '--output'):
            outfile = arg
        elif opt in ('-u', '--update'):
            update_mode = True
            if outfile is None:
                outfile = arg
            engine = UpdatePOEngine(filename=arg)

    if not args:
        print('nothing to do')
        return

    # We don't care about the rendered output of the .pt file
    class Devnull:
        def write(self, s):
            pass

    # check if we've already instantiated an engine;
    # if not, use the stupidest one available
    if not engine:
        engine = POEngine()

    # process each file specified
    for filename in args:
        try:
            engine.file = filename
            p = HTMLTALParser()
            p.parseFile(filename)
            program, macros = p.getCode()
            POTALInterpreter(program, macros, engine, stream=Devnull(),
                             metal=False)()
        except: # Hee hee, I love bare excepts!
            print('There was an error processing', filename)
            traceback.print_exc()

    # Now output the keys in the engine.  Write them to a file if --output or
    # --update was specified; otherwise use standard out.
    if (outfile is None):
        outfile = sys.stdout
    else:
        outfile = open(outfile, update_mode and "a" or "w")

    catalog = {}
    for domain in engine.catalog.keys():
        catalog.update(engine.catalog[domain])

    messages = catalog.copy()
    try:
        messages.update(engine.base)
    except AttributeError:
        pass
    if '' not in messages:
        print(pot_header % {'time': time.ctime(),
                            'version': __version__}, file=outfile)

    # XXX: You should not sort by msgid, but by filename and position. (SR)
    msgids = sorted(catalog.keys())
    for msgid in msgids:
        positions = catalog[msgid]
        for filename, position in positions:
            outfile.write('#: %s:%s\n' % (filename, position[0]))

        outfile.write('msgid "%s"\n'
            % msgid.replace('"', '\\"').replace("\n", '\\n"\n"'))
        outfile.write('msgstr ""\n')
        outfile.write('\n')


if __name__ == '__main__':
    main()

Roundup Issue Tracker: http://roundup-tracker.org/