From 329eea2862f90682c08d896243b4da95945027c1 Mon Sep 17 00:00:00 2001 From: glondu Date: Fri, 1 Jun 2007 15:00:42 +0200 Subject: [PATCH] Import initial des fichiers de la version 1.5.3 de MoinMoin (mj Etch). darcs-hash:20070601130042-68412-6e583291d0079b28e4c0cc18a7c8428051d37cb0.gz --- wiki/request.py | 1573 ++++++++++++++++++++++++++-------------------- wiki/user.py | 683 +++++++++++++------- wiki/userform.py | 569 ++++++++++------- wiki/wikiacl.py | 70 +-- 4 files changed, 1730 insertions(+), 1165 deletions(-) diff --git a/wiki/request.py b/wiki/request.py index 62845603..26849f25 100644 --- a/wiki/request.py +++ b/wiki/request.py @@ -7,17 +7,10 @@ @license: GNU GPL, see COPYING for details. """ -import os, time, sys, types, cgi -from MoinMoin import config, wikiutil, user, error -from MoinMoin.util import MoinMoinNoFooter, IsWin9x, FixScriptName - -# this needs sitecustomize.py in python path to work! -# use our encoding as default encoding: -## sys.setappdefaultencoding(config.charset) -# this is the default python uses without this hack: -## sys.setappdefaultencoding("ascii") -# we could maybe use this to find places where implicit encodings are used: -## sys.setappdefaultencoding("undefined") +import os, re, time, sys, cgi, StringIO +import copy +from MoinMoin import config, wikiutil, user, caching +from MoinMoin.util import MoinMoinNoFooter, IsWin9x # Timing --------------------------------------------------------------- @@ -46,10 +39,21 @@ class Clock: return outlist +# Utilities + +def cgiMetaVariable(header, scheme='http'): + """ Return CGI meta variable for header name + + e.g 'User-Agent' -> 'HTTP_USER_AGENT' + See http://www.faqs.org/rfcs/rfc3875.html section 4.1.18 + """ + var = '%s_%s' % (scheme, header) + return var.upper().replace('-', '_') + # Request Base ---------------------------------------------------------- -class RequestBase: +class RequestBase(object): """ A collection for all data associated with ONE request. """ # Header set to force misbehaved proxies and browsers to keep their @@ -61,18 +65,33 @@ class RequestBase: "Expires: -1", ] + # Defaults (used by sub classes) + http_accept_language = 'en' + server_name = 'localhost' + server_port = '80' + + # Extra headers we support. Both standalone and twisted store + # headers as lowercase. + moin_location = 'x-moin-location' + proxy_host = 'x-forwarded-host' + def __init__(self, properties={}): + # Decode values collected by sub classes + self.path_info = self.decodePagename(self.path_info) + self.failed = 0 self._available_actions = None self._known_actions = None # Pages meta data that we collect in one request self.pages = {} - + self.sent_headers = 0 self.user_headers = [] + self.cacheable = 0 # may this output get cached by http proxies/caches? self.page = None - + self._dicts = None + # Fix dircaching problems on Windows 9x if IsWin9x(): import dircache @@ -83,15 +102,18 @@ class RequestBase: # not on external non-Apache servers self.forbidden = False if self.request_uri.startswith('http://'): - self.makeForbidden() + self.makeForbidden403() # Init else: self.writestack = [] self.clock = Clock() # order is important here! + self.__dict__.update(properties) self._load_multi_cfg() + self.isSpiderAgent = self.check_spider() + # Set decode charsets. Input from the user is always in # config.charset, which is the page charsets. Except # path_info, which may use utf-8, and handled by decodePagename. @@ -112,70 +134,143 @@ class RequestBase: # # no extra path after script name # rootname = u"" + self.args = {} + self.form = {} + + # MOVED: this was in run() method, but moved here for auth module being able to use it + if not self.query_string.startswith('action=xmlrpc'): + self.args = self.form = self.setup_args() + rootname = u'' self.rootpage = Page(self, rootname, is_rootpage=1) - self.user = user.User(self) - ## self.dicts = self.initdicts() + self.user = self.get_user_from_form() + + if not self.query_string.startswith('action=xmlrpc'): + if not self.forbidden and self.isForbidden(): + self.makeForbidden403() + if not self.forbidden and self.surge_protect(): + self.makeUnavailable503() from MoinMoin import i18n - # Set theme - forced theme, user theme or wiki default - if self.cfg.theme_force: - theme_name = self.cfg.theme_default - #### DEBUT HACK : Utilisation d'un thème différent pour www.crans.org - if self.remote_addr in self.cfg.ip_theme.keys(): - theme_name = self.cfg.ip_theme[self.remote_addr] - #### FIN DU HACK - else: - theme_name = self.user.theme_name - self.loadTheme(theme_name) - - self.args = None - self.form = None self.logger = None self.pragma = {} self.mode_getpagelinks = 0 self.no_closing_html_code = 0 - self.__dict__.update(properties) - self.i18n = i18n self.lang = i18n.requestLanguage(self) # Language for content. Page content should use the wiki # default lang, but generated content like search results # should use the user language. - self.content_lang = self.cfg.default_lang + self.content_lang = self.cfg.language_default self.getText = lambda text, i18n=self.i18n, request=self, lang=self.lang, **kv: i18n.getText(text, request, lang, kv.get('formatted', True)) self.opened_logs = 0 - self.reset() - def __getattr__(self, name): - "load things on demand" - if name == "dicts": - self.dicts = self.initdicts() - return self.dicts - raise AttributeError + def surge_protect(self): + """ check if someone requesting too much from us """ + validuser = self.user.valid + current_id = validuser and self.user.name or self.remote_addr + if not validuser and current_id.startswith('127.'): # localnet + return False + current_action = self.form.get('action', ['show'])[0] + + limits = self.cfg.surge_action_limits + default_limit = self.cfg.surge_action_limits.get('default', (30, 60)) + + now = int(time.time()) + surgedict = {} + surge_detected = False + + try: + cache = caching.CacheEntry(self, 'surgeprotect', 'surge-log') + if cache.exists(): + data = cache.content() + data = data.split("\n") + for line in data: + try: + id, t, action, surge_indicator = line.split("\t") + t = int(t) + maxnum, dt = limits.get(action, default_limit) + if t >= now - dt: + events = surgedict.setdefault(id, copy.copy({})) + timestamps = events.setdefault(action, copy.copy([])) + timestamps.append((t, surge_indicator)) + except StandardError, err: + pass + + maxnum, dt = limits.get(current_action, default_limit) + events = surgedict.setdefault(current_id, copy.copy({})) + timestamps = events.setdefault(current_action, copy.copy([])) + surge_detected = len(timestamps) > maxnum + surge_indicator = surge_detected and "!" or "" + timestamps.append((now, surge_indicator)) + if surge_detected: + if len(timestamps) < maxnum*2: + timestamps.append((now + self.cfg.surge_lockout_time, surge_indicator)) # continue like that and get locked out + + if current_action != 'AttachFile': # don't add AttachFile accesses to all or picture galleries will trigger SP + current_action = 'all' # put a total limit on user's requests + maxnum, dt = limits.get(current_action, default_limit) + events = surgedict.setdefault(current_id, copy.copy({})) + timestamps = events.setdefault(current_action, copy.copy([])) + surge_detected = surge_detected or len(timestamps) > maxnum + + surge_indicator = surge_detected and "!" or "" + timestamps.append((now, surge_indicator)) + if surge_detected: + if len(timestamps) < maxnum*2: + timestamps.append((now + self.cfg.surge_lockout_time, surge_indicator)) # continue like that and get locked out + + data = [] + for id, events in surgedict.items(): + for action, timestamps in events.items(): + for t, surge_indicator in timestamps: + data.append("%s\t%d\t%s\t%s" % (id, t, action, surge_indicator)) + data = "\n".join(data) + cache.update(data) + except StandardError, err: + pass + + return surge_detected + + def getDicts(self): + """ Lazy initialize the dicts on the first access """ + if self._dicts is None: + from MoinMoin import wikidicts + dicts = wikidicts.GroupDict(self) + dicts.scandicts() + self._dicts = dicts + return self._dicts + + def delDicts(self): + """ Delete the dicts, used by some tests """ + del self._dicts + self._dicts = None + + dicts = property(getDicts, None, delDicts) + def _load_multi_cfg(self): # protect against calling multiple times if not hasattr(self, 'cfg'): from MoinMoin import multiconfig self.cfg = multiconfig.getConfig(self.url) - def parse_accept_charset(self, accept_charset): - """ Parse http accept charset header + def setAcceptedCharsets(self, accept_charset): + """ Set accepted_charsets by parsing accept-charset header Set self.accepted_charsets to an ordered list based on http_accept_charset. Reference: http://www.w3.org/Protocols/rfc2616/rfc2616.txt - @param accept_charset: HTTP_ACCEPT_CHARSET string - @rtype: list of strings - @return: sorted list of accepted charsets + TODO: currently no code use this value. + + @param accept_charset: accept-charset header """ charsets = [] if accept_charset: @@ -198,117 +293,246 @@ class RequestBase: # Remove *, its not clear what we should do with it later charsets = [name for qval, name in charsets if name != '*'] - return charsets - + self.accepted_charsets = charsets + def _setup_vars_from_std_env(self, env): - """ Sets the common Request members by parsing a standard - HTTPD environment (as created as environment by most common - webservers. To be used by derived classes. + """ Set common request variables from CGI environment + + Parse a standard CGI environment as created by common web + servers. Reference: http://www.faqs.org/rfcs/rfc3875.html - @param env: the environment to use + @param env: dict like object containing cgi meta variables """ - self.http_accept_language = env.get('HTTP_ACCEPT_LANGUAGE', 'en') - self.server_name = env.get('SERVER_NAME', 'localhost') - self.server_port = env.get('SERVER_PORT', '80') - self.http_host = env.get('HTTP_HOST','localhost') - # Make sure http referer use only ascii (IE again) - self.http_referer = unicode(env.get('HTTP_REFERER', ''), 'ascii', - 'replace').encode('ascii', 'replace') + # Values we can just copy + self.env = env + self.http_accept_language = env.get('HTTP_ACCEPT_LANGUAGE', + self.http_accept_language) + self.server_name = env.get('SERVER_NAME', self.server_name) + self.server_port = env.get('SERVER_PORT', self.server_port) self.saved_cookie = env.get('HTTP_COOKIE', '') self.script_name = env.get('SCRIPT_NAME', '') - - self.request_uri = env.get('REQUEST_URI', '') - path_info = env.get('PATH_INFO', '') - - query_string = env.get('QUERY_STRING', '') - self.query_string = self.decodePagename(query_string) - server_software = env.get('SERVER_SOFTWARE', '') - - # Handle the strange charset semantics on *Windows* - # path_info is transformed into the system code page by the webserver - # Additionally, paths containing dots let most webservers choke. - - # Fig. I - Broken environment variables in different environments: - # path_info script_name - # Apache1 X X PI does not contain dots - # Apache2 X X PI is not encoded correctly - # IIS X X (is fixed somewhere else) - # Other ? - ? := Possible and even RFC-compatible. - # - := Hopefully not. - - # Fix the script_name if run on Windows/Apache - if os.name == 'nt' and server_software.find('Apache/') != -1: - self.script_name = FixScriptName(self.script_name) - - # Check if we can use Apache's request_uri variable in order to - # gather the correct user input - if (os.name != 'posix' and self.request_uri != ''): - import urllib - path_info = urllib.unquote(self.request_uri.replace( - self.script_name, '', 1).replace('?' + query_string, '', 1)) - - # Decode according to filesystem semantics if we cannot gather the - # request_uri - elif os.name == 'nt': - path_info = wikiutil.decodeWindowsPath(path_info).encode("utf-8") - - self.path_info = self.decodePagename(path_info) + self.path_info = env.get('PATH_INFO', '') + self.query_string = env.get('QUERY_STRING', '') self.request_method = env.get('REQUEST_METHOD', None) - self.remote_addr = env.get('REMOTE_ADDR', '') self.http_user_agent = env.get('HTTP_USER_AGENT', '') - self.is_ssl = env.get('SSL_PROTOCOL', '') != '' \ - or env.get('SSL_PROTOCOL_VERSION', '') != '' \ - or env.get('HTTPS', 'off') == 'on' - # We cannot rely on request_uri being set. In fact, - # it is just an addition of Apache to the CGI specs. - if self.request_uri == '': - import urllib - if self.server_port.strip() and self.server_port != '80': - port = ':' + str(self.server_port) - else: - port = '' - self.url = (self.server_name + port + self.script_name + - urllib.quote(self.path_info.replace( - self.script_name, '', 1).encode("utf-8")) - + '?' + self.query_string) + # REQUEST_URI is not part of CGI spec, but an addition of Apache. + self.request_uri = env.get('REQUEST_URI', '') + + # Values that need more work + self.setHttpReferer(env.get('HTTP_REFERER')) + self.setIsSSL(env) + self.setHost(env.get('HTTP_HOST')) + self.fixURI(env) + self.setURL(env) + + ##self.debugEnvironment(env) + + def setHttpReferer(self, referer): + """ Set http_referer, making sure its ascii + + IE might send non-ascii value. + """ + value = '' + if referer: + value = unicode(referer, 'ascii', 'replace') + value = value.encode('ascii', 'replace') + self.http_referer = value + + def setIsSSL(self, env): + """ Set is_ssl + + @param env: dict like object containing cgi meta variables + """ + self.is_ssl = bool(env.get('SSL_PROTOCOL') or + env.get('SSL_PROTOCOL_VERSION') or + env.get('HTTPS') == 'on') + + def setHost(self, host=None): + """ Set http_host + + Create from server name and port if missing. Previous code + default to localhost. + """ + if not host: + port = '' + standardPort = ('80', '443')[self.is_ssl] + if self.server_port != standardPort: + port = ':' + self.server_port + host = self.server_name + port + self.http_host = host + + def fixURI(self, env): + """ Fix problems with script_name and path_info + + Handle the strange charset semantics on Windows and other non + posix systems. path_info is transformed into the system code + page by the web server. Additionally, paths containing dots let + most webservers choke. + + Broken environment variables in different environments: + path_info script_name + Apache1 X X PI does not contain dots + Apache2 X X PI is not encoded correctly + IIS X X path_info include script_name + Other ? - ? := Possible and even RFC-compatible. + - := Hopefully not. + + @param env: dict like object containing cgi meta variables + """ + # Fix the script_name when using Apache on Windows. + server_software = env.get('SERVER_SOFTWARE', '') + if os.name == 'nt' and server_software.find('Apache/') != -1: + # Removes elements ending in '.' from the path. + self.script_name = '/'.join([x for x in self.script_name.split('/') + if not x.endswith('.')]) + + # Fix path_info + if os.name != 'posix' and self.request_uri != '': + # Try to recreate path_info from request_uri. + import urlparse + scriptAndPath = urlparse.urlparse(self.request_uri)[2] + path = scriptAndPath.replace(self.script_name, '', 1) + self.path_info = wikiutil.url_unquote(path, want_unicode=False) + elif os.name == 'nt': + # Recode path_info to utf-8 + path = wikiutil.decodeWindowsPath(self.path_info) + self.path_info = path.encode("utf-8") + + # Fix bug in IIS/4.0 when path_info contain script_name + if self.path_info.startswith(self.script_name): + self.path_info = self.path_info[len(self.script_name):] + + def setURL(self, env): + """ Set url, used to locate wiki config + + This is the place to manipulate url parts as needed. + + @param env: dict like object containing cgi meta variables or + http headers. + """ + # If we serve on localhost:8000 and use a proxy on + # example.com/wiki, our urls will be example.com/wiki/pagename + # Same for the wiki config - they must use the proxy url. + self.rewriteHost(env) + self.rewriteURI(env) + + if not self.request_uri: + self.request_uri = self.makeURI() + self.url = self.http_host + self.request_uri + + def rewriteHost(self, env): + """ Rewrite http_host transparently + + Get the proxy host using 'X-Forwarded-Host' header, added by + Apache 2 and other proxy software. + + TODO: Will not work for Apache 1 or others that don't add this + header. + + TODO: If we want to add an option to disable this feature it + should be in the server script, because the config is not + loaded at this point, and must be loaded after url is set. + + @param env: dict like object containing cgi meta variables or + http headers. + """ + proxy_host = (env.get(self.proxy_host) or + env.get(cgiMetaVariable(self.proxy_host))) + if proxy_host: + self.http_host = proxy_host + + def rewriteURI(self, env): + """ Rewrite request_uri, script_name and path_info transparently + + Useful when running mod python or when running behind a proxy, + e.g run on localhost:8000/ and serve as example.com/wiki/. + + Uses private 'X-Moin-Location' header to set the script name. + This allow setting the script name when using Apache 2 + directive:: + + + RequestHeader set X-Moin-Location /my/wiki/ + + + TODO: does not work for Apache 1 and others that do not allow + setting custom headers per request. + + @param env: dict like object containing cgi meta variables or + http headers. + """ + location = (env.get(self.moin_location) or + env.get(cgiMetaVariable(self.moin_location))) + if location is None: + return + + scriptAndPath = self.script_name + self.path_info + location = location.rstrip('/') + self.script_name = location + + # This may happen when using mod_python + if scriptAndPath.startswith(location): + self.path_info = scriptAndPath[len(location):] + + # Recreate the URI from the modified parts + if self.request_uri: + self.request_uri = self.makeURI() + + def makeURI(self): + """ Return uri created from uri parts """ + uri = self.script_name + wikiutil.url_quote(self.path_info) + if self.query_string: + uri += '?' + self.query_string + return uri + + def splitURI(self, uri): + """ Return path and query splited from uri + + Just like CGI environment, the path is unquoted, the query is + not. + """ + if '?' in uri: + path, query = uri.split('?', 1) else: - self.url = self.server_name + self.request_uri - - ac = env.get('HTTP_ACCEPT_CHARSET', '') - self.accepted_charsets = self.parse_accept_charset(ac) - - self.auth_username = None + path, query = uri, '' + return wikiutil.url_unquote(path, want_unicode=False), query - # need config here, so check: - self._load_multi_cfg() + def get_user_from_form(self): + """ read the maybe present UserPreferences form and call get_user with the values """ + name = self.form.get('name', [None])[0] + password = self.form.get('password', [None])[0] + login = self.form.has_key('login') + logout = self.form.has_key('logout') + u = self.get_user_default_unknown(name=name, password=password, + login=login, logout=logout, + user_obj=None) + return u + + def get_user_default_unknown(self, **kw): + """ call do_auth and if it doesnt return a user object, make some "Unknown User" """ + user_obj = self.get_user_default_None(**kw) + if user_obj is None: + user_obj = user.User(self, auth_method="request:427") + return user_obj - if self.cfg.auth_http_enabled: - auth_type = env.get('AUTH_TYPE','') - if auth_type in ['Basic', 'Digest', 'NTLM', ]: - username = env.get('REMOTE_USER','') - if auth_type == 'NTLM': - # converting to standard case so that the user can even enter wrong case - # (added since windows does not distinguish between e.g. "Mike" and "mike") - username = username.split('\\')[-1] # split off domain e.g. from DOMAIN\user - # this "normalizes" the login name from {meier, Meier, MEIER} to Meier - # put a comment sign in front of next line if you don't want that: - username = username.title() - self.auth_username = username - -## f=open('/tmp/env.log','a') -## f.write('---ENV\n') -## f.write('script_name = %s\n'%(self.script_name)) -## f.write('path_info = %s\n'%(self.path_info)) -## f.write('server_name = %s\n'%(self.server_name)) -## f.write('server_port = %s\n'%(self.server_port)) -## f.write('http_host = %s\n'%(self.http_host)) -## f.write('------\n') -## f.write('%s\n'%(repr(env))) -## f.write('------\n') -## f.close() - + def get_user_default_None(self, **kw): + """ loop over auth handlers, return a user obj or None """ + name = kw.get('name') + password = kw.get('password') + login = kw.get('login') + logout = kw.get('logout') + user_obj = kw.get('user_obj') + for auth in self.cfg.auth: + user_obj, continue_flag = auth(self, + name=name, password=password, + login=login, logout=logout, + user_obj=user_obj) + if not continue_flag: + break + return user_obj + def reset(self): """ Reset request state. @@ -320,9 +544,8 @@ class RequestBase: # This is the content language and has nothing to do with # The user interface language. The content language can change # during the rendering of a page by lang macros - self.current_lang = self.cfg.default_lang + self.current_lang = self.cfg.language_default - self._footer_fragments = {} self._all_pages = None # caches unique ids self._page_ids = {} @@ -340,22 +563,29 @@ class RequestBase: @param theme_name: the name of the theme @type theme_name: str - @returns: 0 on success, 1 if user theme could not be loaded, - 2 if a hard fallback to modern theme was required. @rtype: int @return: success code + 0 on success + 1 if user theme could not be loaded, + 2 if a hard fallback to modern theme was required. """ fallback = 0 - Theme = wikiutil.importPlugin(self.cfg, 'theme', theme_name, 'Theme') - if Theme is None: + if theme_name == "": + theme_name = self.cfg.theme_default + + try: + Theme = wikiutil.importPlugin(self.cfg, 'theme', theme_name, + 'Theme') + except wikiutil.PluginMissingError: fallback = 1 - Theme = wikiutil.importPlugin(self.cfg, 'theme', - self.cfg.theme_default, 'Theme') - if Theme is None: + try: + Theme = wikiutil.importPlugin(self.cfg, 'theme', + self.cfg.theme_default, 'Theme') + except wikiutil.PluginMissingError: fallback = 2 from MoinMoin.theme.modern import Theme + self.theme = Theme(self) - return fallback def setContentLanguage(self, lang): @@ -368,12 +598,6 @@ class RequestBase: self.content_lang = lang self.current_lang = lang - def add2footer(self, key, htmlcode): - """ Add a named HTML fragment to the footer, after the default links - """ - self._footer_fragments[key] = htmlcode - - def getPragma(self, key, defval=None): """ Query a pragma value (#pragma processing instruction) @@ -381,7 +605,6 @@ class RequestBase: """ return self.pragma.get(key.lower(), defval) - def setPragma(self, key, value): """ Set a pragma value (#pragma processing instruction) @@ -399,16 +622,29 @@ class RequestBase: return '' return self.script_name + def getPageNameFromQueryString(self): + """ Try to get pagename from the query string + + Support urls like http://netloc/script/?page_name. Allow + solving path_info encoding problems by calling with the page + name as a query. + """ + pagename = wikiutil.url_unquote(self.query_string, want_unicode=False) + pagename = self.decodePagename(pagename) + pagename = self.normalizePagename(pagename) + return pagename + def getKnownActions(self): """ Create a dict of avaiable actions - Return cached version if avaiable. TODO: when we have a wiki - object in long running process, we should get it from it. + Return cached version if avaiable. @rtype: dict @return: dict of all known actions """ - if self._known_actions is None: + try: + self.cfg._known_actions # check + except AttributeError: from MoinMoin import wikiaction # Add built in actions from wikiaction actions = [name[3:] for name in wikiaction.__dict__ @@ -424,10 +660,10 @@ class RequestBase: # TODO: Use set when we require Python 2.3 actions = dict(zip(actions, [''] * len(actions))) - self._known_actions = actions + self.cfg._known_actions = actions # Return a copy, so clients will not change the dict. - return self._known_actions.copy() + return self.cfg._known_actions.copy() def getAvailableActions(self, page): """ Get list of avaiable actions for this request @@ -440,7 +676,7 @@ class RequestBase: @return: dict of avaiable actions """ if self._available_actions is None: - # Add actions for existing pages only!, incliding deleted pages. + # Add actions for existing pages only, including deleted pages. # Fix *OnNonExistingPage bugs. if not (page.exists(includeDeleted=1) and self.user.may.read(page.page_name)): @@ -453,20 +689,18 @@ class RequestBase: del actions[key] # Filter wiki excluded actions - for key in self.cfg.excluded_actions: + for key in self.cfg.actions_excluded: if key in actions: del actions[key] # Filter actions by page type, acl and user state excluded = [] if ((page.isUnderlayPage() and not page.isStandardPage()) or - not self.user.may.write(page.page_name)): + not self.user.may.write(page.page_name) or + not self.user.may.delete(page.page_name)): # Prevent modification of underlay only pages, or pages - # the user can't write to + # the user can't write and can't delete excluded = [u'RenamePage', u'DeletePage',] # AttachFile must NOT be here! - elif not self.user.valid: - # Prevent rename and delete for non registered users - excluded = [u'RenamePage', u'DeletePage'] for key in excluded: if key in actions: del actions[key] @@ -475,12 +709,25 @@ class RequestBase: # Return a copy, so clients will not change the dict. return self._available_actions.copy() - + + def redirectedOutput(self, function, *args, **kw): + """ Redirect output during function, return redirected output """ + buffer = StringIO.StringIO() + self.redirect(buffer) + try: + function(*args, **kw) + finally: + self.redirect() + text = buffer.getvalue() + buffer.close() + return text + def redirect(self, file=None): - if file: # redirect output to "file" + """ Redirect output to file, or restore saved output """ + if file: self.writestack.append(self.write) self.write = file.write - else: # restore saved output file + else: self.write = self.writestack.pop() def reset_output(self): @@ -514,7 +761,7 @@ class RequestBase: wd = [] for d in data: try: - if isinstance(d, type(u'')): + if isinstance(d, unicode): # if we are REALLY sure, we can use "strict" d = d.encode(config.charset, 'replace') wd.append(d) @@ -548,7 +795,7 @@ class RequestBase: try: page = unicode(page, 'utf-8', 'strict') # Fit data into config.charset, replacing what won't - # fit. Better have few "?" in the name then crash. + # fit. Better have few "?" in the name than crash. page = page.encode(config.charset, 'replace') except UnicodeError: pass @@ -619,133 +866,174 @@ class RequestBase: """ raise NotImplementedError - def initdicts(self): - from MoinMoin import wikidicts - dicts = wikidicts.GroupDict(self) - dicts.scandicts() - return dicts - + def check_spider(self): + """ check if the user agent for current request is a spider/bot """ + isSpider = False + spiders = self.cfg.ua_spiders + if spiders: + ua = self.getUserAgent() + if ua: + isSpider = re.search(spiders, ua, re.I) is not None + return isSpider + def isForbidden(self): """ check for web spiders and refuse anything except viewing """ forbidden = 0 - # we do not have a parsed query string here - # so we can just do simple matching - if ((self.query_string != '' or self.request_method != 'GET') and - self.query_string != 'action=rss_rc' and not + # we do not have a parsed query string here, so we can just do simple matching + qs = self.query_string + if ((qs != '' or self.request_method != 'GET') and + not 'action=rss_rc' in qs and # allow spiders to get attachments and do 'show' - (self.query_string.find('action=AttachFile') >= 0 and self.query_string.find('do=get') >= 0) and not - (self.query_string.find('action=show') >= 0) + not ('action=AttachFile' in qs and 'do=get' in qs) and + not 'action=show' in qs ): - from MoinMoin.util import web - forbidden = web.isSpiderAgent(self) + forbidden = self.isSpiderAgent if not forbidden and self.cfg.hosts_deny: ip = self.remote_addr for host in self.cfg.hosts_deny: - if ip == host or host[-1] == '.' and ip.startswith(host): + if host[-1] == '.' and ip.startswith(host): forbidden = 1 + #self.log("hosts_deny (net): %s" % str(forbidden)) + break + if ip == host: + forbidden = 1 + #self.log("hosts_deny (ip): %s" % str(forbidden)) break return forbidden - def setup_args(self, form=None): - return {} + """ Return args dict + + In POST request, invoke _setup_args_from_cgi_form to handle + possible file uploads. For other request simply parse the query + string. + + Warning: calling with a form might fail, depending on the type + of the request! Only the request know which kind of form it can + handle. + + TODO: The form argument should be removed in 1.5. + """ + if form is not None or self.request_method == 'POST': + return self._setup_args_from_cgi_form(form) + args = cgi.parse_qs(self.query_string, keep_blank_values=1) + return self.decodeArgs(args) def _setup_args_from_cgi_form(self, form=None): - """ Setup args from a FieldStorage form + """ Return args dict from a FieldStorage - Create the args from a standard cgi.FieldStorage to be used by - derived classes, or from given form. + Create the args from a standard cgi.FieldStorage or from given + form. Each key contain a list of values. - All values are decoded using config.charset. - - @keyword form: a cgi.FieldStorage + @param form: a cgi.FieldStorage @rtype: dict - @return dict with form keys, each contains a list of values + @return: dict with form keys, each contains a list of values """ - decode = wikiutil.decodeUserInput - - # Use cgi.FieldStorage by default if form is None: form = cgi.FieldStorage() args = {} - # Convert form keys to dict keys, each key contains a list of - # values. - for key in form.keys(): + for key in form: values = form[key] - if not isinstance(values, types.ListType): + if not isinstance(values, list): values = [values] fixedResult = [] - for i in values: - if isinstance(i, cgi.MiniFieldStorage): - data = decode(i.value, self.decode_charsets) - elif isinstance(i, cgi.FieldStorage): - data = i.value - if i.filename: - # multiple uploads to same form field are stupid! - args[key+'__filename__'] = decode(i.filename, self.decode_charsets) - else: - # we do not have a file upload, so we decode: - data = decode(data, self.decode_charsets) - # Append decoded value - fixedResult.append(data) - + for item in values: + fixedResult.append(item.value) + if isinstance(item, cgi.FieldStorage) and item.filename: + # Save upload file name in a separate key + args[key + '__filename__'] = item.filename args[key] = fixedResult - return args + return self.decodeArgs(args) + + def decodeArgs(self, args): + """ Decode args dict + + Decoding is done in a separate path because it is reused by + other methods and sub classes. + """ + decode = wikiutil.decodeUserInput + result = {} + for key in args: + if key + '__filename__' in args: + # Copy file data as is + result[key] = args[key] + elif key.endswith('__filename__'): + result[key] = decode(args[key], self.decode_charsets) + else: + result[key] = [decode(value, self.decode_charsets) + for value in args[key]] + return result def getBaseURL(self): """ Return a fully qualified URL to this script. """ return self.getQualifiedURL(self.getScriptname()) + def getQualifiedURL(self, uri=''): + """ Return an absolute URL starting with schema and host. - def getQualifiedURL(self, uri=None): - """ Return a full URL starting with schema, server name and port. + Already qualified urls are returned unchanged. - *uri* -- append this server-rooted uri (must start with a slash) + @param uri: server rooted uri e.g /scriptname/pagename. It + must start with a slash. Must be ascii and url encoded. """ - if uri and uri[:4] == "http": + import urlparse + scheme = urlparse.urlparse(uri)[0] + if scheme: return uri - schema, stdport = (('http', '80'), ('https', '443'))[self.is_ssl] - host = self.http_host - if not host: - host = self.server_name - port = self.server_port - if port != stdport: - host = "%s:%s" % (host, port) - - result = "%s://%s" % (schema, host) - if uri: - result = result + uri - - return result + scheme = ('http', 'https')[self.is_ssl] + result = "%s://%s%s" % (scheme, self.http_host, uri) + # This might break qualified urls in redirects! + # e.g. mapping 'http://netloc' -> '/' + return wikiutil.mapURL(self, result) def getUserAgent(self): """ Get the user agent. """ return self.http_user_agent - def makeForbidden(self): - self.forbidden = True + def makeForbidden(self, resultcode, msg): + statusmsg = { + 403: 'FORBIDDEN', + 503: 'Service unavailable', + } self.http_headers([ - 'Status: 403 FORBIDDEN', + 'Status: %d %s' % (resultcode, statusmsg[resultcode]), 'Content-Type: text/plain' ]) - self.write('You are not allowed to access this!\r\n') - self.setResponseCode(403) - return self.finish() + self.write(msg) + self.setResponseCode(resultcode) + self.forbidden = True + + def makeForbidden403(self): + self.makeForbidden(403, 'You are not allowed to access this!\r\n') + + def makeUnavailable503(self): + self.makeForbidden(503, "Warning:\r\n" + "You triggered the wiki's surge protection by doing too many requests in a short time.\r\n" + "Please make a short break reading the stuff you already got.\r\n" + "When you restart doing requests AFTER that, slow down or you might get locked out for a longer time!\r\n") + + def initTheme(self): + """ Set theme - forced theme, user theme or wiki default """ + if self.cfg.theme_force: + theme_name = self.cfg.theme_default + else: + theme_name = self.user.theme_name + self.loadTheme(theme_name) def run(self): - # __init__ may have failed + # Exit now if __init__ failed or request is forbidden if self.failed or self.forbidden: - return - - if self.isForbidden(): - self.makeForbidden() - if self.forbidden: - return + #Don't sleep()! Seems to bind too much resources, so twisted will + #run out of threads, files, whatever (with low CPU load) and stop + #serving requests. + #if self.forbidden: + # time.sleep(10) # let the sucker wait! + return self.finish() self.open_logs() _ = self.getText @@ -766,8 +1054,12 @@ class RequestBase: # parse request data try: - self.args = self.setup_args() - self.form = self.args + self.initTheme() + + # MOVED: moved to __init__() for auth module being able to use it + #self.args = self.setup_args() + #self.form = self.args + action = self.form.get('action',[None])[0] # Get pagename @@ -777,13 +1069,7 @@ class RequestBase: pagename = self.normalizePagename(path) else: pagename = None - except: # catch and print any exception - self.reset_output() - self.http_headers() - self.print_exception() - return self.finish() - try: # Handle request. We have these options: # 1. If user has a bad user name, delete its bad cookie and @@ -792,7 +1078,7 @@ class RequestBase: msg = _("""Invalid user name {{{'%s'}}}. Name may contain any Unicode alpha numeric character, with optional one space between words. Group page name is not allowed.""") % self.user.name - self.deleteCookie() + self.user = self.get_user_default_unknown(name=self.user.name, logout=True) page = wikiutil.getSysPage(self, 'UserPreferences') page.send_page(self, msg=msg) @@ -801,7 +1087,11 @@ space between words. Group page name is not allowed.""") % self.user.name pagetrail = self.user.getTrail() if pagetrail: # Redirect to last page visited - url = Page(self, pagetrail[-1]).url(self) + if ":" in pagetrail[-1]: + wikitag, wikiurl, wikitail, error = wikiutil.resolve_wiki(self, pagetrail[-1]) + url = wikiurl + wikiutil.quoteWikinameURL(wikitail) + else: + url = Page(self, pagetrail[-1]).url(self) else: # Or to localized FrontPage url = wikiutil.getFrontPage(self).url(self) @@ -837,9 +1127,7 @@ space between words. Group page name is not allowed.""") % self.user.name msg = _("You are not allowed to do %s on this page.") % wikiutil.escape(action) if not self.user.valid: # Suggest non valid user to login - login = wikiutil.getSysPage(self, 'UserPreferences') - login = login.link_to(self, _('Login')) - msg += _(" %s and try again.", formatted=0) % login + msg += _(" %s and try again.", formatted=0) % _('Login') # XXX merge into 1 string after 1.5.3 release self.page.send_page(self, msg=msg) # Try action @@ -855,32 +1143,15 @@ space between words. Group page name is not allowed.""") % self.user.name # 6. Or (at last) visit pagename else: - if not pagename: - # Get pagename from the query string - pagename = self.normalizePagename(self.query_string) - + if not pagename and self.query_string: + pagename = self.getPageNameFromQueryString() # pagename could be empty after normalization e.g. '///' -> '' if not pagename: pagename = wikiutil.getFrontPage(self).page_name # Visit pagename - if self.cfg.allow_extended_names: - self.page = Page(self, pagename) - self.page.send_page(self, count_hit=1) - else: - # TODO: kill this. Why disable allow extended names? - from MoinMoin.parser.wiki import Parser - import re - word_match = re.match(Parser.word_rule, pagename) - if word_match: - word = word_match.group(0) - self.page = Page(self, word) - self.page.send_page(self, count_hit=1) - else: - self.http_headers() - err = u'

%s "

%s
"' % ( - _("Can't work out query"), pagename) - self.write(err) + self.page = Page(self, pagename) + self.page.send_page(self, count_hit=1) # generate page footer (actions that do not want this footer # use raise util.MoinMoinNoFooter to break out of the @@ -898,78 +1169,47 @@ space between words. Group page name is not allowed.""") % self.user.name for t in self.clock.dump(): self.write('
  • %s
  • \n' % t) self.write('\n') - + #self.write('' % repr(self.user.auth_method)) self.write('\n\n\n') except MoinMoinNoFooter: pass - - except error.FatalError, err: + except Exception, err: self.fail(err) - - except: # catch and print any exception - saved_exc = sys.exc_info() - self.reset_output() - self.http_headers() - self.write(u"\n\n") - try: - from MoinMoin.support import cgitb - except: - # no cgitb, for whatever reason - self.print_exception(*saved_exc) - else: - try: - cgitb.Hook(file=self).handle(saved_exc) - # was: cgitb.handler() - except: - self.print_exception(*saved_exc) - self.write("\n\n
    \n") - self.write("

    Additionally, cgitb raised this exception:

    \n") - self.print_exception() - del saved_exc return self.finish() def http_redirect(self, url): - """ Redirect to a fully qualified, or server-rooted URL """ - if url.find("://") == -1: - url = self.getQualifiedURL(url) + """ Redirect to a fully qualified, or server-rooted URL + + @param url: relative or absolute url, ascii using url encoding. + """ + url = self.getQualifiedURL(url) + self.http_headers(["Status: 302 Found", "Location: %s" % url]) - #### DEBUT HACK : pour le www, on redirige vers du www - if self.remote_addr in self.cfg.ip_url_replace.keys(): - url = url.replace(self.cfg.ip_url_replace[self.remote_addr][0],self.cfg.ip_url_replace[self.remote_addr][1]) - #### FIN DU HACK - - self.http_headers(["Status: 302", "Location: %s" % url]) + def setHttpHeader(self, header): + """ Save header for later send. """ + self.user_headers.append(header) def setResponseCode(self, code, message=None): pass def fail(self, err): - """ Fail with nice error message when we can't continue + """ Fail when we can't continue - Log the error, then try to print nice error message. + Send 500 status code with the error name. Reference: + http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1.1 - @param err: MoinMoin.error.FatalError instance or subclass. + Log the error, then let failure module handle it. + + @param err: Exception instance or subclass. """ - self.failed = 1 # save state for self.run() - self.log(err.asLog()) - self.http_headers() - self.write(err.asHTML()) - return self.finish() - - def print_exception(self, type=None, value=None, tb=None, limit=None): - if type is None: - type, value, tb = sys.exc_info() - import traceback - self.write("

    request.print_exception handler

    \n") - self.write("

    Traceback (most recent call last):

    \n") - list = traceback.format_tb(tb, limit) + \ - traceback.format_exception_only(type, value) - self.write("
    %s%s
    \n" % ( - wikiutil.escape("".join(list[:-1])), - wikiutil.escape(list[-1]),)) - del tb + self.failed = 1 # save state for self.run() + self.http_headers(['Status: 500 MoinMoin Internal Error']) + self.setResponseCode(500) + self.log('%s: %s' % (err.__class__.__name__, str(err))) + from MoinMoin import failure + failure.handle(self) def open_logs(self): pass @@ -985,11 +1225,11 @@ space between words. Group page name is not allowed.""") % self.user.name @returns: an unique id @rtype: unicode """ - if not isinstance(base, types.UnicodeType): - base=unicode(str(base),'ascii','ignore') - count = self._page_ids.get(base,-1) + 1 + if not isinstance(base, unicode): + base = unicode(str(base), 'ascii', 'ignore') + count = self._page_ids.get(base, -1) + 1 self._page_ids[base] = count - if count==0: + if count == 0: return base return u'%s_%04d' % (base, count) @@ -998,33 +1238,39 @@ space between words. Group page name is not allowed.""") % self.user.name See http://www.cse.ohio-state.edu/cgi-bin/rfc/rfc2068.html#sec-3.3 - Http 1.1 server should use only rfc1123 date, but cookies - expires field should use older rfc850 date. + A http 1.1 server should use only rfc1123 date, but cookie's + "expires" field should use the older obsolete rfc850 date. + + Note: we can not use strftime() because that honors the locale + and rfc2822 requires english day and month names. + + We can not use email.Utils.formatdate because it formats the + zone as '-0000' instead of 'GMT', and creates only rfc1123 + dates. This is a modified version of email.Utils.formatdate + from Python 2.4. @param when: seconds from epoch, as returned by time.time() - @param rfc: either '1123' or '850' + @param rfc: conform to rfc ('1123' or '850') @rtype: string - @return: http date + @return: http date conforming to rfc1123 or rfc850 """ if when is None: when = time.time() - if rfc not in ['1123', '850']: - raise ValueError("%s: Invalid rfc value" % rfc) - - import locale - t = time.gmtime(when) - - # TODO: make this a critical section for persistent environments - # Should acquire lock here - loc=locale.setlocale(locale.LC_TIME, 'C') + now = time.gmtime(when) + month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', + 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][now.tm_mon - 1] if rfc == '1123': - date = time.strftime("%A, %d %b %Y %H:%M:%S GMT", t) + day = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][now.tm_wday] + date = '%02d %s %04d' % (now.tm_mday, month, now.tm_year) + elif rfc == '850': + day = ["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday", "Saturday", "Sunday"][now.tm_wday] + date = '%02d-%s-%s' % (now.tm_mday, month, str(now.tm_year)[-2:]) else: - date = time.strftime("%A, %d-%b-%Y %H:%M:%S GMT", t) - locale.setlocale(locale.LC_TIME, loc) - # Should release lock here + raise ValueError("Invalid rfc value: %s" % rfc) - return date + return '%s, %s %02d:%02d:%02d GMT' % (day, date, now.tm_hour, + now.tm_min, now.tm_sec) def disableHttpCaching(self): """ Prevent caching of pages that should not be cached @@ -1052,80 +1298,6 @@ space between words. Group page name is not allowed.""") % self.user.name # Set Pragma for http 1.0 caches # See http://www.cse.ohio-state.edu/cgi-bin/rfc/rfc2068.html#sec-14.32 self.setHttpHeader('Pragma: no-cache') - - def setCookie(self): - """ Set cookie for the current user - - cfg.cookie_lifetime and the user 'remember_me' setting set the - lifetime of the cookie. lifetime in int hours, see table: - - value cookie lifetime - ---------------------------------------------------------------- - = 0 forever, ignoring user 'remember_me' setting - > 0 n hours, or forever if user checked 'remember_me' - < 0 -n hours, ignoring user 'remember_me' setting - - TODO: do we really need this cookie_lifetime setting? - """ - # Calculate cookie maxage and expires - lifetime = int(self.cfg.cookie_lifetime) * 3600 - forever = 10*365*24*3600 # 10 years - now = time.time() - if not lifetime: - maxage = forever - elif lifetime > 0: - if self.user.remember_me: - maxage = forever - else: - maxage = lifetime - elif lifetime < 0: - maxage = (-lifetime) - expires = now + maxage - - # Set the cookie - from Cookie import SimpleCookie - c = SimpleCookie() - c['MOIN_ID'] = self.user.id - c['MOIN_ID']['max-age'] = maxage - c['MOIN_ID']['path'] = self.getScriptname() - # Set expires for older clients - c['MOIN_ID']['expires'] = self.httpDate(when=expires, rfc='850') - self.setHttpHeader(c.output()) - - # Update the saved cookie, so other code works with new setup - self.saved_cookie = c.output() - - # IMPORTANT: Prevent caching of current page and cookie - self.disableHttpCaching() - - def deleteCookie(self): - """ Delete the user cookie by sending expired cookie with null value - - According to http://www.cse.ohio-state.edu/cgi-bin/rfc/rfc2109.html#sec-4.2.2 - Deleted cookie should have Max-Age=0. We also have expires - attribute, which is probably needed for older browsers. - - Finally, delete the saved cookie and create a new user based on - the new settings. - """ - # Set cookie - from Cookie import SimpleCookie - c = SimpleCookie() - c['MOIN_ID'] = '' - c['MOIN_ID']['path'] = self.getScriptname() - c['MOIN_ID']['max-age'] = 0 - # Set expires to one year ago for older clients - yearago = time.time() - (3600 * 24 * 365) - c['MOIN_ID']['expires'] = self.httpDate(when=yearago, rfc='850') - self.setHttpHeader(c.output()) - - # Update saved cookie and set new unregistered user - self.saved_cookie = '' - self.auth_username = '' - self.user = user.User(self) - - # IMPORTANT: Prevent caching of current page and cookie - self.disableHttpCaching() def finish(self): """ General cleanup on end of request @@ -1142,6 +1314,51 @@ space between words. Group page name is not allowed.""") % self.user.name except: pass + # ------------------------------------------------------------------ + # Debug + + def debugEnvironment(self, env): + """ Environment debugging aid """ + # Keep this one name per line so its easy to comment stuff + names = [ +# 'http_accept_language', +# 'http_host', +# 'http_referer', +# 'http_user_agent', +# 'is_ssl', + 'path_info', + 'query_string', +# 'remote_addr', + 'request_method', +# 'request_uri', +# 'saved_cookie', + 'script_name', +# 'server_name', +# 'server_port', + ] + names.sort() + attributes = [] + for name in names: + attributes.append(' %s = %r\n' % (name, + getattr(self, name, None))) + attributes = ''.join(attributes) + + environment = [] + names = env.keys() + names.sort() + for key in names: + environment.append(' %s = %r\n' % (key, env[key])) + environment = ''.join(environment) + + data = '\nRequest Attributes\n%s\nEnviroment\n%s' % (attributes, + environment) + f = open('/tmp/env.log','a') + try: + f.write(data) + finally: + f.close() + + # CGI --------------------------------------------------------------- class RequestCGI(RequestBase): @@ -1149,19 +1366,16 @@ class RequestCGI(RequestBase): def __init__(self, properties={}): try: - self._setup_vars_from_std_env(os.environ) - #sys.stderr.write("----\n") - #for key in os.environ.keys(): - # sys.stderr.write(" %s = '%s'\n" % (key, os.environ[key])) - RequestBase.__init__(self, properties) - # force input/output to binary if sys.platform == "win32": import msvcrt msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - except error.FatalError, err: + self._setup_vars_from_std_env(os.environ) + RequestBase.__init__(self, properties) + + except Exception, err: self.fail(err) def open_logs(self): @@ -1170,9 +1384,6 @@ class RequestCGI(RequestBase): sys.stderr = open(os.path.join(self.cfg.data_dir, 'error.log'), 'at') self.opened_logs = 1 - def setup_args(self, form=None): - return self._setup_args_from_cgi_form(form) - def read(self, n=None): """ Read from input stream. """ @@ -1198,26 +1409,8 @@ class RequestCGI(RequestBase): import errno if ex.errno != errno.EPIPE: raise - # Accessors -------------------------------------------------------- - - def getPathinfo(self): - """ Return the remaining part of the URL. """ - pathinfo = self.path_info - - # Fix for bug in IIS/4.0 - if os.name == 'nt': - scriptname = self.getScriptname() - if pathinfo.startswith(scriptname): - pathinfo = pathinfo[len(scriptname):] - - return pathinfo - # Headers ---------------------------------------------------------- - def setHttpHeader(self, header): - self.user_headers.append(header) - - def http_headers(self, more_headers=[]): # Send only once if getattr(self, 'sent_headers', None): @@ -1254,69 +1447,61 @@ class RequestTwisted(RequestBase): def __init__(self, twistedRequest, pagename, reactor, properties={}): try: self.twistd = twistedRequest - self.http_accept_language = self.twistd.getHeader('Accept-Language') self.reactor = reactor + + # Copy headers + self.http_accept_language = self.twistd.getHeader('Accept-Language') self.saved_cookie = self.twistd.getHeader('Cookie') + self.http_user_agent = self.twistd.getHeader('User-Agent') + + # Copy values from twisted request self.server_protocol = self.twistd.clientproto self.server_name = self.twistd.getRequestHostname().split(':')[0] self.server_port = str(self.twistd.getHost()[2]) self.is_ssl = self.twistd.isSecure() - if self.server_port != ('80', '443')[self.is_ssl]: - self.http_host = self.server_name + ':' + self.server_port - else: - self.http_host = self.server_name - self.script_name = "/" + '/'.join(self.twistd.prepath[:-1]) - path_info = '/' + '/'.join([pagename] + self.twistd.postpath) - self.path_info = self.decodePagename(path_info) + self.path_info = '/' + '/'.join([pagename] + self.twistd.postpath) self.request_method = self.twistd.method self.remote_host = self.twistd.getClient() self.remote_addr = self.twistd.getClientIP() - self.http_user_agent = self.twistd.getHeader('User-Agent') self.request_uri = self.twistd.uri - self.url = self.http_host + self.request_uri # was: self.server_name + self.request_uri + self.script_name = "/" + '/'.join(self.twistd.prepath[:-1]) - ac = self.twistd.getHeader('Accept-Charset') or '' - self.accepted_charsets = self.parse_accept_charset(ac) + # Values that need more work + self.query_string = self.splitURI(self.twistd.uri)[1] + self.setHttpReferer(self.twistd.getHeader('Referer')) + self.setHost() + self.setURL(self.twistd.getAllHeaders()) - qindex = self.request_uri.find('?') - if qindex != -1: - query_string = self.request_uri[qindex+1:] - self.query_string = self.decodePagename(query_string) - else: - self.query_string = '' - self.outputlist = [] - self.auth_username = None - - # need config here, so check: - self._load_multi_cfg() + ##self.debugEnvironment(twistedRequest.getAllHeaders()) - if self.cfg.auth_http_enabled and self.cfg.auth_http_insecure: - self.auth_username = self.twistd.getUser() - # TODO password check, twisted does NOT do that for us - # this maybe requires bigger or critical changes, so we delay that - # to 1.4's new auth stuff - RequestBase.__init__(self, properties) - #print "request.RequestTwisted.__init__: received_headers=\n" + str(self.twistd.received_headers) - except error.FatalError, err: - self.fail(err) + except MoinMoinNoFooter: # might be triggered by http_redirect + self.http_headers() # send headers (important for sending MOIN_ID cookie) + self.finish() + + except Exception, err: + # Wrap err inside an internal error if needed + from MoinMoin import error + if isinstance(err, error.FatalError): + self.delayedError = err + else: + self.delayedError = error.InternalError(str(err)) + + def run(self): + """ Handle delayed errors then invoke base class run """ + if hasattr(self, 'delayedError'): + self.fail(self.delayedError) + return self.finish() + RequestBase.run(self) def setup_args(self, form=None): - args = {} - for key,values in self.twistd.args.items(): - if key[-12:] == "__filename__": - args[key] = wikiutil.decodeUserInput(values, self.decode_charsets) - continue - if not isinstance(values, types.ListType): - values = [values] - fixedResult = [] - for v in values: - if not self.twistd.args.has_key(key+"__filename__"): - v = wikiutil.decodeUserInput(v, self.decode_charsets) - fixedResult.append(v) - args[key] = fixedResult - return args + """ Return args dict + + Twisted already parsed args, including __filename__ hacking, + but did not decoded the values. + """ + return self.decodeArgs(self.twistd.args) def read(self, n=None): """ Read from input stream. @@ -1356,15 +1541,12 @@ class RequestTwisted(RequestBase): # Headers ---------------------------------------------------------- - def setHttpHeader(self, header): - self.user_headers.append(header) - def __setHttpHeader(self, header): if type(header) is unicode: header = header.encode('ascii') key, value = header.split(':',1) value = value.lstrip() - if key.lower()=='set-cookie': + if key.lower() == 'set-cookie': key, value = value.split('=',1) self.twistd.addCookie(key, value) else: @@ -1378,7 +1560,7 @@ class RequestTwisted(RequestBase): have_ct = 0 # set http headers - for header in more_headers + self.user_headers: + for header in more_headers + getattr(self, 'user_headers', []): if header.lower().startswith("content-type:"): # don't send content-type multiple times! if have_ct: continue @@ -1389,13 +1571,11 @@ class RequestTwisted(RequestBase): self.__setHttpHeader("Content-type: text/html;charset=%s" % config.charset) def http_redirect(self, url): - """ Redirect to a fully qualified, or server-rooted URL """ - if url.count("://") == 0: - # no https method?? - url = "http://%s:%s%s" % (self.server_name, self.server_port, url) - - if isinstance(url, type(u'')): - url = url.encode('ascii') + """ Redirect to a fully qualified, or server-rooted URL + + @param url: relative or absolute url, ascii using url encoding. + """ + url = self.getQualifiedURL(url) self.twistd.redirect(url) # calling finish here will send the rest of the data to the next # request. leave the finish call to run() @@ -1411,28 +1591,22 @@ class RequestCLI(RequestBase): """ specialized on command line interface and script requests """ def __init__(self, url='CLI', pagename='', properties={}): - self.http_accept_language = '' self.saved_cookie = '' - self.path_info = self.decodePagename('/' + pagename) + self.path_info = '/' + pagename self.query_string = '' - self.remote_addr = '127.0.0.127' + self.remote_addr = '127.0.0.1' self.is_ssl = 0 - self.auth_username = None self.http_user_agent = 'CLI/Script' - self.outputlist = [] self.url = url - self.accepted_charsets = ['utf-8'] - self.decode_charsets = self.accepted_charsets self.request_method = 'GET' self.request_uri = '/' + pagename # TODO check - self.server_name = 'localhost' - self.server_port = '80' self.http_host = 'localhost' self.http_referer = '' - self.script_name = '' + self.script_name = '.' RequestBase.__init__(self, properties) self.cfg.caching_formats = [] # don't spoil the cache - + self.initTheme() # usually request.run() does this, but we don't use it + def read(self, n=None): """ Read from input stream. """ @@ -1459,26 +1633,19 @@ class RequestCLI(RequestBase): if ex.errno != errno.EPIPE: raise def isForbidden(self): - """ check for web spiders and refuse anything except viewing """ + """ Nothing is forbidden """ return 0 # Accessors -------------------------------------------------------- - def getScriptname(self): - """ Return the scriptname part of the URL ("/path/to/my.cgi"). """ - return '.' - - def getQualifiedURL(self, uri = None): - """ Return a full URL starting with schema, server name and port. - - *uri* -- append this server-rooted uri (must start with a slash) + def getQualifiedURL(self, uri=None): + """ Return a full URL starting with schema and host + + TODO: does this create correct pages when you render wiki pages + within a cli request?! """ return uri - def getBaseURL(self): - """ Return a fully qualified URL to this script. """ - return self.getQualifiedURL(self.getScriptname()) - # Headers ---------------------------------------------------------- def setHttpHeader(self, header): @@ -1488,7 +1655,10 @@ class RequestCLI(RequestBase): pass def http_redirect(self, url): - """ Redirect to a fully qualified, or server-rooted URL """ + """ Redirect to a fully qualified, or server-rooted URL + + TODO: Does this work for rendering redirect pages? + """ raise Exception("Redirect not supported for command line tools!") @@ -1498,7 +1668,8 @@ class RequestStandAlone(RequestBase): """ specialized on StandAlone Server (MoinMoin.server.standalone) requests """ - + script_name = '' + def __init__(self, sa, properties={}): """ @param sa: stand alone server object @@ -1510,24 +1681,8 @@ class RequestStandAlone(RequestBase): self.rfile = sa.rfile self.headers = sa.headers self.is_ssl = 0 - - # Split path and query string and unquote path - # query is unquoted by setup_args - import urllib - if '?' in sa.path: - path, query = sa.path.split('?', 1) - else: - path, query = sa.path, '' - path = urllib.unquote(path) - - #HTTP headers - self.env = {} - for hline in sa.headers.headers: - key = sa.headers.isheader(hline) - if key: - hdr = sa.headers.getheader(key) - self.env[key] = hdr - + + # TODO: remove in 1.5 #accept = [] #for line in sa.headers.getallmatchingheaders('accept'): # if line[:1] in string.whitespace: @@ -1537,77 +1692,69 @@ class RequestStandAlone(RequestBase): # #env['HTTP_ACCEPT'] = ','.join(accept) + # Copy headers + self.http_accept_language = (sa.headers.getheader('accept-language') + or self.http_accept_language) + self.http_user_agent = sa.headers.getheader('user-agent', '') co = filter(None, sa.headers.getheaders('cookie')) - - self.http_accept_language = sa.headers.getheader('Accept-Language') + self.saved_cookie = ', '.join(co) or '' + + # Copy rest from standalone request self.server_name = sa.server.server_name self.server_port = str(sa.server.server_port) - self.http_host = sa.headers.getheader('host') - # Make sure http referer use only ascii - referer = sa.headers.getheader('referer') or '' - self.http_referer = unicode(referer, 'ascii', - 'replace').encode('ascii', 'replace') - self.saved_cookie = ', '.join(co) or '' - self.script_name = '' self.request_method = sa.command self.request_uri = sa.path self.remote_addr = sa.client_address[0] - self.http_user_agent = sa.headers.getheader('user-agent') or '' - # next must be http_host as server_name == reverse_lookup(IP) - if self.http_host: - self.url = self.http_host + self.request_uri - else: - self.url = "localhost" + self.request_uri - ac = sa.headers.getheader('Accept-Charset') or '' - self.accepted_charsets = self.parse_accept_charset(ac) - - # Decode path_info and query string, they may contain non-ascii - self.path_info = self.decodePagename(path) - self.query_string = self.decodePagename(query) + # Values that need more work + self.path_info, self.query_string = self.splitURI(sa.path) + self.setHttpReferer(sa.headers.getheader('referer')) + self.setHost(sa.headers.getheader('host')) + self.setURL(sa.headers) + # TODO: remove in 1.5 # from standalone script: # XXX AUTH_TYPE # XXX REMOTE_USER # XXX REMOTE_IDENT - self.auth_username = None #env['PATH_TRANSLATED'] = uqrest #self.translate_path(uqrest) #host = self.address_string() #if host != self.client_address[0]: # env['REMOTE_HOST'] = host # env['SERVER_PROTOCOL'] = self.protocol_version + + ##self.debugEnvironment(sa.headers) + RequestBase.__init__(self, properties) - except error.FatalError, err: + except Exception, err: self.fail(err) - def setup_args(self, form=None): - self.env['REQUEST_METHOD'] = self.request_method - - # This is a ugly hack to get the query into the environment, due - # to the wired way standalone form is created. - self.env['QUERY_STRING'] = self.query_string.encode(config.charset) - - ct = self.headers.getheader('content-type') - if ct: - self.env['CONTENT_TYPE'] = ct - cl = self.headers.getheader('content-length') - if cl: - self.env['CONTENT_LENGTH'] = cl - - #print "env = ", self.env - #form = cgi.FieldStorage(self, headers=self.env, environ=self.env) - if form is None: - form = cgi.FieldStorage(self.rfile, environ=self.env) - return self._setup_args_from_cgi_form(form) + def _setup_args_from_cgi_form(self, form=None): + """ Override to create standlone form """ + form = cgi.FieldStorage(self.rfile, + headers=self.headers, + environ={'REQUEST_METHOD': 'POST'}) + return RequestBase._setup_args_from_cgi_form(self, form) def read(self, n=None): - """ Read from input stream. + """ Read from input stream + + Since self.rfile.read() will block, content-length will be used + instead. + + TODO: test with n > content length, or when calling several times + with smaller n but total over content length. """ if n is None: - return self.rfile.read() - else: - return self.rfile.read(n) + try: + n = int(self.headers.get('content-length')) + except (TypeError, ValueError): + import warnings + warnings.warn("calling request.read() when content-length is " + "not available will block") + return self.rfile.read() + return self.rfile.read(n) def write(self, *data): """ Write to output stream. @@ -1623,17 +1770,16 @@ class RequestStandAlone(RequestBase): # Headers ---------------------------------------------------------- - def setHttpHeader(self, header): - self.user_headers.append(header) - def http_headers(self, more_headers=[]): if getattr(self, 'sent_headers', None): return + self.sent_headers = 1 - + user_headers = getattr(self, 'user_headers', []) + # check for status header and send it our_status = 200 - for header in more_headers + self.user_headers: + for header in more_headers + user_headers: if header.lower().startswith("status:"): try: our_status = int(header.split(':',1)[1].strip().split(" ", 1)[0]) @@ -1646,7 +1792,7 @@ class RequestStandAlone(RequestBase): # send http headers have_ct = 0 - for header in more_headers + self.user_headers: + for header in more_headers + user_headers: if type(header) is unicode: header = header.encode('ascii') if header.lower().startswith("content-type:"): @@ -1665,7 +1811,6 @@ class RequestStandAlone(RequestBase): #sys.stderr.write(pformat(more_headers)) #sys.stderr.write(pformat(self.user_headers)) - # mod_python/Apache ---------------------------------------------------- class RequestModPy(RequestBase): @@ -1694,13 +1839,50 @@ class RequestModPy(RequestBase): self._setup_vars_from_std_env(env) RequestBase.__init__(self) - except error.FatalError, err: + except Exception, err: self.fail(err) - def setup_args(self, form=None): - """ Sets up args by using mod_python.util.FieldStorage, which - is different to cgi.FieldStorage. So we need a separate - method for this. + def fixURI(self, env): + """ Fix problems with script_name and path_info using + PythonOption directive to rewrite URI. + + This is needed when using Apache 1 or other server which does + not support adding custom headers per request. With mod_python we + can use the PythonOption directive: + + + PythonOption X-Moin-Location /url/to/mywiki/ + + + Note that *neither* script_name *nor* path_info can be trusted + when Moin is invoked as a mod_python handler with apache1, so we + must build both using request_uri and the provided PythonOption. + """ + # Be compatible with release 1.3.5 "Location" option + # TODO: Remove in later release, we should have one option only. + old_location = 'Location' + options_table = self.mpyreq.get_options() + if not hasattr(options_table, 'get'): + options = dict(options_table) + else: + options = options_table + location = options.get(self.moin_location) or options.get(old_location) + if location: + env[self.moin_location] = location + # Try to recreate script_name and path_info from request_uri. + import urlparse + scriptAndPath = urlparse.urlparse(self.request_uri)[2] + self.script_name = location.rstrip('/') + path = scriptAndPath.replace(self.script_name, '', 1) + self.path_info = wikiutil.url_unquote(path, want_unicode=False) + + RequestBase.fixURI(self, env) + + def _setup_args_from_cgi_form(self, form=None): + """ Override to use mod_python.util.FieldStorage + + Its little different from cgi.FieldStorage, so we need to + duplicate the conversion code. """ from mod_python import util if form is None: @@ -1709,27 +1891,22 @@ class RequestModPy(RequestBase): args = {} for key in form.keys(): values = form[key] - if not isinstance(values, types.ListType): + if not isinstance(values, list): values = [values] fixedResult = [] - for i in values: - ## if object has a filename attribute, remember it - ## with a name hack - if hasattr(i,'filename') and i.filename: - args[key+'__filename__']=i.filename - has_fn=1 - else: - has_fn=0 - ## mod_python 2.7 might return strings instead - ## of Field objects - if hasattr(i,'value'): - i = i.value - ## decode if it is not a file - if not has_fn: - i = wikiutil.decodeUserInput(i, self.decode_charsets) - fixedResult.append(i) + + for item in values: + # Remember filenames with a name hack + if hasattr(item, 'filename') and item.filename: + args[key + '__filename__'] = item.filename + # mod_python 2.7 might return strings instead of Field + # objects. + if hasattr(item, 'value'): + item = item.value + fixedResult.append(item) args[key] = fixedResult - return args + + return self.decodeArgs(args) def run(self, req): """ mod_python calls this with its request object. We don't @@ -1801,9 +1978,9 @@ class RequestModPy(RequestBase): """ Sends out headers and possibly sets default content-type and status. - @keyword more_headers: list of strings, defaults to [] + @param more_headers: list of strings, defaults to [] """ - for header in more_headers: + for header in more_headers + getattr(self, 'user_headers', []): self.setHttpHeader(header) # if we don't had an content-type header, set text/html if self._have_ct == 0: @@ -1834,14 +2011,14 @@ class RequestFastCGI(RequestBase): self._setup_vars_from_std_env(env) RequestBase.__init__(self, properties) - except error.FatalError, err: + except Exception, err: self.fail(err) - def setup_args(self, form=None): - """ Use the FastCGI form to setup arguments. """ + def _setup_args_from_cgi_form(self, form=None): + """ Override to use FastCGI form """ if form is None: form = self.fcgform - return self._setup_args_from_cgi_form(form) + return RequestBase._setup_args_from_cgi_form(self, form) def read(self, n=None): """ Read from input stream. @@ -1868,26 +2045,7 @@ class RequestFastCGI(RequestBase): RequestBase.finish(self) self.fcgreq.finish() - # Accessors -------------------------------------------------------- - - def getPathinfo(self): - """ Return the remaining part of the URL. """ - pathinfo = self.path_info - - # Fix for bug in IIS/4.0 - if os.name == 'nt': - scriptname = self.getScriptname() - if pathinfo.startswith(scriptname): - pathinfo = pathinfo[len(scriptname):] - - return pathinfo - # Headers ---------------------------------------------------------- - - def setHttpHeader(self, header): - """ Save header for later send. """ - self.user_headers.append(header) - def http_headers(self, more_headers=[]): """ Send out HTTP headers. Possibly set a default content-type. @@ -1898,7 +2056,7 @@ class RequestFastCGI(RequestBase): have_ct = 0 # send http headers - for header in more_headers + self.user_headers: + for header in more_headers + getattr(self, 'user_headers', []): if type(header) is unicode: header = header.encode('ascii') if header.lower().startswith("content-type:"): @@ -1916,3 +2074,78 @@ class RequestFastCGI(RequestBase): #sys.stderr.write(pformat(more_headers)) #sys.stderr.write(pformat(self.user_headers)) +# WSGI -------------------------------------------------------------- + +class RequestWSGI(RequestBase): + def __init__(self, env): + try: + self.env = env + self.hasContentType = False + + self.stdin = env['wsgi.input'] + self.stdout = StringIO.StringIO() + + self.status = '200 OK' + self.headers = [] + + self._setup_vars_from_std_env(env) + RequestBase.__init__(self, {}) + + except Exception, err: + self.fail(err) + + def setup_args(self, form=None): + if form is None: + form = cgi.FieldStorage(fp=self.stdin, environ=self.env, keep_blank_values=1) + return self._setup_args_from_cgi_form(form) + + def read(self, n=None): + if n is None: + return self.stdin.read() + else: + return self.stdin.read(n) + + def write(self, *data): + self.stdout.write(self.encode(data)) + + def reset_output(self): + self.stdout = StringIO.StringIO() + + def setHttpHeader(self, header): + if type(header) is unicode: + header = header.encode('ascii') + + key, value = header.split(':', 1) + value = value.lstrip() + if key.lower() == 'content-type': + # save content-type for http_headers + if self.hasContentType: + # we only use the first content-type! + return + else: + self.hasContentType = True + + elif key.lower() == 'status': + # save status for finish + self.status = value + return + + self.headers.append((key, value)) + + def http_headers(self, more_headers=[]): + for header in more_headers: + self.setHttpHeader(header) + + if not self.hasContentType: + self.headers.insert(0, ('Content-Type', 'text/html;charset=%s' % config.charset)) + + def flush(self): + pass + + def finish(self): + pass + + def output(self): + return self.stdout.getvalue() + + diff --git a/wiki/user.py b/wiki/user.py index 0455c2a1..22e902b2 100644 --- a/wiki/user.py +++ b/wiki/user.py @@ -6,7 +6,7 @@ @license: GNU GPL, see COPYING for details. """ -import os, string, time, Cookie, sha, codecs +import os, time, sha, codecs try: import cPickle as pickle @@ -14,16 +14,10 @@ except ImportError: import pickle # Set pickle protocol, see http://docs.python.org/lib/node64.html -try: - # Requires 2.3 - PICKLE_PROTOCOL = pickle.HIGHEST_PROTOCOL -except AttributeError: - # Use protocol 1, binary format compatible with all python versions - PICKLE_PROTOCOL = 1 - +PICKLE_PROTOCOL = pickle.HIGHEST_PROTOCOL from MoinMoin import config, caching, wikiutil -from MoinMoin.util import datetime +from MoinMoin.util import filesys, timefuncs def getUserList(request): @@ -36,9 +30,10 @@ def getUserList(request): import re, dircache user_re = re.compile(r'^\d+\.\d+(\.\d+)?$') files = dircache.listdir(request.cfg.user_dir) - userlist = filter(user_re.match, files) + userlist = [f for f in files if user_re.match(f)] return userlist + def getUserId(request, searchName): """ Get the user ID for a specific user NAME. @@ -74,6 +69,7 @@ def getUserId(request, searchName): id = _name2id.get(searchName, None) return id + def getUserIdentification(request, username=None): """ Return user name or IP or '' indicator. @@ -96,7 +92,7 @@ def encodePassword(pwd, charset='utf-8'): Compatible to Apache htpasswd SHA encoding. - When using different encoding then 'utf-8', the encoding might fail + When using different encoding than 'utf-8', the encoding might fail and raise UnicodeError. @param pwd: the cleartext password, (unicode) @@ -115,6 +111,7 @@ def encodePassword(pwd, charset='utf-8'): pwd = sha.new(pwd).digest() pwd = '{SHA}' + base64.encodestring(pwd).rstrip() return pwd + def normalizeName(name): """ Make normalized user name @@ -132,22 +129,26 @@ def normalizeName(name): @rtype: unicode @return: user name that can be used in acl lines """ - # Strip non alpha numeric characters, keep white space - name = ''.join([c for c in name if c.isalnum() or c.isspace()]) + name = name.replace('_', ' ') # we treat _ as a blank + username_allowedchars = "'@." # ' for names like O'Brian or email addresses. + # "," and ":" must not be allowed (ACL delimiters). + # Strip non alpha numeric characters (except username_allowedchars), keep white space + name = ''.join([c for c in name if c.isalnum() or c.isspace() or c in username_allowedchars]) # Normalize white space. Each name can contain multiple # words separated with only one space. name = ' '.join(name.split()) return name - + def isValidName(request, name): """ Validate user name @param name: user name, unicode """ normalized = normalizeName(name) + name = name.replace('_', ' ') # we treat _ as a blank return (name == normalized) and not wikiutil.isGroupPage(request, name) @@ -185,47 +186,49 @@ def decodeList(line): continue items.append(item) return items - + class User: """A MoinMoin User""" - _checkbox_fields = [ - ('edit_on_doubleclick', lambda _: _('Open editor on double click')), - ('remember_last_visit', lambda _: _('Remember last page visited')), - ('show_fancy_links', lambda _: _('Show fancy links')), - ('show_nonexist_qm', lambda _: _('Show question mark for non-existing pagelinks')), - ('show_page_trail', lambda _: _('Show page trail')), - ('show_toolbar', lambda _: _('Show icon toolbar')), - ('show_topbottom', lambda _: _('Show top/bottom links in headings')), - ('show_fancy_diff', lambda _: _('Show fancy diffs')), - ('wikiname_add_spaces', lambda _: _('Add spaces to displayed wiki names')), - ('remember_me', lambda _: _('Remember login information')), - ('want_trivial', lambda _: _('Subscribe to trivial changes')), - ('disabled', lambda _: _('Disable this account forever')), - ] - _transient_fields = ['id', 'valid', 'may', 'auth_username', 'trusted'] - - def __init__(self, request, id=None, name="", password=None, auth_username=""): - """ - Initialize user object + def __init__(self, request, id=None, name="", password=None, auth_username="", **kw): + """ Initialize User object @param request: the request object @param id: (optional) user ID @param name: (optional) user name @param password: (optional) user password - @param auth_username: (optional) already authenticated user name (e.g. apache basic auth) + @param auth_username: (optional) already authenticated user name + (e.g. when using http basic auth) + @keyword auth_method: method that was used for authentication, + default: 'internal' + @keyword auth_attribs: tuple of user object attribute names that are + determined by auth method and should not be + changed by UserPreferences form, default: (). + First tuple element was used for authentication. """ self._cfg = request.cfg self.valid = 0 + self.trusted = 0 self.id = id - if auth_username: - self.auth_username = auth_username - elif request: - self.auth_username = request.auth_username - else: - self.auth_username = "" - self.name = name + self.auth_username = auth_username + self.auth_method = kw.get('auth_method', 'internal') + self.auth_attribs = kw.get('auth_attribs', ()) + + # create some vars automatically + for tuple in self._cfg.user_form_fields: + key = tuple[0] + default = self._cfg.user_form_defaults.get(key, '') + setattr(self, key, default) + + if name: + self.name = name + elif auth_username: # this is needed for user_autocreate + self.name = auth_username + + # create checkbox fields (with default 0) + for key, label in self._cfg.user_checkbox_fields: + setattr(self, key, self._cfg.user_checkbox_defaults.get(key, 0)) self.enc_password = "" if password: @@ -237,69 +240,41 @@ class User: except UnicodeError: pass # Should never happen - self.trusted = 0 - self.email = "" - self.edit_rows = self._cfg.edit_rows #self.edit_cols = 80 self.tz_offset = int(float(self._cfg.tz_offset) * 3600) - self.last_saved = str(time.time()) - self.css_url = "" self.language = "" - self.quicklinks = [] self.date_fmt = "" self.datetime_fmt = "" + self.quicklinks = [] self.subscribed_pages = [] self.theme_name = self._cfg.theme_default - - # if an account is disabled, it may be used for looking up - # id -> username for page info and recent changes, but it - # is not usable for the user any more: - # self.disabled = 0 - # is handled by checkbox now. + self.editor_default = self._cfg.editor_default + self.editor_ui = self._cfg.editor_ui + self.last_saved = str(time.time()) # attrs not saved to profile self._request = request self._trail = [] - # create checkbox fields (with default 0) - for key, label in self._checkbox_fields: - setattr(self, key, 0) - self.wikiname_add_spaces = 1 - self.show_page_trail = 1 - self.show_fancy_links = 1 - #self.show_emoticons = 1 - self.show_toolbar = 1 - self.show_nonexist_qm = self._cfg.nonexist_qm - self.show_fancy_diff = 1 - self.want_trivial = 0 - self.remember_me = 1 - - if not self.id and not self.auth_username: - try: - cookie = Cookie.SimpleCookie(request.saved_cookie) - except Cookie.CookieError: - # ignore invalid cookies, else user can't re login - cookie = None - if cookie and cookie.has_key('MOIN_ID'): - self.id = cookie['MOIN_ID'].value - # we got an already authenticated username: + check_pass = 0 if not self.id and self.auth_username: self.id = getUserId(request, self.auth_username) - + if not password is None: + check_pass = 1 if self.id: - self.load_from_id() + self.load_from_id(check_pass) if self.name == self.auth_username: self.trusted = 1 elif self.name: - self.load() + self.id = getUserId(self._request, self.name) + if self.id: + self.load_from_id(1) + else: + self.id = self.make_id() else: - #!!! this should probably be a hash of REMOTE_ADDR, HTTP_USER_AGENT - # and some other things identifying remote users, then we could also - # use it reliably in edit locking - from random import randint - self.id = "%s.%d" % (str(time.time()), randint(0,65535)) - + self.id = self.make_id() + # "may" so we can say "if user.may.read(pagename):" if self._cfg.SecurityPolicy: self.may = self._cfg.SecurityPolicy(self) @@ -311,40 +286,53 @@ class User: if self.language and not languages.has_key(self.language): self.language = 'en' + def __repr__(self): + return "<%s.%s at 0x%x name:%r id:%s valid:%r>" % ( + self.__class__.__module__, self.__class__.__name__, + id(self), self.name, self.id, self.valid) - def __filename(self): + def make_id(self): + """ make a new unique user id """ + #!!! this should probably be a hash of REMOTE_ADDR, HTTP_USER_AGENT + # and some other things identifying remote users, then we could also + # use it reliably in edit locking + from random import randint + return "%s.%d" % (str(time.time()), randint(0,65535)) + + def create_or_update(self, changed=False): + """ Create or update a user profile + + @param changed: bool, set this to True if you updated the user profile values """ - get filename of the user's file on disk + if self._cfg.user_autocreate: + if not self.valid and not self.disabled or changed: # do we need to save/update? + self.save() # yes, create/update user profile + + def __filename(self): + """ Get filename of the user's file on disk + @rtype: string @return: full path and filename of user account file """ return os.path.join(self._cfg.user_dir, self.id or "...NONE...") + def __bookmark_filename(self): + if self._cfg.interwikiname: + return (self.__filename() + "." + self._cfg.interwikiname + + ".bookmark") + else: + return self.__filename() + ".bookmark" def exists(self): - """ - Do we have a user account for this user? + """ Do we have a user account for this user? @rtype: bool @return: true, if we have a user account """ return os.path.exists(self.__filename()) - - def load(self): - """ - Lookup user ID by user name and load user account. - - Can load user data if the user name is known, but only if the password is set correctly. - """ - self.id = getUserId(self._request, self.name) - if self.id: - self.load_from_id(1) - #print >>sys.stderr, "self.id: %s, self.name: %s" % (self.id, self.name) - def load_from_id(self, check_pass=0): - """ - Load user account data from disk. + """ Load user account data from disk. Can only load user data if the id number is already known. @@ -354,7 +342,8 @@ class User: @param check_pass: If 1, then self.enc_password must match the password in the user account file. """ - if not self.exists(): return + if not self.exists(): + return data = codecs.open(self.__filename(), "r", config.charset).readlines() user_data = {'enc_password': ''} @@ -364,7 +353,7 @@ class User: try: key, val = line.strip().split('=', 1) - if key not in self._transient_fields and key[0] != '_': + if key not in self._cfg.user_transient_fields and key[0] != '_': # Decode list values if key in ['quicklinks', 'subscribed_pages']: val = decodeList(val) @@ -387,6 +376,11 @@ class User: else: self.trusted = 1 + # Remove ignored checkbox values from user data + for key, label in self._cfg.user_checkbox_fields: + if user_data.has_key(key) and key in self._cfg.user_checkbox_disable: + del user_data[key] + # Copy user data into user object for key, val in user_data.items(): vars(self)[key] = val @@ -394,14 +388,14 @@ class User: self.tz_offset = int(self.tz_offset) # Remove old unsupported attributes from user data file. - remove_attributes = ['password', 'passwd', 'show_emoticons'] + remove_attributes = ['passwd', 'show_emoticons'] for attr in remove_attributes: if hasattr(self, attr): delattr(self, attr) changed = 1 # make sure checkboxes are boolean - for key, label in self._checkbox_fields: + for key, label in self._cfg.user_checkbox_fields: try: setattr(self, key, int(getattr(self, key))) except ValueError: @@ -467,7 +461,6 @@ class User: return False, False # First get all available pre13 charsets on this system - import codecs pre13 = ['iso-8859-1', 'iso-8859-2', 'euc-jp', 'gb2312', 'big5',] available = [] for charset in pre13: @@ -496,8 +489,7 @@ class User: return False, False def save(self): - """ - Save user account data to user account file on disk. + """ Save user account data to user account file on disk. This saves all member variables, except "id" and "valid" and those starting with an underscore. @@ -506,9 +498,7 @@ class User: return user_dir = self._cfg.user_dir - if not os.path.isdir(user_dir): - os.mkdir(user_dir, 0777 & config.umask) - os.chmod(user_dir, 0777 & config.umask) + filesys.makeDirs(user_dir) self.last_saved = str(time.time()) @@ -522,7 +512,7 @@ class User: attrs = vars(self).items() attrs.sort() for key, value in attrs: - if key not in self._transient_fields and key[0] != '_': + if key not in self._cfg.user_transient_fields and key[0] != '_': # Encode list values if key in ['quicklinks', 'subscribed_pages']: value = encodeList(value) @@ -538,20 +528,21 @@ class User: if not self.disabled: self.valid = 1 + # ----------------------------------------------------------------- + # Time and date formatting + def getTime(self, tm): - """ - Get time in user's timezone. + """ Get time in user's timezone. @param tm: time (UTC UNIX timestamp) @rtype: int @return: tm tuple adjusted for user's timezone """ - return datetime.tmtuple(tm + self.tz_offset) + return timefuncs.tmtuple(tm + self.tz_offset) def getFormattedDate(self, tm): - """ - Get formatted date adjusted for user's timezone. + """ Get formatted date adjusted for user's timezone. @param tm: time (UTC UNIX timestamp) @rtype: string @@ -562,8 +553,7 @@ class User: def getFormattedDateTime(self, tm): - """ - Get formatted date and time adjusted for user's timezone. + """ Get formatted date and time adjusted for user's timezone. @param tm: time (UTC UNIX timestamp) @rtype: string @@ -572,62 +562,165 @@ class User: datetime_fmt = self.datetime_fmt or self._cfg.datetime_fmt return time.strftime(datetime_fmt, self.getTime(tm)) + # ----------------------------------------------------------------- + # Bookmark - def setBookmark(self, tm=None): - """ - Set bookmark timestamp. + def setBookmark(self, tm): + """ Set bookmark timestamp. - @param tm: time (UTC UNIX timestamp), default: current time + @param tm: timestamp """ if self.valid: - if tm is None: - tm = time.time() - bmfile = open(self.__filename() + ".bookmark", "w") + bm_fn = self.__bookmark_filename() + bmfile = open(bm_fn, "w") bmfile.write(str(tm)+"\n") bmfile.close() try: - os.chmod(self.__filename() + ".bookmark", 0666 & config.umask) + os.chmod(bm_fn, 0666 & config.umask) except OSError: pass - - # XXX Do we need that??? - #try: - # os.utime(self.__filename() + ".bookmark", (tm, tm)) - #except OSError: - # pass - def getBookmark(self): - """ - Get bookmark timestamp. + """ Get bookmark timestamp. @rtype: int - @return: bookmark time (UTC UNIX timestamp) or None + @return: bookmark timestamp or None """ - if self.valid and os.path.exists(self.__filename() + ".bookmark"): - try: - return int(open(self.__filename() + ".bookmark", 'r').readline()) - except (OSError, ValueError): - return None - return None + bm = None + bm_fn = self.__bookmark_filename() + if self.valid and os.path.exists(bm_fn): + try: + bm = long(open(bm_fn, 'r').readline()) # must be long for py 2.2 + except (OSError, ValueError): + pass + return bm def delBookmark(self): - """ - Removes bookmark timestamp. + """ Removes bookmark timestamp. @rtype: int @return: 0 on success, 1 on failure """ + bm_fn = self.__bookmark_filename() if self.valid: - if os.path.exists(self.__filename() + ".bookmark"): + if os.path.exists(bm_fn): try: - os.unlink(self.__filename() + ".bookmark") + os.unlink(bm_fn) except OSError: return 1 return 0 return 1 + # ----------------------------------------------------------------- + # Subscribe + + def getSubscriptionList(self): + """ Get list of pages this user has subscribed to + + @rtype: list + @return: pages this user has subscribed to + """ + return self.subscribed_pages + + def isSubscribedTo(self, pagelist): + """ Check if user subscription matches any page in pagelist. + + The subscription list may contain page names or interwiki page + names. e.g 'Page Name' or 'WikiName:Page_Name' + + TODO: check if its fast enough when calling with many users + from page.getSubscribersList() + + @param pagelist: list of pages to check for subscription + @rtype: bool + @return: if user is subscribed any page in pagelist + """ + if not self.valid: + return False + + import re + # Create a new list with both names and interwiki names. + pages = pagelist[:] + if self._cfg.interwikiname: + pages += [self._interWikiName(pagename) for pagename in pagelist] + # Create text for regular expression search + text = '\n'.join(pages) + + for pattern in self.getSubscriptionList(): + # Try simple match first + if pattern in pages: + return True + # Try regular expression search, skipping bad patterns + try: + pattern = re.compile(r'^%s$' % pattern, re.M) + except re.error: + continue + if pattern.search(text): + return True + + return False + + def subscribe(self, pagename): + """ Subscribe to a wiki page. + + To enable shared farm users, if the wiki has an interwiki name, + page names are saved as interwiki names. + + @param pagename: name of the page to subscribe + @type pagename: unicode + @rtype: bool + @return: if page was subscribed + """ + if self._cfg.interwikiname: + pagename = self._interWikiName(pagename) + + if pagename not in self.subscribed_pages: + self.subscribed_pages.append(pagename) + self.save() + return True + + return False + + def unsubscribe(self, pagename): + """ Unsubscribe a wiki page. + + Try to unsubscribe by removing non-interwiki name (leftover + from old use files) and interwiki name from the subscription + list. + + Its possible that the user will be subscribed to a page by more + then one pattern. It can be both pagename and interwiki name, + or few patterns that all of them match the page. Therefore, we + must check if the user is still subscribed to the page after we + try to remove names from the list. + + TODO: should we remove non-interwiki subscription? what if the + user want to subscribe to the same page in multiple wikis? + + @param pagename: name of the page to subscribe + @type pagename: unicode + @rtype: bool + @return: if unsubscrieb was successful. If the user has a + regular expression that match, it will always fail. + """ + changed = False + if pagename in self.subscribed_pages: + self.subscribed_pages.remove(pagename) + changed = True + + interWikiName = self._interWikiName(pagename) + if interWikiName and interWikiName in self.subscribed_pages: + self.subscribed_pages.remove(interWikiName) + changed = True + + if changed: + self.save() + return not self.isSubscribedTo([pagename]) + + # ----------------------------------------------------------------- + # Quicklinks + def getQuickLinks(self): """ Get list of pages this user wants in the navibar @@ -635,80 +728,105 @@ class User: @return: quicklinks from user account """ return self.quicklinks - - def getSubscriptionList(self): - """ Get list of pages this user has subscribed to + + def isQuickLinkedTo(self, pagelist): + """ Check if user quicklink matches any page in pagelist. - @rtype: list - @return: pages this user has subscribed to - """ - return self.subscribed_pages - - def isSubscribedTo(self, pagelist): - """ - Check if user subscription matches any page in pagelist. - - @param pagelist: list of pages to check for subscription - @rtype: int - @return: 1, if user has subscribed any page in pagelist - 0, if not - """ - import re - - matched = 0 - if self.valid: - pagelist_lines = '\n'.join(pagelist) - for pattern in self.getSubscriptionList(): - # check if pattern matches one of the pages in pagelist - matched = pattern in pagelist - if matched: break - try: - rexp = re.compile("^"+pattern+"$", re.M) - except re.error: - # skip bad regex - continue - matched = rexp.search(pagelist_lines) - if matched: break - if matched: - return 1 - else: - return 0 - - - def subscribePage(self, pagename, remove=False): - """ Subscribe or unsubscribe to a wiki page. - - Note that you need to save the user data to make this stick! - - @param pagename: name of the page to subscribe - @param remove: unsubscribe pagename if set - @type remove: bool + @param pagelist: list of pages to check for quicklinks @rtype: bool - @return: true, if page was NEWLY subscribed. + @return: if user has quicklinked any page in pagelist """ - if remove: - if pagename in self.subscribed_pages: - self.subscribed_pages.remove(pagename) - return 1 - else: - if pagename not in self.subscribed_pages: - self.subscribed_pages.append(pagename) - return 1 - return 0 + if not self.valid: + return False + + for pagename in pagelist: + if pagename in self.quicklinks: + return True + interWikiName = self._interWikiName(pagename) + if interWikiName and interWikiName in self.quicklinks: + return True + + return False + def addQuicklink(self, pagename): + """ Adds a page to the user quicklinks + + If the wiki has an interwiki name, all links are saved as + interwiki names. If not, as simple page name. + + @param pagename: page name + @type pagename: unicode + @rtype: bool + @return: if pagename was added + """ + changed = False + interWikiName = self._interWikiName(pagename) + if interWikiName: + if pagename in self.quicklinks: + self.quicklinks.remove(pagename) + changed = True + if interWikiName not in self.quicklinks: + self.quicklinks.append(interWikiName) + changed = True + else: + if pagename not in self.quicklinks: + self.quicklinks.append(pagename) + changed = True + + if changed: + self.save() + return changed + + def removeQuicklink(self, pagename): + """ Remove a page from user quicklinks + + Remove both interwiki and simple name from quicklinks. + + @param pagename: page name + @type pagename: unicode + @rtype: bool + @return: if pagename was removed + """ + changed = False + interWikiName = self._interWikiName(pagename) + if interWikiName and interWikiName in self.quicklinks: + self.quicklinks.remove(interWikiName) + changed = True + if pagename in self.quicklinks: + self.quicklinks.remove(pagename) + changed = True + + if changed: + self.save() + return changed + + def _interWikiName(self, pagename): + """ Return the inter wiki name of a page name + + @param pagename: page name + @type pagename: unicode + """ + if not self._cfg.interwikiname: + return None + + # Interwiki links must use _ e.g Wiki:Main_Page + pagename = pagename.replace(" ", "_") + return "%s:%s" % (self._cfg.interwikiname, pagename) + + # ----------------------------------------------------------------- + # Trail def addTrail(self, pagename): - """ - Add page to trail. + """ Add page to trail. @param pagename: the page name to add to the trail """ + # TODO: acquire lock here, so multiple processes don't clobber + # each one trail. + if self.valid and (self.show_page_trail or self.remember_last_visit): # load trail if not known self.getTrail() - - # don't append tail to trail ;) - if self._trail and self._trail[-1] == pagename: return # Add only existing pages that the user may read if self._request: @@ -718,25 +836,47 @@ class User: self._request.user.may.read(page.page_name)): return - # append new page, limiting the length - self._trail = filter(lambda p, pn=pagename: p != pn, self._trail) + # Save interwiki links internally + if self._cfg.interwikiname: + pagename = self._interWikiName(pagename) + + # Don't append tail to trail ;) + if self._trail and self._trail[-1] == pagename: + return + + # Append new page, limiting the length + self._trail = [p for p in self._trail if p != pagename] self._trail = self._trail[-(self._cfg.trail_size-1):] self._trail.append(pagename) + self.saveTrail() - # save new trail - trailfile = codecs.open(self.__filename() + ".trail", "w", config.charset) - for t in self._trail: - trailfile.write('%s\n' % t) - trailfile.close() + # TODO: release lock here + + def saveTrail(self): + """ Save trail file + + Save using one write call, which should be fine in most cases, + but will fail in rare cases without real file locking. + """ + data = '\n'.join(self._trail) + '\n' + path = self.__filename() + ".trail" + try: + file = codecs.open(path, "w", config.charset) try: - os.chmod(self.__filename() + ".trail", 0666 & config.umask) - except OSError: - pass + file.write(data) + finally: + file.close() + try: + os.chmod(path, 0666 & config.umask) + except OSError, err: + self._request.log("Can't change mode of trail file: %s" % + str(err)) + except (IOError, OSError), err: + self._request.log("Can't save trail file: %s" % str(err)) def getTrail(self): - """ - Return list of recently visited pages. + """ Return list of recently visited pages. @rtype: list @return: pages in trail @@ -745,12 +885,99 @@ class User: and not self._trail \ and os.path.exists(self.__filename() + ".trail"): try: - self._trail = codecs.open(self.__filename() + ".trail", 'r', config.charset).readlines() + trail = codecs.open(self.__filename() + ".trail", 'r', config.charset).readlines() except (OSError, ValueError): - self._trail = [] - else: - self._trail = filter(None, map(string.strip, self._trail)) - self._trail = self._trail[-self._cfg.trail_size:] + trail = [] + trail = [t.strip() for t in trail] + trail = [t for t in trail if t] + self._trail = trail[-self._cfg.trail_size:] return self._trail + # ----------------------------------------------------------------- + # Other + + def isCurrentUser(self): + return self._request.user.name == self.name + + def isSuperUser(self): + superusers = self._request.cfg.superuser + assert isinstance(superusers, (list, tuple)) + return self.valid and self.name and self.name in superusers + + def host(self): + """ Return user host """ + _ = self._request.getText + host = self.isCurrentUser() and self._cfg.show_hosts and self._request.remote_addr + return host or _("") + + def signature(self): + """ Return user signature using markup + + Users sign with a link to their homepage, or with text if they + don't have one. The text may be parsed as a link if it's using + CamelCase. Visitors return their host address. + + TODO: The signature use wiki format only, for example, it will + not create a link when using rst format. It will also break if + we change wiki syntax. + """ + if not self.name: + return self.host() + + wikiname, pagename = wikiutil.getInterwikiHomePage(self._request, + self.name) + if wikiname == 'Self': + if not wikiutil.isStrictWikiname(self.name): + markup = '["%s"]' % pagename + else: + markup = pagename + else: + markup = '%s:%s' % (wikiname, pagename.replace(" ","_")) + return markup + + def mailAccountData(self, cleartext_passwd=None): + from MoinMoin.util import mail + from MoinMoin.wikiutil import getSysPage + _ = self._request.getText + + if not self.enc_password: # generate pw if there is none yet + from random import randint + import base64 + + charset = 'utf-8' + pwd = "%s%d" % (str(time.time()), randint(0, 65535)) + pwd = pwd.encode(charset) + + pwd = sha.new(pwd).digest() + pwd = '{SHA}%s' % base64.encodestring(pwd).rstrip() + + self.enc_password = pwd + self.save() + + text = '\n' + _("""\ +Login Name: %s + +Login Password: %s + +Login URL: %s/%s +""", formatted=False) % ( + self.name, self.enc_password, self._request.getBaseURL(), getSysPage(self._request, 'UserPreferences').page_name) + + text = _("""\ +Somebody has requested to submit your account data to this email address. + +If you lost your password, please use the data below and just enter the +password AS SHOWN into the wiki's password form field (use copy and paste +for that). + +After successfully logging in, it is of course a good idea to set a new and known password. +""", formatted=False) + text + + + subject = _('[%(sitename)s] Your wiki account data', + formatted=False) % {'sitename': self._cfg.sitename or "Wiki"} + mailok, msg = mail.sendmail(self._request, [self.email], subject, + text, mail_from=self._cfg.mail_from) + return msg + diff --git a/wiki/userform.py b/wiki/userform.py index 2f2803ff..733246d5 100644 --- a/wiki/userform.py +++ b/wiki/userform.py @@ -6,15 +6,13 @@ @license: GNU GPL, see COPYING for details. """ -import string, time, re, Cookie -from MoinMoin import config, user, util, wikiutil -from MoinMoin.util import web, mail, datetime +import string, time, re +from MoinMoin import user, util, wikiutil +from MoinMoin.util import web, mail, timefuncs from MoinMoin.widget import html -from MoinMoin.PageEditor import PageEditor _debug = 0 - ############################################################################# ### Form POST Handling ############################################################################# @@ -56,8 +54,9 @@ class UserSettingsHandler: if not item: continue # Normalize names - except [name_with_spaces label] - if not (item.startswith('[') and item.endswith(']')): - item = self.request.normalizePagename(item) + # Commented out to allow URLs + #if not (item.startswith('[') and item.endswith(']')): + # item = self.request.normalizePagename(item) items.append(item) return items @@ -65,15 +64,11 @@ class UserSettingsHandler: _ = self._ form = self.request.form - if form.has_key('logout'): - # clear the cookie in the browser and locally. Does not - # check if we have a valid user logged, just make sure we - # don't have one after this call. - self.request.deleteCookie() - return _("Cookie deleted. You are now logged out.") - - if form.has_key('login_sendmail'): - if not self.cfg.mail_smarthost: + if form.has_key('cancel'): + return + + if form.has_key('account_sendmail'): + if not self.cfg.mail_enabled: return _("""This wiki is not enabled for mail processing. Contact the owner of the wiki, who can enable email.""") try: @@ -81,83 +76,32 @@ Contact the owner of the wiki, who can enable email.""") except KeyError: return _("Please provide a valid email address!") - text = '' users = user.getUserList(self.request) for uid in users: theuser = user.User(self.request, uid) if theuser.valid and theuser.email.lower() == email: - text = "%s\n\nID: %s\nName: %s\nPassword: %s\nLogin URL: %s/?action=userform&uid=%s" % ( - text, theuser.id, theuser.name, theuser.enc_password, self.request.getBaseURL(), theuser.id) - - if not text: - return _("Found no account matching the given email address '%(email)s'!") % {'email': wikiutil.escape(email)} - - mailok, msg = util.mail.sendmail(self.request, [email], - 'Your wiki account data', text, mail_from=self.cfg.mail_from) - return wikiutil.escape(msg) + msg = theuser.mailAccountData() + return wikiutil.escape(msg) - if form.has_key('login'): - # Trying to login with a user name and a password + return _("Found no account matching the given email address '%(email)s'!") % {'email': wikiutil.escape(email)} - # Require valid user name - name = form.get('username', [''])[0] - if not user.isValidName(self.request, name): - return _("""Invalid user name {{{'%s'}}}. -Name may contain any Unicode alpha numeric character, with optional one -space between words. Group page name is not allowed.""") % wikiutil.escape(name) - - # Check that user exists - if not user.getUserId(self.request, name): - return _('Unknown user name: {{{"%s"}}}. Please enter' - ' user name and password.') % name - - # Require password - password = form.get('password',[None])[0] - if not password: - return _("Missing password. Please enter user name and" - " password.") - - # Load the user data and check for validness - theuser = user.User(self.request, name=name, password=password) - if not theuser.valid: - return _("Sorry, wrong password.") - - # Save the user and send a cookie - self.request.user = theuser - self.request.setCookie() - - elif form.has_key('uid'): - # Trying to login with the login URL, soon to be removed! - try: - uid = form['uid'][0] - except KeyError: - return _("Bad relogin URL.") - - # Load the user data and check for validness - theuser = user.User(self.request, uid) - if not theuser.valid: - return _("Unknown user.") - - # Save the user and send a cookie - self.request.user = theuser - self.request.setCookie() - - else: - # Save user profile - theuser = user.User(self.request) + if (form.has_key('create') or + form.has_key('create_only') or + form.has_key('create_and_mail')): + if self.request.request_method != 'POST': + return _("Use UserPreferences to change your settings or create an account.") + # Create user profile + if form.has_key('create'): + theuser = self.request.get_user_from_form() + else: + theuser = user.User(self.request, auth_method="request:152") # Require non-empty name try: - theuser.name = form['username'][0] + theuser.name = form['name'][0] except KeyError: return _("Empty user name. Please enter a user name.") - #### HACK CRANS : oblige les utilistaeurs a créer un WikiNom valide - if not wikiutil.isStrictWikiname(theuser.name): - return (u"""Nom d'utilisateur invalide {{{'%s'}}}. -Le login doit être de la forme WikiNom, WikiPseudo, PrenomNom... (voir ci dessous pour plus d'informations).""" % wikiutil.escape(theuser.name)) - #### FIN HACK - # Don't allow users with invalid names if not user.isValidName(self.request, theuser.name): return _("""Invalid user name {{{'%s'}}}. @@ -173,16 +117,6 @@ space between words. Group page name is not allowed.""") % wikiutil.escape(theus else: newuser = 0 - #### HACK SAUVAGE - - if newuser and not self.cfg.ip_autorised_create_account(self.request.remote_addr): - return _(u"""Création de compte impossible. -Pour des raisons de sécurité, la fonction de création d'un compte n'est -possible que depuis la zone CRANS. -Si vous possédez un compte sur zamok, vous pouvez y exécuter -creer_compte_wiki.""") - #### FIN DU HACK - # try to get the password and pw repeat password = form.get('password', [''])[0] password2 = form.get('password2',[''])[0] @@ -200,30 +134,126 @@ creer_compte_wiki.""") # Should never happen return "Can't encode password: %s" % str(err) - # try to get the (optional) email + # try to get the (required) email email = form.get('email', [''])[0] theuser.email = email.strip() - - # Require email if acl is enabled - if not theuser.email and self.cfg.acl_enabled: - return _("Please provide your email address. If you loose your" + if not theuser.email: + return _("Please provide your email address. If you lose your" " login information, you can get it by email.") - # Email required to be unique - # See also MoinMoin/scripts/moin_usercheck.py - if theuser.email: + # Email should be unique - see also MoinMoin/script/accounts/moin_usercheck.py + if theuser.email and self.request.cfg.user_email_unique: users = user.getUserList(self.request) for uid in users: if uid == theuser.id: continue thisuser = user.User(self.request, uid) + if thisuser.email == theuser.email and not thisuser.disabled: + return _("This email already belongs to somebody else.") + + # save data + theuser.save() + if form.has_key('create_and_mail'): + theuser.mailAccountData() + + result = _("User account created! You can use this account to login now...") + if _debug: + result = result + util.dumpFormData(form) + return result + + + # Select user profile (su user) - only works with cookie auth active. + if form.has_key('select_user'): + if (wikiutil.checkTicket(self.request.form['ticket'][0]) and + self.request.request_method == 'POST' and + self.request.user.isSuperUser()): + su_user = form.get('selected_user', [''])[0] + uid = user.getUserId(self.request, su_user) + theuser = user.User(self.request, uid) + theuser.disabled = None + theuser.save() + from MoinMoin import auth + auth.setCookie(self.request, theuser) + self.request.user = theuser + return _("Use UserPreferences to change settings of the selected user account") + else: + return _("Use UserPreferences to change your settings or create an account.") + + if form.has_key('save'): # Save user profile + if self.request.request_method != 'POST': + return _("Use UserPreferences to change your settings or create an account.") + theuser = self.request.get_user_from_form() + + if not 'name' in theuser.auth_attribs: + # Require non-empty name + theuser.name = form.get('name', [theuser.name])[0] + if not theuser.name: + return _("Empty user name. Please enter a user name.") + + # Don't allow users with invalid names + if not user.isValidName(self.request, theuser.name): + return _("""Invalid user name {{{'%s'}}}. +Name may contain any Unicode alpha numeric character, with optional one +space between words. Group page name is not allowed.""") % wikiutil.escape(theuser.name) + + # Is this an existing user trying to change information or a new user? + # Name required to be unique. Check if name belong to another user. + newuser = 1 + if user.getUserId(self.request, theuser.name): + if theuser.name != self.request.user.name: + return _("This user name already belongs to somebody else.") + else: + newuser = 0 + + if not 'password' in theuser.auth_attribs: + # try to get the password and pw repeat + password = form.get('password', [''])[0] + password2 = form.get('password2',[''])[0] + + # Check if password is given and matches with password repeat + if password != password2: + return _("Passwords don't match!") + if not password and newuser: + return _("Please specify a password!") + # Encode password + if password and not password.startswith('{SHA}'): + try: + theuser.enc_password = user.encodePassword(password) + except UnicodeError, err: + # Should never happen + return "Can't encode password: %s" % str(err) + + if not 'email' in theuser.auth_attribs: + # try to get the email + email = form.get('email', [theuser.email])[0] + theuser.email = email.strip() + + # Require email + if not theuser.email: + return _("Please provide your email address. If you lose your" + " login information, you can get it by email.") + + # Email should be unique - see also MoinMoin/script/accounts/moin_usercheck.py + if theuser.email and self.request.cfg.user_email_unique: + users = user.getUserList(self.request) + for uid in users: + if uid == theuser.id: + continue + thisuser = user.User(self.request, uid, auth_method='userform:283') if thisuser.email == theuser.email: return _("This email already belongs to somebody else.") - + if not 'aliasname' in theuser.auth_attribs: + # aliasname + theuser.aliasname = form.get('aliasname', [''])[0] + # editor size theuser.edit_rows = util.web.getIntegerInput(self.request, 'edit_rows', theuser.edit_rows, 10, 60) + # try to get the editor + theuser.editor_default = form.get('editor_default', [self.cfg.editor_default])[0] + theuser.editor_ui = form.get('editor_ui', [self.cfg.editor_ui])[0] + # time zone theuser.tz_offset = util.web.getIntegerInput(self.request, 'tz_offset', theuser.tz_offset, -84600, 84600) @@ -232,7 +262,8 @@ creer_compte_wiki.""") dt_d_combined = UserSettings._date_formats.get(form['datetime_fmt'][0], '') theuser.datetime_fmt, theuser.date_fmt = dt_d_combined.split(' & ') except (KeyError, ValueError): - pass + theuser.datetime_fmt = '' # default + theuser.date_fmt = '' # default # try to get the (optional) theme theme_name = form.get('theme_name', [self.cfg.theme_default])[0] @@ -248,22 +279,41 @@ creer_compte_wiki.""") theme_name = wikiutil.escape(theme_name) return _("The theme '%(theme_name)s' could not be loaded!") % locals() - # User CSS URL - theuser.css_url = form.get('css_url', [''])[0] - # try to get the (optional) preferred language theuser.language = form.get('language', [''])[0] + # I want to handle all inputs from user_form_fields, but + # don't want to handle the cases that have already been coded + # above. + # This is a horribly fragile kludge that's begging to break. + # Something that might work better would be to define a + # handler for each form field, instead of stuffing them all in + # one long and inextensible method. That would allow for + # plugins to provide methods to validate their fields as well. + already_handled = ['name', 'password', 'password2', 'email', + 'aliasname', 'edit_rows', 'editor_default', + 'editor_ui', 'tz_offset', 'datetime_fmt', + 'theme_name', 'language'] + for field in self.cfg.user_form_fields: + key = field[0] + if ((key in self.cfg.user_form_disable) + or (key in already_handled)): + continue + default = self.cfg.user_form_defaults[key] + value = form.get(key, [default])[0] + setattr(theuser, key, value) + # checkbox options if not newuser: - for key, label in user.User._checkbox_fields: - value = form.get(key, ["0"])[0] - try: - value = int(value) - except ValueError: - pass - else: - setattr(theuser, key, value) + for key, label in self.cfg.user_checkbox_fields: + if key not in self.cfg.user_checkbox_disable and key not in self.cfg.user_checkbox_remove: + value = form.get(key, ["0"])[0] + try: + value = int(value) + except ValueError: + pass + else: + setattr(theuser, key, value) # quicklinks for navibar theuser.quicklinks = self.decodePageList('quicklinks') @@ -271,19 +321,10 @@ creer_compte_wiki.""") # subscription for page change notification theuser.subscribed_pages = self.decodePageList('subscribed_pages') - # save data and send cookie + # save data theuser.save() self.request.user = theuser - self.request.setCookie() - #### HACK : création de la page WikiNom - try: - p = PageEditor(self.request, theuser.name) - p.saveText( 'Décrire ici %s' % theuser.name, 0) - except: - pass - #### FIN DU HACK - result = _("User preferences saved!") if _debug: result = result + util.dumpFormData(form) @@ -326,7 +367,7 @@ class UserSettings: options.append(( str(offset), '%s [%s%s:%s]' % ( - time.strftime(self.cfg.datetime_fmt, util.datetime.tmtuple(t)), + time.strftime(self.cfg.datetime_fmt, timefuncs.tmtuple(t)), "+-"[offset < 0], string.zfill("%d" % (abs(offset) / 3600), 2), string.zfill("%d" % (abs(offset) % 3600 / 60), 2), @@ -366,15 +407,44 @@ class UserSettings: return util.web.makeSelection('language', options, cur_lang) + def _user_select(self): + options = [] + users = user.getUserList(self.request) + for uid in users: + name = user.User(self.request, id=uid).name # + '_' + uid # for debugging + options.append((name, name)) + options.sort() + + size = min(5, len(options)) + current_user = self.request.user.name + return util.web.makeSelection('selected_user', options, current_user, size=size) + def _theme_select(self): """ Create theme selection. """ cur_theme = self.request.user.valid and self.request.user.theme_name or self.cfg.theme_default - options = [] + options = [("", "<%s>" % self._("Default"))] for theme in wikiutil.getPlugins('theme', self.request.cfg): options.append((theme, theme)) return util.web.makeSelection('theme_name', options, cur_theme) + def _editor_default_select(self): + """ Create editor selection. """ + editor_default = self.request.user.valid and self.request.user.editor_default or self.cfg.editor_default + options = [("", "<%s>" % self._("Default"))] + for editor in ['text','gui',]: + options.append((editor, editor)) + return util.web.makeSelection('editor_default', options, editor_default) + + def _editor_ui_select(self): + """ Create editor selection. """ + editor_ui = self.request.user.valid and self.request.user.editor_ui or self.cfg.editor_ui + options = [("", "<%s>" % self._("Default")), + ("theonepreferred", self._("the one preferred")), + ("freechoice", self._("free choice")), + ] + return util.web.makeSelection('editor_ui', options, editor_ui) + def make_form(self): """ Create the FORM, and the TABLE with the input fields """ @@ -402,98 +472,83 @@ class UserSettings: ])) - def asHTML(self): + def asHTML(self, create_only=False): """ Create the complete HTML form code. """ _ = self._ self.make_form() - if self.request.user.valid: - # User preferences interface - buttons = [ - ('save', _('Save')), - ('logout', _('Logout')), - ] - else: - # Login / register interface - buttons = [ - # IMPORTANT: login should be first to be the default - # button when a user click enter. - ('login', _('Login')), - ("save", _('Create Profile')), - ] - if self.cfg.mail_smarthost: - buttons.append(("login_sendmail", _('Mail me my account data'))) - - self.make_row(_('Name'), [ - html.INPUT( - type="text", size="36", name="username", value=self.request.user.name - ), - ' ', _('(Use FirstnameLastname)', formatted=False), - ]) - - self.make_row(_('Password'), [ - html.INPUT( - type="password", size="36", name="password", - ), - ' ', - ]) - - self.make_row(_('Password repeat'), [ - html.INPUT( - type="password", size="36", name="password2", - ), - ' ', _('(Only when changing passwords)'), - ]) - - self.make_row(_('Email'), [ - html.INPUT( - type="text", size="36", name="email", value=self.request.user.email - ), - ' ', - ]) - - # Show options only if already logged in - if self.request.user.valid: + if self.request.user.isSuperUser(): + ticket = wikiutil.createTicket() + self.make_row(_('Select User'), [self._user_select()]) + self._form.append(html.INPUT(type="hidden", name="ticket", value="%s" % ticket)) + buttons = [("select_user", _('Select User'))] + button_cell = [] + for name, label in buttons: + button_cell.extend([ + html.INPUT(type="submit", name=name, value=label), + ' ', + ]) + self.make_row('', button_cell) - if not self.cfg.theme_force: + if self.request.user.valid and not create_only: + buttons = [('save', _('Save')), ('cancel', _('Cancel')), ] + uf_remove = self.cfg.user_form_remove + uf_disable = self.cfg.user_form_disable + for attr in self.request.user.auth_attribs: + if attr == 'password': + uf_remove.append(attr) + uf_remove.append('password2') + else: + uf_disable.append(attr) + for key, label, type, length, textafter in self.cfg.user_form_fields: + default = self.cfg.user_form_defaults[key] + if not key in uf_remove: + if key in uf_disable: + self.make_row(_(label), + [ html.INPUT(type=type, size=length, name=key, disabled="disabled", + value=getattr(self.request.user, key)), ' ', _(textafter), ]) + else: + self.make_row(_(label), + [ html.INPUT(type=type, size=length, name=key, value=getattr(self.request.user, key)), ' ', _(textafter), ]) + + if not self.cfg.theme_force and not "theme_name" in self.cfg.user_form_remove: self.make_row(_('Preferred theme'), [self._theme_select()]) - self.make_row(_('User CSS URL'), [ - html.INPUT( - type="text", size="40", name="css_url", value=self.request.user.css_url - ), - ' ', _('(Leave it empty for disabling user CSS)'), - ]) + if not self.cfg.editor_force: + if not "editor_default" in self.cfg.user_form_remove: + self.make_row(_('Editor Preference'), [self._editor_default_select()]) + if not "editor_ui" in self.cfg.user_form_remove: + self.make_row(_('Editor shown on UI'), [self._editor_ui_select()]) - self.make_row(_('Editor size'), [ - html.INPUT(type="text", size="3", maxlength="3", - name="edit_rows", value=str(self.request.user.edit_rows)), - ]) + if not "tz_offset" in self.cfg.user_form_remove: + self.make_row(_('Time zone'), [ + _('Your time is'), ' ', + self._tz_select(), + html.BR(), + _('Server time is'), ' ', + time.strftime(self.cfg.datetime_fmt, timefuncs.tmtuple()), + ' (UTC)', + ]) - self.make_row(_('Time zone'), [ - _('Your time is'), ' ', - self._tz_select(), - html.BR(), - _('Server time is'), ' ', - time.strftime(self.cfg.datetime_fmt, util.datetime.tmtuple()), - ' (UTC)', - ]) + if not "datetime_fmt" in self.cfg.user_form_remove: + self.make_row(_('Date format'), [self._dtfmt_select()]) - self.make_row(_('Date format'), [self._dtfmt_select()]) - - self.make_row(_('Preferred language'), [self._lang_select()]) + if not "language" in self.cfg.user_form_remove: + self.make_row(_('Preferred language'), [self._lang_select()]) # boolean user options bool_options = [] - checkbox_fields = user.User._checkbox_fields + checkbox_fields = self.cfg.user_checkbox_fields _ = self.request.getText checkbox_fields.sort(lambda a, b: cmp(a[1](_), b[1](_))) for key, label in checkbox_fields: - bool_options.extend([ - html.INPUT(type="checkbox", name=key, value="1", - checked=getattr(self.request.user, key, 0)), - ' ', label(_), html.BR(), - ]) + if not key in self.cfg.user_checkbox_remove: + bool_options.extend([ + html.INPUT(type="checkbox", name=key, value="1", + checked=getattr(self.request.user, key, 0), + disabled=key in self.cfg.user_checkbox_disable and True or None), + ' ', label(_), html.BR(), + ]) self.make_row(_('General options'), bool_options, valign="top") self.make_row(_('Quick links'), [ @@ -502,7 +557,7 @@ class UserSettings: ], valign="top") # subscribed pages - if self.cfg.mail_smarthost: + if self.cfg.mail_enabled: # Get list of subscribe pages, DO NOT sort! it should # stay in the order the user entered it in his input # box. @@ -524,24 +579,114 @@ class UserSettings: ] + warning, valign="top" ) + else: # not logged in + # Login / register interface + buttons = [ + # IMPORTANT: login should be first to be the default + # button when a user hits ENTER. + #('login', _('Login')), # we now have a Login macro + ('create', _('Create Profile')), + ('cancel', _('Cancel')), + ] + for key, label, type, length, textafter in self.cfg.user_form_fields: + if key in ('name', 'password', 'password2', 'email'): + self.make_row(_(label), + [ html.INPUT(type=type, size=length, name=key, + value=''), + ' ', _(textafter), ]) + + if self.cfg.mail_enabled: + buttons.append(("account_sendmail", _('Mail me my account data'))) + + if create_only: + buttons = [("create_only", _('Create Profile'))] + if self.cfg.mail_enabled: + buttons.append(("create_and_mail", "%s + %s" % + (_('Create Profile'), _('Email')))) # Add buttons button_cell = [] for name, label in buttons: - button_cell.extend([ - html.INPUT(type="submit", name=name, value=label), - ' ', - ]) + if not name in self.cfg.user_form_remove: + button_cell.extend([ + html.INPUT(type="submit", name=name, value=label), + ' ', + ]) self.make_row('', button_cell) return unicode(self._form) -def getUserForm(request): +def getUserForm(request, create_only=False): """ Return HTML code for the user settings. """ - return UserSettings(request).asHTML() + return UserSettings(request).asHTML(create_only=create_only) +class Login: + """ User login. """ + + def __init__(self, request): + """ Initialize user settings form. + """ + self.request = request + self._ = request.getText + self.cfg = request.cfg + + def make_row(self, label, cell, **kw): + """ Create a row in the form table. + """ + self._table.append(html.TR().extend([ + html.TD(**kw).extend([html.B().append(label), ' ']), + html.TD().extend(cell), + ])) + + + def asHTML(self): + """ Create the complete HTML form code. """ + _ = self._ + request = self.request + sn = request.getScriptname() + pi = request.getPathinfo() + action = u"%s%s" % (sn, pi) + userprefslink = wikiutil.getSysPage(request, "UserPreferences").link_to(request) + hint = _("To create an account or recover a lost password, see the %(userprefslink)s page.") % { + 'userprefslink': userprefslink} + self._form = html.FORM(action=action) + self._table = html.TABLE(border="0") + + # Use the user interface language and direction + lang_attr = request.theme.ui_lang_attr() + self._form.append(html.Raw('
    ' % lang_attr)) + + self._form.append(html.INPUT(type="hidden", name="action", value="login")) + self._form.append(self._table) + self._form.append(html.P().append(hint)) + self._form.append(html.Raw("
    ")) + + self.make_row(_('Name'), [ + html.INPUT( + type="text", size="32", name="name", + ), + ]) + + self.make_row(_('Password'), [ + html.INPUT( + type="password", size="32", name="password", + ), + ]) + + self.make_row('', [ + html.INPUT( + type="submit", name='login', value=_('Login') + ), + ]) + + return unicode(self._form) + +def getLogin(request): + """ Return HTML code for the login. """ + return Login(request).asHTML() + ############################################################################# ### User account administration ############################################################################# @@ -557,7 +702,7 @@ def do_user_browser(request): #Column('id', label=('ID'), align='right'), Column('name', label=('Username')), Column('email', label=('Email')), - #Column('action', label=_('Action')), + Column('action', label=_('Action')), ] # Iterate over users @@ -573,10 +718,14 @@ def do_user_browser(request): data.addRow(( #request.formatter.code(1) + uid + request.formatter.code(0), request.formatter.rawHTML(namelink), - (request.formatter.url(1, 'mailto:' + account.email, 'external', pretty_url=1, unescaped=1) + + (request.formatter.url(1, 'mailto:' + account.email, css='mailto', do_escape=0) + request.formatter.text(account.email) + request.formatter.url(0)), - #'', + request.page.link_to(request, text=_('Mail me my account data'), + querystr= {"action":"userform", + "email": account.email, + "account_sendmail": "1", + "sysadm": "users",}) )) if data: diff --git a/wiki/wikiacl.py b/wiki/wikiacl.py index 5aac18a1..3761cdf6 100644 --- a/wiki/wikiacl.py +++ b/wiki/wikiacl.py @@ -8,13 +8,7 @@ """ import re -from MoinMoin import user, search - -#### HACK SAUVAGE 1/4 -import sys -sys.path.append('/usr/scripts/gestion/') -from iptools import is_crans -#### FIN DU HACK 1/4 +from MoinMoin import user class AccessControlList: ''' Access Control List @@ -93,10 +87,6 @@ class AccessControlList: Configuration options - cfg.acl_enabled - If true will enable ACL support. - Default: 0 - cfg.acl_rights_default It is is ONLY used when no other ACLs are given. Default: "Known:read,write,delete All:read,write", @@ -117,10 +107,7 @@ class AccessControlList: Default: ["read", "write", "delete", "admin"] ''' - #special_users = ["All", "Known", "Trusted"] - #### HACK SAUVAGE 2/4 - special_users = ["All", "Known", "Trusted", "Conf"] - #### FIN DU HACK 2/4 + special_users = ["All", "Known", "Trusted"] # order is important def __init__(self, request, lines=[]): """Initialize an ACL, starting from . @@ -159,7 +146,6 @@ class AccessControlList: @param aclstring: acl string from page or cfg @param remember: should add the line to self.acl_lines """ - # FIXME: should compile this once and cache (in cfg?) group_re = re.compile(cfg.page_group_regex) # Remember lines @@ -195,17 +181,6 @@ class AccessControlList: """May ? Returns boolean answer. """ - if not request.cfg.acl_enabled: - # everybody may read and write: - if dowhat in ["read", "write",]: - return 1 - # only known users may do some more dangerous things: - if request.user.valid: - if dowhat in ["delete", "revert",]: - return 1 - # in any other case, we better disallow it: - return 0 - is_group_member = request.dicts.has_member allowed = None @@ -213,8 +188,16 @@ class AccessControlList: if entry in self.special_users: handler = getattr(self, "_special_"+entry, None) allowed = handler(request, name, dowhat, rightsdict) - elif self._is_group.get(entry) and is_group_member(entry, name): - allowed = rightsdict.get(dowhat) + elif self._is_group.get(entry): + if is_group_member(entry, name): + allowed = rightsdict.get(dowhat) + else: + for special in self.special_users: + if is_group_member(entry, special): + handler = getattr(self, "_special_"+ special, None) + allowed = handler(request, name, + dowhat, rightsdict) + break # order of self.special_users is important elif entry == name: allowed = rightsdict.get(dowhat) if allowed is not None: @@ -226,8 +209,6 @@ class AccessControlList: return ''.join(["%s%s%s" % (b,l,e) for l in self.acl_lines]) def _special_All(self, request, name, dowhat, rightsdict): - if dowhat == "read" and self.is_page_public(request): - return True return rightsdict.get(dowhat) def _special_Known(self, request, name, dowhat, rightsdict): @@ -238,11 +219,6 @@ class AccessControlList: if user.getUserId(request, name): # is a user with this name known? return rightsdict.get(dowhat) return None - - #### HACK SAUVAGE 3/4 - def _special_Conf(self, request, name, dowhat, rightsdict): - return request.cfg.acl_request(self, request, name, dowhat, rightsdict) - #### FIN Du HACK 3/4 def _special_Trusted(self, request, name, dowhat, rightsdict): """ check if user is known AND even has logged in using a password. @@ -257,22 +233,6 @@ class AccessControlList: return self.acl_lines == other.acl_lines def __ne__(self, other): return self.acl_lines != other.acl_lines - - #### HACK SAUVAGE 4/4 - def is_page_public(self,request): - ## On recherche si la page est publique - if not request.page: - return False - this_page = request.page.page_name - query = search.QueryParser().parse_query(u'CatégoriePagePublique') - page = search.Page(request, this_page) - result = query.search(page) - if result: - return True - else: - return False - #### FIN DU HACK 4/4 - class ACLStringIterator: @@ -291,7 +251,7 @@ class ACLStringIterator: """ Initialize acl iterator @param rights: the acl rights to consider when parsing - @param aclstirng: string to parse + @param aclstring: string to parse """ self.rights = rights self.rest = aclstring.strip() @@ -331,7 +291,6 @@ class ACLStringIterator: else: # Get entries try: - # XXX TODO disallow : and , in usernames entries, self.rest = self.rest.split(':', 1) except ValueError: self.finished = 1 @@ -360,9 +319,6 @@ def parseACL(request, body): Use ACL object may(request, dowhat) to get acl rights. """ - if not request.cfg.acl_enabled: - return AccessControlList(request) - acl_lines = [] while body and body[0] == '#': # extract first line