Merge remote-tracking branch 'origin/orgs'

Conflicts:
	static/partials/repo-admin.html
This commit is contained in:
yackob03 2013-11-08 16:44:49 -05:00
commit d7cae4fbca
72 changed files with 18683 additions and 1028 deletions

View file

@ -43,3 +43,16 @@ kill -HUP <pid of gunicorn>
kill <pids of worker daemons>
restart daemons
```
running the tests:
```
STACK=test python -m unittest discover
```
generating screenshots:
```
cd screenshots
casperjs screenshots.js --d
```

10
app.py
View file

@ -7,7 +7,8 @@ from flask.ext.principal import Principal
from flask.ext.login import LoginManager
from flask.ext.mail import Mail
from config import ProductionConfig, DebugConfig, LocalHostedConfig
from config import (ProductionConfig, DebugConfig, LocalHostedConfig,
TestConfig)
from util import analytics
@ -22,6 +23,9 @@ if stack.startswith('prod'):
elif stack.startswith('localhosted'):
logger.info('Running with debug config on production data.')
config = LocalHostedConfig()
elif stack.startswith('test'):
logger.info('Running with test config on ephemeral data.')
config = TestConfig()
else:
logger.info('Running with debug config.')
config = DebugConfig()
@ -36,6 +40,6 @@ login_manager.init_app(app)
mail = Mail()
mail.init_app(app)
stripe.api_key = app.config['STRIPE_SECRET_KEY']
stripe.api_key = app.config.get('STRIPE_SECRET_KEY', None)
mixpanel = analytics.init_app(app)
mixpanel = app.config['ANALYTICS'].init_app(app)

View file

@ -14,6 +14,8 @@ logger = logging.getLogger(__name__)
_ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role'])
_RepositoryNeed = partial(_ResourceNeed, 'repository')
_OrganizationNeed = namedtuple('organization', ['orgname', 'role'])
_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role'])
class QuayDeferredPermissionUser(Identity):
@ -27,13 +29,32 @@ class QuayDeferredPermissionUser(Identity):
logger.debug('Loading user permissions after deferring.')
user_object = model.get_user(self.id)
for user in model.get_all_user_permissions(user_object):
grant = _RepositoryNeed(user.repositorypermission.repository.namespace,
user.repositorypermission.repository.name,
user.repositorypermission.role.name)
# Add the user specific permissions
user_grant = UserNeed(user_object.username)
self.provides.add(user_grant)
# Every user is the admin of their own 'org'
user_namespace = _OrganizationNeed(user_object.username, 'admin')
self.provides.add(user_namespace)
# Add repository permissions
for perm in model.get_all_user_permissions(user_object):
grant = _RepositoryNeed(perm.repository.namespace,
perm.repository.name, perm.role.name)
logger.debug('User added permission: {0}'.format(grant))
self.provides.add(grant)
# Add namespace permissions derived
for team in model.get_org_wide_permissions(user_object):
grant = _OrganizationNeed(team.organization.username, team.role.name)
logger.debug('Organization team added permission: {0}'.format(grant))
self.provides.add(grant)
team_grant = _TeamNeed(team.organization.username, team.name,
team.role.name)
logger.debug('Team added permission: {0}'.format(team_grant))
self.provides.add(team_grant)
self._permissions_loaded = True
return super(QuayDeferredPermissionUser, self).can(permission)
@ -43,7 +64,9 @@ class ModifyRepositoryPermission(Permission):
def __init__(self, namespace, name):
admin_need = _RepositoryNeed(namespace, name, 'admin')
write_need = _RepositoryNeed(namespace, name, 'write')
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need)
org_admin_need = _OrganizationNeed(namespace, 'admin')
super(ModifyRepositoryPermission, self).__init__(admin_need, write_need,
org_admin_need)
class ReadRepositoryPermission(Permission):
@ -51,14 +74,25 @@ class ReadRepositoryPermission(Permission):
admin_need = _RepositoryNeed(namespace, name, 'admin')
write_need = _RepositoryNeed(namespace, name, 'write')
read_need = _RepositoryNeed(namespace, name, 'read')
org_admin_need = _OrganizationNeed(namespace, 'admin')
super(ReadRepositoryPermission, self).__init__(admin_need, write_need,
read_need)
read_need, org_admin_need)
class AdministerRepositoryPermission(Permission):
def __init__(self, namespace, name):
admin_need = _RepositoryNeed(namespace, name, 'admin')
super(AdministerRepositoryPermission, self).__init__(admin_need)
org_admin_need = _OrganizationNeed(namespace, 'admin')
super(AdministerRepositoryPermission, self).__init__(admin_need,
org_admin_need)
class CreateRepositoryPermission(Permission):
def __init__(self, namespace):
admin_org = _OrganizationNeed(namespace, 'admin')
create_repo_org = _OrganizationNeed(namespace, 'creator')
super(CreateRepositoryPermission, self).__init__(admin_org,
create_repo_org)
class UserPermission(Permission):
@ -67,6 +101,31 @@ class UserPermission(Permission):
super(UserPermission, self).__init__(user_need)
class AdministerOrganizationPermission(Permission):
def __init__(self, org_name):
admin_org = _OrganizationNeed(org_name, 'admin')
super(AdministerOrganizationPermission, self).__init__(admin_org)
class OrganizationMemberPermission(Permission):
def __init__(self, org_name):
admin_org = _OrganizationNeed(org_name, 'admin')
repo_creator_org = _OrganizationNeed(org_name, 'creator')
org_member = _OrganizationNeed(org_name, 'member')
super(OrganizationMemberPermission, self).__init__(admin_org, org_member,
repo_creator_org)
class ViewTeamPermission(Permission):
def __init__(self, org_name, team_name):
team_admin = _TeamNeed(org_name, team_name, 'admin')
team_creator = _TeamNeed(org_name, team_name, 'creator')
team_member = _TeamNeed(org_name, team_name, 'member')
admin_org = _OrganizationNeed(org_name, 'admin')
super(ViewTeamPermission, self).__init__(team_admin, team_creator,
team_member, admin_org)
@identity_loaded.connect_via(app)
def on_identity_loaded(sender, identity):
logger.debug('Identity loaded: %s' % identity)

View file

@ -2,6 +2,13 @@ import logging
import sys
from peewee import MySQLDatabase, SqliteDatabase
from storage.s3 import S3Storage
from storage.local import LocalStorage
from data.userfiles import UserRequestFiles
from util import analytics
from test.teststorage import FakeStorage, FakeUserfiles
from test import analytics as fake_analytics
LOG_FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - ' + \
@ -31,6 +38,12 @@ class SQLiteDB(object):
DB_DRIVER = SqliteDatabase
class EphemeralDB(object):
DB_NAME = ':memory:'
DB_CONNECTION_ARGS = {}
DB_DRIVER = SqliteDatabase
class RDSMySQL(object):
DB_NAME = 'quay'
DB_CONNECTION_ARGS = {
@ -49,12 +62,27 @@ class AWSCredentials(object):
class S3Storage(AWSCredentials):
STORAGE_KIND = 's3'
STORAGE = S3Storage('', AWSCredentials.AWS_ACCESS_KEY,
AWSCredentials.AWS_SECRET_KEY,
AWSCredentials.REGISTRY_S3_BUCKET)
class LocalStorage(object):
STORAGE_KIND = 'local'
LOCAL_STORAGE_DIR = 'test/data/registry'
STORAGE = LocalStorage('test/data/registry')
class FakeStorage(object):
STORAGE = FakeStorage()
class FakeUserfiles(object):
USERFILES = FakeUserfiles()
class S3Userfiles(AWSCredentials):
USERFILES = UserRequestFiles(AWSCredentials.AWS_ACCESS_KEY,
AWSCredentials.AWS_SECRET_KEY,
AWSCredentials.REGISTRY_S3_BUCKET)
class StripeTestConfig(object):
@ -67,11 +95,16 @@ class StripeLiveConfig(object):
STRIPE_PUBLISHABLE_KEY = 'pk_live_P5wLU0vGdHnZGyKnXlFG4oiu'
class FakeAnalytics(object):
ANALYTICS = fake_analytics
class MixpanelTestConfig(object):
ANALYTICS = analytics
MIXPANEL_KEY = '38014a0f27e7bdc3ff8cc7cc29c869f9'
class MixpanelProdConfig(object):
class MixpanelProdConfig(MixpanelTestConfig):
MIXPANEL_KEY = '50ff2b2569faa3a51c8f5724922ffb7e'
@ -100,9 +133,20 @@ class BuildNodeConfig(object):
BUILD_NODE_PULL_TOKEN = 'F02O2E86CQLKZUQ0O81J8XDHQ6F0N1V36L9JTOEEK6GKKMT1GI8PTJQT4OU88Y6G'
class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles,
FakeAnalytics):
LOGGING_CONFIG = {
'level': logging.DEBUG,
'format': LOG_FORMAT
}
POPULATE_DB_TEST_DATA = True
TESTING = True
INCLUDE_TEST_ENDPOINTS = True
class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
StripeTestConfig, MixpanelTestConfig, GitHubTestConfig,
DigitalOceanConfig, AWSCredentials, BuildNodeConfig):
DigitalOceanConfig, BuildNodeConfig, S3Userfiles):
LOGGING_CONFIG = {
'level': logging.DEBUG,
'format': LOG_FORMAT
@ -115,7 +159,7 @@ class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB,
class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
StripeLiveConfig, MixpanelTestConfig,
GitHubProdConfig, DigitalOceanConfig,
BuildNodeConfig):
BuildNodeConfig, S3Userfiles):
LOGGING_CONFIG = {
'level': logging.DEBUG,
'format': LOG_FORMAT
@ -125,7 +169,8 @@ class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
class ProductionConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL,
StripeLiveConfig, MixpanelProdConfig,
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig):
GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig,
S3Userfiles):
LOGGING_CONFIG = {
'stream': sys.stderr,
'level': logging.DEBUG,

View file

@ -4,7 +4,6 @@ import logging
from random import SystemRandom
from datetime import datetime
from peewee import *
from peewee import create_model_tables
from app import app
@ -34,6 +33,37 @@ class User(BaseModel):
email = CharField(unique=True, index=True)
verified = BooleanField(default=False)
stripe_id = CharField(index=True, null=True)
organization = BooleanField(default=False, index=True)
class TeamRole(BaseModel):
name = CharField(index=True)
class Team(BaseModel):
name = CharField(index=True)
organization = ForeignKeyField(User, index=True)
role = ForeignKeyField(TeamRole)
description = TextField(default='')
class Meta:
database = db
indexes = (
# A team name must be unique within an organization
(('name', 'organization'), True),
)
class TeamMember(BaseModel):
user = ForeignKeyField(User, index=True)
team = ForeignKeyField(Team, index=True)
class Meta:
database = db
indexes = (
# A user may belong to a team only once
(('user', 'team'), True),
)
class LoginService(BaseModel):
@ -79,13 +109,15 @@ class Role(BaseModel):
class RepositoryPermission(BaseModel):
user = ForeignKeyField(User, index=True)
team = ForeignKeyField(Team, index=True, null=True)
user = ForeignKeyField(User, index=True, null=True)
repository = ForeignKeyField(Repository, index=True)
role = ForeignKeyField(Role)
class Meta:
database = db
indexes = (
(('team', 'repository'), True),
(('user', 'repository'), True),
)
@ -168,14 +200,7 @@ class QueueItem(BaseModel):
processing_expires = DateTimeField(null=True, index=True)
def initialize_db():
create_model_tables([User, Repository, Image, AccessToken, Role,
RepositoryPermission, Visibility, RepositoryTag,
EmailConfirmation, FederatedLogin, LoginService,
QueueItem, RepositoryBuild])
Role.create(name='admin')
Role.create(name='write')
Role.create(name='read')
Visibility.create(name='public')
Visibility.create(name='private')
LoginService.create(name='github')
all_models = [User, Repository, Image, AccessToken, Role,
RepositoryPermission, Visibility, RepositoryTag,
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole]

View file

@ -22,6 +22,14 @@ class InvalidUsernameException(DataModelException):
pass
class InvalidOrganizationException(DataModelException):
pass
class InvalidTeamException(DataModelException):
pass
class InvalidPasswordException(DataModelException):
pass
@ -73,6 +81,147 @@ def create_user(username, password, email):
raise DataModelException(ex.message)
def create_organization(name, email, creating_user):
try:
# Create the org
new_org = create_user(name, None, email)
new_org.organization = True
new_org.save()
# Create a team for the owners
owners_team = create_team('owners', new_org, 'admin')
# Add the user who created the org to the owners team
add_user_to_team(creating_user, owners_team)
return new_org
except InvalidUsernameException:
raise InvalidOrganizationException('Invalid organization name: %s' % name)
def convert_user_to_organization(user, admin_user):
# Change the user to an organization.
user.organization = True
# disable this account for login.
user.password_hash = None
user.save()
# Clear any federated auth pointing to this user
FederatedLogin.delete().where(FederatedLogin.user == user).execute()
# Create a team for the owners
owners_team = create_team('owners', user, 'admin')
# Add the user who will admin the org to the owners team
add_user_to_team(admin_user, owners_team)
return user
def create_team(name, org, team_role_name, description=''):
if not validate_username(name):
raise InvalidTeamException('Invalid team name: %s' % name)
if not org.organization:
raise InvalidOrganizationException('User with name %s is not an org.' %
org.username)
team_role = TeamRole.get(TeamRole.name == team_role_name)
return Team.create(name=name, organization=org, role=team_role,
description=description)
def __get_user_admin_teams(org_name, team_name, username):
Org = User.alias()
user_teams = Team.select().join(TeamMember).join(User)
with_org = user_teams.switch(Team).join(Org,
on=(Org.id == Team.organization))
with_role = with_org.switch(Team).join(TeamRole)
admin_teams = with_role.where(User.username == username,
Org.username == org_name,
TeamRole.name == 'admin')
return admin_teams
def remove_team(org_name, team_name, removed_by_username):
joined = Team.select(Team, TeamRole).join(User).switch(Team).join(TeamRole)
found = list(joined.where(User.organization == True,
User.username == org_name,
Team.name == team_name))
if not found:
raise InvalidTeamException('Team \'%s\' is not a team in org \'%s\'' %
(team_name, org_name))
team = found[0]
if team.role.name == 'admin':
admin_teams = list(__get_user_admin_teams(org_name, team_name,
removed_by_username))
if len(admin_teams) <= 1:
# The team we are trying to remove is the only admin team for this user
msg = ('Deleting team \'%s\' would remove all admin from user \'%s\'' %
(team_name, removed_by_username))
raise DataModelException(msg)
team.delete_instance(recursive=True, delete_nullable=True)
def add_user_to_team(user, team):
try:
return TeamMember.create(user=user, team=team)
except Exception:
raise DataModelException('Unable to add user \'%s\' to team: \'%s\'' %
(user.username, team.name))
def remove_user_from_team(org_name, team_name, username, removed_by_username):
Org = User.alias()
joined = TeamMember.select().join(User).switch(TeamMember).join(Team)
with_role = joined.join(TeamRole)
with_org = with_role.switch(Team).join(Org,
on=(Org.id == Team.organization))
found = list(with_org.where(User.username == username,
Org.username == org_name,
Team.name == team_name))
if not found:
raise DataModelException('User %s does not belong to team %s' %
(username, team_name))
if username == removed_by_username:
admin_team_query = __get_user_admin_teams(org_name, team_name, username)
admin_team_names = {team.name for team in admin_team_query}
if team_name in admin_team_names and len(admin_team_names) <= 1:
msg = 'User cannot remove themselves from their only admin team.'
raise DataModelException(msg)
user_in_team = found[0]
user_in_team.delete_instance()
def get_team_org_role(team):
return TeamRole.get(TeamRole.id == team.role.id)
def set_team_org_permission(team, team_role_name, set_by_username):
if team.role.name == 'admin' and team_role_name != 'admin':
# We need to make sure we're not removing the users only admin role
user_admin_teams = __get_user_admin_teams(team.organization.username,
team.name, set_by_username)
admin_team_set = {admin_team.name for admin_team in user_admin_teams}
if team.name in admin_team_set and len(admin_team_set) <= 1:
msg = (('Cannot remove admin from team \'%s\' because calling user ' +
'would no longer have admin on org \'%s\'') %
(team.name, team.organization.username))
raise DataModelException(msg)
new_role = TeamRole.get(TeamRole.name == team_role_name)
team.role = new_role
team.save()
return team
def create_federated_user(username, email, service_name, service_id):
new_user = create_user(username, None, email)
new_user.verified = True
@ -142,14 +291,41 @@ def validate_reset_code(code):
def get_user(username):
try:
return User.get(User.username == username)
return User.get(User.username == username, User.organization == False)
except User.DoesNotExist:
return None
def get_matching_users(username_prefix):
query = User.select().where(User.username ** (username_prefix + '%'))
return list(query.limit(10))
def get_matching_teams(team_prefix, organization):
query = Team.select().where(Team.name ** (team_prefix + '%'),
Team.organization == organization)
return query.limit(10)
def get_matching_users(username_prefix, organization=None):
Org = User.alias()
users_no_orgs = (User.username ** (username_prefix + '%') &
(User.organization == False))
query = User.select(User.username, Org.username).where(users_no_orgs)
if organization:
with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team,
JOIN_LEFT_OUTER)
with_org = with_team.join(Org, JOIN_LEFT_OUTER,
on=(Org.id == Team.organization))
query = with_org.where((Org.id == organization) | (Org.id >> None))
class MatchingUserResult(object):
def __init__(self, *args):
self.username = args[0]
if organization:
self.is_org_member = (args[1] == organization.username)
else:
self.is_org_member = None
return (MatchingUserResult(*args) for args in query.tuples().limit(10))
def verify_user(username, password):
@ -167,33 +343,121 @@ def verify_user(username, password):
return None
def get_user_organizations(username):
UserAlias = User.alias()
all_teams = User.select().distinct().join(Team).join(TeamMember)
with_user = all_teams.join(UserAlias, on=(UserAlias.id == TeamMember.user))
return with_user.where(User.organization == True,
UserAlias.username == username)
def get_organization(name):
try:
return User.get(username=name, organization=True)
except User.DoesNotExist:
raise InvalidOrganizationException('Organization does not exist: %s' %
name)
def get_organization_team(orgname, teamname):
joined = Team.select().join(User)
query = joined.where(Team.name == teamname, User.organization == True,
User.username == orgname).limit(1)
result = list(query)
if not result:
raise InvalidTeamException('Team does not exist: %s/%s', orgname,
teamname)
return result[0]
def get_organization_members_with_teams(organization):
joined = TeamMember.select().annotate(Team).annotate(User)
query = joined.where(Team.organization == organization)
return query
def get_organization_team_members(teamid):
joined = User.select().join(TeamMember).join(Team)
query = joined.where(Team.id == teamid)
return query
def get_organization_member_set(orgname):
Org = User.alias()
user_teams = User.select(User.username).join(TeamMember).join(Team)
with_org = user_teams.join(Org, on=(Org.username == orgname))
return {user.username for user in with_org}
def get_teams_within_org(organization):
return Team.select().where(Team.organization == organization)
def get_user_teams_within_org(username, organization):
joined = Team.select().join(TeamMember).join(User)
return joined.where(Team.organization == organization,
User.username == username)
def get_visible_repositories(username=None, include_public=True, limit=None,
sort=False):
sort=False, namespace=None):
if not username and not include_public:
return []
query = Repository.select().distinct().join(Visibility)
or_clauses = []
if include_public:
or_clauses.append((Visibility.name == 'public'))
query = (Repository
.select(Repository, Visibility)
.distinct()
.join(Visibility)
.switch(Repository)
.join(RepositoryPermission, JOIN_LEFT_OUTER))
where_clause = None
admin_query = None
if username:
with_perms = query.switch(Repository).join(RepositoryPermission,
JOIN_LEFT_OUTER)
query = with_perms.join(User)
or_clauses.append(User.username == username)
UserThroughTeam = User.alias()
Org = User.alias()
AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias()
AdminUser = User.alias()
if sort:
with_images = query.switch(Repository).join(Image, JOIN_LEFT_OUTER)
query = with_images.order_by(Image.created.desc())
query = (query
.join(User, JOIN_LEFT_OUTER)
.switch(RepositoryPermission)
.join(Team, JOIN_LEFT_OUTER)
.join(TeamMember, JOIN_LEFT_OUTER)
.join(UserThroughTeam, JOIN_LEFT_OUTER, on=(UserThroughTeam.id ==
TeamMember.user))
.switch(Repository)
.join(Org, JOIN_LEFT_OUTER, on=(Org.username == Repository.namespace))
.join(AdminTeam, JOIN_LEFT_OUTER, on=(Org.id ==
AdminTeam.organization))
.join(TeamRole, JOIN_LEFT_OUTER, on=(AdminTeam.role == TeamRole.id))
.switch(AdminTeam)
.join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id ==
AdminTeamMember.team))
.join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user ==
AdminUser.id)))
if (or_clauses):
query = query.where(reduce(operator.or_, or_clauses))
where_clause = ((User.username == username) |
(UserThroughTeam.username == username) |
((AdminUser.username == username) &
(TeamRole.name == 'admin')))
if namespace:
where_clause = where_clause & (Repository.namespace == namespace)
if include_public:
new_clause = (Visibility.name == 'public')
if where_clause:
where_clause = where_clause | new_clause
else:
where_clause = new_clause
if limit:
query = query.limit(limit)
query.limit(limit)
return query
return query.where(where_clause)
def get_matching_repositories(repo_term, username=None):
@ -234,10 +498,41 @@ def update_email(user, new_email):
def get_all_user_permissions(user):
select = User.select(User, Repository, RepositoryPermission, Role)
with_repo = select.join(RepositoryPermission).join(Repository)
with_role = with_repo.switch(RepositoryPermission).join(Role)
return with_role.where(User.username == user.username)
select = RepositoryPermission.select(RepositoryPermission, Role, Repository)
with_role = select.join(Role)
with_repo = with_role.switch(RepositoryPermission).join(Repository)
through_user = with_repo.switch(RepositoryPermission).join(User,
JOIN_LEFT_OUTER)
as_perm = through_user.switch(RepositoryPermission)
through_team = as_perm.join(Team, JOIN_LEFT_OUTER).join(TeamMember,
JOIN_LEFT_OUTER)
UserThroughTeam = User.alias()
with_team_member = through_team.join(UserThroughTeam, JOIN_LEFT_OUTER,
on=(UserThroughTeam.id ==
TeamMember.user))
return with_team_member.where((User.id == user) |
(UserThroughTeam.id == user))
def get_org_wide_permissions(user):
Org = User.alias()
team_with_role = Team.select(Team, Org, TeamRole).join(TeamRole)
with_org = team_with_role.switch(Team).join(Org, on=(Team.organization ==
Org.id))
with_user = with_org.switch(Team).join(TeamMember).join(User)
return with_user.where(User.id == user, Org.organization == True)
def get_all_repo_teams(namespace_name, repository_name):
select = RepositoryPermission.select(Team.name.alias('team_name'),
Role.name, RepositoryPermission)
with_team = select.join(Team)
with_role = with_team.switch(RepositoryPermission).join(Role)
with_repo = with_role.switch(RepositoryPermission).join(Repository)
return with_repo.where(Repository.namespace == namespace_name,
Repository.name == repository_name)
def get_all_repo_users(namespace_name, repository_name):
@ -292,8 +587,10 @@ def create_repository(namespace, name, owner, visibility='private'):
repo = Repository.create(namespace=namespace, name=name,
visibility=private)
admin = Role.get(name='admin')
permission = RepositoryPermission.create(user=owner, repository=repo,
role=admin)
if owner and not owner.organization:
permission = RepositoryPermission.create(user=owner, repository=repo,
role=admin)
return repo
@ -337,6 +634,7 @@ def get_repository_images(namespace_name, repository_name):
return joined.where(Repository.name == repository_name,
Repository.namespace == namespace_name)
def list_repository_tags(namespace_name, repository_name):
select = RepositoryTag.select(RepositoryTag, Image)
with_repo = select.join(Repository)
@ -386,9 +684,18 @@ def get_parent_images(image_obj):
def create_or_update_tag(namespace_name, repository_name, tag_name,
tag_docker_image_id):
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
image = Image.get(Image.docker_image_id == tag_docker_image_id)
try:
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
except Repository.DoesNotExist:
raise DataModelException('Invalid repository %s/%s' %
(namespace_name, repository_name))
try:
image = Image.get(Image.docker_image_id == tag_docker_image_id)
except Image.DoesNotExist:
raise DataModelException('Invalid image with id: %s' %
tag_docker_image_id)
try:
tag = RepositoryTag.get(RepositoryTag.repository == repo,
@ -402,78 +709,127 @@ def create_or_update_tag(namespace_name, repository_name, tag_name,
def delete_tag(namespace_name, repository_name, tag_name):
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
tag = RepositoryTag.get(RepositoryTag.repository == repo,
RepositoryTag.name == tag_name)
tag.delete_instance()
joined = RepositoryTag.select().join(Repository)
found = list(joined.where(Repository.name == repository_name,
Repository.namespace == namespace_name,
RepositoryTag.name == tag_name))
if not found:
msg = ('Invalid repository tag \'%s\' on repository \'%s/%s\'' %
(tag_name, namespace_name, repository_name))
raise DataModelException(msg)
found[0].delete_instance()
def delete_all_repository_tags(namespace_name, repository_name):
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
RepositoryTag.delete().where(RepositoryTag.repository == repo)
try:
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
except Repository.DoesNotExist:
raise DataModelException('Invalid repository \'%s/%s\'' %
(namespace_name, repository_name))
RepositoryTag.delete().where(RepositoryTag.repository == repo).execute()
def get_user_repo_permissions(user, repository):
select = RepositoryPermission.select()
return select.where(RepositoryPermission.user == user,
RepositoryPermission.repository == repository)
def user_permission_repo_query(username, namespace_name, repository_name):
selected = RepositoryPermission.select(User, Repository, Role,
def __entity_permission_repo_query(entity_id, entity_table,
entity_id_property, namespace_name,
repository_name):
""" This method works for both users and teams. """
selected = RepositoryPermission.select(entity_table, Repository, Role,
RepositoryPermission)
with_user = selected.join(User)
with_user = selected.join(entity_table)
with_role = with_user.switch(RepositoryPermission).join(Role)
with_repo = with_role.switch(RepositoryPermission).join(Repository)
return with_repo.where(Repository.name == repository_name,
Repository.namespace == namespace_name,
User.username == username)
entity_id_property == entity_id)
def get_user_reponame_permission(username, namespace_name, repository_name):
fetched = list(user_permission_repo_query(username, namespace_name,
repository_name))
fetched = list(__entity_permission_repo_query(username, User, User.username,
namespace_name,
repository_name))
if not fetched:
raise DataModelException('User does not have permission for repo.')
return fetched[0]
def get_team_reponame_permission(team_name, namespace_name, repository_name):
fetched = list(__entity_permission_repo_query(team_name, Team, Team.name,
namespace_name,
repository_name))
if not fetched:
raise DataModelException('Team does not have permission for repo.')
return fetched[0]
def delete_user_permission(username, namespace_name, repository_name):
if username == namespace_name:
raise DataModelException('Namespace owner must always be admin.')
fetched = list(__entity_permission_repo_query(username, User, User.username,
namespace_name,
repository_name))
if not fetched:
raise DataModelException('User does not have permission for repo.')
fetched[0].delete_instance()
def delete_team_permission(team_name, namespace_name, repository_name):
fetched = list(__entity_permission_repo_query(team_name, Team, Team.name,
namespace_name,
repository_name))
if not fetched:
raise DataModelException('Team does not have permission for repo.')
fetched[0].delete_instance()
def __set_entity_repo_permission(entity, permission_entity_property,
namespace_name, repository_name, role_name):
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
new_role = Role.get(Role.name == role_name)
# Fetch any existing permission for this entity on the repo
try:
entity_attr = getattr(RepositoryPermission, permission_entity_property)
perm = RepositoryPermission.get(entity_attr == entity,
RepositoryPermission.repository == repo)
perm.role = new_role
perm.save()
return perm
except RepositoryPermission.DoesNotExist:
set_entity_kwargs = {permission_entity_property: entity}
new_perm = RepositoryPermission.create(repository=repo, role=new_role,
**set_entity_kwargs)
return new_perm
def set_user_repo_permission(username, namespace_name, repository_name,
role_name):
if username == namespace_name:
raise DataModelException('Namespace owner must always be admin.')
user = User.get(User.username == username)
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
new_role = Role.get(Role.name == role_name)
# Fetch any existing permission for this user on the repo
try:
perm = RepositoryPermission.get(RepositoryPermission.user == user,
RepositoryPermission.repository == repo)
perm.role = new_role
perm.save()
return perm
except RepositoryPermission.DoesNotExist:
new_perm = RepositoryPermission.create(repository=repo, user=user,
role=new_role)
return new_perm
return __set_entity_repo_permission(user, 'user', namespace_name,
repository_name, role_name)
def delete_user_permission(username, namespace_name, repository_name):
if username == namespace_name:
raise DataModelException('Namespace owner must always be admin.')
def set_team_repo_permission(team_name, namespace_name, repository_name,
role_name):
team = list(Team.select().join(User).where(Team.name == team_name,
User.username == namespace_name))
if not team:
raise DataModelException('No team \'%s\' in organization \'%s\'.' %
(team_name, namespace_name))
fetched = list(user_permission_repo_query(username, namespace_name,
repository_name))
if not fetched:
raise DataModelException('User does not have permission for repo.')
fetched[0].delete_instance()
return __set_entity_repo_permission(team[0], 'team', namespace_name,
repository_name, role_name)
def purge_repository(namespace_name, repository_name):
@ -554,14 +910,6 @@ def load_token_data(code):
raise InvalidTokenException('Invalid delegate token code: %s' % code)
def get_repository_build(request_dbid):
try:
return RepositoryBuild.get(RepositoryBuild.id == request_dbid)
except RepositoryBuild.DoesNotExist:
msg = 'Unable to locate a build by id: %s' % request_dbid
raise InvalidRepositoryBuildException(msg)
def list_repository_builds(namespace_name, repository_name,
include_inactive=True):
joined = RepositoryBuild.select().join(Repository)

87
data/plans.py Normal file
View file

@ -0,0 +1,87 @@
import json
import itertools
USER_PLANS = [
{
'title': 'Open Source',
'price': 0,
'privateRepos': 0,
'stripeId': 'free',
'audience': 'Share with the world',
},
{
'title': 'Micro',
'price': 700,
'privateRepos': 5,
'stripeId': 'micro',
'audience': 'For smaller teams',
},
{
'title': 'Basic',
'price': 1200,
'privateRepos': 10,
'stripeId': 'small',
'audience': 'For your basic team',
},
{
'title': 'Medium',
'price': 2200,
'privateRepos': 20,
'stripeId': 'medium',
'audience': 'For medium teams',
},
{
'title': 'Large',
'price': 5000,
'privateRepos': 50,
'stripeId': 'large',
'audience': 'For larger teams',
},
]
BUSINESS_PLANS = [
{
'title': 'Open Source',
'price': 0,
'privateRepos': 0,
'stripeId': 'bus-free',
'audience': 'Committment to FOSS',
},
{
'title': 'Skiff',
'price': 2500,
'privateRepos': 10,
'stripeId': 'bus-micro',
'audience': 'For startups',
},
{
'title': 'Yacht',
'price': 5000,
'privateRepos': 20,
'stripeId': 'bus-small',
'audience': 'For small businesses',
},
{
'title': 'Freighter',
'price': 10000,
'privateRepos': 50,
'stripeId': 'bus-medium',
'audience': 'For normal businesses',
},
{
'title': 'Tanker',
'price': 20000,
'privateRepos': 125,
'stripeId': 'bus-large',
'audience': 'For large businesses',
},
]
def get_plan(id):
""" Returns the plan with the given ID or None if none. """
for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS):
if plan['stripeId'] == id:
return plan
return None

View file

@ -5,30 +5,33 @@ import requests
import urlparse
import json
from flask import request, make_response, jsonify, abort, url_for
from flask import request, make_response, jsonify, abort
from flask.ext.login import login_required, current_user, logout_user
from flask.ext.principal import identity_changed, AnonymousIdentity
from functools import wraps
from collections import defaultdict
import storage
from data import model
from data.userfiles import UserRequestFiles
from data.queue import dockerfile_build_queue
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
from app import app
from util.email import send_confirmation_email, send_recovery_email
from util.names import parse_repository_name
from util.gravatar import compute_hash
from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission,
AdministerRepositoryPermission)
AdministerRepositoryPermission,
CreateRepositoryPermission,
AdministerOrganizationPermission,
OrganizationMemberPermission,
ViewTeamPermission)
from endpoints import registry
from endpoints.web import common_login
from util.cache import cache_control
store = storage.load()
store = app.config['STORAGE']
user_files = app.config['USERFILES']
logger = logging.getLogger(__name__)
@ -46,17 +49,41 @@ def handle_dme(ex):
return make_response(ex.message, 400)
@app.errorhandler(KeyError)
def handle_dme(ex):
return make_response(ex.message, 400)
@app.route('/api/')
def welcome():
return make_response('welcome', 200)
@app.route('/api/plans/')
def plans_list():
return jsonify({
'user': USER_PLANS,
'business': BUSINESS_PLANS,
})
@app.route('/api/user/', methods=['GET'])
def get_logged_in_user():
def org_view(o):
admin_org = AdministerOrganizationPermission(o.username)
return {
'name': o.username,
'gravatar': compute_hash(o.email),
'is_org_admin': admin_org.can(),
'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can()
}
if current_user.is_anonymous():
return jsonify({'anonymous': True})
user = current_user.db_user()
organizations = model.get_user_organizations(user.username)
return jsonify({
'verified': user.verified,
'anonymous': False,
@ -64,8 +91,47 @@ def get_logged_in_user():
'email': user.email,
'gravatar': compute_hash(user.email),
'askForPassword': user.password_hash is None,
'organizations': [org_view(o) for o in organizations],
'can_create_repo': True
})
@app.route('/api/user/convert', methods=['POST'])
@api_login_required
def convert_user_to_organization():
user = current_user.db_user()
convert_data = request.get_json()
# Ensure that the new admin user is the not user being converted.
admin_username = convert_data['adminUser']
if admin_username == user.username:
error_resp = jsonify({
'reason': 'invaliduser'
})
error_resp.status_code = 400
return error_resp
# Ensure that the sign in credentials work.
admin_password = convert_data['adminPassword']
if not model.verify_user(admin_username, admin_password):
error_resp = jsonify({
'reason': 'invaliduser'
})
error_resp.status_code = 400
return error_resp
# Subscribe the organization to the new plan.
plan = convert_data['plan']
subscribe(user, plan, None, BUSINESS_PLANS)
# Convert the user to an organization.
model.convert_user_to_organization(user, model.get_user(admin_username))
# And finally login with the admin credentials.
return conduct_signin(admin_username, admin_password)
@app.route('/api/user/', methods=['PUT'])
@api_login_required
def change_user_details():
@ -74,7 +140,7 @@ def change_user_details():
user_data = request.get_json();
try:
if user_data['password']:
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
model.change_password(user, user_data['password'])
except model.InvalidPasswordException, ex:
@ -93,9 +159,11 @@ def change_user_details():
'askForPassword': user.password_hash is None,
})
@app.route('/api/user/', methods=['POST'])
def create_user_api():
user_data = request.get_json()
existing_user = model.get_user(user_data['username'])
if existing_user:
error_resp = jsonify({
@ -125,6 +193,10 @@ def signin_api():
username = signin_data['username']
password = signin_data['password']
return conduct_signin(username, password)
def conduct_signin(username, password):
#TODO Allow email login
needs_email_verification = False
invalid_credentials = False
@ -175,34 +247,320 @@ def get_matching_users(prefix):
})
user_files = UserRequestFiles(app.config['AWS_ACCESS_KEY'],
app.config['AWS_SECRET_KEY'],
app.config['REGISTRY_S3_BUCKET'])
@app.route('/api/entities/<prefix>', methods=['GET'])
@api_login_required
def get_matching_entities(prefix):
teams = []
organization_name = request.args.get('organization', None)
organization = None
if organization_name:
permission = OrganizationMemberPermission(organization_name)
if permission.can():
try:
organization = model.get_organization(organization_name)
except:
pass
if organization:
teams = model.get_matching_teams(prefix, organization)
users = model.get_matching_users(prefix, organization)
def team_view(team):
result = {
'name': team.name,
'kind': 'team',
'is_org_member': True
}
return result
def user_view(user):
user_json = {
'name': user.username,
'kind': 'user',
}
if user.is_org_member is not None:
user_json['is_org_member'] = user.is_org_member
return user_json
team_data = [team_view(team) for team in teams]
user_data = [user_view(user) for user in users]
return jsonify({
'results': team_data + user_data
})
def team_view(orgname, t):
view_permission = ViewTeamPermission(orgname, t.name)
role = model.get_team_org_role(t).name
return {
'id': t.id,
'name': t.name,
'description': t.description,
'can_view': view_permission.can(),
'role': role
}
@app.route('/api/organization/', methods=['POST'])
@api_login_required
def create_organization_api():
org_data = request.get_json()
existing = None
try:
existing = model.get_organization(org_data['name']) or model.get_user(org_data['name'])
except:
pass
if existing:
error_resp = jsonify({
'message': 'A user or organization with this name already exists'
})
error_resp.status_code = 400
return error_resp
try:
organization = model.create_organization(org_data['name'], org_data['email'],
current_user.db_user())
return make_response('Created', 201)
except model.DataModelException as ex:
error_resp = jsonify({
'message': ex.message,
})
error_resp.status_code = 400
return error_resp
@app.route('/api/organization/<orgname>', methods=['GET'])
@api_login_required
def get_organization(orgname):
permission = OrganizationMemberPermission(orgname)
if permission.can():
user = current_user.db_user()
def org_view(o, teams):
admin_org = AdministerOrganizationPermission(orgname)
is_admin = admin_org.can()
return {
'name': o.username,
'gravatar': compute_hash(o.email),
'teams': {t.name : team_view(orgname, t) for t in teams},
'is_admin': is_admin
}
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
teams = model.get_teams_within_org(org)
return jsonify(org_view(org, teams))
abort(403)
@app.route('/api/organization/<orgname>/members', methods=['GET'])
@api_login_required
def get_organization_members(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
abort(404)
# Loop to create the members dictionary. Note that the members collection
# will return an entry for *every team* a member is on, so we will have
# duplicate keys (which is why we pre-build the dictionary).
members_dict = {}
members = model.get_organization_members_with_teams(org)
for member in members:
if not member.user.username in members_dict:
members_dict[member.user.username] = {'username': member.user.username, 'teams': []}
members_dict[member.user.username]['teams'].append(member.team.name)
return jsonify({'members': members_dict})
abort(403)
@app.route('/api/organization/<orgname>/private', methods=['GET'])
@api_login_required
def get_organization_private_allowed(orgname):
permission = CreateRepositoryPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
private_repos = model.get_private_repo_count(organization.username)
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
repos_allowed = get_plan(cus.subscription.plan.id)
return jsonify({
'privateAllowed': (private_repos < repos_allowed)
})
return jsonify({
'privateAllowed': False
})
abort(403)
def member_view(m):
return {
'username': m.username
}
@app.route('/api/organization/<orgname>/team/<teamname>',
methods=['PUT', 'POST'])
@api_login_required
def update_organization_team(orgname, teamname):
edit_permission = AdministerOrganizationPermission(orgname)
if edit_permission.can():
team = None
json = request.get_json()
is_existing = False
try:
team = model.get_organization_team(orgname, teamname)
is_existing = True
except:
# Create the new team.
description = json['description'] if 'description' in json else ''
role = json['role'] if 'role' in json else 'member'
org = model.get_organization(orgname)
team = model.create_team(teamname, org, role, description)
if is_existing:
if 'description' in json:
team.description = json['description']
team.save()
if 'role' in json:
team = model.set_team_org_permission(team, json['role'],
current_user.db_user().username)
resp = jsonify(team_view(orgname, team))
if not is_existing:
resp.status_code = 201
return resp
abort(403)
@app.route('/api/organization/<orgname>/team/<teamname>',
methods=['DELETE'])
@api_login_required
def delete_organization_team(orgname, teamname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.remove_team(orgname, teamname, current_user.db_user().username)
return make_response('Deleted', 204)
abort(403)
@app.route('/api/organization/<orgname>/team/<teamname>/members',
methods=['GET'])
@api_login_required
def get_organization_team_members(orgname, teamname):
view_permission = ViewTeamPermission(orgname, teamname)
edit_permission = AdministerOrganizationPermission(orgname)
if view_permission.can():
user = current_user.db_user()
team = None
try:
team = model.get_organization_team(orgname, teamname)
except:
abort(404)
members = model.get_organization_team_members(team.id)
return jsonify({
'members': { m.username : member_view(m) for m in members },
'can_edit': edit_permission.can()
})
abort(403)
@app.route('/api/organization/<orgname>/team/<teamname>/members/<membername>',
methods=['PUT', 'POST'])
@api_login_required
def update_organization_team_member(orgname, teamname, membername):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
team = None
user = None
# Find the team.
try:
team = model.get_organization_team(orgname, teamname)
except:
abort(404)
# Find the user.
user = model.get_user(membername)
if not user:
abort(400)
# Add the user to the team.
model.add_user_to_team(user, team)
return jsonify(member_view(user))
abort(403)
@app.route('/api/organization/<orgname>/team/<teamname>/members/<membername>',
methods=['DELETE'])
@api_login_required
def delete_organization_team_member(orgname, teamname, membername):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
# Remote the user from the team.
invoking_user = current_user.db_user().username
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
return make_response('Deleted', 204)
abort(403)
@app.route('/api/repository', methods=['POST'])
@api_login_required
def create_repo_api():
owner = current_user.db_user()
json = request.get_json()
namespace_name = json['namespace'] if 'namespace' in json else owner.username
namespace_name = owner.username
repository_name = request.get_json()['repository']
permission = CreateRepositoryPermission(namespace_name)
if permission.can():
repository_name = json['repository']
visibility = json['visibility']
existing = model.get_repository(namespace_name, repository_name)
if existing:
return make_response('Repository already exists', 400)
existing = model.get_repository(namespace_name, repository_name)
if existing:
return make_response('Repository already exists', 400)
visibility = request.get_json()['visibility']
visibility = json['visibility']
repo = model.create_repository(namespace_name, repository_name, owner,
visibility)
repo.description = request.get_json()['description']
repo.save()
repo = model.create_repository(namespace_name, repository_name, owner,
visibility)
repo.description = json['description']
repo.save()
return jsonify({
'namespace': namespace_name,
'name': repository_name
})
return jsonify({
'namespace': namespace_name,
'name': repository_name
})
abort(403)
@app.route('/api/find/repository', methods=['GET'])
@ -231,16 +589,15 @@ def match_repos_api():
@app.route('/api/repository/', methods=['GET'])
def list_repos_api():
def repo_view(repo_obj):
is_public = model.repository_is_public(repo_obj.namespace, repo_obj.name)
return {
'namespace': repo_obj.namespace,
'name': repo_obj.name,
'description': repo_obj.description,
'is_public': is_public
'is_public': repo_obj.visibility.name == 'public',
}
limit = request.args.get('limit', None)
namespace_filter = request.args.get('namespace', None)
include_public = request.args.get('public', 'true')
include_private = request.args.get('private', 'true')
sort = request.args.get('sort', 'false')
@ -260,7 +617,8 @@ def list_repos_api():
repo_query = model.get_visible_repositories(username, limit=limit,
include_public=include_public,
sort=sort)
sort=sort,
namespace=namespace_filter)
repos = [repo_view(repo) for repo in repo_query]
response = {
'repositories': repos
@ -284,7 +642,7 @@ def update_repo_api(namespace, repository):
'success': True
})
abort(404)
abort(403)
@app.route('/api/repository/<path:repository>/changevisibility',
@ -302,7 +660,7 @@ def change_repo_visibility_api(namespace, repository):
'success': True
})
abort(404)
abort(403)
@app.route('/api/repository/<path:repository>', methods=['DELETE'])
@ -315,7 +673,7 @@ def delete_repository(namespace, repository):
registry.delete_repository_storage(namespace, repository)
return make_response('Deleted', 204)
abort(404)
abort(403)
def image_view(image):
@ -343,6 +701,12 @@ def get_repo_api(namespace, repository):
'image': image_view(image),
}
organization = None
try:
organization = model.get_organization(namespace)
except:
pass
permission = ReadRepositoryPermission(namespace, repository)
is_public = model.repository_is_public(namespace, repository)
if permission.can() or is_public:
@ -364,9 +728,10 @@ def get_repo_api(namespace, repository):
'can_admin': can_admin,
'is_public': is_public,
'is_building': len(active_builds) > 0,
'is_organization': bool(organization)
})
abort(404) # Not fount
abort(404) # Not found
abort(403) # Permission denied
@ -405,6 +770,7 @@ def get_repo_builds(namespace, repository):
@app.route('/api/filedrop/', methods=['POST'])
@api_login_required
def get_filedrop_url():
mime_type = request.get_json()['mimeType']
(url, file_id) = user_files.prepare_for_drop(mime_type)
@ -432,19 +798,26 @@ def request_repo_build(namespace, repository):
tag)
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
return jsonify({
resp = jsonify({
'started': True
})
resp.status_code = 201
return resp
abort(403) # Permissions denied
def role_view(repo_perm_obj):
return {
'role': repo_perm_obj.role.name
'role': repo_perm_obj.role.name,
}
def wrap_role_view_org(role_json, org_member):
role_json['is_org_member'] = org_member
return role_json
@app.route('/api/repository/<path:repository>/image/', methods=['GET'])
@parse_repository_name
def list_repository_images(namespace, repository):
@ -508,7 +881,11 @@ def get_image_changes(namespace, repository, image_id):
def list_tag_images(namespace, repository, tag):
permission = ReadRepositoryPermission(namespace, repository)
if permission.can() or model.repository_is_public(namespace, repository):
tag_image = model.get_tag_image(namespace, repository, tag)
try:
tag_image = model.get_tag_image(namespace, repository, tag)
except model.DataModelException:
abort(404)
parent_images = model.get_parent_images(tag_image)
parents = list(parent_images)
@ -522,42 +899,101 @@ def list_tag_images(namespace, repository, tag):
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/', methods=['GET'])
@app.route('/api/repository/<path:repository>/permissions/team/',
methods=['GET'])
@api_login_required
@parse_repository_name
def list_repo_permissions(namespace, repository):
def list_repo_team_permissions(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
repo_perms = model.get_all_repo_users(namespace, repository)
repo_perms = model.get_all_repo_teams(namespace, repository)
return jsonify({
'permissions': {repo_perm.user.username: role_view(repo_perm)
'permissions': {repo_perm.team.name: role_view(repo_perm)
for repo_perm in repo_perms}
})
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/<username>',
@app.route('/api/repository/<path:repository>/permissions/user/',
methods=['GET'])
@api_login_required
@parse_repository_name
def get_permissions(namespace, repository, username):
def list_repo_user_permissions(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
# Determine how to wrap the permissions
role_view_func = role_view
try:
model.get_organization(namespace) # Will raise an error if not org
org_members = model.get_organization_member_set(namespace)
def wrapped_role_view(repo_perm):
unwrapped = role_view(repo_perm)
return wrap_role_view_org(unwrapped,
repo_perm.user.username in org_members)
role_view_func = wrapped_role_view
except model.InvalidOrganizationException:
# This repository isn't under an org
pass
repo_perms = model.get_all_repo_users(namespace, repository)
return jsonify({
'permissions': {perm.user.username: role_view_func(perm)
for perm in repo_perms}
})
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
methods=['GET'])
@api_login_required
@parse_repository_name
def get_user_permissions(namespace, repository, username):
logger.debug('Get repo: %s/%s permissions for user %s' %
(namespace, repository, username))
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
perm = model.get_user_reponame_permission(username, namespace, repository)
perm_view = role_view(perm)
try:
model.get_organization(namespace)
org_members = model.get_organization_member_set(namespace)
perm_view = wrap_role_view_org(perm_view,
perm.user.username in org_members)
except model.InvalidOrganizationException:
# This repository is not part of an organization
pass
return jsonify(perm_view)
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
methods=['GET'])
@api_login_required
@parse_repository_name
def get_team_permissions(namespace, repository, teamname):
logger.debug('Get repo: %s/%s permissions for team %s' %
(namespace, repository, teamname))
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
perm = model.get_team_reponame_permission(teamname, namespace, repository)
return jsonify(role_view(perm))
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/<username>',
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
methods=['PUT', 'POST'])
@api_login_required
@parse_repository_name
def change_permissions(namespace, repository, username):
def change_user_permissions(namespace, repository, username):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
new_permission = request.get_json()
@ -565,12 +1001,48 @@ def change_permissions(namespace, repository, username):
logger.debug('Setting permission to: %s for user %s' %
(new_permission['role'], username))
perm = model.set_user_repo_permission(username, namespace, repository,
new_permission['role'])
perm_view = role_view(perm)
try:
perm = model.set_user_repo_permission(username, namespace, repository,
new_permission['role'])
except model.DataModelException:
logger.warning('User tried to remove themselves as admin.')
abort(409)
model.get_organization(namespace)
org_members = model.get_organization_member_set(namespace)
perm_view = wrap_role_view_org(perm_view,
perm.user.username in org_members)
except model.InvalidOrganizationException:
# This repository is not part of an organization
pass
except model.DataModelException as ex:
error_resp = jsonify({
'message': ex.message,
})
error_resp.status_code = 400
return error_resp
resp = jsonify(perm_view)
if request.method == 'POST':
resp.status_code = 201
return resp
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
methods=['PUT', 'POST'])
@api_login_required
@parse_repository_name
def change_team_permissions(namespace, repository, teamname):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
new_permission = request.get_json()
logger.debug('Setting permission to: %s for team %s' %
(new_permission['role'], teamname))
perm = model.set_team_repo_permission(teamname, namespace, repository,
new_permission['role'])
resp = jsonify(role_view(perm))
if request.method == 'POST':
@ -580,19 +1052,35 @@ def change_permissions(namespace, repository, username):
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/<username>',
@app.route('/api/repository/<path:repository>/permissions/user/<username>',
methods=['DELETE'])
@api_login_required
@parse_repository_name
def delete_permissions(namespace, repository, username):
def delete_user_permissions(namespace, repository, username):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
try:
model.delete_user_permission(username, namespace, repository)
except model.DataModelException:
logger.warning('User tried to remove themselves as admin.')
abort(409)
except model.DataModelException as ex:
error_resp = jsonify({
'message': ex.message,
})
error_resp.status_code = 400
return error_resp
return make_response('Deleted', 204)
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/permissions/team/<teamname>',
methods=['DELETE'])
@api_login_required
@parse_repository_name
def delete_team_permissions(namespace, repository, teamname):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_team_permission(teamname, namespace, repository)
return make_response('Deleted', 204)
abort(403) # Permission denied
@ -695,19 +1183,27 @@ def subscription_view(stripe_subscription, used_repos):
@app.route('/api/user/plan', methods=['PUT'])
@api_login_required
def subscribe():
# Amount in cents
amount = 500
def subscribe_api():
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
user = current_user.db_user()
return subscribe(user, plan, token, USER_PLANS)
def subscribe(user, plan, token, accepted_plans):
plan_found = None
for plan_obj in accepted_plans:
if plan_obj['stripeId'] == plan:
plan_found = plan_obj
if not plan_found:
abort(404)
private_repos = model.get_private_repo_count(user.username)
if not user.stripe_id:
# Create the customer and plan simultaneously
card = request_data['token']
card = token
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
user.stripe_id = cus.id
user.save()
@ -720,29 +1216,41 @@ def subscribe():
# Change the plan
cus = stripe.Customer.retrieve(user.stripe_id)
if plan == 'free':
if plan_found['price'] == 0:
cus.cancel_subscription()
cus.save()
response_json = {
'plan': 'free',
'plan': plan,
'usedPrivateRepos': private_repos,
}
else:
cus.plan = plan
# User may have been a previous customer who is resubscribing
if 'token' in request_data:
cus.card = request_data['token']
if token:
cus.card = token
cus.save()
response_json = subscription_view(cus.subscription, private_repos)
return jsonify(response_json)
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
@api_login_required
def subscribe_org_api(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
request_data = request.get_json()
plan = request_data['plan']
token = request_data['token'] if 'token' in request_data else None
organization = model.get_organization(orgname)
return subscribe(organization, plan, token, BUSINESS_PLANS)
abort(403)
@app.route('/api/user/plan', methods=['GET'])
@api_login_required
def get_subscription():
@ -752,10 +1260,31 @@ def get_subscription():
if user.stripe_id:
cus = stripe.Customer.retrieve(user.stripe_id)
if cus.subscription:
if cus.subscription:
return jsonify(subscription_view(cus.subscription, private_repos))
return jsonify({
'plan': 'free',
'usedPrivateRepos': private_repos,
})
@app.route('/api/organization/<orgname>/plan', methods=['GET'])
@api_login_required
def get_org_subscription(orgname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
private_repos = model.get_private_repo_count(orgname)
organization = model.get_organization(orgname)
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
return jsonify(subscription_view(cus.subscription, private_repos))
return jsonify({
'plan': 'bus-free',
'usedPrivateRepos': private_repos,
})
abort(403)

View file

@ -13,8 +13,9 @@ from auth.auth import (process_auth, get_authenticated_user,
get_validated_token)
from util.names import parse_namespace_repository, parse_repository_name
from util.email import send_confirmation_email
from auth.permissions import (ModifyRepositoryPermission,
ReadRepositoryPermission, UserPermission)
from auth.permissions import (ModifyRepositoryPermission, UserPermission,
ReadRepositoryPermission,
CreateRepositoryPermission)
logger = logging.getLogger(__name__)
@ -77,10 +78,12 @@ def create_user():
@app.route('/v1/users/', methods=['GET'])
@process_auth
def get_user():
return jsonify({
'username': get_authenticated_user().username,
'email': get_authenticated_user().email,
})
if get_authenticated_user():
return jsonify({
'username': get_authenticated_user().username,
'email': get_authenticated_user().email,
})
abort(404)
@app.route('/v1/users/<username>/', methods=['PUT'])
@ -127,7 +130,9 @@ def create_repository(namespace, repository):
abort(403)
else:
if get_authenticated_user().username != namespace:
permission = CreateRepoPermission('namespace')
if not permission.can():
logger.info('Attempt to create a new repo with insufficient perms.')
abort(403)
logger.debug('Creaing repository with owner: %s' %
@ -216,18 +221,18 @@ def get_repository_images(namespace, repository):
@parse_repository_name
@generate_headers(role='write')
def delete_repository_images(namespace, repository):
pass
return make_response('Not Implemented', 501)
@app.route('/v1/repositories/<path:repository>/auth', methods=['PUT'])
@parse_repository_name
def put_repository_auth(namespace, repository):
pass
return make_response('Not Implemented', 501)
@app.route('/v1/search', methods=['GET'])
def get_search():
pass
return make_response('Not Implemented', 501)
@app.route('/_ping')

View file

@ -6,7 +6,6 @@ from functools import wraps
from datetime import datetime
from time import time
import storage
from data.queue import image_diff_queue
from app import app
@ -17,7 +16,7 @@ from auth.permissions import (ReadRepositoryPermission,
from data import model
store = storage.load()
store = app.config['STORAGE']
logger = logging.getLogger(__name__)

View file

@ -4,8 +4,6 @@ import json
from flask import abort, request, jsonify, make_response
import storage
from app import app
from util.names import parse_repository_name
from auth.auth import process_auth
@ -14,7 +12,6 @@ from auth.permissions import (ReadRepositoryPermission,
from data import model
store = storage.load()
logger = logging.getLogger(__name__)
@ -71,7 +68,7 @@ def delete_tag(namespace, repository, tag):
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
model.delete_tag(namespace, repository, tag_name)
model.delete_tag(namespace, repository, tag)
return make_response('Deleted', 204)

View file

@ -43,6 +43,7 @@ def load_user(username):
@app.route('/', methods=['GET'], defaults={'path': ''})
@app.route('/repository/<path:path>', methods=['GET'])
@app.route('/organization/<path:path>', methods=['GET'])
def index(path):
return render_template('index.html')
@ -57,6 +58,11 @@ def guide():
return index('')
@app.route('/organizations/')
@app.route('/organizations/new/')
def organizations():
return index('')
@app.route('/user/')
def user():
return index('')
@ -167,7 +173,6 @@ def github_oauth_callback():
if common_login(to_login):
return redirect(url_for('index'))
# TODO something bad happened, we need to tell the user somehow
return render_template('githuberror.html')

189
initdb.py
View file

@ -6,23 +6,21 @@ import hashlib
from datetime import datetime, timedelta
from flask import url_for
from peewee import SqliteDatabase, create_model_tables, drop_model_tables
import storage
from data.database import initialize_db
from data.database import *
from data import model
from app import app
logger = logging.getLogger(__name__)
store = storage.load()
logging.basicConfig(**app.config['LOGGING_CONFIG'])
store = app.config['STORAGE']
SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i
for i in range(1, 10)]
REFERENCE_DATE = datetime(2013, 6, 23)
TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG'
def __gen_checksum(image_id):
@ -38,7 +36,7 @@ def __gen_image_id(repo, image_num):
global_image_num = [0]
def create_subtree(repo, structure, parent):
def __create_subtree(repo, structure, parent):
num_nodes, subtrees, last_node_tags = structure
# create the nodes
@ -76,7 +74,7 @@ def create_subtree(repo, structure, parent):
new_image.docker_image_id)
for subtree in subtrees:
create_subtree(repo, subtree, new_image)
__create_subtree(repo, subtree, new_image)
def __generate_repository(user, name, description, is_public, permissions,
@ -94,65 +92,130 @@ def __generate_repository(user, name, description, is_public, permissions,
model.set_user_repo_permission(delegate.username, user.username, name,
role)
create_subtree(repo, structure, None)
__create_subtree(repo, structure, None)
return repo
def initialize_database():
create_model_tables(all_models)
Role.create(name='admin')
Role.create(name='write')
Role.create(name='read')
TeamRole.create(name='admin')
TeamRole.create(name='creator')
TeamRole.create(name='member')
Visibility.create(name='public')
Visibility.create(name='private')
LoginService.create(name='github')
def wipe_database():
logger.debug('Wiping all data from the DB.')
# Sanity check to make sure we're not killing our prod db
db = model.db
if (not isinstance(model.db, SqliteDatabase) or
app.config['DB_DRIVER'] is not SqliteDatabase):
raise RuntimeError('Attempted to wipe production database!')
drop_model_tables(all_models, fail_silently=True)
def populate_database():
logger.debug('Populating the DB with test data.')
new_user_1 = model.create_user('devtable', 'password',
'jschorr@devtable.com')
new_user_1.verified = True
new_user_1.save()
new_user_2 = model.create_user('public', 'password',
'jacob.moshenko@gmail.com')
new_user_2.verified = True
new_user_2.save()
new_user_3 = model.create_user('freshuser', 'password', 'no@thanks.com')
new_user_3.verified = True
new_user_3.save()
reader = model.create_user('reader', 'password', 'no1@thanks.com')
reader.verified = True
reader.save()
outside_org = model.create_user('outsideorg', 'password', 'no2@thanks.com')
outside_org.verified = True
outside_org.save()
__generate_repository(new_user_1, 'simple', 'Simple repository.', False,
[], (4, [], ['latest', 'prod']))
__generate_repository(new_user_1, 'complex',
'Complex repository with many branches and tags.',
False, [(new_user_2, 'read')],
(2, [(3, [], 'v2.0'),
(1, [(1, [(1, [], ['prod'])],
'staging'),
(1, [], None)], None)], None))
__generate_repository(new_user_1, 'gargantuan', None, False, [],
(2, [(3, [], 'v2.0'),
(1, [(1, [(1, [], ['latest', 'prod'])],
'staging'),
(1, [], None)], None),
(20, [], 'v3.0'),
(5, [], 'v4.0'),
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
None))
__generate_repository(new_user_2, 'publicrepo',
'Public repository pullable by the world.', True,
[], (10, [], 'latest'))
__generate_repository(new_user_1, 'shared',
'Shared repository, another user can write.', False,
[(new_user_2, 'write'), (reader, 'read')],
(5, [], 'latest'))
building = __generate_repository(new_user_1, 'building',
'Empty repository which is building.',
False, [], (0, [], None))
org = model.create_organization('buynlarge', 'quay@devtable.com',
new_user_1)
org.stripe_id = TEST_STRIPE_ID
org.save()
owners = model.get_organization_team('buynlarge', 'owners')
owners.description = 'Owners have unfetterd access across the entire org.'
owners.save()
org_repo = __generate_repository(org, 'orgrepo',
'Repository owned by an org.', False,
[(outside_org, 'read')],
(4, [], ['latest', 'prod']))
reader_team = model.create_team('readers', org, 'member',
'Readers of orgrepo.')
model.set_team_repo_permission(reader_team.name, org_repo.namespace,
org_repo.name, 'read')
model.add_user_to_team(new_user_2, reader_team)
model.add_user_to_team(reader, reader_team)
token = model.create_access_token(building, 'write')
tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
build = model.create_repository_build(building, token, '123-45-6789', tag)
build.build_node_id = 1
build.phase = 'building'
build.status_url = 'http://localhost:5000/test/build/status'
build.save()
if __name__ == '__main__':
initialize_db()
logging.basicConfig(**app.config['LOGGING_CONFIG'])
initialize_database()
if app.config.get('POPULATE_DB_TEST_DATA', False):
logger.debug('Populating the DB with test data.')
new_user_1 = model.create_user('devtable', 'password',
'jschorr@devtable.com')
new_user_1.verified = True
new_user_1.save()
new_user_2 = model.create_user('public', 'password',
'jacob.moshenko@gmail.com')
new_user_2.verified = True
new_user_2.save()
__generate_repository(new_user_1, 'simple', 'Simple repository.', False,
[], (4, [], ['latest', 'prod']))
__generate_repository(new_user_1, 'complex',
'Complex repository with many branches and tags.',
False, [(new_user_2, 'read')],
(2, [(3, [], 'v2.0'),
(1, [(1, [(1, [], ['prod'])],
'staging'),
(1, [], None)], None)], None))
__generate_repository(new_user_1, 'gargantuan', None, False, [],
(2, [(3, [], 'v2.0'),
(1, [(1, [(1, [], ['latest', 'prod'])],
'staging'),
(1, [], None)], None),
(20, [], 'v3.0'),
(5, [], 'v4.0'),
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
None))
__generate_repository(new_user_2, 'publicrepo',
'Public repository pullable by the world.', True,
[], (10, [], 'latest'))
__generate_repository(new_user_1, 'shared',
'Shared repository, another user can write.', False,
[(new_user_2, 'write')], (5, [], 'latest'))
building = __generate_repository(new_user_1, 'building',
'Empty repository which is building.',
False, [], (0, [], None))
token = model.create_access_token(building, 'write')
tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name)
build = model.create_repository_build(building, token, '123-45-6789', tag)
build.build_node_id = 1
build.phase = 'building'
build.status_url = 'http://localhost:5000/test/build/status'
build.save()
populate_database()

View file

@ -1,4 +1,4 @@
var width = 993;
var width = 1024;
var height = 768;
var casper = require('casper').create({
@ -12,12 +12,14 @@ var casper = require('casper').create({
var disableOlark = function() {
casper.then(function() {
this.waitForText('Contact us!', function() {
this.waitForText('Chat with us!', function() {
this.evaluate(function() {
console.log(olark);
window.olark.configure('box.start_hidden', true);
window.olark('api.box.hide');
});
}, function() {
// Do nothing, if olark never loaded we're ok with that
});
});
};
@ -27,6 +29,8 @@ var isDebug = !!options['d'];
var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/';
var repo = isDebug ? 'complex' : 'r0';
var org = isDebug ? 'buynlarge' : 'quay'
var orgrepo = 'orgrepo'
var outputDir = "screenshots/";
@ -42,11 +46,11 @@ casper.start(rootUrl + 'signin', function () {
this.fill('.form-signin', {
'username': 'devtable',
'password': isDebug ? 'password': 'C>K98%y"_=54x"<',
}, true);
}, false);
});
casper.then(function() {
this.waitForText('Your Top Repositories');
casper.thenClick('.form-signin button[type=submit]', function() {
this.waitForText('Top Repositories');
});
disableOlark();
@ -90,4 +94,44 @@ casper.then(function() {
this.capture(outputDir + 'repo-admin.png');
});
casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() {
this.waitForText('Repositories');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-repo-list.png');
});
casper.thenOpen(rootUrl + 'organization/' + org, function() {
this.waitForSelector('.organization-name');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-teams.png');
});
casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() {
this.waitForSelector('#repository-usage-chart');
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-admin.png');
});
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
this.waitForText('outsideorg')
});
disableOlark();
casper.then(function() {
this.capture(outputDir + 'org-repo-admin.png');
});
casper.run();

View file

@ -2,6 +2,70 @@
font-family: 'Droid Sans', sans-serif;
}
.button-hidden {
visibility: hidden;
}
.organization-header-element {
padding: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
font-size: 20px;
}
.organization-header-element .organization-name {
display: inline-block;
margin-left: 10px;
}
.organization-header-element .divider {
color: #aaa;
margin-left: 10px;
margin-right: 10px;
}
.organization-header-element .organization-name {
display: inline-block;
font-size: 20px;
margin-left: 10px;
}
.organization-header-element .team-name {
text-transform: none;
}
.organization-header-element .header-buttons {
float: right;
}
.namespace-selector-dropdown .namespace {
padding: 6px;
padding-left: 10px;
cursor: pointer;
font-size: 14px;
}
.namespace-selector-dropdown .namespace-item {
position: relative;
}
.namespace-selector-dropdown .namespace-item .fa {
position: absolute;
right: 12px;
top: 12px;
color: #aaa;
}
.namespace-selector-dropdown .namespace-item.disabled img {
-webkit-filter: grayscale(1);
opacity: 0.5;
}
.namespace-selector-dropdown .namespace-item .tooltip-inner {
min-width: 200px;
}
.user-notification {
background: red;
}
@ -179,10 +243,6 @@
color: #444 !important;
}
.new-repo .new-header span {
font-size: 22px;
}
.new-repo .new-header .popover {
font-size: 14px;
}
@ -277,24 +337,46 @@
color: #428bca;
}
.plans .all-plans .business-feature {
color: #46ac39;
}
.plans-list {
text-align: center;
margin-bottom: 25px;
}
.plans-list .plan-container {
padding: 5px;
}
.plans-list .plan {
width: 245px;
vertical-align: top;
display: inline-block;
padding: 10px;
margin-right: 10px;
border: 1px solid #eee;
border-top: 4px solid #94C9F7;
margin-top: 10px;
font-size: 1.4em;
margin-top: 5px;
}
.plans-list .plan.small {
border: 1px solid #ddd;
border-top: 4px solid #428bca;
margin-top: 0px;
font-size: 1.6em;
}
.plans-list .plan.business-plan {
border: 1px solid #eee;
border-top: 4px solid #94F794;
}
.plans-list .plan.bus-small {
border: 1px solid #ddd;
border-top: 4px solid #47A447;
margin-top: 0px;
font-size: 1.6em;
}
.plans-list .plan:last-child {
@ -317,7 +399,7 @@
}
.plan-price:after {
content: "/ month";
content: "/ mo";
position: absolute;
bottom: 0px;
right: 20px;
@ -333,6 +415,10 @@
color: #428bca;
}
.plans-list .plan.business-plan .count b {
color: #46ac39;
}
.plans-list .plan .description {
font-size: 1em;
font-size: 16px;
@ -354,14 +440,6 @@
margin-right: 5px;
}
.plans-list .plan.small {
border: 1px solid #ddd;
border-top: 4px solid #428bca;
margin-top: 0px;
font-size: 1.6em;
}
.plans .plan-faq dd{
margin-bottom: 20px;
}
@ -575,14 +653,20 @@ form input.ng-valid.ng-dirty {
}
.user-mini-listing {
.entity-mini-listing {
margin: 2px;
}
.user-mini-listing i {
.entity-mini-listing i {
margin-right: 8px;
}
.entity-mini-listing .warning {
margin-top: 6px;
font-size: 10px;
padding: 4px;
}
.editable {
position: relative;
}
@ -611,9 +695,8 @@ p.editable {
display: inline-block;
}
p.editable .content:empty:after {
p.editable .empty {
display: inline-block;
content: "(Click to add)";
color: #aaa;
}
@ -867,6 +950,25 @@ p.editable:hover i {
margin-bottom: 40px;
}
.repo-list .button-bar-right {
float: right;
}
.button-bar-bottom {
margin-bottom: 60px;
}
.repo-list .section-header {
padding: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.repo-list .button-bar-right button {
margin-right: 10px;
}
.repo-listing {
display: block;
margin-bottom: 20px;
@ -899,6 +1001,10 @@ p.editable:hover i {
padding-left: 44px;
}
.repo-admin .entity-search input {
width: 300px;
}
.repo-admin .token-dialog-body .well {
margin-bottom: 0px;
}
@ -916,20 +1022,35 @@ p.editable:hover i {
width: 620px;
}
.repo-admin .user i {
margin-right: 6px;
.repo-admin .user i.fa-user {
margin-left: 2px;
margin-right: 7px;
}
.repo-admin .user {
.repo-admin .team i.fa-group {
margin-right: 4px;
}
.repo-admin .entity {
font-size: 1.2em;
min-width: 300px;
}
.repo-admin .entity .popover {
font-size: 14px;
}
.repo-admin .entity i.fa-exclamation-triangle {
color: #c09853;
float: right;
margin-right: 10px;
margin-top: 4px;
}
.repo-admin .token a {
cursor: pointer;
}
.repo .build-info {
padding: 10px;
margin: 0px;
@ -1100,7 +1221,7 @@ p.editable:hover i {
}
.delete-ui:focus .delete-ui-button {
width: 54px;
width: 60px;
}
.repo-admin .repo-delete {
@ -1160,12 +1281,13 @@ p.editable:hover i {
border: inherit;
}
.user-admin .panel-plan {
.user-admin #migrate .panel {
max-width: 600px;
text-align: center;
}
.user-admin .panel-plan .button-hidden {
visibility: hidden;
.user-admin .panel-plan {
text-align: center;
}
.user-admin .plan-description {
@ -1183,6 +1305,41 @@ p.editable:hover i {
margin-bottom: 12px;
}
.user-admin .convert-form h3 {
margin-bottom: 20px;
}
.user-admin #convertForm {
max-width: 500px;
}
.user-admin #convertForm .form-group {
margin-bottom: 20px;
}
.user-admin #convertForm input {
margin-bottom: 10px;
margin-left: 20px;
}
.user-admin #convertForm .existing-data {
font-size: 16px;
font-weight: bold;
}
.user-admin #convertForm .description {
margin-top: 10px;
display: block;
color: #888;
font-size: 12px;
margin-left: 20px;
}
.user-admin #convertForm .existing-data {
display: block;
padding-left: 20px;
margin-top: 10px;
}
#image-history-container {
overflow: hidden;
@ -1280,6 +1437,49 @@ p.editable:hover i {
stroke-width: 1.5px;
}
#repository-usage-chart {
display: inline-block;
vertical-align: middle;
width: 200px;
height: 200px;
}
#repository-usage-chart .count-text {
font-size: 22px;
}
#repository-usage-chart.limit-at path.arc-0 {
fill: #c09853;
}
#repository-usage-chart.limit-over path.arc-0 {
fill: #b94a48;
}
#repository-usage-chart.limit-near path.arc-0 {
fill: #468847;
}
#repository-usage-chart.limit-over path.arc-1 {
fill: #fcf8e3;
}
#repository-usage-chart.limit-at path.arc-1 {
fill: #f2dede;
}
#repository-usage-chart.limit-near path.arc-1 {
fill: #dff0d8;
}
.plan-manager-element .usage-caption {
display: inline-block;
color: #aaa;
font-size: 26px;
margin-left: 10px;
}
/* Overrides for the markdown editor. */
.wmd-panel .btn-toolbar {
@ -1309,6 +1509,271 @@ p.editable:hover i {
min-height: 50px;
}
.team-view .panel {
display: inline-block;
width: 620px;
}
.team-view .entity {
font-size: 1.2em;
min-width: 510px;
}
.team-view .entity i {
margin-right: 6px;
}
.team-view .entity-search {
margin-top: 10px;
display: inline-block;
}
.team-view .delete-ui {
display: inline-block;
width: 78px;
}
.team-view .delete-ui i {
margin-top: 8px;
float: right;
}
.org-view .team-listing {
padding: 4px;
}
.org-view .header-col {
color: #444;
margin-bottom: 10px;
}
.org-view .header-col dd {
margin-bottom: 20px;
}
.org-view .header-col .info-icon {
float: none;
margin-left: 10px;
}
.org-view .team-listing .control-col button.btn-danger {
margin-left: 10px;
}
.org-view .team-listing i {
margin-right: 10px;
}
.org-view .highlight .team-title {
animation: highlighttemp 1s 2;
animation-timing-function: ease-in-out;
animation-direction: alternate;
-moz-animation: highlighttemp 1s 2;
-moz-animation-timing-function: ease-in-out;
-moz-animation-direction: alternate;
-webkit-animation: highlighttemp 1s 2;
-webkit-animation-timing-function: ease-in-out;
-webkit-animation-direction: alternate;
}
@-moz-keyframes highlighttemp {
0% { background-color: white; }
100% { background-color: rgba(92, 184, 92, 0.36); }
}
@-webkit-keyframes highlighttemp {
0% { background-color: white; }
100% { background-color: rgba(92, 184, 92, 0.36); }
}
@keyframes highlighttemp {
0% { background-color: white; }
100% { background-color: rgba(92, 184, 92, 0.36); }
}
.org-view .team-title {
font-size: 20px;
text-transform: none;
padding: 4px;
}
.org-view .team-listing .team-description {
margin-top: 6px;
margin-left: 41px;
font-size: 16px;
}
.org-view #create-team-box {
border: none;
font-size: 14px;
padding: 6px;
}
.org-admin .team-link {
display: inline-block;
text-transform: none;
margin-right: 20px;
}
.org-admin #members table td {
font-size: 16px;
}
.org-admin #members table i {
margin-right: 4px;
}
.org-admin #members .side-controls {
float: right;
}
.org-admin #members .result-count {
display: inline-block;
margin-right: 10px;
}
.org-admin #members .filter-input {
display: inline-block;
}
.org-list h2 {
margin-bottom: 20px;
}
.org-list .button-bar-right {
text-align: right;
}
.org-list .organization-listing {
font-size: 18px;
padding: 10px;
}
.org-list .organization-listing img {
margin-left: 10px;
margin-right: 16px;
}
.create-org .steps-container {
text-align: center;
}
.create-org .steps {
background: #222;
display: inline-block;
margin-top: 16px;
margin-left: 0px;
border-radius: 4px;
padding: 0px;
list-style: none;
height: 46px;
width: 675px;
text-align: left;
}
.create-org .steps .step {
width: 225px;
float: left;
padding: 10px;
border-right: 1px solid #222;
margin: 0px;
background: rgba(255, 255, 255, 0.2);
color: #aaa;
border-left: 4px solid transparent;
}
.create-org .steps .step i {
font-size: 26px;
margin-right: 6px;
vertical-align: middle;
}
.create-org .steps .step.active {
color: white;
border-left: 4px solid steelblue;
background: transparent;
}
.create-org .steps .step:last-child {
border-right: 0px;
}
.create-org .steps .step b {
display: block;
}
.create-org .button-bar {
margin-bottom: 40px;
}
.create-org .form-group {
margin-bottom: 32px;
}
.create-org .plan-group {
padding-left: 10px;
}
.create-org .plan-group strong {
margin-bottom: 10px;
}
.create-org .step-container .description {
margin-top: 10px;
display: block;
color: #888;
font-size: 12px;
margin-left: 10px;
}
.create-org .form-group input {
margin-top: 10px;
margin-left: 10px;
}
.create-org h3 {
margin-bottom: 20px;
}
.plan-manager-element .plans-list-table thead td {
color: #aaa;
font-weight: bold;
}
.plan-manager-element .plans-list-table td {
padding: 10px;
font-size: 16px;
vertical-align: middle;
}
.plan-manager-element .plans-list-table td.controls {
text-align: right;
}
.plan-manager-element .plans-list-table .plan-price {
font-size: 16px;
margin-bottom: 0px;
}
.plans-table-element table {
margin: 20px;
border: 1px solid #eee;
}
.plans-table-element td {
vertical-align: middle !important;
}
.plans-table-element .plan-price {
font-size: 16px;
}
/* Overrides for typeahead to work with bootstrap 3. */
.twitter-typeahead .tt-query,
@ -1489,4 +1954,8 @@ p.editable:hover i {
top: -9px;
font-weight: bold;
font-size: .4em;
}
.page-description {
margin-bottom: 40px;
}

View file

@ -0,0 +1 @@
<input class="entity-search-control form-control">

View file

@ -0,0 +1,31 @@
<div class="markdown-input-container">
<p ng-class="'lead ' + (canWrite ? 'editable' : 'noteditable')" ng-click="editContent()">
<span class="markdown-view" content="content"></span>
<span class="empty" ng-show="!content && canWrite">(Click to set {{ fieldTitle }})</span>
<i class="fa fa-edit"></i>
</p>
<!-- Modal editor -->
<div class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Edit {{ fieldTitle }}</h4>
</div>
<div class="modal-body">
<div class="wmd-panel">
<div id="wmd-button-bar-description-{{id}}"></div>
<textarea class="wmd-input" id="wmd-input-description-{{id}}" placeholder="Enter {{ fieldTitle }}">{{ content }}</textarea>
</div>
<div id="wmd-preview-description-{{id}}" class="wmd-panel wmd-preview"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" ng-click="saveContent()">Save changes</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -0,0 +1 @@
<span class="markdown-view-content" ng-bind-html-unsafe="getMarkedDown(content, firstLineOnly)"></span>

View file

@ -0,0 +1,34 @@
<span class="namespace-selector-dropdown">
<span ng-show="user.organizations.length == 0">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon" />
<span class="namespace-name">{{user.username}}</span>
</span>
<div class="btn-group" ng-show="user.organizations.length > 0">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<img src="//www.gravatar.com/avatar/{{ namespaceObj.gravatar }}?s=16&d=identicon" />
{{namespace}} <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li class="namespace-item" ng-repeat="org in user.organizations"
ng-class="(requireCreate && !namespaces[org.name].can_create_repo) ? 'disabled' : ''">
<a class="namespace" href="javascript:void(0)" ng-click="setNamespace(org)">
<img src="//www.gravatar.com/avatar/{{ org.gravatar }}?s=24&d=identicon" />
<span class="namespace-name">{{ org.name }}</span>
</a>
<i class="fa fa-exclamation-triangle" ng-show="requireCreate && !namespaces[org.name].can_create_repo"
title="You do not have permission to create repositories for this organization"
data-placement="right"
bs-tooltip="tooltip.title"></i>
</li>
<li class="divider"></li>
<li>
<a class="namespace" href="javascript:void(0)" ng-click="setNamespace(user)">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon" />
<span class="namespace-name">{{ user.username }}</span>
</a>
</li>
</ul>
</div>
</span>

View file

@ -0,0 +1,18 @@
<div class="organization-header-element">
<img src="//www.gravatar.com/avatar/{{ organization.gravatar }}?s=24&amp;d=identicon">
<span class="organization-name" ng-show="teamName">
<a href="/organization/{{ organization.name }}">{{ organization.name }}</a>
</span>
<span class="organization-name" ng-show="!teamName">
{{ organization.name }}
</span>
<span ng-show="teamName">
<span class="divider">/</span>
<i class="fa fa-group"></i>
<span class="team-name">
{{ teamName }}
</span>
</span>
<span ng-transclude></span>
</div>

View file

@ -0,0 +1,62 @@
<div class="plan-manager-element">
<!-- Loading/Changing -->
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planLoading"></i>
<!-- Alerts -->
<div class="alert alert-danger" ng-show="limit == 'over' && !planLoading">
You are using more private repositories than your plan allows. Please
upgrade your subscription to avoid disruptions in your <span ng-show="organization">organization's</span> service.
</div>
<div class="alert alert-warning" ng-show="limit == 'at' && !planLoading">
You are at your current plan's number of allowed private repositories. Please upgrade your subscription to avoid future disruptions in your <span ng-show="organization">organization's</span> service.
</div>
<div class="alert alert-success" ng-show="limit == 'near' && !planLoading">
You are nearing the number of allowed private repositories. It might be time to think about
upgrading your subscription to avoid future disruptions in your <span ng-show="organization">organization's</span> service.
</div>
<!-- Chart -->
<div>
<div id="repository-usage-chart" class="limit-{{limit}}"></div>
<span class="usage-caption" ng-show="chart">Repository Usage</span>
</div>
<!-- Plans Table -->
<table class="table table-hover plans-list-table" ng-show="!planLoading">
<thead>
<td>Plan</td>
<td>Private Repositories</td>
<td style="min-width: 64px">Price</td>
<td></td>
</thead>
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''">
<td>{{ plan.title }}</td>
<td>{{ plan.privateRepos }}</td>
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
<td class="controls">
<div ng-switch='plan.stripeId'>
<div ng-switch-when='bus-free'>
<button class="btn button-hidden">Hidden!</button>
</div>
<div ng-switch-default>
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
ng-click="changeSubscription(plan.stripeId)">
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
<span ng-show="!planChanging && subscribedPlan.price != 0">Change</span>
<span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span>
</button>
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
ng-click="cancelSubscription()">
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
<span ng-show="!planChanging">Cancel</span>
</button>
</div>
</div>
</td>
</tr>
</table>
</div>

View file

@ -0,0 +1,23 @@
<div class="plans-table-element">
<table class="table table-hover plans-table-table" ng-show="plans">
<thead>
<th>Plan</th>
<th>Private Repositories</th>
<th style="min-width: 85px">Price</th>
<th></th>
</thead>
<tr ng-repeat="plan in plans" ng-class="currentPlan == plan ? 'active' : ''">
<td>{{ plan.title }}</td>
<td>{{ plan.privateRepos }}</td>
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
<td class="controls">
<a class="btn" href="javascript:void(0)"
ng-class="currentPlan == plan ? 'btn-primary' : 'btn-default'"
ng-click="setPlan(plan)">
{{ currentPlan == plan ? 'Selected' : 'Choose' }}
</a>
</td>
</tr>
</table>
</div>

View file

@ -0,0 +1,5 @@
<div class="btn-group btn-group-sm">
<button ng-repeat="role in roles"
type="button" class="btn" ng-click="setRole(role.id)"
ng-class="(currentRole == role.id) ? ('active btn-' + role.kind) : 'btn-default'">{{ role.title }}</button>
</div>

View file

@ -0,0 +1,25 @@
<div class="signin-form-element">
<form class="form-signin" ng-submit="signin();">
<input type="text" class="form-control input-lg" name="username"
placeholder="Username" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password"
placeholder="Password" ng-model="user.password">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
<span class="social-alternate">
<i class="fa fa-circle"></i>
<span class="inner-text">OR</span>
</span>
<a id="github-signin-link"
href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ mixpanelDistinctIdClause }}"
class="btn btn-primary btn-lg btn-block">
<i class="fa fa-github fa-lg"></i> Sign In with GitHub
</a>
</form>
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
<div class="alert alert-danger" ng-show="needsEmailVerification">
You must verify your email address before you can sign in.
</div>
</div>

BIN
static/img/org-admin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
static/img/org-teams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View file

@ -1,15 +1,65 @@
function getFirstTextLine(commentString) {
if (!commentString) { return ''; }
var lines = commentString.split('\n');
var MARKDOWN_CHARS = {
'#': true,
'-': true,
'>': true,
'`': true
};
for (var i = 0; i < lines.length; ++i) {
// Skip code lines.
if (lines[i].indexOf(' ') == 0) {
continue;
}
// Skip empty lines.
if ($.trim(lines[i]).length == 0) {
continue;
}
// Skip control lines.
if (MARKDOWN_CHARS[$.trim(lines[i])[0]]) {
continue;
}
return getMarkedDown(lines[i]);
}
return '';
}
function getRestUrl(args) {
var url = '';
for (var i = 0; i < arguments.length; ++i) {
if (i > 0) {
url += '/';
}
url += encodeURI(arguments[i])
}
return url;
}
function getMarkedDown(string) {
return Markdown.getSanitizingConverter().makeHtml(string || '');
}
// Start the application code itself.
quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', 'angulartics.mixpanel', '$strap.directives'], function($provide) {
$provide.factory('UserService', ['Restangular', function(Restangular) {
quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics', 'angulartics.mixpanel', '$strap.directives', 'ngCookies'], function($provide) {
$provide.factory('UserService', ['Restangular', 'PlanService', function(Restangular, PlanService) {
var userResponse = {
verified: false,
anonymous: true,
username: null,
email: null,
askForPassword: false,
organizations: []
}
var userService = {}
var currentSubscription = null;
userService.load = function() {
var userFetch = Restangular.one('user/');
@ -30,6 +80,18 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
});
};
userService.resetCurrentSubscription = function() {
currentSubscription = null;
};
userService.getCurrentSubscription = function(callback, failure) {
if (currentSubscription) { callback(currentSubscription); }
PlanService.getSubscription(null, function(sub) {
currentSubscription = sub;
callback(sub);
}, failure);
};
userService.currentUser = function() {
return userResponse;
}
@ -55,93 +117,134 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
}]);
$provide.factory('PlanService', ['Restangular', 'KeyService', function(Restangular, KeyService) {
var plans = [
{
title: 'Open Source',
price: 0,
privateRepos: 0,
stripeId: 'free',
audience: 'Share with the world',
},
{
title: 'Micro',
price: 700,
privateRepos: 5,
stripeId: 'micro',
audience: 'For smaller teams',
},
{
title: 'Basic',
price: 1200,
privateRepos: 10,
stripeId: 'small',
audience: 'For your basic team',
},
{
title: 'Medium',
price: 2200,
privateRepos: 20,
stripeId: 'medium',
audience: 'For medium-sized teams',
},
];
var plans = null;
var planDict = {};
var i;
for(i = 0; i < plans.length; i++) {
planDict[plans[i].stripeId] = plans[i];
}
var planService = {}
planService.planList = function() {
return plans;
};
planService.getPlan = function(planId) {
return planDict[planId];
};
planService.getMinimumPlan = function(privateCount) {
for (var i = 0; i < plans.length; i++) {
var plan = plans[i];
if (plan.privateRepos >= privateCount) {
return plan;
}
planService.verifyLoaded = function(callback) {
if (plans) {
callback(plans);
return;
}
return null;
var getPlans = Restangular.one('plans');
getPlans.get().then(function(data) {
var i = 0;
for(i = 0; i < data.user.length; i++) {
planDict[data.user[i].stripeId] = data.user[i];
}
for(i = 0; i < data.business.length; i++) {
planDict[data.business[i].stripeId] = data.business[i];
}
plans = data;
callback(plans);
}, function() { callback([]); });
};
planService.showSubscribeDialog = function($scope, planId, started, success, failed) {
var submitToken = function(token) {
$scope.$apply(function() {
started();
});
planService.getMatchingBusinessPlan = function(callback) {
planService.getPlans(function() {
planService.getSubscription(null, function(sub) {
var plan = planDict[sub.plan];
if (!plan) {
planService.getMinimumPlan(0, true, callback);
return;
}
var count = Math.max(sub.usedPrivateRepos, plan.privateRepos);
planService.getMinimumPlan(count, true, callback);
}, function() {
planService.getMinimumPlan(0, true, callback);
});
});
};
planService.getPlans = function(callback) {
planService.verifyLoaded(callback);
};
planService.getPlan = function(planId, callback) {
planService.verifyLoaded(function() {
if (planDict[planId]) {
callback(planDict[planId]);
}
});
};
planService.getMinimumPlan = function(privateCount, isBusiness, callback) {
planService.verifyLoaded(function() {
var planSource = plans.user;
if (isBusiness) {
planSource = plans.business;
}
for (var i = 0; i < planSource.length; i++) {
var plan = planSource[i];
if (plan.privateRepos >= privateCount) {
callback(plan);
return;
}
}
callback(null);
});
};
planService.getSubscription = function(organization, success, failure) {
var url = planService.getSubscriptionUrl(organization);
var getSubscription = Restangular.one(url);
getSubscription.get().then(success, failure);
};
planService.getSubscriptionUrl = function(orgname) {
return orgname ? getRestUrl('organization', orgname, 'plan') : 'user/plan';
};
planService.setSubscription = function(orgname, planId, success, failure, opt_token) {
var subscriptionDetails = {
plan: planId
};
if (opt_token) {
subscriptionDetails['token'] = opt_token.id;
}
var url = planService.getSubscriptionUrl(orgname);
var createSubscriptionRequest = Restangular.one(url);
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failure);
};
planService.changePlan = function($scope, orgname, planId, hasExistingSubscription, started, success, failure) {
if (!hasExistingSubscription) {
planService.showSubscribeDialog($scope, orgname, planId, started, success, failure);
return;
}
started();
planService.setSubscription(orgname, planId, success, failure);
};
planService.showSubscribeDialog = function($scope, orgname, planId, started, success, failure) {
var submitToken = function(token) {
mixpanel.track('plan_subscribe');
var subscriptionDetails = {
token: token.id,
plan: planId,
};
var createSubscriptionRequest = Restangular.one('user/plan');
$scope.$apply(function() {
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failed);
started();
planService.setSubscription(orgname, planId, success, failure);
});
};
var planDetails = planService.getPlan(planId)
StripeCheckout.open({
key: KeyService.stripePublishableKey,
address: false, // TODO change to true
amount: planDetails.price,
currency: 'usd',
name: 'Quay ' + planDetails.title + ' Subscription',
description: 'Up to ' + planDetails.privateRepos + ' private repositories',
panelLabel: 'Subscribe',
token: submitToken
planService.getPlan(planId, function(planDetails) {
StripeCheckout.open({
key: KeyService.stripePublishableKey,
address: false,
amount: planDetails.price,
currency: 'usd',
name: 'Quay ' + planDetails.title + ' Subscription',
description: 'Up to ' + planDetails.privateRepos + ' private repositories',
panelLabel: 'Subscribe',
token: submitToken,
image: 'static/img/quay-icon-stripe.png'
});
});
};
@ -194,12 +297,19 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
when('/repository/:namespace/:name/image/:image', {templateUrl: '/static/partials/image-view.html', controller: ImageViewCtrl}).
when('/repository/:namespace/:name/admin', {templateUrl: '/static/partials/repo-admin.html', controller:RepoAdminCtrl}).
when('/repository/', {title: 'Repositories', templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}).
when('/user/', {title: 'User Admin', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
when('/guide/', {title: 'User Guide', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}).
when('/user/', {title: 'Account Settings', templateUrl: '/static/partials/user-admin.html', controller: UserAdminCtrl}).
when('/guide/', {title: 'Guide', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}).
when('/plans/', {title: 'Plans and Pricing', templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
when('/signin/', {title: 'Signin', templateUrl: '/static/partials/signin.html', controller: SigninCtrl}).
when('/signin/', {title: 'Sign In', templateUrl: '/static/partials/signin.html', controller: SigninCtrl}).
when('/new/', {title: 'Create new repository', templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
when('/organizations/', {title: 'Organizations', templateUrl: '/static/partials/organizations.html', controller: OrgsCtrl}).
when('/organizations/new/', {title: 'New Organization', templateUrl: '/static/partials/new-organization.html', controller: NewOrgCtrl}).
when('/organization/:orgname', {templateUrl: '/static/partials/org-view.html', controller: OrgViewCtrl}).
when('/organization/:orgname/admin', {templateUrl: '/static/partials/org-admin.html', controller: OrgAdminCtrl}).
when('/organization/:orgname/teams/:teamname', {templateUrl: '/static/partials/team-view.html', controller: TeamViewCtrl}).
when('/v1/', {title: 'Activation information', templateUrl: '/static/partials/v1-page.html', controller: V1Ctrl}).
when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl}).
@ -209,6 +319,31 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
RestangularProvider.setBaseUrl('/api/');
});
quayApp.directive('markdownView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-view.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
'firstLineOnly': '=firstLineOnly'
},
controller: function($scope, $element) {
$scope.getMarkedDown = function(content, firstLineOnly) {
if (firstLineOnly) {
content = getFirstTextLine(content);
}
return getMarkedDown(content);
};
}
};
return directiveDefinitionObject;
});
quayApp.directive('repoCircle', function () {
var directiveDefinitionObject = {
priority: 0,
@ -226,6 +361,415 @@ quayApp.directive('repoCircle', function () {
});
quayApp.directive('signinForm', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/signin-form.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'redirectUrl': '=redirectUrl'
},
controller: function($scope, $location, $timeout, Restangular, KeyService, UserService) {
$scope.githubClientId = KeyService.githubClientId;
var appendMixpanelId = function() {
if (mixpanel.get_distinct_id !== undefined) {
$scope.mixpanelDistinctIdClause = "&state=" + mixpanel.get_distinct_id();
} else {
// Mixpanel not yet loaded, try again later
$timeout(appendMixpanelId, 200);
}
};
appendMixpanelId();
$scope.signin = function() {
var signinPost = Restangular.one('signin');
signinPost.customPOST($scope.user).then(function() {
$scope.needsEmailVerification = false;
$scope.invalidCredentials = false;
// Redirect to the specified page or the landing page
UserService.load();
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
}, function(result) {
$scope.needsEmailVerification = result.data.needsEmailVerification;
$scope.invalidCredentials = result.data.invalidCredentials;
});
};
}
};
return directiveDefinitionObject;
});
quayApp.directive('plansTable', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/plans-table.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'plans': '=plans',
'currentPlan': '=currentPlan'
},
controller: function($scope, $element) {
$scope.setPlan = function(plan) {
$scope.currentPlan = plan;
};
}
};
return directiveDefinitionObject;
});
quayApp.directive('organizationHeader', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/organization-header.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'organization': '=organization',
'teamName': '=teamName'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});
quayApp.directive('markdownInput', function () {
var counter = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-input.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
'canWrite': '=canWrite',
'contentChanged': '=contentChanged',
'fieldTitle': '=fieldTitle'
},
controller: function($scope, $element) {
var elm = $element[0];
$scope.id = (counter++);
$scope.editContent = function() {
if (!$scope.canWrite) { return; }
if (!$scope.markdownDescriptionEditor) {
var converter = Markdown.getSanitizingConverter();
var editor = new Markdown.Editor(converter, '-description-' + $scope.id);
editor.run();
$scope.markdownDescriptionEditor = editor;
}
$('#wmd-input-description-' + $scope.id)[0].value = $scope.content;
$(elm).find('.modal').modal({});
};
$scope.saveContent = function() {
$scope.content = $('#wmd-input-description-' + $scope.id)[0].value;
$(elm).find('.modal').modal('hide');
if ($scope.contentChanged) {
$scope.contentChanged($scope.content);
}
};
}
};
return directiveDefinitionObject;
});
quayApp.directive('entitySearch', function () {
var number = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/entity-search.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'organization': '=organization',
'inputTitle': '=inputTitle',
'entitySelected': '=entitySelected'
},
controller: function($scope, $element) {
if (!$scope.entitySelected) { return; }
number++;
var input = $element[0].firstChild;
$scope.organization = $scope.organization || '';
$(input).typeahead({
name: 'entities' + number,
remote: {
url: '/api/entities/%QUERY',
replace: function (url, uriEncodedQuery) {
url = url.replace('%QUERY', uriEncodedQuery);
if ($scope.organization) {
url += '?organization=' + encodeURIComponent($scope.organization);
}
return url;
},
filter: function(data) {
var datums = [];
for (var i = 0; i < data.results.length; ++i) {
var entity = data.results[i];
datums.push({
'value': entity.name,
'tokens': [entity.name],
'entity': entity
});
}
return datums;
}
},
template: function (datum) {
template = '<div class="entity-mini-listing">';
if (datum.entity.kind == 'user') {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'team') {
template += '<i class="fa fa-group fa-lg"></i>';
}
template += '<span class="name">' + datum.value + '</span>';
if (datum.entity.is_org_member !== undefined && !datum.entity.is_org_member) {
template += '<div class="alert-warning warning">This user is outside your organization</div>';
}
template += '</div>';
return template;
},
});
$(input).on('typeahead:selected', function(e, datum) {
$(input).typeahead('setQuery', '');
$scope.entitySelected(datum.entity);
});
$scope.$watch('inputTitle', function(title) {
input.setAttribute('placeholder', title);
});
}
};
return directiveDefinitionObject;
});
quayApp.directive('roleGroup', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/role-group.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'roles': '=roles',
'currentRole': '=currentRole',
'roleChanged': '&roleChanged'
},
controller: function($scope, $element) {
$scope.setRole = function(role) {
if ($scope.currentRole == role) { return; }
if ($scope.roleChanged) {
$scope.roleChanged({'role': role});
} else {
$scope.currentRole = role;
}
};
}
};
return directiveDefinitionObject;
});
quayApp.directive('planManager', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/plan-manager.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'user': '=user',
'organization': '=organization',
'readyForPlan': '&readyForPlan'
},
controller: function($scope, $element, PlanService, Restangular) {
var hasSubscription = false;
$scope.getActiveSubClass = function() {
return 'active';
};
$scope.changeSubscription = function(planId) {
if ($scope.planChanging) { return; }
PlanService.changePlan($scope, $scope.organization, planId, hasSubscription, function() {
// Started.
$scope.planChanging = true;
}, function(sub) {
// Success.
subscribedToPlan(sub);
}, function() {
// Failure.
$scope.planChanging = false;
});
};
$scope.cancelSubscription = function() {
$scope.changeSubscription(getFreePlan());
};
var subscribedToPlan = function(sub) {
$scope.subscription = sub;
if (sub.plan != getFreePlan()) {
hasSubscription = true;
}
PlanService.getPlan(sub.plan, function(subscribedPlan) {
$scope.subscribedPlan = subscribedPlan;
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
$scope.limit = 'over';
} else if (sub.usedPrivateRepos == $scope.subscribedPlan.privateRepos) {
$scope.limit = 'at';
} else if (sub.usedPrivateRepos >= $scope.subscribedPlan.privateRepos * 0.7) {
$scope.limit = 'near';
} else {
$scope.limit = 'none';
}
if (!$scope.chart) {
$scope.chart = new RepositoryUsageChart();
$scope.chart.draw('repository-usage-chart');
}
$scope.chart.update(sub.usedPrivateRepos || 0, $scope.subscribedPlan.privateRepos || 0);
$scope.planChanging = false;
$scope.planLoading = false;
});
};
var getFreePlan = function() {
for (var i = 0; i < $scope.plans.length; ++i) {
if ($scope.plans[i].price == 0) {
return $scope.plans[i].stripeId;
}
}
return 'free';
};
var update = function() {
$scope.planLoading = true;
if (!$scope.plans) { return; }
PlanService.getSubscription($scope.organization, subscribedToPlan, function() {
// User/Organization has no subscription.
subscribedToPlan({ 'plan': getFreePlan() });
});
};
var loadPlans = function() {
if ($scope.plans || $scope.loadingPlans) { return; }
if (!$scope.user && !$scope.organization) { return; }
$scope.loadingPlans = true;
PlanService.getPlans(function(plans) {
$scope.plans = plans[$scope.organization ? 'business' : 'user'];
update();
if ($scope.readyForPlan) {
var planRequested = $scope.readyForPlan();
if (planRequested && planRequested != getFreePlan()) {
$scope.changeSubscription(planRequested);
}
}
});
};
// Start the initial download.
$scope.planLoading = true;
loadPlans();
$scope.$watch('organization', loadPlans);
$scope.$watch('user', loadPlans);
}
};
return directiveDefinitionObject;
});
quayApp.directive('namespaceSelector', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/namespace-selector.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'user': '=user',
'namespace': '=namespace',
'requireCreate': '=requireCreate'
},
controller: function($scope, $element, $routeParams, $cookieStore) {
$scope.namespaces = {};
$scope.initialize = function(user) {
var namespaces = {};
namespaces[user.username] = user;
if (user.organizations) {
for (var i = 0; i < user.organizations.length; ++i) {
namespaces[user.organizations[i].name] = user.organizations[i];
}
}
var initialNamespace = $routeParams['namespace'] || $cookieStore.get('quay.currentnamespace') || $scope.user.username;
$scope.namespaces = namespaces;
$scope.setNamespace($scope.namespaces[initialNamespace]);
};
$scope.setNamespace = function(namespaceObj) {
if (!namespaceObj) {
namespaceObj = $scope.namespaces[$scope.user.username];
}
if ($scope.requireCreate && !namespaceObj.can_create_repo) {
namespaceObj = $scope.namespaces[$scope.user.username];
}
var newNamespace = namespaceObj.name || namespaceObj.username;
$scope.namespaceObj = namespaceObj;
$scope.namespace = newNamespace;
$cookieStore.put('quay.currentnamespace', newNamespace);
};
$scope.$watch('user', function(user) {
$scope.user = user;
$scope.initialize(user);
});
}
};
return directiveDefinitionObject;
});
quayApp.directive('buildStatus', function () {
var directiveDefinitionObject = {
priority: 0,
@ -292,6 +836,15 @@ quayApp.directive('buildStatus', function () {
return directiveDefinitionObject;
});
// Note: ngBlur is not yet in Angular stable, so we add it manaully here.
quayApp.directive('ngBlur', function() {
return function( scope, elem, attrs ) {
elem.bind('blur', function() {
scope.$apply(attrs.ngBlur);
});
};
});
quayApp.run(['$location', '$rootScope', function($location, $rootScope) {
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
if (current.$$route.title) {

File diff suppressed because it is too large Load diff

View file

@ -137,6 +137,10 @@ ImageHistoryTree.prototype.draw = function(container) {
.direction('e')
.html(function(d) {
var html = '';
if (d.virtual) {
return d.name;
}
if (d.collapsed) {
for (var i = 1; i < d.encountered.length; ++i) {
html += '<span>' + d.encountered[i].image.id.substr(0, 12) + '</span>';
@ -272,6 +276,7 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// For each node, attach it to its immediate parent. If there is no immediate parent,
// then the node is the root.
var roots = [];
for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i];
var imageNode = imageByDBID[image.dbid];
@ -283,10 +288,22 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
imageNode.parent = parent;
parent.children.push(imageNode);
} else {
formatted = imageNode;
roots.push(imageNode);
}
}
// If there are multiple root nodes, then there is at least one branch without shared
// ancestry and we use the virtual node. Otherwise, we use the root node found.
var root = {
'name': '',
'children': roots,
'virtual': true
};
if (roots.length == 1) {
root = roots[0];
}
// Determine the maximum number of nodes at a particular level. This is used to size
// the width of the tree properly.
var maxChildCount = 0;
@ -300,14 +317,14 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
// section. We only do this if the max width is > 1 (since for a single width tree, no long
// chain will hide a branch).
if (maxChildCount > 1) {
this.collapseNodes_(formatted);
this.collapseNodes_(root);
}
// Determine the maximum height of the tree.
var maxHeight = this.determineMaximumHeight_(formatted);
var maxHeight = this.determineMaximumHeight_(root);
// Finally, set the root node and return.
this.root_ = formatted;
this.root_ = root;
return {
'maxWidth': maxChildCount + 1,
@ -566,7 +583,6 @@ ImageHistoryTree.prototype.update_ = function(source) {
// Translate the foreign object so the tags are under the ID.
fo.attr("transform", function(d, i) {
bbox = this.getBBox()
return "translate(" + [-130, 0 ] + ")";
});
@ -594,6 +610,9 @@ ImageHistoryTree.prototype.update_ = function(source) {
if (d.collapsed) {
return 'collapsed';
}
if (d.virtual) {
return 'virtual';
}
if (!currentImage) {
return '';
}
@ -1130,4 +1149,116 @@ ImageFileChangeTree.prototype.toggle_ = function(d) {
d.children = d._children;
d._children = null;
}
};
////////////////////////////////////////////////////////////////////////////////
/**
* Based off of http://bl.ocks.org/mbostock/1346410
*/
function RepositoryUsageChart() {
this.total_ = null;
this.count_ = null;
this.drawn_ = false;
}
/**
* Updates the chart with the given count and total of number of repositories.
*/
RepositoryUsageChart.prototype.update = function(count, total) {
if (!this.g_) { return; }
this.total_ = total;
this.count_ = count;
this.drawInternal_();
};
/**
* Conducts the actual draw or update (if applicable).
*/
RepositoryUsageChart.prototype.drawInternal_ = function() {
// If the total is null, then we have not yet set the proper counts.
if (this.total_ === null) { return; }
var duration = 750;
var arc = this.arc_;
var pie = this.pie_;
var arcTween = this.arcTween_;
var color = d3.scale.category20();
var count = this.count_;
var total = this.total_;
var data = [Math.max(count, 1), Math.max(0, total - count)];
var arcTween = function(a) {
var i = d3.interpolate(this._current, a);
this._current = i(0);
return function(t) {
return arc(i(t));
};
};
if (!this.drawn_) {
var text = this.g_.append("svg:text")
.attr("dy", 10)
.attr("dx", 0)
.attr('dominant-baseline', 'auto')
.attr('text-anchor', 'middle')
.attr('class', 'count-text')
.text(this.count_ + ' / ' + this.total_);
var path = this.g_.datum(data).selectAll("path")
.data(pie)
.enter().append("path")
.attr("fill", function(d, i) { return color(i); })
.attr("class", function(d, i) { return 'arc-' + i; })
.attr("d", arc)
.each(function(d) { this._current = d; }); // store the initial angles
this.path_ = path;
this.text_ = text;
} else {
pie.value(function(d, i) { return data[i]; }); // change the value function
this.path_ = this.path_.data(pie); // compute the new angles
this.path_.transition().duration(duration).attrTween("d", arcTween); // redraw the arcs
// Update the text.
this.text_.text(this.count_ + ' / ' + this.total_);
}
this.drawn_ = true;
};
/**
* Draws the chart in the given container.
*/
RepositoryUsageChart.prototype.draw = function(container) {
var cw = 200;
var ch = 200;
var radius = Math.min(cw, ch) / 2;
var pie = d3.layout.pie().sort(null);
var arc = d3.svg.arc()
.innerRadius(radius - 50)
.outerRadius(radius - 25);
var svg = d3.select("#" + container).append("svg:svg")
.attr("width", cw)
.attr("height", ch);
var g = svg.append("g")
.attr("transform", "translate(" + cw / 2 + "," + ch / 2 + ")");
this.svg_ = svg;
this.g_ = g;
this.pie_ = pie;
this.arc_ = arc;
this.width_ = cw;
this.drawInternal_();
};

7
static/lib/angular-cookies.min.js vendored Normal file
View file

@ -0,0 +1,7 @@
/*
AngularJS v1.2.0-ed8640b
(c) 2010-2012 Google, Inc. http://angularjs.org
License: MIT
*/
(function(p,f,n){'use strict';f.module("ngCookies",["ng"]).factory("$cookies",["$rootScope","$browser",function(d,b){var c={},g={},h,k=!1,l=f.copy,m=f.isUndefined;b.addPollFn(function(){var a=b.cookies();h!=a&&(h=a,l(a,g),l(a,c),k&&d.$apply())})();k=!0;d.$watch(function(){var a,e,d;for(a in g)m(c[a])&&b.cookies(a,n);for(a in c)(e=c[a],f.isString(e))?e!==g[a]&&(b.cookies(a,e),d=!0):f.isDefined(g[a])?c[a]=g[a]:delete c[a];if(d)for(a in e=b.cookies(),c)c[a]!==e[a]&&(m(e[a])?delete c[a]:c[a]=e[a])});
return c}]).factory("$cookieStore",["$cookies",function(d){return{get:function(b){return(b=d[b])?f.fromJson(b):b},put:function(b,c){d[b]=f.toJson(c)},remove:function(b){delete d[b]}}}])})(window,window.angular);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
<form name="newteamform" ng-submit="createTeam(); hide()" novalidate>
<input id="create-team-box" type="text form-control" placeholder="Team Name" ng-blur="hide()" ng-pattern="/^[a-zA-Z][a-zA-Z0-9]+$/" ng-model="newTeamName" ng-trim="false" ng-minlength="2" required>
</form>

View file

@ -1,7 +1,7 @@
<div class="container ready-indicator" data-status="{{ status }}">
<div class="alert alert-warning">Warning: Quay requires docker version 0.6.2 or higher to work</div>
<h2>User guide</h2>
<h2>User Guide</h2>
<div class="user-guide container">
<h3>Pulling a repository from Quay</h3>

View file

@ -15,8 +15,9 @@
<div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav">
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}">Repositories</a></li>
<li><a ng-href="/guide/" target="{{ appLinkTarget() }}">User Guide</a></li>
<li><a ng-href="/plans/" target="{{ appLinkTarget() }}">Plans &amp; Pricing</a></li>
<li><a ng-href="/guide/" target="{{ appLinkTarget() }}">Guide</a></li>
<li><a ng-href="/plans/" target="{{ appLinkTarget() }}">Pricing</a></li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
</ul>
@ -28,7 +29,7 @@
</form>
<span class="navbar-left user-tools" ng-show="!user.anonymous">
<a href="/new/"><i class="fa fa-upload user-tool" title="Create new repository"></i></a>
<a href="/new/"><i class="fa fa-upload user-tool" bs-tooltip="tooltip.title" data-placement="bottom" title="Create new repository"></i></a>
</span>
<li class="dropdown" ng-switch-when="false">
@ -45,6 +46,7 @@
<span class="badge user-notification" ng-show="user.askForPassword">1</span>
</a>
</li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
<li><a href="javascript:void(0)" ng-click="signout()">Sign out</a></li>
</ul>
</li>

View file

@ -20,7 +20,9 @@
</div>
<!-- Comment -->
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
<blockquote ng-show="image.comment">
<span class="markdown-view" content="image.comment"></span>
</blockquote>
<!-- Information -->
<dl class="dl-normal">

View file

@ -12,22 +12,22 @@
<div ng-show="loadingmyrepos">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<span class="namespace-selector" user="user" namespace="namespace" ng-show="!loadingmyrepos && user.organizations"></span>
<div ng-show="!loadingmyrepos && myrepos.length > 0">
<h2>Your Top Repositories</h2>
<h2>Top Repositories</h2>
<div class="repo-listing" ng-repeat="repository in myrepos">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div>
</div>
<div ng-show="!loadingmyrepos && myrepos.length == 0">
<div class="sub-message">
You don't have any <b>private</b> repositories yet!
<div class="sub-message" style="margin-top: 20px">
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
<div class="options">
<div class="option"><a href="/guide">Learn how to create a repository</a></div>
<div class="or"><span>or</span></div>
<div class="option"><a href="/repository">Browse the public repositories</a></div>
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
</div>
</div>
</div>

View file

@ -0,0 +1,102 @@
<div class="loading" ng-show="loading || creating">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="container create-org" ng-show="!loading && !creating">
<div class="row header-row">
<div class="col-md-8 col-md-offset-1">
<h2>Create Organization</h2>
<div class="steps-container" ng-show="false">
<ul class="steps">
<li class="step" ng-class="!user || user.anonymous ? 'active' : ''">
<i class="fa fa-sign-in"></i>
<span class="title">Login with an account</span>
</li>
<li class="step" ng-class="!user.anonymous && !created ? 'active' : ''">
<i class="fa fa-gear"></i>
<span class="title">Setup your organization</span>
</li>
<li class="step" ng-class="!user.anonymous && created ? 'active' : ''">
<i class="fa fa-group"></i>
<span class="title">Create teams</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Step 1 -->
<div class="row" ng-show="!user || user.anonymous">
<div class="col-md-10 col-md-offset-1 page-description">
In order to create a new organization, <b>you must first be signed in</b> as the
user that <b>will become an admin</b> for the organization. Please sign-in if
you already have an account, or <a href="/">sign up</a> on the landing
page to create a new account.
</div>
<div class="col-sm-6 col-sm-offset-3">
<div class="step-container" >
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">Sign In</h4>
</div>
<div class="panel-body">
<div class="signin-form" redirect-url="'/organizations/new'"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="row" ng-show="user && !user.anonymous && !created">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="step-container">
<h3>Setup the new organization</h3>
<form method="post" name="newOrgForm" id="newOrgForm" ng-submit="createNewOrg()">
<div class="form-group">
<label for="orgName">Organization Name</label>
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name"
ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
data-placement="right">
<span class="description">This will also be the namespace for your repositories</span>
</div>
<div class="form-group">
<label for="orgName">Organization Email</label>
<input id="orgEmail" name="orgEmail" type="email" class="form-control" placeholder="Organization Email"
ng-model="org.email" required>
<span class="description">This address must be different from your account's email</span>
</div>
<!-- Plans Table -->
<div class="form-group plan-group">
<strong>Choose your organization's plan</strong>
<div class="plans-table" plans="plans" current-plan="currentPlan"></div>
</div>
<div class="button-bar">
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan">
Create Organization
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Step 3 -->
<div class="row" ng-show="user && !user.anonymous && created">
<div class="col-md-1"></div>
<div class="col-md-8">
<div class="step-container">
<h3>Organization Created</h3>
<h4><a href="/organization/{{ org.name }}">Manage Teams Now</a></h4>
</div>
</div>
</div>
</div>

View file

@ -28,17 +28,19 @@
<div class="col-md-8">
<div class="section">
<div class="new-header">
<span class="repo-circle no-background" repo="repo"></span>
<span style="color: #444;"> {{user.username}}</span> <span style="color: #ccc">/</span> <span class="name-container"><input id="repoName" name="repoName" type="text" class="form-control" placeholder="Repository Name" ng-model="repo.name" required autofocus data-trigger="manual" data-content="{{ createError }}" data-placement="right"></span>
<span style="color: #444;">
<span class="namespace-selector" user="user" namespace="repo.namespace" require-create="true"></span>
<span style="color: #ccc">/</span>
<span class="name-container">
<input id="repoName" name="repoName" type="text" class="form-control" placeholder="Repository Name" ng-model="repo.name" required autofocus data-trigger="manual" data-content="{{ createError }}" data-placement="right">
</span>
</div>
</div>
<div class="section">
<strong>Description:</strong><br>
<p class="description lead editable" ng-click="editDescription()">
<span class="content" ng-bind-html-unsafe="getMarkedDown(repo.description)"></span>
<i class="fa fa-edit"></i>
</p>
<div class="description markdown-input" content="repo.description" can-write="true"
field-title="'repository description'"></div>
</div>
</div>
</div>
@ -68,13 +70,19 @@
</div>
<!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired">
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
<div class="alert alert-warning">
In order to make this repository private, youll need to upgrade your plan from <b>{{ subscribedPlan.title }}</b> to <b>{{ planRequired.title }}</b>. This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
</div>
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i>
</div>
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
<div class="alert alert-warning">
This organization has reached its private repository limit. Please contact your administrator.
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,67 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="loading" ng-show="!loading && !organization">
No matching organization found
</div>
<div class="org-admin container" ng-show="!loading && organization">
<div class="organization-header" organization="organization"></div>
<div class="row">
<!-- Side tabs -->
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
</ul>
</div>
<!-- Content -->
<div class="col-md-10">
<div class="tab-content">
<!-- Plans tab -->
<div id="plan" class="tab-pane active">
<div class="plan-manager" organization="orgname"></div>
</div>
<!-- Members tab -->
<div id="members" class="tab-pane">
<i class="fa fa-spinner fa-spin fa-3x" ng-show="membersLoading"></i>
<div ng-show="!membersLoading">
<div class="side-controls">
<div class="result-count">
Showing {{(membersFound | filter:search | limitTo:50).length}} of {{(membersFound | filter:search).length}} matching members
</div>
<div class="filter-input">
<input id="member-filter" class="form-control" placeholder="Filter Members" type="text" ng-model="search.$">
</div>
</div>
<table class="table table-striped">
<thead>
<th>User</th>
<th>Teams</th>
</thead>
<tr ng-repeat="memberInfo in (membersFound | filter:search | limitTo:50)">
<td>
<i class="fa fa-user"></i>
{{ memberInfo.username }}
</td>
<td>
<span class="team-link" ng-repeat="team in memberInfo.teams">
<i class="fa fa-group"></i>
<a href="/organization/{{ organization.name }}/teams/{{ team }}">{{ team }}</a>
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,86 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="loading" ng-show="!loading && !organization">
No matching organization found
</div>
<div class="org-view container" ng-show="!loading && organization">
<div class="organization-header" organization="organization">
<div class="header-buttons" ng-show="organization.is_admin">
<button class="btn btn-success" data-trigger="click" bs-popover="'static/partials/create-team-dialog.html'" data-placement="bottom" ng-click="createTeamShown()"><i class="fa fa-group"></i> Create Team</button>
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
</div>
</div>
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
Team Permissions
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
</div>
</div>
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
<div id="team-{{name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<i class="fa fa-group"></i>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
</div>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
</div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotChangeTeamModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change team</h4>
</div>
<div class="modal-body">
<span ng-show="!roleError">You do not have permission to change properties on teams.</span>
<span ng-show="roleError">{{ roleError }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete Team?</h4>
</div>
<div class="modal-body">
Are you sure you would like to delete this team? This <b>cannot be undone</b>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" ng-click="deleteTeam()">Delete Team</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -0,0 +1,129 @@
<div class="container org-list">
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="button-bar-right">
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<button class="btn btn-success">
<i class="fa fa-plus"></i>
Create New Organization
</button>
</a>
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<button class="btn btn-primary">
<i class="fa fa-caret-square-o-right"></i>
Convert account
</button>
</a>
</div>
<!-- Organizations -->
<div ng-show="user.organizations.length > 0">
<h2>Organizations</h2>
<div class="organization-listing" ng-repeat="organization in user.organizations">
<img class="gravatar" src="//www.gravatar.com/avatar/{{ organization.gravatar }}?s=32&amp;d=identicon">
<a class="org-title" href="/organization/{{ organization.name }}">{{ organization.name }}</a>
</div>
</div>
<!-- Organization Help/Tour -->
<div class="product-tour" ng-show="!user.organizations || user.organizations.length == 0">
<div class="tour-section row">
<div class="col-md-12">
<div class="tour-section-title">Organizations</div>
<div class="tour-section-description">
Organizations in Quay provide unique features for businesses and other
groups, including team-based sharing and fine-grained permission controls.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">A central collection of repositories</div>
<div class="tour-section-description">
Your organization is the focal point for all activity that occurs within
your public or private repositories. Your repositories are centrally visible
and managed within the namespace of your organization. You may share
your repositories with as many users and teams as you like, without
any additional cost.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Organization settings at a glance</div>
<div class="tour-section-description">
Your organization allows you to view your private repository count
and manage billing settings in a centralized place.
</div>
<div class="tour-section-description">
You can also see all of the users who have access to your organization
and the teams of which they are members. This allows you to audit the
access that has been granted in your organization.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-md-7"><img src="/static/img/org-teams.png" title="buynlarge - Quay" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
<div class="col-md-5">
<div class="tour-section-title">Teams simplify access controls</div>
<div class="tour-section-description">
Teams allow your organization to delegate access to your namespace and
repositories in a controlled fashion. Each team has permissions that
apply across the entire org, and can also be given specific levels of
access to specific repositories. A user is switching roles? No problem,
change their team membership and their access will be adjusted accordingly.
</div>
<div class="tour-section-description">
Owners of your organization, and members of other teams with
administrator privileges, have full permissions to all repositories
in the organization, as well as permissions to view and adjust the
account settings for the organization. Add users to these teams with
caution.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
<div class="col-md-5 col-md-pull-7">
<div class="tour-section-title">Fine-grained control of sharing</div>
<div class="tour-section-description">
Repositories that you create within your organization can be assigned
fine-grained permissions just like any other repository. You can also
add teams that exist in your organization, or individual users from
inside our outside your organization.
</div>
<div class="tour-section-description">
In order to protect your intellectual property, we warn you before
you share your repositories with anyone who is not currently a member
of a team in your organization.
</div>
</div>
</div>
<div class="button-bar-right button-bar-bottom">
<a href="/organizations/new/" title="Starts the process to create a new organization" bs-tooltip="tooltip.title">
<button class="btn btn-success">
<i class="fa fa-plus"></i>
Create New Organization
</button>
</a>
<a href="/user/?migrate" ng-show="!user.anonymous" title="Starts the process to convert this account into an organization" bs-tooltip="tooltip.title">
<button class="btn btn-primary">
<i class="fa fa-caret-square-o-right"></i>
Convert account
</button>
</a>
</div>
</div>
</div>

View file

@ -7,15 +7,39 @@
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
</div>
<div class="plans-list">
<div class="plan" ng-repeat="plan in plans" ng-class="plan.stripeId">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="description">{{ plan.audience }}</div>
<div class="smaller">SSL secured connections</div>
<div class="row plans-list">
<div class="col-xs-0 col-lg-1"></div>
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.user">
<div class="plan" ng-class="plan.stripeId">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="description">{{ plan.audience }}</div>
<div class="smaller">SSL secured connections</div>
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
</div>
</div>
</div>
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
<div class="callout">
Business Plan Pricing
</div>
<div class="all-plans">
All business plans include all of the personal plan features, plus: <span class="business-feature">organizations</span> and <span class="business-feature">teams</span> with <span class="business-feature">delegated access</span> to the organization. All business plans have a <span class="business-feature">14-day free trial</span>.
</div>
<div class="row plans-list">
<div class="col-xs-0 col-lg-1"></div>
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.business">
<div class="plan business-plan" ng-class="plan.stripeId">
<div class="plan-title">{{ plan.title }}</div>
<div class="plan-price">${{ plan.price/100 }}</div>
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
<div class="description">{{ plan.audience }}</div>
<div class="smaller">SSL secured connections</div>
<button class="btn btn-success btn-block" ng-click="createOrg(plan.stripeId)">Sign Up Now</button>
</div>
</div>
</div>

View file

@ -20,44 +20,61 @@
<!-- User Access Permissions -->
<div class="panel panel-default">
<div class="panel-heading">User Access Permissions
<div class="panel-heading">User <span ng-show="repo.is_organization">and Team</span> Access Permissions
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users to read, write or administer this repository"></i>
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Allow any number of users or teams to read, write or administer this repository"></i>
</div>
<div class="panel-body">
<table class="permissions">
<thead>
<tr>
<td>User</td>
<td>User<span ng-show="repo.is_organization">/Team</span></td>
<td>Permissions</td>
<td></td>
<td style="width: 95px;"></td>
</tr>
</thead>
<tr ng-repeat="(username, permission) in permissions">
<td class="user">
<!-- Team Permissions -->
<tr ng-repeat="(name, permission) in permissions['team']">
<td class="team entity">
<i class="fa fa-group"></i>
<span><a href="/organization/{{ repo.namespace }}/teams/{{ name }}">{{name}}</a></span>
</td>
<td class="user-permissions">
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'team')" roles="roles"></span>
</td>
<td>
<span class="delete-ui" tabindex="0">
<span class="delete-ui-button" ng-click="deleteRole(name, 'team')"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
</span>
</td>
</tr>
<!-- User Permissions -->
<tr ng-repeat="(name, permission) in permissions['user']">
<td class="{{ 'user entity ' + (permission.is_org_member? '' : 'outside') }}">
<i class="fa fa-user"></i>
<span>{{username}}</span>
<span>{{name}}</span>
<i class="fa fa-exclamation-triangle" ng-show="permission.is_org_member === false" data-trigger="hover" bs-popover="{'content': 'This user is not a member of the organization'}"></i>
</td>
<td class="user-permissions">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-default" ng-click="setRole(username, 'read')" ng-class="{read: 'active', write: '', admin: ''}[permission.role]">Read only</button>
<button type="button" class="btn btn-default" ng-click="setRole(username, 'write')" ng-class="{read: '', write: 'active', admin: ''}[permission.role]">Write</button>
<button type="button" class="btn btn-default" ng-click="setRole(username, 'admin')" ng-class="{read: '', write: '', admin: 'active'}[permission.role]">Admin</button>
<span class="role-group" current-role="permission.role" role-changed="setRole(role, name, 'user')" roles="roles"></span>
</div>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Delete Permission">
<span class="delete-ui-button" ng-click="deleteRole(username)"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times"></i>
<span class="delete-ui-button" ng-click="deleteRole(name, 'user')"><button class="btn btn-danger">Delete</button></span>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Permission"></i>
</span>
</td>
</tr>
<tr>
<td colspan="2">
<input id="userSearch" class="form-control" placeholder="Add new user...">
<span class="entity-search" organization="repo.namespace" input-title="'Add a ' + (repo.is_organization ? 'team or ' : '') + 'user...'" entity-selected="addNewPermission"></span>
</td>
</tr>
</table>
@ -93,9 +110,9 @@
</div>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Delete Token">
<span class="delete-ui" tabindex="0">
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger" type="button">Delete</button></span>
<i class="fa fa-times"></i>
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Token"></i>
</span>
</td>
</tr>
@ -246,7 +263,7 @@
<!-- Modal message dialog -->
<div class="modal fade" id="onlyadminModal">
<div class="modal fade" id="channgechangepermModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@ -254,7 +271,8 @@
<h4 class="modal-title">Cannot change permissions</h4>
</div>
<div class="modal-body">
The selected permissions could not be changed because the user is the only <b>admin</b> on the repo.
<span ng-show="!changePermError">You do not have permission to change the permissions on the repository.</span>
<span ng-show="changePermError">{{ changePermError }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
@ -283,4 +301,24 @@
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="confirmaddoutsideModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Add User?</h4>
</div>
<div class="modal-body">
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -4,25 +4,41 @@
<div class="container ready-indicator" ng-show="!loading" data-status="{{ loading ? '' : 'ready' }}">
<div class="repo-list" ng-show="!user.anonymous">
<a href="/new/">
<button class="btn btn-success" style="float: right">
<i class="fa fa-upload user-tool" title="Create new repository"></i>
Create Repository
</button>
</a>
<div ng-class="user.organizations.length ? 'section-header' : ''">
<div class="button-bar-right">
<a href="/new/">
<button class="btn btn-success">
<i class="fa fa-upload user-tool" title="Create new repository"></i>
Create Repository
</button>
</a>
<a href="/organization/{{ namespace }}" ng-show="namespace != user.username">
<button class="btn btn-default">
<i class="fa fa-group user-tool"></i>
View Organization
</button>
</a>
</div>
<span class="namespace-selector" user="user" namespace="namespace" ng-show="user.organizations"></span>
</div>
<h3>Your Repositories</h3>
<div ng-show="private_repositories.length > 0">
<div class="repo-listing" ng-repeat="repository in private_repositories">
<h3 ng-show="namespace == user.username">Your Repositories</h3>
<h3 ng-show="namespace != user.username">Repositories</h3>
<div ng-show="user_repositories.length > 0">
<div class="repo-listing" ng-repeat="repository in user_repositories">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
</div>
</div>
<div ng-show="private_repositories.length == 0" style="padding:20px;">
<div ng-show="user_repositories.length == 0" style="padding:20px;">
<div class="alert alert-info">
<h4>You don't have any repositories yet!</h4>
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
</div>
@ -34,7 +50,7 @@
<div class="repo-listing" ng-repeat="repository in public_repositories">
<span class="repo-circle no-background" repo="repository"></span>
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="description" ng-bind-html-unsafe="getCommentFirstLine(repository.description)"></div>
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
</div>
</div>
</div>

View file

@ -12,22 +12,7 @@
</div>
<div id="collapseSignin" class="panel-collapse collapse in">
<div class="panel-body">
<form class="form-signin" ng-submit="signin();">
<input type="text" class="form-control input-lg" name="username" placeholder="Username" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password" placeholder="Password" ng-model="user.password">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
<span class="social-alternate">
<i class="fa fa-circle"></i>
<span class="inner-text">OR</span>
</span>
<a id='github-signin-link' href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ mixpanelDistinctIdClause }}" class="btn btn-primary btn-lg btn-block"><i class="fa fa-github fa-lg"></i> Sign In with GitHub</a>
</form>
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>
<div class="alert alert-danger" ng-show="needsEmailVerification">You must verify your email address before you can sign in.</div>
<div class="signin-form"></div>
</div>
</div>
</div>
@ -56,17 +41,3 @@
</div>
</div>
</div>
<!-- <script type="text/javascript">
function appendMixpanelId() {
if (mixpanel.get_distinct_id !== undefined) {
var signinLink = document.getElementById("github-signin-link");
signinLink.href += ("&state=" + mixpanel.get_distinct_id());
} else {
// Mixpanel not yet loaded, try again later
window.setTimeout(appendMixpanelId, 200);
}
};
appendMixpanelId();
</script> -->

View file

@ -0,0 +1,79 @@
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="loading" ng-show="!loading && !organization">
No matching team found
</div>
<div class="team-view container" ng-show="!loading && organization">
<div class="organization-header" organization="organization" team-name="teamname"></div>
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
content-changed="updateForDescription" field-title="'team description'"></div>
<div class="panel panel-default">
<div class="panel-heading">Team Members
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
</div>
<div class="panel-body">
<table class="permissions">
<tr ng-repeat="(name, member) in members">
<td class="user entity">
<i class="fa fa-user"></i>
<span>{{ member.username }}</span>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
<i class="fa fa-times"></i>
</span>
</td>
</tr>
<tr ng-show="canEditMembers">
<td colspan="2">
<span class="entity-search" organization="''" input-title="'Add a user...'" entity-selected="addNewMember"></span>
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotChangeTeamModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change team</h4>
</div>
<div class="modal-body">
You do not have permission to change properties of this team.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="cannotChangeMembersModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot change members</h4>
</div>
<div class="modal-body">
You do not have permission to change the members of this team.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -1,80 +1,181 @@
<div class="container user-admin">
<div class="loading" ng-show="planLoading || planChanging">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="row" ng-show="errorMessage">
<div class="col-md-12">
<div class="alert alert-danger">{{ errorMessage }}</div>
<div class="loading" ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="loading" ng-show="!loading && !user">
No matching user found
</div>
<div class="user-admin container" ng-show="!loading && user">
<div class="row">
<div class="organization-header-element">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&amp;d=identicon">
<span class="organization-name">
{{ user.username }}
</span>
</div>
</div>
<div class="row" ng-show="askForPassword">
<div class="col-md-12">
<div class="alert alert-warning">Your account does not currently have a password. You will need to create a password before you will be able to <strong>push</strong> or <strong>pull</strong> repositories.</div>
</div>
</div>
<div class="row" ng-hide="planLoading">
<div class="col-md-3" ng-repeat='plan in plans'>
<div class="panel" ng-class="{'panel-success': subscription.plan == plan.stripeId, 'panel-default': subscription.plan != plan.stripeId}">
<div class="panel-heading">
{{ plan.title }}
<span class="pull-right" ng-show="subscription.plan == plan.stripeId">
<i class="fa fa-ok"></i>
Subscribed
</span>
</div>
<div class="panel-body panel-plan">
<div class="plan-price">${{ plan.price / 100 }}</div>
<div class="plan-description"><b>{{ plan.privateRepos }}</b> Private Repositories</div>
<div ng-switch='plan.stripeId'>
<div ng-switch-when='free'>
<button class="btn button-hidden">Hidden!</button>
</div>
<div ng-switch-default>
<button class="btn btn-primary" ng-show="subscription.plan === 'free'" ng-click="subscribe(plan.stripeId)">Subscribe</button>
<button class="btn btn-default" ng-hide="subscription.plan === 'free' || subscription.plan === plan.stripeId" ng-click="changeSubscription(plan.stripeId)">Change</button>
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId" ng-click="cancelSubscription()">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row" ng-show="subscription">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
Plan Usage
</div>
<div class="panel-body">
<div class="used-description">
<b>{{ subscription.usedPrivateRepos }}</b> of <b>{{ subscribedPlan.privateRepos }}</b> private repositories used
</div>
<div class="progress">
<div ng-class="'progress-bar ' + (planUsagePercent > 90 ? 'progress-bar-danger' : '')" role="progressbar" aria-valuenow="{{ subscription.usedPrivateRepos }}" aria-valuemin="0" aria-valuemax="{{ subscribedPlan.privateRepos }}" style="width: {{ planUsagePercent }}%;">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="loading" ng-show="updatingUser">
<i class="fa fa-spinner fa-spin fa-3x"></i>
<!-- Side tabs -->
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
</ul>
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
Change Password
<!-- Content -->
<div class="col-md-10">
<div class="tab-content">
<!-- Plans tab -->
<div id="plan" class="tab-pane active">
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()"></div>
</div>
<div class="panel-body">
<form class="form-change-pw" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual" data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword" match="user.password" required>
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit" analytics-on analytics-event="register">Change Password</button>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
</form>
<!-- Change password tab -->
<div id="password" class="tab-pane">
<div class="loading" ng-show="updatingUser">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div class="row">
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword"
match="user.password" required>
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
analytics-on analytics-event="register">Change Password</button>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
</form>
</div>
</div>
<!-- Convert to organization tab -->
<div id="migrate" class="tab-pane">
<!-- Step 0 -->
<div class="panel" ng-show="convertStep == 0">
<div class="panel-body" ng-show="user.organizations.length > 0">
<div class="alert alert-info">
Cannot convert this account into an organization, as it is a member of {{user.organizations.length}} other
organization{{user.organizations.length > 1 ? 's' : ''}}. Please leave
{{user.organizations.length > 1 ? 'those organizations' : 'that organization'}} first.
</div>
</div>
<div class="panel-body" ng-show="user.organizations.length == 0">
<div class="alert alert-danger">
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons!
</div>
<button class="btn btn-danger" ng-click="showConvertForm()">Start conversion process</button>
</div>
</div>
<!-- Step 1 -->
<div class="convert-form" ng-show="convertStep == 1">
<h3>Convert to organization</h3>
<form method="post" name="convertForm" id="convertForm" ng-submit="convertToOrg()">
<div class="form-group">
<label for="orgName">Organization Name</label>
<div class="existing-data">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&amp;d=identicon">
{{ user.username }}</div>
<span class="description">This will continue to be the namespace for your repositories</span>
</div>
<div class="form-group">
<label for="orgName">Admin User</label>
<input id="adminUsername" name="adminUsername" type="text" class="form-control" placeholder="Admin Username"
ng-model="org.adminUser" required autofocus>
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
ng-model="org.adminPassword" required>
<span class="description">The username and password for an <b>existing account</b> that will become administrator of the organization</span>
</div>
<!-- Plans Table -->
<div class="form-group plan-group">
<label>Organization Plan</label>
<div class="plans-table" plans="orgPlans" current-plan="org.plan"></div>
</div>
<div class="button-bar">
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan">
Convert To Organization
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotconvertModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Cannot convert account</h4>
</div>
<div class="modal-body">
Your account could not be converted. Please try again in a moment.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="invalidadminModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Username or password invalid</h4>
</div>
<div class="modal-body">
The username or password specified for the admin account is not valid.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="reallyconvertModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Convert to organization?</h4>
</div>
<div class="modal-body">
<div class="alert alert-danger">You will not be able to login to this account once converted</div>
<div>Are you <b>absolutely sure</b> you would like to convert this account to an organization? Once done, there is no going back.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-dismiss="modal" ng-click="reallyConvert()">Absolutely: Convert Now</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->

View file

@ -14,7 +14,7 @@
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings">
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
<i class="fa fa-cog fa-lg"></i>
</a>
@ -52,12 +52,8 @@
</div>
<!-- Description -->
<div class="description">
<p ng-class="'lead ' + (repo.can_write ? 'editable' : 'noteditable')" ng-click="editDescription()">
<span class="content" ng-bind-html-unsafe="getMarkedDown(repo.description)"></span>
<i class="fa fa-edit"></i>
</p>
</div>
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
content-changed="updateForDescription" field-title="'repository description'"></div>
<!-- Empty message -->
<div class="repo-content" ng-show="!currentTag.image && !repo.is_building">
@ -79,7 +75,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<!-- Tag dropdown -->
<div class="tag-dropdown dropdown" title="Tags">
<div class="tag-dropdown dropdown" title="Tags" bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-tag"><span class="tag-count">{{getTagCount(repo)}}</span></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag.name}} <b class="caret"></b></a>
<ul class="dropdown-menu">
@ -107,7 +103,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<!-- Image dropdown -->
<div class="tag-dropdown dropdown" title="Images">
<div class="tag-dropdown dropdown" title="Images" bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-archive"><span class="tag-count">{{imageHistory.length}}</span></i>
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
<ul class="dropdown-menu">
@ -122,7 +118,8 @@
<div class="panel-body">
<div id="current-image">
<div ng-show="currentImage.comment">
<blockquote style="margin-top: 10px;" ng-bind-html-unsafe="getMarkedDown(currentImage.comment)">
<blockquote style="margin-top: 10px;">
<span class="markdown-view" content="currentImage.comment"></span>
</blockquote>
</div>
@ -141,15 +138,18 @@
<div class="changes-container small-changes-container"
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added">
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-plus-square"></i>
<b>{{currentImageChanges.added.length}}</b>
</span>
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed">
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-minus-square"></i>
<b>{{currentImageChanges.removed.length}}</b>
</span>
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed">
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
bs-tooltip="tooltip.title" data-placement="top">
<i class="fa fa-pencil-square"></i>
<b>{{currentImageChanges.changed.length}}</b>
</span>
@ -182,29 +182,4 @@
</div>
</div>
</div>
<!-- Modal edit for the description -->
<div class="modal fade" id="editModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Edit Repository Description</h4>
</div>
<div class="modal-body">
<div class="wmd-panel">
<div id="wmd-button-bar-description"></div>
<textarea class="wmd-input" id="wmd-input-description" placeholder="Enter description">{{ repo.description }}</textarea>
</div>
<div id="wmd-preview-description" class="wmd-panel wmd-preview"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" ng-click="saveDescription()">Save changes</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -1,150 +0,0 @@
import logging
import contextlib
import tempfile
from app import app
__all__ = ['load']
logger = logging.getLogger(__name__)
class Storage(object):
"""Storage is organized as follow:
$ROOT/images/<image_id>/json
$ROOT/images/<image_id>/layer
$ROOT/repositories/<namespace>/<repository_name>/<tag_name>
"""
# Useful if we want to change those locations later without rewriting
# the code which uses Storage
repositories = 'repositories'
images = 'images'
# Set the IO buffer to 64kB
buffer_size = 64 * 1024
#FIXME(samalba): Move all path resolver in each module (out of the base)
def images_list_path(self, namespace, repository):
return '{0}/{1}/{2}/_images_list'.format(self.repositories,
namespace,
repository)
def image_json_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
repository, image_id)
def image_mark_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/_inprogress'.format(self.images, namespace,
repository, image_id)
def image_checksum_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/_checksum'.format(self.images, namespace,
repository, image_id)
def image_layer_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/layer'.format(self.images, namespace,
repository, image_id)
def image_ancestry_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/ancestry'.format(self.images, namespace,
repository, image_id)
def repository_namespace_path(self, namespace, repository):
return '{0}/{1}/{2}/'.format(self.images, namespace, repository)
def image_file_trie_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/files.trie'.format(self.images, namespace,
repository, image_id)
def image_file_diffs_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/diffs.json'.format(self.images, namespace,
repository, image_id)
def get_content(self, path):
raise NotImplementedError
def put_content(self, path, content):
raise NotImplementedError
def stream_read(self, path):
raise NotImplementedError
def stream_read_file(self, path):
raise NotImplementedError
def stream_write(self, path, fp):
raise NotImplementedError
def list_directory(self, path=None):
raise NotImplementedError
def exists(self, path):
raise NotImplementedError
def remove(self, path):
raise NotImplementedError
def get_size(self, path):
raise NotImplementedError
@contextlib.contextmanager
def store_stream(stream):
"""Stores the entire stream to a temporary file."""
tmpf = tempfile.TemporaryFile()
while True:
try:
buf = stream.read(4096)
if not buf:
break
tmpf.write(buf)
except IOError:
break
tmpf.seek(0)
yield tmpf
tmpf.close()
def temp_store_handler():
tmpf = tempfile.TemporaryFile()
def fn(buf):
try:
tmpf.write(buf)
except IOError:
pass
return tmpf, fn
from local import LocalStorage
from s3 import S3Storage
_storage = {}
def load(kind=None):
"""Returns the right storage class according to the configuration."""
global _storage
# TODO hard code to local for now
kind = app.config['STORAGE_KIND']
# if not kind:
# kind = cfg.storage.lower()
if kind in _storage:
return _storage[kind]
if kind == 's3':
logger.debug('Using s3 storage.')
store = S3Storage('', app.config['AWS_ACCESS_KEY'],
app.config['AWS_SECRET_KEY'],
app.config['REGISTRY_S3_BUCKET'])
elif kind == 'local':
logger.debug('Using local storage.')
store = LocalStorage(app.config['LOCAL_STORAGE_DIR'])
else:
raise ValueError('Not supported storage \'{0}\''.format(kind))
_storage[kind] = store
return store

78
storage/basestorage.py Normal file
View file

@ -0,0 +1,78 @@
class Storage(object):
"""Storage is organized as follow:
$ROOT/images/<image_id>/json
$ROOT/images/<image_id>/layer
$ROOT/repositories/<namespace>/<repository_name>/<tag_name>
"""
# Useful if we want to change those locations later without rewriting
# the code which uses Storage
repositories = 'repositories'
images = 'images'
# Set the IO buffer to 64kB
buffer_size = 64 * 1024
#FIXME(samalba): Move all path resolver in each module (out of the base)
def images_list_path(self, namespace, repository):
return '{0}/{1}/{2}/_images_list'.format(self.repositories,
namespace,
repository)
def image_json_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
repository, image_id)
def image_mark_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/_inprogress'.format(self.images, namespace,
repository, image_id)
def image_checksum_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/_checksum'.format(self.images, namespace,
repository, image_id)
def image_layer_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/layer'.format(self.images, namespace,
repository, image_id)
def image_ancestry_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/ancestry'.format(self.images, namespace,
repository, image_id)
def repository_namespace_path(self, namespace, repository):
return '{0}/{1}/{2}/'.format(self.images, namespace, repository)
def image_file_trie_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/files.trie'.format(self.images, namespace,
repository, image_id)
def image_file_diffs_path(self, namespace, repository, image_id):
return '{0}/{1}/{2}/{3}/diffs.json'.format(self.images, namespace,
repository, image_id)
def get_content(self, path):
raise NotImplementedError
def put_content(self, path, content):
raise NotImplementedError
def stream_read(self, path):
raise NotImplementedError
def stream_read_file(self, path):
raise NotImplementedError
def stream_write(self, path, fp):
raise NotImplementedError
def list_directory(self, path=None):
raise NotImplementedError
def exists(self, path):
raise NotImplementedError
def remove(self, path):
raise NotImplementedError
def get_size(self, path):
raise NotImplementedError

View file

@ -2,7 +2,7 @@
import os
import shutil
from . import Storage
from basestorage import Storage
class LocalStorage(Storage):

View file

@ -5,7 +5,7 @@ import logging
import boto.s3.connection
import boto.s3.key
from . import Storage
from basestorage import Storage
logger = logging.getLogger(__name__)

View file

@ -14,7 +14,6 @@
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.0/css/font-awesome.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-theme.min.css">
<link href='//fonts.googleapis.com/css?family=Droid+Sans:400,700' rel='stylesheet' type='text/css'>
<link rel="stylesheet" href="/static/css/quay.css">
@ -48,6 +47,7 @@
<script src="static/lib/angulartics-mixpanel.js"></script>
<script src="static/lib/angular-moment.min.js"></script>
<script src="static/lib/angular-cookies.min.js"></script>
<script src="static/lib/typeahead.min.js"></script>

0
test/__init__.py Normal file
View file

6
test/analytics.py Normal file
View file

@ -0,0 +1,6 @@
class FakeMixpanel(object):
def track(*args, **kwargs):
pass
def init_app(app):
return FakeMixpanel()

View file

@ -0,0 +1,45 @@
{
"removed": [],
"added": [
"/opt/elasticsearch-0.90.5/LICENSE.txt",
"/opt/elasticsearch-0.90.5/NOTICE.txt",
"/opt/elasticsearch-0.90.5/README.textile",
"/opt/elasticsearch-0.90.5/bin/elasticsearch",
"/opt/elasticsearch-0.90.5/bin/elasticsearch.in.sh",
"/opt/elasticsearch-0.90.5/bin/plugin",
"/opt/elasticsearch-0.90.5/config/elasticsearch.yml",
"/opt/elasticsearch-0.90.5/config/logging.yml",
"/opt/elasticsearch-0.90.5/lib/elasticsearch-0.90.5.jar",
"/opt/elasticsearch-0.90.5/lib/jna-3.3.0.jar",
"/opt/elasticsearch-0.90.5/lib/jts-1.12.jar",
"/opt/elasticsearch-0.90.5/lib/log4j-1.2.17.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-analyzers-common-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-codecs-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-core-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-grouping-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-highlighter-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-join-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-memory-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-misc-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-queries-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-queryparser-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-sandbox-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-spatial-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/lucene-suggest-4.4.0.jar",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-freebsd-6.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-linux.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-amd64-solaris.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-ia64-linux.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-sparc-solaris.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-sparc64-solaris.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-universal-macosx.dylib",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-universal64-macosx.dylib",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-freebsd-5.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-freebsd-6.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-linux.so",
"/opt/elasticsearch-0.90.5/lib/sigar/libsigar-x86-solaris.so",
"/opt/elasticsearch-0.90.5/lib/sigar/sigar-1.6.4.jar",
"/opt/elasticsearch-0.90.5/lib/spatial4j-0.3.jar"
],
"changed": []
}

View file

@ -0,0 +1,5 @@
{
"removed": [],
"added": [],
"changed": []
}

Binary file not shown.

544
test/specs.py Normal file
View file

@ -0,0 +1,544 @@
import json
from flask import url_for
from collections import OrderedDict
from uuid import uuid4
from base64 import b64encode
NO_REPO = None
PUBLIC_REPO = 'public/publicrepo'
PRIVATE_REPO = 'devtable/shared'
ORG = 'buynlarge'
ORG_REPO = ORG + '/orgrepo'
ORG_READERS = 'readers'
ORG_OWNER = 'devtable'
ORG_OWNERS = 'owners'
ORG_READERS = 'readers'
FAKE_IMAGE_ID = str(uuid4())
FAKE_TAG_NAME = str(uuid4())
FAKE_USERNAME = str(uuid4())
FAKE_TOKEN = str(uuid4())
NEW_ORG_REPO_DETAILS = {
'repository': str(uuid4()),
'visibility': 'private',
'description': '',
'namespace': ORG,
}
NEW_USER_DETAILS = {
'username': 'bob',
'password': 'password',
'email': 'jake@devtable.com',
}
SEND_RECOVERY_DETAILS = {
'email': 'jacob.moshenko@gmail.com',
}
SIGNIN_DETAILS = {
'username': 'devtable',
'password': 'password',
}
FILE_DROP_DETAILS = {
'mimeType': 'application/zip',
}
CHANGE_PERMISSION_DETAILS = {
'role': 'admin',
}
CREATE_BUILD_DETAILS = {
'file_id': str(uuid4()),
}
CHANGE_VISIBILITY_DETAILS = {
'visibility': 'public',
}
CREATE_TOKEN_DETAILS = {
'friendlyName': 'A new token',
}
UPDATE_REPO_DETAILS = {
'description': 'A new description',
}
class TestSpec(object):
def __init__(self, url, anon_code=401, no_access_code=403, read_code=403,
admin_code=200):
self._url = url
self._data = None
self._method = 'GET'
self.anon_code = anon_code
self.no_access_code = no_access_code
self.read_code = read_code
self.admin_code = admin_code
def set_data_from_obj(self, json_serializable):
self._data = json.dumps(json_serializable)
return self
def set_method(self, method):
self._method = method
return self
def get_client_args(self):
kwargs = {
'method': self._method
}
if self._data or self._method == 'POST' or self._method == 'PUT':
kwargs['data'] = self._data if self._data else '{}'
kwargs['content_type'] = 'application/json'
return self._url, kwargs
def build_specs():
return [
TestSpec(url_for('welcome'), 200, 200, 200, 200),
TestSpec(url_for('plans_list'), 200, 200, 200, 200),
TestSpec(url_for('get_logged_in_user'), 200, 200, 200, 200),
TestSpec(url_for('change_user_details'),
401, 200, 200, 200).set_method('PUT'),
TestSpec(url_for('create_user_api'), 201, 201, 201,
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
TestSpec(url_for('signin_api'), 200, 200, 200,
200).set_method('POST').set_data_from_obj(SIGNIN_DETAILS),
TestSpec(url_for('send_recovery'), 201, 201, 201,
201).set_method('POST').set_data_from_obj(SEND_RECOVERY_DETAILS),
TestSpec(url_for('get_matching_users', prefix='dev'), 401, 200, 200, 200),
TestSpec(url_for('get_matching_entities', prefix='dev'), 401, 200, 200,
200),
TestSpec(url_for('get_organization', orgname=ORG), 401, 403, 200, 200),
TestSpec(url_for('get_organization_private_allowed', orgname=ORG)),
TestSpec(url_for('update_organization_team', orgname=ORG,
teamname=ORG_OWNERS)).set_method('PUT'),
TestSpec(url_for('update_organization_team', orgname=ORG,
teamname=ORG_READERS)).set_method('PUT'),
TestSpec(url_for('delete_organization_team', orgname=ORG,
teamname=ORG_OWNERS),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_organization_team', orgname=ORG,
teamname=ORG_READERS),
admin_code=204).set_method('DELETE'),
TestSpec(url_for('get_organization_team_members', orgname=ORG,
teamname=ORG_OWNERS)),
TestSpec(url_for('get_organization_team_members', orgname=ORG,
teamname=ORG_READERS), read_code=200),
TestSpec(url_for('update_organization_team_member', orgname=ORG,
teamname=ORG_OWNERS, membername=ORG_OWNER),
admin_code=400).set_method('PUT'),
TestSpec(url_for('update_organization_team_member', orgname=ORG,
teamname=ORG_READERS,
membername=ORG_OWNER)).set_method('PUT'),
TestSpec(url_for('delete_organization_team_member', orgname=ORG,
teamname=ORG_OWNERS, membername=ORG_OWNER),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_organization_team_member', orgname=ORG,
teamname=ORG_READERS, membername=ORG_OWNER),
admin_code=400).set_method('DELETE'),
(TestSpec(url_for('create_repo_api'))
.set_method('POST')
.set_data_from_obj(NEW_ORG_REPO_DETAILS)),
TestSpec(url_for('match_repos_api'), 200, 200, 200, 200),
TestSpec(url_for('list_repos_api'), 200, 200, 200, 200),
TestSpec(url_for('update_repo_api', repository=PUBLIC_REPO),
admin_code=403).set_method('PUT'),
(TestSpec(url_for('update_repo_api', repository=ORG_REPO))
.set_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)),
(TestSpec(url_for('update_repo_api', repository=PRIVATE_REPO))
.set_method('PUT')
.set_data_from_obj(UPDATE_REPO_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=PUBLIC_REPO),
admin_code=403).set_method('POST')
.set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=ORG_REPO))
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
(TestSpec(url_for('change_repo_visibility_api', repository=PRIVATE_REPO))
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
TestSpec(url_for('delete_repository', repository=PUBLIC_REPO),
admin_code=403).set_method('DELETE'),
TestSpec(url_for('delete_repository', repository=ORG_REPO),
admin_code=204).set_method('DELETE'),
TestSpec(url_for('delete_repository', repository=PRIVATE_REPO),
admin_code=204).set_method('DELETE'),
TestSpec(url_for('get_repo_api', repository=PUBLIC_REPO),
200, 200, 200,200),
TestSpec(url_for('get_repo_api', repository=ORG_REPO),
403, 403, 200, 200),
TestSpec(url_for('get_repo_api', repository=PRIVATE_REPO),
403, 403, 200, 200),
TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO),
admin_code=403),
TestSpec(url_for('get_repo_builds', repository=ORG_REPO)),
TestSpec(url_for('get_repo_builds', repository=PRIVATE_REPO)),
TestSpec(url_for('get_filedrop_url'), 401, 200, 200,
200).set_method('POST').set_data_from_obj(FILE_DROP_DETAILS),
(TestSpec(url_for('request_repo_build', repository=PUBLIC_REPO),
admin_code=403).set_method('POST')
.set_data_from_obj(CREATE_BUILD_DETAILS)),
(TestSpec(url_for('request_repo_build', repository=ORG_REPO),
admin_code=201).set_method('POST')
.set_data_from_obj(CREATE_BUILD_DETAILS)),
(TestSpec(url_for('request_repo_build', repository=PRIVATE_REPO),
admin_code=201).set_method('POST')
.set_data_from_obj(CREATE_BUILD_DETAILS)),
TestSpec(url_for('list_repository_images', repository=PUBLIC_REPO),
200, 200, 200, 200),
TestSpec(url_for('list_repository_images', repository=ORG_REPO),
403, 403, 200, 200),
TestSpec(url_for('list_repository_images', repository=PRIVATE_REPO),
403, 403, 200, 200),
TestSpec(url_for('get_image', repository=PUBLIC_REPO,
image_id=FAKE_IMAGE_ID), 404, 404, 404, 404),
TestSpec(url_for('get_image', repository=ORG_REPO,
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
TestSpec(url_for('get_image', repository=PRIVATE_REPO,
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
TestSpec(url_for('get_image_changes', repository=PUBLIC_REPO,
image_id=FAKE_IMAGE_ID), 404, 404, 404, 404),
TestSpec(url_for('get_image_changes', repository=ORG_REPO,
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
TestSpec(url_for('get_image_changes', repository=PRIVATE_REPO,
image_id=FAKE_IMAGE_ID), 403, 403, 404, 404),
TestSpec(url_for('list_tag_images', repository=PUBLIC_REPO,
tag=FAKE_TAG_NAME), 404, 404, 404, 404),
TestSpec(url_for('list_tag_images', repository=ORG_REPO,
tag=FAKE_TAG_NAME), 403, 403, 404, 404),
TestSpec(url_for('list_tag_images', repository=PRIVATE_REPO,
tag=FAKE_TAG_NAME), 403, 403, 404, 404),
TestSpec(url_for('list_repo_team_permissions', repository=PUBLIC_REPO),
admin_code=403),
TestSpec(url_for('list_repo_team_permissions', repository=ORG_REPO)),
TestSpec(url_for('list_repo_team_permissions', repository=PRIVATE_REPO)),
TestSpec(url_for('list_repo_user_permissions', repository=PUBLIC_REPO),
admin_code=403),
TestSpec(url_for('list_repo_user_permissions', repository=ORG_REPO)),
TestSpec(url_for('list_repo_user_permissions', repository=PRIVATE_REPO)),
TestSpec(url_for('get_user_permissions', repository=PUBLIC_REPO,
username=FAKE_USERNAME), admin_code=403),
TestSpec(url_for('get_user_permissions', repository=ORG_REPO,
username=FAKE_USERNAME), admin_code=400),
TestSpec(url_for('get_user_permissions', repository=PRIVATE_REPO,
username=FAKE_USERNAME), admin_code=400),
TestSpec(url_for('get_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_OWNERS), admin_code=403),
TestSpec(url_for('get_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_READERS), admin_code=403),
TestSpec(url_for('get_team_permissions', repository=ORG_REPO,
teamname=ORG_OWNERS), admin_code=400),
TestSpec(url_for('get_team_permissions', repository=ORG_REPO,
teamname=ORG_READERS)),
TestSpec(url_for('get_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_OWNERS), admin_code=400),
TestSpec(url_for('get_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_READERS), admin_code=400),
TestSpec(url_for('change_user_permissions', repository=PUBLIC_REPO,
username=FAKE_USERNAME),
admin_code=403).set_method('PUT'),
TestSpec(url_for('change_user_permissions', repository=ORG_REPO,
username=FAKE_USERNAME),
admin_code=400).set_method('PUT'),
TestSpec(url_for('change_user_permissions', repository=PRIVATE_REPO,
username=FAKE_USERNAME),
admin_code=400).set_method('PUT'),
(TestSpec(url_for('change_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_OWNERS), admin_code=403)
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
(TestSpec(url_for('change_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_READERS), admin_code=403)
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
(TestSpec(url_for('change_team_permissions', repository=ORG_REPO,
teamname=ORG_OWNERS))
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
(TestSpec(url_for('change_team_permissions', repository=ORG_REPO,
teamname=ORG_READERS))
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
(TestSpec(url_for('change_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_OWNERS), admin_code=400)
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
(TestSpec(url_for('change_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_READERS), admin_code=400)
.set_method('PUT')
.set_data_from_obj(CHANGE_PERMISSION_DETAILS)),
TestSpec(url_for('delete_user_permissions', repository=PUBLIC_REPO,
username=FAKE_USERNAME),
admin_code=403).set_method('DELETE'),
TestSpec(url_for('delete_user_permissions', repository=ORG_REPO,
username=FAKE_USERNAME),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_user_permissions', repository=PRIVATE_REPO,
username=FAKE_USERNAME),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_OWNERS),
admin_code=403).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=PUBLIC_REPO,
teamname=ORG_READERS),
admin_code=403).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=ORG_REPO,
teamname=ORG_OWNERS),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=ORG_REPO,
teamname=ORG_READERS),
admin_code=204).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_OWNERS),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_team_permissions', repository=PRIVATE_REPO,
teamname=ORG_READERS),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('list_repo_tokens', repository=PUBLIC_REPO),
admin_code=403),
TestSpec(url_for('list_repo_tokens', repository=ORG_REPO)),
TestSpec(url_for('list_repo_tokens', repository=PRIVATE_REPO)),
TestSpec(url_for('get_tokens', repository=PUBLIC_REPO, code=FAKE_TOKEN),
admin_code=403),
TestSpec(url_for('get_tokens', repository=ORG_REPO, code=FAKE_TOKEN),
admin_code=400),
TestSpec(url_for('get_tokens', repository=PRIVATE_REPO, code=FAKE_TOKEN),
admin_code=400),
TestSpec(url_for('create_token', repository=PUBLIC_REPO),
admin_code=403).set_method('POST'),
(TestSpec(url_for('create_token', repository=ORG_REPO),
admin_code=201).set_method('POST')
.set_data_from_obj(CREATE_TOKEN_DETAILS)),
(TestSpec(url_for('create_token', repository=PRIVATE_REPO),
admin_code=201).set_method('POST')
.set_data_from_obj(CREATE_TOKEN_DETAILS)),
TestSpec(url_for('change_token', repository=PUBLIC_REPO, code=FAKE_TOKEN),
admin_code=403).set_method('PUT'),
TestSpec(url_for('change_token', repository=ORG_REPO, code=FAKE_TOKEN),
admin_code=400).set_method('PUT'),
TestSpec(url_for('change_token', repository=PRIVATE_REPO,
code=FAKE_TOKEN), admin_code=400).set_method('PUT'),
TestSpec(url_for('delete_token', repository=PUBLIC_REPO, code=FAKE_TOKEN),
admin_code=403).set_method('DELETE'),
TestSpec(url_for('delete_token', repository=ORG_REPO, code=FAKE_TOKEN),
admin_code=400).set_method('DELETE'),
TestSpec(url_for('delete_token', repository=PRIVATE_REPO,
code=FAKE_TOKEN), admin_code=400).set_method('DELETE'),
TestSpec(url_for('subscribe_api'), 401, 400, 400, 400).set_method('PUT'),
TestSpec(url_for('subscribe_org_api', orgname=ORG),
401, 403, 403, 400).set_method('PUT'),
TestSpec(url_for('get_subscription'), 401, 200, 200, 200),
TestSpec(url_for('get_org_subscription', orgname=ORG)),
]
class IndexTestSpec(object):
def __init__(self, url, sess_repo=None, anon_code=403, no_access_code=403,
read_code=200, admin_code=200):
self._url = url
self._method = 'GET'
self._data = None
self.sess_repo = sess_repo
self.anon_code = anon_code
self.no_access_code = no_access_code
self.read_code = read_code
self.admin_code = admin_code
def gen_basic_auth(self, username, password):
encoded = b64encode('%s:%s' % (username, password))
return 'basic %s' % encoded
def set_data_from_obj(self, json_serializable):
self._data = json.dumps(json_serializable)
return self
def set_method(self, method):
self._method = method
return self
def get_client_args(self):
kwargs = {
'method': self._method
}
if self._data or self._method == 'POST' or self._method == 'PUT':
kwargs['data'] = self._data if self._data else '{}'
kwargs['content_type'] = 'application/json'
return self._url, kwargs
def build_index_specs():
return [
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 200, 200, 200, 200),
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO),
IndexTestSpec(url_for('get_image_layer', image_id=FAKE_IMAGE_ID),
ORG_REPO),
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO, 403, 403, 403, 404).set_method('PUT'),
IndexTestSpec(url_for('put_image_layer', image_id=FAKE_IMAGE_ID),
ORG_REPO, 403, 403, 403, 404).set_method('PUT'),
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('put_image_checksum', image_id=FAKE_IMAGE_ID),
ORG_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 404, 404, 404, 404),
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO, 403, 403, 404, 404),
IndexTestSpec(url_for('get_image_json', image_id=FAKE_IMAGE_ID),
ORG_REPO, 403, 403, 404, 404),
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 404, 404, 404, 404),
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO, 403, 403, 404, 404),
IndexTestSpec(url_for('get_image_ancestry', image_id=FAKE_IMAGE_ID),
ORG_REPO, 403, 403, 404, 404),
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
PUBLIC_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
PRIVATE_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('put_image_json', image_id=FAKE_IMAGE_ID),
ORG_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('create_user'), NO_REPO, 201, 201, 201,
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
IndexTestSpec(url_for('get_user'), NO_REPO, 404, 200, 200, 200),
IndexTestSpec(url_for('update_user', username=FAKE_USERNAME),
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('create_repository', repository=PUBLIC_REPO),
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('create_repository', repository=PRIVATE_REPO),
NO_REPO, 403, 403, 403, 201).set_method('PUT'),
IndexTestSpec(url_for('create_repository', repository=ORG_REPO),
NO_REPO, 403, 403, 403, 201).set_method('PUT'),
IndexTestSpec(url_for('update_images', repository=PUBLIC_REPO), NO_REPO,
403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('update_images', repository=PRIVATE_REPO), NO_REPO,
403, 403, 403, 204).set_method('PUT'),
IndexTestSpec(url_for('update_images', repository=ORG_REPO), NO_REPO,
403, 403, 403, 204).set_method('PUT'),
IndexTestSpec(url_for('get_repository_images', repository=PUBLIC_REPO),
NO_REPO, 200, 200, 200, 200),
IndexTestSpec(url_for('get_repository_images', repository=PRIVATE_REPO)),
IndexTestSpec(url_for('get_repository_images', repository=ORG_REPO)),
IndexTestSpec(url_for('delete_repository_images', repository=PUBLIC_REPO),
NO_REPO, 501, 501, 501, 501).set_method('DELETE'),
IndexTestSpec(url_for('put_repository_auth', repository=PUBLIC_REPO),
NO_REPO, 501, 501, 501, 501).set_method('PUT'),
IndexTestSpec(url_for('get_search'), NO_REPO, 501, 501, 501, 501),
IndexTestSpec(url_for('ping'), NO_REPO, 200, 200, 200, 200),
IndexTestSpec(url_for('get_tags', repository=PUBLIC_REPO), NO_REPO,
200, 200, 200, 200),
IndexTestSpec(url_for('get_tags', repository=PRIVATE_REPO)),
IndexTestSpec(url_for('get_tags', repository=ORG_REPO)),
IndexTestSpec(url_for('get_tag', repository=PUBLIC_REPO,
tag=FAKE_TAG_NAME), NO_REPO, 400, 400, 400, 400),
IndexTestSpec(url_for('get_tag', repository=PRIVATE_REPO,
tag=FAKE_TAG_NAME), NO_REPO, 403, 403, 400, 400),
IndexTestSpec(url_for('get_tag', repository=ORG_REPO,
tag=FAKE_TAG_NAME), NO_REPO, 403, 403, 400, 400),
IndexTestSpec(url_for('put_tag', repository=PUBLIC_REPO,
tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 403).set_method('PUT'),
IndexTestSpec(url_for('put_tag', repository=PRIVATE_REPO,
tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('put_tag', repository=ORG_REPO, tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 400).set_method('PUT'),
IndexTestSpec(url_for('delete_tag', repository=PUBLIC_REPO,
tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 403).set_method('DELETE'),
IndexTestSpec(url_for('delete_tag', repository=PRIVATE_REPO,
tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 400).set_method('DELETE'),
IndexTestSpec(url_for('delete_tag', repository=ORG_REPO,
tag=FAKE_TAG_NAME),
NO_REPO, 403, 403, 403, 400).set_method('DELETE'),
IndexTestSpec(url_for('delete_repository_tags', repository=PUBLIC_REPO),
NO_REPO, 403, 403, 403, 403).set_method('DELETE'),
IndexTestSpec(url_for('delete_repository_tags', repository=PRIVATE_REPO),
NO_REPO, 403, 403, 403, 204).set_method('DELETE'),
IndexTestSpec(url_for('delete_repository_tags', repository=ORG_REPO),
NO_REPO, 403, 403, 403, 204).set_method('DELETE'),
]

95
test/test_api_security.py Normal file
View file

@ -0,0 +1,95 @@
import unittest
import json
import endpoints.api
from app import app
from initdb import wipe_database, initialize_database, populate_database
from specs import build_specs
NO_ACCESS_USER = 'freshuser'
READ_ACCESS_USER = 'reader'
ADMIN_ACCESS_USER = 'devtable'
class ApiTestCase(unittest.TestCase):
def setUp(self):
wipe_database()
initialize_database()
populate_database()
class _SpecTestBuilder(type):
@staticmethod
def _test_generator(url, expected_status, open_kwargs, auth_username=None):
def test(self):
with app.test_client() as c:
if auth_username:
# Temporarily remove the teardown functions
teardown_funcs = app.teardown_request_funcs[None]
app.teardown_request_funcs[None] = []
with c.session_transaction() as sess:
sess['user_id'] = auth_username
sess['identity.id'] = auth_username
sess['identity.auth_type'] = 'username'
# Restore the teardown functions
app.teardown_request_funcs[None] = teardown_funcs
rv = c.open(url, **open_kwargs)
msg = '%s %s: %s expected: %s' % (open_kwargs['method'], url,
rv.status_code, expected_status)
self.assertEqual(rv.status_code, expected_status, msg)
return test
def __new__(cls, name, bases, attrs):
with app.test_request_context() as ctx:
specs = attrs['spec_func']()
for test_spec in specs:
url, open_kwargs = test_spec.get_client_args()
expected_status = getattr(test_spec, attrs['result_attr'])
test = _SpecTestBuilder._test_generator(url, expected_status,
open_kwargs,
attrs['auth_username'])
test_name_url = url.replace('/', '_').replace('-', '_')
test_name = 'test_%s_%s' % (open_kwargs['method'].lower(),
test_name_url)
attrs[test_name] = test
return type(name, bases, attrs)
class TestAnonymousAccess(ApiTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_specs
result_attr = 'anon_code'
auth_username = None
class TestNoAccess(ApiTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_specs
result_attr = 'no_access_code'
auth_username = NO_ACCESS_USER
class TestReadAccess(ApiTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_specs
result_attr = 'read_code'
auth_username = READ_ACCESS_USER
class TestAdminAccess(ApiTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_specs
result_attr = 'admin_code'
auth_username = ADMIN_ACCESS_USER
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,107 @@
import unittest
import endpoints.registry
import endpoints.index
import endpoints.tags
from app import app
from util.names import parse_namespace_repository
from initdb import wipe_database, initialize_database, populate_database
from specs import build_index_specs
NO_ACCESS_USER = 'freshuser'
READ_ACCESS_USER = 'reader'
ADMIN_ACCESS_USER = 'devtable'
class EndpointTestCase(unittest.TestCase):
def setUp(self):
wipe_database()
initialize_database()
populate_database()
class _SpecTestBuilder(type):
@staticmethod
def _test_generator(url, expected_status, open_kwargs, session_var_list):
def test(self):
with app.test_client() as c:
if session_var_list:
# Temporarily remove the teardown functions
teardown_funcs = app.teardown_request_funcs[None]
app.teardown_request_funcs[None] = []
with c.session_transaction() as sess:
for sess_key, sess_val in session_var_list:
sess[sess_key] = sess_val
# Restore the teardown functions
app.teardown_request_funcs[None] = teardown_funcs
rv = c.open(url, **open_kwargs)
msg = '%s %s: %s expected: %s' % (open_kwargs['method'], url,
rv.status_code, expected_status)
if rv.status_code != expected_status:
print msg
self.assertEqual(rv.status_code, expected_status, msg)
return test
def __new__(cls, name, bases, attrs):
with app.test_request_context() as ctx:
specs = attrs['spec_func']()
for test_spec in specs:
url, open_kwargs = test_spec.get_client_args()
if attrs['auth_username']:
basic_auth = test_spec.gen_basic_auth(attrs['auth_username'],
'password')
open_kwargs['headers'] = [('authorization', '%s' % basic_auth)]
session_vars = []
if test_spec.sess_repo:
ns, repo = parse_namespace_repository(test_spec.sess_repo)
session_vars.append(('namespace', ns))
session_vars.append(('repository', repo))
expected_status = getattr(test_spec, attrs['result_attr'])
test = _SpecTestBuilder._test_generator(url, expected_status,
open_kwargs,
session_vars)
test_name_url = url.replace('/', '_').replace('-', '_')
sess_repo = str(test_spec.sess_repo).replace('/', '_')
test_name = 'test_%s%s_%s' % (open_kwargs['method'].lower(),
test_name_url, sess_repo)
attrs[test_name] = test
return type(name, bases, attrs)
class TestAnonymousAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_index_specs
result_attr = 'anon_code'
auth_username = None
class TestNoAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_index_specs
result_attr = 'no_access_code'
auth_username = NO_ACCESS_USER
class TestReadAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_index_specs
result_attr = 'read_code'
auth_username = READ_ACCESS_USER
class TestAdminAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_index_specs
result_attr = 'admin_code'
auth_username = ADMIN_ACCESS_USER

37
test/teststorage.py Normal file
View file

@ -0,0 +1,37 @@
from uuid import uuid4
from storage.basestorage import Storage
class FakeStorage(Storage):
def _init_path(self, path=None, create=False):
return path
def get_content(self, path):
raise IOError('Fake files are fake!')
def put_content(self, path, content):
return path
def stream_read(self, path):
yield ''
def stream_write(self, path, fp):
pass
def remove(self, path):
pass
def exists(self, path):
return False
class FakeUserfiles(object):
def prepare_for_drop(self, mime_type):
return ('http://fake/url', uuid4())
def store_file(self, flask_file):
raise NotImplementedError()
def get_file_url(self, file_id, expires_in=300):
return ('http://fake/url')

View file

@ -13,7 +13,6 @@ from base64 import b64encode
from requests.exceptions import ConnectionError
from data.queue import dockerfile_build_queue
from data.userfiles import UserRequestFiles
from data import model
from data.database import db as db_connection
from app import app
@ -151,9 +150,7 @@ def babysit_builder(request):
ssh_client.exec_command(remove_auth_cmd)
# Prepare the signed resource url the build node can fetch the job from
user_files = UserRequestFiles(app.config['AWS_ACCESS_KEY'],
app.config['AWS_SECRET_KEY'],
app.config['REGISTRY_S3_BUCKET'])
user_files = app.config['USERFILES']
resource_url = user_files.get_file_url(repository_build.resource_key)
# Start the build server