mirror of
https://github.com/reddit-archive/reddit.git
synced 2026-02-04 11:45:04 -05:00
3414 lines
134 KiB
Python
Executable File
3414 lines
134 KiB
Python
Executable File
# 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-2013 reddit
|
|
# Inc. All Rights Reserved.
|
|
###############################################################################
|
|
|
|
from r2.controllers.reddit_base import (
|
|
cross_domain,
|
|
MinimalController,
|
|
pagecache_policy,
|
|
PAGECACHE_POLICY,
|
|
paginated_listing,
|
|
RedditController,
|
|
set_user_cookie,
|
|
)
|
|
|
|
from pylons.i18n import _
|
|
from pylons import c, request, response
|
|
|
|
from r2.lib.validator import *
|
|
|
|
from r2.models import *
|
|
|
|
from r2.lib import amqp
|
|
|
|
from r2.lib.utils import get_title, sanitize_url, timeuntil, set_last_modified
|
|
from r2.lib.utils import query_string, timefromnow, randstr
|
|
from r2.lib.utils import timeago, tup, filter_links
|
|
from r2.lib.pages import (EnemyList, FriendList, ContributorList, ModList,
|
|
BannedList, WikiBannedList, WikiMayContributeList,
|
|
BoringPage, FormPage, CssError, UploadedImage,
|
|
ClickGadget, UrlParser, WrappedUser)
|
|
from r2.lib.pages import FlairList, FlairCsv, FlairTemplateEditor, \
|
|
FlairSelector
|
|
from r2.lib.pages import PrefApps
|
|
from r2.lib.pages.things import wrap_links, default_thing_wrapper
|
|
from r2.models.last_modified import LastModified
|
|
|
|
from r2.lib.menus import CommentSortMenu
|
|
from r2.lib.captcha import get_iden
|
|
from r2.lib.strings import strings
|
|
from r2.lib.filters import _force_unicode, websafe_json, websafe, spaceCompress
|
|
from r2.lib.db import queries
|
|
from r2.lib.db.queries import changed
|
|
from r2.lib import media
|
|
from r2.lib.db import tdb_cassandra
|
|
from r2.lib import promote
|
|
from r2.lib.comment_tree import delete_comment
|
|
from r2.lib import tracking, cssfilter, emailer
|
|
from r2.lib.subreddit_search import search_reddits
|
|
from r2.lib.log import log_text
|
|
from r2.lib.filters import safemarkdown
|
|
from r2.lib.scraper import str_to_image
|
|
from r2.controllers.api_docs import api_doc, api_section
|
|
from r2.lib.search import SearchQuery
|
|
from r2.controllers.oauth2 import OAuth2ResourceController, require_oauth2_scope
|
|
from r2.lib.template_helpers import add_sr, get_domain
|
|
from r2.lib.system_messages import notify_user_added
|
|
from r2.controllers.ipn import generate_blob
|
|
from r2.lib.lock import TimeoutExpired
|
|
|
|
from r2.models import wiki
|
|
from r2.lib.merge import ConflictException
|
|
|
|
import csv
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
import hashlib
|
|
import re
|
|
import urllib
|
|
import urllib2
|
|
|
|
def reject_vote(thing):
|
|
voteword = request.params.get('dir')
|
|
|
|
if voteword == '1':
|
|
voteword = 'upvote'
|
|
elif voteword == '0':
|
|
voteword = '0-vote'
|
|
elif voteword == '-1':
|
|
voteword = 'downvote'
|
|
|
|
log_text ("rejected vote", "Rejected %s from %s (%s) on %s %s via %s" %
|
|
(voteword, c.user.name, request.ip, thing.__class__.__name__,
|
|
thing._id36, request.referer), "info")
|
|
|
|
|
|
class ApiminimalController(MinimalController):
|
|
"""
|
|
Put API calls in here which don't rely on the user being logged in
|
|
"""
|
|
|
|
@validatedForm()
|
|
@api_doc(api_section.captcha)
|
|
def POST_new_captcha(self, form, jquery, *a, **kw):
|
|
"""
|
|
Responds with an `iden` of a new CAPTCHA
|
|
|
|
Use this endpoint if a user cannot read a given CAPTCHA,
|
|
and wishes to receive a new CAPTCHA.
|
|
|
|
To request the CAPTCHA image for an iden, use
|
|
[/captcha/`iden`](#GET_captcha_{iden}).
|
|
"""
|
|
|
|
iden = get_iden()
|
|
jquery("body").captcha(iden)
|
|
form._send_data(iden = iden)
|
|
|
|
|
|
class ApiController(RedditController, OAuth2ResourceController):
|
|
"""
|
|
Controller which deals with almost all AJAX site interaction.
|
|
"""
|
|
|
|
def pre(self):
|
|
self.check_for_bearer_token()
|
|
RedditController.pre(self)
|
|
|
|
@validatedForm()
|
|
def ajax_login_redirect(self, form, jquery, dest):
|
|
form.redirect("/login" + query_string(dict(dest=dest)))
|
|
|
|
@pagecache_policy(PAGECACHE_POLICY.NEVER)
|
|
@require_oauth2_scope("read")
|
|
@validate(link1 = VUrl(['url']),
|
|
link2 = VByName('id'),
|
|
count = VLimit('limit'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def GET_info(self, link1, link2, count):
|
|
"""Get a link by fullname or a list of links by URL.
|
|
|
|
If `id` is provided, the link with the given fullname will be returned.
|
|
If `url` is provided, a list of links with the given URL will be
|
|
returned.
|
|
|
|
If both `url` and `id` are provided, `id` will take precedence.
|
|
|
|
"""
|
|
|
|
c.update_last_visit = False
|
|
|
|
links = []
|
|
if link2:
|
|
links = filter_links(tup(link2), filter_spam = False)
|
|
elif link1 and ('ALREADY_SUB', 'url') in c.errors:
|
|
links = filter_links(tup(link1), filter_spam = False)
|
|
|
|
listing = wrap_links(filter(None, links or []), num = count)
|
|
return BoringPage(_("API"), content = listing).render()
|
|
|
|
|
|
@json_validate()
|
|
@api_doc(api_section.account, extensions=["json"])
|
|
def GET_me(self, responder):
|
|
"""Get info about the currently authenticated user.
|
|
|
|
Response includes a modhash, karma, and new mail status.
|
|
|
|
"""
|
|
if c.user_is_loggedin:
|
|
return Wrapped(c.user).render()
|
|
else:
|
|
return {}
|
|
|
|
@json_validate(user=VUname(("user",)))
|
|
@api_doc(api_section.users, extensions=["json"])
|
|
def GET_username_available(self, responder, user):
|
|
"""
|
|
Check whether a username is available for registration.
|
|
"""
|
|
if not (responder.has_errors("user", errors.BAD_USERNAME)):
|
|
return bool(user)
|
|
|
|
@validatedForm(VCaptcha(),
|
|
name=VRequired('name', errors.NO_NAME),
|
|
email=ValidEmails('email', num = 1),
|
|
reason = VOneOf('reason', ('ad_inq', 'feedback')),
|
|
message=VRequired('text', errors.NO_TEXT),
|
|
)
|
|
def POST_feedback(self, form, jquery, name, email, reason, message):
|
|
if not (form.has_errors('name', errors.NO_NAME) or
|
|
form.has_errors('email', errors.BAD_EMAILS) or
|
|
form.has_errors('text', errors.NO_TEXT) or
|
|
form.has_errors('captcha', errors.BAD_CAPTCHA)):
|
|
|
|
if reason == 'ad_inq':
|
|
emailer.ad_inq_email(email, message, name, reply_to = '')
|
|
else:
|
|
emailer.feedback_email(email, message, name, reply_to = '')
|
|
form.set_html(".status", _("thanks for your message! "
|
|
"you should hear back from us shortly."))
|
|
form.set_inputs(text = "", captcha = "")
|
|
form.find(".spacer").hide()
|
|
form.find(".btn").hide()
|
|
|
|
POST_ad_inq = POST_feedback
|
|
|
|
@require_oauth2_scope("privatemessages")
|
|
@validatedForm(VCaptcha(),
|
|
VUser(),
|
|
VModhash(),
|
|
ip = ValidIP(),
|
|
to = VMessageRecipient('to'),
|
|
subject = VLength('subject', 100, empty_error=errors.NO_SUBJECT),
|
|
body = VMarkdown(['text', 'message']))
|
|
@api_doc(api_section.messages)
|
|
def POST_compose(self, form, jquery, to, subject, body, ip):
|
|
"""
|
|
handles message composition under /message/compose.
|
|
"""
|
|
if not (form.has_errors("to", errors.USER_DOESNT_EXIST,
|
|
errors.NO_USER, errors.SUBREDDIT_NOEXIST,
|
|
errors.USER_BLOCKED) or
|
|
form.has_errors("subject", errors.NO_SUBJECT) or
|
|
form.has_errors("subject", errors.TOO_LONG) or
|
|
form.has_errors("text", errors.NO_TEXT, errors.TOO_LONG) or
|
|
form.has_errors("captcha", errors.BAD_CAPTCHA)):
|
|
|
|
m, inbox_rel = Message._new(c.user, to, subject, body, ip)
|
|
form.set_html(".status", _("your message has been delivered"))
|
|
form.set_inputs(to = "", subject = "", text = "", captcha="")
|
|
|
|
amqp.add_item('new_message', m._fullname)
|
|
|
|
queries.new_message(m, inbox_rel)
|
|
|
|
@require_oauth2_scope("submit")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
VCaptcha(),
|
|
VRatelimit(rate_user = True, rate_ip = True,
|
|
prefix = "rate_submit_"),
|
|
VShamedDomain('url'),
|
|
ip = ValidIP(),
|
|
sr = VSubmitSR('sr', 'kind'),
|
|
url = VUrl(['url', 'sr', 'resubmit']),
|
|
title = VTitle('title'),
|
|
save = VBoolean('save'),
|
|
sendreplies = VBoolean('sendreplies'),
|
|
selftext = VSelfText('text'),
|
|
kind = VOneOf('kind', ['link', 'self']),
|
|
then = VOneOf('then', ('tb', 'comments'),
|
|
default='comments'),
|
|
extension=VLength("extension", 20, docs={"extension":
|
|
"extension used for redirects"}),
|
|
)
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_submit(self, form, jquery, url, selftext, kind, title,
|
|
save, sr, ip, then, extension, sendreplies):
|
|
"""Submit a link to a subreddit.
|
|
|
|
Submit will create a link or self-post in the subreddit `sr` with the
|
|
title `title`. If `kind` is `"link"`, then `url` is expected to be a
|
|
valid URL to link to. Otherwise, `text`, if present, will be the
|
|
body of the self-post.
|
|
|
|
If a link with the same URL has already been submitted to the specified
|
|
subreddit an error will be returned unless `resubmit` is true.
|
|
`extension` is used for determining which view-type (e.g. `json`,
|
|
`compact` etc.) to use for the redirect that is generated if the
|
|
`resubmit` error occurs.
|
|
|
|
If `save` is true, the link will be implicitly saved after submission
|
|
(see [/api/save](#POST_api_save) for more information).
|
|
|
|
"""
|
|
|
|
from r2.models.admintools import is_banned_domain
|
|
|
|
if isinstance(url, (unicode, str)):
|
|
#backwards compatability
|
|
if url.lower() == 'self':
|
|
url = kind = 'self'
|
|
|
|
# VUrl may have replaced 'url' by adding 'http://'
|
|
form.set_inputs(url = url)
|
|
|
|
if not kind or form.has_errors('sr', errors.INVALID_OPTION):
|
|
# this should only happen if somebody is trying to post
|
|
# links in some automated manner outside of the regular
|
|
# submission page, and hasn't updated their script
|
|
return
|
|
|
|
if form.has_errors('captcha', errors.BAD_CAPTCHA):
|
|
return
|
|
|
|
if (form.has_errors('sr',
|
|
errors.SUBREDDIT_NOEXIST,
|
|
errors.SUBREDDIT_NOTALLOWED,
|
|
errors.SUBREDDIT_REQUIRED,
|
|
errors.NO_SELFS,
|
|
errors.NO_LINKS)
|
|
or not sr):
|
|
# checking to get the error set in the form, but we can't
|
|
# check for rate-limiting if there's no subreddit
|
|
return
|
|
|
|
if sr.link_type == 'link' and kind == 'self':
|
|
# this could happen if they actually typed "self" into the
|
|
# URL box and we helpfully translated it for them
|
|
c.errors.add(errors.NO_SELFS, field='sr')
|
|
|
|
# and trigger that by hand for the form
|
|
form.has_errors('sr', errors.NO_SELFS)
|
|
|
|
return
|
|
|
|
should_ratelimit = sr.should_ratelimit(c.user, 'link')
|
|
#remove the ratelimit error if the user's karma is high
|
|
if not should_ratelimit:
|
|
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
|
|
|
|
banmsg = None
|
|
|
|
if kind == 'link':
|
|
check_domain = True
|
|
|
|
# check for no url, or clear that error field on return
|
|
if form.has_errors("url", errors.NO_URL, errors.BAD_URL):
|
|
pass
|
|
elif form.has_errors("url", errors.DOMAIN_BANNED):
|
|
g.stats.simple_event('spam.shame.link')
|
|
elif form.has_errors("url", errors.ALREADY_SUB):
|
|
check_domain = False
|
|
u = url[0].already_submitted_link
|
|
if extension:
|
|
u = UrlParser(u)
|
|
u.set_extension(extension)
|
|
u = u.unparse()
|
|
form.redirect(u)
|
|
# check for title, otherwise look it up and return it
|
|
elif form.has_errors("title", errors.NO_TEXT):
|
|
pass
|
|
|
|
if url is None:
|
|
g.log.warning("%s is trying to submit url=None (title: %r)"
|
|
% (request.ip, title))
|
|
elif check_domain:
|
|
|
|
banmsg = is_banned_domain(url, request.ip)
|
|
else:
|
|
form.has_errors('text', errors.TOO_LONG)
|
|
|
|
if form.has_errors("title", errors.TOO_LONG, errors.NO_TEXT):
|
|
pass
|
|
|
|
if form.has_errors('ratelimit', errors.RATELIMIT):
|
|
pass
|
|
|
|
if form.has_error() or not title:
|
|
return
|
|
|
|
if should_ratelimit:
|
|
filled_quota = c.user.quota_full('link')
|
|
if filled_quota is not None:
|
|
if c.user._spam:
|
|
msg = strings.generic_quota_msg
|
|
else:
|
|
log_text ("over-quota",
|
|
"%s just went over their per-%s quota" %
|
|
(c.user.name, filled_quota), "info")
|
|
|
|
verify_link = "/verify?reason=submit"
|
|
reddiquette_link = "/wiki/reddiquette"
|
|
|
|
if c.user.email_verified:
|
|
msg = strings.verified_quota_msg % dict(reddiquette=reddiquette_link)
|
|
else:
|
|
msg = strings.unverified_quota_msg % dict(verify=verify_link,
|
|
reddiquette=reddiquette_link)
|
|
|
|
md = safemarkdown(msg)
|
|
form.set_html(".status", md)
|
|
c.errors.add(errors.QUOTA_FILLED)
|
|
form.set_error(errors.QUOTA_FILLED, None)
|
|
return
|
|
|
|
if not c.user.gold or not hasattr(request.post, 'sendreplies'):
|
|
sendreplies = kind == 'self'
|
|
|
|
# get rid of extraneous whitespace in the title
|
|
cleaned_title = re.sub(r'\s+', ' ', request.post.title, flags=re.UNICODE)
|
|
cleaned_title = cleaned_title.strip()
|
|
|
|
# well, nothing left to do but submit it
|
|
l = Link._submit(cleaned_title, url if kind == 'link' else 'self',
|
|
c.user, sr, ip, spam=c.user._spam, sendreplies=sendreplies)
|
|
|
|
if banmsg:
|
|
g.stats.simple_event('spam.domainban.link_url')
|
|
admintools.spam(l, banner = "domain (%s)" % banmsg)
|
|
|
|
if kind == 'self':
|
|
l.url = l.make_permalink_slow()
|
|
l.is_self = True
|
|
l.selftext = selftext
|
|
|
|
l._commit()
|
|
l.set_url_cache()
|
|
|
|
queries.queue_vote(c.user, l, True, ip,
|
|
cheater = (errors.CHEATER, None) in c.errors)
|
|
if save:
|
|
r = l._save(c.user)
|
|
|
|
#set the ratelimiter
|
|
if should_ratelimit:
|
|
c.user.clog_quota('link', l)
|
|
VRatelimit.ratelimit(rate_user=True, rate_ip = True,
|
|
prefix = "rate_submit_")
|
|
|
|
#update the queries
|
|
queries.new_link(l)
|
|
changed(l)
|
|
|
|
if then == 'comments':
|
|
path = add_sr(l.make_permalink_slow())
|
|
elif then == 'tb':
|
|
form.attr('target', '_top')
|
|
path = add_sr('/tb/%s' % l._id36)
|
|
if extension:
|
|
path += ".%s" % extension
|
|
form.redirect(path)
|
|
form._send_data(url=path)
|
|
form._send_data(id=l._id36)
|
|
form._send_data(name=l._fullname)
|
|
|
|
@validatedForm(VRatelimit(rate_ip = True,
|
|
rate_user = True,
|
|
prefix = 'fetchtitle_'),
|
|
VUser(),
|
|
url = VSanitizedUrl('url'))
|
|
def POST_fetch_title(self, form, jquery, url):
|
|
if form.has_errors('ratelimit', errors.RATELIMIT):
|
|
form.set_html(".title-status", "");
|
|
return
|
|
|
|
VRatelimit.ratelimit(rate_ip = True, rate_user = True,
|
|
prefix = 'fetchtitle_', seconds=1)
|
|
if url:
|
|
title = get_title(url)
|
|
if title:
|
|
form.set_inputs(title = title)
|
|
form.set_html(".title-status", "");
|
|
else:
|
|
form.set_html(".title-status", _("no title found"))
|
|
|
|
def _login(self, responder, user, rem = None):
|
|
"""
|
|
AJAX login handler, used by both login and register to set the
|
|
user cookie and send back a redirect.
|
|
"""
|
|
self.login(user, rem = rem)
|
|
|
|
if request.params.get("hoist") != "cookie":
|
|
responder._send_data(modhash = user.modhash())
|
|
responder._send_data(cookie = user.make_cookie())
|
|
|
|
@validatedForm(VLoggedOut(),
|
|
user = VThrottledLogin(['user', 'passwd']),
|
|
rem = VBoolean('rem'))
|
|
def _handle_login(self, form, responder, user, rem):
|
|
exempt_ua = (request.user_agent and
|
|
any(ua in request.user_agent for ua
|
|
in g.config.get('exempt_login_user_agents', ())))
|
|
if (errors.LOGGED_IN, None) in c.errors:
|
|
if user == c.user or exempt_ua:
|
|
# Allow funky clients to re-login as the current user.
|
|
c.errors.remove((errors.LOGGED_IN, None))
|
|
else:
|
|
from r2.lib.base import abort
|
|
from r2.lib.errors import reddit_http_error
|
|
abort(reddit_http_error(409, errors.LOGGED_IN))
|
|
|
|
if not (responder.has_errors("vdelay", errors.RATELIMIT) or
|
|
responder.has_errors("passwd", errors.WRONG_PASSWORD)):
|
|
self._login(responder, user, rem)
|
|
|
|
@cross_domain(allow_credentials=True)
|
|
@api_doc(api_section.account, extends=_handle_login)
|
|
def POST_login(self, *args, **kwargs):
|
|
"""Log in to an account.
|
|
|
|
`rem` specifies whether or not the session cookie returned should last
|
|
beyond the current browser session (that is, if `rem` is `True` the
|
|
cookie will have an explicit expiration far in the future indicating
|
|
that it is not a session cookie.)
|
|
|
|
"""
|
|
return self._handle_login(*args, **kwargs)
|
|
|
|
@validatedForm(VCaptcha(),
|
|
VRatelimit(rate_ip = True, prefix = "rate_register_"),
|
|
name = VUname(['user']),
|
|
email=ValidEmails(
|
|
"email",
|
|
num=1,
|
|
docs={
|
|
"email": "(optional) the user's email address",
|
|
},
|
|
),
|
|
password = VPassword(['passwd', 'passwd2']),
|
|
rem = VBoolean('rem'))
|
|
def _handle_register(self, form, responder, name, email,
|
|
password, rem):
|
|
bad_captcha = responder.has_errors('captcha', errors.BAD_CAPTCHA)
|
|
if not (responder.has_errors("user", errors.BAD_USERNAME,
|
|
errors.USERNAME_TAKEN_DEL,
|
|
errors.USERNAME_TAKEN) or
|
|
responder.has_errors("email", errors.BAD_EMAILS) or
|
|
responder.has_errors("passwd", errors.BAD_PASSWORD) or
|
|
responder.has_errors("passwd2", errors.BAD_PASSWORD_MATCH) or
|
|
responder.has_errors('ratelimit', errors.RATELIMIT) or
|
|
(not g.disable_captcha and bad_captcha)):
|
|
|
|
user = register(name, password, request.ip)
|
|
VRatelimit.ratelimit(rate_ip = True, prefix = "rate_register_")
|
|
|
|
#anything else we know (email, languages)?
|
|
if email:
|
|
user.email = email
|
|
|
|
user.pref_lang = c.lang
|
|
if c.content_langs == 'all':
|
|
user.pref_content_langs = 'all'
|
|
else:
|
|
langs = list(c.content_langs)
|
|
langs.sort()
|
|
user.pref_content_langs = tuple(langs)
|
|
|
|
d = c.user._dirties.copy()
|
|
user._commit()
|
|
|
|
amqp.add_item('new_account', user._fullname)
|
|
|
|
c.user = user
|
|
self._login(responder, user, rem)
|
|
|
|
@cross_domain(allow_credentials=True)
|
|
@api_doc(api_section.account, extends=_handle_register)
|
|
def POST_register(self, *args, **kwargs):
|
|
"""Register a new account.
|
|
|
|
`rem` specifies whether or not the session cookie returned should last
|
|
beyond the current browser session (that is, if `rem` is `True` the
|
|
cookie will have an explicit expiration far in the future indicating
|
|
that it is not a session cookie.)
|
|
|
|
"""
|
|
return self._handle_register(*args, **kwargs)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
container = VByName('id'))
|
|
@api_doc(api_section.moderation)
|
|
def POST_leavemoderator(self, container):
|
|
"""
|
|
Handles self-removal as moderator from a subreddit as rendered
|
|
in the subreddit sidebox on any of that subreddit's pages.
|
|
"""
|
|
if container and container.is_moderator(c.user):
|
|
container.remove_moderator(c.user)
|
|
ModAction.create(container, c.user, 'removemoderator', target=c.user,
|
|
details='remove_self')
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
container = VByName('id'))
|
|
@api_doc(api_section.moderation)
|
|
def POST_leavecontributor(self, container):
|
|
"""
|
|
same comment as for POST_leave_moderator.
|
|
"""
|
|
if container and container.is_contributor(c.user):
|
|
container.remove_contributor(c.user)
|
|
|
|
|
|
_sr_friend_types = (
|
|
'moderator',
|
|
'moderator_invite',
|
|
'contributor',
|
|
'banned',
|
|
'wikibanned',
|
|
'wikicontributor',
|
|
)
|
|
|
|
_sr_friend_types_with_permissions = (
|
|
'moderator',
|
|
'moderator_invite',
|
|
)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
nuser = VExistingUname('name'),
|
|
iuser = VByName('id'),
|
|
container = nop('container'),
|
|
type = VOneOf('type', ('friend', 'enemy') +
|
|
_sr_friend_types))
|
|
@api_doc(api_section.users)
|
|
def POST_unfriend(self, nuser, iuser, container, type):
|
|
"""
|
|
Handles removal of a friend (a user-user relation) or removal
|
|
of a user's privileges from a subreddit (a user-subreddit
|
|
relation). The user can either be passed in by name (nuser)
|
|
or by fullname (iuser). If type is friend or enemy, 'container'
|
|
will be the current user, otherwise the subreddit must be set.
|
|
"""
|
|
if type in self._sr_friend_types:
|
|
container = c.site
|
|
else:
|
|
container = VByName('container').run(container)
|
|
if not container:
|
|
return
|
|
|
|
# The user who made the request must be an admin or a moderator
|
|
# for the privilege change to succeed.
|
|
# (Exception: a user can remove privilege from oneself)
|
|
victim = iuser or nuser
|
|
required_perms = []
|
|
if c.user != victim:
|
|
if type.startswith('wiki'):
|
|
required_perms.append('wiki')
|
|
else:
|
|
required_perms.append('access')
|
|
if (not c.user_is_admin
|
|
and (type in self._sr_friend_types
|
|
and not container.is_moderator_with_perms(
|
|
c.user, *required_perms))):
|
|
abort(403, 'forbidden')
|
|
if (type == 'moderator' and not
|
|
(c.user_is_admin or container.can_demod(c.user, victim))):
|
|
abort(403, 'forbidden')
|
|
# if we are (strictly) unfriending, the container had better
|
|
# be the current user.
|
|
if type in ("friend", "enemy") and container != c.user:
|
|
abort(403, 'forbidden')
|
|
fn = getattr(container, 'remove_' + type)
|
|
new = fn(victim)
|
|
|
|
# Log this action
|
|
if new and type in self._sr_friend_types:
|
|
action = dict(banned='unbanuser', moderator='removemoderator',
|
|
moderator_invite='uninvitemoderator',
|
|
wikicontributor='removewikicontributor',
|
|
wikibanned='wikiunbanned',
|
|
contributor='removecontributor').get(type, None)
|
|
ModAction.create(container, c.user, action, target=victim)
|
|
|
|
if type == "friend" and c.user.gold:
|
|
c.user.friend_rels_cache(_update=True)
|
|
|
|
@validatedForm(VSrModerator(), VModhash(),
|
|
target=VExistingUname('name'),
|
|
type_and_permissions=VPermissions('type', 'permissions'))
|
|
@api_doc(api_section.users)
|
|
def POST_setpermissions(self, form, jquery, target, type_and_permissions):
|
|
if form.has_errors('name', errors.USER_DOESNT_EXIST, errors.NO_USER):
|
|
return
|
|
if form.has_errors('type', errors.INVALID_PERMISSION_TYPE):
|
|
return
|
|
if form.has_errors('permissions', errors.INVALID_PERMISSIONS):
|
|
return
|
|
|
|
type, permissions = type_and_permissions
|
|
update = None
|
|
|
|
if type in ("moderator", "moderator_invite"):
|
|
if not c.user_is_admin:
|
|
if type == "moderator" and (
|
|
c.user == target or not c.site.can_demod(c.user, target)):
|
|
abort(403, 'forbidden')
|
|
if (type == "moderator_invite"
|
|
and not c.site.is_unlimited_moderator(c.user)):
|
|
abort(403, 'forbidden')
|
|
if type == "moderator":
|
|
rel = c.site.get_moderator(target)
|
|
if type == "moderator_invite":
|
|
rel = c.site.get_moderator_invite(target)
|
|
rel.set_permissions(permissions)
|
|
rel._commit()
|
|
update = rel.encoded_permissions
|
|
ModAction.create(c.site, c.user, action='setpermissions',
|
|
target=target, details='permission_' + type,
|
|
description=update)
|
|
|
|
if update:
|
|
row = form.closest('tr')
|
|
editor = row.find('.permissions').data('PermissionEditor')
|
|
editor.onCommit(update)
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
ip = ValidIP(),
|
|
friend = VExistingUname('name'),
|
|
container = nop('container'),
|
|
type = VOneOf('type', ('friend',) + _sr_friend_types),
|
|
type_and_permissions = VPermissions('type', 'permissions'),
|
|
note = VLength('note', 300))
|
|
@api_doc(api_section.users)
|
|
def POST_friend(self, form, jquery, ip, friend,
|
|
container, type, type_and_permissions, note):
|
|
"""
|
|
Complement to POST_unfriend: handles friending as well as
|
|
privilege changes on subreddits.
|
|
"""
|
|
if type in self._sr_friend_types:
|
|
container = c.site
|
|
else:
|
|
container = VByName('container').run(container)
|
|
if not container:
|
|
return
|
|
|
|
if type == "moderator" and not c.user_is_admin:
|
|
# attempts to add moderators now create moderator invites.
|
|
type = "moderator_invite"
|
|
|
|
fn = getattr(container, 'add_' + type)
|
|
|
|
# The user who made the request must be an admin or a moderator
|
|
# for the privilege change to succeed.
|
|
perm = 'wiki' if type.startswith('wiki') else 'access'
|
|
if (not c.user_is_admin
|
|
and type in self._sr_friend_types
|
|
and (not container.is_moderator_with_perms(c.user, perm)
|
|
or c.user._spam)):
|
|
if c.user._spam:
|
|
return
|
|
else:
|
|
abort(403, 'forbidden')
|
|
|
|
if type in self._sr_friend_types and not c.user_is_admin:
|
|
quota_key = "sr%squota-%s" % (str(type), container._id36)
|
|
g.cache.add(quota_key, 0, time=g.sr_quota_time)
|
|
subreddit_quota = g.cache.incr(quota_key)
|
|
quota_limit = getattr(g, "sr_%s_quota" % type)
|
|
if subreddit_quota > quota_limit and container.use_quotas:
|
|
form.set_html(".status", errors.SUBREDDIT_RATELIMIT)
|
|
c.errors.add(errors.SUBREDDIT_RATELIMIT)
|
|
form.set_error(errors.SUBREDDIT_RATELIMIT, None)
|
|
return
|
|
|
|
# if we are (strictly) friending, the container
|
|
# had better be the current user.
|
|
if type == "friend" and container != c.user:
|
|
abort(403,'forbidden')
|
|
|
|
elif form.has_errors("name", errors.USER_DOESNT_EXIST, errors.NO_USER):
|
|
return
|
|
elif form.has_errors("note", errors.TOO_LONG):
|
|
return
|
|
|
|
if type in self._sr_friend_types_with_permissions:
|
|
if form.has_errors('type', errors.INVALID_PERMISSION_TYPE):
|
|
return
|
|
if form.has_errors('permissions', errors.INVALID_PERMISSIONS):
|
|
return
|
|
else:
|
|
permissions = None
|
|
|
|
if type == "moderator_invite" and container.is_moderator(friend):
|
|
c.errors.add(errors.ALREADY_MODERATOR, field="name")
|
|
form.set_error(errors.ALREADY_MODERATOR, "name")
|
|
return
|
|
|
|
if type == "moderator":
|
|
container.remove_moderator_invite(friend)
|
|
|
|
new = fn(friend, permissions=type_and_permissions[1])
|
|
|
|
# Log this action
|
|
if new and type in self._sr_friend_types:
|
|
action = dict(banned='banuser',
|
|
moderator='addmoderator',
|
|
moderator_invite='invitemoderator',
|
|
wikicontributor='wikicontributor',
|
|
contributor='addcontributor',
|
|
wikibanned='wikibanned').get(type, None)
|
|
ModAction.create(container, c.user, action, target=friend)
|
|
|
|
if type == "friend" and c.user.gold:
|
|
# Yes, the order of the next two lines is correct.
|
|
# First you recalculate the rel_ids, then you find
|
|
# the right one and update its data.
|
|
c.user.friend_rels_cache(_update=True)
|
|
c.user.add_friend_note(friend, note or '')
|
|
|
|
if type in ('banned', 'wikibanned'):
|
|
container.add_rel_note(type, friend, note)
|
|
|
|
cls = dict(friend=FriendList,
|
|
moderator=ModList,
|
|
moderator_invite=ModList,
|
|
contributor=ContributorList,
|
|
wikicontributor=WikiMayContributeList,
|
|
banned=BannedList, wikibanned=WikiBannedList).get(type)
|
|
userlist = cls()
|
|
form.set_inputs(name = "")
|
|
if note:
|
|
form.set_inputs(note = "")
|
|
form.removeClass("edited")
|
|
form.set_html(".status:first", userlist.executed_message(type))
|
|
if new and cls:
|
|
user_row = userlist.user_row(type, friend)
|
|
jquery("." + type + "-table").show(
|
|
).find("table").insert_table_rows(user_row)
|
|
|
|
if new:
|
|
notify_user_added(type, c.user, friend, container)
|
|
|
|
@validatedForm(VGold(),
|
|
friend = VExistingUname('name'),
|
|
note = VLength('note', 300))
|
|
def POST_friendnote(self, form, jquery, friend, note):
|
|
if form.has_errors("note", errors.TOO_LONG):
|
|
return
|
|
c.user.add_friend_note(friend, note)
|
|
form.set_html('.status', _("saved"))
|
|
|
|
@validatedForm(type = VOneOf('type', ('bannednote', 'wikibannednote')),
|
|
user = VExistingUname('name'),
|
|
note = VLength('note', 300))
|
|
def POST_relnote(self, form, jquery, type, user, note):
|
|
if form.has_errors("note", errors.TOO_LONG):
|
|
return
|
|
c.site.add_rel_note(type[:-4], user, note)
|
|
form.set_html('.status', _("saved"))
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
ip=ValidIP())
|
|
@api_doc(api_section.subreddits)
|
|
def POST_accept_moderator_invite(self, form, jquery, ip):
|
|
rel = c.site.get_moderator_invite(c.user)
|
|
if not c.site.remove_moderator_invite(c.user):
|
|
c.errors.add(errors.NO_INVITE_FOUND)
|
|
form.set_error(errors.NO_INVITE_FOUND, None)
|
|
return
|
|
|
|
permissions = rel.get_permissions()
|
|
ModAction.create(c.site, c.user, "acceptmoderatorinvite")
|
|
c.site.add_moderator(c.user, permissions=rel.get_permissions())
|
|
notify_user_added("accept_moderator_invite", c.user, c.user, c.site)
|
|
jquery.refresh()
|
|
|
|
@json_validate(VUser(),
|
|
VGold(),
|
|
VModhash(),
|
|
deal=VLength('deal', 100))
|
|
def POST_claim_gold_partner_deal_code(self, responder, deal):
|
|
try:
|
|
return {'code': GoldPartnerDealCode.claim_code(c.user, deal)}
|
|
except GoldPartnerCodesExhaustedError:
|
|
return {'error': 'GOLD_PARTNER_CODES_EXHAUSTED',
|
|
'explanation': _("sorry, we're out of codes!")}
|
|
|
|
@validatedForm(VUser('curpass', default=''),
|
|
VModhash(),
|
|
password=VPassword(
|
|
['curpass', 'curpass'],
|
|
docs=dict(curpass="the user's current password")
|
|
),
|
|
dest = VDestination())
|
|
@api_doc(api_section.account)
|
|
def POST_clear_sessions(self, form, jquery, password, dest):
|
|
"""Clear all session cookies and replace the current one.
|
|
|
|
A valid password (`curpass`) must be supplied.
|
|
|
|
"""
|
|
# password is required to proceed
|
|
if form.has_errors("curpass", errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
form.set_html('.status',
|
|
_('all other sessions have been logged out'))
|
|
form.set_inputs(curpass = "")
|
|
|
|
# deauthorize all access tokens
|
|
OAuth2AccessToken.revoke_all_by_user(c.user)
|
|
OAuth2RefreshToken.revoke_all_by_user(c.user)
|
|
|
|
# run the change password command to get a new salt
|
|
change_password(c.user, password)
|
|
# the password salt has changed, so the user's cookie has been
|
|
# invalidated. drop a new cookie.
|
|
self.login(c.user)
|
|
|
|
@validatedForm(VUser('curpass', default = ''),
|
|
VModhash(),
|
|
email = ValidEmails("email", num = 1),
|
|
password = VPassword(['newpass', 'verpass']),
|
|
verify = VBoolean("verify"))
|
|
@api_doc(api_section.account)
|
|
def POST_update(self, form, jquery, email, password, verify):
|
|
"""
|
|
Update account email address and password.
|
|
|
|
Called by /prefs/update on the site. For frontend form verification
|
|
purposes, `newpass` and `verpass` must be equal for a password change
|
|
to succeed.
|
|
"""
|
|
# password is required to proceed
|
|
if form.has_errors("curpass", errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
# check if the email is valid. If one is given and it is
|
|
# different from the current address (or there is not one
|
|
# currently) apply it
|
|
updated = False
|
|
if (not form.has_errors("email", errors.BAD_EMAILS) and
|
|
email):
|
|
if (not hasattr(c.user,'email') or c.user.email != email):
|
|
if c.user.email_verified:
|
|
emailer.email_change_email(c.user)
|
|
c.user.email = email
|
|
# unverified email for now
|
|
c.user.email_verified = None
|
|
c.user._commit()
|
|
Award.take_away("verified_email", c.user)
|
|
updated = True
|
|
if verify:
|
|
# TODO: rate limit this?
|
|
emailer.verify_email(c.user)
|
|
form.set_html('.status',
|
|
_("you should be getting a verification email shortly."))
|
|
else:
|
|
form.set_html('.status', _('your email has been updated'))
|
|
|
|
# user is removing their email
|
|
if (not email and c.user.email and
|
|
(errors.NO_EMAILS, 'email') in c.errors):
|
|
c.errors.remove((errors.NO_EMAILS, 'email'))
|
|
c.user.email = ''
|
|
c.user.email_verified = None
|
|
c.user._commit()
|
|
Award.take_away("verified_email", c.user)
|
|
updated = True
|
|
form.set_html('.status', _('your email has been updated'))
|
|
|
|
# change password
|
|
if (password and
|
|
not (form.has_errors("newpass", errors.BAD_PASSWORD) or
|
|
form.has_errors("verpass", errors.BAD_PASSWORD_MATCH))):
|
|
change_password(c.user, password)
|
|
if c.user.email_verified:
|
|
emailer.password_change_email(c.user)
|
|
if updated:
|
|
form.set_html(".status",
|
|
_('your email and password have been updated'))
|
|
else:
|
|
form.set_html('.status',
|
|
_('your password has been updated'))
|
|
form.set_inputs(curpass = "", newpass = "", verpass = "")
|
|
# the password has changed, so the user's cookie has been
|
|
# invalidated. drop a new cookie.
|
|
self.login(c.user)
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
delete_message = VLength("delete_message", max_length=500),
|
|
username = VRequired("user", errors.NOT_USER),
|
|
user = VThrottledLogin(["user", "passwd"]),
|
|
confirm = VBoolean("confirm"))
|
|
@api_doc(api_section.account)
|
|
def POST_delete_user(self, form, jquery, delete_message, username, user, confirm):
|
|
"""Delete the currently logged in account.
|
|
|
|
A valid username/password and confirmation must be supplied. An
|
|
optional `delete_message` may be supplied to explain the reason the
|
|
account is to be deleted.
|
|
|
|
Called by /prefs/delete on the site.
|
|
|
|
"""
|
|
if username and username.lower() != c.user.name.lower():
|
|
c.errors.add(errors.NOT_USER, field="user")
|
|
|
|
if not confirm:
|
|
c.errors.add(errors.CONFIRM, field="confirm")
|
|
|
|
if not (form.has_errors('vdelay', errors.RATELIMIT) or
|
|
form.has_errors("user", errors.NOT_USER) or
|
|
form.has_errors("passwd", errors.WRONG_PASSWORD) or
|
|
form.has_errors("delete_message", errors.TOO_LONG) or
|
|
form.has_errors("confirm", errors.CONFIRM)):
|
|
c.user.delete(delete_message)
|
|
form.redirect("/?deleted=true")
|
|
|
|
@require_oauth2_scope("edit")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
thing = VByNameIfAuthor('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_del(self, thing):
|
|
"""Delete a Link or Comment."""
|
|
if not thing: return
|
|
was_deleted = thing._deleted
|
|
thing._deleted = True
|
|
if (getattr(thing, "promoted", None) is not None and
|
|
not promote.is_promoted(thing)):
|
|
promote.reject_promotion(thing)
|
|
thing._commit()
|
|
|
|
# flag search indexer that something has changed
|
|
changed(thing)
|
|
|
|
#expire the item from the sr cache
|
|
if isinstance(thing, Link):
|
|
queries.delete(thing)
|
|
|
|
#comments have special delete tasks
|
|
elif isinstance(thing, Comment):
|
|
parent_id = getattr(thing, 'parent_id', None)
|
|
link_id = thing.link_id
|
|
recipient = None
|
|
|
|
if parent_id:
|
|
parent_comment = Comment._byID(parent_id, data=True)
|
|
recipient = Account._byID(parent_comment.author_id)
|
|
else:
|
|
parent_link = Link._byID(link_id, data=True)
|
|
if parent_link.is_self:
|
|
recipient = Account._byID(parent_link.author_id)
|
|
|
|
if not was_deleted:
|
|
delete_comment(thing)
|
|
|
|
if recipient:
|
|
inbox_class = Inbox.rel(Account, Comment)
|
|
d = inbox_class._fast_query(recipient, thing, ("inbox",
|
|
"selfreply",
|
|
"mention"))
|
|
rels = filter(None, d.values()) or None
|
|
queries.new_comment(thing, rels)
|
|
|
|
queries.delete(thing)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
VSrCanAlter('id'),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_marknsfw(self, thing):
|
|
"""Mark a link NSFW.
|
|
|
|
See also: [/api/unmarknsfw](#POST_api_unmarknsfw).
|
|
|
|
"""
|
|
thing.over_18 = True
|
|
thing._commit()
|
|
|
|
if c.user._id != thing.author_id:
|
|
ModAction.create(thing.subreddit_slow, c.user, target=thing,
|
|
action='marknsfw')
|
|
|
|
# flag search indexer that something has changed
|
|
changed(thing)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
VSrCanAlter('id'),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_unmarknsfw(self, thing):
|
|
"""Remove the NSFW marking from a link.
|
|
|
|
See also: [/api/marknsfw](#POST_api_marknsfw).
|
|
|
|
"""
|
|
thing.over_18 = False
|
|
thing._commit()
|
|
|
|
if c.user._id != thing.author_id:
|
|
ModAction.create(thing.subreddit_slow, c.user, target=thing,
|
|
action='marknsfw', details='remove')
|
|
|
|
# flag search indexer that something has changed
|
|
changed(thing)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
VSrCanBan('id'),
|
|
thing=VByName('id'),
|
|
state=VBoolean('state'))
|
|
def POST_set_contest_mode(self, form, jquery, thing, state):
|
|
thing.contest_mode = state
|
|
thing._commit()
|
|
jquery.refresh()
|
|
|
|
@noresponse(VUser(), VModhash(),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_report(self, thing):
|
|
"""Report a link or comment.
|
|
|
|
Reporting a thing brings it to the attention of the subreddit's
|
|
moderators. The thing is implicitly hidden as well (see
|
|
[/api/hide](#POST_api_hide) for details).
|
|
|
|
"""
|
|
if not thing or thing._deleted:
|
|
return
|
|
elif getattr(thing, 'promoted', False):
|
|
return
|
|
|
|
# if it is a message that is being reported, ban it.
|
|
# every user is admin over their own personal inbox
|
|
if isinstance(thing, Message):
|
|
admintools.spam(thing, False, True, c.user.name)
|
|
# auto-hide links that are reported
|
|
elif isinstance(thing, Link):
|
|
r = thing._hide(c.user)
|
|
# TODO: be nice to be able to remove comments that are reported
|
|
# from a user's inbox so they don't have to look at them.
|
|
elif isinstance(thing, Comment):
|
|
pass
|
|
|
|
sr = getattr(thing, 'subreddit_slow', None)
|
|
if (c.user._spam or
|
|
c.user.ignorereports or
|
|
(sr and sr.is_banned(c.user))):
|
|
return
|
|
Report.new(c.user, thing)
|
|
admintools.report(thing)
|
|
|
|
@require_oauth2_scope("privatemessages")
|
|
@noresponse(VUser(), VModhash(),
|
|
thing=VByName('id'))
|
|
@api_doc(api_section.messages)
|
|
def POST_block(self, thing):
|
|
'''for blocking via inbox'''
|
|
if not thing:
|
|
return
|
|
|
|
# Users may only block someone who has
|
|
# actively harassed them (i.e., comment/link reply
|
|
# or PM). Check that 'thing' is in the user's inbox somewhere
|
|
inbox_cls = Inbox.rel(Account, thing.__class__)
|
|
rels = inbox_cls._fast_query(c.user, thing,
|
|
("inbox", "selfreply", "mention"))
|
|
if not filter(None, rels.values()):
|
|
return
|
|
|
|
block_acct = Account._byID(thing.author_id)
|
|
if block_acct.name in g.admins:
|
|
return
|
|
c.user.add_enemy(block_acct)
|
|
|
|
@require_oauth2_scope("edit")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
item = VByNameIfAuthor('thing_id'),
|
|
text = VSelfText('text'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_editusertext(self, form, jquery, item, text):
|
|
"""Edit the body text of a comment or self-post."""
|
|
if (not form.has_errors("text",
|
|
errors.NO_TEXT, errors.TOO_LONG) and
|
|
not form.has_errors("thing_id", errors.NOT_AUTHOR)):
|
|
|
|
if isinstance(item, Comment):
|
|
kind = 'comment'
|
|
item.body = text
|
|
elif isinstance(item, Link):
|
|
kind = 'link'
|
|
if not getattr(item, "is_self", False):
|
|
return abort(403, "forbidden")
|
|
item.selftext = text
|
|
else:
|
|
g.log.warning("%s tried to edit usertext on %r", c.user, item)
|
|
return
|
|
|
|
if item._deleted:
|
|
return abort(403, "forbidden")
|
|
|
|
if (item._date < timeago('3 minutes')
|
|
or (item._ups + item._downs > 2)):
|
|
item.editted = c.start_time
|
|
|
|
item.ignore_reports = False
|
|
|
|
item._commit()
|
|
|
|
changed(item)
|
|
|
|
amqp.add_item('usertext_edited', item._fullname)
|
|
|
|
if kind == 'link':
|
|
set_last_modified(item, 'comments')
|
|
LastModified.touch(item._fullname, 'Comments')
|
|
|
|
wrapper = default_thing_wrapper(expand_children = True)
|
|
jquery(".content").replace_things(item, True, True, wrap = wrapper)
|
|
jquery(".content .link .rank").hide()
|
|
|
|
@require_oauth2_scope("submit")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
VRatelimit(rate_user = True, rate_ip = True,
|
|
prefix = "rate_comment_"),
|
|
ip = ValidIP(),
|
|
parent = VSubmitParent(['thing_id', 'parent']),
|
|
comment = VMarkdown(['text', 'comment']))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_comment(self, commentform, jquery, parent, comment, ip):
|
|
"""Submit a new comment or reply to a message.
|
|
|
|
`parent` is the fullname of the thing being replied to. Its value
|
|
changes the kind of object created by this request:
|
|
|
|
* the fullname of a Link: a top-level comment in that Link's thread.
|
|
* the fullname of a Comment: a comment reply to that comment.
|
|
* the fullname of a Message: a message reply to that message.
|
|
|
|
`text` should be the raw markdown body of the comment or message.
|
|
|
|
To start a new message thread, use [/api/compose](#POST_api_compose).
|
|
|
|
"""
|
|
should_ratelimit = True
|
|
#check the parent type here cause we need that for the
|
|
#ratelimit checks
|
|
if isinstance(parent, Message):
|
|
if not getattr(parent, "repliable", True):
|
|
abort(403, 'forbidden')
|
|
if not parent.can_view_slow():
|
|
abort(403, 'forbidden')
|
|
is_message = True
|
|
should_ratelimit = False
|
|
else:
|
|
is_message = False
|
|
is_comment = True
|
|
if isinstance(parent, Link):
|
|
link = parent
|
|
parent_comment = None
|
|
else:
|
|
link = Link._byID(parent.link_id, data = True)
|
|
parent_comment = parent
|
|
sr = parent.subreddit_slow
|
|
if ((link.is_self and link.author_id == c.user._id)
|
|
or not sr.should_ratelimit(c.user, 'comment')):
|
|
should_ratelimit = False
|
|
parent_age = c.start_time - parent._date
|
|
if not link.promoted and parent_age.days > g.REPLY_AGE_LIMIT:
|
|
c.errors.add(errors.TOO_OLD, field = "parent")
|
|
|
|
#remove the ratelimit error if the user's karma is high
|
|
if not should_ratelimit:
|
|
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
|
|
|
|
if (not commentform.has_errors("text",
|
|
errors.NO_TEXT,
|
|
errors.TOO_LONG) and
|
|
not commentform.has_errors("ratelimit",
|
|
errors.RATELIMIT) and
|
|
not commentform.has_errors("parent",
|
|
errors.DELETED_COMMENT,
|
|
errors.DELETED_LINK,
|
|
errors.TOO_OLD,
|
|
errors.USER_BLOCKED)):
|
|
|
|
if is_message:
|
|
if parent.from_sr:
|
|
to = Subreddit._byID(parent.sr_id)
|
|
else:
|
|
to = Account._byID(parent.author_id)
|
|
subject = parent.subject
|
|
re = "re: "
|
|
if not subject.startswith(re):
|
|
subject = re + subject
|
|
item, inbox_rel = Message._new(c.user, to, subject,
|
|
comment, ip, parent = parent)
|
|
item.parent_id = parent._id
|
|
else:
|
|
item, inbox_rel = Comment._new(c.user, link, parent_comment,
|
|
comment, ip)
|
|
queries.queue_vote(c.user, item, True, ip,
|
|
cheater = (errors.CHEATER, None) in c.errors)
|
|
|
|
# adding to comments-tree is done as part of
|
|
# newcomments_q, so if they refresh immediately they
|
|
# won't see their comment
|
|
|
|
# clean up the submission form and remove it from the DOM (if reply)
|
|
t = commentform.find("textarea")
|
|
t.attr('rows', 3).html("").val("")
|
|
if isinstance(parent, (Comment, Message)):
|
|
commentform.remove()
|
|
jquery.things(parent._fullname).set_html(".reply-button:first",
|
|
_("replied"))
|
|
|
|
# insert the new comment
|
|
jquery.insert_things(item)
|
|
|
|
# remove any null listings that may be present
|
|
jquery("#noresults").hide()
|
|
|
|
#update the queries
|
|
if is_message:
|
|
queries.new_message(item, inbox_rel)
|
|
else:
|
|
queries.new_comment(item, inbox_rel)
|
|
|
|
#set the ratelimiter
|
|
if should_ratelimit:
|
|
VRatelimit.ratelimit(rate_user=True, rate_ip = True,
|
|
prefix = "rate_comment_")
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
VCaptcha(),
|
|
VRatelimit(rate_user = True, rate_ip = True,
|
|
prefix = "rate_share_"),
|
|
share_from = VLength('share_from', max_length = 100),
|
|
emails = ValidEmailsOrExistingUnames("share_to"),
|
|
reply_to = ValidEmails("replyto", num = 1),
|
|
message = VLength("message", max_length = 1000),
|
|
thing = VByName('parent'),
|
|
ip = ValidIP())
|
|
def POST_share(self, shareform, jquery, emails, thing, share_from, reply_to,
|
|
message, ip):
|
|
|
|
# remove the ratelimit error if the user's karma is high
|
|
sr = thing.subreddit_slow
|
|
should_ratelimit = sr.should_ratelimit(c.user, 'link')
|
|
if not should_ratelimit:
|
|
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
|
|
|
|
# share_from and messages share a too_long error.
|
|
# finding an error on one necessitates hiding the other error
|
|
if shareform.has_errors("share_from", errors.TOO_LONG):
|
|
shareform.find(".message-errors").children().hide()
|
|
elif shareform.has_errors("message", errors.TOO_LONG):
|
|
shareform.find(".share-form-errors").children().hide()
|
|
# reply_to and share_to also share errors...
|
|
elif shareform.has_errors("share_to", errors.BAD_EMAILS,
|
|
errors.NO_EMAILS,
|
|
errors.TOO_MANY_EMAILS):
|
|
shareform.find(".reply-to-errors").children().hide()
|
|
elif shareform.has_errors("replyto", errors.BAD_EMAILS,
|
|
errors.TOO_MANY_EMAILS):
|
|
shareform.find(".share-to-errors").children().hide()
|
|
# lastly, check the captcha.
|
|
elif shareform.has_errors("captcha", errors.BAD_CAPTCHA):
|
|
pass
|
|
elif shareform.has_errors("ratelimit", errors.RATELIMIT):
|
|
pass
|
|
else:
|
|
emails, users = emails
|
|
c.user.add_share_emails(emails)
|
|
c.user._commit()
|
|
link = jquery.things(thing._fullname)
|
|
link.set_html(".share", _("shared"))
|
|
shareform.html("<div class='clearleft'></div>"
|
|
"<p class='error'>%s</p>" %
|
|
_("your link has been shared."))
|
|
|
|
# Set up the parts that are common between e-mail and PMs
|
|
urlparts = (get_domain(cname=c.cname, subreddit=False),
|
|
thing._id36)
|
|
url = "http://%s/tb/%s" % urlparts
|
|
|
|
if message:
|
|
message = message + "\n\n"
|
|
else:
|
|
message = ""
|
|
message = message + '\n%s\n\n%s\n\n' % (thing.title,url)
|
|
|
|
# Deliberately not translating this, as it'd be in the
|
|
# sender's language
|
|
if thing.num_comments:
|
|
count = ("There are currently %(num_comments)s comments on " +
|
|
"this link. You can view them here:")
|
|
if thing.num_comments == 1:
|
|
count = ("There is currently %(num_comments)s comment " +
|
|
"on this link. You can view it here:")
|
|
|
|
numcom = count % {'num_comments':thing.num_comments}
|
|
message = message + "%s\n\n" % numcom
|
|
else:
|
|
message = message + "You can leave a comment here:\n\n"
|
|
|
|
url = add_sr(thing.make_permalink_slow(), force_hostname=True)
|
|
message = message + url
|
|
|
|
# E-mail everyone
|
|
emailer.share(thing, emails, from_name = share_from or "",
|
|
body = message or "", reply_to = reply_to or "")
|
|
|
|
# Send the PMs
|
|
subject = "%s has shared a link with you!" % c.user.name
|
|
# Prepend this subject to the message - we're repeating ourselves
|
|
# because it looks very abrupt without it.
|
|
message = "%s\n\n%s" % (subject,message)
|
|
|
|
for target in users:
|
|
|
|
m, inbox_rel = Message._new(c.user, target, subject,
|
|
message, ip)
|
|
# Queue up this PM
|
|
amqp.add_item('new_message', m._fullname)
|
|
|
|
queries.new_message(m, inbox_rel)
|
|
|
|
#set the ratelimiter
|
|
if should_ratelimit:
|
|
VRatelimit.ratelimit(rate_user=True, rate_ip = True,
|
|
prefix = "rate_share_")
|
|
|
|
|
|
@require_oauth2_scope("vote")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
vote_type = VVotehash(('vh', 'id')),
|
|
ip = ValidIP(),
|
|
dir=VInt('dir', min=-1, max=1, docs={"dir":
|
|
"vote direction. one of (1, 0, -1)"}),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_vote(self, dir, thing, ip, vote_type):
|
|
"""Cast a vote on a thing.
|
|
|
|
`id` should be the fullname of the Link or Comment to vote on.
|
|
|
|
`dir` indicates the direction of the vote. Voting `1` is an upvote,
|
|
`-1` is a downvote, and `0` is equivalent to "un-voting" by clicking
|
|
again on a highlighted arrow.
|
|
|
|
**Note: votes must be cast by humans.** That is, API clients proxying a
|
|
human's action one-for-one are OK, but bots deciding how to vote on
|
|
content or amplifying a human's vote are not. See [the reddit
|
|
rules](/rules) for more details on what constitutes vote cheating.
|
|
|
|
"""
|
|
|
|
ip = request.ip
|
|
user = c.user
|
|
store = True
|
|
|
|
if not thing or thing._deleted:
|
|
return
|
|
|
|
if vote_type == "rejected":
|
|
reject_vote(thing)
|
|
store = False
|
|
|
|
thing_age = c.start_time - thing._date
|
|
if thing_age.days > g.VOTE_AGE_LIMIT:
|
|
g.log.debug("ignoring vote on old thing %s" % thing._fullname)
|
|
store = False
|
|
|
|
if getattr(c.user, "suspicious", False):
|
|
g.log.info("%s cast a %d vote on %s", c.user.name, dir, thing._fullname)
|
|
|
|
dir = (True if dir > 0
|
|
else False if dir < 0
|
|
else None)
|
|
|
|
organic = vote_type == 'organic'
|
|
queries.queue_vote(user, thing, dir, ip, organic, store = store,
|
|
cheater = (errors.CHEATER, None) in c.errors)
|
|
|
|
@require_oauth2_scope("modconfig")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
# nop is safe: handled after auth checks below
|
|
stylesheet_contents = nop('stylesheet_contents'),
|
|
prevstyle = VLength('prevstyle', max_length=36),
|
|
op = VOneOf('op',['save','preview']))
|
|
@api_doc(api_section.subreddits)
|
|
def POST_subreddit_stylesheet(self, form, jquery,
|
|
stylesheet_contents = '', prevstyle='', op='save'):
|
|
|
|
if form.has_errors("prevstyle", errors.TOO_LONG):
|
|
return
|
|
report, parsed = c.site.parse_css(stylesheet_contents)
|
|
|
|
# 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.
|
|
prevstyle = request.post.get('prevstyle')
|
|
if not report:
|
|
return abort(403, 'forbidden')
|
|
|
|
if report.errors:
|
|
error_items = [ CssError(x).render(style='html')
|
|
for x in sorted(report.errors) ]
|
|
form.set_html(".status", _('validation errors'))
|
|
form.set_html(".errors ul", ''.join(error_items))
|
|
form.find('.errors').show()
|
|
c.errors.add(errors.BAD_CSS, field="stylesheet_contents")
|
|
form.has_errors("stylesheet_contents", errors.BAD_CSS)
|
|
return
|
|
else:
|
|
form.find('.errors').hide()
|
|
form.find('#conflict_box').hide()
|
|
form.set_html(".errors ul", '')
|
|
|
|
stylesheet_contents_parsed = parsed or ''
|
|
if op == 'save':
|
|
c.site.stylesheet_contents = stylesheet_contents_parsed
|
|
try:
|
|
wr = c.site.change_css(stylesheet_contents, parsed, prevstyle)
|
|
form.find('.conflict_box').hide()
|
|
form.find('.errors').hide()
|
|
form.set_html(".status", _('saved'))
|
|
form.set_html(".errors ul", "")
|
|
if wr:
|
|
description = wiki.modactions.get('config/stylesheet')
|
|
form.set_inputs(prevstyle=str(wr._id))
|
|
ModAction.create(c.site, c.user, 'wikirevise', description)
|
|
except ConflictException as e:
|
|
c.errors.add(errors.CONFLICT, field="stylesheet_contents")
|
|
form.has_errors("stylesheet_contents", errors.CONFLICT)
|
|
form.set_html(".status", _('conflict error'))
|
|
form.set_html(".errors ul", _('There was a conflict while editing the stylesheet'))
|
|
form.find('#conflict_box').show()
|
|
form.set_inputs(conflict_old=e.your,
|
|
prevstyle=e.new_id, stylesheet_contents=e.new)
|
|
form.set_html('#conflict_diff', e.htmldiff)
|
|
form.find('.errors').show()
|
|
return
|
|
except (tdb_cassandra.NotFound, ValueError):
|
|
c.errors.add(errors.BAD_REVISION, field="prevstyle")
|
|
form.has_errors("prevstyle", errors.BAD_REVISION)
|
|
return
|
|
jquery.apply_stylesheet(stylesheet_contents_parsed)
|
|
if op == 'preview':
|
|
# try to find a link to use, otherwise give up and
|
|
# return
|
|
links = cssfilter.find_preview_links(c.site)
|
|
if links:
|
|
|
|
jquery('#preview-table').show()
|
|
|
|
# do a regular link
|
|
jquery('#preview_link_normal').html(
|
|
cssfilter.rendered_link(links, media = 'off',
|
|
compress=False))
|
|
# now do one with media
|
|
jquery('#preview_link_media').html(
|
|
cssfilter.rendered_link(links, media = 'on',
|
|
compress=False))
|
|
# do a compressed link
|
|
jquery('#preview_link_compressed').html(
|
|
cssfilter.rendered_link(links, media = 'off',
|
|
compress=True))
|
|
|
|
# and do a comment
|
|
comments = cssfilter.find_preview_comments(c.site)
|
|
if comments:
|
|
jquery('#preview_comment').html(
|
|
cssfilter.rendered_comment(comments))
|
|
|
|
@require_oauth2_scope("modconfig")
|
|
@validatedForm(VSrModerator(perms='config'),
|
|
VModhash(),
|
|
name = VCssName('img_name'))
|
|
@api_doc(api_section.subreddits)
|
|
def POST_delete_sr_img(self, form, jquery, name):
|
|
"""
|
|
Called called upon requested delete on /about/stylesheet.
|
|
Updates the site's image list, and causes the <li> which wraps
|
|
the image to be hidden.
|
|
"""
|
|
# just in case we need to kill this feature from XSS
|
|
if g.css_killswitch:
|
|
return self.abort(403,'forbidden')
|
|
c.site.del_image(name)
|
|
c.site._commit()
|
|
ModAction.create(c.site, c.user, action='editsettings',
|
|
details='del_image', description=name)
|
|
|
|
@require_oauth2_scope("modconfig")
|
|
@validatedForm(VSrModerator(perms='config'),
|
|
VModhash())
|
|
@api_doc(api_section.subreddits)
|
|
def POST_delete_sr_header(self, form, jquery):
|
|
"""
|
|
Called when the user request that the header on a sr be reset.
|
|
"""
|
|
# just in case we need to kill this feature from XSS
|
|
if g.css_killswitch:
|
|
return self.abort(403,'forbidden')
|
|
if c.site.header:
|
|
c.site.header = None
|
|
c.site.header_size = None
|
|
c.site._commit()
|
|
ModAction.create(c.site, c.user, action='editsettings',
|
|
details='del_header')
|
|
|
|
# hide the button which started this
|
|
form.find('.delete-img').hide()
|
|
# hide the preview box
|
|
form.find('.img-preview-container').hide()
|
|
# reset the status boxes
|
|
form.set_html('.img-status', _("deleted"))
|
|
|
|
|
|
def GET_upload_sr_img(self, *a, **kw):
|
|
"""
|
|
Completely unnecessary method which exists because safari can
|
|
be dumb too. On page reload after an image has been posted in
|
|
safari, the iframe to which the request posted preserves the
|
|
URL of the POST, and safari attempts to execute a GET against
|
|
it. The iframe is hidden, so what it returns is completely
|
|
irrelevant.
|
|
"""
|
|
return "nothing to see here."
|
|
|
|
@require_oauth2_scope("modconfig")
|
|
@validate(VSrModerator(perms='config'),
|
|
VModhash(),
|
|
file = VLength('file', max_length=1024*500),
|
|
name = VCssName("name"),
|
|
img_type = VImageType('img_type'),
|
|
form_id = VLength('formid', max_length = 100),
|
|
header = VInt('header', max=1, min=0))
|
|
@api_doc(api_section.subreddits)
|
|
def POST_upload_sr_img(self, file, header, name, form_id, img_type):
|
|
"""
|
|
Called on /about/stylesheet when an image needs to be replaced
|
|
or uploaded, as well as on /about/edit for updating the
|
|
header. Unlike every other POST in this controller, this
|
|
method does not get called with Ajax but rather is from the
|
|
original form POSTing to a hidden iFrame. Unfortunately, this
|
|
means the response needs to generate an page with a script tag
|
|
to fire the requisite updates to the parent document, and,
|
|
more importantly, that we can't use our normal toolkit for
|
|
passing those responses back.
|
|
|
|
The result of this function is a rendered UploadedImage()
|
|
object in charge of firing the completedUploadImage() call in
|
|
JS.
|
|
"""
|
|
|
|
# default error list (default values will reset the errors in
|
|
# the response if no error is raised)
|
|
errors = dict(BAD_CSS_NAME = "", IMAGE_ERROR = "")
|
|
add_image_to_sr = False
|
|
size = None
|
|
|
|
if not header:
|
|
add_image_to_sr = True
|
|
if not name:
|
|
# error if the name wasn't specified and the image was not for a sponsored link or header
|
|
# this may also fail if a sponsored image was added and the user is not an admin
|
|
errors['BAD_CSS_NAME'] = _("bad image name")
|
|
|
|
if c.site.images and add_image_to_sr:
|
|
if c.site.get_num_images() >= g.max_sr_images:
|
|
errors['IMAGE_ERROR'] = _("too many images (you only get %d)") % g.max_sr_images
|
|
|
|
if any(errors.values()):
|
|
return UploadedImage("", "", "", errors=errors, form_id=form_id).render()
|
|
else:
|
|
try:
|
|
new_url = cssfilter.save_sr_image(c.site, file, suffix ='.' + img_type)
|
|
except cssfilter.BadImage:
|
|
errors['IMAGE_ERROR'] = _("Invalid image or general image error")
|
|
return UploadedImage("", "", "", errors=errors, form_id=form_id).render()
|
|
size = str_to_image(file).size
|
|
if header:
|
|
c.site.header = new_url
|
|
c.site.header_size = size
|
|
if add_image_to_sr:
|
|
c.site.add_image(name, url = new_url)
|
|
c.site._commit()
|
|
|
|
if header:
|
|
kw = dict(details='upload_image_header')
|
|
else:
|
|
kw = dict(details='upload_image', description=name)
|
|
ModAction.create(c.site, c.user, action='editsettings', **kw)
|
|
|
|
return UploadedImage(_('saved'), new_url, name,
|
|
errors=errors, form_id=form_id).render()
|
|
|
|
@require_oauth2_scope("modconfig")
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
VRatelimit(rate_user = True,
|
|
rate_ip = True,
|
|
prefix = 'create_reddit_'),
|
|
sr = VByName('sr'),
|
|
name = VSubredditName("name"),
|
|
title = VLength("title", max_length = 100),
|
|
header_title = VLength("header-title", max_length = 500),
|
|
domain = VCnameDomain("domain"),
|
|
public_description = VMarkdown("public_description", max_length = 500),
|
|
prev_public_description_id = VLength('prev_public_description_id', max_length = 36),
|
|
description = VMarkdown("description", max_length = 5120),
|
|
prev_description_id = VLength('prev_description_id', max_length = 36),
|
|
lang = VLang("lang"),
|
|
over_18 = VBoolean('over_18'),
|
|
allow_top = VBoolean('allow_top'),
|
|
show_media = VBoolean('show_media'),
|
|
exclude_banned_modqueue = VBoolean('exclude_banned_modqueue'),
|
|
show_cname_sidebar = VBoolean('show_cname_sidebar'),
|
|
type = VOneOf('type', ('public', 'private', 'restricted', 'gold_restricted', 'archived')),
|
|
link_type = VOneOf('link_type', ('any', 'link', 'self')),
|
|
submit_link_label=VLength('submit_link_label', max_length=60),
|
|
submit_text_label=VLength('submit_text_label', max_length=60),
|
|
comment_score_hide_mins=VInt('comment_score_hide_mins',
|
|
coerce=False, num_default=0, min=0, max=1440),
|
|
wikimode = VOneOf('wikimode', ('disabled', 'modonly', 'anyone')),
|
|
wiki_edit_karma = VInt("wiki_edit_karma", coerce=False, num_default=0, min=0),
|
|
wiki_edit_age = VInt("wiki_edit_age", coerce=False, num_default=0, min=0),
|
|
ip = ValidIP(),
|
|
css_on_cname = VBoolean("css_on_cname"),
|
|
)
|
|
@api_doc(api_section.subreddits)
|
|
def POST_site_admin(self, form, jquery, name, ip, sr, **kw):
|
|
def apply_wikid_field(sr, form, pagename, value, prev, field, error):
|
|
id_field_name = 'prev_%s_id' % field
|
|
try:
|
|
wikipage = wiki.WikiPage.get(sr, pagename)
|
|
except tdb_cassandra.NotFound:
|
|
wikipage = wiki.WikiPage.create(sr, pagename)
|
|
try:
|
|
wr = wikipage.revise(value, previous=prev, author=c.user._id36)
|
|
setattr(sr, field, value)
|
|
if not wr:
|
|
return True
|
|
setattr(sr, id_field_name, str(wikipage.revision))
|
|
ModAction.create(sr, c.user, 'wikirevise', details=wiki.modactions.get(pagename))
|
|
return True
|
|
except ConflictException as e:
|
|
c.errors.add(errors.CONFLICT, field=field)
|
|
form.has_errors(field, errors.CONFLICT)
|
|
form.parent().set_html('.status', error)
|
|
form.find('#%s_conflict_box' % field).show()
|
|
form.set_inputs(**{id_field_name: e.new_id, '%s_conflict_old' % field: e.your, field: e.new})
|
|
form.set_html('#%s_conflict_diff' % field, e.htmldiff)
|
|
except (tdb_cassandra.NotFound, ValueError):
|
|
c.errors.add(errors.BAD_REVISION, field=id_field_name)
|
|
form.has_errors(id_field_name, errors.BAD_REVISION)
|
|
return False
|
|
|
|
# the status button is outside the form -- have to reset by hand
|
|
form.parent().set_html('.status', "")
|
|
|
|
redir = False
|
|
kw = dict((k, v) for k, v in kw.iteritems()
|
|
if k in ('name', 'title', 'domain', 'description',
|
|
'show_media', 'exclude_banned_modqueue',
|
|
'show_cname_sidebar', 'type',
|
|
'link_type', 'submit_link_label', 'comment_score_hide_mins',
|
|
'submit_text_label', 'lang', 'css_on_cname',
|
|
'header_title', 'over_18', 'wikimode', 'wiki_edit_karma',
|
|
'wiki_edit_age', 'allow_top', 'public_description'))
|
|
|
|
public_description = kw.pop('public_description')
|
|
description = kw.pop('description')
|
|
|
|
# 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.
|
|
prev_desc = request.post.get('prev_description_id')
|
|
prev_pubdesc = request.post.get('prev_public_description_id')
|
|
|
|
def update_wiki_text(sr):
|
|
error = False
|
|
if not apply_wikid_field(sr,
|
|
form,
|
|
'config/sidebar',
|
|
description,
|
|
prev_desc,
|
|
'description',
|
|
_("Sidebar was not saved")):
|
|
error = True
|
|
|
|
if not apply_wikid_field(sr,
|
|
form,
|
|
'config/description',
|
|
public_description,
|
|
prev_pubdesc,
|
|
'public_description',
|
|
_("Description was not saved")):
|
|
error = True
|
|
return not error
|
|
|
|
|
|
#if a user is banned, return rate-limit errors
|
|
if c.user._spam:
|
|
time = timeuntil(datetime.now(g.tz) + timedelta(seconds=600))
|
|
c.errors.add(errors.RATELIMIT, {'time': time})
|
|
|
|
domain = kw['domain']
|
|
cname_sr = domain and Subreddit._by_domain(domain)
|
|
if cname_sr and (not sr or sr != cname_sr):
|
|
c.errors.add(errors.USED_CNAME)
|
|
|
|
can_set_archived = c.user_is_admin or (sr and sr.type == 'archived')
|
|
if kw['type'] == 'archived' and not can_set_archived:
|
|
c.errors.add(errors.INVALID_OPTION, field='type')
|
|
|
|
can_set_gold_restricted = c.user_is_admin or (sr and sr.type == 'gold_restricted')
|
|
if kw['type'] == 'gold_restricted' and not can_set_gold_restricted:
|
|
c.errors.add(errors.INVALID_OPTION, field='type')
|
|
|
|
if not sr and form.has_errors("ratelimit", errors.RATELIMIT):
|
|
pass
|
|
elif not sr and form.has_errors("name", errors.SUBREDDIT_EXISTS,
|
|
errors.BAD_SR_NAME):
|
|
form.find('#example_name').hide()
|
|
elif form.has_errors('title', errors.NO_TEXT, errors.TOO_LONG):
|
|
form.find('#example_title').hide()
|
|
elif form.has_errors('domain', errors.BAD_CNAME, errors.USED_CNAME):
|
|
form.find('#example_domain').hide()
|
|
elif (form.has_errors(('type', 'link_type', 'wikimode'),
|
|
errors.INVALID_OPTION) or
|
|
form.has_errors('public_description', errors.TOO_LONG) or
|
|
form.has_errors('description', errors.TOO_LONG)):
|
|
pass
|
|
elif sr and (form.has_errors(('prev_public_description_id',
|
|
'prev_description_id'), errors.TOO_LONG)):
|
|
pass
|
|
elif (form.has_errors(('wiki_edit_karma', 'wiki_edit_age'),
|
|
errors.BAD_NUMBER)):
|
|
pass
|
|
elif form.has_errors('comment_score_hide_mins', errors.BAD_NUMBER):
|
|
pass
|
|
#creating a new reddit
|
|
elif not sr:
|
|
#sending kw is ok because it was sanitized above
|
|
sr = Subreddit._new(name = name, author_id = c.user._id, ip = ip,
|
|
**kw)
|
|
|
|
update_wiki_text(sr)
|
|
sr._commit()
|
|
|
|
Subreddit.subscribe_defaults(c.user)
|
|
# make sure this user is on the admin list of that site!
|
|
if sr.add_subscriber(c.user):
|
|
sr._incr('_ups', 1)
|
|
sr.add_moderator(c.user)
|
|
sr.add_contributor(c.user)
|
|
redir = sr.path + "about/edit/?created=true"
|
|
if not c.user_is_admin:
|
|
VRatelimit.ratelimit(rate_user=True,
|
|
rate_ip = True,
|
|
prefix = "create_reddit_")
|
|
|
|
queries.new_subreddit(sr)
|
|
changed(sr)
|
|
|
|
#editting an existing reddit
|
|
elif sr.is_moderator_with_perms(c.user, 'config') or c.user_is_admin:
|
|
#assume sr existed, or was just built
|
|
old_domain = sr.domain
|
|
|
|
success = update_wiki_text(sr)
|
|
|
|
if not sr.domain:
|
|
del kw['css_on_cname']
|
|
for k, v in kw.iteritems():
|
|
if getattr(sr, k, None) != v:
|
|
ModAction.create(sr, c.user, action='editsettings',
|
|
details=k)
|
|
setattr(sr, k, v)
|
|
sr._commit()
|
|
|
|
#update the domain cache if the domain changed
|
|
if sr.domain != old_domain:
|
|
Subreddit._by_domain(old_domain, _update = True)
|
|
Subreddit._by_domain(sr.domain, _update = True)
|
|
|
|
# flag search indexer that something has changed
|
|
changed(sr)
|
|
if success:
|
|
form.parent().set_html('.status', _("saved"))
|
|
|
|
if form.has_error():
|
|
return
|
|
|
|
if redir:
|
|
form.redirect(redir)
|
|
else:
|
|
jquery.refresh()
|
|
|
|
@noresponse(q = VPrintable('q', max_length=500),
|
|
sort = VPrintable('sort', max_length=10),
|
|
t = VPrintable('t', max_length=10),
|
|
approval = VBoolean('approval'))
|
|
def POST_searchfeedback(self, q, sort, t, approval):
|
|
timestamp = c.start_time.strftime("%Y/%m/%d-%H:%M:%S")
|
|
if c.user_is_loggedin:
|
|
username = c.user.name
|
|
else:
|
|
username = None
|
|
d = dict(username=username, q=q, sort=sort, t=t)
|
|
hex = hashlib.md5(repr(d)).hexdigest()
|
|
key = "searchfeedback-%s-%s-%s" % (timestamp[:10], request.ip, hex)
|
|
d['timestamp'] = timestamp
|
|
d['approval'] = approval
|
|
g.hardcache.set(key, d, time=86400 * 7)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(), VModhash(),
|
|
VSrCanBan('id'),
|
|
thing = VByName('id'),
|
|
spam = VBoolean('spam', default=True))
|
|
@api_doc(api_section.moderation)
|
|
def POST_remove(self, thing, spam):
|
|
|
|
# Don't remove a promoted link
|
|
if getattr(thing, "promoted", None):
|
|
return
|
|
|
|
filtered = thing._spam
|
|
kw = {'target': thing}
|
|
|
|
if filtered and spam:
|
|
kw['details'] = 'confirm_spam'
|
|
train_spam = False
|
|
elif filtered and not spam:
|
|
kw['details'] = 'remove'
|
|
admintools.unspam(thing, unbanner=c.user.name, insert=False)
|
|
train_spam = False
|
|
elif not filtered and spam:
|
|
kw['details'] = 'spam'
|
|
train_spam = True
|
|
elif not filtered and not spam:
|
|
kw['details'] = 'remove'
|
|
train_spam = False
|
|
|
|
admintools.spam(thing, auto=False,
|
|
moderator_banned=not c.user_is_admin,
|
|
banner=c.user.name,
|
|
train_spam=train_spam)
|
|
|
|
if isinstance(thing, (Link, Comment)):
|
|
sr = thing.subreddit_slow
|
|
action = 'remove' + thing.__class__.__name__.lower()
|
|
ModAction.create(sr, c.user, action, **kw)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(), VModhash(),
|
|
VSrCanBan('id'),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.moderation)
|
|
def POST_approve(self, thing):
|
|
if not thing: return
|
|
if thing._deleted: return
|
|
kw = {'target': thing}
|
|
if thing._spam:
|
|
kw['details'] = 'unspam'
|
|
train_spam = True
|
|
insert = True
|
|
else:
|
|
kw['details'] = 'confirm_ham'
|
|
train_spam = False
|
|
insert = False
|
|
|
|
admintools.unspam(thing, moderator_unbanned=not c.user_is_admin,
|
|
unbanner=c.user.name, train_spam=train_spam,
|
|
insert=insert)
|
|
|
|
if isinstance(thing, (Link, Comment)):
|
|
sr = thing.subreddit_slow
|
|
action = 'approve' + thing.__class__.__name__.lower()
|
|
ModAction.create(sr, c.user, action, **kw)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(), VModhash(),
|
|
VSrCanBan('id'),
|
|
thing=VByName('id'))
|
|
@api_doc(api_section.moderation)
|
|
def POST_ignore_reports(self, thing):
|
|
if not thing: return
|
|
if thing._deleted: return
|
|
if thing.ignore_reports: return
|
|
|
|
thing.ignore_reports = True
|
|
thing._commit()
|
|
|
|
sr = thing.subreddit_slow
|
|
ModAction.create(sr, c.user, 'ignorereports', target=thing)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@noresponse(VUser(), VModhash(),
|
|
VSrCanBan('id'),
|
|
thing=VByName('id'))
|
|
@api_doc(api_section.moderation)
|
|
def POST_unignore_reports(self, thing):
|
|
if not thing: return
|
|
if thing._deleted: return
|
|
if not thing.ignore_reports: return
|
|
|
|
thing.ignore_reports = False
|
|
thing._commit()
|
|
|
|
sr = thing.subreddit_slow
|
|
ModAction.create(sr, c.user, 'unignorereports', target=thing)
|
|
|
|
@require_oauth2_scope("modposts")
|
|
@validatedForm(VUser(), VModhash(),
|
|
VCanDistinguish(('id', 'how')),
|
|
thing = VByName('id'),
|
|
how = VOneOf('how', ('yes','no','admin','special')))
|
|
@api_doc(api_section.moderation)
|
|
def POST_distinguish(self, form, jquery, thing, how):
|
|
if not thing:return
|
|
|
|
log_modaction = True
|
|
log_kw = {}
|
|
send_message = False
|
|
original = getattr(thing, 'distinguished', 'no')
|
|
if how == original: # Distinguish unchanged
|
|
log_modaction = False
|
|
elif how in ('admin', 'special'): # Add admin/special
|
|
log_modaction = False
|
|
send_message = True
|
|
elif (original in ('admin', 'special') and
|
|
how == 'no'): # Remove admin/special
|
|
log_modaction = False
|
|
elif how == 'no': # From yes to no
|
|
log_kw['details'] = 'remove'
|
|
else: # From no to yes
|
|
send_message = True
|
|
|
|
# Send a message if this is a top-level comment on a submission that
|
|
# does not have sendreplies set, if it's the first distinguish for this
|
|
# comment, and if the user isn't banned or blocked by the author
|
|
if isinstance(thing, Comment):
|
|
link = Link._byID(thing.link_id, data=True)
|
|
to = Account._byID(link.author_id, data=True)
|
|
if (send_message and
|
|
thing.parent_id is None and
|
|
not link.sendreplies and
|
|
not hasattr(thing, 'distinguished') and
|
|
not c.user._spam and
|
|
c.user._id not in to.enemies and
|
|
to.name != c.user.name):
|
|
inbox_rel = Inbox._add(to, thing, 'selfreply')
|
|
queries.new_comment(thing, inbox_rel)
|
|
|
|
thing.distinguished = how
|
|
thing._commit()
|
|
|
|
wrapper = default_thing_wrapper(expand_children = True)
|
|
w = wrap_links(thing, wrapper)
|
|
jquery(".content").replace_things(w, True, True)
|
|
jquery(".content .link .rank").hide()
|
|
if log_modaction:
|
|
sr = thing.subreddit_slow
|
|
ModAction.create(sr, c.user, 'distinguish', target=thing, **log_kw)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_save(self, thing):
|
|
"""Save a link or comment.
|
|
|
|
Saved things are kept in the user's saved listing for later perusal.
|
|
|
|
See also: [/api/unsave](#POST_api_unsave).
|
|
|
|
"""
|
|
if not thing: return
|
|
if isinstance(thing, Comment) and not c.user.gold: return
|
|
r = thing._save(c.user)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_unsave(self, thing):
|
|
"""Unsave a link or comment.
|
|
|
|
This removes the thing from the user's saved listings as well.
|
|
|
|
See also: [/api/save](#POST_api_save).
|
|
|
|
"""
|
|
if not thing: return
|
|
r = thing._unsave(c.user)
|
|
|
|
def collapse_handler(self, things, collapse):
|
|
if not things:
|
|
return
|
|
things = tup(things)
|
|
srs = Subreddit._byID([t.sr_id for t in things if t.sr_id],
|
|
return_dict = True)
|
|
for t in things:
|
|
if hasattr(t, "to_id") and c.user._id == t.to_id:
|
|
t.to_collapse = collapse
|
|
elif hasattr(t, "author_id") and c.user._id == t.author_id:
|
|
t.author_collapse = collapse
|
|
elif isinstance(t, Message) and t.sr_id:
|
|
if srs[t.sr_id].is_moderator(c.user):
|
|
t.to_collapse = collapse
|
|
t._commit()
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
things = VByName('id', multiple = True))
|
|
def POST_collapse_message(self, things):
|
|
self.collapse_handler(things, True)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
things = VByName('id', multiple = True))
|
|
def POST_uncollapse_message(self, things):
|
|
self.collapse_handler(things, False)
|
|
|
|
def unread_handler(self, things, unread):
|
|
if not things:
|
|
if (errors.TOO_MANY_THING_IDS, 'id') in c.errors:
|
|
return abort(413)
|
|
else:
|
|
return abort(400)
|
|
|
|
sr_messages = defaultdict(list)
|
|
comments = []
|
|
messages = []
|
|
# Group things by subreddit or type
|
|
for thing in things:
|
|
if isinstance(thing, Message):
|
|
if getattr(thing, 'sr_id', False):
|
|
sr_messages[thing.sr_id].append(thing)
|
|
else:
|
|
messages.append(thing)
|
|
else:
|
|
comments.append(thing)
|
|
|
|
if sr_messages:
|
|
mod_srs = Subreddit.reverse_moderator_ids(c.user)
|
|
srs = Subreddit._byID(sr_messages.keys())
|
|
else:
|
|
mod_srs = []
|
|
|
|
# Batch set items as unread
|
|
for sr_id, things in sr_messages.items():
|
|
# Remove the item(s) from the user's inbox
|
|
queries.set_unread(things, c.user, unread)
|
|
if sr_id in mod_srs:
|
|
# Only moderators can change the read status of that
|
|
# message in the modmail inbox
|
|
sr = srs[sr_id]
|
|
queries.set_unread(things, sr, unread)
|
|
if comments:
|
|
queries.set_unread(comments, c.user, unread)
|
|
if messages:
|
|
queries.set_unread(messages, c.user, unread)
|
|
|
|
|
|
@require_oauth2_scope("privatemessages")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
things = VByName('id', multiple=True, limit=25))
|
|
@api_doc(api_section.messages)
|
|
def POST_unread_message(self, things):
|
|
self.unread_handler(things, True)
|
|
|
|
@require_oauth2_scope("privatemessages")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
things = VByName('id', multiple=True, limit=25))
|
|
@api_doc(api_section.messages)
|
|
def POST_read_message(self, things):
|
|
self.unread_handler(things, False)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
thing = VByName('id', thing_cls=Link))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_hide(self, thing):
|
|
"""Hide a link.
|
|
|
|
This removes it from the user's default view of subreddit listings.
|
|
|
|
See also: [/api/unhide](#POST_api_unhide).
|
|
|
|
"""
|
|
if not thing: return
|
|
r = thing._hide(c.user)
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
thing = VByName('id'))
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_unhide(self, thing):
|
|
"""Unhide a link.
|
|
|
|
See also: [/api/hide](#POST_api_hide).
|
|
|
|
"""
|
|
if not thing: return
|
|
r = thing._unhide(c.user)
|
|
|
|
|
|
@validatedForm(VUser(),
|
|
parent = VByName('parent_id'))
|
|
def POST_moremessages(self, form, jquery, parent):
|
|
if not parent.can_view_slow():
|
|
return self.abort(403,'forbidden')
|
|
|
|
if parent.sr_id:
|
|
builder = SrMessageBuilder(parent.subreddit_slow,
|
|
parent = parent, skip = False)
|
|
else:
|
|
builder = UserMessageBuilder(c.user, parent = parent, skip = False)
|
|
listing = Listing(builder).listing()
|
|
a = []
|
|
for item in listing.things:
|
|
a.append(item)
|
|
for x in item.child.things:
|
|
a.append(x)
|
|
for item in a:
|
|
if hasattr(item, "child"):
|
|
item.child = None
|
|
jquery.things(parent._fullname).parent().replace_things(a, False, True)
|
|
|
|
@validatedForm(link = VByName('link_id'),
|
|
sort = VMenu('where', CommentSortMenu),
|
|
children = VCommentIDs('children'),
|
|
pv_hex=VPrintable("pv_hex", 40, docs={"pv_hex":
|
|
"(optional) a previous-visits token"}),
|
|
mc_id=nop("id", docs={"id":
|
|
"(optional) id of the associated MoreChildren object"}),
|
|
)
|
|
@api_doc(api_section.links_and_comments)
|
|
def POST_morechildren(self, form, jquery, link, sort, children,
|
|
pv_hex, mc_id):
|
|
"""Retrieve additional comments omitted from a base comment tree.
|
|
|
|
When a comment tree is rendered, the most relevant comments are
|
|
selected for display first. Remaining comments are stubbed out with
|
|
"MoreComments" links. This API call is used to retrieve the additional
|
|
comments represented by those stubs, up to 20 at a time.
|
|
|
|
The two core parameters required are `link` and `children`. `link` is
|
|
the fullname of the link whose comments are being fetched. `children`
|
|
is a comma-delimited list of comment ID36s that need to be fetched.
|
|
|
|
If `id` is passed, it should be the ID of the MoreComments object this
|
|
call is replacing. This is needed only for the HTML UI's purposes and
|
|
is optional otherwise.
|
|
|
|
`pv_hex` is part of the reddit gold "previous visits" feature. It is
|
|
optional and deprecated.
|
|
|
|
**NOTE:** you may only make one request at a time to this API endpoint.
|
|
Higher concurrency will result in an error being returned.
|
|
|
|
"""
|
|
|
|
CHILD_FETCH_COUNT = 20
|
|
|
|
lock = None
|
|
if c.user_is_loggedin:
|
|
lock = g.make_lock("morechildren", "morechildren-" + c.user.name,
|
|
timeout=0)
|
|
try:
|
|
lock.acquire()
|
|
except TimeoutExpired:
|
|
abort(429)
|
|
|
|
try:
|
|
if not link or not link.subreddit_slow.can_view(c.user):
|
|
return abort(403,'forbidden')
|
|
|
|
if pv_hex:
|
|
c.previous_visits = g.cache.get(pv_hex)
|
|
|
|
if children:
|
|
builder = CommentBuilder(link, CommentSortMenu.operator(sort),
|
|
children)
|
|
listing = Listing(builder, nextprev = False)
|
|
items = listing.get_items(num=CHILD_FETCH_COUNT)
|
|
def _children(cur_items):
|
|
items = []
|
|
for cm in cur_items:
|
|
items.append(cm)
|
|
if hasattr(cm, 'child'):
|
|
if hasattr(cm.child, 'things'):
|
|
items.extend(_children(cm.child.things))
|
|
cm.child = None
|
|
else:
|
|
items.append(cm.child)
|
|
|
|
return items
|
|
# assumes there is at least one child
|
|
# a = _children(items[0].child.things)
|
|
a = []
|
|
for item in items:
|
|
a.append(item)
|
|
if hasattr(item, 'child'):
|
|
a.extend(_children(item.child.things))
|
|
item.child = None
|
|
|
|
# the result is not always sufficient to replace the
|
|
# morechildren link
|
|
jquery.things(str(mc_id)).remove()
|
|
jquery.insert_things(a, append = True)
|
|
|
|
if pv_hex:
|
|
jquery.rehighlight_new_comments()
|
|
finally:
|
|
if lock:
|
|
lock.release()
|
|
|
|
|
|
@validate(uh = nop('uh'), # VModHash() will raise, check manually
|
|
action = VOneOf('what', ('like', 'dislike', 'save')),
|
|
links = VUrl(['u']))
|
|
def GET_bookmarklet(self, action, uh, links):
|
|
'''Controller for the functionality of the bookmarklets (not
|
|
the distribution page)'''
|
|
|
|
# the redirect handler will clobber the extension if not told otherwise
|
|
c.extension = "png"
|
|
|
|
if not c.user_is_loggedin:
|
|
return self.redirect("/static/css_login.png")
|
|
# check the modhash (or force them to get new bookmarlets)
|
|
elif not c.user.valid_hash(uh) or not action:
|
|
return self.redirect("/static/css_update.png")
|
|
# unlike most cases, if not already submitted, error.
|
|
elif errors.ALREADY_SUB in c.errors:
|
|
# preserve the subreddit if not Default
|
|
sr = c.site if not isinstance(c.site, FakeSubreddit) else None
|
|
|
|
# check permissions on those links to make sure votes will count
|
|
Subreddit.load_subreddits(links, return_dict = False)
|
|
user = c.user if c.user_is_loggedin else None
|
|
links = [l for l in links if l.subreddit_slow.can_view(user)]
|
|
|
|
if links:
|
|
if action in ['like', 'dislike']:
|
|
#vote up all of the links
|
|
for link in links:
|
|
queries.queue_vote(c.user, link,
|
|
action == 'like', request.ip,
|
|
cheater = (errors.CHEATER, None) in c.errors)
|
|
elif action == 'save':
|
|
link = max(links, key = lambda x: x._score)
|
|
r = link._save(c.user)
|
|
return self.redirect("/static/css_%sd.png" % action)
|
|
return self.redirect("/static/css_submit.png")
|
|
|
|
|
|
@validatedForm(VUser(),
|
|
code = VPrintable("code", 30))
|
|
def POST_claimgold(self, form, jquery, code):
|
|
status = ''
|
|
if not code:
|
|
c.errors.add(errors.NO_TEXT, field = "code")
|
|
form.has_errors("code", errors.NO_TEXT)
|
|
return
|
|
|
|
rv = claim_gold(code, c.user._id)
|
|
|
|
if rv is None:
|
|
c.errors.add(errors.INVALID_CODE, field = "code")
|
|
log_text ("invalid gold claim",
|
|
"%s just tried to claim %s" % (c.user.name, code),
|
|
"info")
|
|
elif rv == "already claimed":
|
|
c.errors.add(errors.CLAIMED_CODE, field = "code")
|
|
log_text ("invalid gold reclaim",
|
|
"%s just tried to reclaim %s" % (c.user.name, code),
|
|
"info")
|
|
else:
|
|
days, subscr_id = rv
|
|
if days <= 0:
|
|
raise ValueError("days = %r?" % days)
|
|
|
|
log_text ("valid gold claim",
|
|
"%s just claimed %s" % (c.user.name, code),
|
|
"info")
|
|
|
|
if subscr_id:
|
|
c.user.gold_subscr_id = subscr_id
|
|
|
|
if code.startswith("cr_"):
|
|
c.user.gold_creddits += int(days / 31)
|
|
c.user._commit()
|
|
status = 'claimed-creddits'
|
|
else:
|
|
admintools.engolden(c.user, days)
|
|
|
|
g.cache.set("recent-gold-" + c.user.name, True, 600)
|
|
status = 'claimed-gold'
|
|
jquery(".lounge").show()
|
|
|
|
# Activate any errors we just manually set
|
|
if not form.has_errors("code", errors.INVALID_CODE, errors.CLAIMED_CODE,
|
|
errors.NO_TEXT):
|
|
form.redirect("/gold/thanks?v=%s" % status)
|
|
|
|
@validatedForm(user = VUserWithEmail('name'))
|
|
def POST_password(self, form, jquery, user):
|
|
if form.has_errors('name', errors.USER_DOESNT_EXIST):
|
|
return
|
|
elif form.has_errors('name', errors.NO_EMAIL_FOR_USER):
|
|
return
|
|
else:
|
|
if emailer.password_email(user):
|
|
form.set_html(".status",
|
|
_("an email will be sent to that account's address shortly"))
|
|
else:
|
|
form.set_html(".status", _("try again tomorrow"))
|
|
|
|
|
|
@validatedForm(token=VOneTimeToken(PasswordResetToken, "key"),
|
|
password=VPassword(["passwd", "passwd2"]))
|
|
def POST_resetpassword(self, form, jquery, token, password):
|
|
# was the token invalid or has it expired?
|
|
if not token:
|
|
form.redirect("/password?expired=true")
|
|
return
|
|
|
|
# did they fill out the password form correctly?
|
|
form.has_errors("passwd", errors.BAD_PASSWORD)
|
|
form.has_errors("passwd2", errors.BAD_PASSWORD_MATCH)
|
|
if form.has_error():
|
|
return
|
|
|
|
# at this point, we should mark the token used since it's either
|
|
# valid now or will never be valid again.
|
|
token.consume()
|
|
|
|
# load up the user and check that things haven't changed
|
|
user = Account._by_fullname(token.user_id)
|
|
if not token.valid_for_user(user):
|
|
form.redirect('/password?expired=true')
|
|
return
|
|
|
|
# Prevent banned users from resetting, and thereby logging in
|
|
if user._banned:
|
|
return
|
|
|
|
# successfully entered user name and valid new password
|
|
change_password(user, password)
|
|
if user.email_verified:
|
|
emailer.password_change_email(user)
|
|
g.log.warning("%s did a password reset for %s via %s",
|
|
request.ip, user.name, token._id)
|
|
|
|
self._login(jquery, user)
|
|
jquery.redirect('/')
|
|
|
|
|
|
@noresponse(VUser())
|
|
def POST_noframe(self):
|
|
"""
|
|
removes the reddit toolbar if that currently the user's preference
|
|
"""
|
|
c.user.pref_frame = False
|
|
c.user._commit()
|
|
|
|
|
|
@noresponse(VUser())
|
|
def POST_frame(self):
|
|
"""
|
|
undoes POST_noframe
|
|
"""
|
|
c.user.pref_frame = True
|
|
c.user._commit()
|
|
|
|
|
|
@require_oauth2_scope("subscribe")
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
action = VOneOf('action', ('sub', 'unsub')),
|
|
sr = VSubscribeSR('sr', 'sr_name'))
|
|
@api_doc(api_section.subreddits)
|
|
def POST_subscribe(self, action, sr):
|
|
# only users who can make edits are allowed to subscribe.
|
|
# Anyone can leave.
|
|
if sr and (action != 'sub' or sr.can_comment(c.user)):
|
|
self._subscribe(sr, action == 'sub')
|
|
|
|
@classmethod
|
|
def _subscribe(cls, sr, sub):
|
|
try:
|
|
Subreddit.subscribe_defaults(c.user)
|
|
|
|
if sub:
|
|
if sr.add_subscriber(c.user):
|
|
sr._incr('_ups', 1)
|
|
else:
|
|
if sr.remove_subscriber(c.user):
|
|
sr._incr('_ups', -1)
|
|
changed(sr, True)
|
|
except CreationError:
|
|
# This only seems to happen when someone is pounding on the
|
|
# subscribe button or the DBs are really lagged; either way,
|
|
# some other proc has already handled this subscribe request.
|
|
return
|
|
|
|
|
|
@validatedForm(VAdmin(),
|
|
hexkey=VLength("hexkey", max_length=32),
|
|
nickname=VLength("nickname", max_length = 1000),
|
|
status = VOneOf("status",
|
|
("new", "severe", "interesting", "normal", "fixed")))
|
|
def POST_edit_error(self, form, jquery, hexkey, nickname, status):
|
|
if form.has_errors(("hexkey", "nickname", "status"),
|
|
errors.NO_TEXT, errors.INVALID_OPTION):
|
|
pass
|
|
|
|
if form.has_error():
|
|
return
|
|
|
|
key = "error_nickname-%s" % str(hexkey)
|
|
g.hardcache.set(key, nickname, 86400 * 365)
|
|
|
|
key = "error_status-%s" % str(hexkey)
|
|
g.hardcache.set(key, status, 86400 * 365)
|
|
|
|
form.set_html(".status", _('saved'))
|
|
|
|
@validatedForm(VAdmin(),
|
|
award=VByName("fullname"),
|
|
colliding_award=VAwardByCodename(("codename", "fullname")),
|
|
codename=VLength("codename", max_length = 100),
|
|
title=VLength("title", max_length = 100),
|
|
awardtype=VOneOf("awardtype",
|
|
("regular", "manual", "invisible")),
|
|
api_ok=VBoolean("api_ok"),
|
|
imgurl=VLength("imgurl", max_length = 1000))
|
|
def POST_editaward(self, form, jquery, award, colliding_award, codename,
|
|
title, awardtype, api_ok, imgurl):
|
|
if form.has_errors(("codename", "title", "awardtype", "imgurl"),
|
|
errors.NO_TEXT):
|
|
pass
|
|
|
|
if awardtype is None:
|
|
form.set_html(".status", "bad awardtype")
|
|
return
|
|
|
|
if form.has_errors(("codename"), errors.INVALID_OPTION):
|
|
form.set_html(".status", "some other award has that codename")
|
|
pass
|
|
|
|
if form.has_error():
|
|
return
|
|
|
|
if award is None:
|
|
Award._new(codename, title, awardtype, imgurl, api_ok)
|
|
form.set_html(".status", "saved. reload to see it.")
|
|
return
|
|
|
|
award.codename = codename
|
|
award.title = title
|
|
award.awardtype = awardtype
|
|
award.imgurl = imgurl
|
|
award.api_ok = api_ok
|
|
award._commit()
|
|
form.set_html(".status", _('saved'))
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
user = VFlairAccount("name"),
|
|
link = VFlairLink('link'),
|
|
text = VFlairText("text"),
|
|
css_class = VFlairCss("css_class"))
|
|
@api_doc(api_section.flair)
|
|
def POST_flair(self, form, jquery, user, link, text, css_class):
|
|
if link:
|
|
flair_type = LINK_FLAIR
|
|
if hasattr(c.site, '_id') and c.site._id == link.sr_id:
|
|
site = c.site
|
|
else:
|
|
site = Subreddit._byID(link.sr_id, data=True)
|
|
# make sure c.user has permission to set flair on this link
|
|
if not (c.user_is_admin
|
|
or site.is_moderator_with_perms(c.user, 'flair')):
|
|
abort(403, 'forbidden')
|
|
else:
|
|
flair_type = USER_FLAIR
|
|
site = c.site
|
|
if form.has_errors('name', errors.BAD_FLAIR_TARGET):
|
|
return
|
|
|
|
if form.has_errors('css_class', errors.BAD_CSS_NAME):
|
|
form.set_html(".status:first", _('invalid css class'))
|
|
return
|
|
if form.has_errors('css_class', errors.TOO_MUCH_FLAIR_CSS):
|
|
form.set_html(".status:first", _('too many css classes'))
|
|
return
|
|
|
|
if flair_type == LINK_FLAIR:
|
|
if not text and not css_class:
|
|
text = css_class = None
|
|
link.flair_text = text
|
|
link.flair_css_class = css_class
|
|
link._commit()
|
|
changed(link)
|
|
ModAction.create(site, c.user, action='editflair', target=link,
|
|
details='flair_edit')
|
|
elif flair_type == USER_FLAIR:
|
|
if not text and not css_class:
|
|
# empty text and css is equivalent to unflairing
|
|
text = css_class = None
|
|
c.site.remove_flair(user)
|
|
jquery('#flairrow_%s' % user._id36).hide()
|
|
new = False
|
|
elif not c.site.is_flair(user):
|
|
c.site.add_flair(user)
|
|
new = True
|
|
else:
|
|
new = False
|
|
|
|
# Save the flair details in the account data.
|
|
setattr(user, 'flair_%s_text' % c.site._id, text)
|
|
setattr(user, 'flair_%s_css_class' % c.site._id, css_class)
|
|
user._commit()
|
|
|
|
if c.user != user:
|
|
ModAction.create(site, c.user, action='editflair',
|
|
target=user, details='flair_edit')
|
|
|
|
if new:
|
|
jquery.redirect('?name=%s' % user.name)
|
|
else:
|
|
flair = WrappedUser(
|
|
user, force_show_flair=True,
|
|
include_flair_selector=True).render(style='html')
|
|
jquery('.tagline .flairselectable.id-%s'
|
|
% user._fullname).parent().html(flair)
|
|
jquery('input[name="text"]').data('saved', text)
|
|
jquery('input[name="css_class"]').data('saved', css_class)
|
|
form.set_html('.status', _('saved'))
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
user = VFlairAccount("name"))
|
|
@api_doc(api_section.flair)
|
|
def POST_deleteflair(self, form, jquery, user):
|
|
# Check validation.
|
|
if form.has_errors('name', errors.USER_DOESNT_EXIST, errors.NO_USER):
|
|
return
|
|
c.site.remove_flair(user)
|
|
setattr(user, 'flair_%s_text' % c.site._id, None)
|
|
setattr(user, 'flair_%s_css_class' % c.site._id, None)
|
|
user._commit()
|
|
|
|
ModAction.create(c.site, c.user, action='editflair', target=user,
|
|
details='flair_delete')
|
|
|
|
jquery('#flairrow_%s' % user._id36).remove()
|
|
unflair = WrappedUser(
|
|
user, include_flair_selector=True).render(style='html')
|
|
jquery('.tagline .id-%s' % user._fullname).parent().html(unflair)
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validate(VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
flair_csv = nop('flair_csv'))
|
|
@api_doc(api_section.flair)
|
|
def POST_flaircsv(self, flair_csv):
|
|
limit = 100 # max of 100 flair settings per call
|
|
results = FlairCsv()
|
|
# encode to UTF-8, since csv module doesn't fully support unicode
|
|
infile = csv.reader(flair_csv.strip().encode('utf-8').split('\n'))
|
|
for i, row in enumerate(infile):
|
|
line_result = results.add_line()
|
|
line_no = i + 1
|
|
if line_no > limit:
|
|
line_result.error('row',
|
|
'limit of %d rows per call reached' % limit)
|
|
break
|
|
|
|
try:
|
|
name, text, css_class = row
|
|
except ValueError:
|
|
line_result.error('row', 'improperly formatted row, ignoring')
|
|
continue
|
|
|
|
user = VFlairAccount('name').run(name)
|
|
if not user:
|
|
line_result.error('user',
|
|
"unable to resolve user `%s', ignoring"
|
|
% name)
|
|
continue
|
|
|
|
if not text and not css_class:
|
|
# this is equivalent to unflairing
|
|
text = None
|
|
css_class = None
|
|
|
|
orig_text = text
|
|
text = VFlairText('text').run(orig_text)
|
|
if text and orig_text and len(text) < len(orig_text):
|
|
line_result.warn('text',
|
|
'truncating flair text to %d chars'
|
|
% len(text))
|
|
|
|
if css_class and not VFlairCss('css_class').run(css_class):
|
|
line_result.error('css',
|
|
"invalid css class `%s', ignoring"
|
|
% css_class)
|
|
continue
|
|
|
|
# all validation passed, enflair the user
|
|
if text or css_class:
|
|
mode = 'added'
|
|
c.site.add_flair(user)
|
|
else:
|
|
mode = 'removed'
|
|
c.site.remove_flair(user)
|
|
setattr(user, 'flair_%s_text' % c.site._id, text)
|
|
setattr(user, 'flair_%s_css_class' % c.site._id, css_class)
|
|
user._commit()
|
|
|
|
line_result.status = '%s flair for user %s' % (mode, user.name)
|
|
line_result.ok = True
|
|
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_csv')
|
|
|
|
return BoringPage(_("API"), content = results).render()
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
flair_enabled = VBoolean("flair_enabled"))
|
|
@api_doc(api_section.flair)
|
|
def POST_setflairenabled(self, form, jquery, flair_enabled):
|
|
setattr(c.user, 'flair_%s_enabled' % c.site._id, flair_enabled)
|
|
c.user._commit()
|
|
jquery.refresh()
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(
|
|
VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
flair_enabled = VBoolean("flair_enabled"),
|
|
flair_position = VOneOf("flair_position", ("left", "right")),
|
|
link_flair_position = VOneOf("link_flair_position",
|
|
("", "left", "right")),
|
|
flair_self_assign_enabled = VBoolean("flair_self_assign_enabled"),
|
|
link_flair_self_assign_enabled =
|
|
VBoolean("link_flair_self_assign_enabled"))
|
|
@api_doc(api_section.flair)
|
|
def POST_flairconfig(self, form, jquery, flair_enabled, flair_position,
|
|
link_flair_position, flair_self_assign_enabled,
|
|
link_flair_self_assign_enabled):
|
|
if c.site.flair_enabled != flair_enabled:
|
|
c.site.flair_enabled = flair_enabled
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_enabled')
|
|
if c.site.flair_position != flair_position:
|
|
c.site.flair_position = flair_position
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_position')
|
|
if c.site.link_flair_position != link_flair_position:
|
|
c.site.link_flair_position = link_flair_position
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='link_flair_position')
|
|
if c.site.flair_self_assign_enabled != flair_self_assign_enabled:
|
|
c.site.flair_self_assign_enabled = flair_self_assign_enabled
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_self_enabled')
|
|
if (c.site.link_flair_self_assign_enabled
|
|
!= link_flair_self_assign_enabled):
|
|
c.site.link_flair_self_assign_enabled = (
|
|
link_flair_self_assign_enabled)
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='link_flair_self_enabled')
|
|
c.site._commit()
|
|
jquery.refresh()
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@paginated_listing(max_page_size=1000)
|
|
@validate(user = VFlairAccount('name'))
|
|
@api_doc(api_section.flair)
|
|
def GET_flairlist(self, num, after, reverse, count, user):
|
|
flair = FlairList(num, after, reverse, '', user)
|
|
return BoringPage(_("API"), content = flair).render()
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
flair_template = VFlairTemplateByID('flair_template_id'),
|
|
text = VFlairText('text'),
|
|
css_class = VFlairCss('css_class'),
|
|
text_editable = VBoolean('text_editable'),
|
|
flair_type = VOneOf('flair_type', (USER_FLAIR, LINK_FLAIR),
|
|
default=USER_FLAIR))
|
|
@api_doc(api_section.flair)
|
|
def POST_flairtemplate(self, form, jquery, flair_template, text,
|
|
css_class, text_editable, flair_type):
|
|
if text is None:
|
|
text = ''
|
|
if css_class is None:
|
|
css_class = ''
|
|
|
|
# Check validation.
|
|
if form.has_errors('css_class', errors.BAD_CSS_NAME):
|
|
form.set_html(".status:first", _('invalid css class'))
|
|
return
|
|
if form.has_errors('css_class', errors.TOO_MUCH_FLAIR_CSS):
|
|
form.set_html(".status:first", _('too many css classes'))
|
|
return
|
|
|
|
# Load flair template thing.
|
|
if flair_template:
|
|
flair_template.text = text
|
|
flair_template.css_class = css_class
|
|
flair_template.text_editable = text_editable
|
|
flair_template._commit()
|
|
new = False
|
|
else:
|
|
try:
|
|
flair_template = FlairTemplateBySubredditIndex.create_template(
|
|
c.site._id, text=text, css_class=css_class,
|
|
text_editable=text_editable,
|
|
flair_type=flair_type)
|
|
except OverflowError:
|
|
form.set_html(".status:first", _('max flair templates reached'))
|
|
return
|
|
|
|
new = True
|
|
|
|
# Push changes back to client.
|
|
if new:
|
|
empty_ids = {
|
|
USER_FLAIR: '#empty-user-flair-template',
|
|
LINK_FLAIR: '#empty-link-flair-template',
|
|
}
|
|
empty_id = empty_ids[flair_type]
|
|
jquery(empty_id).before(
|
|
FlairTemplateEditor(flair_template, flair_type)
|
|
.render(style='html'))
|
|
empty_template = FlairTemplate()
|
|
empty_template._committed = True # to disable unnecessary warning
|
|
jquery(empty_id).html(
|
|
FlairTemplateEditor(empty_template, flair_type)
|
|
.render(style='html'))
|
|
form.set_html('.status', _('saved'))
|
|
else:
|
|
jquery('#%s' % flair_template._id).html(
|
|
FlairTemplateEditor(flair_template, flair_type)
|
|
.render(style='html'))
|
|
form.set_html('.status', _('saved'))
|
|
jquery('input[name="text"]').data('saved', text)
|
|
jquery('input[name="css_class"]').data('saved', css_class)
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_template')
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(VSrModerator(perms='flair'),
|
|
VModhash(),
|
|
flair_template = VFlairTemplateByID('flair_template_id'))
|
|
@api_doc(api_section.flair)
|
|
def POST_deleteflairtemplate(self, form, jquery, flair_template):
|
|
idx = FlairTemplateBySubredditIndex.by_sr(c.site._id)
|
|
if idx.delete_by_id(flair_template._id):
|
|
jquery('#%s' % flair_template._id).parent().remove()
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_delete_template')
|
|
|
|
@require_oauth2_scope("modflair")
|
|
@validatedForm(VSrModerator(perms='flair'), VModhash(),
|
|
flair_type = VOneOf('flair_type', (USER_FLAIR, LINK_FLAIR),
|
|
default=USER_FLAIR))
|
|
@api_doc(api_section.flair)
|
|
def POST_clearflairtemplates(self, form, jquery, flair_type):
|
|
FlairTemplateBySubredditIndex.clear(c.site._id, flair_type=flair_type)
|
|
jquery.refresh()
|
|
ModAction.create(c.site, c.user, action='editflair',
|
|
details='flair_clear_template')
|
|
|
|
@validate(VUser(),
|
|
user = VFlairAccount('name'),
|
|
link = VFlairLink('link'))
|
|
def POST_flairselector(self, user, link):
|
|
if link:
|
|
if hasattr(c.site, '_id') and c.site._id == link.sr_id:
|
|
site = c.site
|
|
else:
|
|
site = Subreddit._byID(link.sr_id, data=True)
|
|
return FlairSelector(link=link, site=site).render()
|
|
if user and not (c.user_is_admin
|
|
or c.site.is_moderator_with_perms(c.user, 'flair')):
|
|
# ignore user parameter if c.user is not mod/admin
|
|
user = None
|
|
return FlairSelector(user=user).render()
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
user = VFlairAccount('name'),
|
|
link = VFlairLink('link'),
|
|
flair_template_id = nop('flair_template_id'),
|
|
text = VFlairText('text'))
|
|
@api_doc(api_section.flair)
|
|
def POST_selectflair(self, form, jquery, user, link, flair_template_id,
|
|
text):
|
|
if link:
|
|
flair_type = LINK_FLAIR
|
|
if hasattr(c.site, '_id') and c.site._id == link.sr_id:
|
|
site = c.site
|
|
else:
|
|
site = Subreddit._byID(link.sr_id, data=True)
|
|
self_assign_enabled = site.link_flair_self_assign_enabled
|
|
else:
|
|
flair_type = USER_FLAIR
|
|
site = c.site
|
|
self_assign_enabled = site.flair_self_assign_enabled
|
|
|
|
if flair_template_id:
|
|
try:
|
|
flair_template = FlairTemplateBySubredditIndex.get_template(
|
|
site._id, flair_template_id, flair_type=flair_type)
|
|
except NotFound:
|
|
# TODO: serve error to client
|
|
g.log.debug('invalid flair template for subreddit %s', site._id)
|
|
return
|
|
else:
|
|
flair_template = None
|
|
text = None
|
|
|
|
if not (c.user_is_admin
|
|
or site.is_moderator_with_perms(c.user, 'flair')):
|
|
if not self_assign_enabled:
|
|
# TODO: serve error to client
|
|
g.log.debug('flair self-assignment not permitted')
|
|
return
|
|
|
|
# Ignore user choice if not an admin or mod.
|
|
user = c.user
|
|
|
|
# Ignore given text if user doesn't have permission to customize it.
|
|
if not (flair_template and flair_template.text_editable):
|
|
text = None
|
|
|
|
if not text:
|
|
text = flair_template.text if flair_template else None
|
|
|
|
css_class = flair_template.css_class if flair_template else None
|
|
text_editable = (
|
|
flair_template.text_editable if flair_template else False)
|
|
|
|
if flair_type == USER_FLAIR:
|
|
site.add_flair(user)
|
|
setattr(user, 'flair_%s_text' % site._id, text)
|
|
setattr(user, 'flair_%s_css_class' % site._id, css_class)
|
|
user._commit()
|
|
|
|
if ((c.user_is_admin
|
|
or site.is_moderator_with_perms(c.user, 'flair'))
|
|
and c.user != user):
|
|
ModAction.create(site, c.user, action='editflair',
|
|
target=user, details='flair_edit')
|
|
|
|
# Push some client-side updates back to the browser.
|
|
u = WrappedUser(user, force_show_flair=True,
|
|
flair_text_editable=text_editable,
|
|
include_flair_selector=True)
|
|
flair = u.render(style='html')
|
|
jquery('.tagline .flairselectable.id-%s'
|
|
% user._fullname).parent().html(flair)
|
|
jquery('#flairrow_%s input[name="text"]' % user._id36).data(
|
|
'saved', text).val(text)
|
|
jquery('#flairrow_%s input[name="css_class"]' % user._id36).data(
|
|
'saved', css_class).val(css_class)
|
|
elif flair_type == LINK_FLAIR:
|
|
link.flair_text = text
|
|
link.flair_css_class = css_class
|
|
link._commit()
|
|
changed(link)
|
|
|
|
if c.user_is_admin or site.is_moderator_with_perms(c.user, 'flair'):
|
|
ModAction.create(site, c.user, action='editflair',
|
|
target=link, details='flair_edit')
|
|
|
|
# Push some client-side updates back to the browser.
|
|
|
|
jquery('.id-%s .entry .linkflair' % link._fullname).remove()
|
|
title_path = '.id-%s .entry > .title > .title' % link._fullname
|
|
|
|
# TODO: move this to a template
|
|
if flair_template:
|
|
flair = '<span class="linkflair %s">%s</span>' % (
|
|
' '.join('linkflair-' + c for c in css_class.split()), text)
|
|
if site.link_flair_position == 'left':
|
|
jquery(title_path).before(flair)
|
|
elif site.link_flair_position == 'right':
|
|
jquery(title_path).after(flair)
|
|
|
|
# TODO: close the selector popup more gracefully
|
|
jquery('body').click()
|
|
|
|
@validatedForm(secret_used=VAdminOrAdminSecret("secret"),
|
|
award=VByName("fullname"),
|
|
description=VLength("description", max_length=1000),
|
|
url=VLength("url", max_length=1000),
|
|
cup_hours=VFloat("cup_hours",
|
|
coerce=False, min=0, max=24 * 365),
|
|
recipient=VExistingUname("recipient"))
|
|
def POST_givetrophy(self, form, jquery, secret_used, award, description,
|
|
url, cup_hours, recipient):
|
|
if form.has_errors("recipient", errors.USER_DOESNT_EXIST,
|
|
errors.NO_USER):
|
|
pass
|
|
|
|
if form.has_errors("fullname", errors.NO_TEXT, errors.NO_THING_ID):
|
|
pass
|
|
|
|
if form.has_errors("cup_hours", errors.BAD_NUMBER):
|
|
pass
|
|
|
|
if secret_used and not award.api_ok:
|
|
c.errors.add(errors.NO_API, field='secret')
|
|
form.has_errors('secret', errors.NO_API)
|
|
|
|
if form.has_error():
|
|
return
|
|
|
|
if cup_hours:
|
|
cup_seconds = int(cup_hours * 3600)
|
|
cup_expiration = timefromnow("%s seconds" % cup_seconds)
|
|
else:
|
|
cup_expiration = None
|
|
|
|
t = Trophy._new(recipient, award, description=description, url=url,
|
|
cup_info=dict(expiration=cup_expiration))
|
|
|
|
form.set_html(".status", _('saved'))
|
|
form._send_data(trophy_fn=t._id36)
|
|
|
|
@validatedForm(VAdmin(),
|
|
account = VExistingUname("account"))
|
|
def POST_removecup(self, form, jquery, account):
|
|
if not account:
|
|
return self.abort404()
|
|
account.remove_cup()
|
|
|
|
@validatedForm(secret_used=VAdminOrAdminSecret("secret"),
|
|
trophy = VTrophy("trophy_fn"))
|
|
def POST_removetrophy(self, form, jquery, secret_used, trophy):
|
|
if not trophy:
|
|
return self.abort404()
|
|
recipient = trophy._thing1
|
|
award = trophy._thing2
|
|
if secret_used and not award.api_ok:
|
|
c.errors.add(errors.NO_API, field='secret')
|
|
form.has_errors('secret', errors.NO_API)
|
|
|
|
if form.has_error():
|
|
return
|
|
|
|
trophy._delete()
|
|
Trophy.by_account(recipient, _update=True)
|
|
Trophy.by_award(award, _update=True)
|
|
|
|
|
|
@validate(link=nop('link'),
|
|
campaign=nop('campaign'))
|
|
def GET_fetch_promo(self, link, campaign):
|
|
promo_tuples = [promote.PromoTuple(link, 1., campaign)]
|
|
builder = CampaignBuilder(promo_tuples,
|
|
wrap=default_thing_wrapper(),
|
|
keep_fn=promote.is_promoted)
|
|
promoted_links = builder.get_items()[0]
|
|
if promoted_links:
|
|
s = SpotlightListing(promoted_links=promoted_links).listing()
|
|
item = s.things[0]
|
|
return spaceCompress(item.render())
|
|
|
|
|
|
@noresponse(VUser(),
|
|
ui_elem = VOneOf('id', ('organic',)))
|
|
def POST_disable_ui(self, ui_elem):
|
|
if ui_elem:
|
|
pref = "pref_%s" % ui_elem
|
|
if getattr(c.user, pref):
|
|
setattr(c.user, "pref_" + ui_elem, False)
|
|
c.user._commit()
|
|
|
|
@validatedForm(type = VOneOf('type', ('click'), default = 'click'),
|
|
links = VByName('ids', thing_cls = Link, multiple = True))
|
|
def GET_gadget(self, form, jquery, type, links):
|
|
if not links and type == 'click':
|
|
# malformed cookie, clear it out
|
|
set_user_cookie('recentclicks2', '')
|
|
|
|
if not links:
|
|
return
|
|
|
|
content = ClickGadget(links).make_content()
|
|
|
|
jquery('.gadget').show().find('.click-gadget').html(
|
|
spaceCompress(content))
|
|
|
|
@noresponse()
|
|
def POST_tb_commentspanel_show(self):
|
|
# this preference is allowed for non-logged-in users
|
|
c.user.pref_frame_commentspanel = True
|
|
c.user._commit()
|
|
|
|
@noresponse()
|
|
def POST_tb_commentspanel_hide(self):
|
|
# this preference is allowed for non-logged-in users
|
|
c.user.pref_frame_commentspanel = False
|
|
c.user._commit()
|
|
|
|
@json_validate(query=VPrintable('query', max_length=50),
|
|
include_over_18=VBoolean('include_over_18', default=True))
|
|
def POST_search_reddit_names(self, responder, query, include_over_18):
|
|
names = []
|
|
if query:
|
|
names = search_reddits(query, include_over_18)
|
|
|
|
return {'names': names}
|
|
|
|
@validate(link = VByName('link_id', thing_cls = Link))
|
|
def POST_expando(self, link):
|
|
if not link:
|
|
abort(404, 'not found')
|
|
|
|
wrapped = wrap_links(link)
|
|
wrapped = list(wrapped)[0]
|
|
return websafe(spaceCompress(wrapped.link_child.content()))
|
|
|
|
@validatedForm(VUser('password', default=''),
|
|
VModhash(),
|
|
VOneTimePassword("otp",
|
|
required=not g.disable_require_admin_otp),
|
|
remember=VBoolean("remember"),
|
|
dest=VDestination())
|
|
def POST_adminon(self, form, jquery, remember, dest):
|
|
if form.has_errors('password', errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
if form.has_errors("otp", errors.WRONG_PASSWORD,
|
|
errors.NO_OTP_SECRET,
|
|
errors.RATELIMIT):
|
|
return
|
|
|
|
if remember:
|
|
self.remember_otp(c.user)
|
|
|
|
self.enable_admin_mode(c.user)
|
|
form.redirect(dest)
|
|
|
|
@validatedForm(VUser("password", default=""),
|
|
VModhash())
|
|
def POST_generate_otp_secret(self, form, jquery):
|
|
if form.has_errors("password", errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
secret = totp.generate_secret()
|
|
g.cache.set('otp_secret_' + c.user._id36, secret, time=300)
|
|
jquery("body").make_totp_qrcode(secret)
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
otp=nop("otp"))
|
|
def POST_enable_otp(self, form, jquery, otp):
|
|
if form.has_errors("password", errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
secret = g.cache.get("otp_secret_" + c.user._id36)
|
|
if not secret:
|
|
c.errors.add(errors.EXPIRED, field="otp")
|
|
form.has_errors("otp", errors.EXPIRED)
|
|
return
|
|
|
|
if not VOneTimePassword.validate_otp(secret, otp):
|
|
c.errors.add(errors.WRONG_PASSWORD, field="otp")
|
|
form.has_errors("otp", errors.WRONG_PASSWORD)
|
|
return
|
|
|
|
c.user.otp_secret = secret
|
|
c.user._commit()
|
|
|
|
form.redirect("/prefs/otp")
|
|
|
|
@validatedForm(VUser("password", default=""),
|
|
VOneTimePassword("otp", required=True),
|
|
VModhash())
|
|
def POST_disable_otp(self, form, jquery):
|
|
if form.has_errors("password", errors.WRONG_PASSWORD):
|
|
return
|
|
|
|
if form.has_errors("otp", errors.WRONG_PASSWORD,
|
|
errors.NO_OTP_SECRET,
|
|
errors.RATELIMIT):
|
|
return
|
|
|
|
c.user.otp_secret = ""
|
|
c.user._commit()
|
|
form.redirect("/prefs/otp")
|
|
|
|
@json_validate(query=VLength("query", max_length=50))
|
|
@api_doc(api_section.subreddits, extensions=["json"])
|
|
def GET_subreddits_by_topic(self, responder, query):
|
|
if not g.CLOUDSEARCH_SEARCH_API:
|
|
return []
|
|
|
|
query = query and query.strip()
|
|
if not query or len(query) < 2:
|
|
return []
|
|
|
|
exclude = Subreddit.default_subreddits()
|
|
|
|
faceting = {"reddit":{"sort":"-sum(text_relevance)", "count":20}}
|
|
results = SearchQuery(query, sort="relevance", faceting=faceting,
|
|
syntax="plain").run()
|
|
|
|
sr_results = []
|
|
for sr, count in results.subreddit_facets:
|
|
if (sr._id in exclude or (sr.over_18 and not c.over18)
|
|
or sr.type == "archived"):
|
|
continue
|
|
|
|
sr_results.append({
|
|
"name": sr.name,
|
|
})
|
|
|
|
return sr_results
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
client=VOAuth2ClientID())
|
|
@api_doc(api_section.apps)
|
|
def POST_revokeapp(self, client):
|
|
if client:
|
|
client.revoke(c.user)
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
name=VRequired('name', errors.NO_TEXT,
|
|
docs=dict(name="a name for the app")),
|
|
about_url=VSanitizedUrl('about_url'),
|
|
icon_url=VSanitizedUrl('icon_url'),
|
|
redirect_uri=VSanitizedUrl('redirect_uri'))
|
|
@api_doc(api_section.apps)
|
|
def POST_updateapp(self, form, jquery, name, about_url, icon_url, redirect_uri):
|
|
if (form.has_errors('name', errors.NO_TEXT) |
|
|
form.has_errors('redirect_uri', errors.BAD_URL, errors.NO_URL)):
|
|
return
|
|
|
|
description = request.post.get('description', '')
|
|
|
|
client_id = request.post.get('client_id')
|
|
if client_id:
|
|
# client_id was specified, updating existing OAuth2Client
|
|
client = OAuth2Client.get_token(client_id)
|
|
if not client:
|
|
form.set_html('.status', _('invalid client id'))
|
|
return
|
|
if getattr(client, 'deleted', False):
|
|
form.set_html('.status', _('cannot update deleted app'))
|
|
return
|
|
if not client.has_developer(c.user):
|
|
form.set_html('.status', _('app does not belong to you'))
|
|
return
|
|
|
|
client.name = name
|
|
client.description = description
|
|
client.about_url = about_url or ''
|
|
client.redirect_uri = redirect_uri
|
|
client._commit()
|
|
form.set_html('.status', _('application updated'))
|
|
apps = PrefApps([], [client])
|
|
jquery('#developed-app-%s' % client._id).replaceWith(
|
|
apps.call('developed_app', client, collapsed=False))
|
|
else:
|
|
# client_id was omitted or empty, creating new OAuth2Client
|
|
client = OAuth2Client._new(name=name,
|
|
description=description,
|
|
about_url=about_url or '',
|
|
redirect_uri=redirect_uri)
|
|
client._commit()
|
|
client.add_developer(c.user)
|
|
form.set_html('.status', _('application created'))
|
|
apps = PrefApps([], [client])
|
|
jquery('#developed-apps > h1').show()
|
|
jquery('#developed-apps > ul').append(
|
|
apps.call('developed_app', client, collapsed=False))
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
client=VOAuth2ClientDeveloper(),
|
|
account=VExistingUname('name'))
|
|
@api_doc(api_section.apps)
|
|
def POST_adddeveloper(self, form, jquery, client, account):
|
|
if not client:
|
|
return
|
|
if form.has_errors('name', errors.USER_DOESNT_EXIST, errors.NO_USER):
|
|
return
|
|
if client.has_developer(account):
|
|
c.errors.add(errors.DEVELOPER_ALREADY_ADDED, field='name')
|
|
form.set_error(errors.DEVELOPER_ALREADY_ADDED, 'name')
|
|
return
|
|
try:
|
|
client.add_developer(account)
|
|
except OverflowError:
|
|
c.errors.add(errors.TOO_MANY_DEVELOPERS, field='')
|
|
form.set_error(errors.TOO_MANY_DEVELOPERS, '')
|
|
return
|
|
|
|
form.set_html('.status', _('developer added'))
|
|
apps = PrefApps([], [client])
|
|
(jquery('#app-developer-%s input[name="name"]' % client._id).val('')
|
|
.closest('.prefright').find('ul').append(
|
|
apps.call('editable_developer', client, account)))
|
|
|
|
@validatedForm(VUser(),
|
|
VModhash(),
|
|
client=VOAuth2ClientDeveloper(),
|
|
account=VExistingUname('name'))
|
|
@api_doc(api_section.apps)
|
|
def POST_removedeveloper(self, form, jquery, client, account):
|
|
if client and account and not form.has_errors('name'):
|
|
client.remove_developer(account)
|
|
if account._id == c.user._id:
|
|
jquery('#developed-app-%s' % client._id).fadeOut()
|
|
else:
|
|
jquery('li#app-dev-%s-%s' % (client._id, account._id)).fadeOut()
|
|
|
|
@noresponse(VUser(),
|
|
VModhash(),
|
|
client=VOAuth2ClientDeveloper())
|
|
@api_doc(api_section.apps)
|
|
def POST_deleteapp(self, client):
|
|
if client:
|
|
client.deleted = True
|
|
client._commit()
|
|
|
|
@validatedMultipartForm(VUser(),
|
|
VModhash(),
|
|
client=VOAuth2ClientDeveloper(),
|
|
icon_file=VLength(
|
|
'file', max_length=1024*128,
|
|
docs=dict(file="an icon (72x72)")))
|
|
@api_doc(api_section.apps)
|
|
def POST_setappicon(self, form, jquery, client, icon_file):
|
|
if not media.can_upload_icon():
|
|
form.set_error(errors.NOT_SUPPORTED, '')
|
|
if not icon_file:
|
|
form.set_error(errors.TOO_LONG, 'file')
|
|
if not form.has_error():
|
|
filename = 'icon-%s' % client._id
|
|
try:
|
|
client.icon_url = media.upload_icon(filename, icon_file,
|
|
(72, 72))
|
|
except IOError, ex:
|
|
c.errors.add(errors.BAD_IMAGE,
|
|
msg_params=dict(message=ex.message),
|
|
field='file')
|
|
form.set_error(errors.BAD_IMAGE, 'file')
|
|
else:
|
|
client._commit()
|
|
form.set_html('.status', 'uploaded')
|
|
jquery('#developed-app-%s .app-icon img'
|
|
% client._id).attr('src', client.icon_url)
|
|
jquery('#developed-app-%s .ajax-upload-form'
|
|
% client._id).hide()
|
|
jquery('#developed-app-%s .edit-app-icon-button'
|
|
% client._id).toggleClass('collapsed')
|
|
|
|
@json_validate(VUser(),
|
|
VModhash(),
|
|
comment=VByName("comment", thing_cls=Comment))
|
|
def POST_generate_payment_blob(self, responder, comment):
|
|
if not comment:
|
|
abort(400, "Bad Request")
|
|
|
|
comment_sr = Subreddit._byID(comment.sr_id, data=True)
|
|
if not comment_sr.allow_comment_gilding:
|
|
abort(403, "Forbidden")
|
|
|
|
try:
|
|
recipient = Account._byID(comment.author_id, data=True)
|
|
except NotFound:
|
|
self.abort404()
|
|
|
|
if recipient._deleted:
|
|
self.abort404()
|
|
|
|
return generate_blob(dict(
|
|
goldtype="gift",
|
|
account_id=c.user._id,
|
|
account_name=c.user.name,
|
|
status="initialized",
|
|
signed=False,
|
|
recipient=recipient.name,
|
|
giftmessage=None,
|
|
comment=comment._fullname,
|
|
))
|