From d5d9500d07cc192e10669c88d91bb9cbcbb2f77d Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sat, 25 Mar 2023 01:23:05 -0600 Subject: [PATCH] Update admin instance management, improve filtering/sorting and add import/export support --- .../Admin/AdminInstanceController.php | 89 ++++- .../DeleteRemoteProfilePipeline.php | 2 +- .../DeleteRemoteStatusPipeline.php | 2 +- .../components/admin/AdminInstances.vue | 336 ++++++++++++++++-- routes/web.php | 2 + 5 files changed, 390 insertions(+), 41 deletions(-) diff --git a/app/Http/Controllers/Admin/AdminInstanceController.php b/app/Http/Controllers/Admin/AdminInstanceController.php index 6a17fdb2a..0eeaf73fa 100644 --- a/app/Http/Controllers/Admin/AdminInstanceController.php +++ b/app/Http/Controllers/Admin/AdminInstanceController.php @@ -97,7 +97,7 @@ trait AdminInstanceController return AdminInstance::collection( Instance::where('domain', 'like', '%' . $q . '%') ->orderByDesc('user_count') - ->cursorPaginate(20) + ->cursorPaginate(10) ->withQueryString() ); } @@ -120,23 +120,47 @@ trait AdminInstanceController 'all' ]) ], + 'sort' => [ + 'sometimes', + 'string', + Rule::in([ + 'id', + 'domain', + 'software', + 'user_count', + 'status_count', + 'banned', + 'auto_cw', + 'unlisted' + ]) + ], + 'dir' => 'sometimes|in:desc,asc' ]); $filter = $request->input('filter'); $query = $request->input('q'); + $sortCol = $request->input('sort'); + $sortDir = $request->input('dir'); return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) { return $q->where('domain', 'like', '%' . $query . '%'); }) ->when($filter, function($q, $f) use($filter) { - if($filter == 'cw') { return $q->whereAutoCw(true)->orderByDesc('id'); } - if($filter == 'unlisted') { return $q->whereUnlisted(true)->orderByDesc('id'); } - if($filter == 'banned') { return $q->whereBanned(true)->orderByDesc('id'); } + if($filter == 'cw') { return $q->whereAutoCw(true); } + if($filter == 'unlisted') { return $q->whereUnlisted(true); } + if($filter == 'banned') { return $q->whereBanned(true); } if($filter == 'new') { return $q->orderByDesc('id'); } if($filter == 'popular_users') { return $q->orderByDesc('user_count'); } if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); } return $q->orderByDesc('id'); - }, function($q) { - return $q->orderByDesc('id'); + }) + ->when($sortCol, function($q, $s) use($sortCol, $sortDir, $filter) { + if(!in_array($filter, ['popular_users', 'popular_statuses'])) { + return $q->whereNotNull($sortCol)->orderBy($sortCol, $sortDir); + } + }, function($q) use($filter) { + if(!$filter || !in_array($filter, ['popular_users', 'popular_statuses'])) { + return $q->orderByDesc('id'); + } }) ->cursorPaginate(10) ->withQueryString()); @@ -222,4 +246,57 @@ trait AdminInstanceController return 200; } + + public function downloadBackup(Request $request) + { + return response()->streamDownload(function () { + $json = [ + 'version' => 1, + 'auto_cw' => Instance::whereAutoCw(true)->pluck('domain')->toArray(), + 'unlisted' => Instance::whereUnlisted(true)->pluck('domain')->toArray(), + 'banned' => Instance::whereBanned(true)->pluck('domain')->toArray(), + 'created_at' => now()->format('c'), + ]; + $chk = hash('sha256', json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)); + $json['_sha256'] = $chk; + echo json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }, 'pixelfed-instances-mod.json'); + } + + public function importBackup(Request $request) + { + $this->validate($request, [ + 'banned' => 'sometimes|array', + 'auto_cw' => 'sometimes|array', + 'unlisted' => 'sometimes|array', + ]); + + $banned = $request->input('banned'); + $auto_cw = $request->input('auto_cw'); + $unlisted = $request->input('unlisted'); + + foreach($banned as $i) { + Instance::updateOrCreate( + ['domain' => $i], + ['banned' => true] + ); + } + + foreach($auto_cw as $i) { + Instance::updateOrCreate( + ['domain' => $i], + ['auto_cw' => true] + ); + } + + foreach($unlisted as $i) { + Instance::updateOrCreate( + ['domain' => $i], + ['unlisted' => true] + ); + } + + InstanceService::refresh(); + return [200]; + } } diff --git a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php index 430bace79..095cf98e3 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php @@ -150,6 +150,6 @@ class DeleteRemoteProfilePipeline implements ShouldQueue // Delete profile Profile::findOrFail($profile->id)->delete(); - return; + return 1; } } diff --git a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php index 8a692ebc7..d27249c2a 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php @@ -61,7 +61,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue } NetworkTimelineService::del($status->id); - Cache::forget(StatusService::key($status->id)); + StatusService::del($status->id, true); Bookmark::whereStatusId($status->id)->delete(); Notification::whereItemType('App\Status') ->whereItemId($status->id) diff --git a/resources/assets/components/admin/AdminInstances.vue b/resources/assets/components/admin/AdminInstances.vue index 67bfe9fe6..e1c242d14 100644 --- a/resources/assets/components/admin/AdminInstances.vue +++ b/resources/assets/components/admin/AdminInstances.vue @@ -39,6 +39,21 @@
+
+
+
+ + +
+
+

+ Cancel +

+
+
+ + +
@@ -111,7 +126,7 @@ - + @@ -293,7 +308,62 @@ + + +
+
+

NSFW Instances ({{importData.auto_cw.length}})

+

Tap on an instance to remove it.

+ +
+ +
+

Unlisted Instances ({{importData.unlisted.length}})

+

Tap on an instance to remove it.

+ +
+ +
+

Banned Instances ({{importData.banned.length}})

+

Review instances, tap on an instance to remove it.

+ +
+ +
+
+

+ +

+

Nothing to import!

+
+
+
@@ -347,7 +417,10 @@ auto_cw: false, unlisted: false, notes: undefined - } + }, + showImportForm: false, + showImportModal: false, + importData: undefined, } }, @@ -355,31 +428,47 @@ this.fetchStats(); let u = new URLSearchParams(window.location.search); - if(u.has('filter') || u.has('cursor') && !u.has('q')) { - let url = '/i/admin/api/instances/get?'; + if(u.has('filter') && !u.has('q') && !u.has('sort')) { + const url = new URL(window.location.origin + '/i/admin/api/instances/get'); - let filter = u.get('filter'); - if(filter) { - this.tabIndex = this.filterMap.indexOf(filter); - url = url + 'filter=' + filter + '&'; + if(u.has('filter')) { + this.tabIndex = this.filterMap.indexOf(u.get('filter')); + url.searchParams.set('filter', u.get('filter')); + } + if(u.has('cursor')) { + url.searchParams.set('cursor', u.get('cursor')); } - let cursor = u.get('cursor'); - if(cursor) { - url = url + 'cursor=' + cursor; + this.fetchInstances(url.toString()); + } else if(u.has('sort') && !u.has('q')) { + const url = new URL(window.location.origin + '/i/admin/api/instances/get'); + url.searchParams.set('sort', u.get('sort')); + + if(u.has('dir')) { + url.searchParams.set('dir', u.get('dir')); } - this.fetchInstances(url); + if(u.has('filter')) { + url.searchParams.set('filter', u.get('filter')); + } + + if(u.has('cursor')) { + url.searchParams.set('cursor', u.get('cursor')); + } + + this.fetchInstances(url.toString()); } else if(u.has('q')) { this.tabIndex = -1; this.searchQuery = u.get('q'); - let cursor = u.has('cursor'); - let q = u.get('q'); - let url = '/i/admin/api/instances/query?q=' + q; - if(cursor) { - url = url + '&cursor=' + u.get('cursor'); + + const url = new URL(window.location.origin + '/i/admin/api/instances/query'); + url.searchParams.set('q', u.get('q')); + + if(u.has('cursor')) { + url.searchParams.set('cursor', u.get('cursor')); } - this.fetchInstances(url); + + this.fetchInstances(url.toString()); } else { this.fetchInstances(); } @@ -470,19 +559,52 @@ }, toggleCol(col) { - // this.sortCol = col; + if(this.filterMap[this.tabIndex] == col || this.searchQuery) { + return; + } + this.sortCol = col; - // if(!this.sortDir) { - // this.sortDir = 'desc'; - // } else { - // this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc'; - // } + if(!this.sortDir) { + this.sortDir = 'desc'; + } else { + this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc'; + } - // let url = '/i/admin/api/hashtags/query?sort=' + col + '&dir=' + this.sortDir; - // this.fetchHashtags(url); + const url = new URL(window.location.origin + '/i/admin/instances'); + url.searchParams.set('sort', col); + url.searchParams.set('dir', this.sortDir); + if(this.tabIndex != 0) { + url.searchParams.set('filter', this.filterMap[this.tabIndex]); + } + history.pushState(null, '', url); + + const apiUrl = new URL(window.location.origin + '/i/admin/api/instances/get'); + apiUrl.searchParams.set('sort', col); + apiUrl.searchParams.set('dir', this.sortDir); + if(this.tabIndex != 0) { + apiUrl.searchParams.set('filter', this.filterMap[this.tabIndex]); + } + + this.fetchInstances(apiUrl.toString()); }, buildColumn(name, col) { + if([1, 5, 6].indexOf(this.tabIndex) != -1 || (this.searchQuery && this.searchQuery.length)) { + return name; + } + + if(this.tabIndex === 2 && col === 'banned') { + return name; + } + + if(this.tabIndex === 3 && col === 'auto_cw') { + return name; + } + + if(this.tabIndex === 4 && col === 'unlisted') { + return name; + } + let icon = ``; if(col == this.sortCol) { icon = this.sortDir == 'desc' ? @@ -496,17 +618,26 @@ event.currentTarget.blur(); let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev; let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor; - let url = '/i/admin/instances?'; - if(this.tabIndex && !this.searchQuery) { - url = url + 'filter=' + this.filterMap[this.tabIndex] + '&'; - } + + const url = new URL(window.location.origin + '/i/admin/instances'); + if(cursor) { - url = url + 'cursor=' + cursor; + url.searchParams.set('cursor', cursor); } + if(this.searchQuery) { - url = url + '&q=' + this.searchQuery; + url.searchParams.set('q', this.searchQuery); } - history.pushState(null, '', url); + + if(this.sortCol) { + url.searchParams.set('sort', this.sortCol); + } + + if(this.sortDir) { + url.searchParams.set('dir', this.sortDir); + } + + history.pushState(null, '', url.toString()); this.fetchInstances(apiUrl); }, @@ -616,7 +747,146 @@ this.showInstanceModal = false; this.instances = this.instances.filter(i => i.id != this.instanceModal.id); }) + .then(() => { + setTimeout(() => this.fetchStats(), 1000); + }) + }, + + openImportForm() { + let el = document.createElement('p'); + el.classList.add('text-left'); + el.classList.add('mb-0'); + el.innerHTML = '

Import your instance moderation backup.


Import Instructions:

  1. Press OK
  2. Press "Choose File" on Import form input
  3. Select your pixelfed-instances-mod.json file
  4. Review instance moderation actions. Tap on an instance to remove it
  5. Press "Import" button to finish importing
'; + let wrapper = document.createElement('div'); + wrapper.appendChild(el); + swal({ + title: 'Import Backup', + content: wrapper, + icon: 'info' + }) + this.showImportForm = true; + }, + + downloadBackup($event) { + axios.get('/i/admin/api/instances/download-backup', { + responseType: "blob" + }) + .then(res => { + let el = document.createElement('a'); + el.setAttribute('download', 'pixelfed-instances-mod.json') + const href = URL.createObjectURL(res.data); + el.href = href; + el.setAttribute('target', '_blank'); + el.click(); + + swal( + 'Instance Backup Downloading', + 'Your instance moderation backup is downloading. Use this to import auto_cw, banned and unlisted instances to supported Pixelfed instances.', + 'success' + ) + }) + }, + + async onImportUpload(ev) { + let res = await this.getParsedImport(ev.target.files[0]); + + if(!res.hasOwnProperty('version') || res.version !== 1) { + swal('Invalid Backup', 'We cannot validate this backup. Please try again later.', 'error'); + this.showImportForm = false; + this.$refs.importInput.reset(); + return; + } + this.importData = res; + this.showImportModal = true; + }, + + async getParsedImport(ev) { + try { + return await this.parseJsonFile(ev); + } catch(err) { + let el = document.createElement('p'); + el.classList.add('text-left'); + el.classList.add('mb-0'); + el.innerHTML = '

An error occured when attempting to parse the import file. Please try again later.


Error message:

' + err.message + '
'; + let wrapper = document.createElement('div'); + wrapper.appendChild(el); + swal({ + title: 'Import Error', + content: wrapper, + icon: 'error' + }) + return; + } + }, + + async promisedParseJSON(json) { + return new Promise((resolve, reject) => { + try { + resolve(JSON.parse(json)) + } catch (e) { + reject(e) + } + }) + }, + + async parseJsonFile(file) { + return new Promise((resolve, reject) => { + const fileReader = new FileReader() + fileReader.onload = event => resolve(this.promisedParseJSON(event.target.result)) + fileReader.onerror = error => reject(error) + fileReader.readAsText(file) + }) + }, + + filterImportData(type, index) { + switch(type) { + case 'auto_cw': + this.importData.auto_cw.splice(index, 1); + break; + + case 'unlisted': + this.importData.unlisted.splice(index, 1); + break; + + case 'banned': + this.importData.banned.splice(index, 1); + break; + } + }, + + completeImport() { + this.showImportForm = false; + + axios.post('/i/admin/api/instances/import-data', { + 'banned': this.importData.banned, + 'auto_cw': this.importData.auto_cw, + 'unlisted': this.importData.unlisted, + }) + .then(res => { + swal('Import Uploaded', 'Import successfully uploaded, please allow a few minutes to process.', 'success'); + }) + .then(() => { + setTimeout(() => this.fetchStats(), 1000); + }) + }, + + cancelImport(bvModalEvent) { + if(this.importData.banned.length || this.importData.auto_cw.length || this.importData.unlisted.length) { + if(!window.confirm('Are you sure you want to cancel importing?')) { + bvModalEvent.preventDefault(); + return; + } else { + this.showImportForm = false; + this.$refs.importInput.value = ''; + this.importData = { + banned: [], + auto_cw: [], + unlisted: [] + }; + } + } } + } } diff --git a/routes/web.php b/routes/web.php index f83a75788..5e4919f40 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,6 +120,8 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio Route::post('instances/create', 'AdminController@postInstanceCreateNewApi'); Route::post('instances/delete', 'AdminController@postInstanceDeleteApi'); Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi'); + Route::get('instances/download-backup', 'AdminController@downloadBackup'); + Route::post('instances/import-data', 'AdminController@importBackup'); }); });