diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 6c7d376de..058875be4 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -224,6 +224,10 @@ def make_map(global_conf={}, app_conf={}): requirements=dict(action="new_captcha")) mc('/api/:action', controller='api') + mc("/api/v1/:action", controller="oauth2frontend", requirements=dict(action="authorize")) + mc("/api/v1/:action", controller="oauth2access", requirements=dict(action="access_token")) + mc("/api/v1/:action", controller="apiv1") + mc("/button_info", controller="api", action="info", limit = 1) mc('/captcha/:iden', controller='captcha', action='captchaimg') diff --git a/r2/r2/controllers/__init__.py b/r2/r2/controllers/__init__.py index 73c824a58..f3e27e933 100644 --- a/r2/r2/controllers/__init__.py +++ b/r2/r2/controllers/__init__.py @@ -61,6 +61,9 @@ except ImportError: from api import ApiController from api import ApiminimalController +from apiv1 import APIv1Controller as Apiv1Controller +from oauth2 import OAuth2FrontendController as Oauth2frontendController +from oauth2 import OAuth2AccessController as Oauth2accessController from admin import AdminController from redirect import RedirectController from ipn import IpnController diff --git a/r2/r2/controllers/apiv1.py b/r2/r2/controllers/apiv1.py new file mode 100644 index 000000000..5a6745412 --- /dev/null +++ b/r2/r2/controllers/apiv1.py @@ -0,0 +1,33 @@ +# 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 CondeNet, Inc. +# +# All portions of the code written by CondeNet are Copyright (c) 2006-2010 +# CondeNet, Inc. All Rights Reserved. +################################################################################ +from pylons import c +from r2.controllers.oauth2 import OAuth2ResourceController, require_oauth2_scope +from r2.lib.jsontemplates import IdentityJsonTemplate + +class APIv1Controller(OAuth2ResourceController): + def try_pagecache(self): + pass + + @require_oauth2_scope("identity") + def GET_me(self): + resp = IdentityJsonTemplate().data(c.oauth_user) + return self.api_wrapper(resp) diff --git a/r2/r2/controllers/errors.py b/r2/r2/controllers/errors.py index 23292472b..2d7826fd8 100644 --- a/r2/r2/controllers/errors.py +++ b/r2/r2/controllers/errors.py @@ -85,6 +85,8 @@ error_list = dict(( ('TOO_OLD', _("that's a piece of history now; it's too late to reply to it")), ('BAD_CSS_NAME', _('invalid css name')), ('TOO_MUCH_FLAIR_CSS', _('too many flair css classes')), + ('OAUTH2_INVALID_CLIENT', _('invalid client id')), + ('OAUTH2_ACCESS_DENIED', _('access denied by the user')), )) errors = Storage([(e, e) for e in error_list.keys()]) diff --git a/r2/r2/controllers/oauth2.py b/r2/r2/controllers/oauth2.py new file mode 100644 index 000000000..07d4c0012 --- /dev/null +++ b/r2/r2/controllers/oauth2.py @@ -0,0 +1,215 @@ +# 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 CondeNet, Inc. +# +# All portions of the code written by CondeNet are Copyright (c) 2006-2010 +# CondeNet, Inc. All Rights Reserved. +################################################################################ +from urllib import urlencode +import base64 +import simplejson + +from pylons import c, g, request +from pylons.controllers.util import abort +from pylons.i18n import _ +from r2.config.extensions import set_extension +from reddit_base import RedditController, MinimalController, require_https +from r2.lib.db.thing import NotFound +from r2.models import Account +from r2.models.oauth2 import OAuth2Client, OAuth2AuthorizationCode, OAuth2AccessToken +from r2.controllers.errors import errors +from validator import validate, VRequired, VOneOf, VUrl, VUser, VModhash +from r2.lib.pages import OAuth2AuthorizationPage +from r2.lib.require import RequirementException, require, require_split + +scope_info = { + "identity": { + "id": "identity", + "name": _("My Identity"), + "description": _("Access my reddit username and signup date.") + } +} + +class VClientID(VRequired): + default_param = "client_id" + def __init__(self, param=None, *a, **kw): + VRequired.__init__(self, param, errors.OAUTH2_INVALID_CLIENT, *a, **kw) + + def run(self, client_id): + if not client_id: + return self.error() + + client = OAuth2Client.get_token(client_id) + if client: + return client + else: + return self.error() + +class OAuth2FrontendController(RedditController): + def pre(self): + RedditController.pre(self) + require_https() + + def _check_redirect_uri(self, client, redirect_uri): + if not redirect_uri or not client or redirect_uri != client.redirect_uri: + abort(403) + + def _error_response(self, resp): + if (errors.OAUTH2_INVALID_CLIENT, "client_id") in c.errors: + resp["error"] = "unauthorized_client" + elif (errors.OAUTH2_ACCESS_DENIED, "authorize") in c.errors: + resp["error"] = "access_denied" + elif (errors.BAD_HASH, None) in c.errors: + resp["error"] = "access_denied" + elif (errors.INVALID_OPTION, "response_type") in c.errors: + resp["error"] = "unsupported_response_type" + elif (errors.INVALID_OPTION, "scope") in c.errors: + resp["error"] = "invalid_scope" + else: + resp["error"] = "invalid_request" + + @validate(VUser(), + response_type = VOneOf("response_type", ("code",)), + client = VClientID(), + redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False), + scope = VOneOf("scope", scope_info.keys()), + state = VRequired("state", errors.NO_TEXT)) + def GET_authorize(self, response_type, client, redirect_uri, scope, state): + self._check_redirect_uri(client, redirect_uri) + + resp = {} + if not c.errors: + c.deny_frames = True + return OAuth2AuthorizationPage(client, redirect_uri, scope_info[scope], state).render() + else: + self._error_response(resp) + return self.redirect(redirect_uri+"?"+urlencode(resp), code=302) + + @validate(VUser(), + VModhash(fatal=False), + client = VClientID(), + redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False), + scope = VOneOf("scope", scope_info.keys()), + state = VRequired("state", errors.NO_TEXT), + authorize = VRequired("authorize", errors.OAUTH2_ACCESS_DENIED)) + def POST_authorize(self, authorize, client, redirect_uri, scope, state): + self._check_redirect_uri(client, redirect_uri) + + resp = {} + if state: + resp["state"] = state + + if not c.errors: + code = OAuth2AuthorizationCode._new(client._id, redirect_uri, c.user._id, scope) + resp["code"] = code._id + else: + self._error_response(resp) + + return self.redirect(redirect_uri+"?"+urlencode(resp), code=302) + +class OAuth2AccessController(MinimalController): + def pre(self): + set_extension(request.environ, "json") + MinimalController.pre(self) + require_https() + c.oauth2_client = self._get_client_auth() + + def _get_client_auth(self): + auth = request.headers.get("Authorization") + try: + auth_scheme, auth_token = require_split(auth, 2) + require(auth_scheme.lower() == "basic") + try: + auth_data = base64.b64decode(auth_token) + except TypeError: + raise RequirementException + client_id, client_secret = require_split(auth_data, 2, ":") + client = OAuth2Client.get_token(client_id) + require(client) + require(client.secret == client_secret) + return client + except RequirementException: + abort(401, headers=[("WWW-Authenticate", 'Basic realm="reddit"')]) + + @validate(grant_type = VOneOf("grant_type", ("authorization_code",)), + code = VRequired("code", errors.NO_TEXT), + redirect_uri = VUrl("redirect_uri", allow_self=False, lookup=False)) + def POST_access_token(self, grant_type, code, redirect_uri): + resp = {} + if not c.errors: + auth_token = OAuth2AuthorizationCode.use_token(code, c.oauth2_client._id, redirect_uri) + if auth_token: + access_token = OAuth2AccessToken._new(auth_token.user_id, auth_token.scope) + resp["access_token"] = access_token._id + resp["token_type"] = access_token.token_type + resp["expires_in"] = access_token._ttl + resp["scope"] = auth_token.scope + else: + resp["error"] = "invalid_grant" + else: + if (errors.INVALID_OPTION, "grant_type") in c.errors: + resp["error"] = "unsupported_grant_type" + elif (errors.INVALID_OPTION, "scope") in c.errors: + resp["error"] = "invalid_scope" + else: + resp["error"] = "invalid_request" + + return self.api_wrapper(resp) + +class OAuth2ResourceController(MinimalController): + def pre(self): + set_extension(request.environ, "json") + MinimalController.pre(self) + require_https() + + try: + access_token = self._get_bearer_token() + require(access_token) + c.oauth2_access_token = access_token + account = Account._byID(access_token.user_id, data=True) + require(account) + require(not account._deleted) + c.oauth_user = account + except RequirementException: + self._auth_error(401, "invalid_token") + + handler = self._get_action_handler() + if handler: + oauth2_perms = getattr(handler, "oauth2_perms", None) + if oauth2_perms: + if access_token.scope not in oauth2_perms["allowed_scopes"]: + self._auth_error(403, "insufficient_scope") + else: + self._auth_error(400, "invalid_request") + + def _auth_error(self, code, error): + abort(code, headers=[("WWW-Authenticate", 'Bearer realm="reddit", error="%s"' % error)]) + + def _get_bearer_token(self): + auth = request.headers.get("Authorization") + try: + auth_scheme, bearer_token = require_split(auth, 2) + require(auth_scheme.lower() == "bearer") + return OAuth2AccessToken.get_token(bearer_token) + except RequirementException: + self._auth_error(400, "invalid_request") + +def require_oauth2_scope(*scopes): + def oauth2_scope_wrap(fn): + fn.oauth2_perms = {"allowed_scopes": scopes} + return fn + return oauth2_scope_wrap diff --git a/r2/r2/controllers/reddit_base.py b/r2/r2/controllers/reddit_base.py index a1bad4cbd..abe1254aa 100644 --- a/r2/r2/controllers/reddit_base.py +++ b/r2/r2/controllers/reddit_base.py @@ -504,6 +504,10 @@ def cross_domain(origin_check=is_trusted_origin, **options): return cross_domain_handler return cross_domain_wrap +def require_https(): + if not c.secure: + abort(403) + class MinimalController(BaseController): allow_stylesheets = False @@ -539,6 +543,8 @@ class MinimalController(BaseController): c.domain_prefix = request.environ.get("reddit-domain-prefix", g.domain_prefix) + c.secure = request.host in g.secure_domains + #check if user-agent needs a dose of rate-limiting if not c.error_page: ratelimit_agents() @@ -697,7 +703,6 @@ class MinimalController(BaseController): return c.response - class RedditController(MinimalController): @staticmethod @@ -728,7 +733,6 @@ class RedditController(MinimalController): #can't handle broken cookies request.environ['HTTP_COOKIE'] = '' - c.secure = request.host in g.secure_domains c.firsttime = firsttime() # the user could have been logged in via one of the feeds diff --git a/r2/r2/lib/db/tdb_cassandra.py b/r2/r2/lib/db/tdb_cassandra.py index e4d57b915..3e45b08dd 100644 --- a/r2/r2/lib/db/tdb_cassandra.py +++ b/r2/r2/lib/db/tdb_cassandra.py @@ -201,6 +201,7 @@ class ThingBase(object): # updated columns will be present.) This is an expected convention # and is not enforced. _ttl = None + _warn_on_partial_ttl = True # A per-class dictionary of default TTLs that new columns of this # class should have @@ -463,7 +464,7 @@ class ThingBase(object): if self._id is None: raise TdbException("Can't commit %r without an ID" % (self,)) - if self._committed and self._ttl: + if self._committed and self._ttl and self._warn_on_partial_ttl: log.warning("Using a full-TTL object %r in a mutable fashion" % (self,)) diff --git a/r2/r2/lib/jsontemplates.py b/r2/r2/lib/jsontemplates.py index e7684da30..635725b7d 100644 --- a/r2/r2/lib/jsontemplates.py +++ b/r2/r2/lib/jsontemplates.py @@ -211,16 +211,19 @@ class SubredditJsonTemplate(ThingJsonTemplate): else: return ThingJsonTemplate.thing_attr(self, thing, attr) -class AccountJsonTemplate(ThingJsonTemplate): +class IdentityJsonTemplate(ThingJsonTemplate): _data_attrs_ = ThingJsonTemplate.data_attrs(name = "name", link_karma = "safe_karma", comment_karma = "comment_karma", - has_mail = "has_mail", - has_mod_mail = "has_mod_mail", - is_mod = "is_mod", is_gold = "gold" ) +class AccountJsonTemplate(IdentityJsonTemplate): + _data_attrs_ = IdentityJsonTemplate.data_attrs(has_mail = "has_mail", + has_mod_mail = "has_mod_mail", + is_mod = "is_mod", + ) + def thing_attr(self, thing, attr): from r2.models import Subreddit if attr == "has_mail": diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 610801cdc..12e98bfd5 100644 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -25,6 +25,7 @@ from r2.models import FakeSubreddit, Subreddit, Ad, AdSR from r2.models import Friends, All, Sub, NotFound, DomainSR, Random, Mod, RandomNSFW, MultiReddit from r2.models import Link, Printable, Trophy, bidding, PromotionWeights, Comment from r2.models import Flair, FlairTemplate, FlairTemplateBySubredditIndex +from r2.models.oauth2 import OAuth2Client from r2.config import cache from r2.lib.tracking import AdframeInfo from r2.lib.jsonresponse import json_respond @@ -683,11 +684,20 @@ class LoginPage(BoringPage): title = _("login or register") BoringPage.__init__(self, title, **context) + if self.dest: + u = UrlParser(self.dest) + # Display a preview message for OAuth2 client authorizations + if u.path == '/api/v1/authorize': + client_id = u.query_dict.get("client_id") + self.client = client_id and OAuth2Client.get_token(client_id) + self.infobar = self.client and ClientInfoBar(self.client, strings.oauth_login_msg) + def content(self): kw = {} for x in ('user_login', 'user_reg'): kw[x] = getattr(self, x) if hasattr(self, x) else '' - return self.login_template(dest = self.dest, **kw) + login_content = self.login_template(dest = self.dest, **kw) + return self.content_stack((self.infobar, login_content)) @classmethod def login_template(cls, **kw): @@ -707,7 +717,19 @@ class Login(Templated): class Register(Login): pass - + +class OAuth2AuthorizationPage(BoringPage): + def __init__(self, client, redirect_uri, scope, state): + content = OAuth2Authorization(client=client, + redirect_uri=redirect_uri, + scope=scope, + state=state) + BoringPage.__init__(self, _("request for permission"), + show_sidebar=False, content=content) + +class OAuth2Authorization(Templated): + pass + class SearchPage(BoringPage): """Search results page""" searchbox = False @@ -1213,6 +1235,12 @@ class InfoBar(Templated): def __init__(self, message = '', extra_class = ''): Templated.__init__(self, message = message, extra_class = extra_class) +class ClientInfoBar(InfoBar): + """Draws the message the top of a login page before OAuth2 authorization""" + def __init__(self, client, *args, **kwargs): + kwargs.setdefault("extra_class", "client-info") + InfoBar.__init__(self, *args, **kwargs) + self.client = client class RedditError(BoringPage): site_tracking = False diff --git a/r2/r2/lib/require.py b/r2/r2/lib/require.py new file mode 100644 index 000000000..18e48ff8e --- /dev/null +++ b/r2/r2/lib/require.py @@ -0,0 +1,19 @@ +class RequirementException(Exception): + pass + +def require(val): + """A safe version of assert + + Assert can be stripped out if python is run in an optimized + mode. This function implements assertions in a way that is + guaranteed to execute. + """ + if not val: + raise RequirementException + return val + +def require_split(s, length, sep=None): + require(s) + res = s.split(sep) + require(len(res) == length) + return res diff --git a/r2/r2/lib/strings.py b/r2/r2/lib/strings.py index fed21da30..80f42cdae 100644 --- a/r2/r2/lib/strings.py +++ b/r2/r2/lib/strings.py @@ -73,6 +73,8 @@ string_dict = dict( cover_msg = _("you'll need to login or register to do that"), cover_disclaim = _("(don't worry, it only takes a few seconds)"), + oauth_login_msg = _("Log in or register to connect your reddit account with [%(app_name)s](%(app_about_url)s)."), + legal = _("I understand and agree that registration on or use of this site constitutes agreement to its %(user_agreement)s and %(privacy_policy)s."), friends = _('to view reddit with only submissions from your friends, use [reddit.com/r/friends](%s)'), diff --git a/r2/r2/models/__init__.py b/r2/r2/models/__init__.py index d563c8e67..ff1339fed 100644 --- a/r2/r2/models/__init__.py +++ b/r2/r2/models/__init__.py @@ -35,3 +35,4 @@ from bidding import * from mail_queue import Email, has_opted_out, opt_count from gold import * from admintools import * +from oauth2 import * diff --git a/r2/r2/models/oauth2.py b/r2/r2/models/oauth2.py new file mode 100644 index 000000000..20187b07c --- /dev/null +++ b/r2/r2/models/oauth2.py @@ -0,0 +1,136 @@ +# 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 CondeNet, Inc. +# +# All portions of the code written by CondeNet are Copyright (c) 2006-2010 +# CondeNet, Inc. All Rights Reserved. +################################################################################ +from os import urandom +from base64 import urlsafe_b64encode +from r2.lib.db import tdb_cassandra + +def generate_token(size): + return urlsafe_b64encode(urandom(size)).rstrip('=') + +class OAuth2Token(tdb_cassandra.Thing): + """An OAuth2 authorization code for completing authorization flow""" + + @classmethod + def _new(cls, **kwargs): + if "_id" not in kwargs: + kwargs["_id"] = cls._generate_unique_token() + + client = cls(**kwargs) + client._commit() + return client + + @classmethod + def _generate_unique_token(cls): + for i in range(3): + token = generate_token(cls.token_size) + try: + cls._byID(token) + except tdb_cassandra.NotFound: + return token + else: + continue + raise ValueError + + @classmethod + def get_token(cls, _id): + try: + return cls._byID(_id) + except tdb_cassandra.NotFound: + return False + +class OAuth2Client(OAuth2Token): + """A client registered for OAuth2 access""" + token_size = 10 + client_secret_size = 20 + _defaults = dict(name="", + description="", + about_url="", + icon_url="", + secret="", + redirect_uri="", + ) + _use_db = True + _use_new_ring = True + + @classmethod + def _new(cls, **kwargs): + if "secret" not in kwargs: + kwargs["secret"] = generate_token(cls.client_secret_size) + return super(OAuth2Client, cls)._new(**kwargs) + +class OAuth2AuthorizationCode(OAuth2Token): + """An OAuth2 authorization code for completing authorization flow""" + token_size = 20 + _ttl = 10*60 + _defaults = dict(client_id="", + redirect_uri="", + scope="", + used=False, + ) + _bool_props = ("used",) + _int_props = ("user_id",) + _warn_on_partial_ttl = False + _use_db = True + _use_new_ring = True + + @classmethod + def _new(cls, client_id, redirect_uri, user_id, scope): + return super(OAuth2AuthorizationCode, cls)._new( + client_id=client_id, + redirect_uri=redirect_uri, + user_id=user_id, + scope=scope) + + @classmethod + def get_token(cls, _id): + token = super(OAuth2AuthorizationCode, cls).get_token(_id) + if token and not token.used: + return token + else: + return False + + @classmethod + def use_token(cls, _id, client_id, redirect_uri): + token = cls.get_token(_id) + if token and token.client_id == client_id and token.redirect_uri == redirect_uri: + token.used = True + token._commit() + return token + else: + return False + +class OAuth2AccessToken(OAuth2Token): + """An OAuth2 access token for accessing protected resources""" + token_size = 20 + _ttl = 10*60 + _defaults = dict(scope="", + token_type="bearer", + ) + _int_props = ("user_id",) + _use_db = True + _use_new_ring = True + + @classmethod + def _new(cls, user_id, scope): + return super(OAuth2AccessToken, cls)._new( + user_id=user_id, + scope=scope) diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index 28e7349d7..87f531673 100644 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -4755,3 +4755,140 @@ tr.gold-accent + tr > td { box-shadow: inset 0px 1px 1px hsla(0,0%,0%,.3), 0px 1px 0px hsla(0,0%,100%,.6); } +.infobar.client-info { + position: relative; + margin: 10px 2%; + width: 94%; + height: 48px; +} + +.infobar.client-info .icon img { + position: absolute; + left: 10px; + width: 48px; + height: 48px; +} + +.infobar.client-info .md { + line-height: 48px; + margin-left: 56px; +} + +.infobar.client-info .md p { + margin: 0; +} + +.oauth2-authorize { + position: relative; + background: url(/static/snoo-tray.png) no-repeat; + width: 415px; + height: 235px; + margin: 40px auto 0; + padding-left: 280px; + padding-top: 18px; +} + +.oauth2-authorize h1 a { + display: block; + font-weight: bold; + font-size: 1.5em; + letter-spacing: -.04em; +} + +.oauth2-authorize .icon { + position: absolute; + left: 160px; + top: 77px; +} + +.oauth2-authorize .access, +.infobar.client-info { + background: #f7f7f7; + border: 1px solid #b3b3b3; +} + +.oauth2-authorize .access { + position: relative; + margin: 0 -12px; + padding: 10px 15px; + font-size: 1.5em; + line-height: 1.5em; +} + +.oauth2-authorize .access:before { + position: absolute; + display: block; + content: ''; + border-width: 9px; + border-style: solid solid outset; /* mitigates firefox drawing a thicker arrow */ + border-color: transparent #b3b3b3 transparent transparent; + left: -19px; + top: 13px; +} + +.oauth2-authorize .access:after { + position: absolute; + display: block; + content: ''; + border: 9px solid; + border-color: transparent #f7f7f7 transparent transparent; + left: -18px; + top: 13px; +} + +.oauth2-authorize h2 { + font-size: 1em; + font-weight: normal; + color: black; +} + +.oauth2-authorize ul { + list-style-type: disc; + padding-left: 25px; +} + +.oauth2-authorize .notice { + color: #333; + font-size: .85em; + margin: .5em 0; +} + +.oauth2-authorize .fancybutton { + margin: 0; + margin-right: 1em; + cursor: pointer; +} + +.oauth2-authorize .fancybutton.allow { + color: white; + background: #ff4500; + border-color: #541700; + box-shadow: inset 0px 1px 0px rgba(255, 255, 255, .25); + text-shadow: 0px 1px 0px rgba(0, 0, 0, .7); +} + +.oauth2-authorize .fancybutton.allow:hover { + background: #ff571a; +} + +.oauth2-authorize .fancybutton.allow:active { + background: #eb3f00; + box-shadow: inset 0px -1px 0px rgba(255, 255, 255, .25); +} + +.oauth2-authorize .fancybutton.decline { + color: black; + background: #eee; + border-color: #555; + box-shadow: inset 0px 1px 0px rgba(255, 255, 255, .5); + text-shadow: 0px 1px 0px rgba(255, 255, 255, .7); +} + +.oauth2-authorize .fancybutton.decline:hover { + background: #f7f7f7; +} + +.oauth2-authorize .fancybutton.decline:active { + background: #e4e4e4; + box-shadow: inset 0px -1px 0px white; +} diff --git a/r2/r2/public/static/snoo-tray.png b/r2/r2/public/static/snoo-tray.png new file mode 100644 index 000000000..3a585ad54 Binary files /dev/null and b/r2/r2/public/static/snoo-tray.png differ diff --git a/r2/r2/templates/clientinfobar.html b/r2/r2/templates/clientinfobar.html new file mode 100644 index 000000000..1b4a80f7e --- /dev/null +++ b/r2/r2/templates/clientinfobar.html @@ -0,0 +1,30 @@ +## 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 CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2010 +## CondeNet, Inc. All Rights Reserved. +################################################################################ +<%! + from r2.lib.template_helpers import s3_https_if_secure + from r2.lib.filters import safemarkdown +%> +<%namespace file="utils.html" import="img_link"/> +
diff --git a/r2/r2/templates/oauth2authorization.html b/r2/r2/templates/oauth2authorization.html new file mode 100644 index 000000000..950602156 --- /dev/null +++ b/r2/r2/templates/oauth2authorization.html @@ -0,0 +1,49 @@ +## 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 CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2010 +## CondeNet, Inc. All Rights Reserved. +################################################################################ +<%! + from r2.lib.template_helpers import static, s3_https_if_secure + from r2.lib.filters import safemarkdown +%> + +