class AdminToolController(VerbifyController): @validate( VAdmin(), recipient=nop('recipient'), ) def GET_cverbifys(self, recipient): return AdminPage(content=AdminCverbifys(recipient)).render() @validate( VAdmin(), recipient=nop('recipient'), ) def GET_sodium(self, recipient): return AdminPage(content=AdminSodium(recipient)).render()
class APIv1ScopesController(VerbifyController): THREE_SIXTY = OAuth2Scope.FULL_ACCESS @allow_oauth2_access @validate( scope_str=nop("scopes", docs={"scopes": "(optional) An OAuth2 scope string"}), ) @api_docs.api_doc(api_docs.api_section.misc) def GET_scopes(self, scope_str): """Retrieve descriptions of verbify's OAuth2 scopes. If no scopes are given, information on all scopes are returned. Invalid scope(s) will result in a 400 error with body that indicates the invalid scope(s). """ scopes = OAuth2Scope(scope_str or self.THREE_SIXTY) if scope_str and not scopes.is_valid(): invalid = [s for s in scopes.scopes if s not in scopes.scope_info] error = {"error": "invalid_scopes", "invalid_scopes": invalid} http_err = HTTPBadRequest() http_err.error_data = error abort(http_err) return self.api_wrapper({k: v for k, v in scopes.details() if k})
class MediaembedController(MinimalController): @validate( link=VLink('link'), credentials=nop('credentials'), ) def GET_mediaembed(self, link, credentials): if request.host != g.media_domain: # don't serve up untrusted content except on our # specifically untrusted domain abort(404) if link.subverbify_slow.type in Subverbify.private_types: expected_mac = hmac.new(g.secrets["media_embed"], link._id36, hashlib.sha1).hexdigest() if not constant_time_compare(credentials or "", expected_mac): abort(404) if not c.secure: media_object = link.media_object else: media_object = link.secure_media_object if not media_object: abort(404) elif isinstance(media_object, dict): # otherwise it's the new style, which is a dict(type=type, **args) media_embed = get_media_embed(media_object) content = media_embed.content c.allow_framing = True return MediaEmbedBody(body=content).render()
class WebLogController(VerbifyController): on_validation_error = staticmethod(abort_with_error) @csrf_exempt @validate( VRatelimit(rate_user=False, rate_ip=True, prefix='rate_weblog_'), level=VOneOf('level', ('error', )), logs=VValidatedJSON( 'logs', VValidatedJSON.ArrayOf( VValidatedJSON.PartialObject({ 'msg': VPrintable('msg', max_length=256), 'url': VPrintable('url', max_length=256), 'tag': VPrintable('tag', max_length=32), }))), ) def POST_message(self, level, logs): # Whitelist tags to keep the frontend from creating too many keys in statsd valid_frontend_log_tags = { 'unknown', 'verbify-config-migrate-error', } # prevent simple CSRF by requiring a custom header if not request.headers.get('X-Loggit'): abort(403) uid = c.user._id if c.user_is_loggedin else '-' # only accept a maximum of 3 entries per request for log in logs[:3]: if 'msg' not in log or 'url' not in log: continue tag = 'unknown' if log.get('tag') in valid_frontend_log_tags: tag = log['tag'] g.stats.simple_event('frontend.error.' + tag) g.log.warning('[web frontend] %s: %s | U: %s FP: %s UA: %s', level, log['msg'], uid, log['url'], request.user_agent) VRatelimit.ratelimit(rate_user=False, rate_ip=True, prefix="rate_weblog_", seconds=10) def OPTIONS_report_cache_poisoning(self): """Send CORS headers for cache poisoning reports.""" if "Origin" not in request.headers: return origin = request.headers["Origin"] parsed_origin = UrlParser(origin) if not is_subdomain(parsed_origin.hostname, g.domain): return response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Methods"] = "POST" response.headers["Access-Control-Allow-Headers"] = \ "Authorization, X-Loggit, " response.headers["Access-Control-Allow-Credentials"] = "false" response.headers['Access-Control-Expose-Headers'] = \ self.COMMON_VERBIFY_HEADERS @csrf_exempt @validate( VRatelimit(rate_user=False, rate_ip=True, prefix='rate_poison_'), report_mac=VPrintable('report_mac', 255), poisoner_name=VPrintable('poisoner_name', 255), poisoner_id=VInt('poisoner_id'), poisoner_canary=VPrintable('poisoner_canary', 2, min_length=2), victim_canary=VPrintable('victim_canary', 2, min_length=2), render_time=VInt('render_time'), route_name=VPrintable('route_name', 255), url=VPrintable('url', 2048), # To differentiate between web and mweb in the future source=VOneOf('source', ('web', 'mweb')), cache_policy=VOneOf( 'cache_policy', ('loggedin_www', 'loggedin_www_new', 'loggedin_mweb')), # JSON-encoded response headers from when our script re-requested # the poisoned page resp_headers=nop('resp_headers'), ) def POST_report_cache_poisoning( self, report_mac, poisoner_name, poisoner_id, poisoner_canary, victim_canary, render_time, route_name, url, source, cache_policy, resp_headers, ): """Report an instance of cache poisoning and its details""" self.OPTIONS_report_cache_poisoning() if c.errors: abort(400) # prevent simple CSRF by requiring a custom header if not request.headers.get('X-Loggit'): abort(403) # Eh? Why are you reporting this if the canaries are the same? if poisoner_canary == victim_canary: abort(400) expected_mac = make_poisoning_report_mac( poisoner_canary=poisoner_canary, poisoner_name=poisoner_name, poisoner_id=poisoner_id, cache_policy=cache_policy, source=source, route_name=route_name, ) if not constant_time_compare(report_mac, expected_mac): abort(403) if resp_headers: try: resp_headers = json.loads(resp_headers) # Verify this is a JSON map of `header_name => [value, ...]` if not isinstance(resp_headers, dict): abort(400) for hdr_name, hdr_vals in resp_headers.iteritems(): if not isinstance(hdr_name, basestring): abort(400) if not all(isinstance(h, basestring) for h in hdr_vals): abort(400) except ValueError: abort(400) if not resp_headers: resp_headers = {} poison_info = dict( poisoner_name=poisoner_name, poisoner_id=str(poisoner_id), # Convert the JS timestamp to a standard one render_time=render_time * 1000, route_name=route_name, url=url, source=source, cache_policy=cache_policy, resp_headers=resp_headers, ) # For immediate feedback when tracking the effects of caching changes g.stats.simple_event("cache.poisoning.%s.%s" % (source, cache_policy)) # For longer-term diagnosing of caching issues g.events.cache_poisoning_event(poison_info, request=request, context=c) VRatelimit.ratelimit(rate_ip=True, prefix="rate_poison_", seconds=10) return self.api_wrapper({})
class WikiApiController(WikiController): @require_oauth2_scope("wikiedit") @validate(VModhash(), pageandprevious=VWikiPageRevise(('page', 'previous'), restricted=True), content=nop(('content')), page_name=VWikiPageName('page'), reason=VPrintable('reason', 256, empty_error=None)) @api_doc(api_section.wiki, uri='/api/wiki/edit', uses_site=True) def POST_wiki_edit(self, pageandprevious, content, page_name, reason): """Edit a wiki `page`""" page, previous = pageandprevious if c.user._spam: error = _("You are doing that too much, please try again later.") self.handle_error(415, 'SPECIAL_ERRORS', special_errors=[error]) if not page: error = c.errors.get(('WIKI_CREATE_ERROR', 'page')) if error: self.handle_error(403, **(error.msg_params or {})) VNotInTimeout().run(action_name="wikirevise", details_text="create") try: page = WikiPage.create(c.site, page_name) except WikiPageExists: self.handle_error(400, 'WIKI_CREATE_ERROR') else: VNotInTimeout().run(action_name="wikirevise", details_text="edit", target=page) error = c.errors.get(('MAY_NOT_REVISE', 'page')) if error: self.handle_error(403, **(error.msg_params or {})) renderer = RENDERERS_BY_PAGE.get(page.name, 'wiki') if renderer in ('wiki', 'verbify'): content = VMarkdown(('content'), renderer=renderer).run(content) # Use the raw POST value as we need to tell the difference between # None/Undefined and an empty string. The validators use a default # value with both of those cases and would need to be changed. # In order to avoid breaking functionality, this was done instead. previous = previous._id if previous else request.POST.get('previous') try: # special validation methods if page.name == 'config/stylesheet': css_errors, parsed = c.site.parse_css(content, verify=False) if g.css_killswitch: self.handle_error(403, 'STYLESHEET_EDIT_DENIED') if css_errors: error_items = [CssError(x).message for x in css_errors] self.handle_error(415, 'SPECIAL_ERRORS', special_errors=error_items) elif page.name == "config/automoderator": try: rules = Ruleset(content) except ValueError as e: error_items = [e.message] self.handle_error(415, "SPECIAL_ERRORS", special_errors=error_items) # special saving methods if page.name == "config/stylesheet": c.site.change_css(content, parsed, previous, reason=reason) else: try: page.revise(content, previous, c.user._id36, reason=reason) except ContentLengthError as e: self.handle_error(403, 'CONTENT_LENGTH_ERROR', max_length=e.max_length) # continue storing the special pages as data attributes on the subverbify # object. TODO: change this to minimize subverbify get sizes. if page.special and page.name in ATTRIBUTE_BY_PAGE: setattr(c.site, ATTRIBUTE_BY_PAGE[page.name], content) c.site._commit() if page.special or c.is_wiki_mod: description = modactions.get(page.name, 'Page %s edited' % page.name) ModAction.create(c.site, c.user, "wikirevise", details=description, description=reason) except ConflictException as e: self.handle_error(409, 'EDIT_CONFLICT', newcontent=e.new, newrevision=page.revision, diffcontent=e.htmldiff) return json.dumps({}) @require_oauth2_scope("modwiki") @validate(VModhash(), VWikiModerator(), page=VWikiPage('page'), act=VOneOf('act', ('del', 'add')), user=VExistingUname('username')) @api_doc(api_section.wiki, uri='/api/wiki/alloweditor/{act}', uses_site=True, uri_variants=[ '/api/wiki/alloweditor/%s' % act for act in ('del', 'add') ]) def POST_wiki_allow_editor(self, act, page, user): """Allow/deny `username` to edit this wiki `page`""" if not user: self.handle_error(404, 'UNKNOWN_USER') elif act == 'del': VNotInTimeout().run(action_name="wikipermlevel", details_text="del_editor", target=user) page.remove_editor(user._id36) elif act == 'add': VNotInTimeout().run(action_name="wikipermlevel", details_text="allow_editor", target=user) page.add_editor(user._id36) else: self.handle_error(400, 'INVALID_ACTION') return json.dumps({}) @validate( VModhash(), VAdmin(), pv=VWikiPageAndVersion(('page', 'revision')), deleted=VBoolean('deleted'), ) def POST_wiki_revision_delete(self, pv, deleted): page, revision = pv if not revision: self.handle_error(400, 'INVALID_REVISION') if deleted and page.revision == str(revision._id): self.handle_error(400, 'REVISION_IS_CURRENT') revision.admin_deleted = deleted revision._commit() return json.dumps({'status': revision.admin_deleted}) @require_oauth2_scope("modwiki") @validate(VModhash(), VWikiModerator(), pv=VWikiPageAndVersion(('page', 'revision'))) @api_doc(api_section.wiki, uri='/api/wiki/hide', uses_site=True) def POST_wiki_revision_hide(self, pv): """Toggle the public visibility of a wiki page revision""" page, revision = pv if not revision: self.handle_error(400, 'INVALID_REVISION') VNotInTimeout().run(action_name="wikirevise", details_text="revision_hide", target=page) return json.dumps({'status': revision.toggle_hide()}) @require_oauth2_scope("modwiki") @validate(VModhash(), VWikiModerator(), pv=VWikiPageAndVersion(('page', 'revision'))) @api_doc(api_section.wiki, uri='/api/wiki/revert', uses_site=True) def POST_wiki_revision_revert(self, pv): """Revert a wiki `page` to `revision`""" page, revision = pv if not revision: self.handle_error(400, 'INVALID_REVISION') VNotInTimeout().run(action_name="wikirevise", details_text="revision_revert", target=page) content = revision.content reason = 'reverted back %s' % timesince(revision.date) if page.name == 'config/stylesheet': css_errors, parsed = c.site.parse_css(content) if css_errors: self.handle_error(403, 'INVALID_CSS') c.site.change_css(content, parsed, prev=None, reason=reason, force=True) else: try: page.revise(content, author=c.user._id36, reason=reason, force=True) # continue storing the special pages as data attributes on the subverbify # object. TODO: change this to minimize subverbify get sizes. if page.name in ATTRIBUTE_BY_PAGE: setattr(c.site, ATTRIBUTE_BY_PAGE[page.name], content) c.site._commit() except ContentLengthError as e: self.handle_error(403, 'CONTENT_LENGTH_ERROR', max_length=e.max_length) return json.dumps({}) def pre(self): WikiController.pre(self) c.render_style = 'api' set_extension(request.environ, 'json')
class OAuth2AccessController(MinimalController): handles_csrf = True def pre(self): if g.disallow_db_writes: abort(403) set_extension(request.environ, "json") MinimalController.pre(self) require_https() if request.method != "OPTIONS": c.oauth2_client = self._get_client_auth() def _get_client_auth(self): auth = request.headers.get("Authorization") try: client_id, client_secret = parse_http_basic(auth) require(client_id) client = OAuth2Client.get_token(client_id) require(client) if client.is_confidential(): require(client_secret) require(constant_time_compare(client.secret, client_secret)) return client except RequirementException: abort(401, headers=[("WWW-Authenticate", 'Basic realm="verbify"')]) def OPTIONS_access_token(self): """Send CORS headers for access token requests * Allow all origins * Only POST requests allowed to /api/v1/access_token * No ambient credentials * Authorization header required to identify the client * Expose common verbify headers """ if "Origin" in request.headers: response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = \ "POST" response.headers["Access-Control-Allow-Headers"] = \ "Authorization, " response.headers["Access-Control-Allow-Credentials"] = "false" response.headers['Access-Control-Expose-Headers'] = \ self.COMMON_VERBIFY_HEADERS @validate( grant_type=VOneOf("grant_type", ( "authorization_code", "refresh_token", "password", "client_credentials", "https://oauth.verbify.com/grants/installed_client", )), ) def POST_access_token(self, grant_type): """ Exchange an [OAuth 2.0](http://oauth.net/2/) authorization code or refresh token (from [/api/v1/authorize](#api_method_authorize)) for an access token. On success, returns a URL-encoded dictionary containing **access_token**, **token_type**, **expires_in**, and **scope**. If an authorization code for a permanent grant was given, a **refresh_token** will be included. If there is a problem, an **error** parameter will be returned instead. Must be called using SSL, and must contain a HTTP `Authorization:` header which contains the application's client identifier as the username and client secret as the password. (The client id and secret are visible on the [app preferences page](/prefs/apps).) Per the OAuth specification, **grant_type** must be one of: * ``authorization_code`` for the initial access token ("standard" OAuth2 flow) * ``refresh_token`` for renewing the access token. * ``password`` for script-type apps using password auth * ``client_credentials`` for application-only (signed out) access - confidential clients * ``https://oauth.verbify.com/grants/installed_client`` extension grant for application-only (signed out) access - non-confidential (installed) clients **redirect_uri** must exactly match the value that was used in the call to [/api/v1/authorize](#api_method_authorize) that created this grant. See verbify's [OAuth2 wiki](https://github.com/verbify/verbify/wiki/OAuth2) for more information. """ self.OPTIONS_access_token() if grant_type == "authorization_code": return self._access_token_code() elif grant_type == "refresh_token": return self._access_token_refresh() elif grant_type == "password": return self._access_token_password() elif grant_type == "client_credentials": return self._access_token_client_credentials() elif grant_type == "https://oauth.verbify.com/grants/installed_client": return self._access_token_extension_client_credentials() else: resp = {"error": "unsupported_grant_type"} return self.api_wrapper(resp) def _check_for_errors(self): resp = {} if (errors.INVALID_OPTION, "scope") in c.errors: resp["error"] = "invalid_scope" else: resp["error"] = "invalid_request" return resp @classmethod def _make_new_token_response(cls, access_token, refresh_token=None): if not access_token: return {"error": "invalid_grant"} expires_in = int(access_token._ttl) if access_token._ttl else None resp = { "access_token": access_token._id, "token_type": access_token.token_type, "expires_in": expires_in, "scope": access_token.scope, } if refresh_token: resp["refresh_token"] = refresh_token._id if access_token.device_id: resp['device_id'] = access_token.device_id return resp @validate(code=nop("code"), redirect_uri=VRequired("redirect_uri", errors.OAUTH2_INVALID_REDIRECT_URI)) def _access_token_code(self, code, redirect_uri): if not code: c.errors.add("NO_TEXT", field="code") if c.errors: return self.api_wrapper(self._check_for_errors()) access_token = None refresh_token = None client = c.oauth2_client auth_token = OAuth2AuthorizationCode.use_token(code, client._id, redirect_uri) if auth_token: if auth_token.refreshable: refresh_token = OAuth2RefreshToken._new( client_id=auth_token.client_id, user_id=auth_token.user_id, scope=auth_token.scope, ) g.stats.simple_event( 'oauth2.access_token_code.refresh_token_create') device_id = get_device_id(client) access_token = OAuth2AccessToken._new( client_id=auth_token.client_id, user_id=auth_token.user_id, scope=auth_token.scope, refresh_token=refresh_token._id if refresh_token else "", device_id=device_id, ) g.stats.simple_event( 'oauth2.access_token_code.access_token_create') resp = self._make_new_token_response(access_token, refresh_token) return self.api_wrapper(resp) @validate(refresh_token=VOAuth2RefreshToken("refresh_token")) def _access_token_refresh(self, refresh_token): access_token = None if refresh_token: if refresh_token.client_id == c.oauth2_client._id: access_token = OAuth2AccessToken._new( refresh_token.client_id, refresh_token.user_id, refresh_token.scope, refresh_token=refresh_token._id) g.stats.simple_event( 'oauth2.access_token_refresh.access_token_create') else: g.stats.simple_event( 'oauth2.errors.OAUTH2_INVALID_REFRESH_TOKEN') c.errors.add(errors.OAUTH2_INVALID_REFRESH_TOKEN) else: g.stats.simple_event('oauth2.errors.NO_TEXT') c.errors.add("NO_TEXT", field="refresh_token") if c.errors: resp = self._check_for_errors() response.status = 400 else: g.stats.simple_event('oauth2.access_token_refresh.success') resp = self._make_new_token_response(access_token) return self.api_wrapper(resp) @validate(user=VThrottledLogin(["username", "password"]), scope=nop("scope")) def _access_token_password(self, user, scope): # username:password auth via OAuth is only allowed for # private use scripts client = c.oauth2_client if client.app_type != "script": g.stats.simple_event('oauth2.errors.PASSWORD_UNAUTHORIZED_CLIENT') return self.api_wrapper({ "error": "unauthorized_client", "error_description": "Only script apps may use password auth" }) dev_ids = client._developer_ids if not user or user._id not in dev_ids: g.stats.simple_event('oauth2.errors.INVALID_GRANT') return self.api_wrapper({"error": "invalid_grant"}) if c.errors: return self.api_wrapper(self._check_for_errors()) if scope: scope = OAuth2Scope(scope) if not scope.is_valid(): g.stats.simple_event('oauth2.errors.PASSWORD_INVALID_SCOPE') c.errors.add(errors.INVALID_OPTION, "scope") return self.api_wrapper({"error": "invalid_scope"}) else: scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS) device_id = get_device_id(client) access_token = OAuth2AccessToken._new( client_id=client._id, user_id=user._id36, scope=scope, device_id=device_id, ) g.stats.simple_event( 'oauth2.access_token_password.access_token_create') resp = self._make_new_token_response(access_token) return self.api_wrapper(resp) @validate( scope=nop("scope"), ) def _access_token_client_credentials(self, scope): client = c.oauth2_client if not client.is_confidential(): g.stats.simple_event( 'oauth2.errors.CLIENT_CREDENTIALS_UNAUTHORIZED_CLIENT') return self.api_wrapper({ "error": "unauthorized_client", "error_description": "Only confidential clients may use client_credentials auth" }) if scope: scope = OAuth2Scope(scope) if not scope.is_valid(): g.stats.simple_event( 'oauth2.errors.CLIENT_CREDENTIALS_INVALID_SCOPE') c.errors.add(errors.INVALID_OPTION, "scope") return self.api_wrapper({"error": "invalid_scope"}) else: scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS) device_id = get_device_id(client) access_token = OAuth2AccessToken._new( client_id=client._id, user_id="", scope=scope, device_id=device_id, ) g.stats.simple_event( 'oauth2.access_token_client_credentials.access_token_create') resp = self._make_new_token_response(access_token) return self.api_wrapper(resp) @validate( scope=nop("scope"), device_id=VLength("device_id", 50, min_length=20), ) def _access_token_extension_client_credentials(self, scope, device_id): if ((errors.NO_TEXT, "device_id") in c.errors or (errors.TOO_SHORT, "device_id") in c.errors or (errors.TOO_LONG, "device_id") in c.errors): g.stats.simple_event('oauth2.errors.BAD_DEVICE_ID') return self.api_wrapper({ "error": "invalid_request", "error_description": "bad device_id", }) client = c.oauth2_client if scope: scope = OAuth2Scope(scope) if not scope.is_valid(): g.stats.simple_event( 'oauth2.errors.EXTENSION_CLIENT_CREDENTIALS_INVALID_SCOPE') c.errors.add(errors.INVALID_OPTION, "scope") return self.api_wrapper({"error": "invalid_scope"}) else: scope = OAuth2Scope(OAuth2Scope.FULL_ACCESS) access_token = OAuth2AccessToken._new( client_id=client._id, user_id="", scope=scope, device_id=device_id, ) g.stats.simple_event( 'oauth2.access_token_extension_client_credentials.' 'access_token_create') resp = self._make_new_token_response(access_token) return self.api_wrapper(resp) def OPTIONS_revoke_token(self): """Send CORS headers for token revocation requests * Allow all origins * Only POST requests allowed to /api/v1/revoke_token * No ambient credentials * Authorization header required to identify the client * Expose common verbify headers """ if "Origin" in request.headers: response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = \ "POST" response.headers["Access-Control-Allow-Headers"] = \ "Authorization, " response.headers["Access-Control-Allow-Credentials"] = "false" response.headers['Access-Control-Expose-Headers'] = \ self.COMMON_VERBIFY_HEADERS @validate( VRatelimit(rate_user=False, rate_ip=True, prefix="rate_revoke_token_"), token_id=nop("token"), token_hint=VOneOf("token_type_hint", ("access_token", "refresh_token")), ) def POST_revoke_token(self, token_id, token_hint): '''Revoke an OAuth2 access or refresh token. token_type_hint is optional, and hints to the server whether the passed token is a refresh or access token. A call to this endpoint is considered a success if the passed `token_id` is no longer valid. Thus, if an invalid `token_id` was passed in, a successful 204 response will be returned. See [RFC7009](http://tools.ietf.org/html/rfc7009) ''' self.OPTIONS_revoke_token() # In success cases, this endpoint returns no data. response.status = 204 if not token_id: return types = (OAuth2AccessToken, OAuth2RefreshToken) if token_hint == "refresh_token": types = reversed(types) for token_type in types: try: token = token_type._byID(token_id) except tdb_cassandra.NotFound: g.stats.simple_event( 'oauth2.POST_revoke_token.cass_not_found.%s' % token_type.__name__) continue else: break else: # No Token found. The given token ID is already gone # or never existed. Either way, from the client's perspective, # the passed in token is no longer valid. return if constant_time_compare(token.client_id, c.oauth2_client._id): token.revoke() else: # RFC 7009 is not clear on how to handle this case. # Given that a malicious client could do much worse things # with a valid token then revoke it, returning an error # here is best as it may help certain clients debug issues response.status = 400 g.stats.simple_event( 'oauth2.errors.REVOKE_TOKEN_UNAUTHORIZED_CLIENT') return self.api_wrapper({"error": "unauthorized_client"})
class PoliciesController(VerbifyController): @validate(requested_rev=nop('v')) def GET_policy_page(self, page, requested_rev): if c.render_style == 'compact': self.redirect('/wiki/' + page) if page == 'privacypolicy': wiki_name = g.wiki_page_privacy_policy pagename = _('privacy policy') elif page == 'useragreement': wiki_name = g.wiki_page_user_agreement pagename = _('user agreement') elif page == 'contentpolicy': wiki_name = g.wiki_page_content_policy pagename = _('content policy') else: abort(404) wp = WikiPage.get(Frontpage, wiki_name) revs = list(wp.get_revisions()) # collapse minor edits into revisions with reasons rev_info = [] last_edit = None for rev in revs: if rev.is_hidden: continue if not last_edit: last_edit = rev if rev._get('reason'): rev_info.append({ 'id': str(last_edit._id), 'title': rev._get('reason'), }) last_edit = None if requested_rev: try: display_rev = WikiRevision.get(requested_rev, wp._id) except (tdb_cassandra.NotFound, WikiBadRevision): abort(404) else: display_rev = revs[0] doc_html = wikimarkdown(display_rev.content, include_toc=False) soup = BeautifulSoup(doc_html.decode('utf-8')) toc = generate_table_of_contents(soup, prefix='section') self._number_sections(soup) self._linkify_headings(soup) content = PolicyView( body_html=unsafe(soup), toc_html=unsafe(toc), revs=rev_info, display_rev=str(display_rev._id), ) return PolicyPage( pagename=pagename, content=content, ).render() def _number_sections(self, soup): count = 1 for para in soup.find('div', 'md').findAll(['p'], recursive=False): a = Tag(soup, 'a', [ ('class', 'p-anchor'), ('id', 'p_%d' % count), ('href', '#p_%d' % count), ]) a.append(str(count)) para.insert(0, a) para.insert(1, ' ') count += 1 def _linkify_headings(self, soup): md_el = soup.find('div', 'md') for heading in md_el.findAll(['h1', 'h2', 'h3'], recursive=False): heading_a = Tag(soup, "a", [('href', '#%s' % heading['id'])]) heading_a.contents = heading.contents heading.contents = [] heading.append(heading_a)