Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Branch: master
4773 lines (4017 sloc) 182.587 kB
<
# 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-2015 reddit
# Inc. All Rights Reserved.
###############################################################################
from r2.controllers.reddit_base import (
cross_domain,
hsts_modify_redirect,
is_trusted_origin,
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 import recommender
from r2.lib import hooks
from r2.lib.utils import (
blockquote_text,
extract_user_mentions,
get_title,
query_string,
randstr,
sanitize_url,
set_last_modified,
timeago,
timefromnow,
timeuntil,
tup,
)
from r2.lib.pages import (
BoringPage,
ClickGadget,
CssError,
FormPage,
Reddit,
responsive,
UploadedImage,
UrlParser,
WrappedUser,
)
from r2.lib.pages import FlairList, FlairCsv, FlairTemplateEditor, \
FlairSelector
from r2.lib.pages import PrefApps
from r2.lib.pages import (
BannedTableItem,
ContributorTableItem,
FriendTableItem,
InvitedModTableItem,
ModTableItem,
MutedTableItem,
SubredditStylesheet,
WikiBannedTableItem,
WikiMayContributeTableItem,
)
from r2.lib.pages.things import (
default_thing_wrapper,
hot_links_by_url_listing,
wrap_links,
)
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.template_helpers import format_html, header_url
from r2.lib.filters import _force_unicode, _force_utf8, websafe_json, websafe, spaceCompress
from r2.lib.db import queries
from r2.lib import media
from r2.lib.db import tdb_cassandra
from r2.lib import promote
from r2.lib import tracking, emailer, newsletter
from r2.lib.subreddit_search import search_reddits
from r2.lib.log import log_text
from r2.lib.filters import safemarkdown
from r2.lib.media import str_to_image
from r2.controllers.api_docs import api_doc, api_section
from r2.controllers.oauth2 import require_oauth2_scope, allow_oauth2_access
from r2.lib.template_helpers import add_sr, get_domain, make_url_protocol_relative
from r2.lib.system_messages import notify_user_added, send_ban_message
from r2.controllers.ipn import generate_blob, update_blob
from r2.lib.lock import TimeoutExpired
from r2.lib.csrf import csrf_exempt
from r2.models import wiki
from r2.models.recommend import AccountSRFeedback, FEEDBACK_ACTIONS
from r2.lib.merge import ConflictException
import csv
from collections import defaultdict
from datetime import datetime, timedelta
from urlparse import urlparse
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
"""
# Since this is only a MinimalController, the
# @allow_oauth2_access decorator has little effect other than
# (1) to add the endpoint to /dev/api/oauth, and
# (2) to future-proof in case the function moves elsewhere
@allow_oauth2_access
@csrf_exempt
@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):
"""
Controller which deals with almost all AJAX site interaction.
"""
@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(
things=VByName('id', multiple=True, ignore_missing=True, limit=100),
url=VUrl('url'),
)
@api_doc(api_section.links_and_comments, uses_site=True)
def GET_info(self, things, url):
"""
Return a listing of things specified by their fullnames.
Only Links, Comments, and Subreddits are allowed.
"""
if url:
return self.GET_url_info()
thing_classes = (Link, Comment, Subreddit)
things = things or []
things = filter(lambda thing: isinstance(thing, thing_classes), things)
c.update_last_visit = False
listing = wrap_links(things)
return BoringPage(_("API"), content=listing).render()
@pagecache_policy(PAGECACHE_POLICY.NEVER)
@require_oauth2_scope("read")
@validate(
url=VUrl('url'),
count=VLimit('limit'),
things=VByName('id', multiple=True, limit=100),
)
def GET_url_info(self, url, count, things):
"""
Return a list of links with the given URL.
If a subreddit is provided, only links in that subreddit will be
returned.
"""
if things and not url:
return self.GET_info()
c.update_last_visit = False
if url:
listing = hot_links_by_url_listing(url, sr=c.site, num=count)
else:
listing = None
return BoringPage(_("API"), content=listing).render()
@json_validate()
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)
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)
@csrf_exempt
@json_validate(user=VUname(("user",)))
def POST_check_username(self, responder, user):
"""
Check whether a username is valid.
"""
if not (responder.has_errors("user",
errors.USERNAME_TOO_SHORT,
errors.USERNAME_INVALID_CHARACTERS,
errors.USERNAME_TAKEN_DEL,
errors.USERNAME_TAKEN)):
# Pylons does not handle 204s correctly.
return {}
@csrf_exempt
@json_validate(password=VPassword(("passwd")))
def POST_check_password(self, responder, password):
"""
Check whether a password is valid.
"""
if not (responder.has_errors("passwd", errors.SHORT_PASSWORD) or
responder.has_errors("passwd", errors.BAD_PASSWORD)):
# Pylons does not handle 204s correctly.
return {}
@csrf_exempt
@json_validate(email=ValidEmail("email"),
newsletter_subscribe=VBoolean("newsletter_subscribe", default=False),
sponsor=VBoolean("sponsor", default=False))
def POST_check_email(self, responder, email, newsletter_subscribe, sponsor):
"""
Check whether an email is valid. Allows blank emails.
Additionally checks if a newsletter is requested, and will be strict
on blank emails if so.
"""
if newsletter_subscribe and not email:
c.errors.add(errors.NEWSLETTER_NO_EMAIL, field="email")
responder.has_errors("email", errors.NEWSLETTER_NO_EMAIL)
return
if sponsor and not email:
c.errors.add(errors.SPONSOR_NO_EMAIL, field="email")
responder.has_errors("email", errors.SPONSOR_NO_EMAIL)
return
if not (responder.has_errors("email", errors.BAD_EMAIL)):
# Pylons does not handle 204s correctly.
return {}
@cross_domain(allow_credentials=True)
@json_validate(
VModhashIfLoggedIn(),
VRatelimit(rate_ip=True, prefix="rate_newsletter_"),
email=ValidEmail("email"),
source=VOneOf('source', ['newsletterbar', 'standalone'])
)
def POST_newsletter(self, responder, email, source):
"""Add an email to our newsletter."""
VRatelimit.ratelimit(rate_ip=True,
prefix="rate_newsletter_")
try:
newsletter.add_subscriber(email, source=source)
except newsletter.EmailUnacceptableError as e:
c.errors.add(errors.NEWSLETTER_EMAIL_UNACCEPTABLE, field="email")
responder.has_errors("email", errors.NEWSLETTER_EMAIL_UNACCEPTABLE)
return
except newsletter.NewsletterError as e:
g.log.warning("Failed to subscribe: %r" % e)
abort(500)
@allow_oauth2_access
@json_validate()
@api_doc(api_section.captcha)
def GET_needs_captcha(self, responder):
"""
Check whether CAPTCHAs are needed for API methods that define the
"captcha" and "iden" parameters.
"""
return bool(c.user.needs_captcha())
@require_oauth2_scope("privatemessages")
@validatedForm(
VCaptcha(),
VUser(),
VModhash(),
from_sr=VSRByName('from_sr'),
to=VMessageRecipient('to'),
subject=VLength('subject', 100, empty_error=errors.NO_SUBJECT),
body=VMarkdownLength(['text', 'message'], max_length=10000),
)
@api_doc(api_section.messages)
def POST_compose(self, form, jquery, from_sr, to, subject, body):
"""
Handles message composition under /message/compose.
"""
if (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("message", errors.TOO_LONG) or
form.has_errors("captcha", errors.BAD_CAPTCHA) or
form.has_errors("from_sr", errors.SUBREDDIT_NOEXIST)):
return
if form.has_errors("to", errors.USER_MUTED):
g.events.muted_forbidden_event("muted", target=to,
request=request, context=c)
return
if from_sr and isinstance(to, Subreddit):
c.errors.add(errors.NO_SR_TO_SR_MESSAGE, field="from")
form.has_errors("from", errors.NO_SR_TO_SR_MESSAGE)
return
if from_sr and BlockedSubredditsByAccount.is_blocked(to, from_sr):
c.errors.add(errors.USER_BLOCKED_MESSAGE, field="to")
form.has_errors("to", errors.USER_BLOCKED_MESSAGE)
return
if from_sr and from_sr._spam:
return
c.errors.remove((errors.BAD_SR_NAME, "from_sr"))
if from_sr:
if not from_sr.is_moderator_with_perms(c.user, "mail"):
abort(403)
elif from_sr.is_muted(to) and not c.user_is_admin:
c.errors.add(errors.MUTED_FROM_SUBREDDIT, field="to")
form.has_errors("to", errors.MUTED_FROM_SUBREDDIT)
g.events.muted_forbidden_event("muted mod", subreddit=from_sr,
target=to, request=request, context=c)
return
m, inbox_rel = Message._new(c.user, to, subject, body, request.ip,
sr=from_sr, from_sr=True)
else:
m, inbox_rel = Message._new(c.user, to, subject, body, request.ip)
form.set_text(".status", _("your message has been delivered"))
form.set_inputs(to = "", subject = "", text = "", captcha="")
queries.new_message(m, inbox_rel)
@require_oauth2_scope("submit")
@json_validate()
@api_doc(api_section.subreddits, uses_site=True)
def GET_submit_text(self, responder):
"""Get the submission text for the subreddit.
This text is set by the subreddit moderators and intended to be
displayed on the submission form.
See also: [/api/site_admin](#POST_api_site_admin).
"""
if c.site.over_18 and not c.over18:
submit_text = None
submit_text_html = None
else:
submit_text = c.site.submit_text
submit_text_html = safemarkdown(c.site.submit_text)
return {'submit_text': submit_text,
'submit_text_html': submit_text_html}
@require_oauth2_scope("submit")
@validatedForm(
VUser(),
VModhash(),
VCaptcha(),
VRatelimit(rate_user=True, rate_ip=True, prefix="rate_submit_"),
VShamedDomain('url'),
sr=VSubmitSR('sr', 'kind'),
url=VUrl('url'),
title=VTitle('title'),
sendreplies=VBoolean('sendreplies'),
selftext=VMarkdown('text'),
kind=VOneOf('kind', ['link', 'self']),
extension=VLength("extension", 20,
docs={"extension": "extension used for redirects"}),
resubmit=VBoolean('resubmit'),
)
@api_doc(api_section.links_and_comments)
def POST_submit(self, form, jquery, url, selftext, kind, title,
sr, extension, sendreplies, resubmit):
"""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.
"""
from r2.models.admintools import is_banned_domain
if url:
if url.lower() == 'self':
url = kind = 'self'
# VUrl may have replaced 'url' by adding 'http://'
form.set_inputs(url=url)
is_self = (kind == "self")
if not kind or form.has_errors('sr', errors.INVALID_OPTION):
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.INVALID_OPTION,
errors.NO_SELFS,
errors.NO_LINKS):
return
if not sr.can_submit_text(c.user) and is_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')
form.has_errors('sr', errors.NO_SELFS)
return
if form.has_errors("title", errors.NO_TEXT, errors.TOO_LONG):
return
if not sr.should_ratelimit(c.user, 'link'):
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
else:
if form.has_errors('ratelimit', errors.RATELIMIT):
return
if not is_self:
if form.has_errors("url", errors.NO_URL, errors.BAD_URL):
return
if form.has_errors("url", errors.DOMAIN_BANNED):
g.stats.simple_event('spam.shame.link')
return
if not resubmit:
listing = hot_links_by_url_listing(url, sr=sr, num=1)
links = listing.things
if links:
c.errors.add(errors.ALREADY_SUB, field='url')
form.has_errors('url', errors.ALREADY_SUB)
u = links[0].already_submitted_link(url, title)
if extension:
u = UrlParser(u)
u.set_extension(extension)
u = u.unparse()
form.redirect(u)
return
if not c.user_is_admin and is_self:
if len(selftext) > Link.SELFTEXT_MAX_LENGTH:
c.errors.add(errors.TOO_LONG, field='text',
msg_params={'max_length': Link.SELFTEXT_MAX_LENGTH})
form.set_error(errors.TOO_LONG, 'text')
return
if not request.POST.get('sendreplies'):
sendreplies = is_self
# get rid of extraneous whitespace in the title
cleaned_title = re.sub(r'\s+', ' ', title, flags=re.UNICODE)
cleaned_title = cleaned_title.strip()
l = Link._submit(
is_self=is_self,
title=cleaned_title,
content=selftext if is_self else url,
author=c.user,
sr=sr,
ip=request.ip,
spam=c.user._spam,
sendreplies=sendreplies,
)
if not is_self:
ban = is_banned_domain(url)
if ban:
g.stats.simple_event('spam.domainban.link_url')
admintools.spam(l, banner = "domain (%s)" % ban.banmsg)
hooks.get_hook('banned_domain.submit').call(item=l, url=url,
ban=ban)
queries.queue_vote(c.user, l, dir=True, ip=request.ip,
cheater=c.cheater)
if sr.should_ratelimit(c.user, 'link'):
VRatelimit.ratelimit(rate_user=True, rate_ip = True,
prefix = "rate_submit_")
queries.new_link(l)
l.update_search_index()
g.events.submit_event(l, request=request, context=c)
path = add_sr(l.make_permalink_slow())
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)
@csrf_exempt
@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_text(".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_text(".title-status", "")
else:
form.set_text(".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.
"""
c.user = user
c.user_is_loggedin = True
self.login(user, rem = rem)
if request.params.get("hoist") != "cookie":
responder._send_data(modhash = user.modhash())
responder._send_data(cookie = user.make_cookie())
if user.https_forced:
# The client may decide to redirect somewhere after a successful
# login, send it our HSTS grant endpoint so it can redirect through
# there and pick up the user's grant.
hsts_redir = "https://" + g.domain + "/modify_hsts_grant?dest="
responder._send_data(hsts_redir=hsts_redir)
responder._send_data(need_https=user.https_forced)
@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("ratelimit", errors.RATELIMIT) or
responder.has_errors("passwd", errors.WRONG_PASSWORD)):
self._login(responder, user, rem)
@csrf_exempt
@cross_domain(allow_credentials=True)
def POST_login(self, *args, **kwargs):
"""Log into 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(VRatelimit(rate_ip = True, prefix = "rate_register_"),
name = VUname(['user']),
email=ValidEmail("email"),
password = VPasswordChange(['passwd', 'passwd2']),
rem = VBoolean('rem'),
newsletter_subscribe=VBoolean('newsletter_subscribe',
default=False),
sponsor=VBoolean('sponsor', default=False),
)
def _handle_register(self, form, responder, name, email,
password, rem, newsletter_subscribe,
sponsor):
bad_captcha = responder.has_errors('captcha', errors.BAD_CAPTCHA)
if not (responder.has_errors("user",
errors.USERNAME_TOO_SHORT,
errors.USERNAME_INVALID_CHARACTERS,
errors.USERNAME_TAKEN_DEL,
errors.USERNAME_TAKEN) or
responder.has_errors("email", errors.BAD_EMAIL) or
responder.has_errors("passwd", errors.SHORT_PASSWORD) 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)):
if newsletter_subscribe and not email:
c.errors.add(errors.NEWSLETTER_NO_EMAIL, field="email")
form.has_errors("email", errors.NEWSLETTER_NO_EMAIL)
return
if sponsor and not email:
c.errors.add(errors.SPONSOR_NO_EMAIL, field="email")
form.has_errors("email", errors.SPONSOR_NO_EMAIL)
return
user = register(name, password, request.ip)
VRatelimit.ratelimit(rate_ip = True, prefix = "rate_register_")
#anything else we know (email, languages)?
if email:
user.set_email(email)
emailer.verify_email(user)
user.pref_lang = c.lang
if feature.is_enabled('new_user_new_window_preference'):
user.pref_newwindow = True
d = c.user._dirties.copy()
user._commit()
amqp.add_item('new_account', user._fullname)
hooks.get_hook("account.registered").call(user=user)
reject = hooks.get_hook("account.spotcheck").call(account=user)
if any(reject):
return
if newsletter_subscribe and email:
try:
newsletter.add_subscriber(email, source="register")
except newsletter.NewsletterError as e:
g.log.warning("Failed to subscribe: %r" % e)
self._login(responder, user, rem)
@csrf_exempt
@cross_domain(allow_credentials=True)
def POST_register(self, *args, **kwargs):
"""Create 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)
@require_oauth2_scope("modself")
@noresponse(VUser(),
VModhash(),
container = VByName('id'))
@api_doc(api_section.moderation)
def POST_leavemoderator(self, container):
"""Abdicate moderator status in a subreddit.
See also: [/api/friend](#POST_api_friend).
"""
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')
@require_oauth2_scope("modself")
@noresponse(VUser(),
VModhash(),
container = VByName('id'))
@api_doc(api_section.moderation)
def POST_leavecontributor(self, container):
"""Abdicate approved submitter status in a subreddit.
See also: [/api/friend](#POST_api_friend).
"""
if container and container.is_contributor(c.user):
container.remove_contributor(c.user)
_sr_friend_types = (
'moderator',
'moderator_invite',
'contributor',
'banned',
'muted',
'wikibanned',
'wikicontributor',
)
_sr_friend_types_with_permissions = (
'moderator',
'moderator_invite',
)
# Changes to this dict should also update docstrings for
# POST_friend and POST_unfriend
api_friend_scope_map = {
'moderator': {"modothers"},
'moderator_invite': {"modothers"},
'contributor': {"modcontributors"},
'banned': {"modcontributors"},
'muted': {"modcontributors"},
'wikibanned': {"modcontributors", "modwiki"},
'wikicontributor': {"modcontributors", "modwiki"},
'friend': None, # Handled with API v1 endpoint
'enemy': {"privatemessages"}, # Only valid for POST_unfriend
}
def check_api_friend_oauth_scope(self, type_):
if c.oauth_user:
needed_scopes = self.api_friend_scope_map[type_]
if needed_scopes is None:
# OAuth2 access not allowed for this friend rel type
# via /api/friend
self._auth_error(400, "invalid_request")
if not c.oauth_scope.has_access(c.site.name, needed_scopes):
# Token does not have the necessary scope to complete
# this request.
self._auth_error(403, "insufficient_scope")
@allow_oauth2_access
@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, uses_site=True)
def POST_unfriend(self, nuser, iuser, container, type):
"""Remove a relationship between a user and another user or subreddit
The user can either be passed in by name (nuser)
or by [fullname](#fullnames) (iuser). If type is friend or enemy,
'container' MUST be the current user's fullname;
for other types, the subreddit must be set
via URL (e.g., /r/funny/api/unfriend)
OAuth2 use requires appropriate scope based
on the 'type' of the relationship:
* moderator: `modothers`
* moderator_invite: `modothers`
* contributor: `modcontributors`
* banned: `modcontributors`
* muted: `modcontributors`
* wikibanned: `modcontributors` and `modwiki`
* wikicontributor: `modcontributors` and `modwiki`
* friend: Use [/api/v1/me/friends/{username}](#DELETE_api_v1_me_friends_{username})
* enemy: `privatemessages`
Complement to [POST_friend](#POST_api_friend)
"""
if type == 'muted' and not feature.is_enabled('modmail_muting'):
return abort(403, "This feature is not yet enabled")
self.check_api_friend_oauth_scope(type)
victim = iuser or nuser
if type in self._sr_friend_types:
if isinstance(c.site, FakeSubreddit):
abort(403, 'forbidden')
container = c.site
if c.user._spam:
# The requesting user is marked as spam, and is trying to
# do a mod action. The only action they should be allowed to do
# and have it stick is demodding themself
if not (c.user == victim and type == 'moderator'):
return
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)
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',
muted='unmuteuser',
).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)
if type in ('banned', 'wikibanned'):
container.unschedule_unban(victim, type)
if type == 'muted':
MutedAccountsBySubreddit.unmute(container, victim)
@require_oauth2_scope("modothers")
@validatedForm(VSrModerator(), VModhash(),
target=VExistingUname('name'),
type_and_permissions=VPermissions('type', 'permissions'))
@api_doc(api_section.users, uses_site=True)
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
if c.user._spam:
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)
@allow_oauth2_access
@validatedForm(VUser(),
VModhash(),
friend = VExistingUname('name'),
container = nop('container'),
type = VOneOf('type', ('friend',) + _sr_friend_types),
type_and_permissions = VPermissions('type', 'permissions'),
note = VLength('note', 300),
duration = VInt('duration', min=1, max=999),
ban_message = VMarkdownLength('ban_message', max_length=1000,
empty_error=None),
)
@api_doc(api_section.users, uses_site=True)
def POST_friend(self, form, jquery, friend,
container, type, type_and_permissions, note, duration,
ban_message):
"""Create a relationship between a user and another user or subreddit
OAuth2 use requires appropriate scope based
on the 'type' of the relationship:
* moderator: Use "moderator_invite"
* moderator_invite: `modothers`
* contributor: `modcontributors`
* banned: `modcontributors`
* muted: `modcontributors`
* wikibanned: `modcontributors` and `modwiki`
* wikicontributor: `modcontributors` and `modwiki`
* friend: Use [/api/v1/me/friends/{username}](#PUT_api_v1_me_friends_{username})
* enemy: Use [/api/block](#POST_api_block)
Complement to [POST_unfriend](#POST_api_unfriend)
"""
if type == 'muted' and not feature.is_enabled('modmail_muting'):
return abort(403, "This feature is not yet enabled")
self.check_api_friend_oauth_scope(type)
if type in self._sr_friend_types:
if isinstance(c.site, FakeSubreddit):
abort(403, 'forbidden')
container = c.site
else:
container = VByName('container').run(container)
if not container:
return
# Don't let banned users make subreddit access changes
if type in self._sr_friend_types and c.user._spam:
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)
# Make sure the user making the request has the correct permissions
# to be able to make this status change
if type in self._sr_friend_types:
if c.user_is_admin:
has_perms = True
elif type.startswith('wiki'):
has_perms = container.is_moderator_with_perms(c.user, 'wiki')
elif type == 'moderator_invite':
has_perms = container.is_unlimited_moderator(c.user)
else:
has_perms = container.is_moderator_with_perms(c.user, 'access')
if not has_perms:
abort(403, 'forbidden')
if type == 'moderator_invite':
invites = sum(1 for i in container.each_moderator_invite())
if invites >= g.sr_invite_limit:
c.errors.add(errors.SUBREDDIT_RATELIMIT, field="name")
form.set_error(errors.SUBREDDIT_RATELIMIT, "name")
return
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_text(".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 == "banned":
if form.has_errors("ban_message", 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
elif type in ("banned", "muted") and container.is_moderator(friend):
c.errors.add(errors.CANT_RESTRICT_MODERATOR, field="name")
form.set_error(errors.CANT_RESTRICT_MODERATOR, "name")
return
# don't allow increasing privileges of banned or muted users
unbanned_types = ("moderator", "moderator_invite",
"contributor", "wikicontributor")
if type in unbanned_types:
if container.is_banned(friend):
c.errors.add(errors.BANNED_FROM_SUBREDDIT, field="name")
form.set_error(errors.BANNED_FROM_SUBREDDIT, "name")
return
elif container.is_muted(friend):
c.errors.add(errors.MUTED_FROM_SUBREDDIT, field="name")
form.set_error(errors.MUTED_FROM_SUBREDDIT, "name")
return
if type == "moderator":
container.remove_moderator_invite(friend)
new = fn(friend, permissions=type_and_permissions[1])
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 '')
# additional logging/info needed for bans
tempinfo = None
log_details = None
log_description = None
if type in ('banned', 'wikibanned'):
container.add_rel_note(type, friend, note)
log_description = note
if duration:
container.unschedule_unban(friend, type)
tempinfo = container.schedule_unban(
type,
friend,
c.user,
duration,
)
log_details = "%d days" % duration
elif not new:
# Preexisting ban and no duration specified means turn the
# temporary ban into a permanent one.
container.unschedule_unban(friend, type)
else:
log_details = "permanent"
elif new and type == 'muted':
MutedAccountsBySubreddit.mute(container, friend, c.user)
# Log this action
if new and type in self._sr_friend_types:
mod_action_by_type = {
"banned": "banuser",
"muted": "muteuser",
"contributor": "addcontributor",
"moderator": "addmoderator",
"moderator_invite": "invitemoderator",
}
action = mod_action_by_type.get(type, type)
ModAction.create(
container,
c.user,
action,
target=friend,
details=log_details,
description=log_description,
)
row_cls = dict(friend=FriendTableItem,
moderator=ModTableItem,
moderator_invite=InvitedModTableItem,
contributor=ContributorTableItem,
wikicontributor=WikiMayContributeTableItem,
banned=BannedTableItem,
muted=MutedTableItem,
wikibanned=WikiBannedTableItem).get(type)
form.set_inputs(name = "")
if note:
form.set_inputs(note = "")
form.removeClass("edited")
if new and row_cls:
new._thing2 = friend
user_row = row_cls(new)
if tempinfo:
BannedListing.populate_from_tempbans(user_row, tempinfo)
form.set_text(".status:first", user_row.executed_message)
rev_types = ["moderator", "moderator_invite", "friend"]
index = 0 if user_row.type not in rev_types else -1
table = jquery("." + type + "-table").show().find("table")
table.insert_table_rows(user_row, index=index)
table.find(".notfound").hide()
if new:
if type == "banned":
if friend.has_interacted_with(container):
send_ban_message(container, c.user, friend,
ban_message, duration)
else:
notify_user_added(type, c.user, friend, container)
@validatedForm(VGold(),
VModhash(),
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_text('.status', _("saved"))
@validatedForm(VModhash(),
type = VOneOf('type', ('bannednote', 'wikibannednote')),
user = VExistingUname('name'),
note = VLength('note', 300))
def POST_relnote(self, form, jquery, type, user, note):
perm = 'wiki' if type.startswith('wiki') else 'access'
if (not c.user_is_admin
and (not c.site.is_moderator_with_perms(c.user, perm))):
if c.user._spam:
return
else:
abort(403, 'forbidden')
if form.has_errors("note", errors.TOO_LONG):
# NOTE: there's no error displayed in the form
return
c.site.add_rel_note(type[:-4], user, note)
@require_oauth2_scope("modself")
@validatedForm(VUser(),
VModhash())
@api_doc(api_section.moderation, uses_site=True)
def POST_accept_moderator_invite(self, form, jquery):
"""Accept an invite to moderate the specified subreddit.
The authenticated user must have been invited to moderate the subreddit
by one of its current moderators.
See also: [/api/friend](#POST_api_friend) and
[/subreddits/mine](#GET_subreddits_mine_{where}).
"""
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()
@validatedForm(
VUser(),
VModhash(),
password=VVerifyPassword("curpass", fatal=False),
dest=VDestination(),
)
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_text('.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(),
VModhash(),
password=VVerifyPassword("curpass", fatal=False),
force_https=VBoolean("force_https"),
)
def POST_set_force_https(self, form, jquery, password, force_https):
"""Toggle HTTPS-only sessions, invalidating other sessions.
A valid password (`curpass`) must be supplied.
"""
if form.has_errors("curpass", errors.WRONG_PASSWORD):
return
if not force_https and feature.is_enabled("require_https"):
form.set_text(".status",
_("you may not disable HTTPS on this account"))
return
c.user.pref_force_https = force_https
c.user._commit()
# run the change password command to get a new salt.
# OAuth tokens are fine since that always happened over HTTPS.
change_password(c.user, password)
form.set_text(".status",
_("HTTPS preferences have been successfully changed"))
form.set_inputs(curpass="")
# the password salt has changed, so the user's cookie has been
# invalidated. drop a new cookie.
self.login(c.user)
# Modify their HSTS grant
form.redirect(hsts_modify_redirect("/prefs/security"))
@validatedForm(
VUser(),
VModhash(),
VVerifyPassword("curpass", fatal=False),
email=ValidEmails("email", num=1),
verify=VBoolean("verify"),
dest=VDestination(),
)
def POST_update_email(self, form, jquery, email, verify, dest):
"""Update account email address.
Called by /prefs/update on the site.
"""
if form.has_errors("curpass", errors.WRONG_PASSWORD):
return
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:
emailer.email_change_email(c.user)
c.user.set_email(email)
c.user.email_verified = None
c.user._commit()
Award.take_away("verified_email", c.user)
if verify:
if dest == '/':
dest = None
emailer.verify_email(c.user, dest=dest)
form.set_text('.status',
_("you should be getting a verification email shortly."))
else:
form.set_text('.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'))
if c.user.email:
emailer.email_change_email(c.user)
c.user.set_email('')
c.user.email_verified = None
c.user.pref_email_messages = False
c.user._commit()
Award.take_away("verified_email", c.user)
form.set_text('.status', _('your email has been updated'))
@validatedForm(
VUser(),
VModhash(),
VVerifyPassword("curpass", fatal=False),
password=VPasswordChange(['newpass', 'verpass']),
)
def POST_update_password(self, form, jquery, password):
"""Update account 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.
"""
if form.has_errors("curpass", errors.WRONG_PASSWORD):
return
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:
emailer.password_change_email(c.user)
form.set_text('.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"))
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('ratelimit', 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)):
redirect_url = "/?deleted=true"
if c.user.https_forced:
redirect_url = hsts_modify_redirect(redirect_url)
c.user.delete(delete_message)
form.redirect(redirect_url)
@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()
thing.update_search_index()
if isinstance(thing, Link):
queries.delete(thing)
thing.subreddit_slow.remove_sticky(thing)
elif isinstance(thing, Comment):
if not was_deleted:
queries.delete_comment(thing)
queries.new_comment(thing, None) # possible inbox_rels are
# handled by unnotify
queries.unnotify(thing)
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')
thing.update_search_index()
@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).
"""
if promote.is_promo(thing):
if c.user_is_sponsor:
# set the override attribute so this link won't be automatically
# reset as nsfw by promote.make_daily_promotions
thing.over_18_override = True
else:
abort(403,'forbidden')
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')
thing.update_search_index()
@require_oauth2_scope("edit")
@noresponse(VUser(),
VModhash(),
thing=VByNameIfAuthor('id'),
state=VBoolean('state'))
@api_doc(api_section.links_and_comments)
def POST_sendreplies(self, thing, state):
"""Enable or disable inbox replies for a link or comment.
`state` is a boolean that indicates whether you are enabling or
disabling inbox replies - true to enable, false to disable.
"""
if not isinstance(thing, (Link, Comment)):
return
thing.sendreplies = state
thing._commit()
@noresponse(VUser(),
VModhash(),
VSrCanAlter('id'),
thing=VByName('id'))
def POST_rescrape(self, thing):
"""Re-queues the link in the media scraper."""
if not isinstance(thing, Link):
return
# KLUDGE: changing the cache entry to a placeholder for this URL will
# cause the media scraper to force a rescrape. This will be fixed
# when parameters can be passed to the scraper queue.
media_cache.MediaByURL.add_placeholder(thing.url, autoplay=False)
amqp.add_item("scraper_q", thing._fullname)
@require_oauth2_scope("modposts")
@validatedForm(VUser(),
VModhash(),
VSrCanBan('id'),
thing=VByName('id', thing_cls=Link),
sort=VOneOf('sort', CommentSortMenu.suggested_sort_options))
@api_doc(api_section.links_and_comments)
def POST_set_suggested_sort(self, form, jquery, thing, sort):
"""Set a suggested sort for a link.
Suggested sorts are useful to display comments in a certain preferred way
for posts. For example, casual conversation may be better sorted by new
by default, or AMAs may be sorted by Q&A. A sort of an empty string
clears the default sort.
"""
if c.user._id != thing.author_id:
ModAction.create(thing.subreddit_slow, c.user, target=thing,
action='setsuggestedsort')
thing.suggested_sort = sort
thing._commit()
jquery.refresh()
@require_oauth2_scope("modposts")
@validatedForm(VUser(),
VModhash(),
VSrCanBan('id'),
thing=VByName('id'),
state=VBoolean('state'))
@api_doc(api_section.links_and_comments)
def POST_set_contest_mode(self, form, jquery, thing, state):
"""Set or unset "contest mode" for a link's comments.
`state` is a boolean that indicates whether you are enabling or
disabling contest mode - true to enable, false to disable.
"""
thing.contest_mode = state
thing._commit()
jquery.refresh()
@require_oauth2_scope("modposts")
@validatedForm(
VUser(),
VModhash(),
VSrCanBan('id'),
thing=VByName('id'),
state=VBoolean('state'),
num=VInt("num", min=1, max=Subreddit.MAX_STICKIES, coerce=True),
)
@api_doc(api_section.links_and_comments)
def POST_set_subreddit_sticky(self, form, jquery, thing, state, num):
"""Set or unset a Link as the sticky in its subreddit.
`state` is a boolean that indicates whether to sticky or unsticky
this post - true to sticky, false to unsticky.
The `num` argument is optional, and only used when stickying a post.
It allows specifying a particular "slot" to sticky the post into, and
if there is already a post stickied in that slot it will be replaced.
If there is no post in the specified slot to replace, or `num` is None,
the bottom-most slot will be used.
"""
if not isinstance(thing, Link):
return
sr = thing.subreddit_slow
if state:
sr.set_sticky(thing, c.user, num=num)
else:
sr.remove_sticky(thing, c.user)
jquery.refresh()
@require_oauth2_scope("report")
@validatedForm(
VUser(),
VModhash(),
thing=VByName('thing_id'),
reason=VLength('reason', max_length=100, empty_error=None),
other_reason=VLength('other_reason', max_length=100, empty_error=None),
)
@api_doc(api_section.links_and_comments)
def POST_report(self, form, jquery, thing, reason, other_reason):
"""Report a link, comment or message.
Reporting a thing brings it to the attention of the subreddit's
moderators. Reporting a message sends it to a system for admin review.
For links and comments, the thing is implicitly hidden as well (see
[/api/hide](#POST_api_hide) for details).
"""
if not thing:
# preserve old behavior: we used to send the thing's fullname as the
# "id" parameter, but we can't use that because that name is used to
# send the form's id
thing_id = request.POST.get('id')
if thing_id:
thing = VByName('id').run(thing_id)
if not thing or thing._deleted:
return
if (form.has_errors("reason", errors.TOO_LONG) or
form.has_errors("other_reason", errors.TOO_LONG)):
return
reason = other_reason if reason == "other" else reason
sr = getattr(thing, 'subreddit_slow', None)
# if it is a message that is being reported, ban it.
# every user is admin over their own personal inbox
if isinstance(thing, Message):
# Ensure the message is either to them directly or indirectly
# (through modmail), to prevent unauthorized banning through
# spoofing.
if (c.user._id != thing.to_id and
not (sr and c.user._id in sr.moderator_ids())):
abort(403)
admintools.spam(thing, False, True, c.user.name)
# auto-hide links that are reported
elif isinstance(thing, Link):
# don't hide items from admins/moderators when reporting
if not (c.user_is_admin or sr.is_moderator(c.user)):
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
hooks.get_hook("thing.report").call(thing=thing)
if not (c.user._spam or
c.user.ignorereports or
(sr and sr.is_banned(c.user))):
Report.new(c.user, thing, reason)
admintools.report(thing)
if isinstance(thing, (Link, Message)):
button = jquery(".id-%s .report-button" % thing._fullname)
elif isinstance(thing, Comment):
button = jquery(".id-%s .entry:first .report-button" % thing._fullname)
else:
return
button.text(_("reported"))
form.fadeOut()
@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
try:
sr = Subreddit._byID(thing.sr_id) if thing.sr_id else None
except NotFound:
sr = None
if getattr(thing, "from_sr", False) and sr:
BlockedSubredditsByAccount.block(c.user, sr)
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
if not (sr and sr.is_moderator_with_perms(c.user, 'mail')):
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)
display_author = getattr(thing, "display_author", None)
if block_acct.name in g.admins or display_author:
return
c.user.add_enemy(block_acct)
@require_oauth2_scope("privatemessages")
@noresponse(
VUser(),
VModhash(),
thing=VByName('id'),
)
@api_doc(api_section.messages)
def POST_unblock_subreddit(self, thing):
if not thing:
return
try:
sr = Subreddit._byID(thing.sr_id) if thing.sr_id else None
except NotFound:
sr = None
if getattr(thing, "from_sr", False) and sr:
BlockedSubredditsByAccount.unblock(c.user, sr)
return
@require_oauth2_scope("modcontributors")
@noresponse(
VUser(),
VModhash(),
message=VByName('id'),
)
@api_doc(api_section.moderation)
def POST_mute_message_author(self, message):
'''For muting user via modmail.'''
if not message:
return
subreddit = message.subreddit_slow
if not subreddit:
abort(403, 'Not modmail')
if not feature.is_enabled('modmail_muting', subreddit=subreddit.name):
return abort(403, "This feature is not yet enabled")
user = message.author_slow
if not c.user_is_admin:
if not subreddit.is_moderator_with_perms(c.user, 'access', 'mail'):
abort(403, 'Invalid mod permissions')
quota_key = "sr%squota-%s" % ("muted", subreddit._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" % "muted")
if subreddit_quota > quota_limit and subreddit.use_quotas:
abort(403, errors.SUBREDDIT_RATELIMIT)
added = subreddit.add_muted(user)
# Don't mute the user and create another modaction if already muted
if added:
MutedAccountsBySubreddit.mute(subreddit, user, c.user)
ModAction.create(subreddit, c.user, 'muteuser', target=user)
@require_oauth2_scope("modcontributors")
@noresponse(
VUser(),
VModhash(),
message=VByName('id'),
)
@api_doc(api_section.moderation)
def POST_unmute_message_author(self, message):
'''For unmuting user via modmail.'''
if not message:
return
subreddit = message.subreddit_slow
if not subreddit:
abort(403, 'Not modmail')
if not feature.is_enabled('modmail_muting', subreddit=subreddit.name):
return abort(403, "This feature is not yet enabled")
user = message.author_slow
if not c.user_is_admin:
if not subreddit.is_moderator_with_perms(c.user, 'access', 'mail'):
abort(403, 'Invalid mod permissions')
removed = subreddit.remove_muted(user)
if removed:
MutedAccountsBySubreddit.unmute(subreddit, user)
ModAction.create(subreddit, c.user, 'unmuteuser', target=user)
@require_oauth2_scope("edit")
@validatedForm(
VUser(),
VModhash(),
item=VByNameIfAuthor('thing_id'),
text=VMarkdown('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 (form.has_errors('text', errors.NO_TEXT) or
form.has_errors("thing_id", errors.NOT_AUTHOR)):
return
if isinstance(item, Link) and not item.is_self:
return abort(403, "forbidden")
if getattr(item, 'admin_takedown', False):
# this item has been takendown by the admins,
# and not not be edited
# would love to use a 451 (legal) here, but pylons throws an error
return abort(403, "this content is locked and can not be edited")
if isinstance(item, Comment):
max_length = 10000
admin_override = False
else:
max_length = Link.SELFTEXT_MAX_LENGTH
admin_override = c.user_is_admin
if not admin_override and len(text) > max_length:
c.errors.add(errors.TOO_LONG, field='text',
msg_params={'max_length': max_length})
form.set_error(errors.TOO_LONG, 'text')
return
removed_mentions = None
original_text = item.body
if isinstance(item, Comment):
kind = 'comment'
prev_mentions = extract_user_mentions(original_text)
new_mentions = extract_user_mentions(text)
removed_mentions = prev_mentions - new_mentions
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()
# only add to the edited page if this is marked as edited
if hasattr(item, "editted"):
queries.edit(item)
item.update_search_index()
amqp.add_item('%s_text_edited' % kind, item._fullname)
hooks.get_hook("thing.edit").call(
thing=item, original_text=original_text)
# new mentions are subject to more constraints, handled in butler_q
if removed_mentions:
queries.unnotify(item, list(Account._names_to_ids(
removed_mentions,
ignore_missing=True,
)))
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()
@allow_oauth2_access
@validatedForm(
VUser(),
VModhash(),
VRatelimit(rate_user=True, rate_ip=True, prefix="rate_comment_"),
parent=VSubmitParent(['thing_id', 'parent']),
comment=VMarkdownLength(['text', 'comment'], max_length=10000),
)
@api_doc(api_section.links_and_comments)
def POST_comment(self, commentform, jquery, parent, comment):
"""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. (requires `submit` scope)
* the fullname of a Comment: a comment reply to that comment. (requires `submit` scope)
* the fullname of a Message: a message reply to that message. (requires `privatemessages` scope)
`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 (c.oauth_user and not
c.oauth_scope.has_any_scope({'privatemessages', 'submit'})):
abort(403, 'forbidden')
if not getattr(parent, "repliable", True):
abort(403, 'forbidden')
if not parent.can_view_slow():
abort(403, 'forbidden')
if parent.sr_id and not c.user_is_admin:
sr = parent.subreddit_slow
# get the first message to see who the non-mod recipient
# in a modmail conversation is
message = parent
if parent.first_message:
message = Message._byID(parent.first_message, data=True)
user_muted_error = False
if sr.is_muted(message.author_slow):
user_muted_error = True
muted_user = message.author_slow
elif message.to_id and sr.is_muted(message.recipient_slow):
user_muted_error = True
muted_user = message.recipient_slow
if user_muted_error:
if sr.is_moderator(c.user):
c.errors.add(errors.MUTED_FROM_SUBREDDIT, field="parent")
g.events.muted_forbidden_event("muted mod",
sr, parent_message=parent, target=muted_user,
request=request, context=c,
)
else:
c.errors.add(errors.USER_MUTED, field="parent")
g.events.muted_forbidden_event("muted",
parent_message=parent, target=sr,
request=request, context=c,
)
is_message = True
should_ratelimit = False
else:
if (c.oauth_user and not
c.oauth_scope.has_access(c.site.name, {'submit'})):
abort(403, 'forbidden')
is_message = False
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
is_author = link.author_id == c.user._id
if (is_author and (link.is_self or promote.is_promo(link)) or
not sr.should_ratelimit(c.user, 'comment')):
should_ratelimit = False
if link._age > sr.archive_age:
c.errors.add(errors.TOO_OLD, field = "parent")
hooks.get_hook("comment.validate").call(sr=sr, link=link,
parent_comment=parent_comment)
#remove the ratelimit error if the user's karma is high
if not should_ratelimit:
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
if (commentform.has_errors("text", errors.NO_TEXT, errors.TOO_LONG) or
commentform.has_errors("comment", errors.TOO_LONG) or
commentform.has_errors("ratelimit", errors.RATELIMIT) or
commentform.has_errors("parent", errors.DELETED_COMMENT,
errors.DELETED_LINK, errors.TOO_OLD, errors.USER_BLOCKED,
errors.USER_MUTED, errors.MUTED_FROM_SUBREDDIT)
):
return
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,
request.ip, parent=parent)
item.parent_id = parent._id
if parent.display_author and not getattr(parent, "signed", False):
item.display_to = parent.display_author
item._commit()
else:
item, inbox_rel = Comment._new(c.user, link, parent_comment,
comment, request.ip)
queries.queue_vote(c.user, item, dir=True, ip=request.ip,
cheater=c.cheater)
if is_message:
queries.new_message(item, inbox_rel)
else:
queries.new_comment(item, inbox_rel)
if should_ratelimit:
VRatelimit.ratelimit(rate_user=True, rate_ip = True,
prefix = "rate_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_text(".reply-button:first",
_("replied"))
# insert the new comment
jquery.insert_things(item)
# remove any null listings that may be present
jquery("#noresults").hide()
@validatedForm(
VUser(),
VModhash(),
VRatelimitImproved(prefix='share', max_usage=g.RL_SHARE_MAX_REQS,
rate_user=True, rate_ip=True),
share_to=ValidEmailsOrExistingUnames("share_to"),
message=VLength("message", max_length=1000),
link=VByName('parent', thing_cls=Link),
)
def POST_share(self, shareform, jquery, share_to, message, link):
if not link:
abort(404, 'not found')
# remove the ratelimit error if the user's karma is high
sr = link.subreddit_slow
should_ratelimit = sr.should_ratelimit(c.user, 'link')
if not should_ratelimit:
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
if shareform.has_errors("message", errors.TOO_LONG):
return
elif shareform.has_errors("share_to", errors.BAD_EMAILS,
errors.NO_EMAILS,
errors.TOO_MANY_EMAILS):
return
elif shareform.has_errors("ratelimit", errors.RATELIMIT):
return
subreddit = link.subreddit_slow
if subreddit.quarantine or not subreddit.can_view(c.user):
return abort(403, 'forbidden')
emails, users = share_to
if getattr(link, "promoted", None) and link.disable_comments:
message = blockquote_text(message) + "\n\n" if message else ""
message += '\n%s\n\n%s\n\n' % (link.title, link.url)
email_message = pm_message = message
else:
message = blockquote_text(message) + "\n\n" if message else ""
message += '\n%s\n' % link.title
message_body = '\n'
# Deliberately not translating this, as it'd be in the
# sender's language
if link.num_comments:
count = ("There are currently %(num_comments)s comments " +
"on this link. You can view them here:")
if link.num_comments == 1:
count = ("There is currently %(num_comments)s " +
"comment on this link. You can view it here:")
numcom = count % {'num_comments': link.num_comments}
message_body = message_body + "%s\n\n" % numcom
else:
message_body = message_body + "You can leave a comment here:\n\n"
url = add_sr(link.make_permalink_slow(), force_hostname=True)
url_parser = UrlParser(url)
url_parser.update_query(ref="share", ref_source="email")
email_comments_url = url_parser.unparse()
url_parser.update_query(ref_source="pm")
pm_comments_url = url_parser.unparse()
message_body += '%(comments_url)s'
email_message = message + message_body % {
"comments_url": email_comments_url,
}
pm_message = message + message_body % {
"comments_url": pm_comments_url,
}
# E-mail everyone
emailer.share(link, emails, body=email_message 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.
pm_message = "%s\n\n%s" % (subject, pm_message)
for target in users:
m, inbox_rel = Message._new(c.user, target, subject,
pm_message, request.ip)
# Queue up this PM
amqp.add_item('new_message', m._fullname)
queries.new_message(m, inbox_rel)
g.stats.simple_event('share.email_sent', len(emails))
g.stats.simple_event('share.pm_sent', len(users))
# Set the ratelimiter.
VRatelimitImproved.ratelimit('share', rate_user=True, rate_ip=True)
@require_oauth2_scope("vote")
@noresponse(VUser(),
VModhash(),
vote_info=VVotehash('vh'),
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, vote_info):
"""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.
"""
user = c.user
store = True
if not thing or thing._deleted:
return self.abort404()
hooks.get_hook("vote.validate").call(thing=thing)
if not isinstance(thing, (Link, Comment)):
return self.abort404()
if isinstance(thing, Link) and promote.is_promo(thing):
if not promote.is_promoted(thing):
return abort(400, "Bad Request")
if vote_info == 'rejected':
reject_vote(thing)
store = False
if thing._age > thing.subreddit_slow.archive_age:
store = False
dir = (True if dir > 0
else False if dir < 0
else None)
queries.queue_vote(user, thing, dir, request.ip, vote_info=vote_info,
store=store, cheater=c.cheater)
@require_oauth2_scope("modconfig")
@validatedForm(VSrModerator(perms='config'),
VModhash(),
# nop is safe: handled after auth checks below
stylesheet_contents=nop('stylesheet_contents',
docs={"stylesheet_contents":
"the new stylesheet content"}),
reason=VPrintable('reason', 256, empty_error=None),
op = VOneOf('op',['save','preview']))
@api_doc(api_section.subreddits, uses_site=True)
def POST_subreddit_stylesheet(self, form, jquery,
stylesheet_contents = '', prevstyle='',
op='save', reason=None):
"""Update a subreddit's stylesheet.
`op` should be `save` to update the contents of the stylesheet.
"""
css_errors, parsed = c.site.parse_css(stylesheet_contents)
if g.css_killswitch:
return abort(403, 'forbidden')
if css_errors:
error_items = [CssError(x).render(style='html') for x in css_errors]
form.set_text(".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.set_html(".errors ul", '')
if op == 'save':
wr = c.site.change_css(stylesheet_contents, parsed, reason=reason)
form.find('.errors').hide()
form.set_text(".status", _('saved'))
form.set_html(".errors ul", "")
if wr:
description = wiki.modactions.get('config/stylesheet')
ModAction.create(c.site, c.user, 'wikirevise', description)
jquery.apply_stylesheet(parsed)
if op == 'preview':
# try to find a link to use, otherwise give up and
# return
links = SubredditStylesheet.find_preview_links(c.site)
if links:
jquery('#preview-table').show()
# do a regular link
jquery('#preview_link_normal').html(
SubredditStylesheet.rendered_link(
links, media='off', compress=False))
# now do one with media
jquery('#preview_link_media').html(
SubredditStylesheet.rendered_link(
links, media='on', compress=False))
# do a compressed link
jquery('#preview_link_compressed').html(
SubredditStylesheet.rendered_link(
links, media='off', compress=True))
# do a stickied link
jquery('#preview_link_stickied').html(
SubredditStylesheet.rendered_link(
links, media='off', compress=False, stickied=True))
# and do a comment
comments = SubredditStylesheet.find_preview_comments(c.site)
if comments:
jquery('#preview_comment').html(
SubredditStylesheet.rendered_comment(comments))
jquery('#preview_comment_gilded').html(
SubredditStylesheet.rendered_comment(
comments, gilded=True))
@require_oauth2_scope("modconfig")
@validatedForm(VSrModerator(perms='config'),
VModhash(),
name = VCssName('img_name'))
@api_doc(api_section.subreddits, uses_site=True)
def POST_delete_sr_img(self, form, jquery, name):
"""Remove an image from the subreddit's custom image set.
The image will no longer count against the subreddit's image limit.
However, the actual image data may still be accessible for an
unspecified amount of time. If the image is currently referenced by the
subreddit's stylesheet, that stylesheet will no longer validate and
won't be editable until the image reference is removed.
See also: [/api/upload_sr_img](#POST_api_upload_sr_img).
"""
# just in case we need to kill this feature from XSS
if g.css_killswitch:
return abort(403, 'forbidden')
if form.has_errors("img_name", errors.BAD_CSS_NAME):
return
wiki.ImagesByWikiPage.delete_image(c.site, "config/stylesheet", name)
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, uses_site=True)
def POST_delete_sr_header(self, form, jquery):
"""Remove the subreddit's custom header image.
The sitewide-default header image will be shown again after this call.
See also: [/api/upload_sr_img](#POST_api_upload_sr_img).
"""
# just in case we need to kill this feature from XSS
if g.css_killswitch:
return 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_text('.img-status', _("deleted"))
@require_oauth2_scope("modconfig")
@validatedForm(VSrModerator(perms='config'),
VModhash())
@api_doc(api_section.subreddits, uses_site=True)
def POST_delete_sr_icon(self, form, jquery):
"""Remove the subreddit's custom mobile icon.
See also: [/api/upload_sr_img](#POST_api_upload_sr_img).
"""
if c.site.icon_img:
c.site.icon_img = None
c.site.icon_size = None
c.site._commit()
ModAction.create(c.site, c.user, action='editsettings',
details='del_icon')
# 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_text('.img-status', _("deleted"))
@require_oauth2_scope("modconfig")
@validatedForm(VSrModerator(perms='config'),
VModhash())
@api_doc(api_section.subreddits, uses_site=True)
def POST_delete_sr_banner(self, form, jquery):
"""Remove the subreddit's custom mobile banner.
See also: [/api/upload_sr_img](#POST_api_upload_sr_img).
"""
if c.site.banner_img:
c.site.banner_img = None
c.site.banner_size = None
c.site._commit()
ModAction.create(c.site, c.user, action='editsettings',
details='del_banner')
# 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_text('.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 = VUploadLength('file', max_length=1024*500),
name = VCssName("name"),
img_type = VImageType('img_type'),
form_id = VLength('formid', max_length = 100,
docs={"formid": "(optional) can be ignored"}),
upload_type = VOneOf('upload_type',
('img', 'header', 'icon', 'banner')),
header = VInt('header', max=1, min=0))
@api_doc(api_section.subreddits, uses_site=True)
def POST_upload_sr_img(self, file, header, name, form_id, img_type,
upload_type=None):
"""Add or replace a subreddit image, custom header logo, custom mobile
icon, or custom mobile banner.
* If the `upload_type` value is `img`, an image for use in the
subreddit stylesheet is uploaded with the name specified in `name`.
* If the `upload_type` value is `header` then the image uploaded will
be the subreddit's new logo and `name` will be ignored.
* If the `upload_type` value is `icon` then the image uploaded will be
the subreddit's new mobile icon and `name` will be ignored.
* If the `upload_type` value is `banner` then the image uploaded will
be the subreddit's new mobile banner and `name` will be ignored.
For backwards compatibility, if `upload_type` is not specified, the
`header` field will be used instead:
* If the `header` field has value `0`, then `upload_type` is `img`.
* If the `header` field has value `1`, then `upload_type` is `header`.
The `img_type` field specifies whether to store the uploaded image as a
PNG or JPEG.
Subreddits have a limited number of images that can be in use at any
given time. If no image with the specified name already exists, one of
the slots will be consumed.
If an image with the specified name already exists, it will be
replaced. This does not affect the stylesheet immediately, but will
take effect the next time the stylesheet is saved.
See also: [/api/delete_sr_img](#POST_api_delete_sr_img),
[/api/delete_sr_header](#POST_api_delete_sr_header),
[/api/delete_sr_icon](#POST_api_delete_sr_icon), and
[/api/delete_sr_banner](#POST_api_delete_sr_banner).
"""
if c.site.quarantine:
abort(403)
# default error list (default values will reset the errors in
# the response if no error is raised)
errors = dict(BAD_CSS_NAME = "", IMAGE_ERROR = "")
# for backwards compatibility, map header to upload_type
if upload_type is None:
upload_type = 'header' if header else 'img'
if upload_type == 'img' and 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 upload_type == 'img' and not c.user_is_admin:
image_count = wiki.ImagesByWikiPage.get_image_count(
c.site, "config/stylesheet")
if image_count >= g.max_sr_images:
errors['IMAGE_ERROR'] = _("too many images (you only get %d)") % g.max_sr_images
try:
size = str_to_image(file).size
except (IOError, TypeError):
errors['IMAGE_ERROR'] = _('Invalid image or general image error')
else:
if upload_type == 'icon':
if size != Subreddit.ICON_EXACT_SIZE:
errors['IMAGE_ERROR'] = (
_('must be %dx%d pixels') % Subreddit.ICON_EXACT_SIZE)
elif upload_type == 'banner':
if size[0] * 10 / 16 != size[1] * 10 / 9:
# require precision to one decimal point for aspect ratio
errors['IMAGE_ERROR'] = _('16:9 aspect ratio required')
elif size > Subreddit.BANNER_MAX_SIZE:
errors['IMAGE_ERROR'] = (
_('max %dx%d pixels') % Subreddit.BANNER_MAX_SIZE)
elif size < Subreddit.BANNER_MIN_SIZE:
errors['IMAGE_ERROR'] = (
_('min %dx%d pixels') % Subreddit.BANNER_MIN_SIZE)
if any(errors.values()):
return UploadedImage("", "", "", errors=errors, form_id=form_id).render()
else:
try:
new_url = media.upload_media(file, file_type="." + img_type)
except Exception as e:
g.log.warning("error uploading subreddit image: %s", e)
errors['IMAGE_ERROR'] = _("Invalid image or general image error")
return UploadedImage("", "", "", errors=errors, form_id=form_id).render()
if upload_type == 'img':
wiki.ImagesByWikiPage.add_image(c.site, "config/stylesheet",
name, new_url)
kw = dict(details='upload_image', description=name)
elif upload_type == 'header':
c.site.header = new_url
c.site.header_size = size
c.site._commit()
kw = dict(details='upload_image_header')
elif upload_type == 'icon':
c.site.icon_img = new_url
c.site.icon_size = size
c.site._commit()
kw = dict(details='upload_image_icon')
elif upload_type == 'banner':
c.site.banner_img = new_url
c.site.banner_size = size
c.site._commit()
kw = dict(details='upload_image_banner')
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(),
VCaptcha(),
VModhash(),
VRatelimit(rate_user = True,
rate_ip = True,
prefix = 'create_reddit_'),
sr = VByName('sr'),
name = VAvailableSubredditName("name"),
title = VLength("title", max_length = 100),
header_title = VLength("header-title", max_length = 500),
domain = VCnameDomain("domain"),
submit_text = VMarkdownLength("submit_text", max_length=1024),
public_description = VMarkdownLength("public_description", max_length = 500),
description = VMarkdownLength("description", max_length = 5120),
lang = VLang("lang"),
over_18 = VBoolean('over_18'),
allow_top = VBoolean('allow_top'),
show_media = VBoolean('show_media'),
public_traffic = VBoolean('public_traffic'),
collapse_deleted_comments = VBoolean('collapse_deleted_comments'),
exclude_banned_modqueue = VBoolean('exclude_banned_modqueue'),
show_cname_sidebar = VBoolean('show_cname_sidebar'),
spam_links = VOneOf('spam_links', ('low', 'high', 'all')),
spam_selfposts = VOneOf('spam_selfposts', ('low', 'high', 'all')),
spam_comments = VOneOf('spam_comments', ('low', 'high', 'all')),
type = VOneOf('type', Subreddit.valid_types),
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),
css_on_cname = VBoolean("css_on_cname"),
hide_ads = VBoolean("hide_ads"),
suggested_comment_sort=VOneOf('suggested_comment_sort',
CommentSortMenu._options,
default=None),
quarantine = VBoolean('quarantine'),
# community_rules = VLength('community_rules', max_length=1024),
# related_subreddits = VSubredditList('related_subreddits', limit=20),
# key_color = VColor('key_color'),
)
@api_doc(api_section.subreddits)
def POST_site_admin(self, form, jquery, name, sr, **kw):
"""Create or configure a subreddit.
If `sr` is specified, the request will attempt to modify the specified
subreddit. If not, a subreddit with name `name` will be created.
This endpoint expects *all* values to be supplied on every request. If
modifying a subset of options, it may be useful to get the current
settings from [/about/edit.json](#GET_r_{subreddit}_about_edit.json)
first.
For backwards compatibility, `description` is the sidebar text and
`public_description` is the publicly visible subreddit description.
Most of the parameters for this endpoint are identical to options
visible in the user interface and their meanings are best explained
there.
See also: [/about/edit.json](#GET_r_{subreddit}_about_edit.json).
"""
def apply_wikid_field(sr, form, pagename, value, field):
try:
wikipage = wiki.WikiPage.get(sr, pagename)
except tdb_cassandra.NotFound:
wikipage = wiki.WikiPage.create(sr, pagename)
wr = wikipage.revise(value, author=c.user._id36)
setattr(sr, field, value)
if wr:
ModAction.create(sr, c.user, 'wikirevise',
details=wiki.modactions.get(pagename))
# XXX: This should be moved to @validatedForm above when we remove
# the feature flag. Down here to avoid processing when flagged off
# and to hide from API docs.
if feature.is_enabled('mobile_settings'):
mobile_fields = {
'community_rules': VLength('community_rules', max_length=1024),
'related_subreddits': VSubredditList('related_subreddits',
limit=20),
'key_color': VColor('key_color'),
}
for key, validator in mobile_fields.iteritems():
value = request.params.get(key)
kw[key] = validator.run(value)
# the status button is outside the form -- have to reset by hand
form.parent().set_html('.status', "")
redir = False
keyword_fields = [
'allow_top',
'collapse_deleted_comments',
'comment_score_hide_mins',
'community_rules',
'css_on_cname',
'description',
'domain',
'exclude_banned_modqueue',
'header_title',
'hide_ads',
'key_color',
'lang',
'link_type',
'name',
'over_18',
'public_description',
'public_traffic',
'quarantine',
'related_subreddits',
'show_cname_sidebar',
'show_media',
'spam_comments',
'spam_links',
'spam_selfposts',
'submit_link_label',
'submit_text',
'submit_text_label',
'title',
'type',
'wiki_edit_age',
'wiki_edit_karma',
'wikimode',
]
keyword_fields.append('suggested_comment_sort')
kw = {k: v for k, v in kw.iteritems() if k in keyword_fields}
public_description = kw.pop('public_description')
description = kw.pop('description')
submit_text = kw.pop('submit_text')
def update_wiki_text(sr):
error = False
apply_wikid_field(
sr,
form,
'config/sidebar',
description,
'description',
)
apply_wikid_field(
sr,
form,
'config/submit_text',
submit_text,
'submit_text',
)
apply_wikid_field(
sr,
form,
'config/description',
public_description,
'public_description',
)
if not sr and not c.user.can_create_subreddit:
form.set_error(errors.CANT_CREATE_SR, "")
c.errors.add(errors.CANT_CREATE_SR, field="")
# only care about captcha if this is creating a subreddit
if not sr and form.has_errors("captcha", errors.BAD_CAPTCHA):
return
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')
# can't create a gold only subreddit without having gold
can_set_gold_only = (c.user.gold or c.user.gold_charter or
(sr and sr.type == 'gold_only'))
if kw['type'] == 'gold_only' and not can_set_gold_only:
form.set_error(errors.GOLD_REQUIRED, 'type')
c.errors.add(errors.GOLD_REQUIRED, field='type')
can_set_hide_ads = can_set_gold_only and kw['type'] == 'gold_only'
if kw['hide_ads'] and not can_set_hide_ads:
form.set_error(errors.GOLD_ONLY_SR_REQUIRED, 'hide_ads')
c.errors.add(errors.GOLD_ONLY_SR_REQUIRED, field='hide_ads')
elif not can_set_hide_ads and sr:
kw['hide_ads'] = sr.hide_ads
can_set_employees_only = c.user.employee
if kw['type'] == 'employees_only' and not can_set_employees_only:
c.errors.add(errors.INVALID_OPTION, field='type')
# if user is not an admin, set the quarantine argument to the original value
if not c.user_is_admin:
if sr:
kw['quarantine'] = sr.quarantine
else:
kw['quarantine'] = False
if not sr and form.has_errors("ratelimit", errors.RATELIMIT):
pass
elif not sr and form.has_errors("", errors.CANT_CREATE_SR):
pass
# if existing subreddit is employees_only and trying to change type,
# require that admin mode is on
elif (sr and sr.type == 'employees_only' and kw['type'] != sr.type and
not c.user_is_admin):
form.set_error(errors.ADMIN_REQUIRED, 'type')
c.errors.add(errors.ADMIN_REQUIRED, field='type')
# if the user wants to convert an existing subreddit to gold_only,
# let them know that they'll need to contact an admin to convert it.
elif (sr and sr.type != 'gold_only' and kw['type'] == 'gold_only' and
not c.user_is_admin):
form.set_error(errors.CANT_CONVERT_TO_GOLD_ONLY, 'type')
c.errors.add(errors.CANT_CONVERT_TO_GOLD_ONLY, field='type')
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',
'submit_text',
'description'), 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
elif form.has_errors('related_subreddits', errors.SUBREDDIT_NOEXIST,
errors.BAD_SR_NAME, errors.TOO_MANY_SUBREDDITS):
pass
elif form.has_errors('hide_ads', errors.GOLD_ONLY_SR_REQUIRED):
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=request.ip, **kw)
update_wiki_text(sr)
sr._commit()
Subreddit.subscribe_defaults(c.user)
sr.add_subscriber(c.user)
sr.add_moderator(c.user)
if not sr.hide_contributors:
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)
sr.update_search_index()
#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
update_wiki_text(sr)
update_stylesheet = kw['quarantine'] != sr.quarantine
if not sr.domain:
del kw['css_on_cname']
if kw['quarantine']:
del kw['allow_top']
del kw['show_media']
#notify ads if sr in a collection changes over_18 to true
if kw.get('over_18', False) and not sr.over_18:
collections = []
for collection in Collection.get_all():
if (sr.name in collection.sr_names
and not collection.over_18):
collections.append(collection.name)
if collections:
msg = "%s now NSFW, in collection(s) %s"
msg %= (sr.name, ', '.join(collections))
emailer.sales_email(msg)
# do not clobber these fields if absent in request
no_clobber = (
'community_rules',
'key_color',
'related_subreddits',
)
for k, v in kw.iteritems():
if getattr(sr, k, None) != v:
ModAction.create(sr, c.user, action='editsettings',
details=k)
if k in no_clobber and k not in request.params:
continue
setattr(sr, k, v)
sr._commit()
if update_stylesheet:
stylesheet_contents = sr.fetch_stylesheet_source()
css_errors, parsed = sr.parse_css(stylesheet_contents)
sr.change_css(stylesheet_contents, parsed)
#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)
sr.update_search_index()
form.parent().set_text('.status', _("saved"))
if form.has_error():
return
if redir:
form.redirect(redir)
else:
jquery.refresh()
@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):
"""Remove a link, comment, or modmail message.
If the thing is a link, it will be removed from all subreddit listings.
If the thing is a comment, it will be redacted and removed from all
subreddit comment listings.
See also: [/api/approve](#POST_api_approve).
"""
# 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)
modified_thing = None
if isinstance(thing, Link):
modified_thing = thing
elif isinstance(thing, Comment):
modified_thing = Link._byID(thing.link_id)
if modified_thing:
set_last_modified(modified_thing, 'comments')
LastModified.touch(modified_thing._fullname, 'Comments')
if isinstance(thing, (Link, Comment)):
sr = thing.subreddit_slow
action = 'remove' + thing.__class__.__name__.lower()
ModAction.create(sr, c.user, action, **kw)
if isinstance(thing, Link):
sr.remove_sticky(thing)
elif isinstance(thing, Comment):
queries.unnotify(thing)
@require_oauth2_scope("modposts")
@noresponse(VUser(), VModhash(),
VSrCanBan('id'),
thing = VByName('id'))
@api_doc(api_section.moderation)
def POST_approve(self, thing):
"""Approve a link or comment.
If the thing was removed, it will be re-inserted into appropriate
listings. Any reports on the approved thing will be discarded.
See also: [/api/remove](#POST_api_remove).
"""
if not thing: return
if thing._deleted: return
if c.user._spam: 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)
if isinstance(thing, Comment) and insert:
queries.renotify(thing)
@require_oauth2_scope("modposts")
@noresponse(VUser(), VModhash(),
VSrCanBan('id'),
thing=VByName('id'))
@api_doc(api_section.moderation)
def POST_ignore_reports(self, thing):
"""Prevent future reports on a thing from causing notifications.
Any reports made about a thing after this flag is set on it will not
cause notifications or make the thing show up in the various moderation
listings.
See also: [/api/unignore_reports](#POST_api_unignore_reports).
"""
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):
"""Allow future reports on a thing to cause notifications.
See also: [/api/ignore_reports](#POST_api_ignore_reports).
"""
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):
"""Distinguish a thing's author with a sigil.
This can be useful to draw attention to and confirm the identity of the
user in the context of a link or comment of theirs. The options for
distinguish are as follows:
* `yes` - add a moderator distinguish (`[M]`). only if the user is a
moderator of the subreddit the thing is in.
* `no` - remove any distinguishes.
* `admin` - add an admin distinguish (`[A]`). admin accounts only.
* `special` - add a user-specific distinguish. depends on user.
The first time a top-level comment is moderator distinguished, the
author of the link the comment is in reply to will get a notification
in their inbox.
"""
if not thing:return
c.profilepage = request.params.get('profilepage') == 'True'
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 or
# comment that has disabled receiving inbox notifications of replies, if
# it's the first distinguish for this comment, and if the user isn't
# banned or blocked by the author (replying didn't generate an inbox
# notification, send one now upon distinguishing it)
if isinstance(thing, Comment):
if not thing.parent_id:
link = Link._byID(thing.link_id, data=True)
to = Account._byID(link.author_id, data=True)
replies_enabled = link.sendreplies
else:
parent = Comment._byID(thing.parent_id, data=True)
to = Account._byID(parent.author_id, data=True)
replies_enabled = parent.sendreplies
previously_distinguished = hasattr(thing, 'distinguished')
user_can_notify = (not c.user._spam and
c.user._id not in to.enemies and
to.name != c.user.name)
if (send_message and
not replies_enabled and
not previously_distinguished and
user_can_notify):
inbox_rel = Inbox._add(to, thing, 'selfreply')
queries.new_comment(thing, inbox_rel)
thing.distinguished = how
thing._commit()
hooks.get_hook("thing.distinguish").call(thing=thing)
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)
@require_oauth2_scope("save")
@json_validate(VUser())
@api_doc(api_section.links_and_comments)
def GET_saved_categories(self, responder):
"""Get a list of categories in which things are currently saved.
See also: [/api/save](#POST_api_save).
"""
if not c.user.gold:
abort(403)
categories = LinkSavesByCategory.get_saved_categories(c.user)
categories += CommentSavesByCategory.get_saved_categories(c.user)
categories = sorted(set(categories), key=lambda name: name.lower())
categories = [dict(category=category) for category in categories]
return {'categories': categories}
@require_oauth2_scope("save")
@noresponse(
VUser(),
VModhash(),
category=VSavedCategory('category'),
thing=VByName('id'),
)
@api_doc(api_section.links_and_comments)
def POST_save(self, thing, category):
"""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 or not isinstance(thing, (Link, Comment)):
abort(400)
if category and not c.user.gold:
category = None
if ('BAD_SAVE_CATEGORY', 'category') in c.errors:
abort(403)
thing._save(c.user, category=category)
@require_oauth2_scope("save")
@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 or not isinstance(thing, (Link, Comment)):
abort(400)
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))
@api_doc(api_section.messages)
def POST_collapse_message(self, things):
"""Collapse a message
See also: [/api/uncollapse_message](#POST_uncollapse_message)
"""
self.collapse_handler(things, True)
@noresponse(VUser(),
VModhash(),
things = VByName('id', multiple = True))
@api_doc(api_section.messages)
def POST_uncollapse_message(self, things):
"""Uncollapse a message
See also: [/api/collapse_message](#POST_collapse_message)
"""
self.collapse_handler(things, False)
@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):
if not things:
if (errors.TOO_MANY_THING_IDS, 'id') in c.errors:
return abort(413)
else:
return abort(400)
queries.unread_handler(things, c.user, unread=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):
if not things:
if (errors.TOO_MANY_THING_IDS, 'id') in c.errors:
return abort(413)
else:
return abort(400)
queries.unread_handler(things, c.user, unread=False)
@require_oauth2_scope("privatemessages")
@noresponse(VUser(),
VModhash(),
VRatelimit(rate_user=True, prefix="rate_read_all_"))
@api_doc(api_section.messages)
def POST_read_all_messages(self):
"""Queue up marking all messages for a user as read.
This may take some time, and returns 202 to acknowledge acceptance of
the request.
"""
amqp.add_item('mark_all_read', c.user._fullname)
return abort(202)
@require_oauth2_scope("report")
@noresponse(VUser(),
VModhash(),
links=VByName('id', thing_cls=Link, multiple=True, limit=50))
@api_doc(api_section.links_and_comments)
def POST_hide(self, links):
"""Hide a link.
This removes it from the user's default view of subreddit listings.
See also: [/api/unhide](#POST_api_unhide).
"""
if not links:
return abort(400)
LinkHidesByAccount._hide(c.user, links)
@require_oauth2_scope("report")
@noresponse(VUser(),
VModhash(),
links=VByName('id', thing_cls=Link, multiple=True, limit=50))
@api_doc(api_section.links_and_comments)
def POST_unhide(self, links):
"""Unhide a link.
See also: [/api/hide](#POST_api_hide).
"""
if not links:
return abort(400)
LinkHidesByAccount._unhide(c.user, links)
@csrf_exempt
@validatedForm(VUser(),
parent = VByName('parent_id'))
def POST_moremessages(self, form, jquery, parent):
if not parent.can_view_slow():
return 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)
if hasattr(item, "child"):
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)
@require_oauth2_scope("read")
@validatedForm(
link=VByName('link_id'),
sort=VMenu('morechildren', CommentSortMenu, remember=False),
children=VCommentIDs('children'),
mc_id=nop(
"id",
docs={"id": "(optional) id of the associated MoreChildren object"}),
)
@api_doc(api_section.links_and_comments)
def GET_morechildren(self, form, jquery, link, sort, children, 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.
**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 children:
builder = CommentBuilder(link, CommentSortMenu.operator(sort),
children=children,
num=CHILD_FETCH_COUNT)
listing = Listing(builder, nextprev = False)
items = listing.get_items()
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)
finally:
if lock:
lock.release()
@csrf_exempt
@require_oauth2_scope("read")
def POST_morechildren(self):
"""Wrapper around `GET_morechildren` for backwards-compatibility"""
return self.GET_morechildren()
@validatedForm(VUser(),
VModhash(),
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:
# send the user a message if they don't already have gold
if not c.user.gold:
subject = "You claimed a reddit gold code!"
message = strings.gold_claimed_code
message += "\n\n" + strings.gold_benefits_msg
if g.lounge_reddit:
message += "\n* " + strings.lounge_msg
message = append_random_bottlecap_phrase(message)
try:
send_system_message(c.user, subject, message,
distinguished='gold-auto')
except MessageError:
g.log.error('claimgold: could not send system message')
admintools.adjust_gold_expiration(c.user, days=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)
@csrf_exempt
@validatedForm(
VRatelimit(rate_ip=True, prefix="rate_password_"),
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
elif form.has_errors('ratelimit', errors.RATELIMIT):
return
else:
VRatelimit.ratelimit(rate_ip=True, prefix="rate_password_")
if emailer.password_email(user):
form.set_text(".status",
_("an email will be sent to that account's address shortly"))
else:
form.set_text(".status", _("try again tomorrow"))
@csrf_exempt
@validatedForm(token=VOneTimeToken(PasswordResetToken, "key"),
password=VPasswordChange(["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()