Skip to content
Browse files

Wiki: Base wiki code

- Updates snudown dep
  • Loading branch information...
1 parent ebab524 commit 5fe4e997d80162e534f1c4c6ffea2b48aceaa6aa @andre-d andre-d committed with spladug Aug 24, 2012
Showing with 2,070 additions and 69 deletions.
  1. +19 −6 r2/example.ini
  2. +4 −1 r2/r2/config/middleware.py
  3. +19 −1 r2/r2/config/routing.py
  4. +3 −0 r2/r2/controllers/__init__.py
  5. +4 −4 r2/r2/controllers/error.py
  6. +15 −5 r2/r2/controllers/errors.py
  7. +5 −2 r2/r2/controllers/front.py
  8. +3 −1 r2/r2/controllers/reddit_base.py
  9. +4 −2 r2/r2/controllers/validator/validator.py
  10. +274 −0 r2/r2/controllers/validator/wiki.py
  11. +299 −0 r2/r2/controllers/wiki.py
  12. +7 −2 r2/r2/lib/app_globals.py
  13. +12 −8 r2/r2/lib/cssfilter.py
  14. +39 −5 r2/r2/lib/filters.py
  15. +1 −0 r2/r2/lib/js.py
  16. +6 −0 r2/r2/lib/menus.py
  17. +25 −3 r2/r2/lib/merge.py
  18. +40 −6 r2/r2/lib/pages/pages.py
  19. +142 −0 r2/r2/lib/pages/wiki.py
  20. +3 −3 r2/r2/lib/utils/utils.py
  21. +6 −7 r2/r2/models/account.py
  22. +16 −2 r2/r2/models/modaction.py
  23. +113 −3 r2/r2/models/subreddit.py
  24. +356 −0 r2/r2/models/wiki.py
  25. BIN r2/r2/public/static/house.png
  26. BIN r2/r2/public/static/icons/report.png
  27. +1 −0 r2/r2/public/static/js/base.js
  28. +4 −4 r2/r2/public/static/js/reddit.js
  29. +110 −0 r2/r2/public/static/js/wiki.js
  30. BIN r2/r2/public/static/page_white_copy.png
  31. BIN r2/r2/public/static/report.png
  32. +51 −1 r2/r2/templates/createsubreddit.html
  33. +2 −1 r2/r2/templates/printablebuttons.html
  34. +6 −0 r2/r2/templates/subredditstylesheet.html
  35. +67 −0 r2/r2/templates/wikibasepage.html
  36. +23 −0 r2/r2/templates/wikibasepage.xml
  37. +49 −0 r2/r2/templates/wikieditpage.html
  38. +34 −0 r2/r2/templates/wikipagediscussions.html
  39. +42 −0 r2/r2/templates/wikipagelisting.html
  40. +27 −0 r2/r2/templates/wikipagerevisions.html
  41. +23 −0 r2/r2/templates/wikipagerevisions.xml
  42. +70 −0 r2/r2/templates/wikipagesettings.html
  43. +70 −0 r2/r2/templates/wikirevision.html
  44. +30 −0 r2/r2/templates/wikirevision.xml
  45. +44 −0 r2/r2/templates/wikiview.html
  46. +2 −2 r2/setup.py
View
25 r2/example.ini
@@ -35,8 +35,6 @@ log_start = true
amqp_logging = false
# emergency measures: makes the site read only
read_only_mode = false
-# global switch for wiki write permissions
-allow_wiki_editing = true
# a modified read only mode used for cache shown during heavy load 503s
heavy_load_mode = false
# directory to write cProfile stats dumps to (disabled if not set)
@@ -424,9 +422,6 @@ ADMIN_COOKIE_MAX_IDLE = 900
# the maximum life of an otp cookie
OTP_COOKIE_TTL = 604800
-# min amount of karma to edit
-WIKI_KARMA = 100
-
# time in days
MODWINDOW = 2
HOT_PAGE_AGE = 1000
@@ -460,6 +455,8 @@ agents =
sr_banned_quota = 10000
sr_moderator_quota = 10000
sr_contributor_quota = 10000
+sr_wikibanned_quota = 10000
+sr_wikicontributor_quota = 10000
sr_quota_time = 7200
# -- email --
@@ -477,9 +474,25 @@ feedback_email = reddit@gmail.com
# Special case sensitive domains
case_sensitive_domains = i.imgur.com, youtube.com
+# Number of days to keep recent wiki revisions for
+wiki_keep_recent_days = 7
+
+# Max number of bytes for wiki pages
+wiki_max_page_length_bytes = 262144
+
+# Max wiki page name length
+wiki_max_page_name_length = 128
+
+# Max number of separators in a wiki page name
+wiki_max_page_separators = 3
+
+# Disable wiki editing and viewing for everyone except admins
+wiki_disabled = false
+
# Location (directory) for temp files for diff3 merging
# Empty will use python default for temp files
-diff3_temp_location = /dev/shm
+# Pro tip: Use /dev/shm for in-memory diff3
+diff3_temp_location =
[server:main]
use = egg:Paste#http
View
5 r2/r2/config/middleware.py
@@ -73,7 +73,10 @@ def error_mapper(code, message, environ, global_conf=None, **kw):
exception = environ.get('r2.controller.exception')
if exception:
d['explanation'] = exception.explanation
-
+ error_data = getattr(exception, 'error_data', None)
+ if error_data:
+ environ['extra_error_data'] = error_data
+
if environ.get('REDDIT_CNAME'):
d['cnameframe'] = 1
if environ.get('REDDIT_NAME'):
View
20 r2/r2/config/routing.py
@@ -185,7 +185,25 @@ def make_map():
mc('/:action', controller='embed',
requirements=dict(action="help|blog|faq"))
mc('/help/*anything', controller='embed', action='help')
-
+
+ mc('/wiki/create/*page', controller='wiki', action='wiki_create')
+ mc('/wiki/edit/*page', controller='wiki', action='wiki_revise')
+ mc('/wiki/revisions/*page', controller='wiki', action='wiki_revisions')
+ mc('/wiki/settings/*page', controller='wiki', action='wiki_settings')
+ mc('/wiki/discussions/*page', controller='wiki', action='wiki_discussions')
+ mc('/wiki/revisions', controller='wiki', action='wiki_recent')
+ mc('/wiki/pages', controller='wiki', action='wiki_listing')
+
+ mc('/wiki/api/edit/*page', controller='wikiapi', action='wiki_edit')
+ mc('/wiki/api/hide/:revision/*page', controller='wikiapi', action='wiki_revision_hide')
+ mc('/wiki/api/revert/:revision/*page', controller='wikiapi', action='wiki_revision_revert')
+ mc('/wiki/api/alloweditor/:act/:username/*page', controller='wikiapi', action='wiki_allow_editor')
+
+ mc('/wiki/*page', controller='wiki', action='wiki_page')
+ mc('/wiki/', controller='wiki', action='wiki_page')
+
+ mc('/w/*page', controller='wiki', action='wiki_redirect')
+
mc('/goto', controller='toolbar', action='goto')
mc('/tb/:id', controller='toolbar', action='tb')
mc('/toolbar/:action', controller='toolbar',
View
3 r2/r2/controllers/__init__.py
@@ -69,6 +69,9 @@ def load_controllers():
from promotecontroller import PromoteController
from mediaembed import MediaembedController
from mediaembed import AdController
+
+ from wiki import WikiController
+ from wiki import WikiApiController
from querycontroller import QueryController
View
8 r2/r2/controllers/error.py
@@ -31,6 +31,8 @@
import random as rand
from r2.lib.filters import safemarkdown, unsafe
+import json
+
try:
# place all r2 specific imports in here. If there is a code error, it'll get caught and
# the stack trace won't be presented to the user in production
@@ -165,10 +167,8 @@ def GET_document(self):
c.response.content = str(code)
return c.response
elif c.render_style == "api":
- if 'usable_error_content' in request.environ:
- c.response.content = request.environ['usable_error_content']
- else:
- c.response.content = "{\"error\": %s}" % code
+ data = request.environ.get('extra_error_data', {'error': code})
+ c.response.content = json.dumps(data)
return c.response
elif takedown and code == 404:
link = Link._by_fullname(takedown)
View
20 r2/r2/controllers/errors.py
@@ -20,8 +20,9 @@
# Inc. All Rights Reserved.
###############################################################################
-from paste.httpexceptions import HTTPForbidden
+from paste.httpexceptions import HTTPForbidden, HTTPError
from r2.lib.utils import Storage, tup
+from pylons import request
from pylons.i18n import _
from copy import copy
@@ -98,6 +99,7 @@
('OAUTH2_INVALID_SCOPE', _('invalid scope requested')),
('OAUTH2_ACCESS_DENIED', _('access denied by the user')),
('CONFIRM', _("please confirm the form")),
+ ('CONFLICT', _("conflict error while saving")),
('NO_API', _('cannot perform this action via the API')),
('DOMAIN_BANNED', _('%(domain)s is not allowed on reddit: %(reason)s')),
('NO_OTP_SECRET', _('you must enable two-factor authentication')),
@@ -110,12 +112,13 @@
class Error(object):
- def __init__(self, name, i18n_message, msg_params, field = None):
+ def __init__(self, name, i18n_message, msg_params, field=None, code=None):
self.name = name
self.i18n_message = i18n_message
self.msg_params = msg_params
# list of fields in the original form that caused the error
self.fields = tup(field) if field else []
+ self.code = code
@property
def message(self):
@@ -151,10 +154,10 @@ def __iter__(self):
def __len__(self):
return len(self.errors)
- def add(self, error_name, msg_params = {}, field = None):
- msg = error_list[error_name]
+ def add(self, error_name, msg_params={}, field=None, code=None):
+ msg = error_list.get(error_name)
for field_name in tup(field):
- e = Error(error_name, msg, msg_params, field = field_name)
+ e = Error(error_name, msg, msg_params, field=field_name, code=code)
self.errors[(error_name, field_name)] = e
def remove(self, pair):
@@ -163,6 +166,13 @@ def remove(self, pair):
if self.errors.has_key(pair):
del self.errors[pair]
+class WikiError(HTTPError):
+ def __init__(self, code, reason=None, **data):
+ self.code = code
+ data['reason'] = self.explanation = reason or 'UNKNOWN_ERROR'
+ self.error_data = data
+ HTTPError.__init__(self)
+
class ForbiddenError(HTTPForbidden):
def __init__(self, error):
HTTPForbidden.__init__(self)
View
7 r2/r2/controllers/front.py
@@ -871,7 +871,9 @@ def GET_submit(self, url, title, text, selftext, then):
captcha = Captcha() if c.user.needs_captcha() else None
sr_names = (Subreddit.submit_sr_names(c.user) or
Subreddit.submit_sr_names(None))
-
+
+ never_show_self = request.get.get('no_self')
+
return FormPage(_("submit"),
show_sidebar = True,
page_classes=['submit-page'],
@@ -882,6 +884,7 @@ def GET_submit(self, url, title, text, selftext, then):
subreddits = sr_names,
captcha=captcha,
resubmit=resubmit,
+ never_show_self = never_show_self,
then = then)).render()
def GET_frame(self):
@@ -1191,7 +1194,7 @@ def GET_validuser(self):
returns their user name"""
c.response_content_type = 'text/plain'
if c.user_is_loggedin:
- perm = str(g.allow_wiki_editing and c.user.can_wiki())
+ perm = str(c.user.can_wiki())
c.response.content = c.user.name + "," + perm
else:
c.response.content = ''
View
4 r2/r2/controllers/reddit_base.py
@@ -603,7 +603,9 @@ def pre(self):
ratelimit_agents()
c.allow_loggedin_cache = False
-
+
+ c.show_wiki_actions = False
+
# the domain has to be set before Cookies get initialized
set_subreddit()
c.errors = ErrorSet()
View
6 r2/r2/controllers/validator/validator.py
@@ -85,15 +85,15 @@ def __init__(self, param=None, default=None, post=True, get=True, url=True,
self.post, self.get, self.url, self.docs = post, get, url, docs
self.has_errors = False
- def set_error(self, error, msg_params = {}, field = False):
+ def set_error(self, error, msg_params={}, field=False, code=None):
"""
Adds the provided error to c.errors and flags that it is come
from the validator's param
"""
if field is False:
field = self.param
- c.errors.add(error, msg_params = msg_params, field = field)
+ c.errors.add(error, msg_params=msg_params, field=field, code=code)
self.has_errors = True
def param_docs(self):
@@ -161,6 +161,8 @@ def set_api_docs(fn, simple_vals, param_vals):
param_info.update(validator.param_docs())
doc['parameters'] = param_info
+make_validated_kw = _make_validated_kw
+
def validate(*simple_vals, **param_vals):
def val(fn):
@utils.wraps_api(fn)
View
274 r2/r2/controllers/validator/wiki.py
@@ -0,0 +1,274 @@
+# The contents of this file are subject to the Common Public Attribution
+# License Version 1.0. (the "License"); you may not use this file except in
+# compliance with the License. You may obtain a copy of the License at
+# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
+# License Version 1.1, but Sections 14 and 15 have been added to cover use of
+# software over a computer network and provide for limited attribution for the
+# Original Developer. In addition, Exhibit A has been modified to be consistent
+# with Exhibit B.
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
+# the specific language governing rights and limitations under the License.
+#
+# The Original Code is reddit.
+#
+# The Original Developer is the Initial Developer. The Initial Developer of
+# the Original Code is reddit Inc.
+#
+# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit
+# Inc. All Rights Reserved.
+###############################################################################
+
+from os.path import normpath
+import datetime
+
+from pylons.controllers.util import redirect_to
+from pylons import c, g, request
+
+from r2.models.wiki import WikiPage, WikiRevision
+from r2.controllers.validator import Validator, validate, make_validated_kw
+from r2.lib.db import tdb_cassandra
+
+MAX_PAGE_NAME_LENGTH = g.wiki_max_page_name_length
+
+MAX_SEPARATORS = g.wiki_max_page_separators
+
+def wiki_validate(*simple_vals, **param_vals):
+ def val(fn):
+ def newfn(self, *a, **env):
+ kw = make_validated_kw(fn, simple_vals, param_vals, env)
+ for e in c.errors:
+ e = c.errors[e]
+ if e.code:
+ self.handle_error(e.code, e.name)
+ return fn(self, *a, **kw)
+ return newfn
+ return val
+
+def this_may_revise(page=None):
+ if not c.user_is_loggedin:
+ return False
+
+ if c.user_is_admin:
+ return True
+
+ return may_revise(c.site, c.user, page)
+
+def this_may_view(page):
+ user = c.user if c.user_is_loggedin else None
+ return may_view(c.site, user, page)
+
+def may_revise(sr, user, page=None):
+ if sr.is_moderator(user):
+ # Mods may always contribute
+ return True
+
+ if page and page.restricted and not page.special:
+ # People may not contribute to restricted pages
+ # (Except for special pages)
+ return False
+
+ if sr.is_wikibanned(user):
+ # Users who are wiki banned in the subreddit may not contribute
+ return False
+
+ if page and not may_view(sr, user, page):
+ # Users who are not allowed to view the page may not contribute to the page
+ return False
+
+ if not user.can_wiki():
+ # Global wiki contributute ban
+ return False
+
+ if page and page.has_editor(user.name):
+ # If the user is an editor on the page, they may edit
+ return True
+
+ if not sr.can_submit(user):
+ # If the user can not submit to the subreddit
+ # They should not be able to contribute
+ return False
+
+ if page and page.special:
+ # If this is a special page
+ # (and the user is not a mod or page editor)
+ # They should not be allowed to revise
+ return False
+
+ if page and page.permlevel > 0:
+ # If the page is beyond "anyone may contribute"
+ # A normal user should not be allowed to revise
+ return False
+
+ if sr.is_wikicontributor(user):
+ # If the user is a wiki contributor, they may revise
+ return True
+
+ karma = max(user.karma('link', sr), user.karma('comment', sr))
+ if karma < sr.wiki_edit_karma:
+ # If the user has too few karma, they should not contribute
+ return False
+
+ age = (datetime.datetime.now(g.tz) - user._date).days
+ if age < sr.wiki_edit_age:
+ # If they user's account is too young
+ # They should not contribute
+ return False
+
+ # Otherwise, allow them to contribute
+ return True
+
+def may_view(sr, user, page):
+ # User being None means not logged in
+ mod = sr.is_moderator(user) if user else False
+
+ if mod:
+ # Mods may always view
+ return True
+
+ if page.special:
+ # Special pages may always be viewed
+ # (Permission level ignored)
+ return True
+
+ level = page.permlevel
+
+ if level < 2:
+ # Everyone may view in levels below 2
+ return True
+
+ if level == 2:
+ # Only mods may view in level 2
+ return mod
+
+ # In any other obscure level,
+ # (This should not happen but just in case)
+ # nobody may view.
+ return False
+
+def normalize_page(page):
+ # Case insensitive page names
+ page = page.lower()
+
+ # Normalize path
+ page = normpath(page)
+
+ # Chop off initial "/", just in case it exists
+ page = page.lstrip('/')
+
+ return page
+
+class AbortWikiError(Exception):
+ pass
+
+class VWikiPage(Validator):
+ def __init__(self, param, required=True, restricted=True, modonly=False, **kw):
+ self.restricted = restricted
+ self.modonly = modonly
+ self.required = required
+ Validator.__init__(self, param, **kw)
+
+ def run(self, page):
+ if not page:
+ # If no page is specified, give the index page
+ page = "index"
+
+ try:
+ page = str(page)
+ except UnicodeEncodeError:
+ return self.set_error('INVALID_PAGE_NAME', code=400)
+
+ if ' ' in page:
+ new_name = page.replace(' ', '_')
+ url = '%s/%s' % (c.wiki_base_url, new_name)
+ redirect_to(url)
+
+ page = normalize_page(page)
+
+ c.page = page
+ if (not c.is_wiki_mod) and self.modonly:
+ return self.set_error('MOD_REQUIRED', code=403)
+
+ try:
+ wp = self.validpage(page)
+ except AbortWikiError:
+ return
+
+ # TODO: MAKE NOT REQUIRED
+ c.page_obj = wp
+
+ return wp
+
+ def validpage(self, page):
+ try:
+ wp = WikiPage.get(c.site, page)
+ if self.restricted and wp.restricted:
+ if not wp.special:
+ self.set_error('RESTRICTED_PAGE', code=403)
+ raise AbortWikiError
+ if not this_may_view(wp):
+ self.set_error('MAY_NOT_VIEW', code=403)
+ raise AbortWikiError
+ return wp
+ except tdb_cassandra.NotFound:
+ if self.required:
+ self.set_error('PAGE_NOT_FOUND', code=404)
+ raise AbortWikiError
+ return None
+
+ def validversion(self, version, pageid=None):
+ if not version:
+ return
+ try:
+ r = WikiRevision.get(version, pageid)
+ if r.is_hidden and not c.is_wiki_mod:
+ self.set_error('HIDDEN_REVISION', code=403)
+ raise AbortWikiError
+ return r
+ except (tdb_cassandra.NotFound, ValueError):
+ self.set_error('INVALID_REVISION', code=404)
+ raise AbortWikiError
+
+class VWikiPageAndVersion(VWikiPage):
+ def run(self, page, *versions):
+ wp = VWikiPage.run(self, page)
+ validated = []
+ for v in versions:
+ try:
+ validated += [self.validversion(v, wp._id) if v and wp else None]
+ except AbortWikiError:
+ return
+ return tuple([wp] + validated)
+
+class VWikiPageRevise(VWikiPage):
+ def run(self, page, previous=None):
+ wp = VWikiPage.run(self, page)
+ if not wp:
+ return self.set_error('INVALID_PAGE', code=404)
+ if not this_may_revise(wp):
+ return self.set_error('MAY_NOT_REVISE', code=403)
+ if previous:
+ try:
+ prev = self.validversion(previous, wp._id)
+ except AbortWikiError:
+ return
+ return (wp, prev)
+ return (wp, None)
+
+class VWikiPageCreate(Validator):
+ def run(self, page):
+ page = normalize_page(page)
+ if c.is_wiki_mod and WikiPage.is_special(page):
+ c.error = {'reason': 'PAGE_CREATED_ELSEWHERE'}
+ elif page.count('/') > MAX_SEPARATORS:
+ c.error = {'reason': 'PAGE_NAME_MAX_SEPARATORS', 'max_separators': MAX_SEPERATORS}
+ elif len(page) > MAX_PAGE_NAME_LENGTH:
+ c.error = {'reason': 'PAGE_NAME_LENGTH', 'max_length': MAX_PAGE_NAME_LENGTH}
+ else:
+ try:
+ WikiPage.get(c.site, page)
+ c.error = {'reason': 'PAGE_EXISTS'}
+ except tdb_cassandra.NotFound:
+ pass
+ return this_may_revise()
View
299 r2/r2/controllers/wiki.py
@@ -0,0 +1,299 @@
+## The contents of this file are subject to the Common Public Attribution
+## License Version 1.0. (the "License"); you may not use this file except in
+## compliance with the License. You may obtain a copy of the License at
+## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
+## License Version 1.1, but Sections 14 and 15 have been added to cover use of
+## software over a computer network and provide for limited attribution for the
+## Original Developer. In addition, Exhibit A has been modified to be
+## consistent with Exhibit B.
+##
+## Software distributed under the License is distributed on an "AS IS" basis,
+## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
+## the specific language governing rights and limitations under the License.
+##
+## The Original Code is reddit.
+##
+## The Original Developer is the Initial Developer. The Initial Developer of
+## the Original Code is reddit Inc.
+##
+## All portions of the code written by reddit are Copyright (c) 2006-2012
+## reddit Inc. All Rights Reserved.
+###############################################################################
+
+from pylons import request, g, c, Response
+from pylons.controllers.util import redirect_to
+from reddit_base import RedditController
+from r2.lib.utils import url_links
+from reddit_base import paginated_listing
+from r2.models.wiki import (WikiPage, WikiRevision, ContentLengthError,
+ modactions)
+from r2.models.subreddit import Subreddit
+from r2.models.modaction import ModAction
+from r2.models.builder import WikiRevisionBuilder, WikiRecentRevisionBuilder
+
+from r2.lib.template_helpers import join_urls
+
+
+from r2.controllers.validator import VMarkdown
+
+from r2.controllers.validator.wiki import (VWikiPage, VWikiPageAndVersion,
+ VWikiPageRevise, VWikiPageCreate,
+ this_may_view, wiki_validate)
+
+from r2.lib.pages.wiki import (WikiPageView, WikiNotFound, WikiRevisions,
+ WikiEdit, WikiSettings, WikiRecent,
+ WikiListing, WikiDiscussions)
+
+from r2.config.extensions import set_extension
+from r2.lib.template_helpers import add_sr
+from r2.lib.db import tdb_cassandra
+from r2.controllers.errors import errors
+from r2.models.listing import WikiRevisionListing
+from r2.lib.pages.things import default_thing_wrapper
+from r2.lib.pages import BoringPage
+from reddit_base import base_listing
+from r2.models import IDBuilder, LinkListing, DefaultSR
+from validator.validator import VInt, VExistingUname, VRatelimit
+from r2.lib.merge import ConflictException, make_htmldiff
+from pylons.i18n import _
+from r2.lib.pages import PaneStack
+from r2.lib.utils import timesince
+
+from r2.lib.base import abort
+from r2.controllers.errors import WikiError
+
+import json
+
+page_descriptions = {'config/stylesheet':_("This page is the subreddit stylesheet, changes here apply to the subreddit css"),
+ 'config/sidebar':_("The contents of this page appear on the subreddit sidebar")}
+
+class WikiController(RedditController):
+ allow_stylesheets = True
+
+ @wiki_validate(pv=VWikiPageAndVersion(('page', 'v', 'v2'), required=False,
+ restricted=False))
+ def GET_wiki_page(self, pv):
+ page, version, version2 = pv
+ message = None
+
+ if not page:
+ return self.GET_wiki_create(page=c.page, view=True)
+
+ if version:
+ edit_by = version.author_name()
+ edit_date = version.date
+ else:
+ edit_by = page.author_name()
+ edit_date = page._get('last_edit_date')
+
+ diffcontent = None
+ if not version:
+ content = page.content
+ if c.is_wiki_mod and page.name in page_descriptions:
+ message = page_descriptions[page.name]
+ else:
+ message = _("viewing revision from %s") % timesince(version.date)
+ if version2:
+ t1 = timesince(version.date)
+ t2 = timesince(version2.date)
+ timestamp1 = _("%s ago") % t1
+ timestamp2 = _("%s ago") % t2
+ message = _("comparing revisions from %(date_1)s and %(date_2)s") \
+ % {'date_1': t1, 'date_2': t2}
+ diffcontent = make_htmldiff(version.content, version2.content, timestamp1, timestamp2)
+ content = version2.content
+ else:
+ message = _("viewing revision from %s ago") % timesince(version.date)
+ content = version.content
+
+ return WikiPageView(content, alert=message, v=version, diff=diffcontent,
+ edit_by=edit_by, edit_date=edit_date).render()
+
+ @paginated_listing(max_page_size=100, backend='cassandra')
+ @wiki_validate(page=VWikiPage(('page'), restricted=False))
+ def GET_wiki_revisions(self, num, after, reverse, count, page):
+ revisions = page.get_revisions()
+ builder = WikiRevisionBuilder(revisions, num=num, reverse=reverse, count=count, after=after, skip=not c.is_wiki_mod, wrap=default_thing_wrapper())
+ listing = WikiRevisionListing(builder).listing()
+ return WikiRevisions(listing).render()
+
+ @wiki_validate(may_create=VWikiPageCreate('page'))
+ def GET_wiki_create(self, may_create, page, view=False):
+ api = c.extension == 'json'
+
+ if c.error and c.error['reason'] == 'PAGE_EXISTS':
+ return self.redirect(join_urls(c.wiki_base_url, page))
+ elif not may_create or api:
+ if may_create and c.error:
+ self.handle_error(403, **c.error)
+ else:
+ self.handle_error(404, 'PAGE_NOT_FOUND', may_create=may_create)
+ elif c.error:
+ error = ''
+ if c.error['reason'] == 'PAGE_NAME_LENGTH':
+ error = _("this wiki cannot handle page names of that magnitude! please select a page name shorter than %d characters") % c.error['max_length']
+ elif c.error['reason'] == 'PAGE_CREATED_ELSEWHERE':
+ error = _("this page is a special page, please go into the subreddit settings and save the field once to create this special page")
+ elif c.error['reason'] == 'PAGE_NAME_MAX_SEPERATORS':
+ error = _('a max of %d separators "/" are allowed in a wiki page name.') % c.error['max_separator']
+ return BoringPage(_("Wiki error"), infotext=error).render()
+ elif view:
+ return WikiNotFound().render()
+ elif may_create:
+ WikiPage.create(c.site, page)
+ url = join_urls(c.wiki_base_url, '/edit/', page)
+ return self.redirect(url)
+
+ @wiki_validate(page=VWikiPageRevise('page', restricted=True))
+ def GET_wiki_revise(self, page, message=None, **kw):
+ page = page[0]
+ previous = kw.get('previous', page._get('revision'))
+ content = kw.get('content', page.content)
+ if not message and page.name in page_descriptions:
+ message = page_descriptions[page.name]
+ return WikiEdit(content, previous, alert=message).render()
+
+ @paginated_listing(max_page_size=100, backend='cassandra')
+ def GET_wiki_recent(self, num, after, reverse, count):
+ revisions = WikiRevision.get_recent(c.site)
+ builder = WikiRecentRevisionBuilder(revisions, num=num, count=count,
+ reverse=reverse, after=after,
+ wrap=default_thing_wrapper(),
+ skip=not c.is_wiki_mod)
+ listing = WikiRevisionListing(builder).listing()
+ return WikiRecent(listing).render()
+
+ def GET_wiki_listing(self):
+ def check_hidden(page):
+ g.log.debug("Got here %s" % str(this_may_view(page)))
+ return this_may_view(page)
+ pages = WikiPage.get_listing(c.site, filter_check=check_hidden)
+ return WikiListing(pages).render()
+
+ def GET_wiki_redirect(self, page):
+ return redirect_to(str("%s/%s" % (c.wiki_base_url, page)), _code=301)
+
+ @base_listing
+ @wiki_validate(page=VWikiPage('page', restricted=True))
+ def GET_wiki_discussions(self, page, num, after, reverse, count):
+ page_url = add_sr("%s/%s" % (c.wiki_base_url, page.name))
+ links = url_links(page_url)
+ builder = IDBuilder([ link._fullname for link in links ],
+ num = num, after = after, reverse = reverse,
+ count = count, skip = False)
+ listing = LinkListing(builder).listing()
+ return WikiDiscussions(listing).render()
+
+ @wiki_validate(page=VWikiPage('page', restricted=True, modonly=True))
+ def GET_wiki_settings(self, page):
+ settings = {'permlevel': page._get('permlevel', 0)}
+ mayedit = page.get_editors()
+ return WikiSettings(settings, mayedit, show_settings=not page.special).render()
+
+ @wiki_validate(page=VWikiPage('page', restricted=True, modonly=True),\
+ permlevel=VInt('permlevel'))
+ def POST_wiki_settings(self, page, permlevel):
+ oldpermlevel = page.permlevel
+ try:
+ page.change_permlevel(permlevel)
+ except ValueError:
+ self.handle_error(403, 'INVALID_PERMLEVEL')
+ description = 'Page: %s, Changed from %s to %s' % (page.name, oldpermlevel, permlevel)
+ ModAction.create(c.site, c.user, 'wikipermlevel', description=description)
+ return self.GET_wiki_settings(page=page.name)
+
+ def handle_error(self, code, error=None, **data):
+ abort(WikiError(code, error, **data))
+
+ def pre(self):
+ RedditController.pre(self)
+ if g.wiki_disabled and not c.user_is_admin:
+ self.handle_error(403, 'WIKI_DOWN')
+ if not c.site._should_wiki:
+ self.handle_error(404, 'NOT_WIKIABLE') # /r/mod for an example
+ frontpage = isinstance(c.site, DefaultSR)
+ c.wiki_base_url = '/wiki' if frontpage else '/r/%s/wiki' % c.site.name
+ c.wiki_id = g.default_sr if frontpage else c.site.name
+ c.page = None
+ c.show_wiki_actions = True
+ self.editconflict = False
+ c.is_wiki_mod = (c.user_is_admin or c.site.is_moderator(c.user)) if c.user_is_loggedin else False
+ c.wikidisabled = False
+
+ mode = c.site.wikimode
+ if not mode or mode == 'disabled':
+ if not c.is_wiki_mod:
+ self.handle_error(403, 'WIKI_DISABLED')
+ else:
+ c.wikidisabled = True
+
+class WikiApiController(WikiController):
+ @wiki_validate(pageandprevious=VWikiPageRevise(('page', 'previous'), restricted=True),
+ content=VMarkdown(('content')))
+ def POST_wiki_edit(self, pageandprevious, content):
+ page, previous = pageandprevious
+ previous = previous._id if previous else None
+ try:
+ if page.name == 'config/stylesheet':
+ report, parsed = c.site.parse_css(content, verify=False)
+ if report is None: # g.css_killswitch
+ self.handle_error(403, 'STYLESHEET_EDIT_DENIED')
+ if report.errors:
+ error_items = [x.message for x in sorted(report.errors)]
+ self.handle_error(415, 'SPECIAL_ERRORS', special_errors=error_items)
+ c.site.change_css(content, parsed, previous, reason=request.POST['reason'])
+ else:
+ try:
+ page.revise(content, previous, c.user.name, reason=request.POST['reason'])
+ except ContentLengthError as e:
+ self.handle_error(403, 'CONTENT_LENGTH_ERROR', max_length = e.max_length)
+ 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)
+ except ConflictException as e:
+ self.handle_error(409, 'EDIT_CONFLICT', newcontent=e.new, newrevision=page.revision, diffcontent=e.htmldiff)
+ return json.dumps({})
+
+ @wiki_validate(page=VWikiPage('page'), user=VExistingUname('username'))
+ def POST_wiki_allow_editor(self, act, page, user):
+ if not c.is_wiki_mod:
+ self.handle_error(403, 'MOD_REQUIRED')
+ if act == 'del':
+ page.remove_editor(c.username)
+ else:
+ if not user:
+ self.handle_error(404, 'UNKOWN_USER')
+ page.add_editor(user.name)
+ return json.dumps({})
+
+ @wiki_validate(pv=VWikiPageAndVersion(('page', 'revision')))
+ def POST_wiki_revision_hide(self, pv, page, revision):
+ if not c.is_wiki_mod:
+ self.handle_error(403, 'MOD_REQUIRED')
+ page, revision = pv
+ return json.dumps({'status': revision.toggle_hide()})
+
+ @wiki_validate(pv=VWikiPageAndVersion(('page', 'revision')))
+ def POST_wiki_revision_revert(self, pv, page, revision):
+ if not c.is_wiki_mod:
+ self.handle_error(403, 'MOD_REQUIRED')
+ page, revision = pv
+ content = revision.content
+ author = revision._get('author')
+ reason = 'reverted back %s' % timesince(revision.date)
+ if page.name == 'config/stylesheet':
+ report, parsed = c.site.parse_css(content)
+ if report.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=author, reason=reason, force=True)
+ except ContentLengthError as e:
+ self.handle_error(403, 'CONTENT_LENGTH_ERROR', e.max_length)
+ return json.dumps({})
+
+ def pre(self):
+ WikiController.pre(self)
+ c.render_style = 'api'
+ set_extension(request.environ, 'json')
View
9 r2/r2/lib/app_globals.py
@@ -82,7 +82,6 @@ class Globals(object):
'MIN_RATE_LIMIT_COMMENT_KARMA',
'VOTE_AGE_LIMIT',
'REPLY_AGE_LIMIT',
- 'WIKI_KARMA',
'HOT_PAGE_AGE',
'MODWINDOW',
'RATELIMIT',
@@ -103,9 +102,15 @@ class Globals(object):
'bcrypt_work_factor',
'cassandra_pool_size',
'sr_banned_quota',
+ 'sr_wikibanned_quota',
+ 'sr_wikicontributor_quota',
'sr_moderator_quota',
'sr_contributor_quota',
'sr_quota_time',
+ 'wiki_keep_recent_days',
+ 'wiki_max_page_length_bytes',
+ 'wiki_max_page_name_length',
+ 'wiki_max_page_separators',
],
ConfigValue.float: [
@@ -134,7 +139,7 @@ class Globals(object):
'disable_ratelimit',
'amqp_logging',
'read_only_mode',
- 'allow_wiki_editing',
+ 'wiki_disabled',
'heavy_load_mode',
's3_media_direct',
'disable_captcha',
View
20 r2/r2/lib/cssfilter.py
@@ -177,6 +177,17 @@ def __str__(self):
obj = str(self.obj) if hasattr(self,'obj') else ''
return "ValidationError%s: %s (%s)" % (line, self.message, obj)
+def legacy_s3_url(url, site):
+ if isinstance(url, int): # legacy url, needs to be generated
+ bucket = g.s3_old_thumb_bucket
+ baseurl = "http://%s" % (bucket)
+ if g.s3_media_direct:
+ baseurl = "http://%s/%s" % (s3_direct_url, bucket)
+ url = "%s/%s_%d.png"\
+ % (baseurl, site._fullname, url)
+ url = s3_https_if_secure(url)
+ return url
+
# local urls should be in the static directory
local_urls = re.compile(r'\A/static/[a-z./-]+\Z')
# substitutable urls will be css-valid labels surrounded by "%%"
@@ -211,14 +222,7 @@ def valid_url(prop,value,report):
# the label -> image number lookup is stored on the subreddit
if c.site.images.has_key(name):
url = c.site.images[name]
- if isinstance(url, int): # legacy url, needs to be generated
- bucket = g.s3_old_thumb_bucket
- baseurl = "http://%s" % (bucket)
- if g.s3_media_direct:
- baseurl = "http://%s/%s" % (s3_direct_url, bucket)
- url = "%s/%s_%d.png"\
- % (baseurl, c.site._fullname, url)
- url = s3_https_if_secure(url)
+ url = legacy_s3_url(url, c.site)
value._setCssText("url(%s)"%url)
else:
# unknown image label -> error
View
44 r2/r2/lib/filters.py
@@ -42,6 +42,8 @@
MD_START = '<div class="md">'
MD_END = '</div>'
+WIKI_MD_START = '<div class="md wiki">'
+WIKI_MD_END = '</div>'
def python_websafe(text):
return text.replace('&', "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
@@ -175,17 +177,22 @@ def startElementNS(self, tagname, qname, attrs):
markdown_ok_tags = {
'div': ('class'),
'a': set(('href', 'title', 'target', 'nofollow')),
- 'table': ("align", ),
- 'th': ("align", ),
- 'td': ("align", ),
+
}
+
markdown_boring_tags = ('p', 'em', 'strong', 'br', 'ol', 'ul', 'hr', 'li',
'pre', 'code', 'blockquote', 'center',
- 'tbody', 'thead', 'tr', 'sup', 'del',
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',)
+ 'sup', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',)
+
+markdown_user_tags = ('table', 'th', 'tr', 'td', 'tbody', 'img',
+ 'tbody', 'thead', 'tr', 'tfoot', 'caption')
+
for bt in markdown_boring_tags:
markdown_ok_tags[bt] = ()
+for bt in markdown_user_tags:
+ markdown_ok_tags[bt] = ('colspan', 'rowspan', 'cellspacing', 'cellpadding', 'align', 'scope')
+
markdown_xhtml_dtd_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'contrib/dtds/xhtml.dtd')
@@ -229,6 +236,33 @@ def safemarkdown(text, nofollow=False, wrap=True, **kwargs):
else:
return SC_OFF + text + SC_ON
+def wikimarkdown(text):
+ from r2.lib.cssfilter import legacy_s3_url
+
+ def img_swap(tag):
+ name = tag.get('src')
+ if c.site.images.has_key(name):
+ url = c.site.images[name]
+ url = legacy_s3_url(url, c.site)
+ tag['src'] = url
+ else:
+ tag.extract()
+
+ nofollow = True
+ target = None
+
+ text = snudown.markdown(_force_utf8(text), nofollow, target,
+ renderer=snudown.RENDERER_WIKI, enable_toc=True)
+
+ # TODO: We should test how much of a load this adds to the app
+ soup = BeautifulSoup(text)
+ images = soup.findAll('img')
+
+ if images:
+ [img_swap(image) for image in images]
+ text = str(soup)
+
+ return SC_OFF + WIKI_MD_START + text + WIKI_MD_END + SC_ON
def keep_space(text):
text = websafe(text)
View
1 r2/r2/lib/js.py
@@ -282,6 +282,7 @@ def use(self):
"analytics.js",
"flair.js",
"interestbar.js",
+ "wiki.js",
"reddit.js",
"apps.js",
)
View
6 r2/r2/lib/menus.py
@@ -138,6 +138,12 @@ def __getattr__(self, attr):
log = _("moderation log"),
modqueue = _("moderation queue"),
unmoderated = _("unmoderated links"),
+
+ wikibanned = _("ban wiki contributors"),
+ wikicontributors = _("add wiki contributors"),
+
+ wikirecentrevisions = _("recent wiki revisions"),
+ wikipageslist = _("wiki page list"),
popular = _("popular"),
create = _("create"),
View
28 r2/r2/lib/merge.py
@@ -1,3 +1,25 @@
+# The contents of this file are subject to the Common Public Attribution
+# License Version 1.0. (the "License"); you may not use this file except in
+# compliance with the License. You may obtain a copy of the License at
+# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
+# License Version 1.1, but Sections 14 and 15 have been added to cover use of
+# software over a computer network and provide for limited attribution for the
+# Original Developer. In addition, Exhibit A has been modified to be consistent
+# with Exhibit B.
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
+# the specific language governing rights and limitations under the License.
+#
+# The Original Code is reddit.
+#
+# The Original Developer is the Initial Developer. The Initial Developer of
+# the Original Code is reddit Inc.
+#
+# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit
+# Inc. All Rights Reserved.
+###############################################################################
+
import subprocess
import tempfile
import difflib
@@ -20,7 +42,7 @@ def make_htmldiff(a, b, adesc, bdesc):
fromdesc=adesc,
todesc=bdesc)
-def threeWayMerge(original, a, b):
+def threewaymerge(original, a, b):
try:
temp_dir = g.diff3_temp_location if g.diff3_temp_location else None
data = [a, original, b]
@@ -49,8 +71,8 @@ class test_globals:
a = "Hello people of the human rance\n\nHow are you today"
b = "Hello people of the human race\n\nHow are you tday"
- print threeWayMerge(original, a, b)
+ print threewaymerge(original, a, b)
g.diff3_temp_location = '/dev/shm'
- print threeWayMerge(original, a, b)
+ print threewaymerge(original, a, b)
View
46 r2/r2/lib/pages/pages.py
@@ -191,7 +191,30 @@ def __init__(self, space_compress = True, nav_menus = None, loginbox = True,
self._content = content
self.toolbars = self.build_toolbars()
-
+
+ def wiki_actions_menu(self, moderator=False):
+ buttons = []
+
+ buttons.append(NamedButton("wikirecentrevisions",
+ css_class="wikiaction-revisions",
+ dest="revisions"))
+
+ buttons.append(NamedButton("wikipageslist",
+ css_class="wikiaction-pages",
+ dest="pages"))
+ if moderator:
+ buttons += [NamedButton('wikibanned', css_class = 'reddit-ban'),
+ NamedButton('wikicontributors', css_class = 'reddit-contributors')]
+
+ return SideContentBox(_('wiki tools'),
+ [NavMenu(buttons,
+ type="flat_vert",
+ base_path="/wiki/",
+ css_class="icon-menu",
+ separator="")],
+ _id="wikiactions",
+ collapsible=True)
+
def sr_admin_menu(self):
buttons = []
is_single_subreddit = not isinstance(c.site, (ModSR, MultiReddit))
@@ -263,7 +286,6 @@ def rightbox(self):
if isinstance(c.site, (MultiReddit, ModSR)) and c.user_is_loggedin:
srs = Subreddit._byID(c.site.sr_ids,data=True,
return_dict=False)
-
if c.user_is_admin or c.site.is_moderator(c.user):
ps.append(self.sr_admin_menu())
@@ -273,12 +295,17 @@ def rightbox(self):
# don't show the subreddit info bar on cnames unless the option is set
if not isinstance(c.site, FakeSubreddit) and (not c.cname or c.site.show_cname_sidebar):
ps.append(SubredditInfoBar())
- if c.user_is_loggedin and (c.user_is_admin or
- c.site.is_moderator(c.user)):
+ moderator = c.user_is_loggedin and (c.user_is_admin or
+ c.site.is_moderator(c.user))
+ if c.show_wiki_actions:
+ ps.append(self.wiki_actions_menu(moderator=moderator))
+ if moderator:
ps.append(self.sr_admin_menu())
if (c.user.pref_show_adbox or not c.user.gold) and not g.disable_ads:
ps.append(Ads())
no_ads_yet = False
+ elif c.show_wiki_actions:
+ ps.append(self.wiki_actions_menu())
user_banned = c.user_is_loggedin and c.site.is_banned(c.user)
if self.submit_box and (c.user_is_loggedin or not g.read_only_mode) and not user_banned:
@@ -388,6 +415,13 @@ def build_toolbars(self):
if c.user_is_loggedin:
main_buttons.append(NamedButton('saved', False))
+ mod = False
+ if c.user_is_loggedin:
+ mod = bool(c.user_is_admin or c.site.is_moderator(c.user))
+ if c.site.wikimode != 'disabled' or mod:
+ if not g.wiki_disabled:
+ main_buttons.append(NavButton('wiki', 'wiki'))
+
more_buttons = []
if c.user_is_loggedin:
@@ -1888,7 +1922,7 @@ def add_props(cls, user, wrapped):
class NewLink(Templated):
"""Render the link submission form"""
def __init__(self, captcha = None, url = '', title= '', text = '', selftext = '',
- subreddits = (), then = 'comments', resubmit=False):
+ subreddits = (), then = 'comments', resubmit=False, never_show_self=False):
self.show_link = self.show_self = False
@@ -1898,7 +1932,7 @@ def __init__(self, captcha = None, url = '', title= '', text = '', selftext = ''
self.show_link = True
if c.default_sr or c.site.link_type != 'link':
tabs.append(('text', ('text-desc', 'text-field')))
- self.show_self = True
+ self.show_self = not never_show_self
if self.show_self and self.show_link:
all_fields = set(chain(*(parts for (tab, parts) in tabs)))
View
142 r2/r2/lib/pages/wiki.py
@@ -0,0 +1,142 @@
+from r2.lib.pages.pages import Reddit
+from pylons import c
+from r2.lib.wrapped import Templated
+from r2.lib.menus import PageNameNav
+from r2.controllers.validator.wiki import this_may_revise
+from r2.lib.filters import wikimarkdown
+from pylons.i18n import _
+
+class WikiView(Templated):
+ def __init__(self, content, edit_by, edit_date, diff=None):
+ self.page_content = wikimarkdown(content) if content else ''
+ self.page_content_md = content
+ self.diff = diff
+ self.edit_by = edit_by
+ self.edit_date = edit_date
+ self.base_url = c.wiki_base_url
+ self.may_revise = this_may_revise(c.page_obj)
+ Templated.__init__(self)
+
+class WikiPageListing(Templated):
+ def __init__(self, pages):
+ self.pages = pages
+ self.base_url = c.wiki_base_url
+ Templated.__init__(self)
+
+class WikiEditPage(Templated):
+ def __init__(self, page_content, previous):
+ self.page_content = page_content
+ self.previous = previous
+ self.base_url = c.wiki_base_url
+ Templated.__init__(self)
+
+class WikiPageSettings(Templated):
+ def __init__(self, settings, mayedit, show_settings=True, **context):
+ self.permlevel = settings['permlevel']
+ self.show_settings = show_settings
+ self.base_url = c.wiki_base_url
+ self.mayedit = mayedit
+ Templated.__init__(self)
+
+class WikiPageRevisions(Templated):
+ def __init__(self, revisions):
+ self.revisions = revisions
+ Templated.__init__(self)
+
+class WikiPageDiscussions(Templated):
+ def __init__(self, listing):
+ self.listing = listing
+ Templated.__init__(self)
+
+class WikiBasePage(Templated):
+ def __init__(self, content, action, pageactions, showtitle=False, description=None, **context):
+ self.pageactions = pageactions
+ self.base_url = c.wiki_base_url
+ self.action = action
+ self.description = description
+ if showtitle:
+ self.title = action[1]
+ else:
+ self.title = None
+ self.content = content
+ Templated.__init__(self)
+
+class WikiBase(Reddit):
+ extra_page_classes = ['wiki-page']
+
+ def __init__(self, content, actionless=False, alert=None, **context):
+ pageactions = []
+
+ if not actionless and c.page:
+ pageactions += [(c.page, _("view"), False)]
+ if this_may_revise(c.page_obj):
+ pageactions += [('edit', _("edit"), True)]
+ pageactions += [('revisions/%s' % c.page, _("history"), False)]
+ pageactions += [('discussions', _("talk"), True)]
+ if c.is_wiki_mod:
+ pageactions += [('settings', _("settings"), True)]
+
+ action = context.get('wikiaction', (c.page, 'wiki'))
+
+ context['title'] = c.site.name
+ if alert:
+ context['infotext'] = alert
+ elif c.wikidisabled:
+ context['infotext'] = _("this wiki is currently disabled, only mods may interact with this wiki")
+ context['content'] = WikiBasePage(content, action, pageactions, **context)
+ Reddit.__init__(self, **context)
+
+class WikiPageView(WikiBase):
+ def __init__(self, content, diff=None, **context):
+ if not content and not context.get('alert'):
+ if this_may_revise(c.page_obj):
+ context['alert'] = _("this page is empty, edit it to add some content.")
+ content = WikiView(content, context.get('edit_by'), context.get('edit_date'), diff=diff)
+ WikiBase.__init__(self, content, **context)
+
+class WikiNotFound(WikiPageView):
+ def __init__(self, **context):
+ context['alert'] = _("page %s does not exist in this subreddit") % c.page
+ context['actionless'] = True
+ create_link = '%s/create/%s' % (c.wiki_base_url, c.page)
+ text = _("a page with that name does not exist in this subreddit.\n\n[Create a page called %s](%s)") % (c.page, create_link)
+ WikiPageView.__init__(self, text, **context)
+
+class WikiEdit(WikiBase):
+ def __init__(self, content, previous, **context):
+ content = WikiEditPage(content, previous)
+ context['wikiaction'] = ('edit', _("editing"))
+ WikiBase.__init__(self, content, **context)
+
+class WikiSettings(WikiBase):
+ def __init__(self, settings, mayedit, **context):
+ content = WikiPageSettings(settings, mayedit, **context)
+ context['wikiaction'] = ('settings', _("settings"))
+ WikiBase.__init__(self, content, **context)
+
+class WikiRevisions(WikiBase):
+ def __init__(self, revisions, **context):
+ content = WikiPageRevisions(revisions)
+ context['wikiaction'] = ('revisions/%s' % c.page, _("revisions"))
+ WikiBase.__init__(self, content, **context)
+
+class WikiRecent(WikiBase):
+ def __init__(self, revisions, **context):
+ content = WikiPageRevisions(revisions)
+ context['wikiaction'] = ('revisions', _("Viewing recent revisions for /r/%s") % c.wiki_id)
+ WikiBase.__init__(self, content, showtitle=True, **context)
+
+class WikiListing(WikiBase):
+ def __init__(self, pages, **context):
+ content = WikiPageListing(pages)
+ context['wikiaction'] = ('pages', _("Viewing pages for /r/%s") % c.wiki_id)
+ WikiBase.__init__(self, content, showtitle=True, **context)
+
+class WikiDiscussions(WikiBase):
+ def __init__(self, listing, **context):
+ content = WikiPageDiscussions(listing)
+ context['wikiaction'] = ('discussions', _("discussions"))
+ description = _("Discussions are site-wide links to this wiki page.<br/>\
+ Submit a link to this wiki page or see other discussions about this wiki page.")
+ WikiBase.__init__(self, content, description=description, **context)
+
View
6 r2/r2/lib/utils/utils.py
@@ -1040,9 +1040,9 @@ def link_duplicates(article):
if getattr(article, 'is_self', False):
return []
- return url_links(article.url, is_not = article._fullname)
+ return url_links(article.url, exclude = article._fullname)
-def url_links(url, is_not = None):
+def url_links(url, exclude=None):
from r2.models import Link, NotFound
try:
@@ -1051,7 +1051,7 @@ def url_links(url, is_not = None):
links = []
links = [ link for link in links
- if link._fullname != is_not ]
+ if link._fullname != exclude ]
return links
class TimeoutFunctionException(Exception):
View
13 r2/r2/models/account.py
@@ -101,7 +101,7 @@ class Account(Thing):
has_subscribed = False,
pref_media = 'subreddit',
share = {},
- wiki_override = None,
+ wiki_override = True,
email = "",
email_verified = False,
ignorereports = False,
@@ -173,12 +173,11 @@ def safe_karma(self):
return max(karma, 1) if karma > -1000 else karma
def can_wiki(self):
- if self.wiki_override is not None:
- return self.wiki_override
- else:
- return (self.link_karma >= g.WIKI_KARMA and
- self.comment_karma >= g.WIKI_KARMA)
-
+ if self.wiki_override is None:
+ # Legacy, None means user may wiki
+ return True
+ return self.wiki_override
+
def jury_betatester(self):
if g.cache.get("jury-killswitch"):
return False
View
18 r2/r2/models/modaction.py
@@ -50,7 +50,9 @@ class ModAction(tdb_cassandra.UuidThing, Printable):
actions = ('banuser', 'unbanuser', 'removelink', 'approvelink',
'removecomment', 'approvecomment', 'addmoderator',
'removemoderator', 'addcontributor', 'removecontributor',
- 'editsettings', 'editflair', 'distinguish', 'marknsfw')
+ 'editsettings', 'editflair', 'distinguish', 'marknsfw',
+ 'wikibanned', 'wikicontributor', 'wikiunbanned',
+ 'removewikicontributor', 'wikirevise', 'wikipermlevel')
_menu = {'banuser': _('ban user'),
'unbanuser': _('unban user'),
@@ -65,9 +67,19 @@ class ModAction(tdb_cassandra.UuidThing, Printable):
'editsettings': _('edit settings'),
'editflair': _('edit flair'),
'distinguish': _('distinguish'),
- 'marknsfw': _('mark nsfw')}
+ 'marknsfw': _('mark nsfw'),
+ 'wikibanned': _('ban from wiki'),
+ 'wikiunbanned': _('unban from wiki'),
+ 'wikicontributor': _('add wiki contributor'),
+ 'removewikicontributor': _('remove wiki contributor'),
+ 'wikirevise': _('wiki revise page'),
+ 'wikipermlevel': _('wiki page permlevel')}
_text = {'banuser': _('banned'),
+ 'wikibanned': _('wiki banned'),
+ 'wikiunbanned': _('unbanned from wiki'),
+ 'wikicontributor': _('added wiki contributor'),
+ 'removewikicontributor': _('removed wiki contributor'),
'unbanuser': _('unbanned'),
'removelink': _('removed'),
'approvelink': _('approved'),
@@ -79,6 +91,8 @@ class ModAction(tdb_cassandra.UuidThing, Printable):
'removecontributor': _('removed approved contributor'),
'editsettings': _('edited settings'),
'editflair': _('edited flair'),
+ 'wikirevise': _('edited wiki page'),
+ 'wikipermlevel': _('changed wiki page permission level'),
'distinguish': _('distinguished'),
'marknsfw': _('marked nsfw')}
View
116 r2/r2/models/subreddit.py
@@ -69,6 +69,9 @@ class Subreddit(Thing, Printable):
show_cname_sidebar = False,
css_on_cname = True,
domain = None,
+ wikimode = "disabled",
+ wiki_edit_karma = 100,
+ wiki_edit_age = 0,
over_18 = False,
mod_actions = 0,
sponsorship_text = "this reddit is sponsored by",
@@ -200,14 +203,68 @@ def _by_domain(cls, domain, _update = False):
@property
def moderators(self):
return self.moderator_ids()
-
+
+ @property
+ def stylesheet_contents_user(self):
+ try:
+ return WikiPage.get(self, 'config/stylesheet')._get('content','')
+ except tdb_cassandra.NotFound:
+ return self._t.get('stylesheet_contents_user')
+
+ @property
+ def prev_stylesheet(self):
+ try:
+ return WikiPage.get(self, 'config/stylesheet')._get('revision','')
+ except tdb_cassandra.NotFound:
+ return ''
+
+ @property
+ def description(self):
+ try:
+ return WikiPage.get(self, 'config/sidebar')._get('content','')
+ except tdb_cassandra.NotFound:
+ return self._t.get('description')
+
+ @property
+ def public_description(self):
+ try:
+ return WikiPage.get(self, 'config/description')._get('content','')
+ except tdb_cassandra.NotFound:
+ return self._t.get('public_description')
+
+ @property
+ def prev_description_id(self):
+ try:
+ return WikiPage.get(self, 'config/sidebar')._get('revision','')
+ except tdb_cassandra.NotFound:
+ return ''
+
+ @property
+ def prev_public_description_id(self):
+ try:
+ return WikiPage.get(self, 'config/description')._get('revision','')
+ except tdb_cassandra.NotFound:
+ return ''
+
@property
def contributors(self):
return self.contributor_ids()
@property
def banned(self):
return self.banned_ids()
+
+ @property
+ def wikibanned(self):
+ return self.wikibanned_ids()
+
+ @property
+ def wikicontributor(self):
+ return self.wikicontributor_ids()
+
+ @property
+ def _should_wiki(self):
+ return True
@property
def subscribers(self):
@@ -284,6 +341,31 @@ def can_change_stylesheet(self, user):
return c.user_is_admin or self.is_moderator(user)
else:
return False
+
+ def parse_css(self, content, verify=True):
+ from r2.lib import cssfilter
+ if g.css_killswitch or (verify and not self.can_change_stylesheet(c.user)):
+ return (None, None)
+
+ parsed, report = cssfilter.validate_css(content)
+ parsed = parsed.cssText if parsed else ''
+ return (report, parsed)
+
+ def change_css(self, content, parsed, prev=None, reason=None, author=None, force=False):
+ from r2.models import ModAction
+ author = author if author else c.user.name
+ if content is None:
+ content = ''
+ try:
+ wiki = WikiPage.get(self, 'config/stylesheet')
+ except tdb_cassandra.NotFound:
+ wiki = WikiPage.create(self, 'config/stylesheet')
+ wiki.revise(content, previous=prev, author=author, reason=reason, force=force)
+ self.stylesheet_contents = parsed
+ self.stylesheet_hash = md5(parsed).hexdigest()
+ set_last_modified(self, 'stylesheet_contents')
+ c.site._commit()
+ ModAction.create(self, c.user, action='wikirevise', details='Updated subreddit stylesheet')
def is_special(self, user):
return (user
@@ -717,6 +799,10 @@ def __init__(self):
self.title = ''
self.link_flair_position = 'right'
+ @property
+ def _should_wiki(self):
+ return False
+
def is_moderator(self, user):
return c.user_is_loggedin and c.user_is_admin
@@ -906,10 +992,32 @@ def __init__(self):
self._base = Subreddit._by_name(g.default_sr, stale=True)
except NotFound:
self._base = None
-
+
+ @property
+ def _should_wiki(self):
+ return True
+
+ @property
+ def wikimode(self):
+ return self._base.wikimode
+
+ @property
+ def wiki_edit_karma(self):
+ return self._base.wiki_edit_karma
+
+ def is_wikibanned(self, user):
+ return self._base.is_banned(user)
+
+ def is_wikicreate(self, user):
+ return self._base.is_wikicreate(user)
+
@property
def _fullname(self):
return "t5_6"
+
+ @property
+ def _id36(self):
+ return self._base._id36
@property
def type(self):
@@ -1087,7 +1195,9 @@ class SRMember(Relation(Subreddit, Account)): pass
Subreddit.__bases__ += (UserRel('moderator', SRMember),
UserRel('contributor', SRMember),
UserRel('subscriber', SRMember, disable_ids_fn = True),
- UserRel('banned', SRMember))
+ UserRel('banned', SRMember),
+ UserRel('wikibanned', SRMember),
+ UserRel('wikicontributor', SRMember))
class SubredditPopularityByLanguage(tdb_cassandra.View):
_use_db = True
View
356 r2/r2/models/wiki.py
@@ -0,0 +1,356 @@
+# The contents of this file are subject to the Common Public Attribution
+# License Version 1.0. (the "License"); you may not use this file except in
+# compliance with the License. You may obtain a copy of the License at
+# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
+# License Version 1.1, but Sections 14 and 15 have been added to cover use of
+# software over a computer network and provide for limited attribution for the
+# Original Developer. In addition, Exhibit A has been modified to be consistent
+# with Exhibit B.
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
+# the specific language governing rights and limitations under the License.
+#
+# The Original Code is reddit.
+#
+# The Original Developer is the Initial Developer. The Initial Developer of
+# the Original Code is reddit Inc.
+#
+# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit
+# Inc. All Rights Reserved.
+###############################################################################
+
+from datetime import datetime
+from r2.lib.db import tdb_cassandra
+from r2.lib.db.thing import NotFound
+from r2.lib.merge import *
+from pycassa.system_manager import TIME_UUID_TYPE
+from pylons import c, g
+from pylons.controllers.util import abort
+from r2.models.printable import Printable
+from r2.models.account import Account
+from collections import OrderedDict
+
+# Used for the key/id for pages,
+PAGE_ID_SEP = '\t'
+
+# Number of days to keep recent revisions for
+WIKI_RECENT_DAYS = g.wiki_keep_recent_days
+
+# Max length of a single page in bytes
+MAX_PAGE_LENGTH_BYTES = g.wiki_max_page_length_bytes
+
+# Namespaces in which access is denied to do anything but view
+restricted_namespaces = ('reddit/', 'config/', 'special/')
+
+# Pages which may only be edited by mods, must be within restricted namespaces
+special_pages = ('config/stylesheet', 'config/sidebar', 'config/description')
+
+# Pages which have a special length restrictions (In bytes)
+special_length_restrictions_bytes = {'config/stylesheet': 128*1024, 'config/sidebar': 5120, 'config/description': 500}
+
+modactions = {'config/sidebar': "Updated subreddit sidebar"}
+
+# Page "index" in the subreddit "reddit.com" and a seperator of "\t" becomes:
+# "reddit.com\tindex"
+def wiki_id(sr, page):
+ return ('%s%s%s' % (sr, PAGE_ID_SEP, page)).lower()
+
+class ContentLengthError(Exception):
+ def __init__(self, max_length):
+ Exception.__init__(self)
+ self.max_length = max_length
+
+class WikiPageExists(Exception):
+ pass
+
+class WikiPageEditors(tdb_cassandra.View):
+ _use_db = True
+ _value_type = 'str'
+ _connection_pool = 'main'
+
+def get_author_name(author_name):
+ if not author_name:
+ return "[unknown]"
+ try:
+ return Account._by_name(author_name).name
+ except NotFound:
+ return '[deleted]'
+
+class WikiRevision(tdb_cassandra.UuidThing, Printable):
+ """ Contains content (markdown), author of the edit, page the edit belongs to, and datetime of the edit """
+
+ _use_db = True
+ _connection_pool = 'main'
+
+ _str_props = ('pageid', 'content', 'author', 'reason')
+ _bool_props = ('hidden')
+
+ cache_ignore = set(['subreddit'] + list(_str_props)).union(Printable.cache_ignore)
+
+ def author_name(self):
+ return get_author_name(getattr(self, 'author', None))
+
+ @classmethod
+ def add_props(cls, user, wrapped):
+ for item in wrapped:
+ item._hidden = item.is_hidden
+ item._spam = False
+ item.reported = False
+
+ @classmethod
+ def get(cls, revid, pageid):
+ wr = cls._byID(revid)
+ if wr.pageid != pageid:
+ raise ValueError('Revision is not for the expected page')
+ return wr
+
+ def toggle_hide(self):
+ self.hidden = not self.is_hidden
+ self._commit()
+ return self.hidden
+
+ @classmethod
+ def create(cls, pageid, content, author=None, reason=None):
+ kw = dict(pageid=pageid, content=content)
+ if author:
+ kw['author'] = author
+ if reason:
+ kw['reason'] = reason
+ wr = cls(**kw)
+ wr._commit()
+ WikiRevisionsByPage.add_object(wr)
+ WikiRevisionsRecentBySR.add_object(wr)
+ return wr
+
+ def _on_commit(self):
+ WikiRevisionsByPage.add_object(self)
+ WikiRevisionsRecentBySR.add_object(self)
+
+ @classmethod
+ def get_recent(cls, sr, count=100):
+ return WikiRevisionsRecentBySR.query([sr], count=count)
+
+ @property
+ def is_hidden(self):
+ return bool(getattr(self, 'hidden', False))
+
+ @property
+ def info(self, sep=PAGE_ID_SEP):
+ info = self.pageid.split(sep, 1)
+ try:
+ return {'sr': info[0], 'page': info[1]}
+ except IndexError:
+ g.log.error('Broken wiki page ID "%s" did PAGE_ID_SEP change?', self.pageid)
+ return {'sr': 'broken', 'page': 'broken'}
+
+ @property
+ def page(self):
+ return self.info['page']
+
+ @property
+ def sr(self):
+ return self.info['sr']
+
+
+class WikiPage(tdb_cassandra.Thing):
+ """ Contains permissions, current content (markdown), subreddit, and current revision (ID)
+ Key is subreddit-pagename """
+
+ _use_db = True
+ _connection_pool = 'main'
+
+ _read_consistency_level = tdb_cassandra.CL.QUORUM
+ _write_consistency_level = tdb_cassandra.CL.QUORUM
+
+ _date_props = ('last_edit_date')
+ _str_props = ('revision', 'name', 'last_edit_by', 'content', 'sr')
+ _int_props = ('permlevel')
+ _bool_props = ('listed_')
+
+ def author_name(self):
+ return get_author_name(getattr(self, 'last_edit_by', None))
+
+ @classmethod
+ def get(cls, sr, name):
+ id = getattr(sr, '_id36', None)
+ if not id:
+ raise tdb_cassandra.NotFound
+ return cls._byID(wiki_id(sr._id36, name))
+
+ @classmethod
+ def create(cls, sr, name):
+ name = name.lower()
+ kw = dict(sr=sr._id36, name=name, permlevel=0, content='', listed_=False)
+ page = cls(**kw)
+ page._commit()
+ return page
+
+ @property
+ def restricted(self):
+ return WikiPage.is_restricted(self.name)
+
+ @classmethod
+ def is_restricted(cls, page):
+ return ("%s/" % page) in restricted_namespaces or page.startswith(restricted_namespaces)
+
+ @classmethod
+ def is_special(cls, page):
+ return page in special_pages
+
+ @property
+ def special(self):
+ return WikiPage.is_special(self.name)
+
+ def add_to_listing(self):
+ WikiPagesBySR.add_object(self)
+
+ def _on_create(self):
+ self.add_to_listing()
+
+ def _on_commit(self):
+ self.add_to_listing()
+
+ def remove_editor(self, user):
+ WikiPageEditors._remove(self._id, [user])
+
+ def add_editor(self, user):
+ WikiPageEditors._set_values(self._id, {user: ''})
+
+ @classmethod
+ def get_pages(cls, sr, after=None):
+ NUM_AT_A_TIME = 1000
+ pages = WikiPagesBySR.query([sr._id36], after=after, count=NUM_AT_A_TIME)
+ pages = list(pages)
+ if len(pages) >= NUM_AT_A_TIME:
+ return pages + cls.get_pages(sr, after=pages[-1])
+ return pages
+
+ @classmethod
+ def get_listing(cls, sr, filter_check=None):
+ """
+ Create a tree of pages from their path.
+ """
+ page_tree = OrderedDict()
+ pages = cls.get_pages(sr)
+ pages = filter(filter_check, pages)
+ pages = sorted(pages, key=lambda page: page.name)
+ for page in pages:
+ p = page.name.split('/')
+ cur_node = page_tree
+ # Loop through all elements of the path except the page name portion
+ for name in p[:-1]:
+ next_node = cur_node.get(name)
+ # If the element did not already exist in the tree, create it
+ if not next_node:
+ new_node = OrderedDict()
+ cur_node[name] = [None, new_node]
+ else:
+ # Otherwise, continue through
+ new_node = next_node[1]
+ cur_node = new_node
+ # Get the actual page name portion of the path
+ pagename = p[-1]
+ node = cur_node.get(pagename)
+ # The node may already exist as a path name in the tree
+ if node:
+ node[0] = page
+ else:
+ cur_node[pagename] = [page, OrderedDict()]
+
+ return page_tree
+
+ def get_editors(self, properties=None):
+ try:
+ return WikiPageEditors._byID(self._id, properties=properties)._values() or []
+ except tdb_cassandra.NotFoundException:
+ return []
+
+ def has_editor(self, editor):
+ return bool(self.get_editors(properties=[editor]))
+
+ def revise(self, content, previous = None, author=None, force=False, reason=None):
+ if self.content == content:
+ return
+ max_length = special_length_restrictions_bytes.get(self.name, MAX_PAGE_LENGTH_BYTES)
+ if len(content) > max_length:
+ raise ContentLengthError(max_length)
+
+ revision = getattr(self, 'revision', None)
+
+ if not force and (revision and previous != revision):
+ if previous:
+ origcontent = WikiRevision.get(previous, pageid=self._id).content
+ else:
+ origcontent = ''
+ try:
+ content = threewaymerge(origcontent, content, self.content)
+ except ConflictException as e:
+ e.new_id = revision
+ raise e
+
+ wr = WikiRevision.create(self._id, content, author, reason)
+ self.content = content
+ self.last_edit_by = author
+ self.last_edit_date = wr.date
+ self.revision = wr._id
+ self._commit()
+ return wr
+
+ def change_permlevel(self, permlevel, force=False):
+ NUM_PERMLEVELS = 3
+ if permlevel == self.permlevel:
+ return
+ if not force and int(permlevel) not in range(NUM_PERMLEVELS):
+ raise ValueError('Permlevel not valid')
+ self.permlevel = permlevel
+ self._commit()
+
+ def get_revisions(self, after=None, count=100):
+ return WikiRevisionsByPage.query([self._id], after=after, count=count)
+
+ def _commit(self, *a, **kw):
+ if not self._id: # Creating a new page
+ pageid = wiki_id(self.sr, self.name)
+ try:
+ WikiPage._byID(pageid)
+ raise WikiPageExists()
+ except tdb_cassandra.NotFound:
+ self._id = pageid
+ return tdb_cassandra.Thing._commit(self, *a, **kw)
+
+class WikiRevisionsByPage(tdb_cassandra.DenormalizedView):
+ """ Associate revisions with pages """
+
+ _use_db = True
+ _connection_pool = 'main'
+ _view_of = WikiRevision
+ _compare_with = TIME_UUID_TYPE
+
+ @classmethod
+ def _rowkey(cls, wr):
+ return wr.pageid
+
+class WikiPagesBySR(tdb_cassandra.DenormalizedView):
+ """ Associate revisions with subreddits, store only recent """
+ _use_db = True
+ _connection_pool = 'main'
+ _view_of = WikiPage
+
+ @classmethod
+ def _rowkey(cls, wp):
+ return wp.sr
+
+class WikiRevisionsRecentBySR(tdb_cassandra.DenormalizedView):
+ """ Associate revisions with subreddits, store only recent """
+ _use_db = True
+ _connection_pool = 'main'
+ _view_of = WikiRevision
+ _compare_with = TIME_UUID_TYPE
+ _ttl = 60*60*24*WIKI_RECENT_DAYS
+
+ @classmethod
+ def _rowkey(cls, wr):
+ return wr.sr
+
+
View
BIN r2/r2/public/static/house.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
BIN r2/r2/public/static/icons/report.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
1 r2/r2/public/static/js/base.js
@@ -15,4 +15,5 @@ $(function() {
r.ui.HelpBubble.init()
r.interestbar.init()
r.apps.init()
+ r.wiki.init()
})
View
8 r2/r2/public/static/js/reddit.js
@@ -105,8 +105,8 @@ function form_error(form) {
}
}
-function simple_post_form(form, where, fields, block) {
- $.request(where, get_form_fields(form, fields), null, block,
+function simple_post_form(form, where, fields, block, callback) {
+ $.request(where, get_form_fields(form, fields), callback, block,
"json", false, form_error(form));
return false;
};
@@ -157,7 +157,7 @@ function deleteRow(elem) {
/* general things */
-function change_state(elem, op, callback, keep) {
+function change_state(elem, op, callback, keep, post_callback) {
var form = $(elem).parents("form");
/* look to see if the form has an id specified */