Merge pull request #4834 from pixelfed/staging

Add User Domain Blocks
This commit is contained in:
daniel 2023-12-21 05:25:46 -07:00 committed by GitHub
commit eebed73a5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1669 additions and 354 deletions

View file

@ -12,6 +12,7 @@
- Added `app:hashtag-cached-count-update` command to update cached_count of hashtags and add to scheduler to run every 25 minutes past the hour ([1e31fee6](https://github.com/pixelfed/pixelfed/commit/1e31fee6))
- Added `app:hashtag-related-generate` command to generate related hashtags ([176b4ed7](https://github.com/pixelfed/pixelfed/commit/176b4ed7))
- Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46))
- Added User Domain Blocks ([#4834](https://github.com/pixelfed/pixelfed/pull/4834)) ([fa0380ac](https://github.com/pixelfed/pixelfed/commit/fa0380ac))
### Federation
- Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e))
@ -80,6 +81,8 @@
- Update Inbox, improve tombstone query efficiency ([759a4393](https://github.com/pixelfed/pixelfed/commit/759a4393))
- Update AccountService, add setLastActive method ([ebbd98e7](https://github.com/pixelfed/pixelfed/commit/ebbd98e7))
- Update ApiV1Controller, set last_active_at ([b6419545](https://github.com/pixelfed/pixelfed/commit/b6419545))
- Update AdminShadowFilter, fix deleted profile bug ([a492a95a](https://github.com/pixelfed/pixelfed/commit/a492a95a))
- Update FollowerService, add $silent param to remove method to more efficently purge relationships ([1664a5bc](https://github.com/pixelfed/pixelfed/commit/1664a5bc))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9)

View file

@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
use App\Models\DefaultDomainBlock;
use App\Models\UserDomainBlock;
use function Laravel\Prompts\text;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\progress;
class AddUserDomainBlock extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:add-user-domain-block';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Apply a domain block to all users';
/**
* Execute the console command.
*/
public function handle()
{
$domain = text('Enter domain you want to block');
$domain = strtolower($domain);
$domain = $this->validateDomain($domain);
if(!$domain || empty($domain)) {
$this->error('Invalid domain');
return;
}
$this->processBlocks($domain);
return;
}
protected function validateDomain($domain)
{
if(!strpos($domain, '.')) {
return;
}
if(str_starts_with($domain, 'https://')) {
$domain = str_replace('https://', '', $domain);
}
if(str_starts_with($domain, 'http://')) {
$domain = str_replace('http://', '', $domain);
}
$domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST));
$valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE);
if(!$valid) {
return;
}
if($domain === config('pixelfed.domain.app')) {
$this->error('Invalid domain');
return;
}
$confirmed = confirm('Are you sure you want to block ' . $domain . '?');
if(!$confirmed) {
return;
}
return $domain;
}
protected function processBlocks($domain)
{
DefaultDomainBlock::updateOrCreate([
'domain' => $domain
]);
progress(
label: 'Updating user domain blocks...',
steps: User::lazyById(500),
callback: fn ($user) => $this->performTask($user, $domain),
);
}
protected function performTask($user, $domain)
{
if(!$user->profile_id || $user->delete_after) {
return;
}
if($user->status != null && $user->status != 'disabled') {
return;
}
UserDomainBlock::updateOrCreate([
'profile_id' => $user->profile_id,
'domain' => $domain
]);
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
use App\Models\DefaultDomainBlock;
use App\Models\UserDomainBlock;
use function Laravel\Prompts\text;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\progress;
class DeleteUserDomainBlock extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:delete-user-domain-block';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Remove a domain block for all users';
/**
* Execute the console command.
*/
public function handle()
{
$domain = text('Enter domain you want to unblock');
$domain = strtolower($domain);
$domain = $this->validateDomain($domain);
if(!$domain || empty($domain)) {
$this->error('Invalid domain');
return;
}
$this->processUnblocks($domain);
return;
}
protected function validateDomain($domain)
{
if(!strpos($domain, '.')) {
return;
}
if(str_starts_with($domain, 'https://')) {
$domain = str_replace('https://', '', $domain);
}
if(str_starts_with($domain, 'http://')) {
$domain = str_replace('http://', '', $domain);
}
$domain = strtolower(parse_url('https://' . $domain, PHP_URL_HOST));
$valid = filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME|FILTER_NULL_ON_FAILURE);
if(!$valid) {
return;
}
if($domain === config('pixelfed.domain.app')) {
return;
}
$confirmed = confirm('Are you sure you want to unblock ' . $domain . '?');
if(!$confirmed) {
return;
}
return $domain;
}
protected function processUnblocks($domain)
{
DefaultDomainBlock::whereDomain($domain)->delete();
if(!UserDomainBlock::whereDomain($domain)->count()) {
$this->info('No results found!');
return;
}
progress(
label: 'Updating user domain blocks...',
steps: UserDomainBlock::whereDomain($domain)->lazyById(500),
callback: fn ($domainBlock) => $this->performTask($domainBlock),
);
}
protected function performTask($domainBlock)
{
$domainBlock->deleteQuietly();
}
}

View file

@ -19,7 +19,8 @@ class AdminShadowFilterController extends Controller
{
$filter = $request->input('filter');
$searchQuery = $request->input('q');
$filters = AdminShadowFilter::when($filter, function($q, $filter) {
$filters = AdminShadowFilter::whereHas('profile')
->when($filter, function($q, $filter) {
if($filter == 'all') {
return $q;
} else if($filter == 'inactive') {

View file

@ -31,6 +31,7 @@ use App\{
UserSetting,
UserFilter,
};
use App\Models\UserDomainBlock;
use League\Fractal;
use App\Transformer\Api\Mastodon\v1\{
AccountTransformer,
@ -2422,6 +2423,7 @@ class ApiV1Controller extends Controller
$local = $request->has('local');
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
AccountService::setLastActive($user->id);
$domainBlocks = UserFilterService::domainBlocks($user->profile_id);
if($remote && config('instance.timeline.network.cached')) {
Cache::remember('api:v1:timelines:network:cache_check', 10368000, function() {
@ -2496,6 +2498,13 @@ class ApiV1Controller extends Controller
->filter(function($s) use($filtered) {
return $s && isset($s['account']) && in_array($s['account']['id'], $filtered) == false;
})
->filter(function($s) use($domainBlocks) {
if(!$domainBlocks || !count($domainBlocks)) {
return $s;
}
$domain = strtolower(parse_url($s['url'], PHP_URL_HOST));
return !in_array($domain, $domainBlocks);
})
->take($limit)
->values();
@ -3276,6 +3285,7 @@ class ApiV1Controller extends Controller
$limit = $request->input('limit', 20);
$onlyMedia = $request->input('only_media', true);
$pe = $request->has(self::PF_API_ENTITY_KEY);
$pid = $request->user()->profile_id;
if($min || $max) {
$minMax = SnowflakeService::byDate(now()->subMonths(6));
@ -3287,7 +3297,8 @@ class ApiV1Controller extends Controller
}
}
$filters = UserFilterService::filters($request->user()->profile_id);
$filters = UserFilterService::filters($pid);
$domainBlocks = UserFilterService::domainBlocks($pid);
if(!$min && !$max) {
$id = 1;
@ -3313,10 +3324,11 @@ class ApiV1Controller extends Controller
if($onlyMedia && !isset($i['media_attachments']) || !count($i['media_attachments'])) {
return false;
}
return $i && isset($i['account']);
return $i && isset($i['account'], $i['url']);
})
->filter(function($i) use($filters) {
return !in_array($i['account']['id'], $filters);
->filter(function($i) use($filters, $domainBlocks) {
$domain = strtolower(parse_url($i['url'], PHP_URL_HOST));
return !in_array($i['account']['id'], $filters) && !in_array($domain, $domainBlocks);
})
->values()
->toArray();
@ -3619,25 +3631,31 @@ class ApiV1Controller extends Controller
$pid = $request->user()->profile_id;
$ids = Cache::remember('api:v1.1:discover:accounts:popular', 86400, function() {
$ids = Cache::remember('api:v1.1:discover:accounts:popular', 3600, function() {
return DB::table('profiles')
->where('is_private', false)
->whereNull('status')
->orderByDesc('profiles.followers_count')
->limit(20)
->limit(30)
->get();
});
$filters = UserFilterService::filters($pid);
$ids = $ids->map(function($profile) {
return AccountService::get($profile->id, true);
})
->filter(function($profile) use($pid) {
return $profile && isset($profile['id']);
return $profile && isset($profile['id'], $profile['locked']) && !$profile['locked'];
})
->filter(function($profile) use($pid) {
return $profile['id'] != $pid;
})
->take(6)
->filter(function($profile) use($pid) {
return !FollowerService::follows($pid, $profile['id'], true);
})
->filter(function($profile) use($filters) {
return !in_array($profile['id'], $filters);
})
->take(16)
->values();
return $this->json($ids);

View file

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\UserDomainBlock;
use App\Util\ActivityPub\Helpers;
use App\Services\UserFilterService;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use App\Jobs\HomeFeedPipeline\FeedRemoveDomainPipeline;
use App\Jobs\ProfilePipeline\ProfilePurgeNotificationsByDomain;
use App\Jobs\ProfilePipeline\ProfilePurgeFollowersByDomain;
class DomainBlockController extends Controller
{
public function json($res, $code = 200, $headers = [])
{
return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
}
public function index(Request $request)
{
abort_unless($request->user(), 403);
$this->validate($request, [
'limit' => 'sometimes|integer|min:1|max:200'
]);
$limit = $request->input('limit', 100);
$id = $request->user()->profile_id;
$filters = UserDomainBlock::whereProfileId($id)->orderByDesc('id')->cursorPaginate($limit);
$links = null;
$headers = [];
if($filters->nextCursor()) {
$links .= '<'.$filters->nextPageUrl().'&limit='.$limit.'>; rel="next"';
}
if($filters->previousCursor()) {
if($links != null) {
$links .= ', ';
}
$links .= '<'.$filters->previousPageUrl().'&limit='.$limit.'>; rel="prev"';
}
if($links) {
$headers = ['Link' => $links];
}
return $this->json($filters->pluck('domain'), 200, $headers);
}
public function store(Request $request)
{
abort_unless($request->user(), 403);
$this->validate($request, [
'domain' => 'required|active_url|min:1|max:120'
]);
$pid = $request->user()->profile_id;
$domain = trim($request->input('domain'));
if(Helpers::validateUrl($domain) == false) {
return abort(500, 'Invalid domain or already blocked by server admins');
}
$domain = strtolower(parse_url($domain, PHP_URL_HOST));
abort_if(config_cache('pixelfed.domain.app') == $domain, 400, 'Cannot ban your own server');
$existingCount = UserDomainBlock::whereProfileId($pid)->count();
$maxLimit = config('instance.user_filters.max_domain_blocks');
$errorMsg = __('profile.block.domain.max', ['max' => $maxLimit]);
abort_if($existingCount >= $maxLimit, 400, $errorMsg);
$block = UserDomainBlock::updateOrCreate([
'profile_id' => $pid,
'domain' => $domain
]);
if($block->wasRecentlyCreated) {
Bus::batch([
[
new FeedRemoveDomainPipeline($pid, $domain),
new ProfilePurgeNotificationsByDomain($pid, $domain),
new ProfilePurgeFollowersByDomain($pid, $domain)
]
])->allowFailures()->onQueue('feed')->dispatch();
Cache::forget('profile:following:' . $pid);
UserFilterService::domainBlocks($pid, true);
}
return $this->json([]);
}
public function delete(Request $request)
{
abort_unless($request->user(), 403);
$this->validate($request, [
'domain' => 'required|min:1|max:120'
]);
$pid = $request->user()->profile_id;
$domain = strtolower(trim($request->input('domain')));
$filters = UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->delete();
UserFilterService::domainBlocks($pid, true);
return $this->json([]);
}
}

View file

@ -14,6 +14,7 @@ use App\Util\Lexer\PrettyNumber;
use App\Util\ActivityPub\Helpers;
use Auth, Cache, DB;
use Illuminate\Http\Request;
use App\Models\UserDomainBlock;
trait PrivacySettings
{
@ -149,47 +150,25 @@ trait PrivacySettings
public function blockedInstances()
{
$pid = Auth::user()->profile->id;
$filters = UserFilter::whereUserId($pid)
->whereFilterableType('App\Instance')
->whereFilterType('block')
->orderByDesc('id')
->paginate(10);
return view('settings.privacy.blocked-instances', compact('filters'));
// deprecated
abort(404);
}
public function domainBlocks()
{
return view('settings.privacy.domain-blocks');
}
public function blockedInstanceStore(Request $request)
{
$this->validate($request, [
'domain' => 'required|url|min:1|max:120'
]);
$domain = $request->input('domain');
if(Helpers::validateUrl($domain) == false) {
return abort(400, 'Invalid domain');
}
$domain = parse_url($domain, PHP_URL_HOST);
$instance = Instance::firstOrCreate(['domain' => $domain]);
$filter = new UserFilter;
$filter->user_id = Auth::user()->profile->id;
$filter->filterable_id = $instance->id;
$filter->filterable_type = 'App\Instance';
$filter->filter_type = 'block';
$filter->save();
return response()->json(['msg' => 200]);
// deprecated
abort(404);
}
public function blockedInstanceUnblock(Request $request)
{
$this->validate($request, [
'id' => 'required|integer|min:1'
]);
$pid = Auth::user()->profile->id;
$filter = UserFilter::whereFilterableType('App\Instance')
->whereUserId($pid)
->findOrFail($request->input('id'));
$filter->delete();
return redirect(route('settings.privacy.blocked-instances'));
// deprecated
abort(404);
}
public function blockedKeywords()

View file

@ -76,10 +76,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
});
Mention::whereStatusId($status->id)->forceDelete();
Report::whereObjectType('App\Status')->whereObjectId($status->id)->delete();
$statusHashtags = StatusHashtag::whereStatusId($status->id)->get();
foreach($statusHashtags as $stag) {
$stag->delete();
}
StatusHashtag::whereStatusId($status->id)->deleteQuietly();
StatusView::whereStatusId($status->id)->delete();
Status::whereReblogOfId($status->id)->forceDelete();
$status->forceDelete();

View file

@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\FollowerService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
@ -69,7 +70,7 @@ class FeedInsertPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
$sid = $this->sid;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
@ -85,7 +86,24 @@ class FeedInsertPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
return;
}
$skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray();
$domain = strtolower(parse_url($status['url'], PHP_URL_HOST));
$skipIds = [];
if(strtolower(config('pixelfed.domain.app')) !== $domain) {
$skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray();
}
$filters = UserFilter::whereFilterableType('App\Profile')
->whereFilterableId($status['account']['id'])
->whereIn('filter_type', ['mute', 'block'])
->pluck('user_id')
->toArray();
if($filters && count($filters)) {
$skipIds = array_merge($skipIds, $filters);
}
$skipIds = array_unique(array_values($skipIds));
foreach($ids as $id) {
if(!in_array($id, $skipIds)) {

View file

@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\FollowerService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
@ -69,7 +70,7 @@ class FeedInsertRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProces
$sid = $this->sid;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
@ -83,7 +84,24 @@ class FeedInsertRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProces
return;
}
$skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray();
$domain = strtolower(parse_url($status['url'], PHP_URL_HOST));
$skipIds = [];
if(strtolower(config('pixelfed.domain.app')) !== $domain) {
$skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray();
}
$filters = UserFilter::whereFilterableType('App\Profile')
->whereFilterableId($status['account']['id'])
->whereIn('filter_type', ['mute', 'block'])
->pluck('user_id')
->toArray();
if($filters && count($filters)) {
$skipIds = array_merge($skipIds, $filters);
}
$skipIds = array_unique(array_values($skipIds));
foreach($ids as $id) {
if(!in_array($id, $skipIds)) {

View file

@ -0,0 +1,98 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\Services\StatusService;
use App\Services\HomeTimelineService;
class FeedRemoveDomainPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pid;
protected $domain;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'hts:feed:remove:domain:' . $this->pid . ':d-' . $this->domain;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:remove:domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($pid, $domain)
{
$this->pid = $pid;
$this->domain = $domain;
}
/**
* Execute the job.
*/
public function handle(): void
{
if(!config('exp.cached_home_timeline')) {
return;
}
if ($this->batch()->cancelled()) {
return;
}
if(!$this->pid || !$this->domain) {
return;
}
$domain = strtolower($this->domain);
$pid = $this->pid;
$posts = HomeTimelineService::get($pid, '0', '-1');
foreach($posts as $post) {
$status = StatusService::get($post, false);
if(!$status || !isset($status['url'])) {
HomeTimelineService::rem($pid, $post);
continue;
}
$host = strtolower(parse_url($status['url'], PHP_URL_HOST));
if($host === strtolower(config('pixelfed.domain.app')) || !$host) {
continue;
}
if($host === $domain) {
HomeTimelineService::rem($pid, $status['id']);
}
}
}
}

View file

@ -11,6 +11,7 @@ use Illuminate\Queue\SerializesModels;
use App\Hashtag;
use App\StatusHashtag;
use App\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\HashtagFollowService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
@ -77,7 +78,7 @@ class HashtagInsertFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilPro
$sid = $hashtag->status_id;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
@ -85,7 +86,20 @@ class HashtagInsertFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilPro
return;
}
$skipIds = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray();
$domain = strtolower(parse_url($status['url'], PHP_URL_HOST));
$skipIds = [];
if(strtolower(config('pixelfed.domain.app')) !== $domain) {
$skipIds = UserDomainBlock::where('domain', $domain)->pluck('profile_id')->toArray();
}
$filters = UserFilter::whereFilterableType('App\Profile')->whereFilterableId($status['account']['id'])->whereIn('filter_type', ['mute', 'block'])->pluck('user_id')->toArray();
if($filters && count($filters)) {
$skipIds = array_merge($skipIds, $filters);
}
$skipIds = array_unique(array_values($skipIds));
$ids = HashtagFollowService::getPidByHid($hashtag->hashtag_id);

View file

@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Log;
class ImageResize implements ShouldQueue
{
@ -46,6 +47,7 @@ class ImageResize implements ShouldQueue
}
$path = storage_path('app/'.$media->media_path);
if (!is_file($path) || $media->skip_optimize) {
Log::info('Tried to optimize media that does not exist or is not readable. ' . $path);
return;
}
@ -57,6 +59,7 @@ class ImageResize implements ShouldQueue
$img = new Image();
$img->resizeImage($media);
} catch (Exception $e) {
Log::error($e);
}
ImageThumbnail::dispatch($media)->onQueue('mmo');

View file

@ -0,0 +1,119 @@
<?php
namespace App\Jobs\ProfilePipeline;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\Follower;
use App\Profile;
use App\Notification;
use DB;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\NotificationService;
class ProfilePurgeFollowersByDomain implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pid;
protected $domain;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'followers:v1:purge-by-domain:' . $this->pid . ':d-' . $this->domain;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("followers:v1:purge-by-domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($pid, $domain)
{
$this->pid = $pid;
$this->domain = $domain;
}
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$pid = $this->pid;
$domain = $this->domain;
$query = 'SELECT f.*
FROM followers f
JOIN profiles p ON p.id = f.profile_id OR p.id = f.following_id
WHERE (f.profile_id = ? OR f.following_id = ?)
AND p.domain = ?;';
$params = [$pid, $pid, $domain];
foreach(DB::cursor($query, $params) as $n) {
if(!$n || !$n->id) {
continue;
}
$follower = Follower::find($n->id);
if($follower->following_id == $pid && $follower->profile_id) {
FollowerService::remove($follower->profile_id, $pid, true);
$follower->delete();
} else if ($follower->profile_id == $pid && $follower->following_id) {
FollowerService::remove($follower->following_id, $pid, true);
$follower->delete();
}
}
$profile = Profile::find($pid);
$followerCount = DB::table('profiles')
->join('followers', 'profiles.id', '=', 'followers.following_id')
->where('followers.following_id', $pid)
->count();
$followingCount = DB::table('profiles')
->join('followers', 'profiles.id', '=', 'followers.following_id')
->where('followers.profile_id', $pid)
->count();
$profile->followers_count = $followerCount;
$profile->following_count = $followingCount;
$profile->save();
AccountService::del($profile->id);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Jobs\ProfilePipeline;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\Notification;
use DB;
use App\Services\NotificationService;
class ProfilePurgeNotificationsByDomain implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pid;
protected $domain;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'notify:v1:purge-by-domain:' . $this->pid . ':d-' . $this->domain;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("notify:v1:purge-by-domain:{$this->pid}:d-{$this->domain}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($pid, $domain)
{
$this->pid = $pid;
$this->domain = $domain;
}
/**
* Execute the job.
*/
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
$pid = $this->pid;
$domain = $this->domain;
$query = 'SELECT notifications.*
FROM profiles
JOIN notifications on profiles.id = notifications.actor_id
WHERE notifications.profile_id = ?
AND profiles.domain = ?';
$params = [$pid, $domain];
foreach(DB::cursor($query, $params) as $n) {
if(!$n || !$n->id) {
continue;
}
Notification::where('id', $n->id)->delete();
NotificationService::del($pid, $n->id);
}
}
}

View file

@ -174,10 +174,7 @@ class RemoteStatusDelete implements ShouldQueue, ShouldBeUniqueUntilProcessing
->whereObjectId($status->id)
->delete();
StatusArchived::whereStatusId($status->id)->delete();
$statusHashtags = StatusHashtag::whereStatusId($status->id)->get();
foreach($statusHashtags as $stag) {
$stag->delete();
}
StatusHashtag::whereStatusId($status->id)->deleteQuietly();
StatusView::whereStatusId($status->id)->delete();
Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]);

View file

@ -151,10 +151,7 @@ class StatusDelete implements ShouldQueue
->delete();
StatusArchived::whereStatusId($status->id)->delete();
$statusHashtags = StatusHashtag::whereStatusId($status->id)->get();
foreach($statusHashtags as $stag) {
$stag->delete();
}
StatusHashtag::whereStatusId($status->id)->deleteQuietly();
StatusView::whereStatusId($status->id)->delete();
Status::whereInReplyToId($status->id)->update(['in_reply_to_id' => null]);

View file

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Services\AccountService;
use App\Profile;
class AdminShadowFilter extends Model
{
@ -24,4 +25,9 @@ class AdminShadowFilter extends Model
return;
}
public function profile()
{
return $this->belongsTo(Profile::class, 'item_id');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DefaultDomainBlock extends Model
{
use HasFactory;
protected $guarded = [];
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Profile;
class UserDomainBlock extends Model
{
use HasFactory;
protected $guarded = [];
public $timestamps = false;
public function profile()
{
return $this->belongsTo(Profile::class, 'profile_id');
}
}

View file

@ -7,6 +7,9 @@ use App\Follower;
use App\Profile;
use App\User;
use App\UserSetting;
use App\Services\UserFilterService;
use App\Models\DefaultDomainBlock;
use App\Models\UserDomainBlock;
use App\Jobs\FollowPipeline\FollowPipeline;
use DB;
use App\Services\FollowerService;
@ -14,7 +17,18 @@ use App\Services\FollowerService;
class UserObserver
{
/**
* Listen to the User created event.
* Handle the notification "created" event.
*
* @param \App\User $user
* @return void
*/
public function created(User $user): void
{
$this->handleUser($user);
}
/**
* Listen to the User saved event.
*
* @param \App\User $user
*
@ -22,7 +36,38 @@ class UserObserver
*/
public function saved(User $user)
{
if($user->status == 'deleted') {
$this->handleUser($user);
}
/**
* Listen to the User updated event.
*
* @param \App\User $user
*
* @return void
*/
public function updated(User $user): void
{
$this->handleUser($user);
if($user->profile) {
$this->applyDefaultDomainBlocks($user);
}
}
/**
* Handle the user "deleted" event.
*
* @param \App\User $user
* @return void
*/
public function deleted(User $user)
{
FollowerService::delCache($user->profile_id);
}
protected function handleUser($user)
{
if(in_array($user->status, ['deleted', 'delete'])) {
return;
}
@ -49,9 +94,11 @@ class UserObserver
$profile->private_key = $pki_private;
$profile->public_key = $pki_public;
$profile->save();
$this->applyDefaultDomainBlocks($user);
return $profile;
});
DB::transaction(function() use($user, $profile) {
$user = User::findOrFail($user->id);
$user->profile_id = $profile->id;
@ -92,14 +139,22 @@ class UserObserver
}
}
/**
* Handle the user "deleted" event.
*
* @param \App\User $user
* @return void
*/
public function deleted(User $user)
protected function applyDefaultDomainBlocks($user)
{
FollowerService::delCache($user->profile_id);
if($user->profile_id == null) {
return;
}
$defaultDomainBlocks = DefaultDomainBlock::pluck('domain')->toArray();
if(!$defaultDomainBlocks || !count($defaultDomainBlocks)) {
return;
}
foreach($defaultDomainBlocks as $domain) {
UserDomainBlock::updateOrCreate([
'profile_id' => $user->profile_id,
'domain' => strtolower(trim($domain))
]);
}
}
}

View file

@ -7,6 +7,7 @@ use App\Profile;
use App\Status;
use App\User;
use App\UserSetting;
use App\Models\UserDomainBlock;
use App\Transformer\Api\AccountTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -234,4 +235,13 @@ class AccountService
}
return;
}
public static function blocksDomain($pid, $domain = false)
{
if(!$domain) {
return;
}
return UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->exists();
}
}

View file

@ -35,16 +35,18 @@ class FollowerService
Cache::forget('profile:following:' . $actor);
}
public static function remove($actor, $target)
public static function remove($actor, $target, $silent = false)
{
Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
Cache::forget('pf:services:follower:audience:' . $actor);
Cache::forget('pf:services:follower:audience:' . $target);
if($silent !== true) {
AccountService::del($actor);
AccountService::del($target);
RelationshipService::refresh($actor, $target);
Cache::forget('profile:following:' . $actor);
} else {
RelationshipService::forget($actor, $target);
}
}
public static function followers($id, $start = 0, $stop = 10)
@ -89,12 +91,16 @@ class FollowerService
return Redis::zCard(self::FOLLOWING_KEY . $id);
}
public static function follows(string $actor, string $target)
public static function follows(string $actor, string $target, $quickCheck = false)
{
if($actor == $target) {
return false;
}
if($quickCheck) {
return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor);
}
if(self::followerCount($target, false) && self::followingCount($actor, false)) {
self::cacheSyncCheck($target, 'followers');
return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor);

View file

@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use App\Follower;
use App\Status;
use App\Models\UserDomainBlock;
class HomeTimelineService
{
@ -81,6 +82,8 @@ class HomeTimelineService
$following = array_diff($following, $filters);
}
$domainBlocks = UserDomainBlock::whereProfileId($id)->pluck('domain')->toArray();
$ids = Status::where('id', '>', $minId)
->whereIn('profile_id', $following)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
@ -91,6 +94,16 @@ class HomeTimelineService
->pluck('id');
foreach($ids as $pid) {
$status = StatusService::get($pid, false);
if(!$status || !isset($status['account'], $status['url'])) {
continue;
}
if($domainBlocks && count($domainBlocks)) {
$domain = strtolower(parse_url($status['url'], PHP_URL_HOST));
if(in_array($domain, $domainBlocks)) {
continue;
}
}
self::add($id, $pid);
}

View file

@ -13,7 +13,7 @@ class MarkerService
return Cache::get(self::CACHE_KEY . $timeline . ':' . $profileId);
}
public static function set($profileId, $timeline = 'home', $entityId)
public static function set($profileId, $timeline = 'home', $entityId = false)
{
$existing = self::get($profileId, $timeline);
$key = self::CACHE_KEY . $timeline . ':' . $profileId;

View file

@ -95,7 +95,15 @@ class SearchApiV2Service
if(substr($webfingerQuery, 0, 1) !== '@') {
$webfingerQuery = '@' . $webfingerQuery;
}
$banned = InstanceService::getBannedDomains();
$banned = InstanceService::getBannedDomains() ?? [];
$domainBlocks = UserFilterService::domainBlocks($user->profile_id);
if($domainBlocks && count($domainBlocks)) {
$banned = array_unique(
array_values(
array_merge($banned, $domainBlocks)
)
);
}
$operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
$results = Profile::select('username', 'id', 'followers_count', 'domain')
->where('username', $operator, $query)
@ -172,8 +180,18 @@ class SearchApiV2Service
'hashtags' => [],
'statuses' => [],
];
$user = request()->user();
$mastodonMode = self::$mastodonMode;
$query = urldecode($this->query->input('q'));
$banned = InstanceService::getBannedDomains();
$domainBlocks = UserFilterService::domainBlocks($user->profile_id);
if($domainBlocks && count($domainBlocks)) {
$banned = array_unique(
array_values(
array_merge($banned, $domainBlocks)
)
);
}
if(substr($query, 0, 1) === '@' && !Str::contains($query, '.')) {
$default['accounts'] = $this->accounts(substr($query, 1));
return $default;
@ -197,7 +215,11 @@ class SearchApiV2Service
} catch (\Exception $e) {
return $default;
}
if($res && isset($res['id'])) {
if($res && isset($res['id'], $res['url'])) {
$domain = strtolower(parse_url($res['url'], PHP_URL_HOST));
if(in_array($domain, $banned)) {
return $default;
}
$default['accounts'][] = $res;
return $default;
} else {
@ -212,6 +234,10 @@ class SearchApiV2Service
return $default;
}
if($res && isset($res['id'])) {
$domain = strtolower(parse_url($res['url'], PHP_URL_HOST));
if(in_array($domain, $banned)) {
return $default;
}
$default['accounts'][] = $res;
return $default;
} else {
@ -221,6 +247,9 @@ class SearchApiV2Service
if($sid = Status::whereUri($query)->first()) {
$s = StatusService::get($sid->id, false);
if(!$s) {
return $default;
}
if(in_array($s['visibility'], ['public', 'unlisted'])) {
$default['statuses'][] = $s;
return $default;
@ -229,7 +258,7 @@ class SearchApiV2Service
try {
$res = ActivityPubFetchService::get($query);
$banned = InstanceService::getBannedDomains();
if($res) {
$json = json_decode($res, true);

View file

@ -4,12 +4,14 @@ namespace App\Services;
use Cache;
use App\UserFilter;
use App\Models\UserDomainBlock;
use Illuminate\Support\Facades\Redis;
class UserFilterService
{
const USER_MUTES_KEY = 'pf:services:mutes:ids:';
const USER_BLOCKS_KEY = 'pf:services:blocks:ids:';
const USER_DOMAIN_KEY = 'pf:services:domain-blocks:ids:';
public static function mutes(int $profile_id)
{
@ -145,4 +147,17 @@ class UserFilterService
{
return Redis::zcard(self::USER_MUTES_KEY . $profile_id);
}
public static function domainBlocks($pid, $purge = false)
{
if($purge) {
Cache::forget(self::USER_DOMAIN_KEY . $pid);
}
return Cache::remember(
self::USER_DOMAIN_KEY . $pid,
21600,
function() use($pid) {
return UserDomainBlock::whereProfileId($pid)->pluck('domain')->toArray();
});
}
}

View file

@ -33,4 +33,9 @@ class UserFilter extends Model
{
return $this->belongsTo(Instance::class, 'filterable_id');
}
public function user()
{
return $this->belongsTo(Profile::class, 'user_id');
}
}

View file

@ -39,6 +39,7 @@ use App\Util\ActivityPub\Validator\Like as LikeValidator;
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
use App\Util\ActivityPub\Validator\UpdatePersonValidator;
use App\Services\AccountService;
use App\Services\PollService;
use App\Services\FollowerService;
use App\Services\ReblogService;
@ -372,7 +373,11 @@ class Inbox
->whereUsername(array_last(explode('/', $activity['to'][0])))
->firstOrFail();
if(in_array($actor->id, $profile->blockedIds()->toArray())) {
if(!$actor || in_array($actor->id, $profile->blockedIds()->toArray())) {
return;
}
if(AccountService::blocksDomain($profile->id, $actor->domain) == true) {
return;
}
@ -510,6 +515,10 @@ class Inbox
return;
}
if(AccountService::blocksDomain($target->id, $actor->domain) == true) {
return;
}
if(
Follower::whereProfileId($actor->id)
->whereFollowingId($target->id)
@ -581,6 +590,10 @@ class Inbox
return;
}
if(AccountService::blocksDomain($parent->profile_id, $actor->domain) == true) {
return;
}
$blocks = UserFilterService::blocks($parent->profile_id);
if($blocks && in_array($actor->id, $blocks)) {
return;
@ -634,6 +647,10 @@ class Inbox
return;
}
if(AccountService::blocksDomain($target->id, $actor->domain) == true) {
return;
}
$request = FollowRequest::whereFollowerId($actor->id)
->whereFollowingId($target->id)
->whereIsRejected(false)
@ -759,6 +776,10 @@ class Inbox
return;
}
if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) {
return;
}
$blocks = UserFilterService::blocks($status->profile_id);
if($blocks && in_array($profile->id, $blocks)) {
return;
@ -816,6 +837,9 @@ class Inbox
if(!$status) {
return;
}
if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) {
return;
}
FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
Status::whereProfileId($profile->id)
->whereReblogOfId($status->id)
@ -837,6 +861,9 @@ class Inbox
if(!$following) {
return;
}
if(AccountService::blocksDomain($following->id, $profile->domain) == true) {
return;
}
Follower::whereProfileId($profile->id)
->whereFollowingId($following->id)
->delete();
@ -862,6 +889,9 @@ class Inbox
if(!$status) {
return;
}
if(AccountService::blocksDomain($status->profile_id, $profile->domain) == true) {
return;
}
Like::whereProfileId($profile->id)
->whereStatusId($status->id)
->forceDelete();
@ -915,6 +945,10 @@ class Inbox
return;
}
if(AccountService::blocksDomain($story->profile_id, $profile->domain) == true) {
return;
}
if(!FollowerService::follows($profile->id, $story->profile_id)) {
return;
}
@ -985,6 +1019,10 @@ class Inbox
$actorProfile = Helpers::profileFetch($actor);
if(AccountService::blocksDomain($targetProfile->id, $actorProfile->domain) == true) {
return;
}
if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
return;
}
@ -1103,6 +1141,11 @@ class Inbox
$actorProfile = Helpers::profileFetch($actor);
if(AccountService::blocksDomain($targetProfile->id, $actorProfile->domain) == true) {
return;
}
if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
return;
}

View file

@ -110,7 +110,8 @@ return [
'user_filters' => [
'max_user_blocks' => env('PF_MAX_USER_BLOCKS', 50),
'max_user_mutes' => env('PF_MAX_USER_MUTES', 50)
'max_user_mutes' => env('PF_MAX_USER_MUTES', 50),
'max_domain_blocks' => env('PF_MAX_DOMAIN_BLOCKS', 50),
],
'reports' => [

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_domain_blocks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('profile_id')->index();
$table->string('domain')->index();
$table->unique(['profile_id', 'domain'], 'user_domain_blocks_by_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_domain_blocks');
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_batches');
}
};

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\StatusHashtag;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
StatusHashtag::doesntHave('status')->lazyById(200)->each->deleteQuietly();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('default_domain_blocks', function (Blueprint $table) {
$table->id();
$table->string('domain')->unique()->index();
$table->text('note')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('default_domain_blocks');
}
};

View file

@ -12,4 +12,10 @@ return [
'status.disabled.header' => 'Profile Unavailable',
'status.disabled.body' => 'Sorry, this profile is not available at the moment. Please try again shortly.',
'block.domain.max' => 'Max limit of domain blocks reached! You can only block :max domains at a time. Ask your admin to adjust this limit.',
'mutedAccounts' => 'Muted Accounts',
'blockedAccounts' => 'Blocked Accounts',
'blockedDomains' => 'Blocked Domains',
];

View file

@ -8,8 +8,9 @@
<hr>
<div class="form-group pb-1">
<p>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
<a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">{{ __('profile.mutedAccounts') }}</a>
<a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">{{ __('profile.blockedAccounts') }}</a>
<a class="btn btn-link py-0 font-weight-bold" href="{{route('settings.privacy.domain-blocks')}}">{{ __('profile.blockedDomains') }}</a>
</p>
</div>
<form method="post">

View file

@ -2,40 +2,36 @@
@section('section')
<div class="title">
<h3 class="font-weight-bold">Blocked Users</h3>
<div class="d-flex justify-content-between align-items-center">
<div class="title d-flex align-items-center" style="gap: 1rem;">
<p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
<h3 class="font-weight-bold mb-0">Blocked Accounts</h3>
</div>
<hr>
<div class="form-group pb-1">
<p>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
<a class="btn btn-outline-primary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
{{-- <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-keywords')}}">Blocked keywords</a>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-instances')}}">Blocked instances</a> --}}
</p>
</div>
@if($users->count() > 0)
<ul class="list-group list-group-flush">
</div>
<hr />
@if($users->count() > 0)
<div class="list-group">
@foreach($users as $user)
<li class="list-group-item">
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center font-weight-bold">
<span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px">{{$user->username}}</a></span>
<span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">{{$user->username}}</a></span>
<span class="btn-group">
<form method="post">
@csrf
<input type="hidden" name="profile_id" value="{{$user->id}}">
<button type="submit" class="btn btn-outline-secondary btn-sm px-3 font-weight-bold">Unblock</button>
<button type="submit" class="btn btn-link btn-sm px-3 font-weight-bold">Unblock</button>
</form>
</span>
</div>
</li>
@endforeach
</ul>
<div class="d-flex justify-content-center mt-3 font-weight-bold">
{{$users->links()}}
</div>
@else
<p class="lead">You are not blocking any accounts.</p>
@endif
@endforeach
</div>
<div class="d-flex justify-content-center mt-3 font-weight-bold">
{{$users->links()}}
</div>
@else
<p class="lead text-center font-weight-bold">You are not blocking any accounts.</p>
@endif
@endsection

View file

@ -0,0 +1,272 @@
@extends('settings.template-vue')
@section('section')
<div>
<div class="d-flex justify-content-between align-items-center">
<div class="title d-flex align-items-center" style="gap: 1rem;">
<p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
<h3 class="font-weight-bold mb-0">Domain Blocks</h3>
</div>
</div>
<p class="mt-3 mb-n2 small">You can block entire domains, this prevents users on that instance from interacting with your content and from you seeing content from that domain on public feeds.</p>
<hr />
<div v-if="!loaded" class="d-flex justify-content-center align-items-center flex-grow-1">
<b-spinner />
</div>
<div v-else>
<div class="mb-3 d-flex flex-column flex-md-row justify-content-between align-items-center" style="gap: 2rem;">
<div style="width: 60%;">
<div class="input-group align-items-center">
<input class="form-control form-control-sm rounded-lg" v-model="q" placeholder="Search by domain..." style="padding-right: 60px;" :disabled="!blocks || !blocks.length">
<div style="margin-left: -60px;width: 60px;z-index:3">
<button class="btn btn-link" type="button" style="font-size: 12px;text-decoration: none;" v-html="q && q.length ? 'Clear': '&nbsp;'" @click="searchAction()"></button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm font-weight-bold px-3 flex-grow" @click="openModal">
<i class="fas fa-plus mr-1"></i> New Block
</button>
</div>
<div v-if="blocks && blocks.length" class="list-group">
<div
v-for="(item, idx) in chunks[index]"
class="list-group-item">
<div class="d-flex justify-content-between align-items-center font-weight-bold">
<span>
<span v-text="item"></span>
</span>
<span class="btn-group">
<button type="button" class="btn btn-link btn-sm px-3 font-weight-bold" @click="handleUnblock(item)">Unblock</button>
</span>
</div>
</div>
</div>
<nav v-if="blocks && blocks.length && chunks && chunks.length > 1" class="mt-3" aria-label="Domain block pagination">
<ul class="pagination justify-content-center" style="gap: 1rem">
<li
class="page-item"
:class="[ !index ? 'disabled' : 'font-weight-bold' ]"
:disabled="!index"
@click="paginate('prev')">
<span class="page-link px-5 rounded-lg">Previous</span>
</li>
<li
class="page-item"
:class="[ index + 1 === chunks.length ? 'disabled' : 'font-weight-bold' ]"
@click="paginate('next')">
<span class="page-link px-5 rounded-lg" href="#">Next</span>
</li>
</ul>
</nav>
<div v-if="!blocks || !blocks.length">
<hr />
<p class="lead text-center font-weight-bold">You are not blocking any domains.</p>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
let app = new Vue({
el: '#content',
data: {
loaded: false,
q: undefined,
blocks: [],
filteredBlocks: [],
chunks: [],
index: 0,
pagination: [],
},
watch: {
q: function(newVal, oldVal) {
this.filterResults(newVal)
}
},
mounted() {
this.fetchBlocks()
},
methods: {
fetchBlocks() {
axios.get('/api/v1/domain_blocks', { params: { 'limit': 200 }})
.then(res => {
let pages = false
if(res.headers?.link) {
pages = this.parseLinkHeader(res.headers['link'])
}
this.blocks = res.data
if(!pages || !pages.hasOwnProperty('next')) {
this.buildList()
} else {
this.handlePagination(pages)
}
})
.catch(err => {
console.log(err.response)
})
},
handlePagination(pages) {
if(!pages || !pages.hasOwnProperty('next')) {
this.buildList()
return
}
this.pagination = pages
this.fetchPagination()
},
buildList() {
this.index = 0
this.chunks = this.chunkify(this.blocks)
this.loaded = true
},
buildSearchList() {
this.index = 0
this.chunks = this.chunkify(this.filteredBlocks)
this.loaded = true
},
fetchPagination() {
axios.get(this.pagination.next)
.then(res => {
let pages = false
if(res.headers?.link) {
pages = this.parseLinkHeader(res.headers['link'])
}
this.blocks.push(...res.data)
if(!pages || !pages.hasOwnProperty('next')) {
this.buildList()
} else {
this.handlePagination(pages)
}
})
.catch(err => {
this.buildList()
})
},
handleUnblock(domain) {
this.loaded = false
axios.delete('/api/v1/domain_blocks', {
params: {
domain: domain
}
})
.then(res => {
this.blocks = this.blocks.filter(d => d != domain)
this.buildList()
})
.catch(err => {
this.buildList()
})
},
filterResults(query) {
this.loaded = false
let formattedQuery = query.trim().toLowerCase()
this.filteredBlocks = this.blocks.filter(domain => domain.toLowerCase().startsWith(formattedQuery))
this.buildSearchList()
},
searchAction($event) {
event.currentTarget.blur()
this.q = ''
},
openModal() {
swal({
title: 'Domain Block',
text: 'Add domain to block, must start with https://',
content: "input",
button: {
text: "Block",
closeModal: false,
}
}).then(val => {
if (!val) {
swal.stopLoading()
swal.close()
return
}
axios.post('/api/v1/domain_blocks', { domain: val })
.then(res => {
let parsedUrl = new URL(val)
swal.stopLoading()
swal.close()
this.index = 0
this.blocks.unshift(parsedUrl.hostname)
this.buildList()
})
.catch(err => {
swal.stopLoading()
swal.close()
if(err.response?.data?.message || err.response?.data?.error) {
swal('Error', err.response?.data?.message ?? err.response?.data?.error, 'error')
}
})
})
},
chunkify(arr, len = 10) {
var chunks = [],
i = 0,
n = arr.length
while (i < n) {
chunks.push(arr.slice(i, i += len))
}
return chunks
},
paginate(dir) {
if(dir === 'prev' && this.index > 0) {
this.index--
return
}
if(dir === 'next' && this.index + 1 < this.chunks.length) {
this.index++
return
}
},
parseLinkHeader(linkHeader) {
const links = {}
if (!linkHeader) {
return links
}
linkHeader.split(',').forEach(part => {
const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/)
if (match) {
const url = match[1]
const rel = match[2]
if (rel === 'prev' || rel === 'next') {
links[rel] = url
}
}
})
return links
}
}
})
</script>
@endpush

View file

@ -1,41 +1,35 @@
@extends('settings.template')
@section('section')
<div class="title">
<h3 class="font-weight-bold">Muted Users</h3>
<div class="d-flex justify-content-between align-items-center">
<div class="title d-flex align-items-center" style="gap: 1rem;">
<p class="mb-0"><a href="/settings/privacy"><i class="far fa-chevron-left fa-lg"></i></a></p>
<h3 class="font-weight-bold mb-0">Muted Accounts</h3>
</div>
<hr>
<div class="form-group pb-1">
<p>
<a class="btn btn-outline-primary py-0 font-weight-bold" href="{{route('settings.privacy.muted-users')}}">Muted Users</a>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-users')}}">Blocked Users</a>
{{-- <a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-keywords')}}">Blocked keywords</a>
<a class="btn btn-outline-secondary py-0 font-weight-bold" href="{{route('settings.privacy.blocked-instances')}}">Blocked instances</a> --}}
</p>
</div>
@if($users->count() > 0)
<ul class="list-group list-group-flush">
</div>
<hr />
@if($users->count() > 0)
<div class="list-group">
@foreach($users as $user)
<li class="list-group-item">
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center font-weight-bold">
<span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px">{{$user->username}}</a></span>
<span><a href="{{$user->url()}}" class="text-decoration-none text-dark"><img class="rounded-circle mr-3" src="{{$user->avatarUrl()}}" width="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">{{$user->username}}</a></span>
<span class="btn-group">
<form method="post">
@csrf
<input type="hidden" name="profile_id" value="{{$user->id}}">
<button type="submit" class="btn btn-outline-secondary btn-sm px-3 font-weight-bold">Unmute</button>
<button type="submit" class="btn btn-link btn-sm px-3 font-weight-bold">Unmute</button>
</form>
</span>
</div>
</li>
@endforeach
</ul>
<div class="d-flex justify-content-center mt-3 font-weight-bold">
{{$users->links()}}
</div>
@else
<p class="lead">You are not muting any accounts.</p>
@endif
@endforeach
</div>
<div class="d-flex justify-content-center mt-3 font-weight-bold">
{{$users->links()}}
</div>
@else
<p class="lead text-center font-weight-bold">You are not muting any accounts.</p>
@endif
@endsection

View file

@ -0,0 +1,37 @@
@extends('layouts.app')
@section('content')
@if (session('status'))
<div class="alert alert-primary px-3 h6 text-center">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger px-3 h6 text-center">
@foreach($errors->all() as $error)
<p class="font-weight-bold mb-1">{{ $error }}</p>
@endforeach
</div>
@endif
@if (session('error'))
<div class="alert alert-danger px-3 h6 text-center">
{{ session('error') }}
</div>
@endif
<div class="container">
<div class="col-12">
<div class="card shadow-none border mt-5">
<div class="card-body p-0">
<div class="row">
@include('settings.partial.sidebar')
<div class="col-12 col-md-9 p-5">
@yield('section')
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View file

@ -51,9 +51,9 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
Route::get('blocks', 'Api\ApiV1Controller@accountBlocks')->middleware($middleware);
Route::get('conversations', 'Api\ApiV1Controller@conversations')->middleware($middleware);
Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis');
Route::get('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware);
Route::post('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware);
Route::delete('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware($middleware);
Route::get('domain_blocks', 'Api\V1\DomainBlockController@index')->middleware($middleware);
Route::post('domain_blocks', 'Api\V1\DomainBlockController@store')->middleware($middleware);
Route::delete('domain_blocks', 'Api\V1\DomainBlockController@delete')->middleware($middleware);
Route::get('endorsements', 'Api\ApiV1Controller@accountEndorsements')->middleware($middleware);
Route::get('favourites', 'Api\ApiV1Controller@accountFavourites')->middleware($middleware);
Route::get('filters', 'Api\ApiV1Controller@accountFilters')->middleware($middleware);

View file

@ -489,6 +489,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::post('privacy/muted-users', 'SettingsController@mutedUsersUpdate');
Route::get('privacy/blocked-users', 'SettingsController@blockedUsers')->name('settings.privacy.blocked-users');
Route::post('privacy/blocked-users', 'SettingsController@blockedUsersUpdate');
Route::get('privacy/domain-blocks', 'SettingsController@domainBlocks')->name('settings.privacy.domain-blocks');
Route::get('privacy/blocked-instances', 'SettingsController@blockedInstances')->name('settings.privacy.blocked-instances');
Route::post('privacy/blocked-instances', 'SettingsController@blockedInstanceStore');
Route::post('privacy/blocked-instances/unblock', 'SettingsController@blockedInstanceUnblock')->name('settings.privacy.blocked-instances.unblock');