diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 984b83133734e81a87194de73016a4f0ca5ee11f..de12ab1ab9188ca5b1e548dbbe20084690a41de8 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -235,12 +235,23 @@ class ManageUploadFilterSet(filters.FilterSet): ] +def filter_allowed(queryset, name, value): + """ + If value=false, we want to include object with value=null as well + """ + if value: + return queryset.filter(allowed=True) + else: + return queryset.filter(Q(allowed=False) | Q(allowed__isnull=True)) + + class ManageDomainFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) + allowed = filters.BooleanFilter(method=filter_allowed) class Meta: model = federation_models.Domain - fields = ["name"] + fields = ["name", "allowed"] class ManageActorFilterSet(filters.FilterSet): diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 25f8c01dbbf46e572eb5d712408c59202515627d..c72e015bf97095c6206e08c343ea96b471ce3697 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -152,7 +152,11 @@ class ManageDomainUpdateSerializer(ManageDomainSerializer): class ManageDomainActionSerializer(common_serializers.ActionSerializer): - actions = [common_serializers.Action("purge", allow_all=False)] + actions = [ + common_serializers.Action("purge", allow_all=False), + common_serializers.Action("allow_list_add", allow_all=True), + common_serializers.Action("allow_list_remove", allow_all=True), + ] filterset_class = filters.ManageDomainFilterSet pk_field = "name" @@ -161,6 +165,14 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer): ids = objects.values_list("pk", flat=True) common_utils.on_commit(federation_tasks.purge_actors.delay, domains=list(ids)) + @transaction.atomic + def handle_allow_list_add(self, objects): + objects.update(allowed=True) + + @transaction.atomic + def handle_allow_list_remove(self, objects): + objects.update(allowed=False) + class ManageBaseActorSerializer(serializers.ModelSerializer): class Meta: diff --git a/api/funkwhale_api/moderation/dynamic_preferences_registry.py b/api/funkwhale_api/moderation/dynamic_preferences_registry.py index 8d8237cbb4231728021316eacf4eacad00436b14..04a732f4d4863a97d52e1401d0d921cc042c9eeb 100644 --- a/api/funkwhale_api/moderation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/moderation/dynamic_preferences_registry.py @@ -19,8 +19,8 @@ class AllowListPublic(types.BooleanPreference): name = "allow_list_public" verbose_name = "Publish your allowed-domains list" help_text = ( - "If enabled, everyone will be able to retrieve the list of domains you allowed. ", + "If enabled, everyone will be able to retrieve the list of domains you allowed. " "This is useful on open setups, to help people decide if they want to join your pod, or to " - "make your moderation policy public.", + "make your moderation policy public." ) default = False diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index cfbb175171924cb8a05e4a2bdf8de1f3885eab6c..e621bf14c37d19e1dac19220c7033e47692d5e5b 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -176,6 +176,26 @@ def test_manage_domain_action_purge(factories, mocker): ) +def test_manage_domain_action_allow_list_add(factories, mocker): + domains = factories["federation.Domain"].create_batch(size=3, allowed=False) + s = serializers.ManageDomainActionSerializer(queryset=None) + s.handle_allow_list_add(domains[0].__class__.objects.all()) + + for domain in domains: + domain.refresh_from_db() + assert domain.allowed is True + + +def test_manage_domain_action_allow_list_remove(factories, mocker): + domains = factories["federation.Domain"].create_batch(size=3, allowed=True) + s = serializers.ManageDomainActionSerializer(queryset=None) + s.handle_allow_list_remove(domains[0].__class__.objects.all()) + + for domain in domains: + domain.refresh_from_db() + assert domain.allowed is False + + @pytest.mark.parametrize( "param,expected_only", [("block_all", []), ("reject_media", ["media"])] ) diff --git a/front/src/components/manage/moderation/DomainsTable.vue b/front/src/components/manage/moderation/DomainsTable.vue index 544d91156f3101236b8395bec133b9248d2d112a..089b7288e9b50554ff7a312058513c5e999d3ec1 100644 --- a/front/src/components/manage/moderation/DomainsTable.vue +++ b/front/src/components/manage/moderation/DomainsTable.vue @@ -6,6 +6,14 @@ <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> <input name="search" type="text" v-model="search" :placeholder="labels.searchPlaceholder" /> </div> + <div class="field" v-if="allowListEnabled"> + <label><translate translate-context="Content/Moderation/*/Adjective">Is present on allow-list</translate></label> + <select class="ui dropdown" v-model="allowed"> + <option :value="null"><translate translate-context="*/*/*">All</translate></option> + <option :value="true"><translate translate-context="*/*/*">Yes</translate></option> + <option :value="false"><translate translate-context="*/*/*">No</translate></option> + </select> + </div> <div class="field"> <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> <select class="ui dropdown" v-model="ordering"> @@ -44,7 +52,10 @@ </template> <template slot="row-cells" slot-scope="scope"> <td> - <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.name }}">{{ scope.obj.name }}</router-link> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.name }}"> + {{ scope.obj.name }} + <i v-if="allowListEnabled && scope.obj.allowed" class="green check icon" :title="labels.allowListTitle"></i> + </router-link> </td> <td> {{ scope.obj.actors_count }} @@ -93,7 +104,8 @@ import TranslationsMixin from '@/components/mixins/Translations' export default { mixins: [OrderingMixin, TranslationsMixin], props: { - filters: {type: Object, required: false} + filters: {type: Object, required: false}, + allowListEnabled: {type: Boolean, default: false}, }, components: { Pagination, @@ -108,6 +120,7 @@ export default { page: 1, paginateBy: 50, search: '', + allowed: null, orderingDirection: defaultOrdering.direction || '+', ordering: defaultOrdering.field, orderingOptions: [ @@ -124,12 +137,16 @@ export default { }, methods: { fetchData () { - let params = _.merge({ + let baseFilters = { 'page': this.page, 'page_size': this.paginateBy, 'q': this.search, - 'ordering': this.getOrderingAsString() - }, this.filters) + 'ordering': this.getOrderingAsString(), + } + if (this.allowed !== null) { + baseFilters.allowed = this.allowed + } + let params = _.merge(baseFilters, this.filters) let self = this self.isLoading = true self.checked = [] @@ -148,7 +165,8 @@ export default { computed: { labels () { return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by name…') + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by name…'), + allowListTitle: this.$pgettext('Content/Moderation/Popup', 'This domain is present in your allow-list'), } }, actionFilters () { @@ -167,7 +185,21 @@ export default { name: 'purge', label: this.$pgettext('*/*/*/Verb', 'Purge'), isDangerous: true - } + }, + { + name: 'allow_list_add', + label: this.$pgettext('Content/Moderation/Action/Verb', 'Add to allow-list'), + filterCheckable: (obj) => { + return !obj.allowed + } + }, + { + name: 'allow_list_remove', + label: this.$pgettext('Content/Moderation/Action/Verb', 'Remove from allow-list'), + filterCheckable: (obj) => { + return obj.allowed + } + }, ] } }, @@ -179,6 +211,9 @@ export default { page () { this.fetchData() }, + allowed () { + this.fetchData() + }, ordering () { this.fetchData() }, diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index 7102fb311b1b64fa9c63f160d026659ebe06a087..a1d03aab77740435d4636315196ad979482ea295 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -82,6 +82,7 @@ export default { let musicLabel = this.$pgettext('*/*/*/Noun', 'Music') let playlistsLabel = this.$pgettext('*/*/*', 'Playlists') let federationLabel = this.$pgettext('Content/Admin/Menu', 'Federation') + let moderationLabel = this.$pgettext('Content/Admin/Menu', 'Moderation') let subsonicLabel = this.$pgettext('Content/Admin/Menu', 'Subsonic') let statisticsLabel = this.$pgettext('Content/Admin/Menu', 'Statistics') let errorLabel = this.$pgettext('Content/Admin/Menu', 'Error reporting') @@ -118,6 +119,14 @@ export default { id: "playlists", settings: ["playlists__max_tracks"] }, + { + label: moderationLabel, + id: "moderation", + settings: [ + "moderation__allow_list_enabled", + "moderation__allow_list_public", + ] + }, { label: federationLabel, id: "federation", diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue index 564debf79ae8f7dcc42abe94e073511de237f1ac..d1c3ae29fb8e7fb1a074f2dc65e0b30679e1c5f5 100644 --- a/front/src/views/admin/moderation/Base.vue +++ b/front/src/views/admin/moderation/Base.vue @@ -9,12 +9,31 @@ :to="{name: 'manage.moderation.accounts.list'}"><translate translate-context="*/Moderation/Title">Accounts</translate></router-link> </nav> - <router-view :key="$route.fullPath"></router-view> + <router-view :allow-list-enabled="allowListEnabled" :key="$route.fullPath"></router-view> </div> </template> <script> +import _ from '@/lodash' +import axios from 'axios' + export default { + data () { + return { + allowListEnabled: false + } + }, + created () { + this.fetchNodeInfo() + }, + methods: { + fetchNodeInfo () { + let self = this + axios.get('instance/nodeinfo/2.0/').then(response => { + self.allowListEnabled = _.get(response.data, 'metadata.allowList.enabled', false) + }) + }, + }, computed: { labels() { return { @@ -22,6 +41,6 @@ export default { secondaryMenu: this.$pgettext('Menu/*/Hidden text', "Secondary menu") } } - } + }, } </script> diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue index 575b15f93ca2bbf696b9672cbb38e7535c8b09ab..c30f426bbbca56045d292cffcc92d4784b457328 100644 --- a/front/src/views/admin/moderation/DomainsDetail.vue +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -20,6 +20,34 @@ </div> </div> </h2> + <div class="header-buttons"> + <div class="ui icon buttons"> + <a + v-if="$store.state.auth.profile.is_superuser" + class="ui labeled icon button" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/federation/domain/${object.name}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + </div> + <div v-if="allowListEnabled" class="ui icon buttons"> + <button + v-if="object.allowed" + @click.prevent="setAllowList(false)" + :class="['ui', 'labeled', {loading: isLoadingAllowList}, 'icon', 'button']"> + <i class="x icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Remove from allow-list</translate> + </button> + <button + v-else + @click.prevent="setAllowList(true)" + :class="['ui', 'labeled', {loading: isLoadingAllowList}, 'icon', 'button']"> + <i class="check icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Add to allow-list</translate> + </button> + </div> + </div> </div> </div> <div class="ui column"> @@ -74,6 +102,15 @@ </h3> <table class="ui very basic table"> <tbody> + <tr v-if="allowListEnabled"> + <td> + <translate translate-context="Content/Moderation/*/Adjective">Is present on allow-list</translate> + </td> + <td> + <translate v-if="object.allowed" translate-context="*/*/*">Yes</translate> + <translate v-else translate-context="*/*/*">No</translate> + </td> + </tr> <tr> <td> <translate translate-context="Content/*/Table.Label">Last checked</translate> @@ -300,7 +337,7 @@ import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyFor import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard" export default { - props: ["id"], + props: ["id", "allowListEnabled"], components: { InstancePolicyForm, InstancePolicyCard, @@ -311,6 +348,7 @@ export default { isLoading: true, isLoadingStats: false, isLoadingPolicy: false, + isLoadingAllowList: false, policy: null, object: null, stats: null, @@ -353,6 +391,15 @@ export default { self.isLoadingPolicy = false }) }, + setAllowList(value) { + var self = this + this.isLoadingAllowList = true + let url = `manage/federation/domains/${this.id}/` + axios.patch(url, {allowed: value}).then(response => { + self.object = response.data + self.isLoadingAllowList = false + }) + }, refreshNodeInfo (data) { this.object.nodeinfo = data this.object.nodeinfo_fetch_date = new Date() diff --git a/front/src/views/admin/moderation/DomainsList.vue b/front/src/views/admin/moderation/DomainsList.vue index 3ce8e6afc44a2e653bc5ecb88a6837c3d80181d9..b4ffad7b8ca8b7e4b3d121e83474c806d6d4d0aa 100644 --- a/front/src/views/admin/moderation/DomainsList.vue +++ b/front/src/views/admin/moderation/DomainsList.vue @@ -14,6 +14,10 @@ <label for="domain"><translate translate-context="Content/Moderation/Form.Label/Verb">Add a domain</translate></label> <input type="text" name="domain" id="domain" v-model="domainName"> </div> + <div class="field" v-if="allowListEnabled"> + <input type="checkbox" name="allowed" id="allowed" v-model="domainAllowed"> + <label for="allowed"><translate translate-context="Content/Moderation/Form.Label/Verb">Add to allow-list</translate></label> + </div> <div class="field"> <button :class="['ui', {'loading': isCreating}, 'green', 'button']" type="submit" :disabled="isCreating"> <label for="domain"><translate translate-context="Content/Moderation/Button/Verb">Add</translate></label> @@ -22,7 +26,7 @@ </div> </form> <div class="ui clearing hidden divider"></div> - <domains-table></domains-table> + <domains-table :allow-list-enabled="allowListEnabled"></domains-table> </section> </main> </template> @@ -32,12 +36,14 @@ import axios from 'axios' import DomainsTable from "@/components/manage/moderation/DomainsTable" export default { + props: ['allowListEnabled'], components: { DomainsTable }, data () { return { domainName: '', + domainAllowed: this.allowListEnabled ? true : null, isCreating: false, errors: [] } @@ -54,7 +60,7 @@ export default { let self = this this.isCreating = true this.errors = [] - axios.post('manage/federation/domains/', {name: this.domainName}).then((response) => { + axios.post('manage/federation/domains/', {name: this.domainName, allowed: this.domainAllowed}).then((response) => { this.isCreating = false this.$router.push({ name: "manage.moderation.domains.detail",