First stab at token auth. The UI could use a little bit of polishing.
This commit is contained in:
parent
f1746417b1
commit
283f9b81ae
9 changed files with 360 additions and 91 deletions
68
auth/auth.py
68
auth/auth.py
|
@ -32,8 +32,23 @@ def process_basic_auth(auth):
|
|||
credentials = b64decode(normalized[1]).split(':')
|
||||
|
||||
if len(credentials) != 2:
|
||||
logger.debug('Invalid basic auth credential formet.')
|
||||
logger.debug('Invalid basic auth credential format.')
|
||||
|
||||
if credentials[0] == '$token':
|
||||
# Use as token auth
|
||||
try:
|
||||
token = model.load_token_data(credentials[1])
|
||||
logger.debug('Successfully validated token: %s' % credentials[1])
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = token
|
||||
|
||||
identity_changed.send(app, identity=Identity(token.code, 'token'))
|
||||
return
|
||||
|
||||
except model.DataModelException:
|
||||
logger.debug('Invalid token: %s' % credentials[1])
|
||||
|
||||
else:
|
||||
authenticated = model.verify_user(credentials[0], credentials[1])
|
||||
|
||||
if authenticated:
|
||||
|
@ -54,43 +69,38 @@ def process_basic_auth(auth):
|
|||
def process_token(auth):
|
||||
normalized = [part.strip() for part in auth.split(' ') if part]
|
||||
if normalized[0].lower() != 'token' or len(normalized) != 2:
|
||||
logger.debug('Invalid token format.')
|
||||
logger.debug('Not an auth token: %s' % auth)
|
||||
return
|
||||
|
||||
token_details = normalized[1].split(',')
|
||||
|
||||
if len(token_details) != 2:
|
||||
logger.debug('Invalid token format.')
|
||||
return
|
||||
if len(token_details) != 1:
|
||||
logger.warning('Invalid token format: %s' % auth)
|
||||
abort(401)
|
||||
|
||||
token_vals = {val[0]: val[1] for val in
|
||||
(detail.split('=') for detail in token_details)}
|
||||
if ('signature' not in token_vals or 'repository' not in token_vals):
|
||||
logger.debug('Invalid token components.')
|
||||
return
|
||||
|
||||
unquoted = token_vals['repository'][1:-1]
|
||||
namespace, repository = parse_namespace_repository(unquoted)
|
||||
logger.debug('Validing signature: %s' % token_vals['signature'])
|
||||
validated = model.verify_token(token_vals['signature'], namespace,
|
||||
repository)
|
||||
|
||||
if validated:
|
||||
session['repository'] = repository
|
||||
session['namespace'] = namespace
|
||||
|
||||
logger.debug('Successfully validated token: %s' % validated.code)
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = validated
|
||||
|
||||
identity_changed.send(app, identity=Identity(validated.code, 'token'))
|
||||
|
||||
return
|
||||
|
||||
# WE weren't able to authenticate the token
|
||||
logger.debug('Token present but could not be validated.')
|
||||
if 'signature' not in token_vals:
|
||||
logger.warning('Token does not contain signature: %s' % auth)
|
||||
abort(401)
|
||||
|
||||
try:
|
||||
token_data = model.load_token_data(token_vals['signature'])
|
||||
|
||||
except model.InvalidTokenException:
|
||||
logger.warning('Token could not be validated: %s' %
|
||||
token_vals['signature'])
|
||||
abort(401)
|
||||
|
||||
session['repository'] = token_data.repository.name
|
||||
session['namespace'] = token_data.repository.namespace
|
||||
|
||||
logger.debug('Successfully validated token: %s' % token_data.code)
|
||||
ctx = _request_ctx_stack.top
|
||||
ctx.validated_token = token_data
|
||||
|
||||
identity_changed.send(app, identity=Identity(token_data.code, 'token'))
|
||||
|
||||
|
||||
def process_auth(f):
|
||||
@wraps(f)
|
||||
|
|
|
@ -80,19 +80,14 @@ def on_identity_loaded(sender, identity):
|
|||
identity_changed.send(app, identity=switch_to_deferred)
|
||||
|
||||
elif identity.auth_type == 'token':
|
||||
logger.debug('Computing permissions for token: %s' % identity.id)
|
||||
logger.debug('Loading permissions for token: %s' % identity.id)
|
||||
token_data = model.load_token_data(identity.id)
|
||||
|
||||
token = model.get_token(identity.id)
|
||||
|
||||
if token.user:
|
||||
query = model.get_user_repo_permissions(token.user, token.repository)
|
||||
for permission in query:
|
||||
t_grant = _RepositoryNeed(token.repository.namespace,
|
||||
token.repository.name, permission.role.name)
|
||||
logger.debug('Token added permission: {0}'.format(t_grant))
|
||||
identity.provides.add(t_grant)
|
||||
else:
|
||||
logger.debug('Token was anonymous.')
|
||||
repo_grant = _RepositoryNeed(token_data.repository.namespace,
|
||||
token_data.repository.name,
|
||||
token_data.role.name)
|
||||
logger.debug('Delegate token added permission: {0}'.format(repo_grant))
|
||||
identity.provides.add(repo_grant)
|
||||
|
||||
else:
|
||||
logger.error('Unknown identity auth type: %s' % identity.auth_type)
|
||||
|
|
|
@ -99,10 +99,13 @@ def random_string_generator(length=16):
|
|||
|
||||
|
||||
class AccessToken(BaseModel):
|
||||
code = CharField(default=random_string_generator(), unique=True, index=True)
|
||||
user = ForeignKeyField(User, null=True)
|
||||
friendly_name = CharField(null=True)
|
||||
code = CharField(default=random_string_generator(length=64), unique=True,
|
||||
index=True)
|
||||
repository = ForeignKeyField(Repository)
|
||||
created = DateTimeField(default=datetime.now)
|
||||
role = ForeignKeyField(Role)
|
||||
temporary = BooleanField(default=True)
|
||||
|
||||
|
||||
class EmailConfirmation(BaseModel):
|
||||
|
|
|
@ -26,6 +26,10 @@ class InvalidPasswordException(DataModelException):
|
|||
pass
|
||||
|
||||
|
||||
class InvalidTokenException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
def create_user(username, password, email):
|
||||
if not validate_email(email):
|
||||
raise InvalidEmailAddressException('Invalid email address: %s' % email)
|
||||
|
@ -159,25 +163,6 @@ def verify_user(username, password):
|
|||
return None
|
||||
|
||||
|
||||
def create_access_token(user, repository):
|
||||
new_token = AccessToken.create(user=user, repository=repository)
|
||||
return new_token
|
||||
|
||||
|
||||
def verify_token(code, namespace_name, repository_name):
|
||||
joined = AccessToken.select(AccessToken, Repository).join(Repository)
|
||||
tokens = list(joined.where(AccessToken.code == code,
|
||||
Repository.namespace == namespace_name,
|
||||
Repository.name == repository_name))
|
||||
if tokens:
|
||||
return tokens[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_token(code):
|
||||
return AccessToken.get(AccessToken.code == code)
|
||||
|
||||
|
||||
def get_visible_repositories(username=None, include_public=True, limit=None,
|
||||
sort=False):
|
||||
if not username and not include_public:
|
||||
|
@ -485,3 +470,69 @@ def get_private_repo_count(username):
|
|||
joined = Repository.select().join(Visibility)
|
||||
return joined.where(Repository.namespace == username,
|
||||
Visibility.name == 'private').count()
|
||||
|
||||
|
||||
def create_access_token(repository, role):
|
||||
role = Role.get(Role.name == role)
|
||||
new_token = AccessToken.create(repository=repository, temporary=True,
|
||||
role=role)
|
||||
return new_token
|
||||
|
||||
|
||||
def create_delegate_token(namespace_name, repository_name, friendly_name):
|
||||
read_only = Role.get(name='read')
|
||||
repo = Repository.get(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
new_token = AccessToken.create(repository=repo, role=read_only,
|
||||
friendly_name=friendly_name, temporary=False)
|
||||
return new_token
|
||||
|
||||
|
||||
def get_repository_delegate_tokens(namespace_name, repository_name):
|
||||
selected = AccessToken.select(AccessToken, Role)
|
||||
with_repo = selected.join(Repository)
|
||||
with_role = with_repo.switch(AccessToken).join(Role)
|
||||
return with_role.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
AccessToken.temporary == False)
|
||||
|
||||
|
||||
def get_repo_delegate_token(namespace_name, repository_name, code):
|
||||
repo_query = get_repository_delegate_tokens(namespace_name, repository_name)
|
||||
found = list(repo_query.where(AccessToken.code == code))
|
||||
|
||||
if found:
|
||||
return found[0]
|
||||
else:
|
||||
raise InvalidTokenException('Unable to find token with code: %s' % code)
|
||||
|
||||
|
||||
def set_repo_delegate_token_role(namespace_name, repository_name, code, role):
|
||||
token = get_repo_delegate_token(namespace_name, repository_name, code)
|
||||
|
||||
if role != 'read' and role != 'write':
|
||||
raise DataModelException('Invalid role for delegate token: %s' % role)
|
||||
|
||||
new_role = Role.get(Role.name == role)
|
||||
token.role = new_role
|
||||
token.save()
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def delete_delegate_token(namespace_name, repository_name, code):
|
||||
token = get_repo_delegate_token(namespace_name, repository_name, code)
|
||||
token.delete_instance()
|
||||
|
||||
|
||||
def load_token_data(code):
|
||||
""" Load the permissions for any token by code. """
|
||||
selected = AccessToken.select(AccessToken, Repository, Role)
|
||||
with_role = selected.join(Role)
|
||||
with_repo = with_role.switch(AccessToken).join(Repository)
|
||||
fetched = list(with_repo.where(AccessToken.code == code))
|
||||
|
||||
if fetched:
|
||||
return fetched[0]
|
||||
else:
|
||||
raise InvalidTokenException('Invalid delegate token code: %s' % code)
|
||||
|
|
|
@ -456,6 +456,92 @@ def delete_permissions(namespace, repository, username):
|
|||
abort(403) # Permission denied
|
||||
|
||||
|
||||
def token_view(token_obj):
|
||||
return {
|
||||
'friendlyName': token_obj.friendly_name,
|
||||
'code': token_obj.code,
|
||||
'role': token_obj.role.name,
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tokens/', methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def list_repo_tokens(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
tokens = model.get_repository_delegate_tokens(namespace, repository)
|
||||
|
||||
return jsonify({
|
||||
'tokens': {token.code: token_view(token) for token in tokens}
|
||||
})
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tokens/<code>', methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def get_tokens(namespace, repository, code):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
perm = model.get_repo_delegate_token(namespace, repository, code)
|
||||
return jsonify(token_view(perm))
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tokens/', methods=['POST'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def create_token(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
token_params = request.get_json()
|
||||
|
||||
token = model.create_delegate_token(namespace, repository,
|
||||
token_params['friendlyName'])
|
||||
|
||||
resp = jsonify(token_view(token))
|
||||
resp.status_code = 201
|
||||
return resp
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tokens/<code>', methods=['PUT'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def change_token(namespace, repository, code):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
new_permission = request.get_json()
|
||||
|
||||
logger.debug('Setting permission to: %s for code %s' %
|
||||
(new_permission['role'], code))
|
||||
|
||||
token = model.set_repo_delegate_token_role(namespace, repository, code,
|
||||
new_permission['role'])
|
||||
|
||||
resp = jsonify(token_view(token))
|
||||
return resp
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tokens/<code>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def delete_token(namespace, repository, code):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
model.delete_delegate_token(namespace, repository, code)
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
def subscription_view(stripe_subscription, used_repos):
|
||||
return {
|
||||
'currentPeriodStart': stripe_subscription.current_period_start,
|
||||
|
|
|
@ -19,7 +19,8 @@ from auth.permissions import (ModifyRepositoryPermission,
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_headers(f):
|
||||
def generate_headers(role='read'):
|
||||
def decorator_method(f):
|
||||
@wraps(f)
|
||||
def wrapper(namespace, repository, *args, **kwargs):
|
||||
response = f(namespace, repository, *args, **kwargs)
|
||||
|
@ -30,14 +31,14 @@ def generate_headers(f):
|
|||
|
||||
if has_token_request:
|
||||
repo = model.get_repository(namespace, repository)
|
||||
token = model.create_access_token(get_authenticated_user(), repo)
|
||||
token_str = 'signature=%s,repository="%s/%s"' % (token.code, namespace,
|
||||
repository)
|
||||
token = model.create_access_token(repo, role)
|
||||
token_str = 'signature=%s' % token.code
|
||||
response.headers['WWW-Authenticate'] = token_str
|
||||
response.headers['X-Docker-Token'] = token_str
|
||||
|
||||
return response
|
||||
return wrapper
|
||||
return decorator_method
|
||||
|
||||
|
||||
@app.route('/v1/users', methods=['POST'])
|
||||
|
@ -47,6 +48,13 @@ def create_user():
|
|||
username = user_data['username']
|
||||
password = user_data['password']
|
||||
|
||||
if username == '$token':
|
||||
try:
|
||||
token = model.load_token_data(password)
|
||||
return make_response('Verified', 201)
|
||||
except model.InvalidTokenException:
|
||||
abort(401)
|
||||
|
||||
existing_user = model.get_user(username)
|
||||
if existing_user:
|
||||
verified = model.verify_user(username, password)
|
||||
|
@ -100,13 +108,17 @@ def update_user(username):
|
|||
@app.route('/v1/repositories/<path:repository>', methods=['PUT'])
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers
|
||||
@generate_headers(role='write')
|
||||
def create_repository(namespace, repository):
|
||||
image_descriptions = json.loads(request.data)
|
||||
|
||||
repo = model.get_repository(namespace, repository)
|
||||
|
||||
if repo:
|
||||
if not repo and get_authenticated_user() is None:
|
||||
logger.debug('Attempt to create new repository with token auth.')
|
||||
abort(400)
|
||||
|
||||
elif repo:
|
||||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
if not permission.can():
|
||||
abort(403)
|
||||
|
@ -135,7 +147,10 @@ def create_repository(namespace, repository):
|
|||
|
||||
response = make_response('Created', 201)
|
||||
|
||||
if get_authenticated_user():
|
||||
mixpanel.track(get_authenticated_user().username, 'push_repo')
|
||||
else:
|
||||
mixpanel.track(get_validated_token().code, 'push_repo')
|
||||
|
||||
return response
|
||||
|
||||
|
@ -143,7 +158,7 @@ def create_repository(namespace, repository):
|
|||
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers
|
||||
@generate_headers(role='write')
|
||||
def update_images(namespace, repository):
|
||||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
|
||||
|
@ -164,7 +179,7 @@ def update_images(namespace, repository):
|
|||
@app.route('/v1/repositories/<path:repository>/images', methods=['GET'])
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers
|
||||
@generate_headers(role='read')
|
||||
def get_repository_images(namespace, repository):
|
||||
permission = ReadRepositoryPermission(namespace, repository)
|
||||
|
||||
|
@ -196,7 +211,7 @@ def get_repository_images(namespace, repository):
|
|||
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers
|
||||
@generate_headers(role='write')
|
||||
def delete_repository_images(namespace, repository):
|
||||
pass
|
||||
|
||||
|
|
|
@ -469,6 +469,40 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
|||
});
|
||||
};
|
||||
|
||||
$scope.createToken = function() {
|
||||
var friendlyName = {
|
||||
'friendlyName': $scope.newToken.friendlyName
|
||||
};
|
||||
|
||||
var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
|
||||
permissionPost.customPOST(friendlyName).then(function(newToken) {
|
||||
$scope.tokens[newToken.code] = newToken;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.deleteToken = function(tokenCode) {
|
||||
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
|
||||
deleteAction.customDELETE().then(function() {
|
||||
delete $scope.tokens[tokenCode];
|
||||
});
|
||||
};
|
||||
|
||||
$scope.changeTokenAccess = function(tokenCode, newAccess) {
|
||||
var role = {
|
||||
'role': newAccess
|
||||
};
|
||||
|
||||
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
|
||||
deleteAction.customPUT(role).then(function(updated) {
|
||||
$scope.tokens[updated.code] = updated;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.showToken = function(tokenCode) {
|
||||
$scope.shownToken = $scope.tokens[tokenCode];
|
||||
$('#tokenmodal').modal({});
|
||||
};
|
||||
|
||||
$scope.askChangeAccess = function(newAccess) {
|
||||
$('#make' + newAccess + 'Modal').modal({});
|
||||
};
|
||||
|
@ -512,7 +546,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
|||
var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name);
|
||||
repositoryFetch.get().then(function(repo) {
|
||||
$scope.repo = repo;
|
||||
$scope.loading = !($scope.permissions && $scope.repo);
|
||||
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||
}, function() {
|
||||
$scope.permissions = null;
|
||||
$rootScope.title = 'Unknown Repository';
|
||||
|
@ -524,12 +558,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
|||
permissionsFetch.get().then(function(resp) {
|
||||
$rootScope.title = 'Settings - ' + namespace + '/' + name;
|
||||
$scope.permissions = resp.permissions;
|
||||
$scope.loading = !($scope.permissions && $scope.repo);
|
||||
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||
}, function() {
|
||||
$scope.permissions = null;
|
||||
$rootScope.title = 'Unknown Repository';
|
||||
$scope.loading = false;
|
||||
});
|
||||
|
||||
// Fetch the tokens.
|
||||
var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
|
||||
tokensFetch.get().then(function(resp) {
|
||||
$scope.tokens = resp.tokens;
|
||||
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||
}, function() {
|
||||
$scope.tokens = null;
|
||||
$scope.loading = false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) {
|
||||
|
|
|
@ -56,7 +56,53 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<!-- Token Permissions -->
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Access Token Permissions</div>
|
||||
<div class="panel-body">
|
||||
|
||||
<table class="permissions">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Token</td>
|
||||
<td>Permissions</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="(code, token) in tokens">
|
||||
<td class="user">
|
||||
<i class="icon-key"></i>
|
||||
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
|
||||
</td>
|
||||
<td class="user-permissions">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'read')" ng-class="{read: 'active', write: ''}[token.role]">Read only</button>
|
||||
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Delete Token">
|
||||
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="icon-remove"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<form name="createTokenForm" ng-submit="createToken()">
|
||||
<td>
|
||||
<input class="form-control" placeholder="New token friendly name..." ng-model="newToken.friendlyName">
|
||||
</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-sm btn-default">Create</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public/Private -->
|
||||
<div class="panel panel-default">
|
||||
|
@ -114,6 +160,24 @@
|
|||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="tokenmodal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Token code for {{ shownToken.friendlyName }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ shownToken.code }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="makepublicModal">
|
||||
|
|
BIN
test.db
BIN
test.db
Binary file not shown.
Reference in a new issue