Merge master into branch

This commit is contained in:
Joseph Schorr 2014-09-04 18:08:18 -04:00
commit e028d4ae0a
103 changed files with 2319 additions and 1187 deletions

View file

@ -1,11 +1,11 @@
conf/stack conf/stack
screenshots screenshots
tools
test/data/registry test/data/registry
venv venv
.git .git
.gitignore .gitignore
Bobfile Bobfile
README.md README.md
license.py
requirements-nover.txt requirements-nover.txt
run-local.sh run-local.sh

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root ENV HOME /root
# Install the dependencies. # Install the dependencies.
RUN apt-get update # 06AUG2014 RUN apt-get update # 21AUG2014
# New ubuntu packages should be added as their own apt-get install lines below the existing install commands # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
# Build the python dependencies # Build the python dependencies
ADD requirements.txt requirements.txt ADD requirements.txt requirements.txt

View file

@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root ENV HOME /root
# Install the dependencies. # Install the dependencies.
RUN apt-get update # 06AUG2014 RUN apt-get update # 21AUG2014
# New ubuntu packages should be added as their own apt-get install lines below the existing install commands # New ubuntu packages should be added as their own apt-get install lines below the existing install commands
RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev RUN apt-get install -y git python-virtualenv python-dev libjpeg8 libjpeg62-dev libevent-dev gdebi-core g++ libmagic1 phantomjs nodejs npm libldap2-dev libsasl2-dev libpq-dev
# Build the python dependencies # Build the python dependencies
ADD requirements.txt requirements.txt ADD requirements.txt requirements.txt
@ -30,6 +30,7 @@ RUN cd grunt && npm install
RUN cd grunt && grunt RUN cd grunt && grunt
ADD conf/init/svlogd_config /svlogd_config ADD conf/init/svlogd_config /svlogd_config
ADD conf/init/doupdatelimits.sh /etc/my_init.d/
ADD conf/init/preplogsdir.sh /etc/my_init.d/ ADD conf/init/preplogsdir.sh /etc/my_init.d/
ADD conf/init/runmigration.sh /etc/my_init.d/ ADD conf/init/runmigration.sh /etc/my_init.d/
@ -38,9 +39,6 @@ ADD conf/init/nginx /etc/service/nginx
ADD conf/init/diffsworker /etc/service/diffsworker ADD conf/init/diffsworker /etc/service/diffsworker
ADD conf/init/notificationworker /etc/service/notificationworker ADD conf/init/notificationworker /etc/service/notificationworker
# TODO: Remove this after the prod CL push
ADD conf/init/webhookworker /etc/service/webhookworker
# Download any external libs. # Download any external libs.
RUN mkdir static/fonts static/ldn RUN mkdir static/fonts static/ldn
RUN venv/bin/python -m external_libraries RUN venv/bin/python -m external_libraries

52
app.py
View file

@ -1,8 +1,9 @@
import logging import logging
import os import os
import json import json
import yaml
from flask import Flask from flask import Flask as BaseFlask, Config as BaseConfig
from flask.ext.principal import Principal from flask.ext.principal import Principal
from flask.ext.login import LoginManager from flask.ext.login import LoginManager
from flask.ext.mail import Mail from flask.ext.mail import Mail
@ -21,11 +22,37 @@ from data.billing import Billing
from data.buildlogs import BuildLogs from data.buildlogs import BuildLogs
from data.queue import WorkQueue from data.queue import WorkQueue
from data.userevent import UserEventsBuilderModule from data.userevent import UserEventsBuilderModule
from license import load_license
from datetime import datetime from datetime import datetime
OVERRIDE_CONFIG_FILENAME = 'conf/stack/config.py' class Config(BaseConfig):
""" Flask config enhanced with a `from_yamlfile` method """
def from_yamlfile(self, config_file):
with open(config_file) as f:
c = yaml.load(f)
if not c:
logger.debug('Empty YAML config file')
return
if isinstance(c, str):
raise Exception('Invalid YAML config file: ' + str(c))
for key in c.iterkeys():
if key.isupper():
self[key] = c[key]
class Flask(BaseFlask):
""" Extends the Flask class to implement our custom Config class. """
def make_config(self, instance_relative=False):
root_path = self.instance_path if instance_relative else self.root_path
return Config(root_path, self.default_config)
OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml'
OVERRIDE_CONFIG_PY_FILENAME = 'conf/stack/config.py'
OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG' OVERRIDE_CONFIG_KEY = 'QUAY_OVERRIDE_CONFIG'
LICENSE_FILENAME = 'conf/stack/license.enc' LICENSE_FILENAME = 'conf/stack/license.enc'
@ -43,22 +70,17 @@ else:
logger.debug('Loading default config.') logger.debug('Loading default config.')
app.config.from_object(DefaultConfig()) app.config.from_object(DefaultConfig())
if os.path.exists(OVERRIDE_CONFIG_FILENAME): if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_FILENAME) logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_FILENAME)
app.config.from_pyfile(OVERRIDE_CONFIG_FILENAME) app.config.from_pyfile(OVERRIDE_CONFIG_PY_FILENAME)
if os.path.exists(OVERRIDE_CONFIG_YAML_FILENAME):
logger.debug('Applying config file: %s', OVERRIDE_CONFIG_YAML_FILENAME)
app.config.from_yamlfile(OVERRIDE_CONFIG_YAML_FILENAME)
environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}')) environ_config = json.loads(os.environ.get(OVERRIDE_CONFIG_KEY, '{}'))
app.config.update(environ_config) app.config.update(environ_config)
logger.debug('Applying license config from: %s', LICENSE_FILENAME)
try:
app.config.update(load_license(LICENSE_FILENAME))
except IOError:
raise RuntimeError('No license file found, please check your configuration')
if app.config.get('LICENSE_EXPIRATION', datetime.min) < datetime.utcnow():
raise RuntimeError('License has expired, please contact support@quay.io')
features.import_features(app.config) features.import_features(app.config)
Principal(app, use_sessions=False) Principal(app, use_sessions=False)

View file

@ -1,5 +1,5 @@
bind = 'unix:/tmp/gunicorn.sock' bind = 'unix:/tmp/gunicorn.sock'
workers = 8 workers = 16
worker_class = 'gevent' worker_class = 'gevent'
timeout = 2000 timeout = 2000
logconfig = 'conf/logging.conf' logconfig = 'conf/logging.conf'

5
conf/init/doupdatelimits.sh Executable file
View file

@ -0,0 +1,5 @@
#! /bin/bash
set -e
# Update the connection limit
sysctl -w net.core.somaxconn=1024

View file

@ -1,2 +0,0 @@
#!/bin/sh
exec svlogd -t /var/log/webhookworker/

View file

@ -1,8 +0,0 @@
#! /bin/bash
echo 'Starting webhook worker'
cd /
venv/bin/python -m workers.webhookworker
echo 'Webhook worker exited'

View file

@ -1,4 +1,4 @@
client_max_body_size 8G; client_max_body_size 20G;
client_body_temp_path /var/log/nginx/client_body 1 2; client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _; server_name _;

View file

@ -163,6 +163,9 @@ class DefaultConfig(object):
# Feature Flag: Whether to support GitHub build triggers. # Feature Flag: Whether to support GitHub build triggers.
FEATURE_GITHUB_BUILD = False FEATURE_GITHUB_BUILD = False
# Feature Flag: Dockerfile build support.
FEATURE_BUILD_SUPPORT = True
DISTRIBUTED_STORAGE_CONFIG = { DISTRIBUTED_STORAGE_CONFIG = {
'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}],
'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}],

View file

@ -41,6 +41,15 @@ PLANS = [
'bus_features': False, 'bus_features': False,
'deprecated': True, 'deprecated': True,
}, },
{
'title': 'Yacht',
'price': 5000,
'privateRepos': 20,
'stripeId': 'bus-small',
'audience': 'For small businesses',
'bus_features': True,
'deprecated': True,
},
# Active plans # Active plans
{ {
@ -74,7 +83,7 @@ PLANS = [
'title': 'Yacht', 'title': 'Yacht',
'price': 5000, 'price': 5000,
'privateRepos': 20, 'privateRepos': 20,
'stripeId': 'bus-small', 'stripeId': 'bus-coreos-trial',
'audience': 'For small businesses', 'audience': 'For small businesses',
'bus_features': True, 'bus_features': True,
'deprecated': False, 'deprecated': False,

View file

@ -17,6 +17,8 @@ SCHEME_DRIVERS = {
'mysql': MySQLDatabase, 'mysql': MySQLDatabase,
'mysql+pymysql': MySQLDatabase, 'mysql+pymysql': MySQLDatabase,
'sqlite': SqliteDatabase, 'sqlite': SqliteDatabase,
'postgresql': PostgresqlDatabase,
'postgresql+psycopg2': PostgresqlDatabase,
} }
db = Proxy() db = Proxy()
@ -32,7 +34,7 @@ def _db_from_url(url, db_kwargs):
if parsed_url.username: if parsed_url.username:
db_kwargs['user'] = parsed_url.username db_kwargs['user'] = parsed_url.username
if parsed_url.password: if parsed_url.password:
db_kwargs['passwd'] = parsed_url.password db_kwargs['password'] = parsed_url.password
return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs) return SCHEME_DRIVERS[parsed_url.drivername](parsed_url.database, **db_kwargs)
@ -74,6 +76,8 @@ class User(BaseModel):
organization = BooleanField(default=False, index=True) organization = BooleanField(default=False, index=True)
robot = BooleanField(default=False, index=True) robot = BooleanField(default=False, index=True)
invoice_email = BooleanField(default=False) invoice_email = BooleanField(default=False)
invalid_login_attempts = IntegerField(default=0)
last_invalid_login = DateTimeField(default=datetime.utcnow)
class TeamRole(BaseModel): class TeamRole(BaseModel):

View file

@ -0,0 +1,37 @@
"""add log kind for regenerating robot tokens
Revision ID: 43e943c0639f
Revises: 82297d834ad
Create Date: 2014-08-25 17:14:42.784518
"""
# revision identifiers, used by Alembic.
revision = '43e943c0639f'
down_revision = '82297d834ad'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
from data.database import all_models
def upgrade():
schema = gen_sqlalchemy_metadata(all_models)
op.bulk_insert(schema.tables['logentrykind'],
[
{'id': 41, 'name':'regenerate_robot_token'},
])
def downgrade():
schema = gen_sqlalchemy_metadata(all_models)
logentrykind = schema.tables['logentrykind']
op.execute(
(logentrykind.delete()
.where(logentrykind.c.name == op.inline_literal('regenerate_robot_token')))
)

View file

@ -20,12 +20,12 @@ def get_id(query):
def upgrade(): def upgrade():
conn = op.get_bind() conn = op.get_bind()
event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1')
method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1')
conn.execute('Insert Into repositorynotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM webhook' % (event_id, method_id)) conn.execute('Insert Into repositorynotification (uuid, repository_id, event_id, method_id, config_json) Select public_id, repository_id, %s, %s, parameters FROM webhook' % (event_id, method_id))
def downgrade(): def downgrade():
conn = op.get_bind() conn = op.get_bind()
event_id = get_id('Select id From externalnotificationevent Where name="repo_push" Limit 1') event_id = get_id('Select id From externalnotificationevent Where name=\'repo_push\' Limit 1')
method_id = get_id('Select id From externalnotificationmethod Where name="webhook" Limit 1') method_id = get_id('Select id From externalnotificationmethod Where name=\'webhook\' Limit 1')
conn.execute('Insert Into webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM repositorynotification Where event_id=%s And method_id=%s' % (event_id, method_id)) conn.execute('Insert Into webhook (public_id, repository_id, parameters) Select uuid, repository_id, config_json FROM repositorynotification Where event_id=%s And method_id=%s' % (event_id, method_id))

View file

@ -0,0 +1,28 @@
"""Add brute force prevention metadata to the user table.
Revision ID: 4fdb65816b8d
Revises: 43e943c0639f
Create Date: 2014-09-03 12:35:33.722435
"""
# revision identifiers, used by Alembic.
revision = '4fdb65816b8d'
down_revision = '43e943c0639f'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('invalid_login_attempts', sa.Integer(), nullable=False, server_default="0"))
op.add_column('user', sa.Column('last_invalid_login', sa.DateTime(), nullable=False, server_default=sa.func.now()))
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'last_invalid_login')
op.drop_column('user', 'invalid_login_attempts')
### end Alembic commands ###

View file

@ -203,7 +203,7 @@ def upgrade():
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('service_id', sa.Integer(), nullable=False),
sa.Column('service_ident', sa.String(length=255, collation='utf8_general_ci'), nullable=False), sa.Column('service_ident', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], ), sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
@ -375,7 +375,7 @@ def upgrade():
sa.Column('command', sa.Text(), nullable=True), sa.Column('command', sa.Text(), nullable=True),
sa.Column('repository_id', sa.Integer(), nullable=False), sa.Column('repository_id', sa.Integer(), nullable=False),
sa.Column('image_size', sa.BigInteger(), nullable=True), sa.Column('image_size', sa.BigInteger(), nullable=True),
sa.Column('ancestors', sa.String(length=60535, collation='latin1_swedish_ci'), nullable=True), sa.Column('ancestors', sa.String(length=60535), nullable=True),
sa.Column('storage_id', sa.Integer(), nullable=True), sa.Column('storage_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ), sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ), sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ),

View file

@ -0,0 +1,36 @@
"""add US West location
Revision ID: 82297d834ad
Revises: 47670cbeced
Create Date: 2014-08-15 13:35:23.834079
"""
# revision identifiers, used by Alembic.
revision = '82297d834ad'
down_revision = '47670cbeced'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from data.model.sqlalchemybridge import gen_sqlalchemy_metadata
from data.database import all_models
def upgrade():
schema = gen_sqlalchemy_metadata(all_models)
op.bulk_insert(schema.tables['imagestoragelocation'],
[
{'id':8, 'name':'s3_us_west_1'},
])
def downgrade():
schema = gen_sqlalchemy_metadata(all_models)
op.execute(
(imagestoragelocation.delete()
.where(imagestoragelocation.c.name == op.inline_literal('s3_us_west_1')))
)

View file

@ -35,6 +35,7 @@ def upgrade():
{'id':4, 'name':'s3_ap_southeast_2'}, {'id':4, 'name':'s3_ap_southeast_2'},
{'id':5, 'name':'s3_ap_northeast_1'}, {'id':5, 'name':'s3_ap_northeast_1'},
{'id':6, 'name':'s3_sa_east_1'}, {'id':6, 'name':'s3_sa_east_1'},
{'id':7, 'name':'local'},
]) ])
op.create_table('imagestorageplacement', op.create_table('imagestorageplacement',

View file

@ -0,0 +1,35 @@
"""Remove the old webhooks table.
Revision ID: f42b0ea7a4d
Revises: 4fdb65816b8d
Create Date: 2014-09-03 13:43:23.391464
"""
# revision identifiers, used by Alembic.
revision = 'f42b0ea7a4d'
down_revision = '4fdb65816b8d'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('webhook')
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('webhook',
sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
sa.Column('public_id', mysql.VARCHAR(length=255), nullable=False),
sa.Column('repository_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('parameters', mysql.LONGTEXT(), nullable=False),
sa.ForeignKeyConstraint(['repository_id'], [u'repository.id'], name=u'fk_webhook_repository_repository_id'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset=u'latin1',
mysql_engine=u'InnoDB'
)
### end Alembic commands ###

View file

@ -1,12 +1,17 @@
import bcrypt import bcrypt
import logging import logging
import datetime
import dateutil.parser import dateutil.parser
import json import json
from datetime import datetime, timedelta
from data.database import * from data.database import *
from util.validation import * from util.validation import *
from util.names import format_robot_username from util.names import format_robot_username
from util.backoff import exponential_backoff
EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -68,10 +73,15 @@ class TooManyUsersException(DataModelException):
pass pass
def is_create_user_allowed(): class TooManyLoginAttemptsException(Exception):
return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] def __init__(self, message, retry_after):
super(TooManyLoginAttemptsException, self).__init__(message)
self.retry_after = retry_after
def is_create_user_allowed():
return True
def create_user(username, password, email): def create_user(username, password, email):
""" Creates a regular user, if allowed. """ """ Creates a regular user, if allowed. """
if not validate_password(password): if not validate_password(password):
@ -181,6 +191,19 @@ def create_robot(robot_shortname, parent):
except Exception as ex: except Exception as ex:
raise DataModelException(ex.message) raise DataModelException(ex.message)
def get_robot(robot_shortname, parent):
robot_username = format_robot_username(parent.username, robot_shortname)
robot = lookup_robot(robot_username)
if not robot:
msg = ('Could not find robot with username: %s' %
robot_username)
raise InvalidRobotException(msg)
service = LoginService.get(name='quayrobot')
login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
return robot, login.service_ident
def lookup_robot(robot_username): def lookup_robot(robot_username):
joined = User.select().join(FederatedLogin).join(LoginService) joined = User.select().join(FederatedLogin).join(LoginService)
@ -191,7 +214,6 @@ def lookup_robot(robot_username):
return found[0] return found[0]
def verify_robot(robot_username, password): def verify_robot(robot_username, password):
joined = User.select().join(FederatedLogin).join(LoginService) joined = User.select().join(FederatedLogin).join(LoginService)
found = list(joined.where(FederatedLogin.service_ident == password, found = list(joined.where(FederatedLogin.service_ident == password,
@ -204,6 +226,25 @@ def verify_robot(robot_username, password):
return found[0] return found[0]
def regenerate_robot_token(robot_shortname, parent):
robot_username = format_robot_username(parent.username, robot_shortname)
robot = lookup_robot(robot_username)
if not robot:
raise InvalidRobotException('Could not find robot with username: %s' %
robot_username)
password = random_string_generator(length=64)()
robot.email = password
service = LoginService.get(name='quayrobot')
login = FederatedLogin.get(FederatedLogin.user == robot, FederatedLogin.service == service)
login.service_ident = password
login.save()
robot.save()
return robot, password
def delete_robot(robot_username): def delete_robot(robot_username):
try: try:
@ -524,11 +565,30 @@ def verify_user(username_or_email, password):
except User.DoesNotExist: except User.DoesNotExist:
return None return None
now = datetime.utcnow()
if fetched.invalid_login_attempts > 0:
can_retry_at = exponential_backoff(fetched.invalid_login_attempts, EXPONENTIAL_BACKOFF_SCALE,
fetched.last_invalid_login)
if can_retry_at > now:
retry_after = can_retry_at - now
raise TooManyLoginAttemptsException('Too many login attempts.', retry_after.total_seconds())
if (fetched.password_hash and if (fetched.password_hash and
bcrypt.hashpw(password, fetched.password_hash) == bcrypt.hashpw(password, fetched.password_hash) ==
fetched.password_hash): fetched.password_hash):
if fetched.invalid_login_attempts > 0:
fetched.invalid_login_attempts = 0
fetched.save()
return fetched return fetched
fetched.invalid_login_attempts += 1
fetched.last_invalid_login = now
fetched.save()
# We weren't able to authorize the user # We weren't able to authorize the user
return None return None
@ -828,6 +888,34 @@ def get_all_repo_users(namespace_name, repository_name):
Repository.name == repository_name) Repository.name == repository_name)
def get_all_repo_users_transitive_via_teams(namespace_name, repository_name):
select = User.select().distinct()
with_team_member = select.join(TeamMember)
with_team = with_team_member.join(Team)
with_perm = with_team.join(RepositoryPermission)
with_repo = with_perm.join(Repository)
return with_repo.where(Repository.namespace == namespace_name,
Repository.name == repository_name)
def get_all_repo_users_transitive(namespace_name, repository_name):
# Load the users found via teams and directly via permissions.
via_teams = get_all_repo_users_transitive_via_teams(namespace_name, repository_name)
directly = [perm.user for perm in get_all_repo_users(namespace_name, repository_name)]
# Filter duplicates.
user_set = set()
def check_add(u):
if u.username in user_set:
return False
user_set.add(u.username)
return True
return [user for user in list(directly) + list(via_teams) if check_add(user)]
def get_repository_for_resource(resource_key): def get_repository_for_resource(resource_key):
try: try:
return (Repository return (Repository
@ -982,7 +1070,8 @@ def find_create_or_link_image(docker_image_id, repository, username, translation
.join(Repository) .join(Repository)
.join(Visibility) .join(Visibility)
.switch(Repository) .switch(Repository)
.join(RepositoryPermission, JOIN_LEFT_OUTER)) .join(RepositoryPermission, JOIN_LEFT_OUTER)
.where(ImageStorage.uploading == False))
query = (_filter_to_repos_for_user(query, username) query = (_filter_to_repos_for_user(query, username)
.where(Image.docker_image_id == docker_image_id)) .where(Image.docker_image_id == docker_image_id))
@ -1662,19 +1751,20 @@ def create_notification(kind_name, target, metadata={}):
def create_unique_notification(kind_name, target, metadata={}): def create_unique_notification(kind_name, target, metadata={}):
with config.app_config['DB_TRANSACTION_FACTORY'](db): with config.app_config['DB_TRANSACTION_FACTORY'](db):
if list_notifications(target, kind_name).count() == 0: if list_notifications(target, kind_name, limit=1).count() == 0:
create_notification(kind_name, target, metadata) create_notification(kind_name, target, metadata)
def lookup_notification(user, uuid): def lookup_notification(user, uuid):
results = list(list_notifications(user, id_filter=uuid, include_dismissed=True)) results = list(list_notifications(user, id_filter=uuid, include_dismissed=True, limit=1))
if not results: if not results:
return None return None
return results[0] return results[0]
def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False): def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=False,
page=None, limit=None):
Org = User.alias() Org = User.alias()
AdminTeam = Team.alias() AdminTeam = Team.alias()
AdminTeamMember = TeamMember.alias() AdminTeamMember = TeamMember.alias()
@ -1712,6 +1802,11 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F
.switch(Notification) .switch(Notification)
.where(Notification.uuid == id_filter)) .where(Notification.uuid == id_filter))
if page:
query = query.paginate(page, limit)
elif limit:
query = query.limit(limit)
return query return query

View file

@ -1,7 +1,8 @@
import logging import logging
import json import json
import datetime
from flask import Blueprint, request, make_response, jsonify from flask import Blueprint, request, make_response, jsonify, session
from flask.ext.restful import Resource, abort, Api, reqparse from flask.ext.restful import Resource, abort, Api, reqparse
from flask.ext.restful.utils.cors import crossdomain from flask.ext.restful.utils.cors import crossdomain
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
@ -66,6 +67,11 @@ class Unauthorized(ApiException):
ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload) ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload)
class ExceedsLicenseException(ApiException): class ExceedsLicenseException(ApiException):
def __init__(self, payload=None): def __init__(self, payload=None):
ApiException.__init__(self, None, 402, 'Payment Required', payload) ApiException.__init__(self, None, 402, 'Payment Required', payload)
@ -87,6 +93,14 @@ def handle_api_error(error):
return response return response
@api_bp.app_errorhandler(model.TooManyLoginAttemptsException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_too_many_login_attempts(error):
response = make_response('Too many login attempts', 429)
response.headers['Retry-After'] = int(error.retry_after)
return response
def resource(*urls, **kwargs): def resource(*urls, **kwargs):
def wrapper(api_resource): def wrapper(api_resource):
if not api_resource: if not api_resource:
@ -256,6 +270,26 @@ def require_user_permission(permission_class, scope=None):
require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER) require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER)
require_user_admin = require_user_permission(UserAdminPermission, None) require_user_admin = require_user_permission(UserAdminPermission, None)
require_fresh_user_admin = require_user_permission(UserAdminPermission, None)
def require_fresh_login(func):
@add_method_metadata('requires_fresh_login', True)
@wraps(func)
def wrapped(*args, **kwargs):
user = get_authenticated_user()
if not user:
raise Unauthorized()
logger.debug('Checking fresh login for user %s', user.username)
last_login = session.get('login_time', datetime.datetime.now() - datetime.timedelta(minutes=60))
valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10)
if last_login >= valid_span:
return func(*args, **kwargs)
raise FreshLoginRequired()
return wrapped
def require_scope(scope_object): def require_scope(scope_object):

View file

@ -4,7 +4,7 @@ from flask import request
from app import billing from app import billing
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
related_user_resource, internal_only, Unauthorized, NotFound, related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin, show_if, hide_if) require_user_admin, show_if, hide_if, abort)
from endpoints.api.subscribe import subscribe, subscription_view from endpoints.api.subscribe import subscribe, subscription_view
from auth.permissions import AdministerOrganizationPermission from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
@ -23,7 +23,11 @@ def get_card(user):
} }
if user.stripe_id: if user.stripe_id:
cus = billing.Customer.retrieve(user.stripe_id) try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus and cus.default_card: if cus and cus.default_card:
# Find the default card. # Find the default card.
default_card = None default_card = None
@ -46,7 +50,11 @@ def get_card(user):
def set_card(user, token): def set_card(user, token):
if user.stripe_id: if user.stripe_id:
cus = billing.Customer.retrieve(user.stripe_id) try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus: if cus:
try: try:
cus.card = token cus.card = token
@ -55,6 +63,8 @@ def set_card(user, token):
return carderror_response(exc) return carderror_response(exc)
except stripe.InvalidRequestError as exc: except stripe.InvalidRequestError as exc:
return carderror_response(exc) return carderror_response(exc)
except stripe.APIConnectionError as e:
return carderror_response(e)
return get_card(user) return get_card(user)
@ -75,7 +85,11 @@ def get_invoices(customer_id):
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
} }
invoices = billing.Invoice.all(customer=customer_id, count=12) try:
invoices = billing.Invoice.all(customer=customer_id, count=12)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
return { return {
'invoices': [invoice_view(i) for i in invoices.data] 'invoices': [invoice_view(i) for i in invoices.data]
} }
@ -228,7 +242,10 @@ class UserPlan(ApiResource):
private_repos = model.get_private_repo_count(user.username) private_repos = model.get_private_repo_count(user.username)
if user.stripe_id: if user.stripe_id:
cus = billing.Customer.retrieve(user.stripe_id) try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus.subscription: if cus.subscription:
return subscription_view(cus.subscription, private_repos) return subscription_view(cus.subscription, private_repos)
@ -291,7 +308,10 @@ class OrganizationPlan(ApiResource):
private_repos = model.get_private_repo_count(orgname) private_repos = model.get_private_repo_count(orgname)
organization = model.get_organization(orgname) organization = model.get_organization(orgname)
if organization.stripe_id: if organization.stripe_id:
cus = billing.Customer.retrieve(organization.stripe_id) try:
cus = billing.Customer.retrieve(organization.stripe_id)
except stripe.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus.subscription: if cus.subscription:
return subscription_view(cus.subscription, private_repos) return subscription_view(cus.subscription, private_repos)

View file

@ -119,6 +119,11 @@ def swagger_route_data(include_internal=False, compact=False):
if internal is not None: if internal is not None:
new_operation['internal'] = True new_operation['internal'] = True
if include_internal:
requires_fresh_login = method_metadata(method, 'requires_fresh_login')
if requires_fresh_login is not None:
new_operation['requires_fresh_login'] = True
if not internal or (internal and include_internal): if not internal or (internal and include_internal):
operations.append(new_operation) operations.append(new_operation)

View file

@ -24,6 +24,7 @@ def image_view(image):
'dbid': image.id, 'dbid': image.id,
'size': extended_props.image_size, 'size': extended_props.image_size,
'locations': list(image.storage.locations), 'locations': list(image.storage.locations),
'uploading': image.storage.uploading,
} }

View file

@ -35,6 +35,14 @@ class UserRobotList(ApiResource):
@internal_only @internal_only
class UserRobot(ApiResource): class UserRobot(ApiResource):
""" Resource for managing a user's robots. """ """ Resource for managing a user's robots. """
@require_user_admin
@nickname('getUserRobot')
def get(self, robot_shortname):
""" Returns the user's robot with the specified name. """
parent = get_authenticated_user()
robot, password = model.get_robot(robot_shortname, parent)
return robot_view(robot.username, password)
@require_user_admin @require_user_admin
@nickname('createUserRobot') @nickname('createUserRobot')
def put(self, robot_shortname): def put(self, robot_shortname):
@ -79,6 +87,18 @@ class OrgRobotList(ApiResource):
@related_user_resource(UserRobot) @related_user_resource(UserRobot)
class OrgRobot(ApiResource): class OrgRobot(ApiResource):
""" Resource for managing an organization's robots. """ """ Resource for managing an organization's robots. """
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrgRobot')
def get(self, orgname, robot_shortname):
""" Returns the organization's robot with the specified name. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.get_robot(robot_shortname, parent)
return robot_view(robot.username, password)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('createOrgRobot') @nickname('createOrgRobot')
def put(self, orgname, robot_shortname): def put(self, orgname, robot_shortname):
@ -103,3 +123,38 @@ class OrgRobot(ApiResource):
return 'Deleted', 204 return 'Deleted', 204
raise Unauthorized() raise Unauthorized()
@resource('/v1/user/robots/<robot_shortname>/regenerate')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@internal_only
class RegenerateUserRobot(ApiResource):
""" Resource for regenerate an organization's robot's token. """
@require_user_admin
@nickname('regenerateUserRobotToken')
def post(self, robot_shortname):
""" Regenerates the token for a user's robot. """
parent = get_authenticated_user()
robot, password = model.regenerate_robot_token(robot_shortname, parent)
log_action('regenerate_robot_token', parent.username, {'robot': robot_shortname})
return robot_view(robot.username, password)
@resource('/v1/organization/<orgname>/robots/<robot_shortname>/regenerate')
@path_param('orgname', 'The name of the organization')
@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix')
@related_user_resource(RegenerateUserRobot)
class RegenerateOrgRobot(ApiResource):
""" Resource for regenerate an organization's robot's token. """
@require_scope(scopes.ORG_ADMIN)
@nickname('regenerateOrgRobotToken')
def post(self, orgname, robot_shortname):
""" Regenerates the token for an organization robot. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
parent = model.get_organization(orgname)
robot, password = model.regenerate_robot_token(robot_shortname, parent)
log_action('regenerate_robot_token', orgname, {'robot': robot_shortname})
return robot_view(robot.username, password)
raise Unauthorized()

View file

@ -15,6 +15,9 @@ logger = logging.getLogger(__name__)
def carderror_response(exc): def carderror_response(exc):
return {'carderror': exc.message}, 402 return {'carderror': exc.message}, 402
def connection_response(exc):
return {'message': 'Could not contact Stripe. Please try again.'}, 503
def subscription_view(stripe_subscription, used_repos): def subscription_view(stripe_subscription, used_repos):
view = { view = {
@ -74,19 +77,29 @@ def subscribe(user, plan, token, require_business_plan):
log_action('account_change_plan', user.username, {'plan': plan}) log_action('account_change_plan', user.username, {'plan': plan})
except stripe.CardError as e: except stripe.CardError as e:
return carderror_response(e) return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos) response_json = subscription_view(cus.subscription, private_repos)
status_code = 201 status_code = 201
else: else:
# Change the plan # Change the plan
cus = billing.Customer.retrieve(user.stripe_id) try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError as e:
return connection_response(e)
if plan_found['price'] == 0: if plan_found['price'] == 0:
if cus.subscription is not None: if cus.subscription is not None:
# We only have to cancel the subscription if they actually have one # We only have to cancel the subscription if they actually have one
cus.cancel_subscription() try:
cus.save() cus.cancel_subscription()
cus.save()
except stripe.APIConnectionError as e:
return connection_response(e)
check_repository_usage(user, plan_found) check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan}) log_action('account_change_plan', user.username, {'plan': plan})
@ -101,6 +114,8 @@ def subscribe(user, plan, token, require_business_plan):
cus.save() cus.save()
except stripe.CardError as e: except stripe.CardError as e:
return carderror_response(e) return carderror_response(e)
except stripe.APIConnectionError as e:
return connection_response(e)
response_json = subscription_view(cus.subscription, private_repos) response_json = subscription_view(cus.subscription, private_repos)
check_repository_usage(user, plan_found) check_repository_usage(user, plan_found)

View file

@ -42,24 +42,6 @@ class SuperUserLogs(ApiResource):
abort(403) abort(403)
@resource('/v1/superuser/seats')
@internal_only
@show_if(features.SUPER_USERS)
@hide_if(features.BILLING)
class SeatUsage(ApiResource):
""" Resource for managing the seats granted in the license for the system. """
@nickname('getSeatCount')
def get(self):
""" Returns the current number of seats being used in the system. """
if SuperUserPermission().can():
return {
'count': model.get_active_user_count(),
'allowed': app.config.get('LICENSE_USER_LIMIT', 0)
}
abort(403)
def user_view(user): def user_view(user):
return { return {
'username': user.username, 'username': user.username,

View file

@ -350,8 +350,8 @@ class BuildTriggerAnalyze(RepositoryParamResource):
(robot_namespace, shortname) = parse_robot_username(user.username) (robot_namespace, shortname) = parse_robot_username(user.username)
return AdministerOrganizationPermission(robot_namespace).can() return AdministerOrganizationPermission(robot_namespace).can()
repo_perms = model.get_all_repo_users(base_namespace, base_repository) repo_users = list(model.get_all_repo_users_transitive(base_namespace, base_repository))
read_robots = [robot_view(perm.user) for perm in repo_perms if is_valid_robot(perm.user)] read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)]
return { return {
'namespace': base_namespace, 'namespace': base_namespace,

View file

@ -7,8 +7,9 @@ from flask.ext.principal import identity_changed, AnonymousIdentity
from app import app, billing as stripe, authentication from app import app, billing as stripe, authentication
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, log_action, internal_only, NotFound, require_user_admin, parse_args,
InvalidToken, require_scope, format_date, hide_if, show_if, license_error) query_param, InvalidToken, require_scope, format_date, hide_if, show_if,
license_error, require_fresh_login)
from endpoints.api.subscribe import subscribe from endpoints.api.subscribe import subscribe
from endpoints.common import common_login from endpoints.common import common_login
from data import model from data import model
@ -150,6 +151,7 @@ class User(ApiResource):
return user_view(user) return user_view(user)
@require_user_admin @require_user_admin
@require_fresh_login
@nickname('changeUserDetails') @nickname('changeUserDetails')
@internal_only @internal_only
@validate_json_request('UpdateUser') @validate_json_request('UpdateUser')
@ -363,6 +365,37 @@ class Signin(ApiResource):
return conduct_signin(username, password) return conduct_signin(username, password)
@resource('/v1/signin/verify')
@internal_only
class VerifyUser(ApiResource):
""" Operations for verifying the existing user. """
schemas = {
'VerifyUser': {
'id': 'VerifyUser',
'type': 'object',
'description': 'Information required to verify the signed in user.',
'required': [
'password',
],
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
},
},
}
@require_user_admin
@nickname('verifyUser')
@validate_json_request('VerifyUser')
def post(self):
""" Verifies the signed in the user with the specified credentials. """
signin_data = request.get_json()
password = signin_data['password']
return conduct_signin(get_authenticated_user().username, password)
@resource('/v1/signout') @resource('/v1/signout')
@internal_only @internal_only
class Signout(ApiResource): class Signout(ApiResource):
@ -410,11 +443,24 @@ class Recovery(ApiResource):
@internal_only @internal_only
class UserNotificationList(ApiResource): class UserNotificationList(ApiResource):
@require_user_admin @require_user_admin
@parse_args
@query_param('page', 'Offset page number. (int)', type=int, default=0)
@query_param('limit', 'Limit on the number of results (int)', type=int, default=5)
@nickname('listUserNotifications') @nickname('listUserNotifications')
def get(self): def get(self, args):
notifications = model.list_notifications(get_authenticated_user()) page = args['page']
limit = args['limit']
notifications = list(model.list_notifications(get_authenticated_user(), page=page, limit=limit + 1))
has_more = False
if len(notifications) > limit:
has_more = True
notifications = notifications[0:limit]
return { return {
'notifications': [notification_view(notification) for notification in notifications] 'notifications': [notification_view(notification) for notification in notifications],
'additional': has_more
} }

View file

@ -2,8 +2,9 @@ import logging
import urlparse import urlparse
import json import json
import string import string
import datetime
from flask import make_response, render_template, request, abort from flask import make_response, render_template, request, abort, session
from flask.ext.login import login_user, UserMixin from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed from flask.ext.principal import identity_changed
from random import SystemRandom from random import SystemRandom
@ -112,6 +113,7 @@ def common_login(db_user):
logger.debug('Successfully signed in as: %s' % db_user.username) logger.debug('Successfully signed in as: %s' % db_user.username)
new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN}) new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN})
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
session['login_time'] = datetime.datetime.now()
return True return True
else: else:
logger.debug('User could not be logged in, inactive?.') logger.debug('User could not be logged in, inactive?.')

View file

@ -413,8 +413,39 @@ def put_repository_auth(namespace, repository):
@index.route('/search', methods=['GET']) @index.route('/search', methods=['GET'])
@process_auth
def get_search(): def get_search():
abort(501, 'Not Implemented', issue='not-implemented') def result_view(repo):
return {
"name": repo.namespace + '/' + repo.name,
"description": repo.description
}
query = request.args.get('q')
username = None
user = get_authenticated_user()
if user is not None:
username = user.username
if query:
matching = model.get_matching_repositories(query, username)
else:
matching = []
results = [result_view(repo) for repo in matching
if (repo.visibility.name == 'public' or
ReadRepositoryPermission(repo.namespace, repo.name).can())]
data = {
"query": query,
"num_results": len(results),
"results" : results
}
resp = make_response(json.dumps(data), 200)
resp.mimetype = 'application/json'
return resp
@index.route('/_ping') @index.route('/_ping')

View file

@ -184,6 +184,8 @@ class BuildFailureEvent(NotificationEvent):
return 'build_failure' return 'build_failure'
def get_sample_data(self, repository): def get_sample_data(self, repository):
build_uuid = 'fake-build-id'
return build_event_data(repository, { return build_event_data(repository, {
'build_id': build_uuid, 'build_id': build_uuid,
'build_name': 'some-fake-build', 'build_name': 'some-fake-build',

View file

@ -37,6 +37,9 @@ class SocketReader(object):
handler(buf) handler(buf)
return buf return buf
def tell(self):
raise IOError('Stream is not seekable.')
def image_is_uploading(repo_image): def image_is_uploading(repo_image):
if repo_image is None: if repo_image is None:
@ -367,6 +370,7 @@ def generate_ancestry(image_id, uuid, locations, parent_id=None, parent_uuid=Non
if not parent_id: if not parent_id:
store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id])) store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id]))
return return
data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid)) data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid))
data = json.loads(data) data = json.loads(data)
data.insert(0, image_id) data.insert(0, image_id)
@ -467,8 +471,13 @@ def put_image_json(namespace, repository, image_id):
store.put_content(repo_image.storage.locations, json_path, request.data) store.put_content(repo_image.storage.locations, json_path, request.data)
profile.debug('Generating image ancestry') profile.debug('Generating image ancestry')
generate_ancestry(image_id, uuid, repo_image.storage.locations, parent_id, parent_uuid,
parent_locations) try:
generate_ancestry(image_id, uuid, repo_image.storage.locations, parent_id, parent_uuid,
parent_locations)
except IOError as ioe:
profile.debug('Error when generating ancestry: %s' % ioe.message)
abort(404)
profile.debug('Done') profile.debug('Done')
return make_response('true', 200) return make_response('true', 200)

View file

@ -60,7 +60,8 @@ module.exports = function(grunt) {
removeEmptyAttributes: true, removeEmptyAttributes: true,
removeRedundantAttributes: true, removeRedundantAttributes: true,
removeScriptTypeAttributes: true, removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true removeStyleLinkTypeAttributes: true,
keepClosingSlash: true // For inline SVG
} }
}, },
quay: { quay: {

View file

@ -231,13 +231,15 @@ def initialize_database():
LogEntryKind.create(name='delete_application') LogEntryKind.create(name='delete_application')
LogEntryKind.create(name='reset_application_client_secret') LogEntryKind.create(name='reset_application_client_secret')
# Note: These are deprecated. # Note: These next two are deprecated.
LogEntryKind.create(name='add_repo_webhook') LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook') LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='add_repo_notification') LogEntryKind.create(name='add_repo_notification')
LogEntryKind.create(name='delete_repo_notification') LogEntryKind.create(name='delete_repo_notification')
LogEntryKind.create(name='regenerate_robot_token')
ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us') ImageStorageLocation.create(name='local_us')

View file

@ -1,13 +0,0 @@
import pickle
from Crypto.PublicKey import RSA
n = 24311791124264168943780535074639421876317270880681911499019414944027362498498429776192966738844514582251884695124256895677070273097239290537016363098432785034818859765271229653729724078304186025013011992335454557504431888746007324285000011384941749613875855493086506022340155196030616409545906383713728780211095701026770053812741971198465120292345817928060114890913931047021503727972067476586739126160044293621653486418983183727572502888923949587290840425930251185737996066354726953382305020440374552871209809125535533731995494145421279907938079885061852265339259634996180877443852561265066616143910755505151318370667L
e = 65537L
def load_license(license_path):
decryptor = RSA.construct((n, e))
with open(license_path, 'rb') as encrypted_license:
decrypted_data = decryptor.encrypt(encrypted_license.read(), 0)
return pickle.loads(decrypted_data[0])

Binary file not shown.

View file

@ -32,5 +32,7 @@ raven
python-ldap python-ldap
pycrypto pycrypto
logentries logentries
psycopg2
pyyaml
git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/aniso8601-fake.git
git+https://github.com/DevTable/anunidecode.git git+https://github.com/DevTable/anunidecode.git

View file

@ -12,6 +12,7 @@ Pillow==2.5.1
PyGithub==1.25.0 PyGithub==1.25.0
PyMySQL==0.6.2 PyMySQL==0.6.2
PyPDF2==1.22 PyPDF2==1.22
PyYAML==3.11
SQLAlchemy==0.9.7 SQLAlchemy==0.9.7
Werkzeug==0.9.6 Werkzeug==0.9.6
alembic==0.6.5 alembic==0.6.5
@ -44,6 +45,7 @@ python-dateutil==2.2
python-ldap==2.4.15 python-ldap==2.4.15
python-magic==0.4.6 python-magic==0.4.6
pytz==2014.4 pytz==2014.4
psycopg2==2.5.3
raven==5.0.0 raven==5.0.0
redis==2.10.1 redis==2.10.1
reportlab==2.7 reportlab==2.7

View file

@ -9,33 +9,131 @@
} }
} }
.repo-search {
display: none;
}
@media (min-width: 1200px) {
.repo-search {
display: inline-block;
}
}
#quay-logo {
width: 80px;
margin-right: 30px;
}
#padding-container {
padding: 20px;
padding-top: 20px;
padding-bottom: 6px;
}
.d3-tip {
position: absolute;
left: -100000px;
}
#co-l-footer-wrapper {
clear: both;
min-height: 100%;
height: auto !important;
height: 100%;
margin-bottom: -64px;
}
#co-l-footer-wrapper #co-l-footer-push {
height: 64px;
}
#co-l-footer img {
height: 50px;
margin-top: 8px;
}
#co-l-footer .col-md-4 {
text-align: right;
}
#co-l-footer {
clear: both;
position: relative;
background-color: white;
height: 64px;
min-height: 64px;
overflow: hidden;
margin: 0;
}
.co-img-bg-network {
background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed;
background-color: #2277ad;
background-size: auto, 100% 100%;
}
.co-m-navbar {
background-color: white;
margin: 0;
padding-left: 10px;
}
.co-fx-box-shadow {
-webkit-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-ms-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-o-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
.co-fx-box-shadow-heavy {
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
}
.main-panel {
margin-bottom: 20px;
background-color: #fff;
border: 1px solid transparent;
padding: 30px;
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-ms-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
-o-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4);
}
.container {
max-width: none !important;
}
nav.navbar { nav.navbar {
border: 0px; border: 0px;
border-radius: 0px; border-radius: 0px;
background-image: linear-gradient(to top, #535C66 0%,#6E8194 100%);
}
nav.navbar-default .navbar-brand {
padding-left: 14px;
border-right: 1px solid rgb(134, 140, 163);
padding-right: 14px;
padding-top: 5px;
height: 50px;
} }
nav.navbar-default .navbar-nav>li>a { nav.navbar-default .navbar-nav>li>a {
color: white;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: #428bca;
font-size: 16px;
}
nav.navbar-default .navbar-nav>li>a.active {
color: #f04c5c;
} }
.navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus { .navbar-default .navbar-nav>li>a:hover, .navbar-default .navbar-nav>li>a:focus {
color: #BEE1FF; background: #eee;
} }
.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { .navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus {
cursor: pointer; cursor: pointer;
background: rgba(255, 255, 255, 0.4) !important; background: rgba(255, 255, 255, 0.4) !important;
color: white;
} }
.notification-view-element { .notification-view-element {
@ -366,6 +464,22 @@ i.toggle-icon:hover {
.docker-auth-dialog .token-dialog-body .well { .docker-auth-dialog .token-dialog-body .well {
margin-bottom: 0px; margin-bottom: 0px;
position: relative;
padding-right: 24px;
}
.docker-auth-dialog .token-dialog-body .well i.fa-refresh {
position: absolute;
top: 9px;
right: 9px;
font-size: 20px;
color: gray;
transition: all 0.5s ease-in-out;
cursor: pointer;
}
.docker-auth-dialog .token-dialog-body .well i.fa-refresh:hover {
color: black;
} }
.docker-auth-dialog .token-view { .docker-auth-dialog .token-view {
@ -399,17 +513,6 @@ i.toggle-icon:hover {
margin: 0 auto -176px; margin: 0 auto -176px;
} }
.footer-container, .push {
height: 100px;
}
.footer-container.fixed {
position: fixed;
bottom: 16px;
left: 0px;
right: 0px;
}
.button-hidden { .button-hidden {
visibility: hidden; visibility: hidden;
} }
@ -489,6 +592,14 @@ i.toggle-icon:hover {
line-height: 25px; line-height: 25px;
} }
.logs-view-element .log .log-description code {
max-width: 300px;
display: inline-block;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
}
.logs-view-element .log-performer { .logs-view-element .log-performer {
white-space: nowrap; white-space: nowrap;
} }
@ -634,7 +745,7 @@ i.toggle-icon:hover {
} }
.user-notification.notification-animated { .user-notification.notification-animated {
width: 21px; min-width: 21px;
transform: scale(0); transform: scale(0);
-moz-transform: scale(0); -moz-transform: scale(0);
@ -671,12 +782,12 @@ i.toggle-icon:hover {
.user-tools .user-tool { .user-tools .user-tool {
font-size: 24px; font-size: 24px;
margin-top: 14px; margin-top: 14px;
color: white; color: #428bca;
} }
.user-tools i.user-tool:hover { .user-tools i.user-tool:hover {
cursor: pointer; cursor: pointer;
color: #BEE1FF; color: #333;
} }
.status-box a { .status-box a {
@ -728,7 +839,7 @@ i.toggle-icon:hover {
background-color: red; background-color: red;
} }
.phase-icon.waiting, .phase-icon.starting, .phase-icon.initializing { .phase-icon.waiting, .phase-icon.unpacking, .phase-icon.starting, .phase-icon.initializing {
background-color: #ddd; background-color: #ddd;
} }
@ -1000,12 +1111,12 @@ i.toggle-icon:hover {
font-size: 1.4em; font-size: 1.4em;
} }
.plans-list .plan.bus-small { .plans-list .plan.bus-small, .plans-list .plan.bus-coreos-trial {
border-top: 6px solid #46ac39; border-top: 6px solid #46ac39;
margin-top: -10px; margin-top: -10px;
} }
.plans-list .plan.bus-small .plan-box { .plans-list .plan.bus-small .plan-box, .plans-list .plan.bus-coreos-trial .plan-box {
background: black !important; background: black !important;
} }
@ -1040,6 +1151,10 @@ i.toggle-icon:hover {
display: inline; display: inline;
} }
.hidden-xs-inline {
display: inline;
}
@media (min-width: 991px) { @media (min-width: 991px) {
.visible-md-inline { .visible-md-inline {
display: inline; display: inline;
@ -1056,6 +1171,13 @@ i.toggle-icon:hover {
} }
} }
@media (max-width: 700px) {
.hidden-xs-inline {
display: none;
}
}
.visible-xl { .visible-xl {
display: none; display: none;
} }
@ -1085,7 +1207,7 @@ i.toggle-icon:hover {
margin-bottom: 6px; margin-bottom: 6px;
} }
.plans-list .plan.bus-small button { .plans-list .plan.bus-small button, .plans-list .plan.bus-coreos-trial button {
font-size: 1em; font-size: 1em;
} }
@ -1230,14 +1352,64 @@ i.toggle-icon:hover {
margin-right: 5px; margin-right: 5px;
} }
.plans .plan-faq dd{ .plans .plan-faq dd {
margin-bottom: 20px; margin-bottom: 20px;
} }
.enterprise-plan .plan-combine {
text-align: center;
padding: 10px;
margin-bottom: 20px;
}
.enterprise-plan .plan-combine .plus {
font-size: 22px;
display: inline-block;
margin-left: 10px;
margin-right: 10px;
}
.enterprise-plan a {
margin-top: 20px;
}
.enterprise-plan .plan-combine img {
height: 45px;
}
.enterprise-tour .tour-section {
margin-bottom: 50px !important;
padding-bottom: 50px !important;
border-bottom: 1px solid #eee !important;
}
.enterprise-tour .tour-section p {
margin: 0 !important;
}
.enterprise-tour .btn {
font-size: 20px;
}
.feature-illustration {
text-align: center;
}
.feature-illustration svg {
margin: 0 auto;
width: auto !important;
max-width: 100%;
max-height: 110px;
}
.loading { .loading {
padding: 20px; padding: 20px;
} }
.jumbotron {
background: transparent;
}
.jumbotron .disclaimer-link { .jumbotron .disclaimer-link {
font-size: .3em; font-size: .3em;
vertical-align: 23px; vertical-align: 23px;
@ -1255,25 +1427,19 @@ i.toggle-icon:hover {
color: #555; color: #555;
} }
.landing-page .wrapper > nav { .landing-page #padding-container {
display: none; padding: 0px;
} }
.landing-page .nav > li > a { .landing-page .main-panel {
border-radius: 4px; padding: 0px;
border: 0px;
padding-bottom: 10px;
} }
.landing-page .nav > li > a:hover, .landing-page .nav > li > a:focus { .landing-page.signedin .main-panel {
background: rgba(255, 255, 255, 0.4); background: transparent;
} box-shadow: none;
.landing-page .nav .user-view {
color: white !important;
font-weight: bold;
}
.landing-page .user-tool {
color: white;
} }
.landing { .landing {
@ -1284,10 +1450,6 @@ i.toggle-icon:hover {
font-size: 14px; font-size: 14px;
} }
.landing-content {
}
.landing-background { .landing-background {
z-index: 0; z-index: 0;
@ -1297,10 +1459,13 @@ i.toggle-icon:hover {
left: 0px; left: 0px;
right: 0px; right: 0px;
background-color: #1d1d1d; background: url('/static/img/network-tile.png') left top repeat, linear-gradient(30deg, #2277ad, #144768) no-repeat left top fixed;
background-image: url(../img/landing-back-opt.jpg); background-color: #2277ad;
background-repeat: no-repeat; background-size: auto, 100% 100%;
background-size: cover; }
.landing-page.signedin .landing-background {
background: transparent;
} }
.landing-filter { .landing-filter {
@ -1320,6 +1485,55 @@ i.toggle-icon:hover {
z-index: 2; z-index: 2;
} }
.landing .call-to-action i.fa {
margin-left: 10px;
}
.landing .call-to-action {
height: 40px;
font-size: 18px;
padding-left: 14px;
padding-right: 14px;
padding-top: 2px;
padding-bottom: 2px;
background: rgba(15, 131, 203, 0.6);
display: inline-block;
margin-top: 20px;
}
.landing .announcement {
position: absolute;
z-index: 9;
top: 0px;
left: 0px;
right: 0px;
display: block;
background: rgba(8, 61, 95, 0.6);
min-height: 45px;
text-align: center;
font-size: 14px;
line-height: 45px;
}
.landing .announcement .spacer {
display: inline-block;
width: 45px;
}
.landing .announcement img {
height: 45px;
padding-top: 6px;
padding-bottom: 6px;
}
.landing .announcement .plus {
display: inline-block;
margin-left: 10px;
margin-right: 10px;
}
.landing { .landing {
color: white; color: white;
@ -1346,104 +1560,6 @@ i.toggle-icon:hover {
font-size: 40px; font-size: 40px;
} }
.landing .header-bar .navbar-brand {
display: none;
}
.landing .header-bar form {
display: none;
}
.landing .header-bar {
font-size: 16px;
position: absolute;
right: 20px;
top: 20px;
color: white;
z-index: 100;
}
.landing .header-bar .user-tools a {
padding-left: 10px;
padding-right: 10px;
background: transparent !important;
}
.landing .header-bar .user-tools i {
margin-top: 0px;
padding: 12px;
border-radius: 4px;
color: white !important;
}
.landing .header-bar .user-tools i:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.landing .navbar-links a {
font-weight: 600;
color: white;
border-radius: 6px;
letter-spacing: 0.5px;
}
.landing .navbar-links li a:hover {
background: rgba(255, 255, 255, 0.2);
}
.landing .nav .dropdown-menu a {
color: black;
font-weight: normal;
}
@media (max-width: 971px) {
.landing .navbar-collapse {
background: rgba(0, 0, 0, 0.8);
border-radius: 2px;
}
.navbar-header {
float: none;
}
.navbar-toggle {
display: block;
color: white;
font-size: 48px;
line-height: 28px;
padding: 4px;
}
.navbar-collapse {
border-top: 1px solid transparent;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
}
.navbar-collapse.collapse {
display: none!important;
}
.navbar-nav {
float: none!important;
margin: 7.5px -15px;
}
.navbar-nav>li {
float: none;
}
.navbar-nav>li>a {
padding-top: 10px;
padding-bottom: 10px;
}
.navbar-collapse.collapse.in { display: block!important; }
}
.landing .messages b {
color: #59B2FF;
}
.landing .messages h1 { .landing .messages h1 {
font-size: 48px; font-size: 48px;
} }
@ -1651,10 +1767,15 @@ form input.ng-valid.ng-dirty,
font-size: 26px; font-size: 26px;
} }
.page-footer { .footer-container, .push {
padding: 10px; height: 100px;
padding-bottom: 0px; }
border-top: 1px solid #eee;
.footer-container.fixed {
position: fixed;
bottom: 16px;
left: 0px;
right: 0px;
} }
.page-footer-padder { .page-footer-padder {
@ -1838,6 +1959,7 @@ p.editable:hover i {
} }
.right-tag-controls { .right-tag-controls {
cursor: default;
display: inline-block; display: inline-block;
float: right; float: right;
padding: 4px; padding: 4px;
@ -2151,6 +2273,14 @@ p.editable:hover i {
position: relative; position: relative;
} }
.copy-box-element.disabled .input-group-addon {
display: none;
}
.copy-box-element.disabled input {
border-radius: 4px !important;
}
.global-zeroclipboard-container embed { .global-zeroclipboard-container embed {
cursor: pointer; cursor: pointer;
} }
@ -3141,6 +3271,10 @@ p.editable:hover i {
display: inline-block; display: inline-block;
} }
.table-container {
max-width: 100%;
}
.billing-invoices-element .invoice-title { .billing-invoices-element .invoice-title {
padding: 6px; padding: 6px;
cursor: pointer; cursor: pointer;

View file

@ -1,4 +1,4 @@
<div class="copy-box-element"> <div class="copy-box-element" ng-class="disabled ? 'disabled' : ''">
<div class="id-container"> <div class="id-container">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" value="{{ value }}" readonly> <input type="text" class="form-control" value="{{ value }}" readonly>

View file

@ -10,19 +10,33 @@
</div> </div>
<div class="modal-body token-dialog-body"> <div class="modal-body token-dialog-body">
<div class="alert alert-info">The docker <u>username</u> is <b>{{ username }}</b> and the <u>password</u> is the token below. You may use any value for email.</div> <div class="alert alert-info">The docker <u>username</u> is <b>{{ username }}</b> and the <u>password</u> is the token below. You may use any value for email.</div>
<div class="well well-sm">
<div class="well well-sm" ng-show="regenerating">
Regenerating Token...
<i class="fa fa-refresh fa-spin"></i>
</div>
<div class="well well-sm" ng-show="!regenerating">
<input id="token-view" class="token-view" type="text" value="{{ token }}" onClick="this.select();" readonly> <input id="token-view" class="token-view" type="text" value="{{ token }}" onClick="this.select();" readonly>
<i class="fa fa-refresh" ng-show="supportsRegenerate" ng-click="askRegenerate()"
data-title="Regenerate Token"
data-placement="left"
bs-tooltip></i>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer" ng-show="regenerating">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
<div class="modal-footer" ng-show="!regenerating">
<span class="download-cfg" ng-show="isDownloadSupported()"> <span class="download-cfg" ng-show="isDownloadSupported()">
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
<a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a> <a href="javascript:void(0)" ng-click="downloadCfg(shownRobot)">Download .dockercfg file</a>
</span> </span>
<div id="clipboardCopied" style="display: none"> <div class="clipboard-copied-message" style="display: none">
Copied to clipboard Copied
</div> </div>
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button> <input type="hidden" name="command-data" id="command-data" value="{{ command }}">
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="command-data">Copy Login Command</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div> </div>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->

View file

@ -4,19 +4,19 @@
&equiv; &equiv;
</button> </button>
<a class="navbar-brand" href="/" target="{{ appLinkTarget() }}"> <a class="navbar-brand" href="/" target="{{ appLinkTarget() }}">
<img src="/static/img/quay-box-white.png" data-title="Quay.io" data-placement="bottom" bs-tooltip> <img id="quay-logo" src="/static/img/black-horizontal.svg">
</a> </a>
</div> </div>
<!-- Collapsable stuff --> <!-- Collapsable stuff -->
<div class="collapse navbar-collapse navbar-ex1-collapse"> <div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav navbar-links"> <ul class="nav navbar-nav navbar-links">
<li><a ng-href="/tour/" target="{{ appLinkTarget() }}">Tour</a></li> <li><a ng-href="/tour/" target="{{ appLinkTarget() }}" quay-section="tour">Tour</a></li>
<li><a ng-href="/repository/" target="{{ appLinkTarget() }}">Repositories</a></li> <li><a ng-href="/repository/" target="{{ appLinkTarget() }}" quay-section="repository">Repositories</a></li>
<li><a href="http://docs.quay.io/" target="_blank">Docs</a></li> <li><a href="http://docs.quay.io/" target="_blank">Docs</a></li>
<li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}">Tutorial</a></li> <li><a ng-href="/tutorial/" target="{{ appLinkTarget() }}" quay-section="tutorial">Tutorial</a></li>
<li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}">Pricing</a></li> <li quay-require="['BILLING']"><a ng-href="/plans/" target="{{ appLinkTarget() }}" quay-section="plans">Pricing</a></li>
<li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}">Organizations</a></li> <li><a ng-href="{{ user.organizations.length ? '/organizations/' : '/tour/organizations/' }}" target="{{ appLinkTarget() }}" quay-section="organization">Organizations</a></li>
</ul> </ul>
@ -37,15 +37,7 @@
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown user-view" data-toggle="dropdown"> <a href="javascript:void(0)" class="dropdown-toggle user-dropdown user-view" data-toggle="dropdown">
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" /> <img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
{{ user.username }} {{ user.username }}
<span class="badge user-notification notification-animated" <span class="notifications-bubble"></span>
ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses"
bs-tooltip=""
data-title="User Notifications"
data-placement="left"
data-container="body">
{{ notificationService.notifications.length }}
</span>
<b class="caret"></b> <b class="caret"></b>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@ -58,11 +50,7 @@
<a href="javascript:void(0)" data-template="/static/directives/notification-bar.html" <a href="javascript:void(0)" data-template="/static/directives/notification-bar.html"
data-animation="am-slide-right" bs-aside="aside" data-container="body"> data-animation="am-slide-right" bs-aside="aside" data-container="body">
Notifications Notifications
<span class="badge user-notification" <span class="notifications-bubble"></span>
ng-class="notificationService.notificationClasses"
ng-show="notificationService.notifications.length">
{{ notificationService.notifications.length }}
</span>
</a> </a>
</li> </li>
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li> <li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>

View file

@ -38,6 +38,7 @@
</div> </div>
</div> </div>
<div class="table-container">
<table class="table"> <table class="table">
<thead> <thead>
<th>Description</th> <th>Description</th>
@ -77,5 +78,6 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div> </div>

View file

@ -3,7 +3,10 @@
<div class="aside-content"> <div class="aside-content">
<div class="aside-header"> <div class="aside-header">
<button type="button" class="close" ng-click="$hide()">&times;</button> <button type="button" class="close" ng-click="$hide()">&times;</button>
<h4 class="aside-title">Notifications</h4> <h4 class="aside-title">
Notifications
<span class="notifications-bubble"></span>
</h4>
</div> </div>
<div class="aside-body"> <div class="aside-body">
<div ng-repeat="notification in notificationService.notifications"> <div ng-repeat="notification in notificationService.notifications">

View file

@ -0,0 +1,7 @@
<span class="notifications-bubble-element">
<span class="badge user-notification notification-animated"
ng-show="notificationService.notifications.length"
ng-class="notificationService.notificationClasses">
{{ notificationService.notifications.length }}<span ng-if="notificationService.additionalNotifications">+</span>
</span>
</span>

View file

@ -31,7 +31,7 @@
</div> </div>
<div class="docker-auth-dialog" username="shownRobot.name" token="shownRobot.token" <div class="docker-auth-dialog" username="shownRobot.name" token="shownRobot.token"
shown="!!shownRobot" counter="showRobotCounter"> shown="!!shownRobot" counter="showRobotCounter" supports-regenerate="true" regenerate="regenerateToken(username)">
<i class="fa fa-wrench"></i> {{ shownRobot.name }} <i class="fa fa-wrench"></i> {{ shownRobot.name }}
</div> </div>
</div> </div>

View file

@ -4,15 +4,22 @@
placeholder="Username or E-mail Address" ng-model="user.username" autofocus> placeholder="Username or E-mail Address" ng-model="user.username" autofocus>
<input type="password" class="form-control input-lg" name="password" <input type="password" class="form-control input-lg" name="password"
placeholder="Password" ng-model="user.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" quay-require="['GITHUB_LOGIN']"> <div class="alert alert-warning" ng-show="tryAgainSoon > 0">
<i class="fa fa-circle"></i> Too many attempts have been made to login. Please try again in {{ tryAgainSoon }} second<span ng-if="tryAgainSoon != 1">s</span>.
<span class="inner-text">OR</span> </div>
<span ng-show="tryAgainSoon == 0">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign In</button>
<span class="social-alternate" quay-show="Features.GITHUB_LOGIN || Features.GOOGLE_LOGIN">
<i class="fa fa-circle"></i>
<span class="inner-text">OR</span>
</span>
<div class="external-login-button" provider="github" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
<div class="external-login-button" provider="google" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
</span> </span>
<div class="external-login-button" provider="github" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
<div class="external-login-button" provider="google" redirect-url="redirectUrl" sign-in-started="markStarted()"></div>
</form> </form>
<div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div> <div class="alert alert-danger" ng-show="invalidCredentials">Invalid username or password.</div>

View file

@ -20,7 +20,6 @@
</span> </span>
<div class="external-login-button" provider="github"></div> <div class="external-login-button" provider="github"></div>
<div class="external-login-button" provider="google"></div> <div class="external-login-button" provider="google"></div>
<p class="help-block" quay-require="['BILLING']">No credit card required.</p>
</div> </div>
</form> </form>
<div ng-show="registering" style="text-align: center"> <div ng-show="registering" style="text-align: center">

View file

@ -226,71 +226,208 @@
<!-- Enterprise --> <!-- Enterprise -->
<div class="product-tour" ng-if="kind == 'enterprise'"> <div class="product-tour enterprise-tour" ng-if="kind == 'enterprise'">
<div class="tour-section row tour-header"> <div class="tour-section row tour-header">
<div class="col-md-12"> <div class="col-md-12">
<div class="tour-section-title">Quay.io in your data center</div> <div class="tour-section-title">Run Quay.io Behind Your Firewall</div>
<div class="tour-section-description"> <div class="tour-section-description">
All of the power of Quay.io, easily deployed to your data center via docker. <div class="col-lg-6 enterprise-plan col-lg-offset-3">
</div> <div class="plan-combine">
<div class="tour-section-description"> <img src="/static/img/quay-logo.png">
<div class="row"> <span class="plus">+</span>
<div class="alert alert-info col-md-4 col-md-offset-4"><strong>docker run quay.io/quay/enterprise</strong></div> <img src="/static/img/coreos.svg" style="height: 50px">
</div>
Quay.io has partnered with CoreOS to offer <a href="https://coreos.com/products/enterprise-registry">Enterprise Registry</a>, a version
of Quay.io that can be hosted behind your firewall.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="tour-section row"> <div class="tour-section row features">
<div class="col-sm-3 enterprise-icon"> <div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<span class="fa-stack fa-5x"> <div class="feature-illustration">
<i class="fa fa-circle fa-stack-2x"></i> <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
<i class="fa fa-lock fa-stack-1x fa-inverse"></i> preserveAspectRatio="xMinYMin" width="100px" viewBox="0 0 144 144" enable-background="new 0 0 144 144" xml:space="preserve">
</span> <path fill="none" stroke="#010101" stroke-width="2" stroke-miterlimit="10" d="M124.979,62.559
</div> c0.695,3.413,1.058,6.944,1.058,10.56c0,29.104-23.591,52.695-52.695,52.695c-17.612,0-33.207-8.641-42.776-21.913"/>
<div class="col-sm-9"> <path fill="none" stroke="#010101" stroke-width="2" stroke-miterlimit="10" d="M21.319,81.551c-0.44-2.744-0.671-5.561-0.671-8.431
<div class="tour-section-title">Take control of your own security</div> c0-29.104,23.591-52.695,52.695-52.695c17.442,0,32.906,8.474,42.497,21.532"/>
<div class="tour-section-description"> <polygon fill="#010101" points="122.993,57.123 119.268,67.523 131.435,63.599 "/>
The nature of machine images is that they often contain keys and passwords. We're pretty proud of the <a href="/security/">security</a> of our hosted offering, but we recognize that there are situations, such as compliance or auditing, where you must control your own end to end security. Quay.io Enterprise Edition runs in your own data centers, inside your firewall. <polygon fill="#010101" points="23.006,87.718 26.73,77.318 14.565,81.242 "/>
<g enable-background="new ">
<path d="M41.5,75.337v-5.679c0-0.204,0.071-0.403,0.215-0.598c0.143-0.194,0.307-0.302,0.491-0.322l4.757-0.737
c0.225-0.716,0.552-1.493,0.982-2.332c-0.696-0.982-1.617-2.159-2.762-3.53c-0.144-0.225-0.215-0.43-0.215-0.613
c0-0.246,0.071-0.45,0.215-0.614c0.45-0.613,1.289-1.523,2.517-2.731c1.228-1.207,2.036-1.811,2.425-1.811
c0.225,0,0.439,0.072,0.645,0.215l3.529,2.762c0.696-0.368,1.483-0.695,2.363-0.981c0.225-2.21,0.46-3.785,0.706-4.727
c0.143-0.491,0.45-0.737,0.921-0.737h5.708c0.225,0,0.43,0.077,0.614,0.23c0.185,0.153,0.287,0.333,0.307,0.537l0.706,4.696
c0.695,0.204,1.462,0.521,2.302,0.951l3.622-2.731c0.164-0.143,0.368-0.215,0.614-0.215c0.225,0,0.44,0.082,0.645,0.245
c2.946,2.722,4.42,4.358,4.42,4.911c0,0.184-0.072,0.379-0.215,0.583c-0.245,0.327-0.675,0.88-1.289,1.657
c-0.614,0.778-1.074,1.392-1.382,1.842c0.471,0.982,0.818,1.821,1.044,2.517l4.665,0.706c0.205,0.041,0.379,0.148,0.521,0.322
c0.144,0.174,0.215,0.373,0.215,0.599v5.678c0,0.205-0.071,0.404-0.215,0.599c-0.143,0.194-0.307,0.302-0.49,0.322l-4.758,0.736
c-0.225,0.717-0.553,1.494-0.982,2.333c0.695,0.981,1.617,2.159,2.763,3.529c0.144,0.205,0.215,0.409,0.215,0.614
c0,0.245-0.071,0.439-0.215,0.583c-0.471,0.613-1.315,1.529-2.532,2.747c-1.217,1.217-2.021,1.826-2.409,1.826
c-0.225,0-0.44-0.072-0.645-0.215l-3.529-2.763c-0.757,0.389-1.545,0.706-2.363,0.951c-0.226,2.21-0.46,3.796-0.706,4.758
c-0.144,0.491-0.45,0.736-0.921,0.736h-5.708c-0.226,0-0.43-0.077-0.614-0.23s-0.287-0.332-0.307-0.537l-0.706-4.695
c-0.696-0.204-1.463-0.522-2.302-0.952l-3.622,2.732c-0.144,0.143-0.348,0.215-0.614,0.215c-0.226,0-0.44-0.082-0.645-0.246
c-2.946-2.721-4.42-4.358-4.42-4.91c0-0.185,0.072-0.379,0.215-0.584c0.205-0.286,0.624-0.828,1.259-1.626
c0.634-0.798,1.115-1.422,1.442-1.872c-0.471-0.9-0.829-1.739-1.074-2.518l-4.665-0.736c-0.205-0.02-0.379-0.117-0.522-0.291
C41.571,75.762,41.5,75.562,41.5,75.337z M55.587,66.988c-1.534,1.535-2.302,3.387-2.302,5.556s0.768,4.021,2.302,5.555
c1.535,1.535,3.386,2.302,5.556,2.302c2.168,0,4.021-0.767,5.555-2.302C68.233,76.564,69,74.713,69,72.544
s-0.767-4.021-2.302-5.556c-1.535-1.534-3.387-2.302-5.555-2.302C58.974,64.687,57.122,65.454,55.587,66.988z M76.857,58.978
v-4.297c0-0.327,1.523-0.645,4.572-0.951c0.266-0.593,0.573-1.125,0.922-1.596c-1.044-2.312-1.566-3.724-1.566-4.235
c0-0.082,0.041-0.154,0.123-0.215c0.082-0.041,0.439-0.246,1.074-0.614s1.238-0.716,1.811-1.044c0.573-0.326,0.88-0.49,0.922-0.49
c0.163,0,0.633,0.476,1.411,1.427c0.777,0.951,1.31,1.642,1.597,2.071c0.408-0.04,0.715-0.061,0.92-0.061s0.512,0.021,0.921,0.061
c1.044-1.452,1.984-2.598,2.823-3.438l0.185-0.061c0.081,0,1.351,0.716,3.806,2.148c0.082,0.061,0.123,0.133,0.123,0.215
c0,0.512-0.521,1.923-1.565,4.235c0.348,0.471,0.655,1.003,0.921,1.596c3.049,0.307,4.573,0.624,4.573,0.951v4.297
c0,0.328-1.524,0.645-4.573,0.952c-0.246,0.552-0.553,1.084-0.921,1.596c1.044,2.312,1.565,3.725,1.565,4.235
c0,0.082-0.041,0.153-0.123,0.215c-2.496,1.453-3.765,2.179-3.806,2.179c-0.164,0-0.634-0.48-1.411-1.442
c-0.778-0.961-1.311-1.657-1.597-2.087c-0.409,0.041-0.716,0.062-0.921,0.062s-0.512-0.021-0.92-0.062
c-0.287,0.43-0.819,1.126-1.597,2.087c-0.778,0.962-1.248,1.442-1.411,1.442c-0.042,0-1.311-0.726-3.807-2.179
c-0.082-0.062-0.123-0.133-0.123-0.215c0-0.511,0.522-1.923,1.566-4.235c-0.369-0.512-0.676-1.044-0.922-1.596
C78.381,59.622,76.857,59.306,76.857,58.978z M76.857,90.406v-4.297c0-0.327,1.523-0.645,4.572-0.951
c0.266-0.594,0.573-1.125,0.922-1.596c-1.044-2.312-1.566-3.725-1.566-4.236c0-0.081,0.041-0.153,0.123-0.215
c0.082-0.04,0.439-0.245,1.074-0.613s1.238-0.716,1.811-1.044c0.573-0.327,0.88-0.491,0.922-0.491c0.163,0,0.633,0.477,1.411,1.428
c0.777,0.951,1.31,1.642,1.597,2.071c0.408-0.04,0.715-0.062,0.92-0.062s0.512,0.021,0.921,0.062
c1.044-1.452,1.984-2.598,2.823-3.438l0.185-0.062c0.081,0,1.351,0.717,3.806,2.148c0.082,0.062,0.123,0.134,0.123,0.215
c0,0.512-0.521,1.924-1.565,4.236c0.348,0.471,0.655,1.002,0.921,1.596c3.049,0.307,4.573,0.624,4.573,0.951v4.297
c0,0.328-1.524,0.645-4.573,0.951c-0.246,0.553-0.553,1.085-0.921,1.597c1.044,2.312,1.565,3.724,1.565,4.235
c0,0.081-0.041,0.153-0.123,0.215c-2.496,1.452-3.765,2.179-3.806,2.179c-0.164,0-0.634-0.48-1.411-1.442
c-0.778-0.962-1.311-1.657-1.597-2.087c-0.409,0.041-0.716,0.062-0.921,0.062s-0.512-0.021-0.92-0.062
c-0.287,0.43-0.819,1.125-1.597,2.087c-0.778,0.962-1.248,1.442-1.411,1.442c-0.042,0-1.311-0.727-3.807-2.179
c-0.082-0.062-0.123-0.134-0.123-0.215c0-0.512,0.522-1.924,1.566-4.235c-0.369-0.512-0.676-1.044-0.922-1.597
C78.381,91.051,76.857,90.734,76.857,90.406z M84.715,56.829c0,1.085,0.383,2.011,1.15,2.778c0.768,0.767,1.693,1.15,2.777,1.15
s2.011-0.384,2.777-1.15c0.768-0.768,1.151-1.693,1.151-2.778c0-1.063-0.389-1.984-1.166-2.762
c-0.778-0.777-1.698-1.167-2.763-1.167s-1.984,0.39-2.762,1.167C85.103,54.845,84.715,55.766,84.715,56.829z M84.715,88.258
c0,1.085,0.383,2.011,1.15,2.777c0.768,0.768,1.693,1.151,2.777,1.151s2.011-0.384,2.777-1.151
c0.768-0.767,1.151-1.692,1.151-2.777c0-1.063-0.389-1.984-1.166-2.762c-0.778-0.777-1.698-1.167-2.763-1.167
s-1.984,0.39-2.762,1.167C85.103,86.273,84.715,87.194,84.715,88.258z"/>
</g>
</svg>
</div>
<h4>Deployment Made Easy</h4>
<p>Trigger container builds when your code is checked into Github and passes tests. Automatically pushed into your repository for immediate access by your servers.</p>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<div class="feature-illustration">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
preserveAspectRatio="xMinYMin" width="140px" viewBox="0 0 202.5 144" enable-background="new 0 0 202.5 144" xml:space="preserve">
<circle fill="none" stroke="#010101" stroke-width="1.9753" stroke-miterlimit="10" cx="72" cy="72" r="52.567"/>
<circle fill="none" stroke="#010101" stroke-width="1.9753" stroke-miterlimit="10" cx="132.006" cy="72" r="52.567"/>
<g>
<path fill="#00A3A0" d="M90.699,77.453c0-0.552,0.019-1.091,0.055-1.617s0.109-1.094,0.219-1.703s0.247-1.175,0.414-1.696
c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266c0.364-0.349,0.81-0.627,1.336-0.836
c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336c0.344,0.224,0.731,0.474,1.164,0.75
c0.433,0.276,0.995,0.526,1.688,0.75c0.692,0.224,1.388,0.336,2.086,0.336s1.394-0.112,2.086-0.336
c0.692-0.224,1.255-0.474,1.688-0.75c0.433-0.276,0.82-0.526,1.164-0.75c0.344-0.224,0.562-0.336,0.656-0.336
c0.636,0,1.216,0.104,1.742,0.312c0.526,0.208,0.972,0.487,1.336,0.836c0.364,0.349,0.688,0.771,0.969,1.266
c0.281,0.495,0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.696s0.183,1.177,0.219,1.703s0.055,1.065,0.055,1.617
c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086H94.871c-1.261,0-2.271-0.362-3.031-1.086
C91.079,79.689,90.699,78.703,90.699,77.453z M97.457,67.742c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242
s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758
S98.629,68.914,97.457,67.742z"/>
</g>
<g>
<path fill="#4BA0D8" d="M42.782,57.118c0-0.552,0.018-1.091,0.055-1.617c0.036-0.526,0.109-1.094,0.219-1.703
s0.247-1.174,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266
c0.364-0.349,0.81-0.627,1.336-0.836c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.224,0.731,0.474,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75c0.692,0.224,1.388,0.336,2.086,0.336
c0.698,0,1.393-0.112,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75c0.432-0.276,0.82-0.526,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.526,0.208,0.971,0.487,1.336,0.836
c0.364,0.349,0.688,0.771,0.969,1.266c0.281,0.495,0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.695
s0.182,1.177,0.219,1.703c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.38,2.237-1.141,2.961
c-0.761,0.724-1.771,1.086-3.031,1.086H46.954c-1.261,0-2.271-0.362-3.031-1.086C43.162,59.355,42.782,58.368,42.782,57.118z
M49.54,47.407c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S50.712,48.579,49.54,47.407z"/>
</g>
<g>
<path fill="#4BA0D8" d="M27.422,90.324c0-0.552,0.018-1.091,0.055-1.617c0.036-0.526,0.109-1.094,0.219-1.703
s0.247-1.175,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523s0.604-0.917,0.969-1.266c0.364-0.349,0.81-0.628,1.336-0.836
c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336s0.731,0.474,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75
c0.692,0.224,1.388,0.336,2.086,0.336c0.698,0,1.393-0.112,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75
c0.432-0.276,0.82-0.526,1.164-0.75s0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.526,0.208,0.971,0.487,1.336,0.836
c0.364,0.349,0.688,0.771,0.969,1.266s0.505,1.003,0.672,1.523c0.167,0.521,0.305,1.086,0.414,1.695s0.182,1.177,0.219,1.703
c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086H31.594
c-1.261,0-2.271-0.362-3.031-1.086C27.802,92.561,27.422,91.574,27.422,90.324z M34.18,80.613
c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S35.352,81.785,34.18,80.613z"/>
</g>
<g>
<path fill="#4BA0D8" d="M54.999,106.043c0-0.552,0.018-1.091,0.055-1.617c0.036-0.525,0.109-1.094,0.219-1.703
s0.247-1.174,0.414-1.695c0.167-0.521,0.391-1.028,0.672-1.523c0.281-0.494,0.604-0.916,0.969-1.266
c0.364-0.349,0.81-0.627,1.336-0.836c0.526-0.208,1.106-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.225,0.731,0.475,1.164,0.75c0.432,0.276,0.995,0.526,1.688,0.75c0.692,0.225,1.388,0.336,2.086,0.336
c0.698,0,1.393-0.111,2.086-0.336c0.692-0.224,1.255-0.474,1.688-0.75c0.432-0.275,0.82-0.525,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.216,0.104,1.742,0.312c0.525,0.209,0.971,0.487,1.336,0.836
c0.364,0.35,0.688,0.771,0.969,1.266c0.281,0.495,0.505,1.003,0.672,1.523c0.166,0.521,0.305,1.086,0.414,1.695
s0.182,1.178,0.219,1.703c0.036,0.526,0.055,1.065,0.055,1.617c0,1.25-0.381,2.237-1.141,2.961
c-0.761,0.724-1.771,1.086-3.031,1.086H59.171c-1.261,0-2.271-0.362-3.031-1.086C55.379,108.28,54.999,107.293,54.999,106.043z
M61.757,96.332c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S62.929,97.504,61.757,96.332z"/>
</g>
<g>
<path fill="#00A651" d="M141.867,57.118c0-0.552,0.018-1.091,0.055-1.617c0.035-0.526,0.109-1.094,0.219-1.703
s0.246-1.174,0.414-1.695c0.166-0.521,0.391-1.028,0.672-1.523c0.281-0.495,0.604-0.917,0.969-1.266
c0.363-0.349,0.809-0.627,1.336-0.836c0.525-0.208,1.105-0.312,1.742-0.312c0.094,0,0.312,0.112,0.656,0.336
c0.344,0.224,0.73,0.474,1.164,0.75c0.432,0.276,0.994,0.526,1.688,0.75c0.691,0.224,1.387,0.336,2.086,0.336
c0.697,0,1.393-0.112,2.086-0.336c0.691-0.224,1.254-0.474,1.688-0.75c0.432-0.276,0.82-0.526,1.164-0.75
c0.344-0.224,0.562-0.336,0.656-0.336c0.635,0,1.215,0.104,1.742,0.312c0.525,0.208,0.971,0.487,1.336,0.836
c0.363,0.349,0.688,0.771,0.969,1.266c0.281,0.495,0.504,1.003,0.672,1.523c0.166,0.521,0.305,1.086,0.414,1.695
s0.182,1.177,0.219,1.703c0.035,0.526,0.055,1.065,0.055,1.617c0,1.25-0.381,2.237-1.141,2.961
c-0.762,0.724-1.771,1.086-3.031,1.086h-13.656c-1.262,0-2.271-0.362-3.031-1.086C142.246,59.354,141.867,58.368,141.867,57.118z
M148.625,47.407c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758
s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758S149.797,48.579,148.625,47.407z"/>
</g>
<g>
<path fill="#00A651" d="M132.005,102.458c0-0.552,0.019-1.091,0.055-1.617s0.109-1.094,0.219-1.703s0.247-1.175,0.414-1.695
s0.391-1.028,0.672-1.523s0.604-0.917,0.969-1.266s0.81-0.628,1.336-0.836s1.106-0.312,1.742-0.312
c0.094,0,0.312,0.112,0.656,0.336s0.731,0.474,1.164,0.75s0.995,0.526,1.688,0.75s1.388,0.336,2.086,0.336s1.394-0.112,2.086-0.336
s1.255-0.474,1.688-0.75s0.82-0.526,1.164-0.75s0.562-0.336,0.656-0.336c0.636,0,1.216,0.104,1.742,0.312s0.972,0.487,1.336,0.836
s0.688,0.771,0.969,1.266s0.505,1.003,0.672,1.523s0.305,1.086,0.414,1.695s0.183,1.177,0.219,1.703s0.055,1.065,0.055,1.617
c0,1.25-0.38,2.236-1.141,2.961c-0.761,0.724-1.771,1.086-3.031,1.086h-13.656c-1.261,0-2.271-0.362-3.031-1.086
C132.385,104.694,132.005,103.708,132.005,102.458z M138.763,92.747c-1.172-1.172-1.758-2.586-1.758-4.242s0.586-3.07,1.758-4.242
s2.586-1.758,4.242-1.758s3.07,0.586,4.242,1.758s1.758,2.586,1.758,4.242s-0.586,3.07-1.758,4.242s-2.586,1.758-4.242,1.758
S139.935,93.919,138.763,92.747z"/>
</g>
</svg>
</div>
<h4>Teamwork Optimized</h4>
<p>Control how the different teams and projects within your enterprise collaborate on repositories.</p>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 feature-desc">
<div class="feature-illustration">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
preserveAspectRatio="xMinYMin" width="100px" viewBox="0 0 144 144" enable-background="new 0 0 144 144" xml:space="preserve">
<path d="M103.5,123.357V110.5c0-0.595,0.208-1.101,0.625-1.518c0.416-0.416,0.922-0.625,1.518-0.625h0.715v-4.286
c0-2.737,0.982-5.089,2.946-7.053c1.964-1.965,4.315-2.947,7.054-2.947c2.737,0,5.089,0.982,7.054,2.947
c1.964,1.964,2.946,4.315,2.946,7.053v4.286h0.714c0.595,0,1.102,0.209,1.518,0.625c0.417,0.417,0.625,0.923,0.625,1.518v12.857
c0,0.596-0.208,1.102-0.625,1.518c-0.416,0.417-0.923,0.625-1.518,0.625h-21.429c-0.596,0-1.102-0.208-1.518-0.625
C103.708,124.459,103.5,123.953,103.5,123.357z M110.643,108.357h11.429v-4.286c0-1.577-0.558-2.924-1.674-4.04
s-2.463-1.674-4.04-1.674c-1.578,0-2.924,0.558-4.041,1.674c-1.115,1.116-1.674,2.463-1.674,4.04V108.357z"/>
<polyline fill="none" stroke="#000000" stroke-width="2" stroke-miterlimit="10" points="99.889,122.5 21.5,122.5 21.5,20.5
123.5,20.5 123.5,92.469 "/>
<rect x="32.214" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="92.63" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="62.422" y="61.422" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="32.214" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="92.63" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="62.422" y="31.874" fill="#4CA0D8" width="20.156" height="20.156"/>
<rect x="32.214" y="91.909" fill="#4CA0D8" width="20.156" height="20.156"/>
<g>
<path fill="#4CA0D8" d="M101.579,106.437c0.361-0.361,0.756-0.666,1.179-0.913v-1.452c0-3.714,1.346-6.943,4-9.599
c1.073-1.073,2.244-1.921,3.499-2.564H92.63v20.156H99.9V110.5C99.9,108.94,100.48,107.535,101.579,106.437z"/>
</g>
<rect x="62.422" y="91.909" fill="#4CA0D8" width="20.156" height="20.156"/>
</svg>
</div>
<h4>Secure, Private Storage</h4>
<p>Containers often contain keys and passwords &mdash; take control of your registry by running it behind your firewall on <a href="https://coreos.com/products/managed-linux" data-category="Products: Managed Linux" data-event="Product: Managed Linux">CoreOS Managed Linux</a>.</p>
</div> </div>
</div> </div>
<div class="row" style="text-align: center; margin-bottom: 20px;">
<a href="https://coreos.com/products/enterprise-registry" class="btn btn-primary">Learn more about Enterprise Registry</a>
</div> </div>
<div class="tour-section row">
<div class="col-sm-3 col-sm-push-9 enterprise-icon">
<span class="fa-stack fa-5x">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-bar-chart-o fa-stack-1x fa-inverse"></i>
</span>
</div>
<div class="col-sm-9 col-sm-pull-3">
<div class="tour-section-title">Auditing and insight done right</div>
<div class="tour-section-description">
Our platform has built in logging and insight tools, allowing you to see who performed every action on the contents and access control settings of your repositories. This will help you pass internal auditing and compliance requirements more easily.
</div>
</div>
</div>
<div class="tour-section row">
<div class="col-sm-3 enterprise-icon">
<span class="fa-stack fa-5x">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-users fa-stack-1x fa-inverse"></i>
</span>
</div>
<div class="col-sm-9">
<div class="tour-section-title">Organize your repositories like you organize your business</div>
<div class="tour-section-description">
The built in teams and organizations features of <span class="registry-name"></span> allow you to finely control how the different teams and projects within your enterprise collaborate on repositories.
</div>
</div>
</div>
<div class="tour-action">
<a href="mailto:support@devtable.com?Subject=Enterprise%20Quay.io%20Inquiry" data-title="Email us to find out more." bs-tooltip="tooltip.title">
<button class="btn btn-success">
Contact Us
</button>
</a>
</div>
</div> </div>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="325.68px" height="112.574px" viewBox="0 0 325.68 112.574" enable-background="new 0 0 325.68 112.574"
xml:space="preserve">
<path d="M95.701,60.164c0,27.658,9.385,30.316,24.336,30.316c14.95-0.083,24.502-3.488,24.502-30.316
c0-28.489-10.133-31.229-24.502-31.229C105.916,28.934,95.701,31.675,95.701,60.164 M107.329,60.164
c0-19.352,3.738-20.515,12.708-20.515c9.22,0,12.874,1.578,12.874,20.515c0,16.363-3.073,19.602-12.874,19.602
C109.654,79.766,107.329,76.775,107.329,60.164z M116.465,92.889c0.665,10.632,11.711,13.538,20.1,11.711v-9.385
c-4.9,1.329-10.881,2.076-13.538-2.326H116.465z"/>
<path d="M150.438,75.364c0,8.472,2.907,15.532,13.123,15.532c6.727,0,9.635-2.824,14.037-5.399l2.243,4.651h9.386V46.044h-11.628
v31.645c-3.405,2.658-5.98,3.821-10.631,3.821c-2.907,0-4.901-1.495-4.901-5.897V46.044h-11.628V75.364z"/>
<path d="M195.457,78.935c0,5.731,3.987,11.462,12.459,11.711c6.728,0.166,12.957-4.402,13.455-5.316l2.409,4.818h9.22V59.417
c0-10.964-6.479-14.203-15.947-14.203c-8.223,0-14.286,0.665-20.1,2.907v7.226c3.571-0.249,14.618-0.914,18.522-0.914
c3.654,0,5.897,0.665,5.897,5.399c0.083,0.083,0,2.824,0,2.824c0,0.083-12.957,0-12.957,0c-9.469,0-12.957,5.648-12.957,12.127
V78.935z M207.085,77.274v-2.16c0-1.91,0.499-4.236,3.572-4.485l10.714-0.831v8.057c0,0-5.316,3.488-10.133,3.488
C209.078,81.344,207.085,80.929,207.085,77.274z"/>
<path d="M236.573,46.044l8.638,32.891c2.159,8.14,6.977,11.213,13.123,11.213l-6.229,22.426h8.306l6.063-8.472l15.283-58.058H269.63
l-8.804,34.137c-0.664-0.083-3.489,0.249-4.402-3.239l-7.641-30.898H236.573z"/>
<path d="M283.668,89.321c0,0.621,0.443,1.021,1.065,1.021h3.815c0.621,0,1.109-0.222,1.109-1.021v-3.992
c0-0.71-0.311-1.109-1.109-1.109h-3.815c-0.71,0-1.065,0.444-1.065,1.109V89.321z"/>
<path d="M294.138,63.281c0,0.621,0.444,1.021,1.065,1.021h4.658c0.621,0,0.976-0.355,0.976-1.021v-3.904
c0-0.621-0.311-1.153-0.976-1.153h-4.658c-0.71,0-1.065,0.444-1.065,1.153V63.281z M294.36,90.342h6.211V66.785h-6.211V90.342z"/>
<path d="M304.208,78.896c0,8.429,2.617,11.667,10.736,11.667c8.251,0,10.735-2.928,10.735-11.667
c0-9.361-2.617-12.333-10.735-12.333C307.003,66.563,304.208,69.27,304.208,78.896 M310.419,78.896c0-6.61,0.843-7.364,4.525-7.364
c3.726,0,4.525,0.754,4.525,7.364c0,6.21-1.02,6.654-4.525,6.654C311.661,85.55,310.419,84.974,310.419,78.896z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.505,0c13.241,0,24.037,13.64,24.588,30.737h-8.876
c0-11.805-7.035-21.374-15.712-21.374c-8.678,0-15.713,9.569-15.713,21.374h-8.876C14.467,13.64,25.264,0,38.505,0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.504,57.156c2.483,0,4.495,2.013,4.495,4.495c0,0.946-0.292,1.823-0.791,2.547
l2.294,12.511H32.507l2.294-12.511c-0.499-0.724-0.791-1.601-0.791-2.547C34.009,59.168,36.022,57.156,38.504,57.156"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.394,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394H2.394C1.077,99.063,0,97.986,0,96.669V37.195C0,35.878,1.077,34.801,2.394,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.314,34.801h4.724c1.316,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V37.195C13.92,35.878,14.997,34.801,16.314,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.183,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394V51.96
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V37.195C26.789,35.878,27.866,34.801,29.183,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.103,34.801h4.724c1.316,0,2.393,1.077,2.393,2.394V51.96
c0,1.317-1.077,2.394-2.393,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V37.195C40.709,35.878,41.786,34.801,43.103,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.971,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.316,0-2.393-1.077-2.393-2.394V37.195C53.578,35.878,54.655,34.801,55.971,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.891,34.801h4.724c1.317,0,2.394,1.077,2.394,2.394v59.474
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V37.195C67.498,35.878,68.575,34.801,69.891,34.801"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.183,79.51h4.724c1.317,0,2.394,1.077,2.394,2.394V96.67
c0,1.317-1.077,2.394-2.394,2.394h-4.724c-1.317,0-2.394-1.077-2.394-2.394V81.904C26.789,80.588,27.866,79.51,29.183,79.51"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.103,79.51h4.724c1.316,0,2.393,1.077,2.393,2.394V96.67
c0,1.317-1.077,2.394-2.393,2.394h-4.724c-1.316,0-2.394-1.077-2.394-2.394V81.904C40.709,80.588,41.786,79.51,43.103,79.51"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="792px" height="306px" viewBox="0 0 792 306" enable-background="new 0 0 792 306" xml:space="preserve">
<g>
<g>
<g>
<path fill="#FFFFFF" d="M136.168,45.527C76.898,45.527,28.689,93.739,28.689,153c0,59.265,48.209,107.474,107.479,107.474
c59.252,0,107.465-48.209,107.465-107.474C243.633,93.739,195.42,45.527,136.168,45.527z M176.542,180.428
c-0.889,0.129-1.767,0.254-2.659,0.368c-8.111,1.049-16.77,1.761-25.819,2.099c-3.895,0.145-7.872,0.233-11.895,0.233
c-4.035,0-8-0.087-11.907-0.233c-9.038-0.338-17.703-1.05-25.807-2.099c-0.723-6.047-1.213-12.339-1.458-18.811
c-0.117-2.956-0.175-5.956-0.175-8.985c0-3.032,0.058-6.026,0.175-8.988c0.245-6.473,0.735-12.761,1.458-18.808
c0.145-1.259,0.309-2.51,0.478-3.746c5.294-38.424,19.952-66.069,37.235-66.069c53.903,0,97.608,43.699,97.609,97.611
C233.777,165.196,210.296,175.688,176.542,180.428z"/>
</g>
<path fill="#FFFFFF" d="M176.541,125.569c-0.979-1.428-2.029-2.796-3.148-4.11c-8.956-10.557-22.297-17.265-37.224-17.265
c-4.839,0-9.148,7.407-11.907,18.909c-1.096,4.586-1.947,9.819-2.495,15.498c-0.432,4.551-0.665,9.391-0.665,14.399
s0.233,9.849,0.665,14.396c4.554,0.432,9.387,0.664,14.402,0.664c5.009,0,9.842-0.232,14.396-0.664
c10.011-0.95,18.653-2.875,24.775-5.411c6.046-2.501,9.624-5.615,9.624-8.985C184.963,142.832,181.858,133.388,176.541,125.569z"
/>
</g>
<g>
<path fill="#FFFFFF" d="M344.891,100.053c12.585,0,22.816,6.138,29.262,13.062l-10.064,11.326
c-5.353-5.192-11.175-8.495-19.041-8.495c-16.839,0-28.953,14.16-28.953,37.291c0,23.448,11.169,37.608,28.32,37.608
c9.128,0,15.895-3.775,21.717-10.228l10.067,11.169c-8.335,9.598-19.038,14.95-32.099,14.95c-26.119,0-46.731-18.88-46.731-53.025
C297.37,120.036,318.454,100.053,344.891,100.053z"/>
<path fill="#FFFFFF" d="M416.961,125.701c19.352,0,36.822,14.793,36.822,40.597c0,25.647-17.471,40.439-36.822,40.439
c-19.197,0-36.66-14.792-36.66-40.439C380.301,140.494,397.764,125.701,416.961,125.701z M416.961,191.945
c11.33,0,18.25-10.228,18.25-25.647c0-15.577-6.92-25.804-18.25-25.804s-18.094,10.227-18.094,25.804
C398.867,181.717,405.631,191.945,416.961,191.945z"/>
<path fill="#FFFFFF" d="M459.771,127.589h14.943l1.26,13.688h0.629c5.506-10.07,13.691-15.577,21.871-15.577
c3.938,0,6.455,0.472,8.811,1.574l-3.148,15.734c-2.67-0.784-4.717-1.257-8.018-1.257c-6.139,0-13.539,4.245-18.256,15.893v47.203
h-18.092L459.771,127.589L459.771,127.589z"/>
<path fill="#FFFFFF" d="M541.121,125.701c20.928,0,31.941,15.107,31.941,36.667c0,3.458-0.314,6.604-0.787,8.495h-49.09
c1.57,14.003,10.379,21.869,22.811,21.869c6.613,0,12.273-2.041,17.941-5.662l6.135,11.326
c-7.395,4.878-16.676,8.341-26.432,8.341c-21.404,0-38.08-14.95-38.08-40.439C505.561,141.12,523.023,125.701,541.121,125.701z
M557.326,159.376c0-12.277-5.189-19.671-15.732-19.671c-9.125,0-16.996,6.768-18.57,19.671H557.326z"/>
<path fill="#FFFFFF" d="M600.602,152.607c0-32.729,17.785-53.344,42.799-53.344c24.863,0,42.641,20.615,42.641,53.344
c0,32.889-17.777,54.13-42.641,54.13C618.387,206.737,600.602,185.496,600.602,152.607z M678.49,152.607
c0-28.639-14.158-46.731-35.09-46.731c-21.084,0-35.248,18.093-35.248,46.731c0,28.796,14.164,47.521,35.248,47.521
C664.332,200.128,678.49,181.403,678.49,152.607z"/>
<path fill="#FFFFFF" d="M699.738,186.125c7.557,8.495,18.412,14.003,30.529,14.003c15.732,0,25.807-8.499,25.807-20.767
c0-12.904-8.494-17.154-18.723-21.717l-15.736-7.082c-8.969-3.936-20.934-10.385-20.934-25.808
c0-14.947,12.904-25.492,30.059-25.492c12.588,0,22.658,5.665,28.949,12.435l-4.244,4.878c-5.982-6.452-14.32-10.7-24.705-10.7
c-13.691,0-22.816,7.239-22.816,18.565c0,11.962,10.385,16.521,17.936,19.985l15.738,6.921
c11.486,5.195,21.713,11.647,21.713,27.539s-13.061,27.851-33.201,27.851c-15.107,0-26.75-6.451-34.932-15.576L699.738,186.125z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

49
static/img/coreos.svg Normal file
View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="792px" height="306px" viewBox="0 0 792 306" enable-background="new 0 0 792 306" xml:space="preserve">
<g>
<g>
<path fill="#53A3DA" d="M136.168,45.527C76.898,45.527,28.689,93.739,28.689,153c0,59.265,48.209,107.474,107.479,107.474
c59.252,0,107.465-48.209,107.465-107.474C243.633,93.739,195.42,45.527,136.168,45.527z"/>
<path fill="#F1606D" d="M136.168,55.389c-17.283,0-31.941,27.645-37.235,66.069c-0.169,1.236-0.333,2.487-0.478,3.746
c-0.723,6.047-1.213,12.335-1.458,18.808c-0.117,2.962-0.175,5.956-0.175,8.988c0,3.029,0.058,6.029,0.175,8.985
c0.245,6.472,0.735,12.764,1.458,18.811c8.104,1.049,16.769,1.761,25.807,2.099c3.907,0.146,7.872,0.233,11.907,0.233
c4.023,0,8-0.088,11.895-0.233c9.049-0.338,17.708-1.05,25.819-2.099c0.892-0.114,1.77-0.239,2.659-0.368
c33.754-4.74,57.235-15.232,57.235-27.428C233.776,99.088,190.071,55.389,136.168,55.389z"/>
<path fill="#FFFFFF" d="M176.541,125.569c-0.979-1.428-2.029-2.796-3.148-4.11c-8.956-10.557-22.297-17.265-37.224-17.265
c-4.839,0-9.148,7.407-11.907,18.909c-1.096,4.586-1.947,9.819-2.495,15.498c-0.432,4.551-0.665,9.391-0.665,14.399
s0.233,9.849,0.665,14.396c4.554,0.432,9.387,0.664,14.402,0.664c5.009,0,9.842-0.232,14.396-0.664
c10.011-0.95,18.653-2.875,24.775-5.411c6.046-2.501,9.624-5.615,9.624-8.985C184.963,142.832,181.858,133.388,176.541,125.569z"
/>
</g>
<g>
<path fill="#231F20" d="M344.891,100.053c12.585,0,22.816,6.138,29.262,13.062l-10.064,11.326
c-5.353-5.192-11.175-8.495-19.041-8.495c-16.839,0-28.953,14.16-28.953,37.291c0,23.448,11.169,37.608,28.32,37.608
c9.128,0,15.895-3.775,21.717-10.228l10.067,11.169c-8.335,9.598-19.038,14.95-32.099,14.95c-26.119,0-46.731-18.88-46.731-53.025
C297.37,120.036,318.454,100.053,344.891,100.053z"/>
<path fill="#231F20" d="M416.961,125.701c19.352,0,36.822,14.793,36.822,40.597c0,25.647-17.471,40.439-36.822,40.439
c-19.197,0-36.66-14.792-36.66-40.439C380.301,140.494,397.764,125.701,416.961,125.701z M416.961,191.945
c11.33,0,18.25-10.228,18.25-25.647c0-15.577-6.92-25.804-18.25-25.804s-18.094,10.227-18.094,25.804
C398.867,181.717,405.631,191.945,416.961,191.945z"/>
<path fill="#231F20" d="M459.771,127.589h14.943l1.26,13.688h0.629c5.506-10.07,13.691-15.577,21.871-15.577
c3.938,0,6.455,0.472,8.811,1.574l-3.148,15.734c-2.67-0.784-4.717-1.257-8.018-1.257c-6.139,0-13.539,4.245-18.256,15.893v47.203
h-18.092V127.589z"/>
<path fill="#231F20" d="M541.121,125.701c20.928,0,31.941,15.107,31.941,36.667c0,3.458-0.314,6.604-0.787,8.495h-49.09
c1.57,14.003,10.379,21.869,22.811,21.869c6.613,0,12.273-2.041,17.941-5.662l6.135,11.326
c-7.395,4.878-16.676,8.341-26.432,8.341c-21.404,0-38.08-14.95-38.08-40.439C505.561,141.12,523.023,125.701,541.121,125.701z
M557.326,159.376c0-12.277-5.189-19.671-15.732-19.671c-9.125,0-16.996,6.768-18.57,19.671H557.326z"/>
<path fill="#F1606D" d="M600.602,152.607c0-32.729,17.785-53.344,42.799-53.344c24.863,0,42.641,20.615,42.641,53.344
c0,32.889-17.777,54.13-42.641,54.13C618.387,206.737,600.602,185.496,600.602,152.607z M678.49,152.607
c0-28.639-14.158-46.731-35.09-46.731c-21.084,0-35.248,18.093-35.248,46.731c0,28.796,14.164,47.521,35.248,47.521
C664.332,200.128,678.49,181.403,678.49,152.607z"/>
<path fill="#53A4D9" d="M699.738,186.125c7.557,8.495,18.412,14.003,30.529,14.003c15.732,0,25.807-8.499,25.807-20.767
c0-12.904-8.494-17.154-18.723-21.717l-15.736-7.082c-8.969-3.936-20.934-10.385-20.934-25.808
c0-14.947,12.904-25.492,30.059-25.492c12.588,0,22.658,5.665,28.949,12.435l-4.244,4.878c-5.982-6.452-14.32-10.7-24.705-10.7
c-13.691,0-22.816,7.239-22.816,18.565c0,11.962,10.385,16.521,17.936,19.985l15.738,6.921
c11.486,5.195,21.713,11.647,21.713,27.539s-13.061,27.851-33.201,27.851c-15.107,0-26.75-6.451-34.932-15.576L699.738,186.125z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/img/image-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
static/img/network-tile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -1,6 +1,46 @@
var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$'; var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$';
var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$'; var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$';
$.fn.clipboardCopy = function() {
if (zeroClipboardSupported) {
(new ZeroClipboard($(this)));
return true;
}
this.hide();
return false;
};
var zeroClipboardSupported = true;
ZeroClipboard.config({
'swfPath': 'static/lib/ZeroClipboard.swf'
});
ZeroClipboard.on("error", function(e) {
zeroClipboardSupported = false;
});
ZeroClipboard.on('aftercopy', function(e) {
var container = e.target.parentNode.parentNode.parentNode;
var message = $(container).find('.clipboard-copied-message')[0];
// Resets the animation.
var elem = message;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(function() {
elem.style.display = 'none';
}, 5000);
});
function getRestUrl(args) { function getRestUrl(args) {
var url = ''; var url = '';
for (var i = 0; i < arguments.length; ++i) { for (var i = 0; i < arguments.length; ++i) {
@ -59,18 +99,8 @@ function getFirstTextLine(commentString) {
} }
function createRobotAccount(ApiService, is_org, orgname, name, callback) { function createRobotAccount(ApiService, is_org, orgname, name, callback) {
ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}).then(callback, function(resp) { ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name})
bootbox.dialog({ .then(callback, ApiService.errorDisplay('Cannot create robot account'));
"message": resp.data ? resp.data['message'] : 'The robot account could not be created',
"title": "Cannot create robot account",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
} }
function createOrganizationTeam(ApiService, orgname, teamname, callback) { function createOrganizationTeam(ApiService, orgname, teamname, callback) {
@ -84,18 +114,8 @@ function createOrganizationTeam(ApiService, orgname, teamname, callback) {
'teamname': teamname 'teamname': teamname
}; };
ApiService.updateOrganizationTeam(data, params).then(callback, function(resp) { ApiService.updateOrganizationTeam(data, params)
bootbox.dialog({ .then(callback, ApiService.errorDisplay('Cannot create team'));
"message": resp.data ? resp.data : 'The team could not be created',
"title": "Cannot create team",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
} }
function getMarkedDown(string) { function getMarkedDown(string) {
@ -121,7 +141,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
* pauses in the UI for ngRepeat's when the array is significant in size. * pauses in the UI for ngRepeat's when the array is significant in size.
*/ */
$provide.factory('AngularViewArray', ['$interval', function($interval) { $provide.factory('AngularViewArray', ['$interval', function($interval) {
var ADDTIONAL_COUNT = 50; var ADDTIONAL_COUNT = 20;
function _ViewArray() { function _ViewArray() {
this.isVisible = false; this.isVisible = false;
@ -364,7 +384,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
var uiService = {}; var uiService = {};
uiService.hidePopover = function(elem) { uiService.hidePopover = function(elem) {
var popover = $('#signupButton').data('bs.popover'); var popover = $(elem).data('bs.popover');
if (popover) { if (popover) {
popover.hide(); popover.hide();
} }
@ -399,14 +419,18 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
$provide.factory('UtilService', ['$sanitize', function($sanitize) { $provide.factory('UtilService', ['$sanitize', function($sanitize) {
var utilService = {}; var utilService = {};
utilService.textToSafeHtml = function(text) { utilService.escapeHtmlString = function(text) {
var adjusted = text.replace(/&/g, "&amp;") var adjusted = text.replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
return $sanitize(adjusted); return adjusted;
};
utilService.textToSafeHtml = function(text) {
return $sanitize(utilService.escapeHtmlString(text));
}; };
return utilService; return utilService;
@ -417,6 +441,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
var pingService = {}; var pingService = {};
var pingCache = {}; var pingCache = {};
var invokeCallback = function($scope, pings, callback) {
if (pings[0] == -1) {
setTimeout(function() {
$scope.$apply(function() {
callback(-1, false, -1);
});
}, 0);
return;
}
var sum = 0;
for (var i = 0; i < pings.length; ++i) {
sum += pings[i];
}
// Report the average ping.
setTimeout(function() {
$scope.$apply(function() {
callback(Math.floor(sum / pings.length), true, pings.length);
});
}, 0);
};
var reportPingResult = function($scope, url, ping, callback) { var reportPingResult = function($scope, url, ping, callback) {
// Lookup the cached ping data, if any. // Lookup the cached ping data, if any.
var cached = pingCache[url]; var cached = pingCache[url];
@ -429,28 +476,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
// If an error occurred, report it and done. // If an error occurred, report it and done.
if (ping < 0) { if (ping < 0) {
cached['pings'] = [-1]; cached['pings'] = [-1];
setTimeout(function() { invokeCallback($scope, pings, callback);
$scope.$apply(function() {
callback(-1, false, -1);
});
}, 0);
return; return;
} }
// Otherwise, add the current ping and determine the average. // Otherwise, add the current ping and determine the average.
cached['pings'].push(ping); cached['pings'].push(ping);
var sum = 0; // Invoke the callback.
for (var i = 0; i < cached['pings'].length; ++i) { invokeCallback($scope, cached['pings'], callback);
sum += cached['pings'][i];
}
// Report the average ping.
setTimeout(function() {
$scope.$apply(function() {
callback(Math.floor(sum / cached['pings'].length), true, cached['pings'].length);
});
}, 0);
// Schedule another check if we've done less than three. // Schedule another check if we've done less than three.
if (cached['pings'].length < 3) { if (cached['pings'].length < 3) {
@ -486,12 +520,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
pingService.pingUrl = function($scope, url, callback) { pingService.pingUrl = function($scope, url, callback) {
if (pingCache[url]) { if (pingCache[url]) {
cached = pingCache[url]; invokeCallback($scope, pingCache[url]['pings'], callback);
setTimeout(function() {
$scope.$apply(function() {
callback(cached.result, cached.success);
});
}, 0);
return; return;
} }
@ -526,7 +555,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return builderService; return builderService;
}]); }]);
$provide.factory('StringBuilderService', ['$sce', function($sce) { $provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) {
var stringBuilderService = {}; var stringBuilderService = {};
stringBuilderService.buildString = function(value_or_func, metadata) { stringBuilderService.buildString = function(value_or_func, metadata) {
@ -581,6 +610,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
if (key.indexOf('image') >= 0) { if (key.indexOf('image') >= 0) {
value = value.substr(0, 12); value = value.substr(0, 12);
} }
var safe = UtilService.escapeHtmlString(value);
var markedDown = getMarkedDown(value); var markedDown = getMarkedDown(value);
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length); markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
@ -589,7 +620,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
markedDown = '<i class="fa fa-' + icon + '"></i>' + markedDown; markedDown = '<i class="fa fa-' + icon + '"></i>' + markedDown;
} }
description = description.replace('{' + key + '}', '<code>' + markedDown + '</code>'); description = description.replace('{' + key + '}', '<code title="' + safe + '">' + markedDown + '</code>');
} }
} }
return $sce.trustAsHtml(description.replace('\n', '<br>')); return $sce.trustAsHtml(description.replace('\n', '<br>'));
@ -682,7 +713,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return config; return config;
}]); }]);
$provide.factory('ApiService', ['Restangular', function(Restangular) { $provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) {
var apiService = {}; var apiService = {};
var getResource = function(path, opt_background) { var getResource = function(path, opt_background) {
@ -779,6 +810,65 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
} }
}; };
var freshLoginFailCheck = function(opName, opArgs) {
return function(resp) {
var deferred = $q.defer();
// If the error is a fresh login required, show the dialog.
if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') {
bootbox.dialog({
"message": 'It has been more than a few minutes since you last logged in, ' +
'so please verify your password to perform this sensitive operation:' +
'<form style="margin-top: 10px" action="javascript:void(0)">' +
'<input id="freshPassword" class="form-control" type="password" placeholder="Current Password">' +
'</form>',
"title": 'Please Verify',
"buttons": {
"verify": {
"label": "Verify",
"className": "btn-success",
"callback": function() {
var info = {
'password': $('#freshPassword').val()
};
$('#freshPassword').val('');
// Conduct the sign in of the user.
apiService.verifyUser(info).then(function() {
// On success, retry the operation. if it succeeds, then resolve the
// deferred promise with the result. Otherwise, reject the same.
apiService[opName].apply(apiService, opArgs).then(function(resp) {
deferred.resolve(resp);
}, function(resp) {
deferred.reject(resp);
});
}, function(resp) {
// Reject with the sign in error.
deferred.reject({'data': {'message': 'Invalid verification credentials'}});
});
}
},
"close": {
"label": "Cancel",
"className": "btn-default",
"callback": function() {
deferred.reject(resp);
}
}
}
});
// Return a new promise. We'll accept or reject it based on the result
// of the login.
return deferred.promise;
}
// Otherwise, we just 'raise' the error via the reject method on the promise.
return $q.reject(resp);
};
};
var buildMethodsForOperation = function(operation, resource, resourceMap) { var buildMethodsForOperation = function(operation, resource, resourceMap) {
var method = operation['method'].toLowerCase(); var method = operation['method'].toLowerCase();
var operationName = operation['nickname']; var operationName = operation['nickname'];
@ -792,7 +882,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'ignoreLoadingBar': true 'ignoreLoadingBar': true
}); });
} }
return one['custom' + method.toUpperCase()](opt_options);
var opObj = one['custom' + method.toUpperCase()](opt_options);
// If the operation requires_fresh_login, then add a specialized error handler that
// will defer the operation's result if sudo is requested.
if (operation['requires_fresh_login']) {
opObj = opObj.catch(freshLoginFailCheck(operationName, arguments));
}
return opObj;
}; };
// If the method for the operation is a GET, add an operationAsResource method. // If the method for the operation is a GET, add an operationAsResource method.
@ -841,6 +939,38 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
buildMethodsForEndpointResource(endpointResource, resourceMap); buildMethodsForEndpointResource(endpointResource, resourceMap);
} }
apiService.getErrorMessage = function(resp, defaultMessage) {
var message = defaultMessage;
if (resp['data']) {
message = resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message;
}
return message;
};
apiService.errorDisplay = function(defaultMessage, opt_handler) {
return function(resp) {
var message = apiService.getErrorMessage(resp, defaultMessage);
if (opt_handler) {
var handlerMessage = opt_handler(resp);
if (handlerMessage) {
message = handlerMessage;
}
}
bootbox.dialog({
"message": message,
"title": defaultMessage,
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
};
};
return apiService; return apiService;
}]); }]);
@ -1097,7 +1227,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'user': null, 'user': null,
'notifications': [], 'notifications': [],
'notificationClasses': [], 'notificationClasses': [],
'notificationSummaries': [] 'notificationSummaries': [],
'additionalNotifications': false
}; };
var pollTimerHandle = null; var pollTimerHandle = null;
@ -1193,7 +1324,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
'uuid': notification.id 'uuid': notification.id
}; };
ApiService.updateUserNotification(notification, params); ApiService.updateUserNotification(notification, params, function() {
notificationService.update();
}, ApiService.errorDisplay('Could not update notification'));
var index = $.inArray(notification, notificationService.notifications); var index = $.inArray(notification, notificationService.notifications);
if (index >= 0) { if (index >= 0) {
@ -1250,6 +1383,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
ApiService.listUserNotifications().then(function(resp) { ApiService.listUserNotifications().then(function(resp) {
notificationService.notifications = resp['notifications']; notificationService.notifications = resp['notifications'];
notificationService.additionalNotifications = resp['additional'];
notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications);
}); });
}; };
@ -1512,7 +1646,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
}); });
}; };
planService.changePlan = function($scope, orgname, planId, callbacks) { planService.changePlan = function($scope, orgname, planId, callbacks, opt_async) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
if (callbacks['started']) { if (callbacks['started']) {
@ -1525,7 +1659,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
planService.getCardInfo(orgname, function(cardInfo) { planService.getCardInfo(orgname, function(cardInfo) {
if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) { if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) {
var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)'; var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)';
planService.showSubscribeDialog($scope, orgname, planId, callbacks, title); planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true);
return; return;
} }
@ -1598,9 +1732,34 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
return email; return email;
}; };
planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title) { planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) {
if (!Features.BILLING) { return; } if (!Features.BILLING) { return; }
// If the async parameter is true and this is a browser that does not allow async popup of the
// Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead.
var isIE = navigator.appName.indexOf("Internet Explorer") != -1;
var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/);
if (opt_async && (isIE || isMobileSafari)) {
bootbox.dialog({
"message": "Please click 'Subscribe' to continue",
"buttons": {
"subscribe": {
"label": "Subscribe",
"className": "btn-primary",
"callback": function() {
planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false);
}
},
"close": {
"label": "Cancel",
"className": "btn-default"
}
}
});
return;
}
if (callbacks['opening']) { if (callbacks['opening']) {
callbacks['opening'](); callbacks['opening']();
} }
@ -1693,7 +1852,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
when('/repository/:namespace/:name/build', {templateUrl: '/static/partials/repo-build.html', controller:RepoBuildCtrl, reloadOnSearch: false}). when('/repository/:namespace/:name/build', {templateUrl: '/static/partials/repo-build.html', controller:RepoBuildCtrl, reloadOnSearch: false}).
when('/repository/:namespace/:name/build/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}). when('/repository/:namespace/:name/build/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}).
when('/repository/', {title: 'Repositories', description: 'Public and private docker repositories list', when('/repository/', {title: 'Repositories', description: 'Public and private docker repositories list',
templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}). templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl, reloadOnSearch: false}).
when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html', when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html',
reloadOnSearch: false, controller: UserAdminCtrl}). reloadOnSearch: false, controller: UserAdminCtrl}).
when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html', when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html',
@ -1819,6 +1978,26 @@ quayApp.directive('quayShow', function($animate, Features, Config) {
}); });
quayApp.directive('quaySection', function($animate, $location, $rootScope) {
return {
priority: 590,
restrict: 'A',
link: function($scope, $element, $attr, ctrl, $transclude) {
var update = function() {
var result = $location.path().indexOf('/' + $attr.quaySection) == 0;
$animate[!result ? 'removeClass' : 'addClass']($element, 'active');
};
$scope.$watch(function(){
return $location.path();
}, update);
$scope.$watch($attr.quaySection, update);
}
};
});
quayApp.directive('quayClasses', function(Features, Config) { quayApp.directive('quayClasses', function(Features, Config) {
return { return {
priority: 580, priority: 580,
@ -2018,18 +2197,7 @@ quayApp.directive('applicationReference', function () {
template: '/static/directives/application-reference-dialog.html', template: '/static/directives/application-reference-dialog.html',
show: true show: true
}); });
}, function() { }, ApiService.errorDisplay('Application could not be found'));
bootbox.dialog({
"message": 'The application could not be found; it might have been deleted.',
"title": "Cannot find application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
} }
}; };
@ -2110,6 +2278,8 @@ quayApp.directive('copyBox', function () {
'hoveringMessage': '=hoveringMessage' 'hoveringMessage': '=hoveringMessage'
}, },
controller: function($scope, $element, $rootScope) { controller: function($scope, $element, $rootScope) {
$scope.disabled = false;
var number = $rootScope.__copyBoxIdCounter || 0; var number = $rootScope.__copyBoxIdCounter || 0;
$rootScope.__copyBoxIdCounter = number + 1; $rootScope.__copyBoxIdCounter = number + 1;
$scope.inputId = "copy-box-input-" + number; $scope.inputId = "copy-box-input-" + number;
@ -2119,27 +2289,7 @@ quayApp.directive('copyBox', function () {
input.attr('id', $scope.inputId); input.attr('id', $scope.inputId);
button.attr('data-clipboard-target', $scope.inputId); button.attr('data-clipboard-target', $scope.inputId);
$scope.disabled = !button.clipboardCopy();
var clip = new ZeroClipboard($(button), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
clip.on('complete', function(e) {
var message = $(this.parentNode.parentNode.parentNode).find('.clipboard-copied-message')[0];
// Resets the animation.
var elem = message;
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
// Reset the notification.
setTimeout(function() {
elem.style.display = 'none';
}, 5000);
});
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;
@ -2194,7 +2344,7 @@ quayApp.directive('externalLoginButton', function () {
'provider': '@provider', 'provider': '@provider',
'action': '@action' 'action': '@action'
}, },
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) {
$scope.startSignin = function(service) { $scope.startSignin = function(service) {
$scope.signInStarted({'service': service}); $scope.signInStarted({'service': service});
@ -2228,15 +2378,39 @@ quayApp.directive('signinForm', function () {
'signInStarted': '&signInStarted', 'signInStarted': '&signInStarted',
'signedIn': '&signedIn' 'signedIn': '&signedIn'
}, },
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) {
$scope.tryAgainSoon = 0;
$scope.tryAgainInterval = null;
$scope.markStarted = function() { $scope.markStarted = function() {
if ($scope.signInStarted != null) { if ($scope.signInStarted != null) {
$scope.signInStarted(); $scope.signInStarted();
} }
}; };
$scope.cancelInterval = function() {
$scope.tryAgainSoon = 0;
if ($scope.tryAgainInterval) {
$interval.cancel($scope.tryAgainInterval);
}
$scope.tryAgainInterval = null;
};
$scope.$watch('user.username', function() {
$scope.cancelInterval();
});
$scope.$on('$destroy', function() {
$scope.cancelInterval();
});
$scope.signin = function() { $scope.signin = function() {
if ($scope.tryAgainSoon > 0) { return; }
$scope.markStarted(); $scope.markStarted();
$scope.cancelInterval();
ApiService.signinUser($scope.user).then(function() { ApiService.signinUser($scope.user).then(function() {
$scope.needsEmailVerification = false; $scope.needsEmailVerification = false;
@ -2258,8 +2432,23 @@ quayApp.directive('signinForm', function () {
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/'); $location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
}, 500); }, 500);
}, function(result) { }, function(result) {
$scope.needsEmailVerification = result.data.needsEmailVerification; if (result.status == 429 /* try again later */) {
$scope.invalidCredentials = result.data.invalidCredentials; $scope.needsEmailVerification = false;
$scope.invalidCredentials = false;
$scope.cancelInterval();
$scope.tryAgainSoon = result.headers('Retry-After');
$scope.tryAgainInterval = $interval(function() {
$scope.tryAgainSoon--;
if ($scope.tryAgainSoon <= 0) {
$scope.cancelInterval();
}
}, 1000, $scope.tryAgainSoon);
} else {
$scope.needsEmailVerification = result.data.needsEmailVerification;
$scope.invalidCredentials = result.data.invalidCredentials;
}
}); });
}; };
} }
@ -2370,11 +2559,42 @@ quayApp.directive('dockerAuthDialog', function (Config) {
'username': '=username', 'username': '=username',
'token': '=token', 'token': '=token',
'shown': '=shown', 'shown': '=shown',
'counter': '=counter' 'counter': '=counter',
'supportsRegenerate': '@supportsRegenerate',
'regenerate': '&regenerate'
}, },
controller: function($scope, $element) { controller: function($scope, $element) {
var updateCommand = function() {
var escape = function(v) {
if (!v) { return v; }
return v.replace('$', '\\$');
};
$scope.command = 'docker login -e="." -u="' + escape($scope.username) +
'" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME'];
};
$scope.$watch('username', updateCommand);
$scope.$watch('token', updateCommand);
$scope.regenerating = true;
$scope.askRegenerate = function() {
bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) {
if (resp) {
$scope.regenerating = true;
$scope.regenerate({'username': $scope.username, 'token': $scope.token});
}
});
};
$scope.isDownloadSupported = function() { $scope.isDownloadSupported = function() {
try { return !!new Blob(); } catch(e){} var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Doesn't work properly in Safari, sadly.
return false;
}
try { return !!new Blob(); } catch(e) {}
return false; return false;
}; };
@ -2392,6 +2612,8 @@ quayApp.directive('dockerAuthDialog', function (Config) {
}; };
var show = function(r) { var show = function(r) {
$scope.regenerating = false;
if (!$scope.shown || !$scope.username || !$scope.token) { if (!$scope.shown || !$scope.username || !$scope.token) {
$('#dockerauthmodal').modal('hide'); $('#dockerauthmodal').modal('hide');
return; return;
@ -2632,6 +2854,8 @@ quayApp.directive('logsView', function () {
return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}'; return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}';
}, },
'regenerate_robot_token': 'Regenerated token for robot {robot}',
// Note: These are deprecated. // Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}', 'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}' 'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -2675,6 +2899,7 @@ quayApp.directive('logsView', function () {
'reset_application_client_secret': 'Reset Client Secret', 'reset_application_client_secret': 'Reset Client Secret',
'add_repo_notification': 'Add repository notification', 'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification', 'delete_repo_notification': 'Delete repository notification',
'regenerate_robot_token': 'Regenerate Robot Token',
// Note: these are deprecated. // Note: these are deprecated.
'add_repo_webhook': 'Add webhook', 'add_repo_webhook': 'Add webhook',
@ -2801,18 +3026,7 @@ quayApp.directive('applicationManager', function () {
ApiService.createOrganizationApplication(data, params).then(function(resp) { ApiService.createOrganizationApplication(data, params).then(function(resp) {
$scope.applications.push(resp); $scope.applications.push(resp);
}, function(resp) { }, ApiService.errorDisplay('Cannot create application'));
bootbox.dialog({
"message": resp['message'] || 'The application could not be created',
"title": "Cannot create application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -2857,6 +3071,20 @@ quayApp.directive('robotsManager', function () {
$scope.shownRobot = null; $scope.shownRobot = null;
$scope.showRobotCounter = 0; $scope.showRobotCounter = 0;
$scope.regenerateToken = function(username) {
if (!username) { return; }
var shortName = $scope.getShortenedName(username);
ApiService.regenerateRobotToken($scope.organization, null, {'robot_shortname': shortName}).then(function(updated) {
var index = $scope.findRobotIndexByName(username);
if (index >= 0) {
$scope.robots.splice(index, 1);
$scope.robots.push(updated);
}
$scope.shownRobot = updated;
}, ApiService.errorDisplay('Cannot regenerate robot account token'));
};
$scope.showRobot = function(info) { $scope.showRobot = function(info) {
$scope.shownRobot = info; $scope.shownRobot = info;
$scope.showRobotCounter++; $scope.showRobotCounter++;
@ -2897,18 +3125,7 @@ quayApp.directive('robotsManager', function () {
if (index >= 0) { if (index >= 0) {
$scope.robots.splice(index, 1); $scope.robots.splice(index, 1);
} }
}, function() { }, ApiService.errorDisplay('Cannot delete robot account'));
bootbox.dialog({
"message": 'The selected robot account could not be deleted',
"title": "Cannot delete robot account",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -2973,18 +3190,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) { ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) {
prototype.role = role; prototype.role = role;
}, function(resp) { }, ApiService.errorDisplay('Cannot modify permission'));
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be modified',
"title": "Cannot modify permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.comparePrototypes = function(p) { $scope.comparePrototypes = function(p) {
@ -3024,23 +3230,16 @@ quayApp.directive('prototypeManager', function () {
data['activating_user'] = $scope.activatingForNew; data['activating_user'] = $scope.activatingForNew;
} }
var errorHandler = ApiService.errorDisplay('Cannot create permission',
function(resp) {
$('#addPermissionDialogModal').modal('hide');
});
ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) { ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) {
$scope.prototypes.push(resp); $scope.prototypes.push(resp);
$scope.loading = false; $scope.loading = false;
$('#addPermissionDialogModal').modal('hide'); $('#addPermissionDialogModal').modal('hide');
}, function(resp) { }, errorHandler);
$('#addPermissionDialogModal').modal('hide');
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be created',
"title": "Cannot create permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deletePrototype = function(prototype) { $scope.deletePrototype = function(prototype) {
@ -3054,18 +3253,7 @@ quayApp.directive('prototypeManager', function () {
ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) { ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) {
$scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1); $scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1);
$scope.loading = false; $scope.loading = false;
}, function(resp) { }, ApiService.errorDisplay('Cannot delete permission'));
bootbox.dialog({
"message": resp.data ? resp.data : 'The permission could not be deleted',
"title": "Cannot delete permission",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var update = function() { var update = function() {
@ -3836,9 +4024,11 @@ quayApp.directive('billingOptions', function () {
var save = function() { var save = function() {
$scope.working = true; $scope.working = true;
var errorHandler = ApiService.errorDisplay('Could not change user details');
ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) {
$scope.working = false; $scope.working = false;
}); }, errorHandler);
}; };
var checkSave = function() { var checkSave = function() {
@ -3890,7 +4080,7 @@ quayApp.directive('planManager', function () {
return true; return true;
}; };
$scope.changeSubscription = function(planId) { $scope.changeSubscription = function(planId, opt_async) {
if ($scope.planChanging) { return; } if ($scope.planChanging) { return; }
var callbacks = { var callbacks = {
@ -3904,7 +4094,7 @@ quayApp.directive('planManager', function () {
} }
}; };
PlanService.changePlan($scope, $scope.organization, planId, callbacks); PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async);
}; };
$scope.cancelSubscription = function() { $scope.cancelSubscription = function() {
@ -3967,7 +4157,7 @@ quayApp.directive('planManager', function () {
if ($scope.readyForPlan) { if ($scope.readyForPlan) {
var planRequested = $scope.readyForPlan(); var planRequested = $scope.readyForPlan();
if (planRequested && planRequested != PlanService.getFreePlan()) { if (planRequested && planRequested != PlanService.getFreePlan()) {
$scope.changeSubscription(planRequested); $scope.changeSubscription(planRequested, /* async */true);
} }
} }
}); });
@ -3998,7 +4188,7 @@ quayApp.directive('namespaceSelector', function () {
'namespace': '=namespace', 'namespace': '=namespace',
'requireCreate': '=requireCreate' 'requireCreate': '=requireCreate'
}, },
controller: function($scope, $element, $routeParams, CookieService) { controller: function($scope, $element, $routeParams, $location, CookieService) {
$scope.namespaces = {}; $scope.namespaces = {};
$scope.initialize = function(user) { $scope.initialize = function(user) {
@ -4035,6 +4225,10 @@ quayApp.directive('namespaceSelector', function () {
if (newNamespace) { if (newNamespace) {
CookieService.putPermanent('quay.namespace', newNamespace); CookieService.putPermanent('quay.namespace', newNamespace);
if ($routeParams['namespace'] && $routeParams['namespace'] != newNamespace) {
$location.search({'namespace': newNamespace});
}
} }
}; };
@ -4385,26 +4579,17 @@ quayApp.directive('setupTriggerDialog', function () {
$scope.activating = true; $scope.activating = true;
var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) {
$scope.hide();
$scope.canceled({'trigger': $scope.trigger});
});
ApiService.activateBuildTrigger(data, params).then(function(resp) { ApiService.activateBuildTrigger(data, params).then(function(resp) {
$scope.hide(); $scope.hide();
$scope.trigger['is_active'] = true; $scope.trigger['is_active'] = true;
$scope.trigger['pull_robot'] = resp['pull_robot']; $scope.trigger['pull_robot'] = resp['pull_robot'];
$scope.activated({'trigger': $scope.trigger}); $scope.activated({'trigger': $scope.trigger});
}, function(resp) { }, errorHandler);
$scope.hide();
$scope.canceled({'trigger': $scope.trigger});
bootbox.dialog({
"message": resp['data']['message'] || 'The build trigger setup could not be completed',
"title": "Could not activate build trigger",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var check = function() { var check = function() {
@ -4744,6 +4929,9 @@ quayApp.directive('buildMessage', function () {
case 'waiting': case 'waiting':
return 'Waiting for available build worker'; return 'Waiting for available build worker';
case 'unpacking':
return 'Unpacking build package';
case 'pulling': case 'pulling':
return 'Pulling base image'; return 'Pulling base image';
@ -4799,6 +4987,7 @@ quayApp.directive('buildProgress', function () {
case 'starting': case 'starting':
case 'waiting': case 'waiting':
case 'cannot_load': case 'cannot_load':
case 'unpacking':
return 0; return 0;
break; break;
} }
@ -5018,6 +5207,23 @@ quayApp.directive('twitterView', function () {
}); });
quayApp.directive('notificationsBubble', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/notifications-bubble.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
},
controller: function($scope, UserService, NotificationService) {
$scope.notificationService = NotificationService;
}
};
return directiveDefinitionObject;
});
quayApp.directive('notificationView', function () { quayApp.directive('notificationView', function () {
var directiveDefinitionObject = { var directiveDefinitionObject = {
priority: 0, priority: 0,
@ -5329,7 +5535,9 @@ quayApp.directive('locationView', function () {
'local_us': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' }, 'local_us': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' },
'local_eu': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, 'local_eu': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' },
's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' }, 's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States (East)' },
's3_us_west_1': { 'country': 'US', 'data': 'quay-registry-cali.s3.amazonaws.com', 'title': 'United States (West)' },
's3_eu_west_1': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, 's3_eu_west_1': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' },
's3_ap_southeast_1': { 'country': 'SG', 'data': 'quay-registry-singapore.s3-ap-southeast-1.amazonaws.com', 'title': 'Singapore' }, 's3_ap_southeast_1': { 'country': 'SG', 'data': 'quay-registry-singapore.s3-ap-southeast-1.amazonaws.com', 'title': 'Singapore' },
@ -5344,7 +5552,9 @@ quayApp.directive('locationView', function () {
$scope.getLocationTooltip = function(location, ping) { $scope.getLocationTooltip = function(location, ping) {
var tip = $scope.getLocationTitle(location) + '<br>'; var tip = $scope.getLocationTitle(location) + '<br>';
if (ping < 0) { if (ping == null) {
tip += '(Loading)';
} else if (ping < 0) {
tip += '<br><b>Note: Could not contact server</b>'; tip += '<br><b>Note: Could not contact server</b>';
} else { } else {
tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)'); tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)');
@ -5367,7 +5577,7 @@ quayApp.directive('locationView', function () {
}; };
$scope.getLocationPing = function(location) { $scope.getLocationPing = function(location) {
var url = 'http://' + LOCATIONS[location]['data'] + '/okay.txt'; var url = 'https://' + LOCATIONS[location]['data'] + '/okay.txt';
PingService.pingUrl($scope, url, function(ping, success, count) { PingService.pingUrl($scope, url, function(ping, success, count) {
if (count == 3 || !success) { if (count == 3 || !success) {
$scope.locationPing = success ? ping : -1; $scope.locationPing = success ? ping : -1;
@ -5424,7 +5634,8 @@ quayApp.directive('tagSpecificImagesView', function () {
scope: { scope: {
'repository': '=repository', 'repository': '=repository',
'tag': '=tag', 'tag': '=tag',
'images': '=images' 'images': '=images',
'imageCutoff': '=imageCutoff'
}, },
controller: function($scope, $element) { controller: function($scope, $element) {
$scope.getFirstTextLine = getFirstTextLine; $scope.getFirstTextLine = getFirstTextLine;
@ -5446,7 +5657,7 @@ quayApp.directive('tagSpecificImagesView', function () {
return classes; return classes;
}; };
var forAllTagImages = function(tag, callback) { var forAllTagImages = function(tag, callback, opt_cutoff) {
if (!tag) { return; } if (!tag) { return; }
if (!$scope.imageByDBID) { if (!$scope.imageByDBID) {
@ -5464,10 +5675,14 @@ quayApp.directive('tagSpecificImagesView', function () {
callback(tag_image); callback(tag_image);
var ancestors = tag_image.ancestors.split('/'); var ancestors = tag_image.ancestors.split('/').reverse();
for (var i = 0; i < ancestors.length; ++i) { for (var i = 0; i < ancestors.length; ++i) {
var image = $scope.imageByDBID[ancestors[i]]; var image = $scope.imageByDBID[ancestors[i]];
if (image) { if (image) {
if (image == opt_cutoff) {
return;
}
callback(image); callback(image);
} }
} }
@ -5489,7 +5704,7 @@ quayApp.directive('tagSpecificImagesView', function () {
var ids = {}; var ids = {};
forAllTagImages(currentTag, function(image) { forAllTagImages(currentTag, function(image) {
ids[image.dbid] = true; ids[image.dbid] = true;
}); }, $scope.imageCutoff);
return ids; return ids;
}; };
@ -5587,15 +5802,14 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
// Handle session expiration. // Handle session expiration.
Restangular.setErrorInterceptor(function(response) { Restangular.setErrorInterceptor(function(response) {
if (response.status == 401) { if (response.status == 401 && response.data['error_type'] == 'invalid_token' &&
if (response.data['session_required'] == null || response.data['session_required'] === true) { response.data['session_required'] !== false) {
$('#sessionexpiredModal').modal({}); $('#sessionexpiredModal').modal({});
return false; return false;
}
} }
if (!Features.BILLING && response.status == 402) { if (response.status == 503) {
$('#overlicenseModal').modal({}); $('#cannotContactService').modal({});
return false; return false;
} }

View file

@ -1,25 +1,3 @@
$.fn.clipboardCopy = function() {
var clip = new ZeroClipboard($(this), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
clip.on('complete', function() {
// Resets the animation.
var elem = $('#clipboardCopied')[0];
if (!elem) {
return;
}
elem.style.display = 'none';
elem.classList.remove('animated');
// Show the notification.
setTimeout(function() {
if (!elem) { return; }
elem.style.display = 'inline-block';
elem.classList.add('animated');
}, 10);
});
};
function GuideCtrl() { function GuideCtrl() {
} }
@ -431,6 +409,27 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$location.search('current', buildInfo.id); $location.search('current', buildInfo.id);
}; };
$scope.isPushing = function(images) {
if (!images) { return false; }
var cached = images.__isPushing;
if (cached !== undefined) {
return cached;
}
return images.__isPushing = $scope.isPushingInternal(images);
};
$scope.isPushingInternal = function(images) {
if (!images) { return false; }
for (var i = 0; i < images.length; ++i) {
if (images[i].uploading) { return true; }
}
return false;
};
$scope.getTooltipCommand = function(image) { $scope.getTooltipCommand = function(image) {
var sanitized = ImageMetadataService.getEscapedFormattedCommand(image); var sanitized = ImageMetadataService.getEscapedFormattedCommand(image);
return '<span class=\'codetooltip\'>' + sanitized + '</span>'; return '<span class=\'codetooltip\'>' + sanitized + '</span>';
@ -511,48 +510,37 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
'image': image.id 'image': image.id
}; };
var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) {
$('#addTagModal').modal('hide');
});
ApiService.changeTagImage(data, params).then(function(resp) { ApiService.changeTagImage(data, params).then(function(resp) {
$scope.creatingTag = false; $scope.creatingTag = false;
loadViewInfo(); loadViewInfo();
$('#addTagModal').modal('hide'); $('#addTagModal').modal('hide');
}, function(resp) { }, errorHandler);
$('#addTagModal').modal('hide');
bootbox.dialog({
"message": resp.data ? resp.data : 'Could not create or move tag',
"title": "Cannot create or move tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteTag = function(tagName) { $scope.deleteTag = function(tagName) {
if (!$scope.repo.can_admin) { return; } if (!$scope.repo.can_admin) { return; }
$('#confirmdeleteTagModal').modal('hide');
var params = { var params = {
'repository': namespace + '/' + name, 'repository': namespace + '/' + name,
'tag': tagName 'tag': tagName
}; };
var errorHandler = ApiService.errorDisplay('Cannot delete tag', function() {
$('#confirmdeleteTagModal').modal('hide');
$scope.deletingTag = false;
});
$scope.deletingTag = true;
ApiService.deleteFullTag(null, params).then(function() { ApiService.deleteFullTag(null, params).then(function() {
loadViewInfo(); loadViewInfo();
}, function(resp) { $('#confirmdeleteTagModal').modal('hide');
bootbox.dialog({ $scope.deletingTag = false;
"message": resp.data ? resp.data : 'Could not delete tag', }, errorHandler);
"title": "Cannot delete tag",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.getImagesForTagBySize = function(tag) { $scope.getImagesForTagBySize = function(tag) {
@ -731,8 +719,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
// Load the builds for this repository. If none are active it will cancel the poll. // Load the builds for this repository. If none are active it will cancel the poll.
startBuildInfoTimer(repo); startBuildInfoTimer(repo);
$('#copyClipboard').clipboardCopy();
}); });
}; };
@ -1341,17 +1327,16 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
}; };
$scope.deleteRole = function(entityName, kind) { $scope.deleteRole = function(entityName, kind) {
var errorHandler = ApiService.errorDisplay('Cannot change permission', function(resp) {
if (resp.status == 409) {
return 'Cannot change permission as you do not have the authority';
}
});
var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionDelete.customDELETE().then(function() { permissionDelete.customDELETE().then(function() {
delete $scope.permissions[kind][entityName]; delete $scope.permissions[kind][entityName];
}, function(resp) { }, errorHandler);
if (resp.status == 409) {
$scope.changePermError = resp.data || '';
$('#channgechangepermModal').modal({});
} else {
$('#cannotchangeModal').modal({});
}
});
}; };
$scope.addRole = function(entityName, role, kind) { $scope.addRole = function(entityName, role, kind) {
@ -1362,9 +1347,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionPost.customPUT(permission).then(function(result) { permissionPost.customPUT(permission).then(function(result) {
$scope.permissions[kind][entityName] = result; $scope.permissions[kind][entityName] = result;
}, function(result) { }, ApiService.errorDisplay('Cannot change permission'));
$('#cannotchangeModal').modal({});
});
}; };
$scope.roles = [ $scope.roles = [
@ -1579,18 +1562,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams
window.console.log(resp); window.console.log(resp);
var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id'];
document.location = url; document.location = url;
}, function(resp) { }, ApiService.errorDisplay('Could not start build'));
bootbox.dialog({
"message": resp['message'] || 'The build could not be started',
"title": "Could not start build",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteTrigger = function(trigger) { $scope.deleteTrigger = function(trigger) {
@ -1720,18 +1692,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
ApiService.deleteUserAuthorization(null, params).then(function(resp) { ApiService.deleteUserAuthorization(null, params).then(function(resp) {
$scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1);
}, function(resp) { }, ApiService.errorDisplay('Could not revoke authorization'));
bootbox.dialog({
"message": resp.message || 'Could not revoke authorization',
"title": "Cannot revoke authorization",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.loadLogs = function() { $scope.loadLogs = function() {
@ -1740,7 +1701,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
}; };
$scope.loadInvoices = function() { $scope.loadInvoices = function() {
if (!$scope.hasPaidBusinessPlan) { return; }
$scope.invoicesShown++; $scope.invoicesShown++;
}; };
@ -1819,7 +1779,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use
$scope.updatingUser = true; $scope.updatingUser = true;
$scope.changePasswordSuccess = false; $scope.changePasswordSuccess = false;
ApiService.changeUserDetails($scope.cuser).then(function() { ApiService.changeUserDetails($scope.cuser).then(function(resp) {
$scope.updatingUser = false; $scope.updatingUser = false;
$scope.changePasswordSuccess = true; $scope.changePasswordSuccess = true;
@ -1926,9 +1887,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I
// Fetch the image's changes. // Fetch the image's changes.
fetchChanges(); fetchChanges();
$('#copyClipboard').clipboardCopy();
return image; return image;
}); });
}; };
@ -2196,13 +2154,14 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
'teamname': teamname 'teamname': teamname
}; };
var errorHandler = ApiService.errorDisplay('Cannot delete team', function() {
$scope.currentDeleteTeam = null;
});
ApiService.deleteOrganizationTeam(null, params).then(function() { ApiService.deleteOrganizationTeam(null, params).then(function() {
delete $scope.organization.teams[teamname]; delete $scope.organization.teams[teamname];
$scope.currentDeleteTeam = null; $scope.currentDeleteTeam = null;
}, function() { }, errorHandler);
$('#cannotchangeModal').modal({});
$scope.currentDeleteTeam = null;
});
}; };
var loadOrganization = function() { var loadOrganization = function() {
@ -2496,9 +2455,9 @@ function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, Plan
}; };
PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks); PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks);
}, function(result) { }, function(resp) {
$scope.creating = false; $scope.creating = false;
$scope.createError = result.data.error_description || result.data; $scope.createError = ApiService.getErrorMessage(resp);
$timeout(function() { $timeout(function() {
$('#orgName').popover('show'); $('#orgName').popover('show');
}); });
@ -2575,18 +2534,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
$timeout(function() { $timeout(function() {
$location.path('/organization/' + orgname + '/admin'); $location.path('/organization/' + orgname + '/admin');
}, 500); }, 500);
}, function(resp) { }, ApiService.errorDisplay('Could not delete application'));
bootbox.dialog({
"message": resp.message || 'Could not delete application',
"title": "Cannot delete application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.updateApplication = function() { $scope.updateApplication = function() {
@ -2604,22 +2552,13 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
delete $scope.application['gravatar_email']; delete $scope.application['gravatar_email'];
} }
var errorHandler = ApiService.errorDisplay('Could not update application', function(resp) {
$scope.updating = false;
});
ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) { ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) {
$scope.application = resp; $scope.application = resp;
$scope.updating = false; }, errorHandler);
}, function(resp) {
$scope.updating = false;
bootbox.dialog({
"message": resp.message || 'Could not update application',
"title": "Cannot update application",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.resetClientSecret = function() { $scope.resetClientSecret = function() {
@ -2632,18 +2571,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim
ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) { ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) {
$scope.application = resp; $scope.application = resp;
}, function(resp) { }, ApiService.errorDisplay('Could not reset client secret'));
bootbox.dialog({
"message": resp.message || 'Could not reset client secret',
"title": "Cannot reset client secret",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var loadOrganization = function() { var loadOrganization = function() {
@ -2739,18 +2667,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.changeInstallUser(data, params).then(function(resp) { ApiService.changeInstallUser(data, params).then(function(resp) {
$scope.loadUsersInternal(); $scope.loadUsersInternal();
}, function(resp) { }, ApiService.errorDisplay('Could not change user'));
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not change user',
"title": "Cannot change user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
$scope.deleteUser = function(user) { $scope.deleteUser = function(user) {
@ -2762,49 +2679,10 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
ApiService.deleteInstallUser(null, params).then(function(resp) { ApiService.deleteInstallUser(null, params).then(function(resp) {
$scope.loadUsersInternal(); $scope.loadUsersInternal();
}, function(resp) { }, ApiService.errorDisplay('Cannot delete user'));
bootbox.dialog({
"message": resp.data ? resp.data.message : 'Could not delete user',
"title": "Cannot delete user",
"buttons": {
"close": {
"label": "Close",
"className": "btn-primary"
}
}
});
});
}; };
var seatUsageLoaded = function(usage) { $scope.loadUsers();
$scope.usageLoading = false;
if (usage.count > usage.allowed) {
$scope.limit = 'over';
} else if (usage.count == usage.allowed) {
$scope.limit = 'at';
} else if (usage.count >= usage.allowed * 0.7) {
$scope.limit = 'near';
} else {
$scope.limit = 'none';
}
if (!$scope.chart) {
$scope.chart = new UsageChart();
$scope.chart.draw('seat-usage-chart');
}
$scope.chart.update(usage.count, usage.allowed);
};
var loadSeatUsage = function() {
$scope.usageLoading = true;
ApiService.getSeatCount().then(function(resp) {
seatUsageLoaded(resp);
});
};
loadSeatUsage();
} }
function TourCtrl($scope, $location) { function TourCtrl($scope, $location) {

View file

@ -148,6 +148,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
var ch = dimensions.ch; var ch = dimensions.ch;
// Set the height of the container so that it never goes offscreen. // Set the height of the container so that it never goes offscreen.
if (!$('#' + container).removeOverscroll) { return; }
$('#' + container).removeOverscroll(); $('#' + container).removeOverscroll();
var viewportHeight = $(window).height(); var viewportHeight = $(window).height();
var boundingBox = document.getElementById(container).getBoundingClientRect(); var boundingBox = document.getElementById(container).getBoundingClientRect();
@ -402,6 +404,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var roots = []; var roots = [];
for (var i = 0; i < this.images_.length; ++i) { for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i]; var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid]; var imageNode = imageByDBID[image.dbid];
var ancestors = this.getAncestors_(image); var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1; var immediateParent = ancestors[ancestors.length - 1] * 1;
@ -432,6 +438,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() {
var maxChildCount = roots.length; var maxChildCount = roots.length;
for (var i = 0; i < this.images_.length; ++i) { for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i]; var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = imageByDBID[image.dbid]; var imageNode = imageByDBID[image.dbid];
maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode)); maxChildCount = Math.max(maxChildCount, this.determineMaximumChildCount_(imageNode));
} }
@ -582,6 +592,10 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
// Ensure that the children are in the correct order. // Ensure that the children are in the correct order.
for (var i = 0; i < this.images_.length; ++i) { for (var i = 0; i < this.images_.length; ++i) {
var image = this.images_[i]; var image = this.images_[i];
// Skip images that are currently uploading.
if (image.uploading) { continue; }
var imageNode = this.imageByDBID_[image.dbid]; var imageNode = this.imageByDBID_[image.dbid];
var ancestors = this.getAncestors_(image); var ancestors = this.getAncestors_(image);
var immediateParent = ancestors[ancestors.length - 1] * 1; var immediateParent = ancestors[ancestors.length - 1] * 1;

17
static/lib/ZeroClipboard.min.js vendored Executable file → Normal file

File diff suppressed because one or more lines are too long

BIN
static/lib/ZeroClipboard.swf Executable file → Normal file

Binary file not shown.

View file

@ -7,41 +7,49 @@
<div class="col-sm-12 about-basic-info"> <div class="col-sm-12 about-basic-info">
<h3>The Basics</h3> <h3>The Basics</h3>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-calendar"></i></div> <div class="about-basic-icon"><i class="fa fa-3x fa-calendar"></i></div>
<div class="about-basic-text"> <div class="about-basic-text">
<b> Founded</b><br> <b> Founded</b><br>
2012 2012
</div> </div>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-globe"></i></div> <div class="about-basic-icon"><i class="fa fa-3x fa-globe"></i></div>
<div class="about-basic-text"> <div class="about-basic-text">
<b> Location</b><br> <b> Location</b><br>
New York City, NY New York City, NY
</div> </div>
</div> </div>
<div class="col-sm-4"> <div class="col-sm-3">
<div class="about-basic-icon"><i class="fa fa-3x fa-users"></i></div> <div class="about-basic-icon"><i class="fa fa-3x fa-users"></i></div>
<div class="about-basic-text"> <div class="about-basic-text">
<b> Worker Bees</b><br> <b> Local Worker Bees</b><br>
2 2
</div> </div>
</div> </div>
<div class="col-sm-3">
<div class="about-basic-icon"><img src="/static/img/coreos-globe-color-lg.png" style="height: 42px; vertical-align: bottom;"></div>
<div class="about-basic-text">
<b> CoreOS</b><br>
August, 2014
</div>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<h3>Our Story</h3> <h3>Our Story</h3>
<p>Quay.io was originally created out of necessesity when we wanted to use Docker containers with DevTable IDE. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the <a href="http://www.meetup.com/Docker-NewYorkCity/events/142142762/">Docker New York City Meetup</a> on October 2nd, 2013.</p> <p>Quay.io was originally created out of necessesity when we wanted to use Docker containers with our original IDE product. We were using Docker containers to host and isolate server processes invoked on behalf of our users and often running their code. We started by building the Docker image dynamically whenever we spun up a new host node. The image was monolithic. It was too large, took too long to build, and was hard to manage conflicts. It was everything that Docker wasn't supposed to be. When we decided to split it up into pre-built images and host them somewhere, we noticed that there wasn't a good place to host images securely. Determined to scratch our own itch, we built Quay.io, and officially launched it as an aside in our presentation to the <a href="http://www.meetup.com/Docker-NewYorkCity/events/142142762/">Docker New York City Meetup</a> on October 2nd, 2013.</p>
<p>Since that time, our users have demanded that Quay.io become our main focus. Our customers rely on us to make sure they can store and distribute their Docker images, and we understand that solemn responsibility. Our customers have been fantastic with giving us great feedback and suggestions. We are working as hard as we can to deliver on the promise and execute our vision of what a top notch Docker registry should be. We thank you for taking this journey with us.</p> <p>After launch, our customers demanded that Quay.io become our main focus. They rely on us to make sure they can store and distribute their Docker images, and we understand that solemn responsibility. Our customers have been fantastic with giving us great feedback and suggestions.</p>
<p>In August, 2014, Quay.io joined <a href="https://coreos.com">CoreOS</a> to provide registry support for the enterprise. As ever, we are working as hard as we can to deliver on the promise and execute our vision of what a top notch Docker registry should be.
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<h3>The Team</h3> <h3>The Quay.io Team at CoreOS</h3>
Our team is composed of two software engineers turned entrepreneurs: The Quay.io team is composed of two software engineers:
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -67,12 +75,7 @@
<img class="img-rounded founder-photo" src="http://www.gravatar.com/avatar/9fc3232622773fb2e8f71c0027601bc5?s=128&d=mm"> <img class="img-rounded founder-photo" src="http://www.gravatar.com/avatar/9fc3232622773fb2e8f71c0027601bc5?s=128&d=mm">
</div> </div>
<div class="col-sm-7 col-md-10"> <div class="col-sm-7 col-md-10">
<p>Joseph graduated from University of Pennsylvania with a Bachelors and Masters in Computer Science. After a record setting (probably) five internships with Google, he took a full time position there to continue his work on exciting products such as Google Spreadsheets, the Google Closure Compiler, and Google APIs. Joseph was one of the original duo responsible for inventing the language and framework on which DevTable is built.</p> <p>Joseph graduated from University of Pennsylvania with a Bachelors and Masters in Computer Science. After a record setting (probably) five internships with Google, he took a full time position there to continue his work on exciting products such as Google Spreadsheets, the Google Closure Compiler, and Google APIs. </p>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<p>With a combined 10 years experience building tools for software engineers, our founding team knows what it takes to make software engineers happy doing their work. Combined with our love for the web, we are ready to make a difference in the way people think about software development in the cloud.</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,10 +2,6 @@
<div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div> <div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div> <div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-content"> <div class="landing-content">
<img class="logo" src="/static/img/white_horizontal.png">
<div class="header-bar"></div>
<div class="container"> <div class="container">
<div class="row messages"> <div class="row messages">
<div class="col-md-7"> <div class="col-md-7">
@ -28,7 +24,7 @@
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a> <a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div> <div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div> </div>
<a href="/repository/?namespace={{ user.username }}">See All Repositories</a> <a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
</div> </div>
<!-- No Repos --> <!-- No Repos -->

View file

@ -1,18 +1,25 @@
<div class="jumbotron landing"> <div class="jumbotron landing">
<div class="announcement">
<span class="hidden-xs-inline">
<img src="/static/img/white_horizontal.png" style="height: 40px">
<span class="plus">+</span>
<img src="/static/img/coreos-wordmark-horiz-white.svg">
</span>
<span class="spacer"></span>
Quay.io is now part of CoreOS! <a href="http://blog.devtable.com/" target="_blank">Read the blog post.</a>
</div>
<div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div> <div class="landing-background" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div> <div class="landing-filter" ng-class="user.anonymous ? 'landing': 'signedin'"></div>
<div class="landing-content"> <div class="landing-content">
<img class="logo" src="/static/img/white_horizontal.png">
<div class="header-bar"></div>
<div class="container"> <div class="container">
<div class="row messages"> <div class="row messages">
<div class="col-md-7"> <div class="col-md-7">
<div ng-show="user.anonymous"> <div ng-show="user.anonymous">
<h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1> <h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1>
<h3>Use the Docker images <b>your team</b> needs with the safety of <b>private</b> repositories</h3> <h3>Use the Docker images <b>your team</b> needs with the safety of <b>private</b> repositories</h3>
<div class="sellcall"><a href="/plans/">Private repository plans starting at $12/mo</a></div> <a href="/plans/?trial-plan=bus-coreos-trial" class="call-to-action">Get 20 free private repos for 6 months<i class="fa fa-arrow-right"></i></a>
</div> </div>
<div ng-show="!user.anonymous"> <div ng-show="!user.anonymous">
@ -27,7 +34,7 @@
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a> <a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
<div class="markdown-view description" content="repository.description" first-line-only="true"></div> <div class="markdown-view description" content="repository.description" first-line-only="true"></div>
</div> </div>
<a href="/repository/?namespace={{ user.username }}">See All Repositories</a> <a href="/repository/?namespace={{ namespace }}">See All Repositories</a>
</div> </div>
<!-- No Repos --> <!-- No Repos -->
@ -265,7 +272,7 @@
</div> </div>
<div class="container"> <div class="container" ng-if="user.anonymous">
<div class="row"> <div class="row">
<div style="border-top: 1px solid #eee; padding-top: 20px;" class="col-md-12"> <div style="border-top: 1px solid #eee; padding-top: 20px;" class="col-md-12">
<a href="https://mixpanel.com/f/partner"><img src="//cdn.mxpnl.com/site_media/images/partner/badge_light.png" alt="Mobile Analytics" /></a> <a href="https://mixpanel.com/f/partner"><img src="//cdn.mxpnl.com/site_media/images/partner/badge_light.png" alt="Mobile Analytics" /></a>

View file

@ -5,7 +5,7 @@
<div class="container create-org" ng-show="!creating"> <div class="container create-org" ng-show="!creating">
<div class="row header-row"> <div class="row header-row">
<div class="col-md-8 col-md-offset-1"> <div class="col-md-12">
<h2>Create Organization</h2> <h2>Create Organization</h2>
<div class="steps-container" ng-show="false"> <div class="steps-container" ng-show="false">
@ -44,8 +44,7 @@
<!-- Step 2 --> <!-- Step 2 -->
<div class="row" ng-show="user && !user.anonymous && !created"> <div class="row" ng-show="user && !user.anonymous && !created">
<div class="col-md-1"></div> <div class="col-md-12">
<div class="col-md-8">
<div class="step-container"> <div class="step-container">
<h3>Setup the new organization</h3> <h3>Setup the new organization</h3>
@ -54,7 +53,7 @@
<label for="orgName">Organization Name</label> <label for="orgName">Organization Name</label>
<input id="orgName" name="orgName" type="text" class="form-control" placeholder="Organization Name" <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 }}" ng-model="org.name" required autofocus data-trigger="manual" data-content="{{ createError }}"
data-placement="right" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/"> data-placement="bottom" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/">
<span class="description">This will also be the namespace for your repositories</span> <span class="description">This will also be the namespace for your repositories</span>
</div> </div>
@ -85,8 +84,7 @@
<!-- Step 3 --> <!-- Step 3 -->
<div class="row" ng-show="user && !user.anonymous && created"> <div class="row" ng-show="user && !user.anonymous && created">
<div class="col-md-1"></div> <div class="col-md-12">
<div class="col-md-8">
<div class="step-container"> <div class="step-container">
<h3>Organization Created</h3> <h3>Organization Created</h3>
<h4><a href="/organization/{{ org.name }}">Manage Teams Now</a></h4> <h4><a href="/organization/{{ org.name }}">Manage Teams Now</a></h4>

View file

@ -17,8 +17,8 @@
<!-- Header --> <!-- Header -->
<div class="row"> <div class="row">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<div class="section"> <div class="section">
<div class="new-header"> <div class="new-header">
<span style="color: #444;"> <span style="color: #444;">
@ -45,8 +45,8 @@
<!-- Private/public --> <!-- Private/public -->
<div class="row"> <div class="row">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<div class="section-title">Repository Visibility</div> <div class="section-title">Repository Visibility</div>
<div class="section"> <div class="section">
<div class="repo-option"> <div class="repo-option">
@ -98,8 +98,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<div class="section"> <div class="section">
<div class="section-title">Initialize repository</div> <div class="section-title">Initialize repository</div>
@ -137,8 +137,8 @@
</div> </div>
<div class="row" ng-show="repo.initialize == 'dockerfile' || repo.initialize == 'zipfile'"> <div class="row" ng-show="repo.initialize == 'dockerfile' || repo.initialize == 'zipfile'">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<div class="section"> <div class="section">
<div class="section-title">Upload <span ng-if="repo.initialize == 'dockerfile'">Dockerfile</span><span ng-if="repo.initialize == 'zipfile'">Archive</span></div> <div class="section-title">Upload <span ng-if="repo.initialize == 'dockerfile'">Dockerfile</span><span ng-if="repo.initialize == 'zipfile'">Archive</span></div>
<div style="padding-top: 20px;"> <div style="padding-top: 20px;">
@ -153,8 +153,8 @@
</div> </div>
<div class="row" ng-show="repo.initialize == 'github'"> <div class="row" ng-show="repo.initialize == 'github'">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<div class="alert alert-info"> <div class="alert alert-info">
You will be redirected to authorize via GitHub once the repository has been created You will be redirected to authorize via GitHub once the repository has been created
</div> </div>
@ -162,8 +162,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-1"></div>
<div class="col-md-8"> <div class="col-md-12">
<button class="btn btn-large btn-success" type="submit" <button class="btn btn-large btn-success" type="submit"
ng-disabled="uploading || building || newRepoForm.$invalid || (repo.is_public == '0' && (planRequired || checkingPlan)) || ((repo.initialize == 'dockerfile' || repo.initialize == 'zipfile') && !hasDockerfile)"> ng-disabled="uploading || building || newRepoForm.$invalid || (repo.is_public == '0' && (planRequired || checkingPlan)) || ((repo.initialize == 'dockerfile' || repo.initialize == 'zipfile') && !hasDockerfile)">
<i class="fa fa-large" ng-class="repo.is_public == '1' ? 'fa-unlock' : 'fa-lock'" style="margin-right: 4px"></i> <i class="fa fa-large" ng-class="repo.is_public == '1' ? 'fa-unlock' : 'fa-lock'" style="margin-right: 4px"></i>

View file

@ -34,6 +34,13 @@
</span> </span>
<i class="fa fa-upload visible-lg"></i> <i class="fa fa-upload visible-lg"></i>
</div> </div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="Administrators can view and download the full invoice history for their organization">
Invoice History
</span>
<i class="fa fa-calendar visible-lg"></i>
</div>
<div class="feature"> <div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right" <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis"> data-title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
@ -50,16 +57,9 @@
</div> </div>
<div class="feature"> <div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right" <span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="Administrators can view and download the full invoice history for their organization"> data-title="All plans have a free trial">
Invoice History <span class="hidden-sm-inline">Free Trial</span>
</span> <span class="visible-sm-inline">Free Trial</span>
<i class="fa fa-calendar visible-lg"></i>
</div>
<div class="feature">
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
data-title="All plans have a 14-day free trial">
<span class="hidden-sm-inline">14-Day Free Trial</span>
<span class="visible-sm-inline">14-Day Trial</span>
</span> </span>
<i class="fa fa-clock-o visible-lg"></i> <i class="fa fa-clock-o visible-lg"></i>
</div> </div>
@ -81,7 +81,7 @@
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature present"></div> <div class="feature present"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature present"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div> <div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
<div class="feature present"></div> <div class="feature present"></div>
@ -93,10 +93,10 @@
<div class="feature present">SSL Encryption</div> <div class="feature present">SSL Encryption</div>
<div class="feature present">Robot accounts</div> <div class="feature present">Robot accounts</div>
<div class="feature present">Dockerfile Build</div> <div class="feature present">Dockerfile Build</div>
<div class="feature present">Invoice History</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div> <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div> <div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div> <div class="feature present">Free Trial</div>
<div class="feature present">14-Day Free Trial</div>
</div> </div>
<button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'" <button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'"
@ -128,11 +128,19 @@
</dl> </dl>
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-12"> <div class="row enterprise-plan">
<h3>Enterprise</h3> <div class="col-md-6">
<dt>I work in an enterprise and we need to run Quay.io on our servers. Can I do so?</dt> <h2>Run Quay.io Behind Your Firewall</h2>
<dd>Please contact us at our <a href="mailto:support@quay.io">support email address</a> to discuss enterprise plans.</dd> <div class="plan-combine">
<img src="/static/img/quay-logo.png">
<span class="plus">+</span>
<img src="/static/img/coreos.svg" style="height: 50px">
</div>
<div>Quay.io has partnered with CoreOS to offer Enterprise Registry, a version
of Quay.io that can be hosted behind your firewall. More information
can be found on the <a href="https://coreos.com/products/enterprise-registry">CoreOS website</a>.</div>
<a href="https://coreos.com/products/enterprise-registry" class="btn btn-default">Learn more about Enterprise Registry</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -18,7 +18,8 @@
<div class="col-md-2"> <div class="col-md-2">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li> <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()">Build Triggers</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()"
quay-require="['BUILD_SUPPORT']">Build Triggers</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#badge">Status Badge</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#notification" ng-click="loadNotifications()">Notifications</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li> <li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
@ -225,7 +226,7 @@
</div> </div>
<!-- Triggers tab --> <!-- Triggers tab -->
<div id="trigger" class="tab-pane"> <div id="trigger" class="tab-pane" quay-require="['BUILD_SUPPORT']">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading">Build Triggers <div class="panel-heading">Build Triggers
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i> <i class="info-icon fa fa-info-circle" data-placement="left" data-content="Triggers from various services (such as GitHub) which tell the repository to be built and updated."></i>
@ -377,24 +378,6 @@
counter="showNewNotificationCounter" counter="showNewNotificationCounter"
notification-created="handleNotificationCreated(notification)"></div> notification-created="handleNotificationCreated(notification)"></div>
<!-- Modal message dialog -->
<div class="modal fade" id="cannotchangeModal">
<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</h4>
</div>
<div class="modal-body">
The selected action could not be performed because you do not have that authority.
</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 --> <!-- Modal message dialog -->
<div class="modal fade" id="makepublicModal"> <div class="modal fade" id="makepublicModal">
<div class="modal-dialog"> <div class="modal-dialog">
@ -441,26 +424,6 @@
</div><!-- /.modal --> </div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="channgechangepermModal">
<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 permissions</h4>
</div>
<div class="modal-body">
<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>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="confirmdeleteModal"> <div class="modal fade" id="confirmdeleteModal">
<div class="modal-dialog"> <div class="modal-dialog">

View file

@ -8,9 +8,6 @@
<div class="col-md-2"> <div class="col-md-2">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"> <li class="active">
<a href="javascript:void(0)" data-toggle="tab" data-target="#license">License and Usage</a>
</li>
<li>
<a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#users" ng-click="loadUsers()">Users</a>
</li> </li>
</ul> </ul>
@ -19,19 +16,8 @@
<!-- Content --> <!-- Content -->
<div class="col-md-10"> <div class="col-md-10">
<div class="tab-content"> <div class="tab-content">
<!-- License tab -->
<div id="license" class="tab-pane active">
<div class="quay-spinner 3x" ng-show="usageLoading"></div>
<!-- Chart -->
<div>
<div id="seat-usage-chart" class="usage-chart limit-{{limit}}"></div>
<span class="usage-caption" ng-show="chart">Seat Usage</span>
</div>
</div>
<!-- Users tab --> <!-- Users tab -->
<div id="users" class="tab-pane"> <div id="users" class="tab-pane active">
<div class="quay-spinner" ng-show="!users"></div> <div class="quay-spinner" ng-show="!users"></div>
<div class="alert alert-error" ng-show="usersError"> <div class="alert alert-error" ng-show="usersError">
{{ usersError }} {{ usersError }}

View file

@ -25,7 +25,7 @@
<li ng-show="hasPaidPlan" quay-require="['BILLING']"> <li ng-show="hasPaidPlan" quay-require="['BILLING']">
<a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a>
</li> </li>
<li ng-show="hasPaidBusinessPlan" quay-require="['BILLING']"> <li ng-show="hasPaidPlan" quay-require="['BILLING']">
<a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a> <a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a>
</li> </li>
@ -138,13 +138,14 @@
<!-- Change password tab --> <!-- Change password tab -->
<div id="password" class="tab-pane"> <div id="password" class="tab-pane">
<div class="loading" ng-show="updatingUser">
<div class="quay-spinner 3x"></div>
</div>
<div class="row"> <div class="row">
<div class="panel"> <div class="panel">
<div class="panel-title">Change Password</div> <div class="panel-title">Change Password</div>
<div class="loading" ng-show="updatingUser">
<div class="quay-spinner 3x"></div>
</div>
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span> <span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
<div ng-show="!updatingUser" class="panel-body"> <div ng-show="!updatingUser" class="panel-body">

View file

@ -18,7 +18,7 @@
<div class="dropdown" data-placement="top" style="display: inline-block" <div class="dropdown" data-placement="top" style="display: inline-block"
bs-tooltip="" bs-tooltip=""
data-title="{{ runningBuilds.length ? 'Dockerfile Builds Running: ' + (runningBuilds.length) : 'Dockerfile Build' }}" data-title="{{ runningBuilds.length ? 'Dockerfile Builds Running: ' + (runningBuilds.length) : 'Dockerfile Build' }}"
ng-show="repo.can_write || buildHistory.length"> quay-show="Features.BUILD_SUPPORT && (repo.can_write || buildHistory.length)">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown"> <button class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-tasks fa-lg"></i> <i class="fa fa-tasks fa-lg"></i>
<span class="count" ng-class="runningBuilds.length ? 'visible' : ''"><span>{{ runningBuilds.length ? runningBuilds.length : '' }}</span></span> <span class="count" ng-class="runningBuilds.length ? 'visible' : ''"><span>{{ runningBuilds.length ? runningBuilds.length : '' }}</span></span>
@ -58,16 +58,9 @@
<span class="pull-command visible-md-inline"> <span class="pull-command visible-md-inline">
<div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title"> <div class="pull-container" data-title="Pull repository" bs-tooltip="tooltip.title">
<div class="input-group"> <div class="input-group">
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name }}" readonly> <div class="copy-box" hovering-message="true" value="'docker pull ' + Config.getDomain() + '/' + repo.namespace + '/' + repo.name"></div>
<span id="copyClipboard" class="input-group-addon" data-title="Copy to Clipboard" data-clipboard-target="pull-text">
<i class="fa fa-copy"></i>
</span>
</div> </div>
</div> </div>
<div id="clipboardCopied" class="hovering" style="display: none">
Copied to clipboard
</div>
</span> </span>
</div> </div>
</div> </div>
@ -76,33 +69,43 @@
<div class="description markdown-input" content="repo.description" can-write="repo.can_write" <div class="description markdown-input" content="repo.description" can-write="repo.can_write"
content-changed="updateForDescription" field-title="'repository description'"></div> content-changed="updateForDescription" field-title="'repository description'"></div>
<!-- Empty message --> <!-- Empty messages -->
<div class="repo-content" ng-show="!currentTag.image_id && !currentImage && !repo.is_building"> <div ng-if="!currentTag.image_id && !currentImage">
<div class="empty-message"> <!-- !building && !pushing -->
This repository is empty <div class="repo-content" ng-show="!repo.is_building && !isPushing(images)">
</div> <div class="empty-message">
This repository is empty
</div>
<div class="empty-description" ng-show="repo.can_write"> <div class="empty-description" ng-show="repo.can_write">
<div class="panel-default"> <div class="panel-default">
<div class="panel-heading">How to push a new image to this repository:</div> <div class="panel-heading">How to push a new image to this repository:</div>
<div class="panel-body"> <div class="panel-body">
First login to the registry (if you have not done so already): First login to the registry (if you have not done so already):
<pre class="command">sudo docker login {{ Config.getDomain() }}</pre> <pre class="command">sudo docker login {{ Config.getDomain() }}</pre>
Tag an image to this repository: Tag an image to this repository:
<pre class="command">sudo docker tag <i>0u123imageidgoeshere</i> {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre> <pre class="command">sudo docker tag <i>0u123imageidgoeshere</i> {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
Push the image to this repository: Push the image to this repository:
<pre class="command">sudo docker push {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre> <pre class="command">sudo docker push {{ Config.getDomain() }}/{{repo.namespace}}/{{repo.name}}</pre>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <!-- building -->
<div class="repo-content" ng-show="repo.is_building">
<div class="empty-message">
A build is currently processing. If this takes longer than an hour, please <a href="/contact">contact us</a>
</div>
</div>
<div class="repo-content" ng-show="!currentTag.image_id && repo.is_building"> <!-- pushing -->
<div class="empty-message"> <div class="repo-content" ng-show="!repo.is_building && isPushing(images)">
A build is currently processing. If this takes longer than an hour, please <a href="/contact">contact us</a> <div class="empty-message">
A push to this repository is in progress.
</div>
</div> </div>
</div> </div>
@ -357,7 +360,7 @@
<div style="margin: 10px; margin-top: 20px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)"> <div style="margin: 10px; margin-top: 20px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
Note: <span class="label tag label-default">{{ tagToCreate }}</span> is already applied to another image. This will <b>move</b> the tag. Note: <span class="label tag label-default">{{ tagToCreate }}</span> is already applied to another image. This will <b>move</b> the tag.
</div> </div>
<div class="tag-specific-images-view" tag="tagToCreate" repository="repo" images="images" <div class="tag-specific-images-view" tag="tagToCreate" repository="repo" images="images" image-cutoff="toTagImage"
style="margin: 10px; margin-top: 20px; margin-bottom: -10px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)"> style="margin: 10px; margin-top: 20px; margin-bottom: -10px;" ng-show="isAnotherImageTag(toTagImage, tagToCreate)">
This will also delete any unattach images and delete the following images: This will also delete any unattach images and delete the following images:
</div> </div>
@ -388,7 +391,10 @@
</span>? </span>?
</h4> </h4>
</div> </div>
<div class="modal-body"> <div class="modal-body" ng-show="deletingTag">
<div class="quay-spinner"></div>
</div>
<div class="modal-body" ng-show="!deletingTag">
Are you sure you want to delete tag Are you sure you want to delete tag
<span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'"> <span class="label tag" ng-class="tagToDelete == currentTag.name ? 'label-success' : 'label-default'">
{{ tagToDelete }} {{ tagToDelete }}
@ -398,7 +404,7 @@
The following images and any other images not referenced by a tag will be deleted: The following images and any other images not referenced by a tag will be deleted:
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer" ng-show="!deletingTag">
<button type="button" class="btn btn-primary" ng-click="deleteTag(tagToDelete)">Delete Tag</button> <button type="button" class="btn btn-primary" ng-click="deleteTag(tagToDelete)">Delete Tag</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div> </div>

View file

@ -1,9 +1,16 @@
from storage.local import LocalStorage from storage.local import LocalStorage
from storage.s3 import S3Storage from storage.cloud import S3Storage, GoogleCloudStorage
from storage.fakestorage import FakeStorage from storage.fakestorage import FakeStorage
from storage.distributedstorage import DistributedStorage from storage.distributedstorage import DistributedStorage
STORAGE_DRIVER_CLASSES = {
'LocalStorage': LocalStorage,
'S3Storage': S3Storage,
'GoogleCloudStorage': GoogleCloudStorage,
}
class Storage(object): class Storage(object):
def __init__(self, app=None): def __init__(self, app=None):
self.app = app self.app = app
@ -18,13 +25,8 @@ class Storage(object):
driver = storage_params[0] driver = storage_params[0]
parameters = storage_params[1] parameters = storage_params[1]
if driver == 'LocalStorage': driver_class = STORAGE_DRIVER_CLASSES.get(driver, FakeStorage)
storage = LocalStorage(**parameters) storage = driver_class(**parameters)
elif driver == 'S3Storage':
storage = S3Storage(**parameters)
else:
storage = FakeStorage()
storages[location] = storage storages[location] = storage
preference = app.config.get('DISTRIBUTED_STORAGE_PREFERENCE', None) preference = app.config.get('DISTRIBUTED_STORAGE_PREFERENCE', None)

View file

@ -3,7 +3,9 @@ import os
import logging import logging
import boto.s3.connection import boto.s3.connection
import boto.gs.connection
import boto.s3.key import boto.s3.key
import boto.gs.key
from storage.basestorage import BaseStorage from storage.basestorage import BaseStorage
@ -32,22 +34,24 @@ class StreamReadKeyAsFile(object):
return resp return resp
class S3Storage(BaseStorage): class _CloudStorage(BaseStorage):
def __init__(self, connection_class, key_class, upload_params, storage_path, access_key,
def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket): secret_key, bucket_name):
self._initialized = False self._initialized = False
self._bucket_name = s3_bucket self._bucket_name = bucket_name
self._access_key = s3_access_key self._access_key = access_key
self._secret_key = s3_secret_key self._secret_key = secret_key
self._root_path = storage_path self._root_path = storage_path
self._s3_conn = None self._connection_class = connection_class
self._s3_bucket = None self._key_class = key_class
self._upload_params = upload_params
self._cloud_conn = None
self._cloud_bucket = None
def _initialize_s3(self): def _initialize_cloud_conn(self):
if not self._initialized: if not self._initialized:
self._s3_conn = boto.s3.connection.S3Connection(self._access_key, self._cloud_conn = self._connection_class(self._access_key, self._secret_key)
self._secret_key) self._cloud_bucket = self._cloud_conn.get_bucket(self._bucket_name)
self._s3_bucket = self._s3_conn.get_bucket(self._bucket_name)
self._initialized = True self._initialized = True
def _debug_key(self, key): def _debug_key(self, key):
@ -69,33 +73,33 @@ class S3Storage(BaseStorage):
return path return path
def get_content(self, path): def get_content(self, path):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
if not key.exists(): if not key.exists():
raise IOError('No such key: \'{0}\''.format(path)) raise IOError('No such key: \'{0}\''.format(path))
return key.get_contents_as_string() return key.get_contents_as_string()
def put_content(self, path, content): def put_content(self, path, content):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
key.set_contents_from_string(content, encrypt_key=True) key.set_contents_from_string(content, **self._upload_params)
return path return path
def get_supports_resumeable_downloads(self): def get_supports_resumeable_downloads(self):
return True return True
def get_direct_download_url(self, path, expires_in=60): def get_direct_download_url(self, path, expires_in=60):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
k = boto.s3.key.Key(self._s3_bucket, path) k = self._key_class(self._cloud_bucket, path)
return k.generate_url(expires_in) return k.generate_url(expires_in)
def stream_read(self, path): def stream_read(self, path):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
if not key.exists(): if not key.exists():
raise IOError('No such key: \'{0}\''.format(path)) raise IOError('No such key: \'{0}\''.format(path))
while True: while True:
@ -105,21 +109,21 @@ class S3Storage(BaseStorage):
yield buf yield buf
def stream_read_file(self, path): def stream_read_file(self, path):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
if not key.exists(): if not key.exists():
raise IOError('No such key: \'{0}\''.format(path)) raise IOError('No such key: \'{0}\''.format(path))
return StreamReadKeyAsFile(key) return StreamReadKeyAsFile(key)
def stream_write(self, path, fp): def stream_write(self, path, fp):
# Minimum size of upload part size on S3 is 5MB # Minimum size of upload part size on S3 is 5MB
self._initialize_s3() self._initialize_cloud_conn()
buffer_size = 5 * 1024 * 1024 buffer_size = 5 * 1024 * 1024
if self.buffer_size > buffer_size: if self.buffer_size > buffer_size:
buffer_size = self.buffer_size buffer_size = self.buffer_size
path = self._init_path(path) path = self._init_path(path)
mp = self._s3_bucket.initiate_multipart_upload(path, encrypt_key=True) mp = self._cloud_bucket.initiate_multipart_upload(path, **self._upload_params)
num_part = 1 num_part = 1
while True: while True:
try: try:
@ -135,7 +139,7 @@ class S3Storage(BaseStorage):
mp.complete_upload() mp.complete_upload()
def list_directory(self, path=None): def list_directory(self, path=None):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
if not path.endswith('/'): if not path.endswith('/'):
path += '/' path += '/'
@ -143,7 +147,7 @@ class S3Storage(BaseStorage):
if self._root_path != '/': if self._root_path != '/':
ln = len(self._root_path) ln = len(self._root_path)
exists = False exists = False
for key in self._s3_bucket.list(prefix=path, delimiter='/'): for key in self._cloud_bucket.list(prefix=path, delimiter='/'):
exists = True exists = True
name = key.name name = key.name
if name.endswith('/'): if name.endswith('/'):
@ -156,15 +160,15 @@ class S3Storage(BaseStorage):
raise OSError('No such directory: \'{0}\''.format(path)) raise OSError('No such directory: \'{0}\''.format(path))
def exists(self, path): def exists(self, path):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
return key.exists() return key.exists()
def remove(self, path): def remove(self, path):
self._initialize_s3() self._initialize_cloud_conn()
path = self._init_path(path) path = self._init_path(path)
key = boto.s3.key.Key(self._s3_bucket, path) key = self._key_class(self._cloud_bucket, path)
if key.exists(): if key.exists():
# It's a file # It's a file
key.delete() key.delete()
@ -172,5 +176,28 @@ class S3Storage(BaseStorage):
# We assume it's a directory # We assume it's a directory
if not path.endswith('/'): if not path.endswith('/'):
path += '/' path += '/'
for key in self._s3_bucket.list(prefix=path): for key in self._cloud_bucket.list(prefix=path):
key.delete() key.delete()
class S3Storage(_CloudStorage):
def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket):
upload_params = {
'encrypt_key': True,
}
super(S3Storage, self).__init__(boto.s3.connection.S3Connection, boto.s3.key.Key,
upload_params, storage_path, s3_access_key, s3_secret_key,
s3_bucket)
class GoogleCloudStorage(_CloudStorage):
def __init__(self, storage_path, access_key, secret_key, bucket_name):
super(GoogleCloudStorage, self).__init__(boto.gs.connection.GSConnection, boto.gs.key.Key, {},
storage_path, access_key, secret_key, bucket_name)
def stream_write(self, path, fp):
# Minimum size of upload part size on S3 is 5MB
self._initialize_cloud_conn()
path = self._init_path(path)
key = self._key_class(self._cloud_bucket, path)
key.set_contents_from_stream(fp)

View file

@ -91,42 +91,40 @@ mixpanel.init("{{ mixpanel_key }}", { track_pageview : false, debug: {{ is_debug
{% endif %} {% endif %}
</head> </head>
<body ng-class="pageClass"> <body ng-class="pageClass + ' ' + (user.anonymous ? 'anon' : 'signedin')" class="co-img-bg-network">
<div ng-class="!fixFooter ? 'wrapper' : ''"> <div id="co-l-footer-wrapper">
<nav class="navbar navbar-default header-bar" role="navigation"></nav> <nav class="navbar navbar-default header-bar co-m-navbar co-fx-box-shadow" role="navigation"></nav>
{% block body_content %} <div id="padding-container">
<div id="co-l-view-container">
<div class="main-panel co-fx-box-shadow-heavy">
{% block body_content %}
{% endblock %} {% endblock %}
</div>
<div ng-class="!fixFooter ? 'push' : ''"></div> </div>
</div>
<div class="footer-container" ng-class="fixFooter ? 'fixed' : ''">
<div class="page-footer-padder">
<nav class="page-footer hidden-xs">
<div class="row">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2014 DevTable, LLC</span></li>
<li quay-require="['BILLING']"><a href="http://blog.devtable.com/" target="_blank">Blog</a></li>
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
<li quay-require="['BILLING']"><a href="/about/" target="_self">About</a></li>
<li><b><a href="/contact/" target="_self">Contact</a></b></li>
<li quay-require="['BILLING']"><b><a href="http://status.quay.io" target="_self">Service Status</a></b></li>
</ul>
</div>
<div class="col-md-4 logo-container">
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
</div>
</div> <!-- row -->
</nav>
</div> </div>
<div id="co-l-footer-push"></div>
</div> </div>
<nav id="co-l-footer" class="page-footer hidden-xs">
<div class="col-md-8">
<ul>
<li><span class="copyright">&copy;2014 CoreOS, Inc.</span></li>
<li quay-require="['BILLING']"><a href="/tos" target="_self">Terms</a></li>
<li quay-require="['BILLING']"><a href="/privacy" target="_self">Privacy</a></li>
<li quay-require="['BILLING']"><a href="/security/" target="_self">Security</a></li>
<li quay-require="['BILLING']"><a href="/about/" target="_self">About</a></li>
<li><b><a href="/contact/" target="_self">Contact</a></b></li>
<li quay-require="['BILLING']"><b><a href="http://status.quay.io" target="_self">Service Status</a></b></li>
</ul>
</div>
<div class="col-md-4">
<a href="https://coreos.com" target="_blank"><img src="/static/img/coreos.svg"></a>
</div>
</nav>
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="couldnotloadModal" data-backdrop="static"> <div class="modal fade" id="couldnotloadModal" data-backdrop="static">
<div class="modal-dialog"> <div class="modal-dialog">

View file

@ -35,23 +35,18 @@
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->
</div><!-- /.modal --> </div><!-- /.modal -->
{% if not has_billing %}
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="overlicenseModal" data-backdrop="static"> <div class="modal fade" id="cannotContactService" data-backdrop="static">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">Cannot create user</h4> <h4 class="modal-title">Cannot Contact External Service</h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
A new user cannot be created as this organization has reached its licensed seat count. Please contact your administrator. A connection to an external service has failed. Please reload the page to try again.
</div>
<div class="modal-footer">
<a href="javascript:void(0)" class="btn btn-primary" data-dismiss="modal" onclick="location = '/signin'">Sign In</a>
</div> </div>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->
</div><!-- /.modal --> </div><!-- /.modal -->
{% endif %}
{% endblock %} {% endblock %}

Binary file not shown.

View file

@ -196,7 +196,7 @@ def build_index_specs():
IndexTestSpec(url_for('index.put_repository_auth', repository=PUBLIC_REPO), IndexTestSpec(url_for('index.put_repository_auth', repository=PUBLIC_REPO),
NO_REPO, 501, 501, 501, 501).set_method('PUT'), NO_REPO, 501, 501, 501, 501).set_method('PUT'),
IndexTestSpec(url_for('index.get_search'), NO_REPO, 501, 501, 501, 501), IndexTestSpec(url_for('index.get_search'), NO_REPO, 200, 200, 200, 200),
IndexTestSpec(url_for('index.ping'), NO_REPO, 200, 200, 200, 200), IndexTestSpec(url_for('index.ping'), NO_REPO, 200, 200, 200, 200),

View file

@ -14,14 +14,17 @@ from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs, from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
RepositoryBuildList) RepositoryBuildList)
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
RegenerateOrgRobot, RegenerateUserRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze)
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout, from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification) Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
VerifyUser)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@ -37,7 +40,7 @@ from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repos
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList) RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try: try:
@ -432,6 +435,24 @@ class TestSignin(ApiTestCase):
self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'}) self._run_test('POST', 403, 'devtable', {u'username': 'E9RY', u'password': 'LQ0N'})
class TestVerifyUser(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(VerifyUser)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {u'password': 'LQ0N'})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {u'password': 'LQ0N'})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {u'password': 'LQ0N'})
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', {u'password': 'password'})
class TestListPlans(ApiTestCase): class TestListPlans(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
@ -471,13 +492,13 @@ class TestUser(ApiTestCase):
self._run_test('PUT', 401, None, {}) self._run_test('PUT', 401, None, {})
def test_put_freshuser(self): def test_put_freshuser(self):
self._run_test('PUT', 200, 'freshuser', {}) self._run_test('PUT', 401, 'freshuser', {})
def test_put_reader(self): def test_put_reader(self):
self._run_test('PUT', 200, 'reader', {}) self._run_test('PUT', 401, 'reader', {})
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 200, 'devtable', {}) self._run_test('PUT', 401, 'devtable', {})
def test_post_anonymous(self): def test_post_anonymous(self):
self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'}) self._run_test('POST', 400, None, {u'username': 'T946', u'password': '0SG4', u'email': 'MENT'})
@ -1632,6 +1653,19 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(OrgRobot, orgname="buynlarge", robot_shortname="Z7PD") self._set_url(OrgRobot, orgname="buynlarge", robot_shortname="Z7PD")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 400, 'devtable', None)
def test_put_anonymous(self): def test_put_anonymous(self):
self._run_test('PUT', 401, None, None) self._run_test('PUT', 401, None, None)
@ -1644,6 +1678,7 @@ class TestOrgRobotBuynlargeZ7pd(ApiTestCase):
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 400, 'devtable', None) self._run_test('PUT', 400, 'devtable', None)
def test_delete_anonymous(self): def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None) self._run_test('DELETE', 401, None, None)
@ -3040,6 +3075,19 @@ class TestUserRobot5vdy(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(UserRobot, robot_shortname="robotname") self._set_url(UserRobot, robot_shortname="robotname")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 400, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 400, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 400, 'devtable', None)
def test_put_anonymous(self): def test_put_anonymous(self):
self._run_test('PUT', 401, None, None) self._run_test('PUT', 401, None, None)
@ -3052,6 +3100,7 @@ class TestUserRobot5vdy(ApiTestCase):
def test_put_devtable(self): def test_put_devtable(self):
self._run_test('PUT', 201, 'devtable', None) self._run_test('PUT', 201, 'devtable', None)
def test_delete_anonymous(self): def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None) self._run_test('DELETE', 401, None, None)
@ -3065,6 +3114,42 @@ class TestUserRobot5vdy(ApiTestCase):
self._run_test('DELETE', 400, 'devtable', None) self._run_test('DELETE', 400, 'devtable', None)
class TestRegenerateUserRobot(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RegenerateUserRobot, robot_shortname="robotname")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 400, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 400, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 400, 'devtable', None)
class TestRegenerateOrgRobot(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(RegenerateOrgRobot, orgname="buynlarge", robot_shortname="robotname")
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', None)
def test_post_reader(self):
self._run_test('POST', 403, 'reader', None)
def test_post_devtable(self):
self._run_test('POST', 400, 'devtable', None)
class TestOrganizationBuynlarge(ApiTestCase): class TestOrganizationBuynlarge(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)

View file

@ -16,7 +16,8 @@ from endpoints.api.tag import RepositoryTagImages, RepositoryTag
from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImage, RepositoryImageList from endpoints.api.image import RepositoryImage, RepositoryImageList
from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList
from endpoints.api.robot import UserRobotList, OrgRobot, OrgRobotList, UserRobot from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
RegenerateUserRobot, RegenerateOrgRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze) BuildTriggerList, BuildTriggerAnalyze)
@ -40,7 +41,7 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember,
from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission, from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList) RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import SuperUserLogs, SeatUsage, SuperUserList, SuperUserManagement from endpoints.api.superuser import SuperUserLogs, SuperUserList, SuperUserManagement
try: try:
app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(api_bp, url_prefix='/api')
@ -327,6 +328,12 @@ class TestChangeUserDetails(ApiTestCase):
data=dict(password='newpasswordiscool')) data=dict(password='newpasswordiscool'))
self.login(READ_ACCESS_USER, password='newpasswordiscool') self.login(READ_ACCESS_USER, password='newpasswordiscool')
def test_changeeemail(self):
self.login(READ_ACCESS_USER)
self.putJsonResponse(User,
data=dict(email='test+foo@devtable.com'))
def test_changeinvoiceemail(self): def test_changeinvoiceemail(self):
self.login(READ_ACCESS_USER) self.login(READ_ACCESS_USER)
@ -1572,6 +1579,30 @@ class TestUserRobots(ApiTestCase):
robots = self.getRobotNames() robots = self.getRobotNames()
assert not NO_ACCESS_USER + '+bender' in robots assert not NO_ACCESS_USER + '+bender' in robots
def test_regenerate(self):
self.login(NO_ACCESS_USER)
# Create a robot.
json = self.putJsonResponse(UserRobot,
params=dict(robot_shortname='bender'),
expected_code=201)
token = json['token']
# Regenerate the robot.
json = self.postJsonResponse(RegenerateUserRobot,
params=dict(robot_shortname='bender'),
expected_code=200)
# Verify the token changed.
self.assertNotEquals(token, json['token'])
json2 = self.getJsonResponse(UserRobot,
params=dict(robot_shortname='bender'),
expected_code=200)
self.assertEquals(json['token'], json2['token'])
class TestOrgRobots(ApiTestCase): class TestOrgRobots(ApiTestCase):
def getRobotNames(self): def getRobotNames(self):
@ -1601,6 +1632,31 @@ class TestOrgRobots(ApiTestCase):
assert not ORGANIZATION + '+bender' in robots assert not ORGANIZATION + '+bender' in robots
def test_regenerate(self):
self.login(ADMIN_ACCESS_USER)
# Create a robot.
json = self.putJsonResponse(OrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=201)
token = json['token']
# Regenerate the robot.
json = self.postJsonResponse(RegenerateOrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=200)
# Verify the token changed.
self.assertNotEquals(token, json['token'])
json2 = self.getJsonResponse(OrgRobot,
params=dict(orgname=ORGANIZATION, robot_shortname='bender'),
expected_code=200)
self.assertEquals(json['token'], json2['token'])
class TestLogs(ApiTestCase): class TestLogs(ApiTestCase):
def test_user_logs(self): def test_user_logs(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)

View file

@ -46,25 +46,30 @@ class TestImageSharing(unittest.TestCase):
preferred = storage.preferred_locations[0] preferred = storage.preferred_locations[0]
image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {}, image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {},
preferred) preferred)
return image.storage.id image.storage.uploading = False
image.storage.save()
return image.storage
def assertSameStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER): def assertSameStorage(self, docker_image_id, existing_storage, repository=REPO,
new_storage_id = self.createStorage(docker_image_id, repository, username) username=ADMIN_ACCESS_USER):
self.assertEquals(storage_id, new_storage_id) new_storage = self.createStorage(docker_image_id, repository, username)
self.assertEquals(existing_storage.id, new_storage.id)
def assertDifferentStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER): def assertDifferentStorage(self, docker_image_id, existing_storage, repository=REPO,
new_storage_id = self.createStorage(docker_image_id, repository, username) username=ADMIN_ACCESS_USER):
self.assertNotEquals(storage_id, new_storage_id) new_storage = self.createStorage(docker_image_id, repository, username)
self.assertNotEquals(existing_storage.id, new_storage.id)
def test_same_user(self): def test_same_user(self):
""" The same user creates two images, each which should be shared in the same repo. This is a sanity check. """ """ The same user creates two images, each which should be shared in the same repo. This is a
sanity check. """
# Create a reference to a new docker ID => new image. # Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('first-image') first_storage = self.createStorage('first-image')
# Create a reference to the same docker ID => same image. # Create a reference to the same docker ID => same image.
self.assertSameStorage('first-image', first_storage_id) self.assertSameStorage('first-image', first_storage)
# Create a reference to another new docker ID => new image. # Create a reference to another new docker ID => new image.
second_storage_id = self.createStorage('second-image') second_storage_id = self.createStorage('second-image')
@ -73,68 +78,68 @@ class TestImageSharing(unittest.TestCase):
self.assertSameStorage('second-image', second_storage_id) self.assertSameStorage('second-image', second_storage_id)
# Make sure the images are different. # Make sure the images are different.
self.assertNotEquals(first_storage_id, second_storage_id) self.assertNotEquals(first_storage, second_storage_id)
def test_no_user_private_repo(self): def test_no_user_private_repo(self):
""" If no user is specified (token case usually), then no sharing can occur on a private repo. """ """ If no user is specified (token case usually), then no sharing can occur on a private repo. """
# Create a reference to a new docker ID => new image. # Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('the-image', username=None, repository=SHARED_REPO) first_storage = self.createStorage('the-image', username=None, repository=SHARED_REPO)
# Create a areference to the same docker ID, but since no username => new image. # Create a areference to the same docker ID, but since no username => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO) self.assertDifferentStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
def test_no_user_public_repo(self): def test_no_user_public_repo(self):
""" If no user is specified (token case usually), then no sharing can occur on a private repo except when the image is first public. """ """ If no user is specified (token case usually), then no sharing can occur on a private repo except when the image is first public. """
# Create a reference to a new docker ID => new image. # Create a reference to a new docker ID => new image.
first_storage_id = self.createStorage('the-image', username=None, repository=PUBLIC_REPO) first_storage = self.createStorage('the-image', username=None, repository=PUBLIC_REPO)
# Create a areference to the same docker ID. Since no username, we'd expect different but the first image is public so => shaed image. # Create a areference to the same docker ID. Since no username, we'd expect different but the first image is public so => shaed image.
self.assertSameStorage('the-image', first_storage_id, username=None, repository=RANDOM_REPO) self.assertSameStorage('the-image', first_storage, username=None, repository=RANDOM_REPO)
def test_different_user_same_repo(self): def test_different_user_same_repo(self):
""" Two different users create the same image in the same repo. """ """ Two different users create the same image in the same repo. """
# Create a reference to a new docker ID under the first user => new image. # Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO) first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=SHARED_REPO)
# Create a reference to the *same* docker ID under the second user => same image. # Create a reference to the *same* docker ID under the second user => same image.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=SHARED_REPO) self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
def test_different_repo_no_shared_access(self): def test_different_repo_no_shared_access(self):
""" Neither user has access to the other user's repository. """ """ Neither user has access to the other user's repository. """
# Create a reference to a new docker ID under the first user => new image. # Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO) first_storage = self.createStorage('the-image', username=RANDOM_USER, repository=RANDOM_REPO)
# Create a reference to the *same* docker ID under the second user => new image. # Create a reference to the *same* docker ID under the second user => new image.
second_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO) second_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
# Verify that the users do not share storage. # Verify that the users do not share storage.
self.assertNotEquals(first_storage_id, second_storage_id) self.assertNotEquals(first_storage, second_storage_id)
def test_public_than_private(self): def test_public_than_private(self):
""" An image is created publicly then used privately, so it should be shared. """ """ An image is created publicly then used privately, so it should be shared. """
# Create a reference to a new docker ID under the first user => new image. # Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO) first_storage = self.createStorage('the-image', username=PUBLIC_USER, repository=PUBLIC_REPO)
# Create a reference to the *same* docker ID under the second user => same image, since the first was public. # Create a reference to the *same* docker ID under the second user => same image, since the first was public.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO) self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
def test_private_than_public(self): def test_private_than_public(self):
""" An image is created privately then used publicly, so it should *not* be shared. """ """ An image is created privately then used publicly, so it should *not* be shared. """
# Create a reference to a new docker ID under the first user => new image. # Create a reference to a new docker ID under the first user => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=REPO)
# Create a reference to the *same* docker ID under the second user => new image, since the first was private. # Create a reference to the *same* docker ID under the second user => new image, since the first was private.
self.assertDifferentStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO) self.assertDifferentStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
def test_different_repo_with_access(self): def test_different_repo_with_access(self):
@ -143,64 +148,71 @@ class TestImageSharing(unittest.TestCase):
be shared since the user has access. be shared since the user has access.
""" """
# Create the image in the shared repo => new image. # Create the image in the shared repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=SHARED_REPO)
# Create the image in the other user's repo, but since the user (PUBLIC) still has access to the shared # Create the image in the other user's repo, but since the user (PUBLIC) still has access to the shared
# repository, they should reuse the storage. # repository, they should reuse the storage.
self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO) self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
def test_org_access(self): def test_org_access(self):
""" An image is accessible by being a member of the organization. """ """ An image is accessible by being a member of the organization. """
# Create the new image under the org's repo => new image. # Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under the user's repo, but since the user has access to the organization => shared image. # Create an image under the user's repo, but since the user has access to the organization => shared image.
self.assertSameStorage('the-image', first_storage_id, username=ADMIN_ACCESS_USER, repository=REPO) self.assertSameStorage('the-image', first_storage, username=ADMIN_ACCESS_USER, repository=REPO)
# Ensure that the user's robot does not have access, since it is not on the permissions list for the repo. # Ensure that the user's robot does not have access, since it is not on the permissions list for the repo.
self.assertDifferentStorage('the-image', first_storage_id, username=ADMIN_ROBOT_USER, repository=SHARED_REPO) self.assertDifferentStorage('the-image', first_storage, username=ADMIN_ROBOT_USER, repository=SHARED_REPO)
def test_org_access_different_user(self): def test_org_access_different_user(self):
""" An image is accessible by being a member of the organization. """ """ An image is accessible by being a member of the organization. """
# Create the new image under the org's repo => new image. # Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo, but since the user has access to the organization => shared image. # Create an image under a user's repo, but since the user has access to the organization => shared image.
self.assertSameStorage('the-image', first_storage_id, username=PUBLIC_USER, repository=PUBLIC_REPO) self.assertSameStorage('the-image', first_storage, username=PUBLIC_USER, repository=PUBLIC_REPO)
# Also verify for reader. # Also verify for reader.
self.assertSameStorage('the-image', first_storage_id, username=READ_ACCESS_USER, repository=PUBLIC_REPO) self.assertSameStorage('the-image', first_storage, username=READ_ACCESS_USER, repository=PUBLIC_REPO)
def test_org_no_access(self): def test_org_no_access(self):
""" An image is not accessible if not a member of the organization. """ """ An image is not accessible if not a member of the organization. """
# Create the new image under the org's repo => new image. # Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo. Since the user is not a member of the organization => new image. # Create an image under a user's repo. Since the user is not a member of the organization => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=RANDOM_USER, repository=RANDOM_REPO) self.assertDifferentStorage('the-image', first_storage, username=RANDOM_USER, repository=RANDOM_REPO)
def test_org_not_team_member_with_access(self): def test_org_not_team_member_with_access(self):
""" An image is accessible to a user specifically listed as having permission on the org repo. """ """ An image is accessible to a user specifically listed as having permission on the org repo. """
# Create the new image under the org's repo => new image. # Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ORG_REPO)
# Create an image under a user's repo. Since the user has read access on that repo, they can see the image => shared image. # Create an image under a user's repo. Since the user has read access on that repo, they can see the image => shared image.
self.assertSameStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO) self.assertSameStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
def test_org_not_team_member_with_no_access(self): def test_org_not_team_member_with_no_access(self):
""" A user that has access to one org repo but not another and is not a team member. """ """ A user that has access to one org repo but not another and is not a team member. """
# Create the new image under the org's repo => new image. # Create the new image under the org's repo => new image.
first_storage_id = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO) first_storage = self.createStorage('the-image', username=ADMIN_ACCESS_USER, repository=ANOTHER_ORG_REPO)
# Create an image under a user's repo. The user doesn't have access to the repo (ANOTHER_ORG_REPO) so => new image. # Create an image under a user's repo. The user doesn't have access to the repo (ANOTHER_ORG_REPO) so => new image.
self.assertDifferentStorage('the-image', first_storage_id, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO) self.assertDifferentStorage('the-image', first_storage, username=OUTSIDE_ORG_USER, repository=OUTSIDE_ORG_REPO)
def test_no_link_to_uploading(self):
still_uploading = self.createStorage('an-image', repository=PUBLIC_REPO)
still_uploading.uploading = True
still_uploading.save()
self.assertDifferentStorage('an-image', still_uploading)

View file

@ -20,7 +20,7 @@ query = (Image
.join(ImageStorage) .join(ImageStorage)
.switch(Image) .switch(Image)
.join(Repository) .join(Repository)
.where(Repository.name == 'userportal', Repository.namespace == 'crsinc')) .where(ImageStorage.uploading == False))
bad_count = 0 bad_count = 0
good_count = 0 good_count = 0

View file

@ -1,38 +0,0 @@
import argparse
import pickle
from Crypto.PublicKey import RSA
from datetime import datetime, timedelta
def encrypt(message, output_filename):
private_key_file = 'conf/stack/license_key'
with open(private_key_file, 'r') as private_key:
encryptor = RSA.importKey(private_key)
encrypted_data = encryptor.decrypt(message)
with open(output_filename, 'wb') as encrypted_file:
encrypted_file.write(encrypted_data)
parser = argparse.ArgumentParser(description='Create a license file.')
parser.add_argument('--users', type=int, default=20,
help='Number of users allowed by the license')
parser.add_argument('--days', type=int, default=30,
help='Number of days for which the license is valid')
parser.add_argument('--warn', type=int, default=7,
help='Number of days prior to expiration to warn users')
parser.add_argument('--output', type=str, required=True,
help='File in which to store the license')
if __name__ == "__main__":
args = parser.parse_args()
print ('Creating license for %s users for %s days in file: %s' %
(args.users, args.days, args.output))
license_data = {
'LICENSE_EXPIRATION': datetime.utcnow() + timedelta(days=args.days),
'LICENSE_USER_LIMIT': args.users,
'LICENSE_EXPIRATION_WARNING': datetime.utcnow() + timedelta(days=(args.days - args.warn)),
}
encrypt(pickle.dumps(license_data, 2), args.output)

View file

@ -1,4 +1,4 @@
from app import stripe import stripe
from app import app from app import app
from util.invoice import renderInvoiceToHtml from util.invoice import renderInvoiceToHtml

View file

@ -0,0 +1,31 @@
FROM phusion/baseimage:0.9.9
ENV DEBIAN_FRONTEND noninteractive
ENV HOME /root
ENV UPDATE_APT 2
RUN apt-get update
# Install LAMP
RUN apt-get install -y lamp-server^
# Install phpMyAdmin
RUN mysqld & \
service apache2 start; \
sleep 5; \
printf y\\n\\n\\n1\\n | apt-get install -y phpmyadmin; \
sleep 15; \
mysqladmin -u root shutdown
# Setup phpmyadmin to run
RUN echo "Include /etc/phpmyadmin/apache.conf" >> /etc/apache2/apache2.conf
RUN rm /etc/phpmyadmin/config.inc.php
ADD config.inc.php /etc/phpmyadmin/config.inc.php
ADD run-admin.sh /etc/service/phpadmin/run
EXPOSE 80
CMD ["/sbin/my_init"]

View file

@ -0,0 +1,59 @@
<?php
/**
* Debian local configuration file
*
* This file overrides the settings made by phpMyAdmin interactive setup
* utility.
*
* For example configuration see
* /usr/share/doc/phpmyadmin/examples/config.sample.inc.php
* or
* /usr/share/doc/phpmyadmin/examples/config.manyhosts.inc.php
*
* NOTE: do not add security sensitive data to this file (like passwords)
* unless you really know what you're doing. If you do, any user that can
* run PHP or CGI on your webserver will be able to read them. If you still
* want to do this, make sure to properly secure the access to this file
* (also on the filesystem level).
*/
// Load secret generated on postinst
include('/var/lib/phpmyadmin/blowfish_secret.inc.php');
// Load autoconf local config
include('/var/lib/phpmyadmin/config.inc.php');
/**
* Server(s) configuration
*/
$i = 0;
// The $cfg['Servers'] array starts with $cfg['Servers'][1]. Do not use $cfg['Servers'][0].
// You can disable a server config entry by setting host to ''.
$i++;
/**
* Read configuration from dbconfig-common
* You can regenerate it using: dpkg-reconfigure -plow phpmyadmin
*/
if (is_readable('/etc/phpmyadmin/config-db.php')) {
require('/etc/phpmyadmin/config-db.php');
} else {
error_log('phpmyadmin: Failed to load /etc/phpmyadmin/config-db.php.'
. ' Check group www-data has read access.');
}
$cfg['Servers'][$i]['auth_type'] = 'HTTP';
$cfg['Servers'][$i]['hide_db'] = '(mysql|information_schema|phpmyadmin)';
/* Server parameters */
$cfg['Servers'][$i]['host'] = 'db1.quay.io';
$cfg['Servers'][$i]['ssl'] = true;
/*
* End of servers configuration
*/
/*
* Directories for saving/loading files from server
*/
$cfg['UploadDir'] = '';
$cfg['SaveDir'] = '';

4
tools/phpmyadmin/run-admin.sh Executable file
View file

@ -0,0 +1,4 @@
#! /bin/bash
service apache2 start
mysqld

View file

@ -1,4 +1,3 @@
from app import stripe
from app import app from app import app
from util.useremails import send_confirmation_email from util.useremails import send_confirmation_email

View file

@ -30,7 +30,11 @@ class SendToMixpanel(Process):
while True: while True:
mp_request = self._mp_queue.get() mp_request = self._mp_queue.get()
logger.debug('Got queued mixpanel reqeust.') logger.debug('Got queued mixpanel reqeust.')
self._consumer.send(*json.loads(mp_request)) try:
self._consumer.send(*json.loads(mp_request))
except:
# Make sure we don't crash if Mixpanel request fails.
pass
class FakeMixpanel(object): class FakeMixpanel(object):

5
util/backoff.py Normal file
View file

@ -0,0 +1,5 @@
def exponential_backoff(attempts, scaling_factor, base):
backoff = 5 * (pow(2, attempts) - 1)
backoff_time = backoff * scaling_factor
retry_at = backoff_time/10 + base
return retry_at

View file

@ -1,55 +0,0 @@
import calendar
import sys
from email.utils import formatdate
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime, timedelta
from data import model
class ExpirationScheduler(object):
def __init__(self, utc_create_notifications_date, utc_terminate_processes_date):
self._scheduler = BackgroundScheduler()
self._termination_date = utc_terminate_processes_date
soon = datetime.now() + timedelta(seconds=1)
if utc_create_notifications_date > datetime.utcnow():
self._scheduler.add_job(model.delete_all_notifications_by_kind, 'date', run_date=soon,
args=['expiring_license'])
local_notifications_date = self._utc_to_local(utc_create_notifications_date)
self._scheduler.add_job(self._generate_notifications, 'date',
run_date=local_notifications_date)
else:
self._scheduler.add_job(self._generate_notifications, 'date', run_date=soon)
local_termination_date = self._utc_to_local(utc_terminate_processes_date)
self._scheduler.add_job(self._terminate, 'date', run_date=local_termination_date)
@staticmethod
def _format_date(date):
""" Output an RFC822 date format. """
if date is None:
return None
return formatdate(calendar.timegm(date.utctimetuple()))
@staticmethod
def _utc_to_local(utc_dt):
# get integer timestamp to avoid precision lost
timestamp = calendar.timegm(utc_dt.timetuple())
local_dt = datetime.fromtimestamp(timestamp)
return local_dt.replace(microsecond=utc_dt.microsecond)
def _generate_notifications(self):
for user in model.get_active_users():
model.create_unique_notification('expiring_license', user,
{'expires_at': self._format_date(self._termination_date)})
@staticmethod
def _terminate():
sys.exit(1)
def start(self):
self._scheduler.start()

Some files were not shown because too many files have changed in this diff Show more