From 53fb7f4136efe2bc0d6c457a866ceb6faaf625e3 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 Aug 2014 19:05:28 -0400 Subject: [PATCH 01/13] Add documentation for all path parameters --- endpoints/api/__init__.py | 3 +++ endpoints/api/billing.py | 5 ++++- endpoints/api/build.py | 8 +++++++- endpoints/api/discovery.py | 4 ++-- endpoints/api/image.py | 7 ++++++- endpoints/api/logs.py | 4 +++- endpoints/api/organization.py | 13 ++++++++++++- endpoints/api/permission.py | 8 +++++++- endpoints/api/prototype.py | 5 ++++- endpoints/api/repoemail.py | 5 ++++- endpoints/api/repository.py | 6 +++++- endpoints/api/repositorynotification.py | 8 +++++++- endpoints/api/repotoken.py | 5 ++++- endpoints/api/search.py | 3 ++- endpoints/api/superuser.py | 3 ++- endpoints/api/tag.py | 7 ++++++- endpoints/api/team.py | 10 +++++++++- endpoints/api/trigger.py | 18 +++++++++++++++++- endpoints/api/user.py | 4 +++- 19 files changed, 107 insertions(+), 19 deletions(-) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index e8dab28dc..f6c3ecc3f 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -155,6 +155,9 @@ internal_only = add_method_metadata('internal', True) def path_param(name, description): def add_param(func): + if not func: + return func + if '__api_path_params' not in dir(func): func.__api_path_params = {} func.__api_path_params[name] = { diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 3e13df6b6..89c94963b 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -4,7 +4,7 @@ from flask import request from app import billing from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, show_if, hide_if) + require_user_admin, show_if, hide_if, path_param) from endpoints.api.subscribe import subscribe, subscription_view from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user @@ -135,6 +135,7 @@ class UserCard(ApiResource): @resource('/v1/organization//card') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(UserCard) @show_if(features.BILLING) @@ -242,6 +243,7 @@ class UserPlan(ApiResource): @resource('/v1/organization//plan') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(UserPlan) @show_if(features.BILLING) @@ -323,6 +325,7 @@ class UserInvoiceList(ApiResource): @resource('/v1/organization//invoices') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(UserInvoiceList) @show_if(features.BILLING) diff --git a/endpoints/api/build.py b/endpoints/api/build.py index 21d554069..7fa11cc15 100644 --- a/endpoints/api/build.py +++ b/endpoints/api/build.py @@ -6,7 +6,8 @@ from flask import request from app import app, userfiles as user_files, build_logs from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource, require_repo_read, require_repo_write, validate_json_request, - ApiResource, internal_only, format_date, api, Unauthorized, NotFound) + ApiResource, internal_only, format_date, api, Unauthorized, NotFound, + path_param) from endpoints.common import start_build from endpoints.trigger import BuildTrigger from data import model @@ -86,6 +87,7 @@ def build_status_view(build_obj, can_write=False): @resource('/v1/repository//build/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryBuildList(RepositoryParamResource): """ Resource related to creating and listing repository builds. """ schemas = { @@ -190,6 +192,8 @@ class RepositoryBuildList(RepositoryParamResource): @resource('/v1/repository//build//status') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('build_uuid', 'The UUID of the build') class RepositoryBuildStatus(RepositoryParamResource): """ Resource for dealing with repository build status. """ @require_repo_read @@ -205,6 +209,8 @@ class RepositoryBuildStatus(RepositoryParamResource): @resource('/v1/repository//build//logs') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('build_uuid', 'The UUID of the build') class RepositoryBuildLogs(RepositoryParamResource): """ Resource for loading repository build logs. """ @require_repo_write diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index ee8702636..47181dc9f 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -100,7 +100,7 @@ def swagger_route_data(include_internal=False, compact=False): if not compact: new_operation.update({ 'type': 'void', - 'summary': method.__doc__ if method.__doc__ else '', + 'summary': method.__doc__.strip() if method.__doc__ else '', 'parameters': parameters, }) @@ -125,7 +125,7 @@ def swagger_route_data(include_internal=False, compact=False): swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule) new_resource = { 'path': swagger_path, - 'description': view_class.__doc__ if view_class.__doc__ else "", + 'description': view_class.__doc__.strip() if view_class.__doc__ else "", 'operations': operations, 'name': fully_qualified_name(view_class), } diff --git a/endpoints/api/image.py b/endpoints/api/image.py index 3060053ad..6da110eff 100644 --- a/endpoints/api/image.py +++ b/endpoints/api/image.py @@ -4,7 +4,7 @@ from collections import defaultdict from app import storage as store from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource, - format_date, NotFound) + format_date, NotFound, path_param) from data import model from util.cache import cache_control_flask_restful @@ -29,6 +29,7 @@ def image_view(image): @resource('/v1/repository//image/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryImageList(RepositoryParamResource): """ Resource for listing repository images. """ @require_repo_read @@ -54,6 +55,8 @@ class RepositoryImageList(RepositoryParamResource): @resource('/v1/repository//image/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('image_id', 'The Docker image ID') class RepositoryImage(RepositoryParamResource): """ Resource for handling repository images. """ @require_repo_read @@ -68,6 +71,8 @@ class RepositoryImage(RepositoryParamResource): @resource('/v1/repository//image//changes') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('image_id', 'The Docker image ID') class RepositoryImageChanges(RepositoryParamResource): """ Resource for handling repository image change lists. """ diff --git a/endpoints/api/logs.py b/endpoints/api/logs.py index 2ce2bbb30..606c8b198 100644 --- a/endpoints/api/logs.py +++ b/endpoints/api/logs.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args, RepositoryParamResource, require_repo_admin, related_user_resource, format_date, Unauthorized, NotFound, require_user_admin, - internal_only) + internal_only, path_param) from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from data import model @@ -63,6 +63,7 @@ def get_logs(start_time, end_time, performer_name=None, repository=None, namespa @resource('/v1/repository//logs') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') @internal_only class RepositoryLogs(RepositoryParamResource): """ Resource for fetching logs for the specific repository. """ @@ -103,6 +104,7 @@ class UserLogs(ApiResource): @resource('/v1/organization//logs') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(UserLogs) class OrgLogs(ApiResource): diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index f6a381ace..c4c870fec 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -5,7 +5,7 @@ from flask import request from app import billing as stripe from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, log_action, show_if) + require_user_admin, log_action, show_if, path_param) from endpoints.api.team import team_view from endpoints.api.user import User, PrivateRepositories from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, @@ -97,6 +97,7 @@ class OrganizationList(ApiResource): @resource('/v1/organization/') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(User) class Organization(ApiResource): @@ -163,6 +164,7 @@ class Organization(ApiResource): @resource('/v1/organization//private') +@path_param('orgname', 'The name of the organization') @internal_only @related_user_resource(PrivateRepositories) @show_if(features.BILLING) @@ -199,6 +201,7 @@ class OrgPrivateRepositories(ApiResource): @resource('/v1/organization//members') +@path_param('orgname', 'The name of the organization') @internal_only class OrgnaizationMemberList(ApiResource): """ Resource for listing the members of an organization. """ @@ -232,6 +235,8 @@ class OrgnaizationMemberList(ApiResource): @resource('/v1/organization//members/') +@path_param('orgname', 'The name of the organization') +@path_param('membername', 'The username of the organization member') @internal_only class OrganizationMember(ApiResource): """ Resource for managing individual organization members. """ @@ -265,6 +270,7 @@ class OrganizationMember(ApiResource): @resource('/v1/app/') +@path_param('client_id', 'The OAuth client ID') class ApplicationInformation(ApiResource): """ Resource that returns public information about a registered application. """ @nickname('getApplicationInformation') @@ -302,6 +308,7 @@ def app_view(application): @resource('/v1/organization//applications') +@path_param('orgname', 'The name of the organization') @internal_only class OrganizationApplications(ApiResource): """ Resource for managing applications defined by an organizations. """ @@ -386,6 +393,8 @@ class OrganizationApplications(ApiResource): @resource('/v1/organization//applications/') +@path_param('orgname', 'The name of the organization') +@path_param('client_id', 'The OAuth client ID') @internal_only class OrganizationApplicationResource(ApiResource): """ Resource for managing an application defined by an organizations. """ @@ -498,6 +507,8 @@ class OrganizationApplicationResource(ApiResource): @resource('/v1/organization//applications//resetclientsecret') +@path_param('orgname', 'The name of the organization') +@path_param('client_id', 'The OAuth client ID') @internal_only class OrganizationApplicationResetClientSecret(ApiResource): """ Custom verb for resetting the client secret of an application. """ diff --git a/endpoints/api/permission.py b/endpoints/api/permission.py index 601a549e3..f98c8c54e 100644 --- a/endpoints/api/permission.py +++ b/endpoints/api/permission.py @@ -3,7 +3,7 @@ import logging from flask import request from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, - log_action, request_error, validate_json_request) + log_action, request_error, validate_json_request, path_param) from data import model @@ -26,6 +26,7 @@ def wrap_role_view_org(role_json, user, org_members): @resource('/v1/repository//permissions/team/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryTeamPermissionList(RepositoryParamResource): """ Resource for repository team permissions. """ @require_repo_admin @@ -41,6 +42,7 @@ class RepositoryTeamPermissionList(RepositoryParamResource): @resource('/v1/repository//permissions/user/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryUserPermissionList(RepositoryParamResource): """ Resource for repository user permissions. """ @require_repo_admin @@ -80,6 +82,8 @@ class RepositoryUserPermissionList(RepositoryParamResource): @resource('/v1/repository//permissions/user/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('username', 'The username of the user to which the permission applies') class RepositoryUserPermission(RepositoryParamResource): """ Resource for managing individual user permissions. """ schemas = { @@ -175,6 +179,8 @@ class RepositoryUserPermission(RepositoryParamResource): @resource('/v1/repository//permissions/team/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('teamname', 'The name of the team to which the permission applies') class RepositoryTeamPermission(RepositoryParamResource): """ Resource for managing individual team permissions. """ schemas = { diff --git a/endpoints/api/prototype.py b/endpoints/api/prototype.py index bedc19832..8e43bbf42 100644 --- a/endpoints/api/prototype.py +++ b/endpoints/api/prototype.py @@ -1,7 +1,7 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, - log_action, Unauthorized, NotFound, internal_only) + log_action, Unauthorized, NotFound, internal_only, path_param) from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from data import model @@ -54,6 +54,7 @@ def log_prototype_action(action_kind, orgname, prototype, **kwargs): @resource('/v1/organization//prototypes') +@path_param('orgname', 'The name of the organization') @internal_only class PermissionPrototypeList(ApiResource): """ Resource for listing and creating permission prototypes. """ @@ -179,6 +180,8 @@ class PermissionPrototypeList(ApiResource): @resource('/v1/organization//prototypes/') +@path_param('orgname', 'The name of the organization') +@path_param('prototypeid', 'The ID of the prototype') @internal_only class PermissionPrototype(ApiResource): """ Resource for managingin individual permission prototypes. """ diff --git a/endpoints/api/repoemail.py b/endpoints/api/repoemail.py index 6585bbc49..0b7c66917 100644 --- a/endpoints/api/repoemail.py +++ b/endpoints/api/repoemail.py @@ -3,7 +3,8 @@ import logging from flask import request, abort from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, - log_action, validate_json_request, NotFound, internal_only) + log_action, validate_json_request, NotFound, internal_only, + path_param) from app import tf from data import model @@ -26,6 +27,8 @@ def record_view(record): @internal_only @resource('/v1/repository//authorizedemail/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('email', 'The e-mail address') class RepositoryAuthorizedEmail(RepositoryParamResource): """ Resource for checking and authorizing e-mail addresses to receive repo notifications. """ @require_repo_admin diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 17a35fea1..b7ff899f6 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -7,7 +7,9 @@ from data import model from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, require_repo_read, require_repo_write, require_repo_admin, RepositoryParamResource, resource, query_param, parse_args, ApiResource, - request_error, require_scope, Unauthorized, NotFound, InvalidRequest) + request_error, require_scope, Unauthorized, NotFound, InvalidRequest, + path_param) + from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission, CreateRepositoryPermission, ReadRepositoryPermission) from auth.auth_context import get_authenticated_user @@ -140,6 +142,7 @@ class RepositoryList(ApiResource): @resource('/v1/repository/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class Repository(RepositoryParamResource): """Operations for managing a specific repository.""" schemas = { @@ -233,6 +236,7 @@ class Repository(RepositoryParamResource): @resource('/v1/repository//changevisibility') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryVisibility(RepositoryParamResource): """ Custom verb for changing the visibility of the repository. """ schemas = { diff --git a/endpoints/api/repositorynotification.py b/endpoints/api/repositorynotification.py index 1fab89dd0..3a518736e 100644 --- a/endpoints/api/repositorynotification.py +++ b/endpoints/api/repositorynotification.py @@ -4,7 +4,8 @@ from flask import request, abort from app import notification_queue from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, - log_action, validate_json_request, api, NotFound, request_error) + log_action, validate_json_request, api, NotFound, request_error, + path_param) from endpoints.notificationevent import NotificationEvent from endpoints.notificationmethod import (NotificationMethod, CannotValidateNotificationMethodException) @@ -28,6 +29,7 @@ def notification_view(notification): @resource('/v1/repository//notification/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryNotificationList(RepositoryParamResource): """ Resource for dealing with listing and creating notifications on a repository. """ schemas = { @@ -95,6 +97,8 @@ class RepositoryNotificationList(RepositoryParamResource): @resource('/v1/repository//notification/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('uuid', 'The UUID of the notification') class RepositoryNotification(RepositoryParamResource): """ Resource for dealing with specific notifications. """ @require_repo_admin @@ -122,6 +126,8 @@ class RepositoryNotification(RepositoryParamResource): @resource('/v1/repository//notification//test') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('uuid', 'The UUID of the notification') class TestRepositoryNotification(RepositoryParamResource): """ Resource for queuing a test of a notification. """ @require_repo_admin diff --git a/endpoints/api/repotoken.py b/endpoints/api/repotoken.py index b430d2b4e..a6b28275b 100644 --- a/endpoints/api/repotoken.py +++ b/endpoints/api/repotoken.py @@ -3,7 +3,7 @@ import logging from flask import request from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, - log_action, validate_json_request, NotFound) + log_action, validate_json_request, NotFound, path_param) from data import model @@ -19,6 +19,7 @@ def token_view(token_obj): @resource('/v1/repository//tokens/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class RepositoryTokenList(RepositoryParamResource): """ Resource for creating and listing repository tokens. """ schemas = { @@ -66,6 +67,8 @@ class RepositoryTokenList(RepositoryParamResource): @resource('/v1/repository//tokens/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('code', 'The token code') class RepositoryToken(RepositoryParamResource): """ Resource for managing individual tokens. """ schemas = { diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 7cb1a1fda..83a398b6b 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -1,5 +1,5 @@ from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource, - require_scope) + require_scope, path_param) from data import model from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, ReadRepositoryPermission, UserAdminPermission, @@ -12,6 +12,7 @@ from util.gravatar import compute_hash @resource('/v1/entities/') class EntitySearch(ApiResource): """ Resource for searching entities. """ + @path_param('prefix', 'The prefix of the entities being looked up') @parse_args @query_param('namespace', 'Namespace to use when querying for org entities.', type=str, default='') diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 3ade5f1ed..3eacd2f97 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -8,7 +8,7 @@ from flask import request from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, format_date, InvalidToken, require_scope, format_date, hide_if, show_if, parse_args, - query_param, abort) + query_param, abort, path_param) from endpoints.api.logs import get_logs @@ -86,6 +86,7 @@ class SuperUserList(ApiResource): @resource('/v1/superuser/users/') +@path_param('username', 'The username of the user being managed') @internal_only @show_if(features.SUPER_USERS) class SuperUserManagement(ApiResource): diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py index f9210881c..751fd6f60 100644 --- a/endpoints/api/tag.py +++ b/endpoints/api/tag.py @@ -1,13 +1,16 @@ from flask import request from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, - RepositoryParamResource, log_action, NotFound, validate_json_request) + RepositoryParamResource, log_action, NotFound, validate_json_request, + path_param) from endpoints.api.image import image_view from data import model from auth.auth_context import get_authenticated_user @resource('/v1/repository//tag/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('tag', 'The name of the tag') class RepositoryTag(RepositoryParamResource): """ Resource for managing repository tags. """ schemas = { @@ -73,6 +76,8 @@ class RepositoryTag(RepositoryParamResource): @resource('/v1/repository//tag//images') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('tag', 'The name of the tag') class RepositoryTagImages(RepositoryParamResource): """ Resource for listing the images in a specific repository tag. """ @require_repo_read diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 0631cc028..bfbeae650 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -1,7 +1,8 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, - log_action, Unauthorized, NotFound, internal_only, require_scope) + log_action, Unauthorized, NotFound, internal_only, require_scope, + path_param) from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.auth_context import get_authenticated_user from auth import scopes @@ -28,6 +29,8 @@ def member_view(member): @resource('/v1/organization//team/') +@path_param('orgname', 'The name of the organization') +@path_param('teamname', 'The name of the team') @internal_only class OrganizationTeam(ApiResource): """ Resource for manging an organization's teams. """ @@ -111,6 +114,8 @@ class OrganizationTeam(ApiResource): @resource('/v1/organization//team//members') +@path_param('orgname', 'The name of the organization') +@path_param('teamname', 'The name of the team') @internal_only class TeamMemberList(ApiResource): """ Resource for managing the list of members for a team. """ @@ -137,6 +142,9 @@ class TeamMemberList(ApiResource): @resource('/v1/organization//team//members/') +@path_param('orgname', 'The name of the organization') +@path_param('teamname', 'The name of the team') +@path_param('membername', 'The username of the team member') class TeamMember(ApiResource): """ Resource for managing individual members of a team. """ @require_scope(scopes.ORG_ADMIN) diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index 4ec20bfdc..857b5116d 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -8,7 +8,8 @@ from urlparse import urlunparse from app import app from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, log_action, request_error, query_param, parse_args, internal_only, - validate_json_request, api, Unauthorized, NotFound, InvalidRequest) + validate_json_request, api, Unauthorized, NotFound, InvalidRequest, + path_param) from endpoints.api.build import (build_status_view, trigger_view, RepositoryBuildStatus, get_trigger_config) from endpoints.common import start_build @@ -30,6 +31,7 @@ def _prepare_webhook_url(scheme, username, password, hostname, path): @resource('/v1/repository//trigger/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') class BuildTriggerList(RepositoryParamResource): """ Resource for listing repository build triggers. """ @@ -44,6 +46,8 @@ class BuildTriggerList(RepositoryParamResource): @resource('/v1/repository//trigger/') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') class BuildTrigger(RepositoryParamResource): """ Resource for managing specific build triggers. """ @@ -90,6 +94,8 @@ class BuildTrigger(RepositoryParamResource): @resource('/v1/repository//trigger//subdir') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerSubdirs(RepositoryParamResource): """ Custom verb for fetching the subdirs which are buildable for a trigger. """ @@ -137,6 +143,8 @@ class BuildTriggerSubdirs(RepositoryParamResource): @resource('/v1/repository//trigger//activate') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerActivate(RepositoryParamResource): """ Custom verb for activating a build trigger once all required information has been collected. @@ -237,6 +245,8 @@ class BuildTriggerActivate(RepositoryParamResource): @resource('/v1/repository//trigger//analyze') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerAnalyze(RepositoryParamResource): """ Custom verb for analyzing the config for a build trigger and suggesting various changes @@ -372,6 +382,8 @@ class BuildTriggerAnalyze(RepositoryParamResource): @resource('/v1/repository//trigger//start') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') class ActivateBuildTrigger(RepositoryParamResource): """ Custom verb to manually activate a build trigger. """ @@ -408,6 +420,8 @@ class ActivateBuildTrigger(RepositoryParamResource): @resource('/v1/repository//trigger//builds') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') class TriggerBuildList(RepositoryParamResource): """ Resource to represent builds that were activated from the specified trigger. """ @require_repo_admin @@ -425,6 +439,8 @@ class TriggerBuildList(RepositoryParamResource): @resource('/v1/repository//trigger//sources') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerSources(RepositoryParamResource): """ Custom verb to fetch the list of build sources for the trigger config. """ diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 3d79a806d..8ba2c0507 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -7,7 +7,7 @@ from flask.ext.principal import identity_changed, AnonymousIdentity from app import app, billing as stripe, authentication from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, - log_action, internal_only, NotFound, require_user_admin, + log_action, internal_only, NotFound, require_user_admin, path_param, InvalidToken, require_scope, format_date, hide_if, show_if, license_error) from endpoints.api.subscribe import subscribe from endpoints.common import common_login @@ -412,6 +412,7 @@ class UserNotificationList(ApiResource): @resource('/v1/user/notifications/') +@path_param('uuid', 'The uuid of the user notification') @internal_only class UserNotification(ApiResource): schemas = { @@ -482,6 +483,7 @@ class UserAuthorizationList(ApiResource): @resource('/v1/user/authorizations/') +@path_param('access_token_uuid', 'The uuid of the access token') @internal_only class UserAuthorization(ApiResource): @require_user_admin From 4fd249589d9191b3d615a1f08bcfa3198aa7e346 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 Aug 2014 19:21:41 -0400 Subject: [PATCH 02/13] Add scopes to many org admin methods and remove the internal_only on ones we can now expose --- endpoints/api/billing.py | 8 ++++++-- endpoints/api/logs.py | 6 +++--- endpoints/api/organization.py | 26 ++++++++++++++++++-------- endpoints/api/prototype.py | 10 +++++++--- endpoints/api/team.py | 7 +++++-- endpoints/api/trigger.py | 1 - 6 files changed, 39 insertions(+), 19 deletions(-) diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 89c94963b..f5b022ca6 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -4,10 +4,11 @@ from flask import request from app import billing from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, show_if, hide_if, path_param) + require_user_admin, show_if, hide_if, path_param, require_scope) from endpoints.api.subscribe import subscribe, subscription_view from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user +from auth import scopes from data import model from data.billing import PLANS @@ -158,6 +159,7 @@ class OrganizationCard(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('getOrgCard') def get(self, orgname): """ Get the organization's credit card. """ @@ -270,6 +272,7 @@ class OrganizationPlan(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('updateOrgSubscription') @validate_json_request('OrgSubscription') def put(self, orgname): @@ -284,6 +287,7 @@ class OrganizationPlan(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('getOrgSubscription') def get(self, orgname): """ Fetch any existing subscription for the org. """ @@ -326,11 +330,11 @@ class UserInvoiceList(ApiResource): @resource('/v1/organization//invoices') @path_param('orgname', 'The name of the organization') -@internal_only @related_user_resource(UserInvoiceList) @show_if(features.BILLING) class OrgnaizationInvoiceList(ApiResource): """ Resource for listing an orgnaization's invoices. """ + @require_scope(scopes.ORG_ADMIN) @nickname('listOrgInvoices') def get(self, orgname): """ List the invoices for the specified orgnaization. """ diff --git a/endpoints/api/logs.py b/endpoints/api/logs.py index 606c8b198..6a45a497f 100644 --- a/endpoints/api/logs.py +++ b/endpoints/api/logs.py @@ -5,10 +5,11 @@ from datetime import datetime, timedelta from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args, RepositoryParamResource, require_repo_admin, related_user_resource, format_date, Unauthorized, NotFound, require_user_admin, - internal_only, path_param) + internal_only, path_param, require_scope) from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from data import model +from auth import scopes def log_view(log): @@ -64,7 +65,6 @@ def get_logs(start_time, end_time, performer_name=None, repository=None, namespa @resource('/v1/repository//logs') @path_param('repository', 'The full path of the repository. e.g. namespace/name') -@internal_only class RepositoryLogs(RepositoryParamResource): """ Resource for fetching logs for the specific repository. """ @require_repo_admin @@ -105,7 +105,6 @@ class UserLogs(ApiResource): @resource('/v1/organization//logs') @path_param('orgname', 'The name of the organization') -@internal_only @related_user_resource(UserLogs) class OrgLogs(ApiResource): """ Resource for fetching logs for the entire organization. """ @@ -114,6 +113,7 @@ class OrgLogs(ApiResource): @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) @query_param('performer', 'Username for which to filter logs.', type=str) + @require_scope(scopes.ORG_ADMIN) def get(self, args, orgname): """ List the logs for the specified organization. """ permission = AdministerOrganizationPermission(orgname) diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index c4c870fec..83bd9f1ea 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -5,12 +5,14 @@ from flask import request from app import billing as stripe from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, log_action, show_if, path_param) + require_user_admin, log_action, show_if, path_param, + require_scope) from endpoints.api.team import team_view from endpoints.api.user import User, PrivateRepositories from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, CreateRepositoryPermission) from auth.auth_context import get_authenticated_user +from auth import scopes from data import model from data.billing import get_plan from util.gravatar import compute_hash @@ -98,7 +100,6 @@ class OrganizationList(ApiResource): @resource('/v1/organization/') @path_param('orgname', 'The name of the organization') -@internal_only @related_user_resource(User) class Organization(ApiResource): """ Resource for managing organizations. """ @@ -119,6 +120,8 @@ class Organization(ApiResource): }, }, } + + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganization') def get(self, orgname): """ Get the details for the specified organization """ @@ -134,6 +137,7 @@ class Organization(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('changeOrganizationDetails') @validate_json_request('UpdateOrg') def put(self, orgname): @@ -170,6 +174,8 @@ class Organization(ApiResource): @show_if(features.BILLING) class OrgPrivateRepositories(ApiResource): """ Custom verb to compute whether additional private repositories are available. """ + + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationPrivateAllowed') def get(self, orgname): """ Return whether or not this org is allowed to create new private repositories. """ @@ -202,9 +208,10 @@ class OrgPrivateRepositories(ApiResource): @resource('/v1/organization//members') @path_param('orgname', 'The name of the organization') -@internal_only class OrgnaizationMemberList(ApiResource): """ Resource for listing the members of an organization. """ + + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationMembers') def get(self, orgname): """ List the members of the specified organization. """ @@ -237,9 +244,10 @@ class OrgnaizationMemberList(ApiResource): @resource('/v1/organization//members/') @path_param('orgname', 'The name of the organization') @path_param('membername', 'The username of the organization member') -@internal_only class OrganizationMember(ApiResource): """ Resource for managing individual organization members. """ + + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationMember') def get(self, orgname, membername): """ Get information on the specific orgnaization member. """ @@ -273,6 +281,7 @@ class OrganizationMember(ApiResource): @path_param('client_id', 'The OAuth client ID') class ApplicationInformation(ApiResource): """ Resource that returns public information about a registered application. """ + @nickname('getApplicationInformation') def get(self, client_id): """ Get information on the specified application. """ @@ -309,7 +318,6 @@ def app_view(application): @resource('/v1/organization//applications') @path_param('orgname', 'The name of the organization') -@internal_only class OrganizationApplications(ApiResource): """ Resource for managing applications defined by an organizations. """ schemas = { @@ -345,7 +353,7 @@ class OrganizationApplications(ApiResource): }, } - + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationApplications') def get(self, orgname): """ List the applications for the specified organization """ @@ -361,6 +369,7 @@ class OrganizationApplications(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('createOrganizationApplication') @validate_json_request('NewApp') def post(self, orgname): @@ -395,7 +404,6 @@ class OrganizationApplications(ApiResource): @resource('/v1/organization//applications/') @path_param('orgname', 'The name of the organization') @path_param('client_id', 'The OAuth client ID') -@internal_only class OrganizationApplicationResource(ApiResource): """ Resource for managing an application defined by an organizations. """ schemas = { @@ -433,6 +441,7 @@ class OrganizationApplicationResource(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationApplication') def get(self, orgname, client_id): """ Retrieves the application with the specified client_id under the specified organization """ @@ -451,6 +460,7 @@ class OrganizationApplicationResource(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationApplication') @validate_json_request('UpdateApp') def put(self, orgname, client_id): @@ -484,7 +494,7 @@ class OrganizationApplicationResource(ApiResource): return app_view(application) raise Unauthorized() - + @require_scope(scopes.ORG_ADMIN) @nickname('deleteOrganizationApplication') def delete(self, orgname, client_id): """ Deletes the application under this organization. """ diff --git a/endpoints/api/prototype.py b/endpoints/api/prototype.py index 8e43bbf42..d4d736d2c 100644 --- a/endpoints/api/prototype.py +++ b/endpoints/api/prototype.py @@ -1,9 +1,11 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, - log_action, Unauthorized, NotFound, internal_only, path_param) + log_action, Unauthorized, NotFound, internal_only, path_param, + require_scope) from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user +from auth import scopes from data import model @@ -55,7 +57,6 @@ def log_prototype_action(action_kind, orgname, prototype, **kwargs): @resource('/v1/organization//prototypes') @path_param('orgname', 'The name of the organization') -@internal_only class PermissionPrototypeList(ApiResource): """ Resource for listing and creating permission prototypes. """ schemas = { @@ -116,6 +117,7 @@ class PermissionPrototypeList(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationPrototypePermissions') def get(self, orgname): """ List the existing prototypes for this organization. """ @@ -132,6 +134,7 @@ class PermissionPrototypeList(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('createOrganizationPrototypePermission') @validate_json_request('NewPrototype') def post(self, orgname): @@ -182,7 +185,6 @@ class PermissionPrototypeList(ApiResource): @resource('/v1/organization//prototypes/') @path_param('orgname', 'The name of the organization') @path_param('prototypeid', 'The ID of the prototype') -@internal_only class PermissionPrototype(ApiResource): """ Resource for managingin individual permission prototypes. """ schemas = { @@ -207,6 +209,7 @@ class PermissionPrototype(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('deleteOrganizationPrototypePermission') def delete(self, orgname, prototypeid): """ Delete an existing permission prototype. """ @@ -227,6 +230,7 @@ class PermissionPrototype(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationPrototypePermission') @validate_json_request('PrototypeUpdate') def put(self, orgname, prototypeid): diff --git a/endpoints/api/team.py b/endpoints/api/team.py index bfbeae650..4ecef0e6e 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -31,7 +31,6 @@ def member_view(member): @resource('/v1/organization//team/') @path_param('orgname', 'The name of the organization') @path_param('teamname', 'The name of the team') -@internal_only class OrganizationTeam(ApiResource): """ Resource for manging an organization's teams. """ schemas = { @@ -60,6 +59,7 @@ class OrganizationTeam(ApiResource): }, } + @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationTeam') @validate_json_request('TeamDescription') def put(self, orgname, teamname): @@ -101,6 +101,7 @@ class OrganizationTeam(ApiResource): raise Unauthorized() + @require_scope(scopes.ORG_ADMIN) @nickname('deleteOrganizationTeam') def delete(self, orgname, teamname): """ Delete the specified team. """ @@ -116,9 +117,10 @@ class OrganizationTeam(ApiResource): @resource('/v1/organization//team//members') @path_param('orgname', 'The name of the organization') @path_param('teamname', 'The name of the team') -@internal_only class TeamMemberList(ApiResource): """ Resource for managing the list of members for a team. """ + + @require_scope(scopes.ORG_ADMIN) @nickname('getOrganizationTeamMembers') def get(self, orgname, teamname): """ Retrieve the list of members for the specified team. """ @@ -147,6 +149,7 @@ class TeamMemberList(ApiResource): @path_param('membername', 'The username of the team member') class TeamMember(ApiResource): """ Resource for managing individual members of a team. """ + @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationTeamMember') def put(self, orgname, teamname, membername): diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index 857b5116d..4cc224a00 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -145,7 +145,6 @@ class BuildTriggerSubdirs(RepositoryParamResource): @resource('/v1/repository//trigger//activate') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') -@internal_only class BuildTriggerActivate(RepositoryParamResource): """ Custom verb for activating a build trigger once all required information has been collected. """ From 6f1a4030b6366b859341dea7c8db51a56f949317 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 27 Aug 2014 20:57:46 -0400 Subject: [PATCH 03/13] Add response schema validation (only when in TESTING mode) and add one schema. More will be added in a followup CL --- endpoints/api/__init__.py | 25 ++++++++++++++++++++ endpoints/api/discovery.py | 10 ++++++-- endpoints/api/user.py | 47 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index f6c3ecc3f..0234e6820 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,6 +1,7 @@ import logging import json +from app import app from flask import Blueprint, request, make_response, jsonify from flask.ext.restful import Resource, abort, Api, reqparse from flask.ext.restful.utils.cors import crossdomain @@ -52,6 +53,11 @@ class InvalidRequest(ApiException): ApiException.__init__(self, 'invalid_request', 400, error_description, payload) +class InvalidResponse(ApiException): + def __init__(self, error_description, payload=None): + ApiException.__init__(self, 'invalid_response', 500, error_description, payload) + + class InvalidToken(ApiException): def __init__(self, error_description, payload=None): ApiException.__init__(self, 'invalid_token', 401, error_description, payload) @@ -286,6 +292,25 @@ def validate_json_request(schema_name): return wrapper +def define_json_response(schema_name): + def wrapper(func): + @add_method_metadata('response_schema', schema_name) + @wraps(func) + def wrapped(self, *args, **kwargs): + schema = self.schemas[schema_name] + try: + resp = func(self, *args, **kwargs) + + if app.config['TESTING']: + validate(resp, schema) + + return resp + except ValidationError as ex: + raise InvalidResponse(ex.message) + return wrapped + return wrapper + + def request_error(exception=None, **kwargs): data = kwargs.copy() message = 'Request error.' diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index 47181dc9f..db61a14b2 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -94,12 +94,18 @@ def swagger_route_data(include_internal=False, compact=False): new_operation = { 'method': method_name, - 'nickname': method_metadata(method, 'nickname') + 'nickname': method_metadata(method, 'nickname') or '(unnamed)' } if not compact: + response_type = 'void' + res_schema_name = method_metadata(method, 'response_schema') + if res_schema_name: + models[res_schema_name] = view_class.schemas[res_schema_name] + response_type = res_schema_name + new_operation.update({ - 'type': 'void', + 'type': response_type, 'summary': method.__doc__.strip() if method.__doc__ else '', 'parameters': parameters, }) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 8ba2c0507..3bafe40b4 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -8,7 +8,8 @@ from flask.ext.principal import identity_changed, AnonymousIdentity from app import app, billing as stripe, authentication from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, path_param, - InvalidToken, require_scope, format_date, hide_if, show_if, license_error) + InvalidToken, require_scope, format_date, hide_if, show_if, license_error, + define_json_response) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from data import model @@ -50,13 +51,13 @@ def user_view(user): 'verified': user.verified, 'anonymous': False, 'username': user.username, - 'email': user.email, 'gravatar': compute_hash(user.email), } user_admin = UserAdminPermission(user.username) if user_admin.can(): user_response.update({ + 'email': user.email, 'organizations': [org_view(o) for o in organizations], 'logins': [login_view(login) for login in logins], 'can_create_repo': True, @@ -130,10 +131,51 @@ class User(ApiResource): }, }, }, + 'UserView': { + 'id': 'UserView', + 'type': 'object', + 'description': 'Describes a user', + 'required': ['verified', 'anonymous', 'gravatar'], + 'properties': { + 'verified': { + 'type': 'boolean', + 'description': 'Whether the user\'s email address has been verified' + }, + 'anonymous': { + 'type': 'boolean', + 'description': 'true if this user data represents a guest user' + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + }, + 'gravatar': { + 'type': 'string', + 'description': 'Gravatar hash representing the user\'s icon' + }, + 'organizations': { + 'type': 'array', + 'description': 'Information about the organizations in which the user is a member' + }, + 'logins': { + 'type': 'array', + 'description': 'The list of external login providers against which the user has authenticated' + }, + 'can_create_repo': { + 'type': 'boolean', + 'description': 'Whether the user has permission to create repositories' + }, + 'preferred_namespace': { + 'type': 'boolean', + 'description': 'If true, the user\'s namespace is the preferred namespace to display' + } + } + }, } @require_scope(scopes.READ_USER) @nickname('getLoggedInUser') + @define_json_response('UserView') def get(self): """ Get user information for the authenticated user. """ user = get_authenticated_user() @@ -146,6 +188,7 @@ class User(ApiResource): @nickname('changeUserDetails') @internal_only @validate_json_request('UpdateUser') + @define_json_response('UserView') def put(self): """ Update a users details such as password or email. """ user = get_authenticated_user() From 58ca76239beb3a066c76aea566807ec09e88b736 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 17 Nov 2014 14:54:07 -0500 Subject: [PATCH 04/13] Add ability to one-click generate an authorization access token in the applications panel --- data/model/oauth.py | 8 +++++++- endpoints/common.py | 1 + endpoints/web.py | 3 +-- static/css/quay.css | 8 ++++++++ static/js/app.js | 6 ++++++ static/js/controllers.js | 18 +++++++++++++++++- static/partials/manage-application.html | 25 +++++++++++++++++++++++++ templates/base.html | 1 + 8 files changed, 66 insertions(+), 4 deletions(-) diff --git a/data/model/oauth.py b/data/model/oauth.py index 51bfc053e..f6bbed992 100644 --- a/data/model/oauth.py +++ b/data/model/oauth.py @@ -9,6 +9,7 @@ from data.database import (OAuthApplication, OAuthAuthorizationCode, OAuthAccess random_string_generator) from data.model.legacy import get_user from auth import scopes +from flask import render_template logger = logging.getLogger(__name__) @@ -154,6 +155,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider): def get_token_response(self, response_type, client_id, redirect_uri, **params): + # Ensure proper response_type if response_type != 'token': err = 'unsupported_response_type' @@ -161,7 +163,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider): # Check redirect URI is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri) - if not is_valid_redirect_uri: + if redirect_uri != 'display' and not is_valid_redirect_uri: return self._invalid_redirect_uri_response() # Check conditions @@ -196,6 +198,10 @@ class DatabaseAuthorizationProvider(AuthorizationProvider): url = utils.build_url(redirect_uri, params) url += '#access_token=%s&token_type=%s&expires_in=%s' % (access_token, token_type, expires_in) + if redirect_uri == 'display': + return self._make_response( + render_template("message.html", message="Access Token: " + access_token)) + return self._make_response(headers={'Location': url}, status_code=302) diff --git a/endpoints/common.py b/endpoints/common.py index 6a221bd5c..5de16c034 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -198,6 +198,7 @@ def render_page_template(name, **kwargs): feature_set=json.dumps(features.get_features()), config_set=json.dumps(getFrontendVisibleConfig(app.config)), oauth_set=json.dumps(get_oauth_config()), + scope_set=json.dumps(scopes.ALL_SCOPES), mixpanel_key=app.config.get('MIXPANEL_KEY', ''), google_analytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''), sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''), diff --git a/endpoints/web.py b/endpoints/web.py index b355f5ec9..b2fffd811 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -376,7 +376,7 @@ def request_authorization_code(): 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): + if redirect_uri != 'display' and 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) @@ -416,7 +416,6 @@ def request_authorization_code(): 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') diff --git a/static/css/quay.css b/static/css/quay.css index 25934010b..7e44e3a59 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4871,4 +4871,12 @@ i.slack-icon { #startTriggerDialog #runForm .field-title { width: 120px; padding-right: 10px; +} + +#gen-token table { + margin: 10px; +} + +#gen-token input[type="checkbox"] { + margin-right: 10px; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 7c00c5831..4351c6166 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1711,6 +1711,12 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return notificationService; }]); + $provide.factory('OAuthService', ['$location', 'Config', function($location, Config) { + var oauthService = {}; + oauthService.SCOPES = window.__auth_scopes; + return oauthService; + }]); + $provide.factory('KeyService', ['$location', 'Config', function($location, Config) { var keyService = {} var oauth = window.__oauth; diff --git a/static/js/controllers.js b/static/js/controllers.js index 07041ae7a..62254984b 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2676,12 +2676,28 @@ function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangul } -function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $timeout, ApiService) { +function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $timeout, OAuthService, ApiService, UserService, Config) { var orgname = $routeParams.orgname; var clientId = $routeParams.clientid; + $scope.Config = Config; + $scope.OAuthService = OAuthService; $scope.updating = false; + $scope.genScopes = {}; + + UserService.updateUserIn($scope); + + $scope.getScopes = function(scopes) { + var checked = []; + for (var scopeName in scopes) { + if (scopes.hasOwnProperty(scopeName) && scopes[scopeName]) { + checked.push(scopeName); + } + } + return checked; + }; + $scope.askResetClientSecret = function() { $('#resetSecretModal').modal({}); }; diff --git a/static/partials/manage-application.html b/static/partials/manage-application.html index aaae745b8..7d0bb1bfb 100644 --- a/static/partials/manage-application.html +++ b/static/partials/manage-application.html @@ -30,6 +30,7 @@ @@ -91,6 +92,30 @@ + +
+
+ Click the button below to generate a new OAuth 2 Access Token. +
+ +
+ Note: The generated token will act on behalf of user + {{ user.username }} +
+ + + + + +
+ + + Generate Access Token + +
+
diff --git a/templates/base.html b/templates/base.html index 317a3683e..4824848fc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -45,6 +45,7 @@ window.__features = {{ feature_set|safe }}; window.__config = {{ config_set|safe }}; window.__oauth = {{ oauth_set|safe }}; + window.__auth_scopes = {{ scope_set|safe }}; window.__token = '{{ csrf_token() }}'; From e9cac407dfeed35730811ff0f91e971863789f9c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 24 Nov 2014 19:25:13 -0500 Subject: [PATCH 05/13] Add a configurable avatar system and add an internal avatar system for enterprise --- Dockerfile.web | 2 +- app.py | 2 + avatars/__init__.py | 0 avatars/avatars.py | 63 ++++++++++++++ config.py | 4 +- data/database.py | 2 +- endpoints/api/organization.py | 27 +++--- endpoints/api/search.py | 4 +- endpoints/api/team.py | 6 +- endpoints/api/user.py | 13 +-- endpoints/web.py | 20 ++++- static/css/quay.css | 2 +- static/directives/application-info.html | 2 +- static/directives/avatar.html | 1 + static/directives/entity-reference.html | 6 +- static/directives/header-bar.html | 2 +- static/directives/namespace-selector.html | 10 +-- static/directives/notification-view.html | 2 +- static/directives/organization-header.html | 2 +- static/directives/trigger-setup-github.html | 4 +- static/img/empty.png | Bin 0 -> 95 bytes static/js/app.js | 89 ++++++++++++++++++-- static/js/controllers.js | 4 +- static/partials/about.html | 6 -- static/partials/landing-login.html | 2 +- static/partials/landing-normal.html | 2 +- static/partials/manage-application.html | 14 +-- static/partials/org-admin.html | 2 +- static/partials/organizations.html | 2 +- static/partials/team-view.html | 6 +- static/partials/user-admin.html | 6 +- templates/oauthorize.html | 5 +- test/test_api_usage.py | 4 +- util/gravatar.py | 5 -- util/jinjautil.py | 11 ++- util/useremails.py | 1 - 36 files changed, 241 insertions(+), 92 deletions(-) create mode 100644 avatars/__init__.py create mode 100644 avatars/avatars.py create mode 100644 static/directives/avatar.html create mode 100644 static/img/empty.png delete mode 100644 util/gravatar.py diff --git a/Dockerfile.web b/Dockerfile.web index 575332a3a..dd91393f7 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -8,7 +8,7 @@ ENV HOME /root RUN apt-get update # 10SEP2014 # New ubuntu packages should be added as their own apt-get install lines below the existing install commands -RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62 libjpeg62-dev libevent-2.0.5 libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap-2.4-2 libldap2-dev libsasl2-modules libsasl2-dev libpq5 libpq-dev +RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62 libjpeg62-dev libevent-2.0.5 libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap-2.4-2 libldap2-dev libsasl2-modules libsasl2-dev libpq5 libpq-dev libfreetype6-dev # Build the python dependencies ADD requirements.txt requirements.txt diff --git a/app.py b/app.py index 33c22d818..327a62d60 100644 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ from data.buildlogs import BuildLogs from data.archivedlogs import LogArchive from data.queue import WorkQueue from data.userevent import UserEventsBuilderModule +from avatars.avatars import Avatar class Config(BaseConfig): @@ -119,6 +120,7 @@ features.import_features(app.config) Principal(app, use_sessions=False) +avatar = Avatar(app) login_manager = LoginManager(app) mail = Mail(app) storage = Storage(app) diff --git a/avatars/__init__.py b/avatars/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/avatars/avatars.py b/avatars/avatars.py new file mode 100644 index 000000000..386b5fc57 --- /dev/null +++ b/avatars/avatars.py @@ -0,0 +1,63 @@ +import hashlib + +class Avatar(object): + def __init__(self, app=None): + self.app = app + self.state = self._init_app(app) + + def _init_app(self, app): + return AVATAR_CLASSES[app.config.get('AVATAR_KIND', 'Gravatar')]( + app.config['SERVER_HOSTNAME'], + app.config['PREFERRED_URL_SCHEME']) + + def __getattr__(self, name): + return getattr(self.state, name, None) + + +class BaseAvatar(object): + """ Base class for all avatar implementations. """ + def __init__(self, server_hostname, preferred_url_scheme): + self.server_hostname = server_hostname + self.preferred_url_scheme = preferred_url_scheme + + def get_url(self, email, size=16, name=None): + """ Returns the full URL for viewing the avatar of the given email address, with + an optional size. + """ + raise NotImplementedError + + def compute_hash(self, email, name=None): + """ Computes the avatar hash for the given email address. If the name is given and a default + avatar is being computed, the name can be used in place of the email address. """ + raise NotImplementedError + + +class GravatarAvatar(BaseAvatar): + """ Avatar system that uses gravatar for generating avatars. """ + def compute_hash(self, email, name=None): + return hashlib.md5(email.strip().lower()).hexdigest() + + def get_url(self, email, size=16, name=None): + computed = self.compute_hash(email, name=name) + return '%s://www.gravatar.com/avatar/%s?d=identicon&size=%s' % (self.preferred_url_scheme, + computed, size) + +class LocalAvatar(BaseAvatar): + """ Avatar system that uses the local system for generating avatars. """ + def compute_hash(self, email, name=None): + if not name and not email: + return '' + + prefix = name if name else email + return prefix[0] + hashlib.md5(email.strip().lower()).hexdigest() + + def get_url(self, email, size=16, name=None): + computed = self.compute_hash(email, name=name) + return '%s://%s/avatar/%s?size=%s' % (self.preferred_url_scheme, self.server_hostname, + computed, size) + + +AVATAR_CLASSES = { + 'gravatar': GravatarAvatar, + 'local': LocalAvatar +} diff --git a/config.py b/config.py index 506e23b74..2a16e292b 100644 --- a/config.py +++ b/config.py @@ -19,7 +19,7 @@ def build_requests_session(): CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY', 'STRIPE_PUBLISHABLE_KEY', 'ENTERPRISE_LOGO_URL', 'SENTRY_PUBLIC_DSN', 'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT', - 'CONTACT_INFO'] + 'CONTACT_INFO', 'AVATAR_KIND'] def getFrontendVisibleConfig(config_dict): @@ -46,6 +46,8 @@ class DefaultConfig(object): PREFERRED_URL_SCHEME = 'http' SERVER_HOSTNAME = 'localhost:5000' + AVATAR_KIND = 'local' + REGISTRY_TITLE = 'Quay.io' REGISTRY_TITLE_SHORT = 'Quay.io' CONTACT_INFO = [ diff --git a/data/database.py b/data/database.py index 2cb1a51ca..21cd4ef10 100644 --- a/data/database.py +++ b/data/database.py @@ -474,7 +474,7 @@ class OAuthApplication(BaseModel): name = CharField() description = TextField(default='') - gravatar_email = CharField(null=True) + avatar_email = CharField(null=True, db_column='gravatar_email') class OAuthAuthorizationCode(BaseModel): diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index f6a381ace..46e30bd44 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -2,7 +2,7 @@ import logging from flask import request -from app import billing as stripe +from app import billing as stripe, avatar from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, related_user_resource, internal_only, Unauthorized, NotFound, require_user_admin, log_action, show_if) @@ -13,7 +13,6 @@ from auth.permissions import (AdministerOrganizationPermission, OrganizationMem from auth.auth_context import get_authenticated_user from data import model from data.billing import get_plan -from util.gravatar import compute_hash import features @@ -28,7 +27,7 @@ def org_view(o, teams): view = { 'name': o.username, 'email': o.email if is_admin else '', - 'gravatar': compute_hash(o.email), + 'avatar': avatar.compute_hash(o.email, name=o.username), 'teams': {t.name : team_view(o.username, t) for t in teams}, 'is_admin': is_admin } @@ -274,14 +273,16 @@ class ApplicationInformation(ApiResource): if not application: raise NotFound() - org_hash = compute_hash(application.organization.email) - gravatar = compute_hash(application.gravatar_email) if application.gravatar_email else org_hash + org_hash = avatar.compute_hash(application.organization.email, + name=application.organization.username) + app_hash = (avatar.compute_hash(application.avatar_email, name=application.name) if + application.avatar_email else org_hash) return { 'name': application.name, 'description': application.description, 'uri': application.application_uri, - 'gravatar': gravatar, + 'avatar': app_hash, 'organization': org_view(application.organization, []) } @@ -297,7 +298,7 @@ def app_view(application): 'client_id': application.client_id, 'client_secret': application.client_secret if is_admin else None, 'redirect_uri': application.redirect_uri if is_admin else None, - 'gravatar_email': application.gravatar_email if is_admin else None, + 'avatar_email': application.avatar_email if is_admin else None, } @@ -330,9 +331,9 @@ class OrganizationApplications(ApiResource): 'type': 'string', 'description': 'The human-readable description for the application', }, - 'gravatar_email': { + 'avatar_email': { 'type': 'string', - 'description': 'The e-mail address of the gravatar to use for the application', + 'description': 'The e-mail address of the avatar to use for the application', } }, }, @@ -371,7 +372,7 @@ class OrganizationApplications(ApiResource): app_data.get('application_uri', ''), app_data.get('redirect_uri', ''), description = app_data.get('description', ''), - gravatar_email = app_data.get('gravatar_email', None),) + avatar_email = app_data.get('avatar_email', None),) app_data.update({ @@ -416,9 +417,9 @@ class OrganizationApplicationResource(ApiResource): 'type': 'string', 'description': 'The human-readable description for the application', }, - 'gravatar_email': { + 'avatar_email': { 'type': 'string', - 'description': 'The e-mail address of the gravatar to use for the application', + 'description': 'The e-mail address of the avatar to use for the application', } }, }, @@ -462,7 +463,7 @@ class OrganizationApplicationResource(ApiResource): application.application_uri = app_data['application_uri'] application.redirect_uri = app_data['redirect_uri'] application.description = app_data.get('description', '') - application.gravatar_email = app_data.get('gravatar_email', None) + application.avatar_email = app_data.get('avatar_email', None) application.save() app_data.update({ diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 1cce618d9..728539af3 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -6,7 +6,7 @@ from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, AdministerOrganizationPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from util.gravatar import compute_hash +from app import avatar @resource('/v1/entities/') @@ -44,7 +44,7 @@ class EntitySearch(ApiResource): 'name': namespace_name, 'kind': 'org', 'is_org_member': True, - 'gravatar': compute_hash(organization.email), + 'avatar': avatar.compute_hash(organization.email, name=organization.username), }] except model.InvalidOrganizationException: diff --git a/endpoints/api/team.py b/endpoints/api/team.py index a448cefc9..cfc79f581 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -8,7 +8,7 @@ from auth.auth_context import get_authenticated_user from auth import scopes from data import model from util.useremails import send_org_invite_email -from util.gravatar import compute_hash +from app import avatar import features @@ -63,7 +63,7 @@ def member_view(member, invited=False): 'name': member.username, 'kind': 'user', 'is_robot': member.robot, - 'gravatar': compute_hash(member.email) if not member.robot else None, + 'avatar': avatar.compute_hash(member.email, name=member.username) if not member.robot else None, 'invited': invited, } @@ -75,7 +75,7 @@ def invite_view(invite): return { 'email': invite.email, 'kind': 'invite', - 'gravatar': compute_hash(invite.email), + 'avatar': avatar.compute_hash(invite.email), 'invited': True } diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 8cad4b30a..04ef50a07 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -5,7 +5,7 @@ from flask import request from flask.ext.login import logout_user from flask.ext.principal import identity_changed, AnonymousIdentity -from app import app, billing as stripe, authentication +from app import app, billing as stripe, authentication, avatar from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, parse_args, query_param, InvalidToken, require_scope, format_date, hide_if, show_if, @@ -20,7 +20,6 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository UserAdminPermission, UserReadPermission, SuperUserPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from util.gravatar import compute_hash from util.useremails import (send_confirmation_email, send_recovery_email, send_change_email, send_password_changed) from util.names import parse_single_urn @@ -34,7 +33,7 @@ def user_view(user): admin_org = AdministerOrganizationPermission(o.username) return { 'name': o.username, - 'gravatar': compute_hash(o.email), + 'avatar': avatar.compute_hash(o.email, name=o.username), 'is_org_admin': admin_org.can(), 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), 'preferred_namespace': not (o.stripe_id is None) @@ -61,7 +60,7 @@ def user_view(user): 'anonymous': False, 'username': user.username, 'email': user.email, - 'gravatar': compute_hash(user.email), + 'avatar': avatar.compute_hash(user.email, name=user.username), } user_admin = UserAdminPermission(user.username) @@ -572,10 +571,12 @@ def authorization_view(access_token): 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, - 'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email), + 'avatar': avatar.compute_hash(oauth_app.avatar_email or oauth_app.organization.email, + name=oauth_app.name), 'organization': { 'name': oauth_app.organization.username, - 'gravatar': compute_hash(oauth_app.organization.email) + 'avatar': avatar.compute_hash(oauth_app.organization.email, + name=oauth_app.organization.username) } }, 'scopes': scopes.get_scope_information(access_token.scope), diff --git a/endpoints/web.py b/endpoints/web.py index f957e2a97..0ab3ff6db 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -3,13 +3,15 @@ import os from flask import (abort, redirect, request, url_for, make_response, Response, Blueprint, send_from_directory, jsonify) + +from avatar_generator import Avatar from flask.ext.login import current_user from urlparse import urlparse from health.healthcheck import HealthCheck from data import model from data.model.oauth import DatabaseAuthorizationProvider -from app import app, billing as stripe, build_logs +from app import app, billing as stripe, build_logs, avatar from auth.auth import require_session_login, process_oauth from auth.permissions import AdministerOrganizationPermission, ReadRepositoryPermission from util.invoice import renderInvoiceToPdf @@ -18,7 +20,6 @@ from util.cache import no_cache from endpoints.common import common_login, render_page_template, route_show_if, param_required from endpoints.csrf import csrf_protect, generate_csrf_token from util.names import parse_repository_name -from util.gravatar import compute_hash from util.useremails import send_email_changed from auth import scopes @@ -182,6 +183,18 @@ def status(): return response +@app.route("/avatar/") +def avatar(avatar_hash): + try: + size = int(request.args.get('size', 16)) + except ValueError: + size = 16 + + generated = Avatar.generate(size, avatar_hash) + headers = {'Content-Type': 'image/png'} + return make_response(generated, 200, headers) + + @web.route('/tos', methods=['GET']) @no_cache def tos(): @@ -413,7 +426,8 @@ def request_authorization_code(): 'url': oauth_app.application_uri, 'organization': { 'name': oauth_app.organization.username, - 'gravatar': compute_hash(oauth_app.organization.email) + 'avatar': avatar.compute_hash(oauth_app.organization.email, + name=oauth_app.organization.name) } } diff --git a/static/css/quay.css b/static/css/quay.css index 25934010b..3e6a7e3b0 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4819,7 +4819,7 @@ i.slack-icon { margin-bottom: 10px; } -.member-listing .gravatar { +.member-listing .avatar { vertical-align: middle; margin-right: 10px; } diff --git a/static/directives/application-info.html b/static/directives/application-info.html index bbaf56454..241fec279 100644 --- a/static/directives/application-info.html +++ b/static/directives/application-info.html @@ -1,6 +1,6 @@
- +

{{ application.name }}

{{ application.organization.name }} diff --git a/static/directives/avatar.html b/static/directives/avatar.html new file mode 100644 index 000000000..46c56afe5 --- /dev/null +++ b/static/directives/avatar.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index ea65db875..c0fbd3196 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -7,15 +7,15 @@ - + {{entity.name}} {{entity.name}} - - + + diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index fe154341f..bd1022d3d 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -35,7 +35,7 @@

@@ -53,7 +53,7 @@ + ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/image/empty.png' }}"> diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index d7d9b583e..a7916ef7e 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -9,7 +9,7 @@
- + {{ user.username }} @@ -72,7 +72,7 @@
+ ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/img/empty.png' }}"> {{ state.currentRepo.repo }}
- + - + - + {{ member.email }}
- + {{ authInfo.application.name }} @@ -291,7 +291,7 @@
- + {{ user.username }}
This will continue to be the namespace for your repositories
diff --git a/templates/oauthorize.html b/templates/oauthorize.html index a83055fd4..19bde633b 100644 --- a/templates/oauthorize.html +++ b/templates/oauthorize.html @@ -13,10 +13,11 @@
- +

{{ application.name }}

- + {{ application.organization.name }}

diff --git a/test/test_api_usage.py b/test/test_api_usage.py index ab3a4a239..edda10c2a 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -2114,14 +2114,14 @@ class TestOrganizationApplicationResource(ApiTestCase): edit_json = self.putJsonResponse(OrganizationApplicationResource, params=dict(orgname=ORGANIZATION, client_id=FAKE_APPLICATION_CLIENT_ID), data=dict(name="Some App", description="foo", application_uri="bar", - redirect_uri="baz", gravatar_email="meh")) + redirect_uri="baz", avatar_email="meh")) self.assertEquals(FAKE_APPLICATION_CLIENT_ID, edit_json['client_id']) self.assertEquals("Some App", edit_json['name']) self.assertEquals("foo", edit_json['description']) self.assertEquals("bar", edit_json['application_uri']) self.assertEquals("baz", edit_json['redirect_uri']) - self.assertEquals("meh", edit_json['gravatar_email']) + self.assertEquals("meh", edit_json['avatar_email']) # Retrieve the application again. json = self.getJsonResponse(OrganizationApplicationResource, diff --git a/util/gravatar.py b/util/gravatar.py deleted file mode 100644 index 8cee241cc..000000000 --- a/util/gravatar.py +++ /dev/null @@ -1,5 +0,0 @@ -import hashlib - - -def compute_hash(email_address): - return hashlib.md5(email_address.strip().lower()).hexdigest() diff --git a/util/jinjautil.py b/util/jinjautil.py index 2d10414f8..36095a1d8 100644 --- a/util/jinjautil.py +++ b/util/jinjautil.py @@ -1,6 +1,5 @@ -from app import get_app_url +from app import get_app_url, avatar from data import model -from util.gravatar import compute_hash from util.names import parse_robot_username from jinja2 import Template, Environment, FileSystemLoader, contextfilter @@ -25,10 +24,10 @@ def user_reference(username): alt = 'Organization' if user.organization else 'User' return """ - %s %s - """ % (compute_hash(user.email), alt, username) + """ % (avatar.get_url(user.email, 16), alt, username) def repository_tag_reference(repository_path_and_tag): @@ -55,10 +54,10 @@ def repository_reference(pair): return """ - + %s/%s - """ % (compute_hash(owner.email), get_app_url(), namespace, repository, namespace, repository) + """ % (avatar.get_url(owner.email, 16), get_app_url(), namespace, repository, namespace, repository) def admin_reference(username): diff --git a/util/useremails.py b/util/useremails.py index 8dbbc5216..58cd402a9 100644 --- a/util/useremails.py +++ b/util/useremails.py @@ -5,7 +5,6 @@ from flask.ext.mail import Message from app import mail, app, get_app_url from data import model -from util.gravatar import compute_hash from util.jinjautil import get_template_env logger = logging.getLogger(__name__) From 0e13ef3ff81e71650152dce9e0ff16fbfc3982bc Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 24 Nov 2014 19:40:03 -0500 Subject: [PATCH 06/13] Fix various bugs and styling issues --- auth/scopes.py | 2 +- avatars/avatars.py | 2 ++ endpoints/web.py | 5 +++-- static/css/quay.css | 5 +++-- static/partials/manage-application.html | 7 ++++--- templates/oauthorize.html | 4 ++-- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/auth/scopes.py b/auth/scopes.py index c71e5faa7..b92f9278e 100644 --- a/auth/scopes.py +++ b/auth/scopes.py @@ -76,7 +76,7 @@ IMPLIED_SCOPES = { def scopes_from_scope_string(scopes): if not scopes: - return {} + scopes = '' return {ALL_SCOPES.get(scope, None) for scope in scopes.split(',')} diff --git a/avatars/avatars.py b/avatars/avatars.py index 386b5fc57..40935df10 100644 --- a/avatars/avatars.py +++ b/avatars/avatars.py @@ -35,6 +35,7 @@ class BaseAvatar(object): class GravatarAvatar(BaseAvatar): """ Avatar system that uses gravatar for generating avatars. """ def compute_hash(self, email, name=None): + email = email or "" return hashlib.md5(email.strip().lower()).hexdigest() def get_url(self, email, size=16, name=None): @@ -45,6 +46,7 @@ class GravatarAvatar(BaseAvatar): class LocalAvatar(BaseAvatar): """ Avatar system that uses the local system for generating avatars. """ def compute_hash(self, email, name=None): + email = email or "" if not name and not email: return '' diff --git a/endpoints/web.py b/endpoints/web.py index 4a1205108..e297074ff 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -184,7 +184,7 @@ def status(): @app.route("/avatar/") -def avatar(avatar_hash): +def render_avatar(avatar_hash): try: size = int(request.args.get('size', 16)) except ValueError: @@ -424,10 +424,11 @@ def request_authorization_code(): 'name': oauth_app.name, 'description': oauth_app.description, 'url': oauth_app.application_uri, + 'avatar': avatar.compute_hash(oauth_app.avatar_email, name=oauth_app.name), 'organization': { 'name': oauth_app.organization.username, 'avatar': avatar.compute_hash(oauth_app.organization.email, - name=oauth_app.organization.name) + name=oauth_app.organization.username) } } diff --git a/static/css/quay.css b/static/css/quay.css index 9213bb575..9d7568f97 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4256,9 +4256,10 @@ pre.command:before { display: block !important; } -.auth-header > img { +.auth-header > .avatar { float: left; - margin-top: 8px; + display: inline-block; + margin-top: 12px; margin-right: 20px; } diff --git a/static/partials/manage-application.html b/static/partials/manage-application.html index 266a61e6e..c9e084b39 100644 --- a/static/partials/manage-application.html +++ b/static/partials/manage-application.html @@ -7,8 +7,8 @@
-

- {{ application.name || '(Untitled)' }}

+ +

{{ application.name || '(Untitled)' }}

{{ organization.name }} @@ -99,7 +99,8 @@

- Note: The generated token will act on behalf of user + Note: The generated token will act on behalf of user + {{ user.username }}
diff --git a/templates/oauthorize.html b/templates/oauthorize.html index 19bde633b..567547f6f 100644 --- a/templates/oauthorize.html +++ b/templates/oauthorize.html @@ -13,10 +13,10 @@
- +

{{ application.name }}

- {{ application.organization.name }}

From d9f0d36dfec711b96b6a606e728e156eb2df5566 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 25 Nov 2014 16:08:01 -0500 Subject: [PATCH 07/13] Add missing InvalidResponse class. --- endpoints/api/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 85c3c878d..5b5253ff6 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -53,6 +53,10 @@ class InvalidRequest(ApiException): def __init__(self, error_description, payload=None): ApiException.__init__(self, 'invalid_request', 400, error_description, payload) +class InvalidResponse(ApiException): + def __init__(self, error_description, payload=None): + ApiException.__init__(self, 'invalid_response', 400, error_description, payload) + class InvalidToken(ApiException): def __init__(self, error_description, payload=None): @@ -173,7 +177,7 @@ def path_param(name, description): def add_param(func): if not func: return func - + if '__api_path_params' not in dir(func): func.__api_path_params = {} func.__api_path_params[name] = { @@ -293,7 +297,7 @@ def require_fresh_login(func): if not user.password_hash or last_login >= valid_span: return func(*args, **kwargs) - + raise FreshLoginRequired() return wrapped From a1ea2f657123e04b12c23ca27b44c847bfec42a7 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 25 Nov 2014 16:08:29 -0500 Subject: [PATCH 08/13] Update requirements.txt --- requirements-nover.txt | 5 +++-- requirements.txt | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/requirements-nover.txt b/requirements-nover.txt index 262e0594d..8ea0cbcdd 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -20,7 +20,7 @@ redis hiredis docker-py pygithub -flask-restful +flask-restful==0.2.12 jsonschema git+https://github.com/NateFerrero/oauth2lib.git alembic @@ -36,4 +36,5 @@ psycopg2 pyyaml git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git -gipc \ No newline at end of file +gipc +avatar-generator diff --git a/requirements.txt b/requirements.txt index e8479b500..375756e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,9 +15,10 @@ PyPDF2==1.23 PyYAML==3.11 SQLAlchemy==0.9.8 Werkzeug==0.9.6 -git+https://github.com/DevTable/aniso8601-fake.git -git+https://github.com/DevTable/anunidecode.git -alembic==0.6.7 +alembic==0.7.0 +aniso8601==1.00 +anunidecode==0.01 +avatar-generator==0.0.13 backports.ssl-match-hostname==3.4.0.2 beautifulsoup4==4.3.2 blinker==1.3 @@ -34,10 +35,10 @@ html5lib==0.999 itsdangerous==0.24 jsonschema==2.4.0 marisa-trie==0.6 -git+https://github.com/NateFerrero/oauth2lib.git mixpanel-py==3.2.0 +oauth2lib==1.0.0 paramiko==1.15.1 -peewee==2.4.2 +peewee==2.4.3 psycopg2==2.5.4 py-bcrypt==0.4 pycrypto==2.6.1 From 1c32faa31d1c772feeca9b8abd05a0b906d946f7 Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Tue, 25 Nov 2014 16:23:49 -0500 Subject: [PATCH 09/13] Update the requirements-nover.txt to pull from the forked avatar lib and to use the proper forked libs in the versioned requirements.txt. --- requirements-nover.txt | 2 +- requirements.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-nover.txt b/requirements-nover.txt index 8ea0cbcdd..65dc5c1ad 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -37,4 +37,4 @@ pyyaml git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git gipc -avatar-generator +git+https://github.com/DevTable/avatar-generator.git diff --git a/requirements.txt b/requirements.txt index 375756e48..86bbd5c1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,9 +16,9 @@ PyYAML==3.11 SQLAlchemy==0.9.8 Werkzeug==0.9.6 alembic==0.7.0 -aniso8601==1.00 -anunidecode==0.01 -avatar-generator==0.0.13 +git+https://github.com/DevTable/aniso8601-fake.git +git+https://github.com/DevTable/anunidecode.git +git+https://github.com/DevTable/avatar-generator.git backports.ssl-match-hostname==3.4.0.2 beautifulsoup4==4.3.2 blinker==1.3 @@ -36,7 +36,7 @@ itsdangerous==0.24 jsonschema==2.4.0 marisa-trie==0.6 mixpanel-py==3.2.0 -oauth2lib==1.0.0 +git+https://github.com/NateFerrero/oauth2lib.git paramiko==1.15.1 peewee==2.4.3 psycopg2==2.5.4 From b3240de1f8c9785a1ad32d914d8fd237b039b07d Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 25 Nov 2014 19:59:24 -0500 Subject: [PATCH 10/13] Rename gravatar field after the bees merge. --- endpoints/api/user.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 7c4e8cec2..b713b3ff8 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -152,7 +152,7 @@ class User(ApiResource): 'id': 'UserView', 'type': 'object', 'description': 'Describes a user', - 'required': ['verified', 'anonymous', 'gravatar'], + 'required': ['verified', 'anonymous', 'avatar'], 'properties': { 'verified': { 'type': 'boolean', @@ -166,9 +166,9 @@ class User(ApiResource): 'type': 'string', 'description': 'The user\'s email address', }, - 'gravatar': { + 'avatar': { 'type': 'string', - 'description': 'Gravatar hash representing the user\'s icon' + 'description': 'Avatar hash representing the user\'s icon' }, 'organizations': { 'type': 'array', @@ -212,7 +212,7 @@ class User(ApiResource): user = get_authenticated_user() user_data = request.get_json() - try: + try: if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) @@ -230,8 +230,8 @@ class User(ApiResource): if model.find_user_by_email(new_email): # Email already used. raise request_error(message='E-mail address already used') - - if features.MAILING: + + if features.MAILING: logger.debug('Sending email to change email address for user: %s', user.username) code = model.create_confirm_email_code(user, new_email=new_email) @@ -247,7 +247,7 @@ class User(ApiResource): raise request_error(message='Username is already in use') model.change_username(user, new_username) - + except model.InvalidPasswordException, ex: raise request_error(exception=ex) @@ -316,7 +316,7 @@ class PrivateRepositories(ApiResource): plan = get_plan(cus.subscription.plan.id) if plan: repos_allowed = plan['privateRepos'] - + return { 'privateCount': private_repos, 'privateAllowed': (private_repos < repos_allowed) From 182c87b983b0fd0399d2d32ac168231c6ce3cba0 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 26 Nov 2014 10:53:51 -0500 Subject: [PATCH 11/13] Remove unused imports. --- endpoints/api/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 5b5253ff6..821a18f05 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,12 +1,10 @@ import logging -import json import datetime from app import app from flask import Blueprint, request, make_response, jsonify, session from flask.ext.restful import Resource, abort, Api, reqparse from flask.ext.restful.utils.cors import crossdomain -from werkzeug.exceptions import HTTPException from calendar import timegm from email.utils import formatdate from functools import partial, wraps From eab79ff1ade8a8986c3a046c5a087e2fb2c2c9b3 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 26 Nov 2014 10:54:16 -0500 Subject: [PATCH 12/13] Add caching headers to avatar endpoint. --- endpoints/web.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/endpoints/web.py b/endpoints/web.py index e297074ff..05fe65847 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -1,5 +1,4 @@ import logging -import os from flask import (abort, redirect, request, url_for, make_response, Response, Blueprint, send_from_directory, jsonify) @@ -19,6 +18,7 @@ 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.csrf import csrf_protect, generate_csrf_token +from endpoints.registry import set_cache_headers from util.names import parse_repository_name from util.useremails import send_email_changed from auth import scopes @@ -184,15 +184,17 @@ def status(): @app.route("/avatar/") -def render_avatar(avatar_hash): +@set_cache_headers +def render_avatar(avatar_hash, headers): try: size = int(request.args.get('size', 16)) except ValueError: size = 16 generated = Avatar.generate(size, avatar_hash) - headers = {'Content-Type': 'image/png'} - return make_response(generated, 200, headers) + resp = make_response(generated, 200, {'Content-Type': 'image/png'}) + resp.headers.extend(headers) + return resp @web.route('/tos', methods=['GET']) @@ -247,7 +249,7 @@ def receipt(): invoice = stripe.Invoice.retrieve(invoice_id) if invoice: user_or_org = model.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) @@ -257,9 +259,9 @@ def receipt(): else: if not user_or_org.username == current_user.db_user().username: abort(404) - return + return - file_data = renderInvoiceToPdf(invoice, user_or_org) + file_data = renderInvoiceToPdf(invoice, user_or_org) return Response(file_data, mimetype="application/pdf", headers={"Content-Disposition": "attachment;filename=receipt.pdf"}) @@ -276,7 +278,7 @@ def confirm_repo_email(): record = model.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 %s/%s. @@ -298,13 +300,13 @@ def confirm_email(): user, new_email, old_email = model.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') + return redirect(url_for('web.user', tab='email') if new_email else url_for('web.index')) From 8591889c62824d5561bc71e3224ac80967ebc7f1 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 26 Nov 2014 16:52:24 -0500 Subject: [PATCH 13/13] Generate PNG avatars. --- endpoints/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/endpoints/web.py b/endpoints/web.py index 05fe65847..4717f7d40 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -191,7 +191,7 @@ def render_avatar(avatar_hash, headers): except ValueError: size = 16 - generated = Avatar.generate(size, avatar_hash) + generated = Avatar.generate(size, avatar_hash, "PNG") resp = make_response(generated, 200, {'Content-Type': 'image/png'}) resp.headers.extend(headers) return resp