diff --git a/.dockerignore b/.dockerignore index fcc890f76..40ff6c49f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,11 @@ conf/stack screenshots +tools test/data/registry venv .git .gitignore Bobfile README.md -license.py requirements-nover.txt -run-local.sh +run-local.sh \ No newline at end of file diff --git a/Dockerfile.buildworker b/Dockerfile.buildworker index c18c24589..04efe38f0 100644 --- a/Dockerfile.buildworker +++ b/Dockerfile.buildworker @@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive ENV HOME /root # 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 -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 ADD requirements.txt requirements.txt diff --git a/Dockerfile.web b/Dockerfile.web index c9ba36823..e1d253632 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -4,10 +4,10 @@ ENV DEBIAN_FRONTEND noninteractive ENV HOME /root # 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 -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 ADD requirements.txt requirements.txt @@ -30,6 +30,7 @@ RUN cd grunt && npm install RUN cd grunt && grunt 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/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/notificationworker /etc/service/notificationworker -# TODO: Remove this after the prod CL push -ADD conf/init/webhookworker /etc/service/webhookworker - # Download any external libs. RUN mkdir static/fonts static/ldn RUN venv/bin/python -m external_libraries diff --git a/app.py b/app.py index 6a3d12b0d..81c59a30c 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,9 @@ import logging import os 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.login import LoginManager from flask.ext.mail import Mail @@ -21,11 +22,37 @@ from data.billing import Billing from data.buildlogs import BuildLogs from data.queue import WorkQueue from data.userevent import UserEventsBuilderModule -from license import load_license 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' LICENSE_FILENAME = 'conf/stack/license.enc' @@ -43,22 +70,17 @@ else: logger.debug('Loading default config.') app.config.from_object(DefaultConfig()) - if os.path.exists(OVERRIDE_CONFIG_FILENAME): - logger.debug('Applying config file: %s', OVERRIDE_CONFIG_FILENAME) - app.config.from_pyfile(OVERRIDE_CONFIG_FILENAME) + if os.path.exists(OVERRIDE_CONFIG_PY_FILENAME): + logger.debug('Applying config file: %s', OVERRIDE_CONFIG_PY_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, '{}')) 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) Principal(app, use_sessions=False) diff --git a/conf/gunicorn_config.py b/conf/gunicorn_config.py index 4d9d50499..ca8ad5363 100644 --- a/conf/gunicorn_config.py +++ b/conf/gunicorn_config.py @@ -1,5 +1,5 @@ bind = 'unix:/tmp/gunicorn.sock' -workers = 8 +workers = 16 worker_class = 'gevent' timeout = 2000 logconfig = 'conf/logging.conf' diff --git a/conf/init/doupdatelimits.sh b/conf/init/doupdatelimits.sh new file mode 100755 index 000000000..603559de0 --- /dev/null +++ b/conf/init/doupdatelimits.sh @@ -0,0 +1,5 @@ +#! /bin/bash +set -e + +# Update the connection limit +sysctl -w net.core.somaxconn=1024 \ No newline at end of file diff --git a/conf/init/webhookworker/log/run b/conf/init/webhookworker/log/run deleted file mode 100755 index 6738f16f8..000000000 --- a/conf/init/webhookworker/log/run +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec svlogd -t /var/log/webhookworker/ \ No newline at end of file diff --git a/conf/init/webhookworker/run b/conf/init/webhookworker/run deleted file mode 100755 index 04521552a..000000000 --- a/conf/init/webhookworker/run +++ /dev/null @@ -1,8 +0,0 @@ -#! /bin/bash - -echo 'Starting webhook worker' - -cd / -venv/bin/python -m workers.webhookworker - -echo 'Webhook worker exited' \ No newline at end of file diff --git a/conf/server-base.conf b/conf/server-base.conf index 6aeaa689e..a13cf1424 100644 --- a/conf/server-base.conf +++ b/conf/server-base.conf @@ -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; server_name _; diff --git a/config.py b/config.py index 708449714..f797cb36a 100644 --- a/config.py +++ b/config.py @@ -163,6 +163,9 @@ class DefaultConfig(object): # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False + # Feature Flag: Dockerfile build support. + FEATURE_BUILD_SUPPORT = True + DISTRIBUTED_STORAGE_CONFIG = { 'local_eu': ['LocalStorage', {'storage_path': 'test/data/registry/eu'}], 'local_us': ['LocalStorage', {'storage_path': 'test/data/registry/us'}], diff --git a/data/billing.py b/data/billing.py index 220dfe999..4847dd3f8 100644 --- a/data/billing.py +++ b/data/billing.py @@ -41,6 +41,15 @@ PLANS = [ 'bus_features': False, 'deprecated': True, }, + { + 'title': 'Yacht', + 'price': 5000, + 'privateRepos': 20, + 'stripeId': 'bus-small', + 'audience': 'For small businesses', + 'bus_features': True, + 'deprecated': True, + }, # Active plans { @@ -74,7 +83,7 @@ PLANS = [ 'title': 'Yacht', 'price': 5000, 'privateRepos': 20, - 'stripeId': 'bus-small', + 'stripeId': 'bus-coreos-trial', 'audience': 'For small businesses', 'bus_features': True, 'deprecated': False, diff --git a/data/database.py b/data/database.py index 6099cf5d9..4731a06bb 100644 --- a/data/database.py +++ b/data/database.py @@ -17,6 +17,8 @@ SCHEME_DRIVERS = { 'mysql': MySQLDatabase, 'mysql+pymysql': MySQLDatabase, 'sqlite': SqliteDatabase, + 'postgresql': PostgresqlDatabase, + 'postgresql+psycopg2': PostgresqlDatabase, } db = Proxy() @@ -32,7 +34,7 @@ def _db_from_url(url, db_kwargs): if parsed_url.username: db_kwargs['user'] = parsed_url.username 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) @@ -74,6 +76,8 @@ class User(BaseModel): organization = BooleanField(default=False, index=True) robot = BooleanField(default=False, index=True) invoice_email = BooleanField(default=False) + invalid_login_attempts = IntegerField(default=0) + last_invalid_login = DateTimeField(default=datetime.utcnow) class TeamRole(BaseModel): diff --git a/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py new file mode 100644 index 000000000..6ee041e4c --- /dev/null +++ b/data/migrations/versions/43e943c0639f_add_log_kind_for_regenerating_robot_.py @@ -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'))) + + ) diff --git a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py index 6f516e9b9..726145167 100644 --- a/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py +++ b/data/migrations/versions/47670cbeced_migrate_existing_webhooks_to_.py @@ -20,12 +20,12 @@ def get_id(query): def upgrade(): conn = op.get_bind() - 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') + 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') 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(): conn = op.get_bind() - 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') + 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') 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)) diff --git a/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py new file mode 100644 index 000000000..a1c8c95dd --- /dev/null +++ b/data/migrations/versions/4fdb65816b8d_add_brute_force_prevention_metadata_to_.py @@ -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 ### diff --git a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py index 23aaf506a..ffc9d28e6 100644 --- a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py +++ b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py @@ -203,7 +203,7 @@ def upgrade(): sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_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(['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint('id') @@ -375,7 +375,7 @@ def upgrade(): sa.Column('command', sa.Text(), nullable=True), sa.Column('repository_id', sa.Integer(), nullable=False), 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.ForeignKeyConstraint(['repository_id'], ['repository.id'], ), sa.ForeignKeyConstraint(['storage_id'], ['imagestorage.id'], ), diff --git a/data/migrations/versions/82297d834ad_add_us_west_location.py b/data/migrations/versions/82297d834ad_add_us_west_location.py new file mode 100644 index 000000000..59eb1f800 --- /dev/null +++ b/data/migrations/versions/82297d834ad_add_us_west_location.py @@ -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'))) + + ) diff --git a/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py b/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py index 6c405dc1b..eda4b2840 100644 --- a/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py +++ b/data/migrations/versions/bcdde200a1b_add_placements_and_locations_to_the_db.py @@ -35,6 +35,7 @@ def upgrade(): {'id':4, 'name':'s3_ap_southeast_2'}, {'id':5, 'name':'s3_ap_northeast_1'}, {'id':6, 'name':'s3_sa_east_1'}, + {'id':7, 'name':'local'}, ]) op.create_table('imagestorageplacement', diff --git a/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py new file mode 100644 index 000000000..79ea17be0 --- /dev/null +++ b/data/migrations/versions/f42b0ea7a4d_remove_the_old_webhooks_table.py @@ -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 ### diff --git a/data/model/legacy.py b/data/model/legacy.py index bfa310046..64bcdc860 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1,12 +1,17 @@ import bcrypt import logging -import datetime import dateutil.parser import json +from datetime import datetime, timedelta + from data.database import * from util.validation import * from util.names import format_robot_username +from util.backoff import exponential_backoff + + +EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1) logger = logging.getLogger(__name__) @@ -68,10 +73,15 @@ class TooManyUsersException(DataModelException): pass -def is_create_user_allowed(): - return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] +class TooManyLoginAttemptsException(Exception): + 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): """ Creates a regular user, if allowed. """ if not validate_password(password): @@ -181,6 +191,19 @@ def create_robot(robot_shortname, parent): except Exception as ex: 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): joined = User.select().join(FederatedLogin).join(LoginService) @@ -191,7 +214,6 @@ def lookup_robot(robot_username): return found[0] - def verify_robot(robot_username, password): joined = User.select().join(FederatedLogin).join(LoginService) found = list(joined.where(FederatedLogin.service_ident == password, @@ -204,6 +226,25 @@ def verify_robot(robot_username, password): 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): try: @@ -524,11 +565,30 @@ def verify_user(username_or_email, password): except User.DoesNotExist: 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 bcrypt.hashpw(password, fetched.password_hash) == fetched.password_hash): + + if fetched.invalid_login_attempts > 0: + fetched.invalid_login_attempts = 0 + fetched.save() + return fetched + fetched.invalid_login_attempts += 1 + fetched.last_invalid_login = now + fetched.save() + # We weren't able to authorize the user return None @@ -828,6 +888,34 @@ def get_all_repo_users(namespace_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): try: return (Repository @@ -982,7 +1070,8 @@ def find_create_or_link_image(docker_image_id, repository, username, translation .join(Repository) .join(Visibility) .switch(Repository) - .join(RepositoryPermission, JOIN_LEFT_OUTER)) + .join(RepositoryPermission, JOIN_LEFT_OUTER) + .where(ImageStorage.uploading == False)) query = (_filter_to_repos_for_user(query, username) .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={}): 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) 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: return None 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() AdminTeam = Team.alias() AdminTeamMember = TeamMember.alias() @@ -1712,6 +1802,11 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F .switch(Notification) .where(Notification.uuid == id_filter)) + if page: + query = query.paginate(page, limit) + elif limit: + query = query.limit(limit) + return query diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index e8dab28dc..9f9a8c941 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,7 +1,8 @@ import logging 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.utils.cors import crossdomain from werkzeug.exceptions import HTTPException @@ -66,6 +67,11 @@ class Unauthorized(ApiException): 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): def __init__(self, payload=None): ApiException.__init__(self, None, 402, 'Payment Required', payload) @@ -87,6 +93,14 @@ def handle_api_error(error): 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 wrapper(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_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): diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 3e13df6b6..c41bcec77 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -4,7 +4,7 @@ from flask import request from app import billing from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, related_user_resource, internal_only, Unauthorized, NotFound, - require_user_admin, show_if, hide_if) + require_user_admin, show_if, hide_if, abort) from endpoints.api.subscribe import subscribe, subscription_view from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user @@ -23,7 +23,11 @@ def get_card(user): } 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: # Find the default card. default_card = None @@ -46,7 +50,11 @@ def get_card(user): def set_card(user, token): 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: try: cus.card = token @@ -55,6 +63,8 @@ def set_card(user, token): return carderror_response(exc) except stripe.InvalidRequestError as exc: return carderror_response(exc) + except stripe.APIConnectionError as e: + return carderror_response(e) 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 } - 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 { '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) 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: return subscription_view(cus.subscription, private_repos) @@ -291,7 +308,10 @@ class OrganizationPlan(ApiResource): private_repos = model.get_private_repo_count(orgname) organization = model.get_organization(orgname) 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: return subscription_view(cus.subscription, private_repos) diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index ee8702636..1995c6b42 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -119,6 +119,11 @@ def swagger_route_data(include_internal=False, compact=False): if internal is not None: 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): operations.append(new_operation) diff --git a/endpoints/api/image.py b/endpoints/api/image.py index cdf06c6e2..3060053ad 100644 --- a/endpoints/api/image.py +++ b/endpoints/api/image.py @@ -24,6 +24,7 @@ def image_view(image): 'dbid': image.id, 'size': extended_props.image_size, 'locations': list(image.storage.locations), + 'uploading': image.storage.uploading, } diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py index df01f1a0d..b52cd4c5b 100644 --- a/endpoints/api/robot.py +++ b/endpoints/api/robot.py @@ -35,6 +35,14 @@ class UserRobotList(ApiResource): @internal_only class UserRobot(ApiResource): """ 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 @nickname('createUserRobot') def put(self, robot_shortname): @@ -79,6 +87,18 @@ class OrgRobotList(ApiResource): @related_user_resource(UserRobot) class OrgRobot(ApiResource): """ 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) @nickname('createOrgRobot') def put(self, orgname, robot_shortname): @@ -103,3 +123,38 @@ class OrgRobot(ApiResource): return 'Deleted', 204 raise Unauthorized() + + +@resource('/v1/user/robots//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//robots//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() diff --git a/endpoints/api/subscribe.py b/endpoints/api/subscribe.py index dd6de9678..2c3fba359 100644 --- a/endpoints/api/subscribe.py +++ b/endpoints/api/subscribe.py @@ -15,6 +15,9 @@ logger = logging.getLogger(__name__) def carderror_response(exc): 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): view = { @@ -74,19 +77,29 @@ def subscribe(user, plan, token, require_business_plan): log_action('account_change_plan', user.username, {'plan': plan}) except stripe.CardError as e: return carderror_response(e) + except stripe.APIConnectionError as e: + return connection_response(e) response_json = subscription_view(cus.subscription, private_repos) status_code = 201 else: # 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 cus.subscription is not None: # We only have to cancel the subscription if they actually have one - cus.cancel_subscription() - cus.save() + try: + cus.cancel_subscription() + cus.save() + except stripe.APIConnectionError as e: + return connection_response(e) + + check_repository_usage(user, plan_found) log_action('account_change_plan', user.username, {'plan': plan}) @@ -101,6 +114,8 @@ def subscribe(user, plan, token, require_business_plan): cus.save() except stripe.CardError as e: return carderror_response(e) + except stripe.APIConnectionError as e: + return connection_response(e) response_json = subscription_view(cus.subscription, private_repos) check_repository_usage(user, plan_found) diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 3ade5f1ed..5a117289b 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -42,24 +42,6 @@ class SuperUserLogs(ApiResource): 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): return { 'username': user.username, diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index 3203e4a79..4ec20bfdc 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -350,8 +350,8 @@ class BuildTriggerAnalyze(RepositoryParamResource): (robot_namespace, shortname) = parse_robot_username(user.username) return AdministerOrganizationPermission(robot_namespace).can() - repo_perms = model.get_all_repo_users(base_namespace, base_repository) - read_robots = [robot_view(perm.user) for perm in repo_perms if is_valid_robot(perm.user)] + repo_users = list(model.get_all_repo_users_transitive(base_namespace, base_repository)) + read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)] return { 'namespace': base_namespace, diff --git a/endpoints/api/user.py b/endpoints/api/user.py index fb09d012a..ddf05aafa 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -7,8 +7,9 @@ from flask.ext.principal import identity_changed, AnonymousIdentity from app import app, billing as stripe, authentication from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, - log_action, internal_only, NotFound, require_user_admin, - InvalidToken, require_scope, format_date, hide_if, show_if, license_error) + log_action, internal_only, NotFound, require_user_admin, parse_args, + query_param, InvalidToken, require_scope, format_date, hide_if, show_if, + license_error, require_fresh_login) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from data import model @@ -150,6 +151,7 @@ class User(ApiResource): return user_view(user) @require_user_admin + @require_fresh_login @nickname('changeUserDetails') @internal_only @validate_json_request('UpdateUser') @@ -158,7 +160,7 @@ class User(ApiResource): user = get_authenticated_user() user_data = request.get_json() - try: + try: if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) @@ -363,6 +365,37 @@ class Signin(ApiResource): 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') @internal_only class Signout(ApiResource): @@ -410,11 +443,24 @@ class Recovery(ApiResource): @internal_only class UserNotificationList(ApiResource): @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') - def get(self): - notifications = model.list_notifications(get_authenticated_user()) + def get(self, args): + 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 { - 'notifications': [notification_view(notification) for notification in notifications] + 'notifications': [notification_view(notification) for notification in notifications], + 'additional': has_more } diff --git a/endpoints/common.py b/endpoints/common.py index fe09104ca..52715a1d1 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -2,8 +2,9 @@ import logging import urlparse import json 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.principal import identity_changed from random import SystemRandom @@ -112,6 +113,7 @@ def common_login(db_user): logger.debug('Successfully signed in as: %s' % db_user.username) new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN}) identity_changed.send(app, identity=new_identity) + session['login_time'] = datetime.datetime.now() return True else: logger.debug('User could not be logged in, inactive?.') diff --git a/endpoints/index.py b/endpoints/index.py index 39327e6a8..4017d47e9 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -413,8 +413,39 @@ def put_repository_auth(namespace, repository): @index.route('/search', methods=['GET']) +@process_auth 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') diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index f1cbec42c..e393dc134 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -184,6 +184,8 @@ class BuildFailureEvent(NotificationEvent): return 'build_failure' def get_sample_data(self, repository): + build_uuid = 'fake-build-id' + return build_event_data(repository, { 'build_id': build_uuid, 'build_name': 'some-fake-build', diff --git a/endpoints/registry.py b/endpoints/registry.py index 19eedf1ce..72633939e 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -37,6 +37,9 @@ class SocketReader(object): handler(buf) return buf + def tell(self): + raise IOError('Stream is not seekable.') + def image_is_uploading(repo_image): 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: store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id])) return + data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid)) data = json.loads(data) 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) 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') return make_response('true', 200) diff --git a/grunt/Gruntfile.js b/grunt/Gruntfile.js index b3d8b4e2d..3799ebc74 100644 --- a/grunt/Gruntfile.js +++ b/grunt/Gruntfile.js @@ -60,7 +60,8 @@ module.exports = function(grunt) { removeEmptyAttributes: true, removeRedundantAttributes: true, removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true + removeStyleLinkTypeAttributes: true, + keepClosingSlash: true // For inline SVG } }, quay: { diff --git a/initdb.py b/initdb.py index 5d10a2039..eb72982f5 100644 --- a/initdb.py +++ b/initdb.py @@ -231,13 +231,15 @@ def initialize_database(): LogEntryKind.create(name='delete_application') 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='delete_repo_webhook') LogEntryKind.create(name='add_repo_notification') LogEntryKind.create(name='delete_repo_notification') + LogEntryKind.create(name='regenerate_robot_token') + ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_us') diff --git a/license.py b/license.py deleted file mode 100644 index b45d90cf8..000000000 --- a/license.py +++ /dev/null @@ -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]) diff --git a/license.pyc b/license.pyc deleted file mode 100644 index 83687adfa..000000000 Binary files a/license.pyc and /dev/null differ diff --git a/requirements-nover.txt b/requirements-nover.txt index c0979629b..a3c74e89b 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -32,5 +32,7 @@ raven python-ldap pycrypto logentries +psycopg2 +pyyaml git+https://github.com/DevTable/aniso8601-fake.git git+https://github.com/DevTable/anunidecode.git diff --git a/requirements.txt b/requirements.txt index 090ade690..e454e6846 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ Pillow==2.5.1 PyGithub==1.25.0 PyMySQL==0.6.2 PyPDF2==1.22 +PyYAML==3.11 SQLAlchemy==0.9.7 Werkzeug==0.9.6 alembic==0.6.5 @@ -44,6 +45,7 @@ python-dateutil==2.2 python-ldap==2.4.15 python-magic==0.4.6 pytz==2014.4 +psycopg2==2.5.3 raven==5.0.0 redis==2.10.1 reportlab==2.7 diff --git a/static/css/quay.css b/static/css/quay.css index 7cf6ed02a..224029444 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -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 { border: 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 { - color: white; 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 { - color: #BEE1FF; + background: #eee; } .navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:hover, .navbar-default .navbar-nav>.open>a:focus { cursor: pointer; background: rgba(255, 255, 255, 0.4) !important; - color: white; } .notification-view-element { @@ -366,6 +464,22 @@ i.toggle-icon:hover { .docker-auth-dialog .token-dialog-body .well { 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 { @@ -399,17 +513,6 @@ i.toggle-icon:hover { margin: 0 auto -176px; } -.footer-container, .push { - height: 100px; -} - -.footer-container.fixed { - position: fixed; - bottom: 16px; - left: 0px; - right: 0px; -} - .button-hidden { visibility: hidden; } @@ -489,6 +592,14 @@ i.toggle-icon:hover { 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 { white-space: nowrap; } @@ -634,7 +745,7 @@ i.toggle-icon:hover { } .user-notification.notification-animated { - width: 21px; + min-width: 21px; transform: scale(0); -moz-transform: scale(0); @@ -671,12 +782,12 @@ i.toggle-icon:hover { .user-tools .user-tool { font-size: 24px; margin-top: 14px; - color: white; + color: #428bca; } .user-tools i.user-tool:hover { cursor: pointer; - color: #BEE1FF; + color: #333; } .status-box a { @@ -728,7 +839,7 @@ i.toggle-icon:hover { 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; } @@ -1000,12 +1111,12 @@ i.toggle-icon:hover { 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; 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; } @@ -1040,6 +1151,10 @@ i.toggle-icon:hover { display: inline; } +.hidden-xs-inline { + display: inline; +} + @media (min-width: 991px) { .visible-md-inline { display: inline; @@ -1056,6 +1171,13 @@ i.toggle-icon:hover { } } +@media (max-width: 700px) { + .hidden-xs-inline { + display: none; + } +} + + .visible-xl { display: none; } @@ -1085,7 +1207,7 @@ i.toggle-icon:hover { 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; } @@ -1230,14 +1352,64 @@ i.toggle-icon:hover { margin-right: 5px; } -.plans .plan-faq dd{ +.plans .plan-faq dd { 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 { padding: 20px; } +.jumbotron { + background: transparent; +} + .jumbotron .disclaimer-link { font-size: .3em; vertical-align: 23px; @@ -1255,25 +1427,19 @@ i.toggle-icon:hover { color: #555; } -.landing-page .wrapper > nav { - display: none; +.landing-page #padding-container { + padding: 0px; } -.landing-page .nav > li > a { - border-radius: 4px; +.landing-page .main-panel { + padding: 0px; + border: 0px; + padding-bottom: 10px; } -.landing-page .nav > li > a:hover, .landing-page .nav > li > a:focus { - background: rgba(255, 255, 255, 0.4); -} - -.landing-page .nav .user-view { - color: white !important; - font-weight: bold; -} - -.landing-page .user-tool { - color: white; +.landing-page.signedin .main-panel { + background: transparent; + box-shadow: none; } .landing { @@ -1284,10 +1450,6 @@ i.toggle-icon:hover { font-size: 14px; } -.landing-content { - -} - .landing-background { z-index: 0; @@ -1297,10 +1459,13 @@ i.toggle-icon:hover { left: 0px; right: 0px; - background-color: #1d1d1d; - background-image: url(../img/landing-back-opt.jpg); - background-repeat: no-repeat; - background-size: cover; + 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%; +} + +.landing-page.signedin .landing-background { + background: transparent; } .landing-filter { @@ -1320,6 +1485,55 @@ i.toggle-icon:hover { 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 { color: white; @@ -1346,104 +1560,6 @@ i.toggle-icon:hover { 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 { font-size: 48px; } @@ -1651,10 +1767,15 @@ form input.ng-valid.ng-dirty, font-size: 26px; } -.page-footer { - padding: 10px; - padding-bottom: 0px; - border-top: 1px solid #eee; +.footer-container, .push { + height: 100px; +} + +.footer-container.fixed { + position: fixed; + bottom: 16px; + left: 0px; + right: 0px; } .page-footer-padder { @@ -1838,6 +1959,7 @@ p.editable:hover i { } .right-tag-controls { + cursor: default; display: inline-block; float: right; padding: 4px; @@ -2151,6 +2273,14 @@ p.editable:hover i { position: relative; } +.copy-box-element.disabled .input-group-addon { + display: none; +} + +.copy-box-element.disabled input { + border-radius: 4px !important; +} + .global-zeroclipboard-container embed { cursor: pointer; } @@ -3141,6 +3271,10 @@ p.editable:hover i { display: inline-block; } +.table-container { + max-width: 100%; +} + .billing-invoices-element .invoice-title { padding: 6px; cursor: pointer; diff --git a/static/directives/copy-box.html b/static/directives/copy-box.html index 1d996cc31..07dea7407 100644 --- a/static/directives/copy-box.html +++ b/static/directives/copy-box.html @@ -1,4 +1,4 @@ -
+
diff --git a/static/directives/docker-auth-dialog.html b/static/directives/docker-auth-dialog.html index dcb71a25b..e45b8967d 100644 --- a/static/directives/docker-auth-dialog.html +++ b/static/directives/docker-auth-dialog.html @@ -10,19 +10,33 @@
- + +
@@ -77,5 +78,6 @@
Description
+
diff --git a/static/directives/notification-bar.html b/static/directives/notification-bar.html index 5d25a40b4..c6841a7f5 100644 --- a/static/directives/notification-bar.html +++ b/static/directives/notification-bar.html @@ -3,7 +3,10 @@
-

Notifications

+

+ Notifications + +

diff --git a/static/directives/notifications-bubble.html b/static/directives/notifications-bubble.html new file mode 100644 index 000000000..cf10cccf2 --- /dev/null +++ b/static/directives/notifications-bubble.html @@ -0,0 +1,7 @@ + + + {{ notificationService.notifications.length }}+ + + diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index c696937d2..c11c07cf8 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -31,7 +31,7 @@
+ shown="!!shownRobot" counter="showRobotCounter" supports-regenerate="true" regenerate="regenerateToken(username)"> {{ shownRobot.name }}
diff --git a/static/directives/signin-form.html b/static/directives/signin-form.html index ec57619a8..de67c18f6 100644 --- a/static/directives/signin-form.html +++ b/static/directives/signin-form.html @@ -4,16 +4,23 @@ placeholder="Username or E-mail Address" ng-model="user.username" autofocus> - - - - - - +
+ Too many attempts have been made to login. Please try again in {{ tryAgainSoon }} seconds. +
+ + + + + + + + + +
Invalid username or password.
diff --git a/static/directives/signup-form.html b/static/directives/signup-form.html index 249bff31c..112c90e1b 100644 --- a/static/directives/signup-form.html +++ b/static/directives/signup-form.html @@ -20,7 +20,6 @@ -

No credit card required.

diff --git a/static/directives/tour-content.html b/static/directives/tour-content.html index 79a197b3e..a3ccb6647 100644 --- a/static/directives/tour-content.html +++ b/static/directives/tour-content.html @@ -226,71 +226,208 @@ -
+
-
Quay.io in your data center
+
Run Quay.io Behind Your Firewall
- All of the power of Quay.io, easily deployed to your data center via docker. -
-
-
-
docker run quay.io/quay/enterprise
+
+
+ + + + +
+ Quay.io has partnered with CoreOS to offer Enterprise Registry, a version + of Quay.io that can be hosted behind your firewall.
+
-
-
- - - - -
-
-
Take control of your own security
-
- The nature of machine images is that they often contain keys and passwords. We're pretty proud of the security 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. +
+
+
+ + + + + + + + + +
+

Deployment Made Easy

+

Trigger container builds when your code is checked into Github and passes tests. Automatically pushed into your repository for immediate access by your servers.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+

Teamwork Optimized

+

Control how the different teams and projects within your enterprise collaborate on repositories.

+
+
+
+ + + + + + + + + + + + + + + +
+

Secure, Private Storage

+

Containers often contain keys and passwords — take control of your registry by running it behind your firewall on CoreOS Managed Linux.

-
-
-
- - - - -
-
-
Auditing and insight done right
-
- 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. -
-
-
- -
-
- - - - -
-
-
Organize your repositories like you organize your business
-
- The built in teams and organizations features of allow you to finely control how the different teams and projects within your enterprise collaborate on repositories. -
-
-
- - diff --git a/static/img/black-horizontal.svg b/static/img/black-horizontal.svg new file mode 100644 index 000000000..b299889ce --- /dev/null +++ b/static/img/black-horizontal.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/build-history.png b/static/img/build-history.png index e53eb0301..467eb141a 100644 Binary files a/static/img/build-history.png and b/static/img/build-history.png differ diff --git a/static/img/coreos-globe-color-lg.png b/static/img/coreos-globe-color-lg.png new file mode 100644 index 000000000..d3bcfb4fb Binary files /dev/null and b/static/img/coreos-globe-color-lg.png differ diff --git a/static/img/coreos-wordmark-horiz-white.svg b/static/img/coreos-wordmark-horiz-white.svg new file mode 100644 index 000000000..8e01fcc82 --- /dev/null +++ b/static/img/coreos-wordmark-horiz-white.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/coreos.svg b/static/img/coreos.svg new file mode 100644 index 000000000..117352ab2 --- /dev/null +++ b/static/img/coreos.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/image-view.png b/static/img/image-view.png new file mode 100644 index 000000000..4638f4fb2 Binary files /dev/null and b/static/img/image-view.png differ diff --git a/static/img/network-tile.png b/static/img/network-tile.png new file mode 100644 index 000000000..afa4947d9 Binary files /dev/null and b/static/img/network-tile.png differ diff --git a/static/img/org-admin.png b/static/img/org-admin.png index f7bb9394f..010c2ecc9 100644 Binary files a/static/img/org-admin.png and b/static/img/org-admin.png differ diff --git a/static/img/org-logs.png b/static/img/org-logs.png index 9f6a14a22..2e64e4497 100644 Binary files a/static/img/org-logs.png and b/static/img/org-logs.png differ diff --git a/static/img/org-repo-admin.png b/static/img/org-repo-admin.png index 466633e97..dec0064cb 100644 Binary files a/static/img/org-repo-admin.png and b/static/img/org-repo-admin.png differ diff --git a/static/img/org-repo-list.png b/static/img/org-repo-list.png index dac3fd1d5..ad3c7de82 100644 Binary files a/static/img/org-repo-list.png and b/static/img/org-repo-list.png differ diff --git a/static/img/org-teams.png b/static/img/org-teams.png index da847f59f..746607523 100644 Binary files a/static/img/org-teams.png and b/static/img/org-teams.png differ diff --git a/static/img/repo-admin.png b/static/img/repo-admin.png index 91903e1cf..c322bf8df 100644 Binary files a/static/img/repo-admin.png and b/static/img/repo-admin.png differ diff --git a/static/img/repo-changes.png b/static/img/repo-changes.png index 6565ef21d..5cffeee26 100644 Binary files a/static/img/repo-changes.png and b/static/img/repo-changes.png differ diff --git a/static/img/repo-view.png b/static/img/repo-view.png index 7e52dd66a..6048025ac 100644 Binary files a/static/img/repo-view.png and b/static/img/repo-view.png differ diff --git a/static/img/user-home.png b/static/img/user-home.png index facc8a23d..230b02c63 100644 Binary files a/static/img/user-home.png and b/static/img/user-home.png differ diff --git a/static/js/app.js b/static/js/app.js index 978012bb6..c84acace2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,6 +1,46 @@ var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$'; 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) { var url = ''; for (var i = 0; i < arguments.length; ++i) { @@ -59,18 +99,8 @@ function getFirstTextLine(commentString) { } function createRobotAccount(ApiService, is_org, orgname, name, callback) { - ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}).then(callback, function(resp) { - bootbox.dialog({ - "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" - } - } - }); - }); + ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}) + .then(callback, ApiService.errorDisplay('Cannot create robot account')); } function createOrganizationTeam(ApiService, orgname, teamname, callback) { @@ -84,18 +114,8 @@ function createOrganizationTeam(ApiService, orgname, teamname, callback) { 'teamname': teamname }; - ApiService.updateOrganizationTeam(data, params).then(callback, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data : 'The team could not be created', - "title": "Cannot create team", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + ApiService.updateOrganizationTeam(data, params) + .then(callback, ApiService.errorDisplay('Cannot create team')); } 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. */ $provide.factory('AngularViewArray', ['$interval', function($interval) { - var ADDTIONAL_COUNT = 50; + var ADDTIONAL_COUNT = 20; function _ViewArray() { this.isVisible = false; @@ -364,7 +384,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading var uiService = {}; uiService.hidePopover = function(elem) { - var popover = $('#signupButton').data('bs.popover'); + var popover = $(elem).data('bs.popover'); if (popover) { popover.hide(); } @@ -398,15 +418,19 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading $provide.factory('UtilService', ['$sanitize', function($sanitize) { var utilService = {}; - - utilService.textToSafeHtml = function(text) { + + utilService.escapeHtmlString = function(text) { var adjusted = text.replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); - return $sanitize(adjusted); + return adjusted; + }; + + utilService.textToSafeHtml = function(text) { + return $sanitize(utilService.escapeHtmlString(text)); }; return utilService; @@ -417,6 +441,29 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading var pingService = {}; 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) { // Lookup the cached ping data, if any. 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 (ping < 0) { cached['pings'] = [-1]; - setTimeout(function() { - $scope.$apply(function() { - callback(-1, false, -1); - }); - }, 0); + invokeCallback($scope, pings, callback); return; } // Otherwise, add the current ping and determine the average. cached['pings'].push(ping); - var sum = 0; - for (var i = 0; i < cached['pings'].length; ++i) { - 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); + // Invoke the callback. + invokeCallback($scope, cached['pings'], callback); // Schedule another check if we've done less than three. if (cached['pings'].length < 3) { @@ -486,12 +520,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading pingService.pingUrl = function($scope, url, callback) { if (pingCache[url]) { - cached = pingCache[url]; - setTimeout(function() { - $scope.$apply(function() { - callback(cached.result, cached.success); - }); - }, 0); + invokeCallback($scope, pingCache[url]['pings'], callback); return; } @@ -526,7 +555,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return builderService; }]); - $provide.factory('StringBuilderService', ['$sce', function($sce) { + $provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) { var stringBuilderService = {}; stringBuilderService.buildString = function(value_or_func, metadata) { @@ -581,6 +610,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading if (key.indexOf('image') >= 0) { value = value.substr(0, 12); } + + var safe = UtilService.escapeHtmlString(value); var markedDown = getMarkedDown(value); markedDown = markedDown.substr('

'.length, markedDown.length - '

'.length); @@ -589,7 +620,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading markedDown = '' + markedDown; } - description = description.replace('{' + key + '}', '' + markedDown + ''); + description = description.replace('{' + key + '}', '' + markedDown + ''); } } return $sce.trustAsHtml(description.replace('\n', '
')); @@ -682,7 +713,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return config; }]); - $provide.factory('ApiService', ['Restangular', function(Restangular) { + $provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) { var apiService = {}; 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:' + + '
' + + '' + + '
', + "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 method = operation['method'].toLowerCase(); var operationName = operation['nickname']; @@ -792,7 +882,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading '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. @@ -841,6 +939,38 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 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; }]); @@ -1097,7 +1227,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'user': null, 'notifications': [], 'notificationClasses': [], - 'notificationSummaries': [] + 'notificationSummaries': [], + 'additionalNotifications': false }; var pollTimerHandle = null; @@ -1193,7 +1324,9 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading '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); if (index >= 0) { @@ -1250,6 +1383,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading ApiService.listUserNotifications().then(function(resp) { notificationService.notifications = resp['notifications']; + notificationService.additionalNotifications = resp['additional']; 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 (callbacks['started']) { @@ -1525,7 +1659,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading planService.getCardInfo(orgname, function(cardInfo) { if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) { 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; } @@ -1598,9 +1732,34 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 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 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']) { 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/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}). 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', reloadOnSearch: false, controller: UserAdminCtrl}). 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) { return { priority: 580, @@ -2018,18 +2197,7 @@ quayApp.directive('applicationReference', function () { template: '/static/directives/application-reference-dialog.html', show: true }); - }, function() { - 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" - } - } - }); - }); + }, ApiService.errorDisplay('Application could not be found')); }; } }; @@ -2110,6 +2278,8 @@ quayApp.directive('copyBox', function () { 'hoveringMessage': '=hoveringMessage' }, controller: function($scope, $element, $rootScope) { + $scope.disabled = false; + var number = $rootScope.__copyBoxIdCounter || 0; $rootScope.__copyBoxIdCounter = number + 1; $scope.inputId = "copy-box-input-" + number; @@ -2119,27 +2289,7 @@ quayApp.directive('copyBox', function () { input.attr('id', $scope.inputId); button.attr('data-clipboard-target', $scope.inputId); - - 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); - }); + $scope.disabled = !button.clipboardCopy(); } }; return directiveDefinitionObject; @@ -2194,7 +2344,7 @@ quayApp.directive('externalLoginButton', function () { 'provider': '@provider', '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.signInStarted({'service': service}); @@ -2228,15 +2378,39 @@ quayApp.directive('signinForm', function () { 'signInStarted': '&signInStarted', '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() { if ($scope.signInStarted != null) { $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() { + if ($scope.tryAgainSoon > 0) { return; } + $scope.markStarted(); + $scope.cancelInterval(); ApiService.signinUser($scope.user).then(function() { $scope.needsEmailVerification = false; @@ -2258,8 +2432,23 @@ quayApp.directive('signinForm', function () { $location.path($scope.redirectUrl ? $scope.redirectUrl : '/'); }, 500); }, function(result) { - $scope.needsEmailVerification = result.data.needsEmailVerification; - $scope.invalidCredentials = result.data.invalidCredentials; + if (result.status == 429 /* try again later */) { + $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', 'token': '=token', 'shown': '=shown', - 'counter': '=counter' + 'counter': '=counter', + 'supportsRegenerate': '@supportsRegenerate', + 'regenerate': '®enerate' }, - 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() { - 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; }; @@ -2392,6 +2612,8 @@ quayApp.directive('dockerAuthDialog', function (Config) { }; var show = function(r) { + $scope.regenerating = false; + if (!$scope.shown || !$scope.username || !$scope.token) { $('#dockerauthmodal').modal('hide'); return; @@ -2632,6 +2854,8 @@ quayApp.directive('logsView', function () { return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}'; }, + 'regenerate_robot_token': 'Regenerated token for robot {robot}', + // Note: These are deprecated. 'add_repo_webhook': 'Add 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', 'add_repo_notification': 'Add repository notification', 'delete_repo_notification': 'Delete repository notification', + 'regenerate_robot_token': 'Regenerate Robot Token', // Note: these are deprecated. 'add_repo_webhook': 'Add webhook', @@ -2801,18 +3026,7 @@ quayApp.directive('applicationManager', function () { ApiService.createOrganizationApplication(data, params).then(function(resp) { $scope.applications.push(resp); - }, function(resp) { - bootbox.dialog({ - "message": resp['message'] || 'The application could not be created', - "title": "Cannot create application", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Cannot create application')); }; var update = function() { @@ -2857,6 +3071,20 @@ quayApp.directive('robotsManager', function () { $scope.shownRobot = null; $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.shownRobot = info; $scope.showRobotCounter++; @@ -2897,18 +3125,7 @@ quayApp.directive('robotsManager', function () { if (index >= 0) { $scope.robots.splice(index, 1); } - }, function() { - bootbox.dialog({ - "message": 'The selected robot account could not be deleted', - "title": "Cannot delete robot account", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Cannot delete robot account')); }; var update = function() { @@ -2973,18 +3190,7 @@ quayApp.directive('prototypeManager', function () { ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) { prototype.role = role; - }, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data : 'The permission could not be modified', - "title": "Cannot modify permission", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Cannot modify permission')); }; $scope.comparePrototypes = function(p) { @@ -3024,23 +3230,16 @@ quayApp.directive('prototypeManager', function () { data['activating_user'] = $scope.activatingForNew; } + var errorHandler = ApiService.errorDisplay('Cannot create permission', + function(resp) { + $('#addPermissionDialogModal').modal('hide'); + }); + ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) { $scope.prototypes.push(resp); $scope.loading = false; $('#addPermissionDialogModal').modal('hide'); - }, function(resp) { - $('#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" - } - } - }); - }); + }, errorHandler); }; $scope.deletePrototype = function(prototype) { @@ -3054,18 +3253,7 @@ quayApp.directive('prototypeManager', function () { ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) { $scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1); $scope.loading = false; - }, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data : 'The permission could not be deleted', - "title": "Cannot delete permission", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Cannot delete permission')); }; var update = function() { @@ -3836,9 +4024,11 @@ quayApp.directive('billingOptions', function () { var save = function() { $scope.working = true; + + var errorHandler = ApiService.errorDisplay('Could not change user details'); ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { $scope.working = false; - }); + }, errorHandler); }; var checkSave = function() { @@ -3890,7 +4080,7 @@ quayApp.directive('planManager', function () { return true; }; - $scope.changeSubscription = function(planId) { + $scope.changeSubscription = function(planId, opt_async) { if ($scope.planChanging) { return; } 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() { @@ -3967,7 +4157,7 @@ quayApp.directive('planManager', function () { if ($scope.readyForPlan) { var planRequested = $scope.readyForPlan(); if (planRequested && planRequested != PlanService.getFreePlan()) { - $scope.changeSubscription(planRequested); + $scope.changeSubscription(planRequested, /* async */true); } } }); @@ -3998,7 +4188,7 @@ quayApp.directive('namespaceSelector', function () { 'namespace': '=namespace', 'requireCreate': '=requireCreate' }, - controller: function($scope, $element, $routeParams, CookieService) { + controller: function($scope, $element, $routeParams, $location, CookieService) { $scope.namespaces = {}; $scope.initialize = function(user) { @@ -4035,6 +4225,10 @@ quayApp.directive('namespaceSelector', function () { if (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; + var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) { + $scope.hide(); + $scope.canceled({'trigger': $scope.trigger}); + }); + ApiService.activateBuildTrigger(data, params).then(function(resp) { $scope.hide(); $scope.trigger['is_active'] = true; $scope.trigger['pull_robot'] = resp['pull_robot']; $scope.activated({'trigger': $scope.trigger}); - }, function(resp) { - $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" - } - } - }); - }); + }, errorHandler); }; var check = function() { @@ -4744,6 +4929,9 @@ quayApp.directive('buildMessage', function () { case 'waiting': return 'Waiting for available build worker'; + case 'unpacking': + return 'Unpacking build package'; + case 'pulling': return 'Pulling base image'; @@ -4799,6 +4987,7 @@ quayApp.directive('buildProgress', function () { case 'starting': case 'waiting': case 'cannot_load': + case 'unpacking': return 0; 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 () { var directiveDefinitionObject = { priority: 0, @@ -5329,7 +5535,9 @@ quayApp.directive('locationView', function () { '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' }, - '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_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) { var tip = $scope.getLocationTitle(location) + '
'; - if (ping < 0) { + if (ping == null) { + tip += '(Loading)'; + } else if (ping < 0) { tip += '
Note: Could not contact server'; } else { tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)'); @@ -5367,7 +5577,7 @@ quayApp.directive('locationView', function () { }; $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) { if (count == 3 || !success) { $scope.locationPing = success ? ping : -1; @@ -5424,7 +5634,8 @@ quayApp.directive('tagSpecificImagesView', function () { scope: { 'repository': '=repository', 'tag': '=tag', - 'images': '=images' + 'images': '=images', + 'imageCutoff': '=imageCutoff' }, controller: function($scope, $element) { $scope.getFirstTextLine = getFirstTextLine; @@ -5446,7 +5657,7 @@ quayApp.directive('tagSpecificImagesView', function () { return classes; }; - var forAllTagImages = function(tag, callback) { + var forAllTagImages = function(tag, callback, opt_cutoff) { if (!tag) { return; } if (!$scope.imageByDBID) { @@ -5464,10 +5675,14 @@ quayApp.directive('tagSpecificImagesView', function () { callback(tag_image); - var ancestors = tag_image.ancestors.split('/'); + var ancestors = tag_image.ancestors.split('/').reverse(); for (var i = 0; i < ancestors.length; ++i) { var image = $scope.imageByDBID[ancestors[i]]; if (image) { + if (image == opt_cutoff) { + return; + } + callback(image); } } @@ -5489,7 +5704,7 @@ quayApp.directive('tagSpecificImagesView', function () { var ids = {}; forAllTagImages(currentTag, function(image) { ids[image.dbid] = true; - }); + }, $scope.imageCutoff); return ids; }; @@ -5587,15 +5802,14 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi // Handle session expiration. Restangular.setErrorInterceptor(function(response) { - if (response.status == 401) { - if (response.data['session_required'] == null || response.data['session_required'] === true) { - $('#sessionexpiredModal').modal({}); - return false; - } + if (response.status == 401 && response.data['error_type'] == 'invalid_token' && + response.data['session_required'] !== false) { + $('#sessionexpiredModal').modal({}); + return false; } - if (!Features.BILLING && response.status == 402) { - $('#overlicenseModal').modal({}); + if (response.status == 503) { + $('#cannotContactService').modal({}); return false; } diff --git a/static/js/controllers.js b/static/js/controllers.js index 5278e4efe..e4e364c87 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -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() { } @@ -431,6 +409,27 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi $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) { var sanitized = ImageMetadataService.getEscapedFormattedCommand(image); return '' + sanitized + ''; @@ -511,48 +510,37 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi 'image': image.id }; + var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) { + $('#addTagModal').modal('hide'); + }); + ApiService.changeTagImage(data, params).then(function(resp) { $scope.creatingTag = false; loadViewInfo(); $('#addTagModal').modal('hide'); - }, function(resp) { - $('#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" - } - } - }); - }); + }, errorHandler); }; $scope.deleteTag = function(tagName) { if (!$scope.repo.can_admin) { return; } - $('#confirmdeleteTagModal').modal('hide'); var params = { 'repository': namespace + '/' + name, '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() { loadViewInfo(); - }, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data : 'Could not delete tag', - "title": "Cannot delete tag", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + $('#confirmdeleteTagModal').modal('hide'); + $scope.deletingTag = false; + }, errorHandler); }; $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. startBuildInfoTimer(repo); - - $('#copyClipboard').clipboardCopy(); }); }; @@ -1341,17 +1327,16 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams }; $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)); permissionDelete.customDELETE().then(function() { delete $scope.permissions[kind][entityName]; - }, function(resp) { - if (resp.status == 409) { - $scope.changePermError = resp.data || ''; - $('#channgechangepermModal').modal({}); - } else { - $('#cannotchangeModal').modal({}); - } - }); + }, errorHandler); }; $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)); permissionPost.customPUT(permission).then(function(result) { $scope.permissions[kind][entityName] = result; - }, function(result) { - $('#cannotchangeModal').modal({}); - }); + }, ApiService.errorDisplay('Cannot change permission')); }; $scope.roles = [ @@ -1579,18 +1562,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams window.console.log(resp); var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; document.location = url; - }, function(resp) { - bootbox.dialog({ - "message": resp['message'] || 'The build could not be started', - "title": "Could not start build", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Could not start build')); }; $scope.deleteTrigger = function(trigger) { @@ -1720,18 +1692,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use ApiService.deleteUserAuthorization(null, params).then(function(resp) { $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); - }, function(resp) { - bootbox.dialog({ - "message": resp.message || 'Could not revoke authorization', - "title": "Cannot revoke authorization", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Could not revoke authorization')); }; $scope.loadLogs = function() { @@ -1740,7 +1701,6 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use }; $scope.loadInvoices = function() { - if (!$scope.hasPaidBusinessPlan) { return; } $scope.invoicesShown++; }; @@ -1809,7 +1769,7 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.changeEmailForm.$setPristine(); }, function(result) { $scope.updatingUser = false; - UIService.showFormError('#changeEmailForm', result); + UIService.showFormError('#changeEmailForm', result); }); }; @@ -1819,7 +1779,8 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.updatingUser = true; $scope.changePasswordSuccess = false; - ApiService.changeUserDetails($scope.cuser).then(function() { + ApiService.changeUserDetails($scope.cuser).then(function(resp) { + $scope.updatingUser = false; $scope.changePasswordSuccess = true; @@ -1926,9 +1887,6 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, I // Fetch the image's changes. fetchChanges(); - - $('#copyClipboard').clipboardCopy(); - return image; }); }; @@ -2196,13 +2154,14 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) { 'teamname': teamname }; + var errorHandler = ApiService.errorDisplay('Cannot delete team', function() { + $scope.currentDeleteTeam = null; + }); + ApiService.deleteOrganizationTeam(null, params).then(function() { delete $scope.organization.teams[teamname]; $scope.currentDeleteTeam = null; - }, function() { - $('#cannotchangeModal').modal({}); - $scope.currentDeleteTeam = null; - }); + }, errorHandler); }; 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); - }, function(result) { + }, function(resp) { $scope.creating = false; - $scope.createError = result.data.error_description || result.data; + $scope.createError = ApiService.getErrorMessage(resp); $timeout(function() { $('#orgName').popover('show'); }); @@ -2575,18 +2534,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim $timeout(function() { $location.path('/organization/' + orgname + '/admin'); }, 500); - }, function(resp) { - bootbox.dialog({ - "message": resp.message || 'Could not delete application', - "title": "Cannot delete application", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Could not delete application')); }; $scope.updateApplication = function() { @@ -2604,22 +2552,13 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim 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) { $scope.application = resp; - $scope.updating = false; - }, 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" - } - } - }); - }); + }, errorHandler); }; $scope.resetClientSecret = function() { @@ -2632,18 +2571,7 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) { $scope.application = resp; - }, function(resp) { - bootbox.dialog({ - "message": resp.message || 'Could not reset client secret', - "title": "Cannot reset client secret", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Could not reset client secret')); }; var loadOrganization = function() { @@ -2739,18 +2667,7 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { ApiService.changeInstallUser(data, params).then(function(resp) { $scope.loadUsersInternal(); - }, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data.message : 'Could not change user', - "title": "Cannot change user", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Could not change user')); }; $scope.deleteUser = function(user) { @@ -2762,49 +2679,10 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { ApiService.deleteInstallUser(null, params).then(function(resp) { $scope.loadUsersInternal(); - }, function(resp) { - bootbox.dialog({ - "message": resp.data ? resp.data.message : 'Could not delete user', - "title": "Cannot delete user", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); + }, ApiService.errorDisplay('Cannot delete user')); }; - var seatUsageLoaded = function(usage) { - $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(); + $scope.loadUsers(); } function TourCtrl($scope, $location) { diff --git a/static/js/graphing.js b/static/js/graphing.js index b5abf2d00..b18e5ddbe 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -148,6 +148,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() { var ch = dimensions.ch; // Set the height of the container so that it never goes offscreen. + if (!$('#' + container).removeOverscroll) { return; } + $('#' + container).removeOverscroll(); var viewportHeight = $(window).height(); var boundingBox = document.getElementById(container).getBoundingClientRect(); @@ -402,6 +404,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() { var roots = []; for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; + + // Skip images that are currently uploading. + if (image.uploading) { continue; } + var imageNode = imageByDBID[image.dbid]; var ancestors = this.getAncestors_(image); var immediateParent = ancestors[ancestors.length - 1] * 1; @@ -432,6 +438,10 @@ ImageHistoryTree.prototype.buildRoot_ = function() { var maxChildCount = roots.length; for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; + + // Skip images that are currently uploading. + if (image.uploading) { continue; } + var imageNode = imageByDBID[image.dbid]; 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. for (var i = 0; i < this.images_.length; ++i) { var image = this.images_[i]; + + // Skip images that are currently uploading. + if (image.uploading) { continue; } + var imageNode = this.imageByDBID_[image.dbid]; var ancestors = this.getAncestors_(image); var immediateParent = ancestors[ancestors.length - 1] * 1; diff --git a/static/lib/ZeroClipboard.min.js b/static/lib/ZeroClipboard.min.js old mode 100755 new mode 100644 index bfea72566..e8a4a7152 --- a/static/lib/ZeroClipboard.min.js +++ b/static/lib/ZeroClipboard.min.js @@ -1,9 +1,10 @@ /*! -* ZeroClipboard -* The ZeroClipboard library provides an easy way to copy text to the clipboard using an invisible Adobe Flash movie and a JavaScript interface. -* Copyright (c) 2013 Jon Rohan, James M. Greene -* Licensed MIT -* http://zeroclipboard.org/ -* v1.2.3 -*/ -!function(){"use strict";var a,b=function(){var a=/\-([a-z])/g,b=function(a,b){return b.toUpperCase()};return function(c){return c.replace(a,b)}}(),c=function(a,c){var d,e,f,g,h,i;if(window.getComputedStyle?d=window.getComputedStyle(a,null).getPropertyValue(c):(e=b(c),d=a.currentStyle?a.currentStyle[e]:a.style[e]),"cursor"===c&&(!d||"auto"===d))for(f=a.tagName.toLowerCase(),g=["a"],h=0,i=g.length;i>h;h++)if(f===g[h])return"pointer";return d},d=function(a){if(p.prototype._singleton){a||(a=window.event);var b;this!==window?b=this:a.target?b=a.target:a.srcElement&&(b=a.srcElement),p.prototype._singleton.setCurrent(b)}},e=function(a,b,c){a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent&&a.attachEvent("on"+b,c)},f=function(a,b,c){a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent&&a.detachEvent("on"+b,c)},g=function(a,b){if(a.addClass)return a.addClass(b),a;if(b&&"string"==typeof b){var c=(b||"").split(/\s+/);if(1===a.nodeType)if(a.className){for(var d=" "+a.className+" ",e=a.className,f=0,g=c.length;g>f;f++)d.indexOf(" "+c[f]+" ")<0&&(e+=" "+c[f]);a.className=e.replace(/^\s+|\s+$/g,"")}else a.className=b}return a},h=function(a,b){if(a.removeClass)return a.removeClass(b),a;if(b&&"string"==typeof b||void 0===b){var c=(b||"").split(/\s+/);if(1===a.nodeType&&a.className)if(b){for(var d=(" "+a.className+" ").replace(/[\n\t]/g," "),e=0,f=c.length;f>e;e++)d=d.replace(" "+c[e]+" "," ");a.className=d.replace(/^\s+|\s+$/g,"")}else a.className=""}return a},i=function(){var a,b,c,d=1;return"function"==typeof document.body.getBoundingClientRect&&(a=document.body.getBoundingClientRect(),b=a.right-a.left,c=document.body.offsetWidth,d=Math.round(100*(b/c))/100),d},j=function(a){var b={left:0,top:0,width:0,height:0,zIndex:999999999},d=c(a,"z-index");if(d&&"auto"!==d&&(b.zIndex=parseInt(d,10)),a.getBoundingClientRect){var e,f,g,h=a.getBoundingClientRect();"pageXOffset"in window&&"pageYOffset"in window?(e=window.pageXOffset,f=window.pageYOffset):(g=i(),e=Math.round(document.documentElement.scrollLeft/g),f=Math.round(document.documentElement.scrollTop/g));var j=document.documentElement.clientLeft||0,k=document.documentElement.clientTop||0;b.left=h.left+e-j,b.top=h.top+f-k,b.width="width"in h?h.width:h.right-h.left,b.height="height"in h?h.height:h.bottom-h.top}return b},k=function(a,b){var c=!(b&&b.useNoCache===!1);return c?(-1===a.indexOf("?")?"?":"&")+"nocache="+(new Date).getTime():""},l=function(a){var b=[],c=[];return a.trustedOrigins&&("string"==typeof a.trustedOrigins?c.push(a.trustedOrigins):"object"==typeof a.trustedOrigins&&"length"in a.trustedOrigins&&(c=c.concat(a.trustedOrigins))),a.trustedDomains&&("string"==typeof a.trustedDomains?c.push(a.trustedDomains):"object"==typeof a.trustedDomains&&"length"in a.trustedDomains&&(c=c.concat(a.trustedDomains))),c.length&&b.push("trustedOrigins="+encodeURIComponent(c.join(","))),"string"==typeof a.amdModuleId&&a.amdModuleId&&b.push("amdModuleId="+encodeURIComponent(a.amdModuleId)),"string"==typeof a.cjsModuleId&&a.cjsModuleId&&b.push("cjsModuleId="+encodeURIComponent(a.cjsModuleId)),b.join("&")},m=function(a,b){if(b.indexOf)return b.indexOf(a);for(var c=0,d=b.length;d>c;c++)if(b[c]===a)return c;return-1},n=function(a){if("string"==typeof a)throw new TypeError("ZeroClipboard doesn't accept query strings.");return a.length?a:[a]},o=function(a,b,c,d,e){e?window.setTimeout(function(){a.call(b,c,d)},0):a.call(b,c,d)},p=function(a,b){if(a&&(p.prototype._singleton||this).glue(a),p.prototype._singleton)return p.prototype._singleton;p.prototype._singleton=this,this.options={};for(var c in s)this.options[c]=s[c];for(var d in b)this.options[d]=b[d];this.handlers={},p.detectFlashSupport()&&v()},q=[];p.prototype.setCurrent=function(b){a=b,this.reposition();var d=b.getAttribute("title");d&&this.setTitle(d);var e=this.options.forceHandCursor===!0||"pointer"===c(b,"cursor");return r.call(this,e),this},p.prototype.setText=function(a){return a&&""!==a&&(this.options.text=a,this.ready()&&this.flashBridge.setText(a)),this},p.prototype.setTitle=function(a){return a&&""!==a&&this.htmlBridge.setAttribute("title",a),this},p.prototype.setSize=function(a,b){return this.ready()&&this.flashBridge.setSize(a,b),this},p.prototype.setHandCursor=function(a){return a="boolean"==typeof a?a:!!a,r.call(this,a),this.options.forceHandCursor=a,this};var r=function(a){this.ready()&&this.flashBridge.setHandCursor(a)};p.version="1.2.3";var s={moviePath:"ZeroClipboard.swf",trustedOrigins:null,text:null,hoverClass:"zeroclipboard-is-hover",activeClass:"zeroclipboard-is-active",allowScriptAccess:"sameDomain",useNoCache:!0,forceHandCursor:!1};p.setDefaults=function(a){for(var b in a)s[b]=a[b]},p.destroy=function(){p.prototype._singleton.unglue(q);var a=p.prototype._singleton.htmlBridge;a.parentNode.removeChild(a),delete p.prototype._singleton},p.detectFlashSupport=function(){var a=!1;if("function"==typeof ActiveXObject)try{new ActiveXObject("ShockwaveFlash.ShockwaveFlash")&&(a=!0)}catch(b){}return!a&&navigator.mimeTypes["application/x-shockwave-flash"]&&(a=!0),a};var t=null,u=null,v=function(){var a,b,c=p.prototype._singleton,d=document.getElementById("global-zeroclipboard-html-bridge");if(!d){var e={};for(var f in c.options)e[f]=c.options[f];e.amdModuleId=t,e.cjsModuleId=u;var g=l(e),h=' ';d=document.createElement("div"),d.id="global-zeroclipboard-html-bridge",d.setAttribute("class","global-zeroclipboard-container"),d.setAttribute("data-clipboard-ready",!1),d.style.position="absolute",d.style.left="-9999px",d.style.top="-9999px",d.style.width="15px",d.style.height="15px",d.style.zIndex="9999",d.innerHTML=h,document.body.appendChild(d)}c.htmlBridge=d,a=document["global-zeroclipboard-flash-bridge"],a&&(b=a.length)&&(a=a[b-1]),c.flashBridge=a||d.children[0].lastElementChild};p.prototype.resetBridge=function(){return this.htmlBridge.style.left="-9999px",this.htmlBridge.style.top="-9999px",this.htmlBridge.removeAttribute("title"),this.htmlBridge.removeAttribute("data-clipboard-text"),h(a,this.options.activeClass),a=null,this.options.text=null,this},p.prototype.ready=function(){var a=this.htmlBridge.getAttribute("data-clipboard-ready");return"true"===a||a===!0},p.prototype.reposition=function(){if(!a)return!1;var b=j(a);return this.htmlBridge.style.top=b.top+"px",this.htmlBridge.style.left=b.left+"px",this.htmlBridge.style.width=b.width+"px",this.htmlBridge.style.height=b.height+"px",this.htmlBridge.style.zIndex=b.zIndex+1,this.setSize(b.width,b.height),this},p.dispatch=function(a,b){p.prototype._singleton.receiveEvent(a,b)},p.prototype.on=function(a,b){for(var c=a.toString().split(/\s/g),d=0;da;a++)if(null!=(d=h[a]))for(e in d)s.call(d,e)&&(f=i[e],g=d[e],i!==g&&g!==b&&(i[e]=g));return i},x=function(a){var b,c,d,e;if("object"!=typeof a||null==a)b=a;else if("number"==typeof a.length)for(b=[],c=0,d=a.length;d>c;c++)s.call(a,c)&&(b[c]=x(a[c]));else{b={};for(e in a)s.call(a,e)&&(b[e]=x(a[e]))}return b},y=function(a,b){for(var c={},d=0,e=b.length;e>d;d++)b[d]in a&&(c[b[d]]=a[b[d]]);return c},z=function(a,b){var c={};for(var d in a)-1===b.indexOf(d)&&(c[d]=a[d]);return c},A=function(a){if(a)for(var b in a)s.call(a,b)&&delete a[b];return a},B=function(a,b){if(a&&1===a.nodeType&&a.ownerDocument&&b&&(1===b.nodeType&&b.ownerDocument&&b.ownerDocument===a.ownerDocument||9===b.nodeType&&!b.ownerDocument&&b===a.ownerDocument))do{if(a===b)return!0;a=a.parentNode}while(a);return!1},C=function(a){var b;return"string"==typeof a&&a&&(b=a.split("#")[0].split("?")[0],b=a.slice(0,a.lastIndexOf("/")+1)),b},D=function(a){var b,c;return"string"==typeof a&&a&&(c=a.match(/^(?:|[^:@]*@|.+\)@(?=http[s]?|file)|.+?\s+(?: at |@)(?:[^:\(]+ )*[\(]?)((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/),c&&c[1]?b=c[1]:(c=a.match(/\)@((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/),c&&c[1]&&(b=c[1]))),b},E=function(){var a,b;try{throw new k}catch(c){b=c}return b&&(a=b.sourceURL||b.fileName||D(b.stack)),a},F=function(){var a,c,d;if(f.currentScript&&(a=f.currentScript.src))return a;if(c=f.getElementsByTagName("script"),1===c.length)return c[0].src||b;if("readyState"in c[0])for(d=c.length;d--;)if("interactive"===c[d].readyState&&(a=c[d].src))return a;return"loading"===f.readyState&&(a=c[c.length-1].src)?a:(a=E())?a:b},G=function(){var a,c,d,e=f.getElementsByTagName("script");for(a=e.length;a--;){if(!(d=e[a].src)){c=null;break}if(d=C(d),null==c)c=d;else if(c!==d){c=null;break}}return c||b},H=function(){var a=C(F())||G()||"";return a+"ZeroClipboard.swf"},I={bridge:null,version:"0.0.0",pluginType:"unknown",disabled:null,outdated:null,unavailable:null,deactivated:null,overdue:null,ready:null},J="11.0.0",K={},L={},M=null,N={ready:"Flash communication is established",error:{"flash-disabled":"Flash is disabled or not installed","flash-outdated":"Flash is too outdated to support ZeroClipboard","flash-unavailable":"Flash is unable to communicate bidirectionally with JavaScript","flash-deactivated":"Flash is too outdated for your browser and/or is configured as click-to-activate","flash-overdue":"Flash communication was established but NOT within the acceptable time limit"}},O={swfPath:H(),trustedDomains:a.location.host?[a.location.host]:[],cacheBust:!0,forceEnhancedClipboard:!1,flashLoadTimeout:3e4,autoActivate:!0,bubbleEvents:!0,containerId:"global-zeroclipboard-html-bridge",containerClass:"global-zeroclipboard-container",swfObjectId:"global-zeroclipboard-flash-bridge",hoverClass:"zeroclipboard-is-hover",activeClass:"zeroclipboard-is-active",forceHandCursor:!1,title:null,zIndex:999999999},P=function(a){if("object"==typeof a&&null!==a)for(var b in a)if(s.call(a,b))if(/^(?:forceHandCursor|title|zIndex|bubbleEvents)$/.test(b))O[b]=a[b];else if(null==I.bridge)if("containerId"===b||"swfObjectId"===b){if(!cb(a[b]))throw new Error("The specified `"+b+"` value is not valid as an HTML4 Element ID");O[b]=a[b]}else O[b]=a[b];{if("string"!=typeof a||!a)return x(O);if(s.call(O,a))return O[a]}},Q=function(){return{browser:y(g,["userAgent","platform","appName"]),flash:z(I,["bridge"]),zeroclipboard:{version:Fb.version,config:Fb.config()}}},R=function(){return!!(I.disabled||I.outdated||I.unavailable||I.deactivated)},S=function(a,b){var c,d,e,f={};if("string"==typeof a&&a)e=a.toLowerCase().split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&Fb.on(c,a[c]);if(e&&e.length){for(c=0,d=e.length;d>c;c++)a=e[c].replace(/^on/,""),f[a]=!0,K[a]||(K[a]=[]),K[a].push(b);if(f.ready&&I.ready&&Fb.emit({type:"ready"}),f.error){var g=["disabled","outdated","unavailable","deactivated","overdue"];for(c=0,d=g.length;d>c;c++)if(I[g[c]]===!0){Fb.emit({type:"error",name:"flash-"+g[c]});break}}}return Fb},T=function(a,b){var c,d,e,f,g;if(0===arguments.length)f=q(K);else if("string"==typeof a&&a)f=a.split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&Fb.off(c,a[c]);if(f&&f.length)for(c=0,d=f.length;d>c;c++)if(a=f[c].toLowerCase().replace(/^on/,""),g=K[a],g&&g.length)if(b)for(e=g.indexOf(b);-1!==e;)g.splice(e,1),e=g.indexOf(b,e);else g.length=0;return Fb},U=function(a){var b;return b="string"==typeof a&&a?x(K[a])||null:x(K)},V=function(a){var b,c,d;return a=db(a),a&&!jb(a)?"ready"===a.type&&I.overdue===!0?Fb.emit({type:"error",name:"flash-overdue"}):(b=w({},a),ib.call(this,b),"copy"===a.type&&(d=pb(L),c=d.data,M=d.formatMap),c):void 0},W=function(){if("boolean"!=typeof I.ready&&(I.ready=!1),!Fb.isFlashUnusable()&&null===I.bridge){var a=O.flashLoadTimeout;"number"==typeof a&&a>=0&&h(function(){"boolean"!=typeof I.deactivated&&(I.deactivated=!0),I.deactivated===!0&&Fb.emit({type:"error",name:"flash-deactivated"})},a),I.overdue=!1,nb()}},X=function(){Fb.clearData(),Fb.blur(),Fb.emit("destroy"),ob(),Fb.off()},Y=function(a,b){var c;if("object"==typeof a&&a&&"undefined"==typeof b)c=a,Fb.clearData();else{if("string"!=typeof a||!a)return;c={},c[a]=b}for(var d in c)"string"==typeof d&&d&&s.call(c,d)&&"string"==typeof c[d]&&c[d]&&(L[d]=c[d])},Z=function(a){"undefined"==typeof a?(A(L),M=null):"string"==typeof a&&s.call(L,a)&&delete L[a]},$=function(a){return"undefined"==typeof a?x(L):"string"==typeof a&&s.call(L,a)?L[a]:void 0},_=function(a){if(a&&1===a.nodeType){c&&(xb(c,O.activeClass),c!==a&&xb(c,O.hoverClass)),c=a,wb(a,O.hoverClass);var b=a.getAttribute("title")||O.title;if("string"==typeof b&&b){var d=mb(I.bridge);d&&d.setAttribute("title",b)}var e=O.forceHandCursor===!0||"pointer"===yb(a,"cursor");Cb(e),Bb()}},ab=function(){var a=mb(I.bridge);a&&(a.removeAttribute("title"),a.style.left="0px",a.style.top="-9999px",a.style.width="1px",a.style.top="1px"),c&&(xb(c,O.hoverClass),xb(c,O.activeClass),c=null)},bb=function(){return c||null},cb=function(a){return"string"==typeof a&&a&&/^[A-Za-z][A-Za-z0-9_:\-\.]*$/.test(a)},db=function(a){var b;if("string"==typeof a&&a?(b=a,a={}):"object"==typeof a&&a&&"string"==typeof a.type&&a.type&&(b=a.type),b){!a.target&&/^(copy|aftercopy|_click)$/.test(b.toLowerCase())&&(a.target=d),w(a,{type:b.toLowerCase(),target:a.target||c||null,relatedTarget:a.relatedTarget||null,currentTarget:I&&I.bridge||null,timeStamp:a.timeStamp||p()||null});var e=N[a.type];return"error"===a.type&&a.name&&e&&(e=e[a.name]),e&&(a.message=e),"ready"===a.type&&w(a,{target:null,version:I.version}),"error"===a.type&&(/^flash-(disabled|outdated|unavailable|deactivated|overdue)$/.test(a.name)&&w(a,{target:null,minimumVersion:J}),/^flash-(outdated|unavailable|deactivated|overdue)$/.test(a.name)&&w(a,{version:I.version})),"copy"===a.type&&(a.clipboardData={setData:Fb.setData,clearData:Fb.clearData}),"aftercopy"===a.type&&(a=qb(a,M)),a.target&&!a.relatedTarget&&(a.relatedTarget=eb(a.target)),a=fb(a)}},eb=function(a){var b=a&&a.getAttribute&&a.getAttribute("data-clipboard-target");return b?f.getElementById(b):null},fb=function(a){if(a&&/^_(?:click|mouse(?:over|out|down|up|move))$/.test(a.type)){var c=a.target,d="_mouseover"===a.type&&a.relatedTarget?a.relatedTarget:b,g="_mouseout"===a.type&&a.relatedTarget?a.relatedTarget:b,h=Ab(c),i=e.screenLeft||e.screenX||0,j=e.screenTop||e.screenY||0,k=f.body.scrollLeft+f.documentElement.scrollLeft,l=f.body.scrollTop+f.documentElement.scrollTop,m=h.left+("number"==typeof a._stageX?a._stageX:0),n=h.top+("number"==typeof a._stageY?a._stageY:0),o=m-k,p=n-l,q=i+o,r=j+p,s="number"==typeof a.movementX?a.movementX:0,t="number"==typeof a.movementY?a.movementY:0;delete a._stageX,delete a._stageY,w(a,{srcElement:c,fromElement:d,toElement:g,screenX:q,screenY:r,pageX:m,pageY:n,clientX:o,clientY:p,x:o,y:p,movementX:s,movementY:t,offsetX:0,offsetY:0,layerX:0,layerY:0})}return a},gb=function(a){var b=a&&"string"==typeof a.type&&a.type||"";return!/^(?:(?:before)?copy|destroy)$/.test(b)},hb=function(a,b,c,d){d?h(function(){a.apply(b,c)},0):a.apply(b,c)},ib=function(a){if("object"==typeof a&&a&&a.type){var b=gb(a),c=K["*"]||[],d=K[a.type]||[],f=c.concat(d);if(f&&f.length){var g,h,i,j,k,l=this;for(g=0,h=f.length;h>g;g++)i=f[g],j=l,"string"==typeof i&&"function"==typeof e[i]&&(i=e[i]),"object"==typeof i&&i&&"function"==typeof i.handleEvent&&(j=i,i=i.handleEvent),"function"==typeof i&&(k=w({},a),hb(i,j,[k],b))}return this}},jb=function(a){var b=a.target||c||null,e="swf"===a._source;delete a._source;var f=["flash-disabled","flash-outdated","flash-unavailable","flash-deactivated","flash-overdue"];switch(a.type){case"error":-1!==f.indexOf(a.name)&&w(I,{disabled:"flash-disabled"===a.name,outdated:"flash-outdated"===a.name,unavailable:"flash-unavailable"===a.name,deactivated:"flash-deactivated"===a.name,overdue:"flash-overdue"===a.name,ready:!1});break;case"ready":var g=I.deactivated===!0;w(I,{disabled:!1,outdated:!1,unavailable:!1,deactivated:!1,overdue:g,ready:!g});break;case"beforecopy":d=b;break;case"copy":var h,i,j=a.relatedTarget;!L["text/html"]&&!L["text/plain"]&&j&&(i=j.value||j.outerHTML||j.innerHTML)&&(h=j.value||j.textContent||j.innerText)?(a.clipboardData.clearData(),a.clipboardData.setData("text/plain",h),i!==h&&a.clipboardData.setData("text/html",i)):!L["text/plain"]&&a.target&&(h=a.target.getAttribute("data-clipboard-text"))&&(a.clipboardData.clearData(),a.clipboardData.setData("text/plain",h));break;case"aftercopy":Fb.clearData(),b&&b!==vb()&&b.focus&&b.focus();break;case"_mouseover":Fb.focus(b),O.bubbleEvents===!0&&e&&(b&&b!==a.relatedTarget&&!B(a.relatedTarget,b)&&kb(w({},a,{type:"mouseenter",bubbles:!1,cancelable:!1})),kb(w({},a,{type:"mouseover"})));break;case"_mouseout":Fb.blur(),O.bubbleEvents===!0&&e&&(b&&b!==a.relatedTarget&&!B(a.relatedTarget,b)&&kb(w({},a,{type:"mouseleave",bubbles:!1,cancelable:!1})),kb(w({},a,{type:"mouseout"})));break;case"_mousedown":wb(b,O.activeClass),O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_mouseup":xb(b,O.activeClass),O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_click":d=null,O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}));break;case"_mousemove":O.bubbleEvents===!0&&e&&kb(w({},a,{type:a.type.slice(1)}))}return/^_(?:click|mouse(?:over|out|down|up|move))$/.test(a.type)?!0:void 0},kb=function(a){if(a&&"string"==typeof a.type&&a){var b,c=a.target||null,d=c&&c.ownerDocument||f,g={view:d.defaultView||e,canBubble:!0,cancelable:!0,detail:"click"===a.type?1:0,button:"number"==typeof a.which?a.which-1:"number"==typeof a.button?a.button:d.createEvent?0:1},h=w(g,a);c&&d.createEvent&&c.dispatchEvent&&(h=[h.type,h.canBubble,h.cancelable,h.view,h.detail,h.screenX,h.screenY,h.clientX,h.clientY,h.ctrlKey,h.altKey,h.shiftKey,h.metaKey,h.button,h.relatedTarget],b=d.createEvent("MouseEvents"),b.initMouseEvent&&(b.initMouseEvent.apply(b,h),b._source="js",c.dispatchEvent(b)))}},lb=function(){var a=f.createElement("div");return a.id=O.containerId,a.className=O.containerClass,a.style.position="absolute",a.style.left="0px",a.style.top="-9999px",a.style.width="1px",a.style.height="1px",a.style.zIndex=""+Db(O.zIndex),a},mb=function(a){for(var b=a&&a.parentNode;b&&"OBJECT"===b.nodeName&&b.parentNode;)b=b.parentNode;return b||null},nb=function(){var a,b=I.bridge,c=mb(b);if(!b){var d=ub(e.location.host,O),g="never"===d?"none":"all",h=sb(O),i=O.swfPath+rb(O.swfPath,O);c=lb();var j=f.createElement("div");c.appendChild(j),f.body.appendChild(c);var k=f.createElement("div"),l="activex"===I.pluginType;k.innerHTML='"+(l?'':"")+'',b=k.firstChild,k=null,u(b).ZeroClipboard=Fb,c.replaceChild(b,j)}return b||(b=f[O.swfObjectId],b&&(a=b.length)&&(b=b[a-1]),!b&&c&&(b=c.firstChild)),I.bridge=b||null,b},ob=function(){var a=I.bridge;if(a){var b=mb(a);b&&("activex"===I.pluginType&&"readyState"in a?(a.style.display="none",function c(){if(4===a.readyState){for(var d in a)"function"==typeof a[d]&&(a[d]=null);a.parentNode&&a.parentNode.removeChild(a),b.parentNode&&b.parentNode.removeChild(b)}else h(c,10)}()):(a.parentNode&&a.parentNode.removeChild(a),b.parentNode&&b.parentNode.removeChild(b))),I.ready=null,I.bridge=null,I.deactivated=null}},pb=function(a){var b={},c={};if("object"==typeof a&&a){for(var d in a)if(d&&s.call(a,d)&&"string"==typeof a[d]&&a[d])switch(d.toLowerCase()){case"text/plain":case"text":case"air:text":case"flash:text":b.text=a[d],c.text=d;break;case"text/html":case"html":case"air:html":case"flash:html":b.html=a[d],c.html=d;break;case"application/rtf":case"text/rtf":case"rtf":case"richtext":case"air:rtf":case"flash:rtf":b.rtf=a[d],c.rtf=d}return{data:b,formatMap:c}}},qb=function(a,b){if("object"!=typeof a||!a||"object"!=typeof b||!b)return a;var c={};for(var d in a)if(s.call(a,d)){if("success"!==d&&"data"!==d){c[d]=a[d];continue}c[d]={};var e=a[d];for(var f in e)f&&s.call(e,f)&&s.call(b,f)&&(c[d][b[f]]=e[f])}return c},rb=function(a,b){var c=null==b||b&&b.cacheBust===!0;return c?(-1===a.indexOf("?")?"?":"&")+"noCache="+p():""},sb=function(a){var b,c,d,f,g="",h=[];if(a.trustedDomains&&("string"==typeof a.trustedDomains?f=[a.trustedDomains]:"object"==typeof a.trustedDomains&&"length"in a.trustedDomains&&(f=a.trustedDomains)),f&&f.length)for(b=0,c=f.length;c>b;b++)if(s.call(f,b)&&f[b]&&"string"==typeof f[b]){if(d=tb(f[b]),!d)continue;if("*"===d){h.length=0,h.push(d);break}h.push.apply(h,[d,"//"+d,e.location.protocol+"//"+d])}return h.length&&(g+="trustedOrigins="+i(h.join(","))),a.forceEnhancedClipboard===!0&&(g+=(g?"&":"")+"forceEnhancedClipboard=true"),"string"==typeof a.swfObjectId&&a.swfObjectId&&(g+=(g?"&":"")+"swfObjectId="+i(a.swfObjectId)),g},tb=function(a){if(null==a||""===a)return null;if(a=a.replace(/^\s+|\s+$/g,""),""===a)return null;var b=a.indexOf("//");a=-1===b?a:a.slice(b+2);var c=a.indexOf("/");return a=-1===c?a:-1===b||0===c?null:a.slice(0,c),a&&".swf"===a.slice(-4).toLowerCase()?null:a||null},ub=function(){var a=function(a){var b,c,d,e=[];if("string"==typeof a&&(a=[a]),"object"!=typeof a||!a||"number"!=typeof a.length)return e;for(b=0,c=a.length;c>b;b++)if(s.call(a,b)&&(d=tb(a[b]))){if("*"===d){e.length=0,e.push("*");break}-1===e.indexOf(d)&&e.push(d)}return e};return function(b,c){var d=tb(c.swfPath);null===d&&(d=b);var e=a(c.trustedDomains),f=e.length;if(f>0){if(1===f&&"*"===e[0])return"always";if(-1!==e.indexOf(b))return 1===f&&b===d?"sameDomain":"always"}return"never"}}(),vb=function(){try{return f.activeElement}catch(a){return null}},wb=function(a,b){if(!a||1!==a.nodeType)return a;if(a.classList)return a.classList.contains(b)||a.classList.add(b),a;if(b&&"string"==typeof b){var c=(b||"").split(/\s+/);if(1===a.nodeType)if(a.className){for(var d=" "+a.className+" ",e=a.className,f=0,g=c.length;g>f;f++)d.indexOf(" "+c[f]+" ")<0&&(e+=" "+c[f]);a.className=e.replace(/^\s+|\s+$/g,"")}else a.className=b}return a},xb=function(a,b){if(!a||1!==a.nodeType)return a;if(a.classList)return a.classList.contains(b)&&a.classList.remove(b),a;if("string"==typeof b&&b){var c=b.split(/\s+/);if(1===a.nodeType&&a.className){for(var d=(" "+a.className+" ").replace(/[\n\t]/g," "),e=0,f=c.length;f>e;e++)d=d.replace(" "+c[e]+" "," ");a.className=d.replace(/^\s+|\s+$/g,"")}}return a},yb=function(a,b){var c=e.getComputedStyle(a,null).getPropertyValue(b);return"cursor"!==b||c&&"auto"!==c||"A"!==a.nodeName?c:"pointer"},zb=function(){var a,b,c,d=1;return"function"==typeof f.body.getBoundingClientRect&&(a=f.body.getBoundingClientRect(),b=a.right-a.left,c=f.body.offsetWidth,d=o(b/c*100)/100),d},Ab=function(a){var b={left:0,top:0,width:0,height:0};if(a.getBoundingClientRect){var c,d,g,h=a.getBoundingClientRect();"pageXOffset"in e&&"pageYOffset"in e?(c=e.pageXOffset,d=e.pageYOffset):(g=zb(),c=o(f.documentElement.scrollLeft/g),d=o(f.documentElement.scrollTop/g));var i=f.documentElement.clientLeft||0,j=f.documentElement.clientTop||0;b.left=h.left+c-i,b.top=h.top+d-j,b.width="width"in h?h.width:h.right-h.left,b.height="height"in h?h.height:h.bottom-h.top}return b},Bb=function(){var a;if(c&&(a=mb(I.bridge))){var b=Ab(c);w(a.style,{width:b.width+"px",height:b.height+"px",top:b.top+"px",left:b.left+"px",zIndex:""+Db(O.zIndex)})}},Cb=function(a){I.ready===!0&&(I.bridge&&"function"==typeof I.bridge.setHandCursor?I.bridge.setHandCursor(a):I.ready=!1)},Db=function(a){if(/^(?:auto|inherit)$/.test(a))return a;var b;return"number"!=typeof a||n(a)?"string"==typeof a&&(b=Db(l(a,10))):b=a,"number"==typeof b?b:"auto"},Eb=function(a){function b(a){var b=a.match(/[\d]+/g);return b.length=3,b.join(".")}function c(a){return!!a&&(a=a.toLowerCase())&&(/^(pepflashplayer\.dll|libpepflashplayer\.so|pepperflashplayer\.plugin)$/.test(a)||"chrome.plugin"===a.slice(-13))}function d(a){a&&(i=!0,a.version&&(l=b(a.version)),!l&&a.description&&(l=b(a.description)),a.filename&&(k=c(a.filename)))}var e,f,h,i=!1,j=!1,k=!1,l="";if(g.plugins&&g.plugins.length)e=g.plugins["Shockwave Flash"],d(e),g.plugins["Shockwave Flash 2.0"]&&(i=!0,l="2.0.0.11");else if(g.mimeTypes&&g.mimeTypes.length)h=g.mimeTypes["application/x-shockwave-flash"],e=h&&h.enabledPlugin,d(e);else if("undefined"!=typeof a){j=!0;try{f=new a("ShockwaveFlash.ShockwaveFlash.7"),i=!0,l=b(f.GetVariable("$version"))}catch(n){try{f=new a("ShockwaveFlash.ShockwaveFlash.6"),i=!0,l="6.0.21"}catch(o){try{f=new a("ShockwaveFlash.ShockwaveFlash"),i=!0,l=b(f.GetVariable("$version"))}catch(p){j=!1}}}}I.disabled=i!==!0,I.outdated=l&&m(l)c;c++)a=e[c].replace(/^on/,""),f[a]=!0,g[a]||(g[a]=[]),g[a].push(b);if(f.ready&&I.ready&&this.emit({type:"ready",client:this}),f.error){var h=["disabled","outdated","unavailable","deactivated","overdue"];for(c=0,d=h.length;d>c;c++)if(I[h[c]]){this.emit({type:"error",name:"flash-"+h[c],client:this});break}}}return this},Nb=function(a,b){var c,d,e,f,g,h=Hb[this.id]&&Hb[this.id].handlers;if(0===arguments.length)f=q(h);else if("string"==typeof a&&a)f=a.split(/\s+/);else if("object"==typeof a&&a&&"undefined"==typeof b)for(c in a)s.call(a,c)&&"string"==typeof c&&c&&"function"==typeof a[c]&&this.off(c,a[c]);if(f&&f.length)for(c=0,d=f.length;d>c;c++)if(a=f[c].toLowerCase().replace(/^on/,""),g=h[a],g&&g.length)if(b)for(e=g.indexOf(b);-1!==e;)g.splice(e,1),e=g.indexOf(b,e);else g.length=0;return this},Ob=function(a){var b=null,c=Hb[this.id]&&Hb[this.id].handlers;return c&&(b="string"==typeof a&&a?c[a]?c[a].slice(0):[]:x(c)),b},Pb=function(a){if(Ub.call(this,a)){"object"==typeof a&&a&&"string"==typeof a.type&&a.type&&(a=w({},a));var b=w({},db(a),{client:this});Vb.call(this,b)}return this},Qb=function(a){a=Wb(a);for(var b=0;b0,d=!a.target||c&&-1!==b.indexOf(a.target),e=a.relatedTarget&&c&&-1!==b.indexOf(a.relatedTarget),f=a.client&&a.client===this;return d||e||f?!0:!1},Vb=function(a){if("object"==typeof a&&a&&a.type){var b=gb(a),c=Hb[this.id]&&Hb[this.id].handlers["*"]||[],d=Hb[this.id]&&Hb[this.id].handlers[a.type]||[],f=c.concat(d);if(f&&f.length){var g,h,i,j,k,l=this;for(g=0,h=f.length;h>g;g++)i=f[g],j=l,"string"==typeof i&&"function"==typeof e[i]&&(i=e[i]),"object"==typeof i&&i&&"function"==typeof i.handleEvent&&(j=i,i=i.handleEvent),"function"==typeof i&&(k=w({},a),hb(i,j,[k],b))}return this}},Wb=function(a){return"string"==typeof a&&(a=[]),"number"!=typeof a.length?[a]:a},Xb=function(a){if(a&&1===a.nodeType){var b=function(a){(a||(a=e.event))&&("js"!==a._source&&(a.stopImmediatePropagation(),a.preventDefault()),delete a._source)},c=function(c){(c||(c=e.event))&&(b(c),Fb.focus(a))};a.addEventListener("mouseover",c,!1),a.addEventListener("mouseout",b,!1),a.addEventListener("mouseenter",b,!1),a.addEventListener("mouseleave",b,!1),a.addEventListener("mousemove",b,!1),Kb[a.zcClippingId]={mouseover:c,mouseout:b,mouseenter:b,mouseleave:b,mousemove:b}}},Yb=function(a){if(a&&1===a.nodeType){var b=Kb[a.zcClippingId];if("object"==typeof b&&b){for(var c,d,e=["move","leave","enter","out","over"],f=0,g=e.length;g>f;f++)c="mouse"+e[f],d=b[c],"function"==typeof d&&a.removeEventListener(c,d,!1);delete Kb[a.zcClippingId]}}};Fb._createClient=function(){Lb.apply(this,v(arguments))},Fb.prototype.on=function(){return Mb.apply(this,v(arguments))},Fb.prototype.off=function(){return Nb.apply(this,v(arguments))},Fb.prototype.handlers=function(){return Ob.apply(this,v(arguments))},Fb.prototype.emit=function(){return Pb.apply(this,v(arguments))},Fb.prototype.clip=function(){return Qb.apply(this,v(arguments))},Fb.prototype.unclip=function(){return Rb.apply(this,v(arguments))},Fb.prototype.elements=function(){return Sb.apply(this,v(arguments))},Fb.prototype.destroy=function(){return Tb.apply(this,v(arguments))},Fb.prototype.setText=function(a){return Fb.setData("text/plain",a),this},Fb.prototype.setHtml=function(a){return Fb.setData("text/html",a),this},Fb.prototype.setRichText=function(a){return Fb.setData("application/rtf",a),this},Fb.prototype.setData=function(){return Fb.setData.apply(this,v(arguments)),this},Fb.prototype.clearData=function(){return Fb.clearData.apply(this,v(arguments)),this},Fb.prototype.getData=function(){return Fb.getData.apply(this,v(arguments))},"function"==typeof define&&define.amd?define(function(){return Fb}):"object"==typeof module&&module&&"object"==typeof module.exports&&module.exports?module.exports=Fb:a.ZeroClipboard=Fb}(function(){return this||window}()); +window.ZeroClipboard = ZeroClipboard; \ No newline at end of file diff --git a/static/lib/ZeroClipboard.swf b/static/lib/ZeroClipboard.swf old mode 100755 new mode 100644 index 880e64ee7..d4e2561b3 Binary files a/static/lib/ZeroClipboard.swf and b/static/lib/ZeroClipboard.swf differ diff --git a/static/partials/about.html b/static/partials/about.html index f59865cae..7b068e45c 100644 --- a/static/partials/about.html +++ b/static/partials/about.html @@ -7,41 +7,49 @@

The Basics

-
+
Founded
2012
-
+
Location
New York City, NY
-
+
- Worker Bees
+ Local Worker Bees
2
+
+
+
+ CoreOS
+ August, 2014 +
+

Our Story

-

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 Docker New York City Meetup on October 2nd, 2013.

-

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.

+

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 Docker New York City Meetup on October 2nd, 2013.

+

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.

+

In August, 2014, Quay.io joined CoreOS 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.

-

The Team

- Our team is composed of two software engineers turned entrepreneurs: +

The Quay.io Team at CoreOS

+ The Quay.io team is composed of two software engineers:
@@ -67,12 +75,7 @@
-

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.

+

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.

-
-
-

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.

-
-
-
\ No newline at end of file +
diff --git a/static/partials/landing-login.html b/static/partials/landing-login.html index 5b8b03ce5..0a3046d2a 100644 --- a/static/partials/landing-login.html +++ b/static/partials/landing-login.html @@ -2,10 +2,6 @@
- - -
-
diff --git a/static/partials/landing-normal.html b/static/partials/landing-normal.html index 783b60786..6b9b6e42e 100644 --- a/static/partials/landing-normal.html +++ b/static/partials/landing-normal.html @@ -1,18 +1,25 @@
+
+ + + + + + + + + Quay.io is now part of CoreOS! Read the blog post. +
+
-
- - -
- +

Secure hosting for private Docker* repositories

Use the Docker images your team needs with the safety of private repositories

- + Get 20 free private repos for 6 months
- See All Repositories + See All Repositories
@@ -265,7 +272,7 @@
-
+
Mobile Analytics diff --git a/static/partials/new-organization.html b/static/partials/new-organization.html index 463c3784d..0de90293f 100644 --- a/static/partials/new-organization.html +++ b/static/partials/new-organization.html @@ -5,7 +5,7 @@
-
+

Create Organization

@@ -44,8 +44,7 @@
-
-
+

Setup the new organization

@@ -54,7 +53,7 @@ + data-placement="bottom" data-container="body" ng-pattern="/^[a-z0-9_]{4,30}$/"> This will also be the namespace for your repositories
@@ -85,8 +84,7 @@
-
-
+

Organization Created

Manage Teams Now

diff --git a/static/partials/new-repo.html b/static/partials/new-repo.html index 92d2c3ca6..0d9417288 100644 --- a/static/partials/new-repo.html +++ b/static/partials/new-repo.html @@ -17,8 +17,8 @@
-
-
+ +
@@ -45,8 +45,8 @@
-
-
+ +
Repository Visibility
@@ -98,8 +98,8 @@
-
-
+ +
Initialize repository
@@ -137,8 +137,8 @@
-
-
+ +
Upload DockerfileArchive
@@ -153,8 +153,8 @@
-
-
+ +
You will be redirected to authorize via GitHub once the repository has been created
@@ -162,8 +162,8 @@
-
-
+ +
+
+ + Invoice History + + +
@@ -50,16 +57,9 @@
- Invoice History - - -
-
- - 14-Day Free Trial - 14-Day Trial + data-title="All plans have a free trial"> + Free Trial + Free Trial
@@ -81,7 +81,7 @@
-
+
@@ -93,10 +93,10 @@
SSL Encryption
Robot accounts
Dockerfile Build
+
Invoice History
Teams
Logging
-
Invoice History
-
14-Day Free Trial
+
Free Trial
-
-
-

Enterprise

-
I work in an enterprise and we need to run Quay.io on our servers. Can I do so?
-
Please contact us at our support email address to discuss enterprise plans.
+ +
+
+

Run Quay.io Behind Your Firewall

+
+ + + + +
+
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 CoreOS website.
+ Learn more about Enterprise Registry
diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index 17405f7ff..6bd329091 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -18,7 +18,8 @@ -
+
Build Triggers @@ -377,24 +378,6 @@ counter="showNewNotificationCounter" notification-created="handleNotificationCreated(notification)">
- - -