Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Commit

Permalink
feature: contributors allowed to delete repositories/tags
Browse files Browse the repository at this point in the history
With the variety usage of Portus, the need of giving contributors more
responsibilities arose. So far only admin were able to delete repositories
and tags if the capability were enabled. This patch extends the feature to
also allow contributors to be able delete resources.

We also took the opportunity to migrate and add two endpoints [0] [1] the
API regarding this topic. Users are now able to invoke the API to perform
those actions if they want to.

[0] DELETE /api/v1/repositories/:id
[1] DELETE /api/v1/tags/:id

Signed-off-by: Vítor Avelino <[email protected]>
  • Loading branch information
vitoravelino authored and Vítor Avelino committed Feb 27, 2018
1 parent 4bba5e1 commit 53ce896
Show file tree
Hide file tree
Showing 31 changed files with 592 additions and 293 deletions.
5 changes: 5 additions & 0 deletions app/assets/javascripts/main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Alert from '~/shared/components/alert';

import 'jquery-ujs';

// Bootstrap
Expand Down Expand Up @@ -30,6 +32,9 @@ import { setTimeOutAlertDelay, refreshFloatAlertPosition } from './utils/effects

// Actions to be done to initialize any page.
$(function () {
// process scheduled alerts
Alert.$process();

refreshFloatAlertPosition();

// necessary to be compatible with the js rendered
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
<button type="button" class="btn btn-danger tag-delete-btn" @click="deleteTags()" v-if="state.selectedTags.length > 0">
<button type="button" class="btn btn-danger tag-delete-btn" @click="deleteTags()" :disabled="state.isDeleting" v-if="state.selectedTags.length > 0">
<i class="fa fa-trash"></i>
Delete {{ tagNormalized }}
</button>
Expand Down
47 changes: 44 additions & 3 deletions app/assets/javascripts/modules/repositories/pages/show.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ $(() => {

return {
state: store.state,
isLoading: false,
isDeleting: false,
isLoading: true,
notLoaded: false,
unableToFetchBefore: false,
tags: [],
Expand All @@ -58,6 +59,11 @@ $(() => {
fetchTags() {
const repositoryId = this.$refs.repoTitle.dataset.id;

if (this.state.isDeleting) {
setTimeout(() => this.fetchTags(), POLLING_VALUE);
return;
}

RepositoriesService.groupedTags(repositoryId).then((response) => {
set(this, 'tags', response.body);
set(this, 'notLoaded', false);
Expand Down Expand Up @@ -113,27 +119,62 @@ $(() => {

if (!this.tags.length) {
const namespaceHref = this.$refs.repoLink.href;

this.$alert.$schedule('Repository removed with all its tags.');
window.location.href = namespaceHref;
}
}
};

this.state.selectedTags.forEach((t) => {
set(this.state, 'isDeleting', true);

TagsService.remove(t.id).then(() => {
this.removeFromCollection(t.id);
this.removeFromSelection(t.id);
success.push(t.name);
}).catch(() => {
failure.push(t.name);
}).finally(() => showAlert(++promiseCount));
}).finally(() => {
set(this.state, 'isDeleting', false);
showAlert(++promiseCount);
});
});
},

deleteRepository() {
set(this.state, 'isDeleting', true);

RepositoriesService.remove(this.state.repository.id).then(() => {
const namespaceHref = this.$refs.repoLink.href;

this.$alert.$schedule('Repository removed with all its tags');
window.location.href = namespaceHref;
}).catch((response) => {
let errors = response.data.errors || response.data.error;

if (Array.isArray(errors)) {
errors = errors.join('<br />');
}

this.$alert.$show(errors, false);
}).finally(() => set(this.state, 'isDeleting', false));
},
},

mounted() {
set(this, 'isLoading', true);
const DELETE_BTN = '.repository-delete-btn';
const POPOVER_DELETE = '.popover-repository-delete';

// TODO: refactor bootstrap popover to a component
$(this.$el).on('inserted.bs.popover', DELETE_BTN, () => {
const $yes = $(POPOVER_DELETE).find('.yes');
$yes.click(this.deleteRepository.bind(this));
});

this.loadData();
this.$bus.$on('deleteTags', () => this.deleteTags());

// eslint-disable-next-line no-new
new CommentsPanel($('.comments-wrapper'));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ function get(id) {
return resource.get({ id });
}

function remove(id) {
return resource.delete({ id });
}

function groupedTags(repositoryId) {
return tagsResource.groupedTags({ repositoryId });
}

export default {
get,
groupedTags,
remove,
};
4 changes: 2 additions & 2 deletions app/assets/javascripts/modules/repositories/services/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import VueResource from 'vue-resource';

Vue.use(VueResource);

const oldResource = Vue.resource('tags{/id}.json');
const resource = Vue.resource('api/v1/tags{/id}');

function remove(id) {
return oldResource.delete({ id });
return resource.delete({ id });
}

export default {
Expand Down
5 changes: 4 additions & 1 deletion app/assets/javascripts/modules/repositories/store.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export default class RepositoriesStore {
constructor() {
this.state = {};
this.state = {
isDeleting: false,
repository: {},
};

this.state.selectedTags = [];
}
Expand Down
29 changes: 26 additions & 3 deletions app/assets/javascripts/shared/components/alert.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
const ALERT_ELEMENT = '#float-alert';
const TEXT_ALERT_ELEMENT = '#float-alert p';
const HIDE_TIMEOUT = 5000;
const STORAGE_KEY = 'portus.alerts.schedule';

function $show(text, autohide = true) {
const storage = window.localStorage;

const $show = (text, autohide = true, timeout = HIDE_TIMEOUT) => {
$(TEXT_ALERT_ELEMENT).html(text);
$(ALERT_ELEMENT).fadeIn();

if (autohide) {
setTimeout(() => $(ALERT_ELEMENT).fadeOut(), HIDE_TIMEOUT);
setTimeout(() => $(ALERT_ELEMENT).fadeOut(), timeout);
}
}
};

const scheduledMessages = () => JSON.parse(storage.getItem(STORAGE_KEY)) || [];
const storeMessages = messages => storage.setItem(STORAGE_KEY, JSON.stringify(messages));

// the idea is to simulate the alert that is showed after a redirect
// e.g.: something happened that requires a page reload/redirect and
// we need to show this info to the user.
const $schedule = (text) => {
const messages = scheduledMessages();
messages.push(text);
storeMessages(messages);
};

const $process = () => {
const messages = scheduledMessages();
messages.forEach(m => $show(m, false));
storage.clear(STORAGE_KEY);
};

export default {
$show,
$schedule,
$process,
};
18 changes: 0 additions & 18 deletions app/controllers/concerns/deletable.rb

This file was deleted.

28 changes: 1 addition & 27 deletions app/controllers/repositories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
# frozen_string_literal: true

class RepositoriesController < ApplicationController
include Deletable

before_action :set_repository, only: %i[show destroy toggle_star]
before_action :set_repository, only: %i[show toggle_star]

# GET /repositories
# GET /repositories.json
Expand Down Expand Up @@ -32,30 +30,6 @@ def toggle_star
render template: "repositories/star", locals: { user: current_user }
end

# Removes all the tags that belong to this repository, and removes it.
def destroy
authorize @repository

# First of all we mark the repo and the tags, so we don't have concurrency
# problems when "delete" events come in.
@repository.tags.update_all(marked: true)
@repository.update_attributes(marked: true)

# Remove all tags, effectively removing them from the registry too.
@repository.groupped_tags.map { |t| t.first.delete_by_digest!(current_user) }

# Delete this repository if all tags were successfully deleted.
if @repository.reload.tags.any?
ts = @repository.tags.pluck(:name).join(", ")
logger.error "The following tags could not be removed: #{ts}."
redirect_to repository_path(@repository), alert: "Could not remove all the tags", float: true
else
@repository.delete_by!(current_user)
redirect_to namespace_path(@repository.namespace),
notice: "Repository removed with all its tags", float: true
end
end

protected

def set_repository
Expand Down
23 changes: 0 additions & 23 deletions app/controllers/tags_controller.rb
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
# frozen_string_literal: true

class TagsController < ApplicationController
include Deletable

def show
@tag = Tag.find(params[:id])
authorize @tag

@names = Tag.where(digest: @tag.digest).sort.map(&:name)
@vulnerabilities = @tag.fetch_vulnerabilities
end

# Removes all tags that match the digest of the tag with the given ID.
# Moreover, it will also remove the image if it's left empty after removing
# the tags.
def destroy
tag = Tag.find(params[:id])
authorize tag

# And now remove the tag by the digest. If the repository containing said
# tags becomes empty after that, remove it too.
repo = tag.repository
if tag.delete_by_digest!(current_user)
if repo.tags.empty?
repo.delete_by!(current_user)
flash[:notice] = "Repository removed with all its tags"
end
head :ok
else
head :internal_server_error
end
end
end
5 changes: 2 additions & 3 deletions app/helpers/repositories_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ def render_delete_activity(activity)
end

# Returns true if the user can remove the given repository.
def can_destroy?(repository)
APP_CONFIG.enabled?("delete") &&
RepositoryPolicy.new(current_user, repository).destroy?
def can_destroy_repository?(repository)
RepositoryPolicy.new(current_user, repository).destroy?
end

# Returns if any security module is enabled
Expand Down
48 changes: 27 additions & 21 deletions app/models/repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,27 +67,9 @@ def groupped_tags
# regarding the removal of this.
def delete_by!(actor)
logger.tagged("catalog") { logger.info "Removed the image '#{name}'." }

# Take care of current activities.
PublicActivity::Activity.where(trackable: self).update_all(
trackable_type: Namespace,
trackable_id: namespace.id,
recipient_type: nil
)

# Add a "delete" activity"
namespace.create_activity(
:delete,
owner: actor,
recipient: self,
parameters: {
repository_name: name,
namespace_id: namespace.id,
namespace_name: namespace.clean_name
}
)

destroy
destroyed = destroy
create_delete_activities!(actor) if destroyed
destroyed
end

# Handle a push event from the registry.
Expand Down Expand Up @@ -256,4 +238,28 @@ def self.create_tags(client, repository, author, tags)
logger.tagged("catalog") { logger.info "Created the tag '#{tag}'." }
end
end

protected

# Create/update the activities for a delete operation.
def create_delete_activities!(actor)
# Take care of current activities.
PublicActivity::Activity.where(trackable: self).update_all(
trackable_type: Namespace,
trackable_id: namespace.id,
recipient_type: nil
)

# Add a "delete" activity"
namespace.create_activity(
:delete,
owner: actor,
recipient: self,
parameters: {
repository_name: name,
namespace_id: namespace.id,
namespace_name: namespace.clean_name
}
)
end
end
11 changes: 8 additions & 3 deletions app/models/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ def delete_by_digest!(actor)
return false
end

Tag.where(digest: dig).map { |t| t.delete_by!(actor) }
success = true
Tag.where(digest: dig).find_each do |tag|
success &&= tag&.delete_by!(actor)
end
success
end

# Delete this tag and update its activity.
Expand All @@ -86,8 +90,9 @@ def delete_by!(actor)
end

# Delete tag and create the corresponding activities.
destroy
create_delete_activities!(actor)
destroyed = destroy
create_delete_activities!(actor) if destroyed
destroyed
end

# Returns vulnerabilities if there are any available and security scanning is
Expand Down
Loading

0 comments on commit 53ce896

Please sign in to comment.