diff --git a/CHANGELOG.md b/CHANGELOG.md
index 795033bf2..41338d426 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -138,6 +138,7 @@
- Update admin instances dashboard ([ecfc0766](https://github.com/pixelfed/pixelfed/commit/ecfc0766))
- Update ap helpers, fix album order bug by setting media order ([871f798c](https://github.com/pixelfed/pixelfed/commit/871f798c))
- Update image pipeline, dispatch jobs to mmo queue and add "replace_id" param to v2/media endpoint to dispatch delayed MediaDeletePipeline job for original media id to improve media gc on supported clients ([5a67e9f9](https://github.com/pixelfed/pixelfed/commit/5a67e9f9))
+- Update admin instance management, improve filtering/sorting and add import/export support ([d5d9500d](https://github.com/pixelfed/pixelfed/commit/d5d9500d))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)
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/public/js/admin.js b/public/js/admin.js
index f2cd2496c..c51e4892d 100644
Binary files a/public/js/admin.js and b/public/js/admin.js differ
diff --git a/public/js/admin.js.LICENSE.txt b/public/js/admin.js.LICENSE.txt
index 7105f07c6..baf12926e 100644
--- a/public/js/admin.js.LICENSE.txt
+++ b/public/js/admin.js.LICENSE.txt
@@ -17,3 +17,5 @@
*/
/*! @source http://purl.eligrey.com/github/canvas-toBlob.js/blob/master/canvas-toBlob.js */
+
+/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
diff --git a/public/js/manifest.js b/public/js/manifest.js
index 2f79a370c..01c1ea7d9 100644
Binary files a/public/js/manifest.js and b/public/js/manifest.js differ
diff --git a/public/js/post.chunk.52e6d50f600ac40a.js b/public/js/post.chunk.52e6d50f600ac40a.js
new file mode 100644
index 000000000..c0427d48e
Binary files /dev/null and b/public/js/post.chunk.52e6d50f600ac40a.js differ
diff --git a/public/js/post.chunk.734a9056e41a9e23.js b/public/js/post.chunk.734a9056e41a9e23.js
deleted file mode 100644
index 21f31ab36..000000000
Binary files a/public/js/post.chunk.734a9056e41a9e23.js and /dev/null differ
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index bc1ce547d..7820d9cbe 100644
Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ
diff --git a/resources/assets/components/Post.vue b/resources/assets/components/Post.vue
new file mode 100644
index 000000000..7e0774933
--- /dev/null
+++ b/resources/assets/components/Post.vue
@@ -0,0 +1,406 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Error displaying post
+
This can happen for a few reasons:
+
+ - The url is invalid or has a typo
+ - The page has been flagged for review by our automated abuse detection systems
+ - The content may have been deleted
+ - You do not have permission to view this content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
@@ -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:
- Press OK
- Press "Choose File" on Import form input
- Select your pixelfed-instances-mod.json file
- Review instance moderation actions. Tap on an instance to remove it
- 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');
});
});