This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/endpoints/web.py
Matt Jibson f4b57eff96 Set and use ETag headers
Also set no-cache exactly as github recommends. The removed @no_cache
decorater used "Cache-Control:no-cache, no-store, must-revalidate", but
just no-cache should be sufficient, and should certainly work correctly
with github.

See: https://github.com/github/markup/issues/224#issuecomment-48532178

fixes #712
2015-11-06 12:15:15 -05:00

675 lines
19 KiB
Python

import logging
from flask import (abort, redirect, request, url_for, make_response, Response,
Blueprint, send_from_directory, jsonify, send_file)
from flask.ext.login import current_user
from urlparse import urlparse
from health.healthcheck import get_healthchecker
from data import model
from data.database import db
from app import app, billing as stripe, build_logs, avatar, signer, log_archive, config_provider
from auth.auth import require_session_login, process_oauth
from auth.permissions import (AdministerOrganizationPermission, ReadRepositoryPermission,
SuperUserPermission, AdministerRepositoryPermission,
ModifyRepositoryPermission)
from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot
from util.cache import no_cache
from endpoints.common import common_login, render_page_template, route_show_if, param_required
from endpoints.decorators import anon_protect
from endpoints.csrf import csrf_protect, generate_csrf_token, verify_csrf
from buildtrigger.customhandler import CustomBuildTrigger
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
from buildtrigger.triggerutil import TriggerProviderException
from buildtrigger.basehandler import BuildTriggerHandler
from util.names import parse_repository_name, parse_repository_name_and_tag
from util.useremails import send_email_changed
from util.systemlogs import build_logs_archive
from util.headers import parse_basic_auth
from auth import scopes
import features
import json
logger = logging.getLogger(__name__)
# Capture the unverified SSL errors.
logging.captureWarnings(True)
web = Blueprint('web', __name__)
STATUS_TAGS = app.config['STATUS_TAGS']
@web.route('/', methods=['GET'], defaults={'path': ''})
@no_cache
def index(path, **kwargs):
return render_page_template('index.html', **kwargs)
@web.route('/500', methods=['GET'])
def internal_error_display():
return render_page_template('500.html')
@web.errorhandler(404)
@web.route('/404', methods=['GET'])
def not_found_error_display(e = None):
resp = render_page_template('404.html')
resp.status_code = 404
return resp
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache
def org_view(path):
return index('')
@web.route('/user/<path:path>', methods=['GET'])
@no_cache
def user_view(path):
return index('')
@web.route('/snapshot', methods=['GET'])
@web.route('/snapshot/', methods=['GET'])
@web.route('/snapshot/<path:path>', methods=['GET'])
def snapshot(path = ''):
parsed = urlparse(request.url)
final_url = '%s://%s/%s' % (parsed.scheme, 'localhost', path)
result = render_snapshot(final_url)
if result:
return result
abort(404)
@web.route('/aci-signing-key')
@no_cache
@anon_protect
def aci_signing_key():
if not signer.name:
abort(404)
return send_file(signer.public_key_path)
@web.route('/plans/')
@no_cache
@route_show_if(features.BILLING)
def plans():
return index('')
@web.route('/guide/')
@no_cache
def guide():
return index('')
@web.route('/tour/')
@web.route('/tour/<path:path>')
@no_cache
def tour(path = ''):
return index('')
@web.route('/tutorial/')
@no_cache
def tutorial():
return index('')
@web.route('/organizations/')
@web.route('/organizations/new/')
@no_cache
def organizations():
return index('')
@web.route('/user/')
@no_cache
def user():
return index('')
@web.route('/superuser/')
@no_cache
@route_show_if(features.SUPER_USERS)
def superuser():
return index('')
@web.route('/setup/')
@no_cache
@route_show_if(features.SUPER_USERS)
def setup():
return index('')
@web.route('/signin/')
@no_cache
def signin(redirect=None):
return index('')
@web.route('/contact/')
@no_cache
def contact():
return index('')
@web.route('/about/')
@no_cache
def about():
return index('')
@web.route('/new/')
@no_cache
def new():
return index('')
@web.route('/confirminvite')
@no_cache
def confirm_invite():
code = request.values['code']
return index('', code=code)
@web.route('/repository/', defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET'])
@no_cache
def repository(path):
return index('')
@web.route('/starred/')
@no_cache
def starred():
return index('')
@web.route('/security/')
@no_cache
def security():
return index('')
@web.route('/__exp/<expname>')
@no_cache
def exp(expname):
return index('')
@web.route('/v1')
@web.route('/v1/')
@no_cache
def v1():
return index('')
# TODO(jschorr): Remove this mirrored endpoint once we migrate ELB.
@web.route('/health', methods=['GET'])
@web.route('/health/instance', methods=['GET'])
@no_cache
def instance_health():
checker = get_healthchecker(app, config_provider)
(data, status_code) = checker.check_instance()
response = jsonify(dict(data=data, status_code=status_code))
response.status_code = status_code
return response
# TODO(jschorr): Remove this mirrored endpoint once we migrate pingdom.
@web.route('/status', methods=['GET'])
@web.route('/health/endtoend', methods=['GET'])
@no_cache
def endtoend_health():
checker = get_healthchecker(app, config_provider)
(data, status_code) = checker.check_endtoend()
response = jsonify(dict(data=data, status_code=status_code))
response.status_code = status_code
return response
@web.route('/health/dbrevision', methods=['GET'])
@route_show_if(features.BILLING) # Since this is only used in production.
@no_cache
def dbrevision_health():
# Find the revision from the database.
result = db.execute_sql('select * from alembic_version limit 1').fetchone()
db_revision = result[0]
# Find the local revision from the file system.
with open('ALEMBIC_HEAD', 'r') as f:
local_revision = f.readline().split(' ')[0]
data = {
'db_revision': db_revision,
'local_revision': local_revision,
}
status_code = 200 if db_revision == local_revision else 400
response = jsonify(dict(data=data, status_code=status_code))
response.status_code = status_code
return response
@web.route('/tos', methods=['GET'])
@no_cache
def tos():
return render_page_template('tos.html')
@web.route('/disclaimer', methods=['GET'])
@no_cache
def disclaimer():
return render_page_template('disclaimer.html')
@web.route('/privacy', methods=['GET'])
@no_cache
def privacy():
return render_page_template('privacy.html')
@web.route('/robots.txt', methods=['GET'])
@no_cache
def robots():
return send_from_directory('static', 'robots.txt')
@web.route('/buildlogs/<build_uuid>', methods=['GET'])
@route_show_if(features.BUILD_SUPPORT)
@require_session_login
def buildlogs(build_uuid):
found_build = model.build.get_repository_build(build_uuid)
if not found_build:
abort(403)
repo = found_build.repository
if not ModifyRepositoryPermission(repo.namespace_user.username, repo.name).can():
abort(403)
# If the logs have been archived, just return a URL of the completed archive
if found_build.logs_archived:
return redirect(log_archive.get_file_url(found_build.uuid))
_, logs = build_logs.get_log_entries(found_build.uuid, 0)
response = jsonify({
'logs': [log for log in logs]
})
response.headers["Content-Disposition"] = "attachment;filename=" + found_build.uuid + ".json"
return response
@web.route('/receipt', methods=['GET'])
@route_show_if(features.BILLING)
@require_session_login
def receipt():
if not current_user.is_authenticated():
abort(401)
return
invoice_id = request.args.get('id')
if invoice_id:
invoice = stripe.Invoice.retrieve(invoice_id)
if invoice:
user_or_org = model.user.get_user_or_org_by_customer_id(invoice.customer)
if user_or_org:
if user_or_org.organization:
admin_org = AdministerOrganizationPermission(user_or_org.username)
if not admin_org.can():
abort(404)
return
else:
if not user_or_org.username == current_user.db_user().username:
abort(404)
return
file_data = renderInvoiceToPdf(invoice, user_or_org)
return Response(file_data,
mimetype="application/pdf",
headers={"Content-Disposition": "attachment;filename=receipt.pdf"})
abort(404)
@web.route('/authrepoemail', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_repo_email():
code = request.values['code']
record = None
try:
record = model.repository.confirm_email_authorization_for_repo(code)
except model.DataModelException as ex:
return render_page_template('confirmerror.html', error_message=ex.message)
message = """
Your E-mail address has been authorized to receive notifications for repository
<a href="%s://%s/repository/%s/%s">%s/%s</a>.
""" % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'],
record.repository.namespace_user.username, record.repository.name,
record.repository.namespace_user.username, record.repository.name)
return render_page_template('message.html', message=message)
@web.route('/confirm', methods=['GET'])
@route_show_if(features.MAILING)
def confirm_email():
code = request.values['code']
user = None
new_email = None
try:
user, new_email, old_email = model.user.confirm_user_email(code)
except model.DataModelException as ex:
return render_page_template('confirmerror.html', error_message=ex.message)
if new_email:
send_email_changed(user.username, old_email, new_email)
common_login(user)
return redirect(url_for('web.user', tab='email')
if new_email else url_for('web.index'))
@web.route('/recovery', methods=['GET'])
def confirm_recovery():
code = request.values['code']
user = model.user.validate_reset_code(code)
if user:
common_login(user)
return redirect(url_for('web.user'))
else:
abort(403)
@web.route('/repository/<path:repository>/status', methods=['GET'])
@parse_repository_name
@anon_protect
def build_status_badge(namespace, repository):
token = request.args.get('token', None)
is_public = model.repository.repository_is_public(namespace, repository)
if not is_public:
repo = model.repository.get_repository(namespace, repository)
if not repo or token != repo.badge_token:
abort(404)
# Lookup the tags for the repository.
tags = model.tag.list_repository_tags(namespace, repository)
is_empty = len(list(tags)) == 0
recent_build = model.build.get_recent_repository_build(namespace, repository)
if not is_empty and (not recent_build or recent_build.phase == 'complete'):
status_name = 'ready'
elif recent_build and recent_build.phase == 'error':
status_name = 'failed'
elif recent_build and recent_build.phase != 'complete':
status_name = 'building'
else:
status_name = 'none'
if request.headers.get('If-None-Match') == status_name:
return Response(status=304)
response = make_response(STATUS_TAGS[status_name])
response.content_type = 'image/svg+xml'
response.headers['Cache-Control'] = 'no-cache'
response.headers['ETag'] = status_name
return response
class FlaskAuthorizationProvider(model.oauth.DatabaseAuthorizationProvider):
def get_authorized_user(self):
return current_user.db_user()
def _make_response(self, body='', headers=None, status_code=200):
return make_response(body, status_code, headers)
@web.route('/oauth/authorizeapp', methods=['POST'])
@csrf_protect
def authorize_application():
if not current_user.is_authenticated():
abort(401)
return
provider = FlaskAuthorizationProvider()
client_id = request.form.get('client_id', None)
redirect_uri = request.form.get('redirect_uri', None)
scope = request.form.get('scope', None)
# Add the access token.
return provider.get_token_response('token', client_id, redirect_uri, scope=scope)
@web.route(app.config['LOCAL_OAUTH_HANDLER'], methods=['GET'])
def oauth_local_handler():
if not current_user.is_authenticated():
abort(401)
return
if not request.args.get('scope'):
return render_page_template("message.html", message="Authorization canceled")
else:
return render_page_template("generatedtoken.html")
@web.route('/oauth/denyapp', methods=['POST'])
@csrf_protect
def deny_application():
if not current_user.is_authenticated():
abort(401)
return
provider = FlaskAuthorizationProvider()
client_id = request.form.get('client_id', None)
redirect_uri = request.form.get('redirect_uri', None)
scope = request.form.get('scope', None)
# Add the access token.
return provider.get_auth_denied_response('token', client_id, redirect_uri, scope=scope)
@web.route('/oauth/authorize', methods=['GET'])
@no_cache
@param_required('client_id')
@param_required('redirect_uri')
@param_required('scope')
def request_authorization_code():
provider = FlaskAuthorizationProvider()
response_type = request.args.get('response_type', 'code')
client_id = request.args.get('client_id', None)
redirect_uri = request.args.get('redirect_uri', None)
scope = request.args.get('scope', None)
if (not current_user.is_authenticated() or
not provider.validate_has_scopes(client_id, current_user.db_user().username, scope)):
if not provider.validate_redirect_uri(client_id, redirect_uri):
current_app = provider.get_application_for_client_id(client_id)
if not current_app:
abort(404)
return provider._make_redirect_error_response(current_app.redirect_uri,
'redirect_uri_mismatch')
# Load the scope information.
scope_info = scopes.get_scope_information(scope)
if not scope_info:
abort(404)
return
# Load the application information.
oauth_app = provider.get_application_for_client_id(client_id)
app_email = oauth_app.avatar_email or oauth_app.organization.email
oauth_app_view = {
'name': oauth_app.name,
'description': oauth_app.description,
'url': oauth_app.application_uri,
'avatar': json.dumps(avatar.get_data(oauth_app.name, app_email, 'app')),
'organization': {
'name': oauth_app.organization.username,
'avatar': json.dumps(avatar.get_data_for_org(oauth_app.organization))
}
}
# Show the authorization page.
has_dangerous_scopes = any([check_scope['dangerous'] for check_scope in scope_info])
return render_page_template('oauthorize.html', scopes=scope_info,
has_dangerous_scopes=has_dangerous_scopes,
application=oauth_app_view,
enumerate=enumerate, client_id=client_id,
redirect_uri=redirect_uri, scope=scope,
csrf_token_val=generate_csrf_token())
if response_type == 'token':
return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope)
else:
return provider.get_authorization_code(response_type, client_id, redirect_uri, scope=scope)
@web.route('/oauth/access_token', methods=['POST'])
@no_cache
@param_required('grant_type', allow_body=True)
@param_required('client_id', allow_body=True)
@param_required('redirect_uri', allow_body=True)
@param_required('code', allow_body=True)
@param_required('scope', allow_body=True)
def exchange_code_for_token():
grant_type = request.values.get('grant_type', None)
client_id = request.values.get('client_id', None)
client_secret = request.values.get('client_id', None)
redirect_uri = request.values.get('redirect_uri', None)
code = request.values.get('code', None)
scope = request.values.get('scope', None)
# Sometimes OAuth2 clients place the client id/secret in the Auth header.
basic_header = parse_basic_auth(request.headers.get('Authorization'))
if basic_header is not None:
client_id = basic_header[0] or client_id
client_secret = basic_header[1] or client_secret
provider = FlaskAuthorizationProvider()
return provider.get_token(grant_type, client_id, client_secret, redirect_uri, code, scope=scope)
@web.route('/systemlogsarchive', methods=['GET'])
@process_oauth
@route_show_if(features.SUPER_USERS)
@no_cache
def download_logs_archive():
# Note: We cannot use the decorator here because this is a GET method. That being said, this
# information is sensitive enough that we want the extra protection.
verify_csrf()
if SuperUserPermission().can():
archive_data = build_logs_archive(app)
return Response(archive_data,
mimetype="application/octet-stream",
headers={"Content-Disposition": "attachment;filename=erlogs.tar.gz"})
abort(403)
@web.route('/bitbucket/setup/<path:repository>', methods=['GET'])
@require_session_login
@parse_repository_name
@route_show_if(features.BITBUCKET_BUILD)
def attach_bitbucket_trigger(namespace, repository_name):
permission = AdministerRepositoryPermission(namespace, repository_name)
if permission.can():
repo = model.repository.get_repository(namespace, repository_name)
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace, repository_name)
abort(404, message=msg)
trigger = model.build.create_build_trigger(repo, BitbucketBuildTrigger.service_name(), None,
current_user.db_user())
try:
oauth_info = BuildTriggerHandler.get_handler(trigger).get_oauth_url()
config = {
'access_token': oauth_info['access_token']
}
access_token_secret = oauth_info['access_token_secret']
model.build.update_build_trigger(trigger, config, auth_token=access_token_secret)
return redirect(oauth_info['url'])
except TriggerProviderException:
trigger.delete_instance()
abort(400, message='Could not retrieve OAuth URL from Bitbucket')
abort(403)
@web.route('/customtrigger/setup/<path:repository>', methods=['GET'])
@require_session_login
@parse_repository_name
def attach_custom_build_trigger(namespace, repository_name):
permission = AdministerRepositoryPermission(namespace, repository_name)
if permission.can():
repo = model.repository.get_repository(namespace, repository_name)
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace, repository_name)
abort(404, message=msg)
trigger = model.build.create_build_trigger(repo, CustomBuildTrigger.service_name(),
None, current_user.db_user())
repo_path = '%s/%s' % (namespace, repository_name)
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
trigger.uuid)
logger.debug('Redirecting to full url: %s', full_url)
return redirect(full_url)
abort(403)
@web.route('/<path:repository>')
@no_cache
@process_oauth
@parse_repository_name_and_tag
@anon_protect
def redirect_to_repository(namespace, reponame, tag_name):
permission = ReadRepositoryPermission(namespace, reponame)
is_public = model.repository.repository_is_public(namespace, reponame)
if request.args.get('ac-discovery', 0) == 1:
return index('')
if permission.can() or is_public:
repository_name = '/'.join([namespace, reponame])
return redirect(url_for('web.repository', path=repository_name, tag=tag_name))
abort(404)
@web.route('/<namespace>')
@no_cache
@process_oauth
@anon_protect
def redirect_to_namespace(namespace):
user_or_org = model.user.get_user_or_org(namespace)
if not user_or_org:
abort(404)
if user_or_org.organization:
return redirect(url_for('web.org_view', path=namespace))
else:
return redirect(url_for('web.user_view', path=namespace))