merge dev

This commit is contained in:
Christian Winther 2024-01-04 11:33:54 +00:00
commit 7b3e11012f
248 changed files with 17043 additions and 8376 deletions

View file

@ -1,8 +1,8 @@
root = true root = true
[*] [*]
indent_style = space
indent_size = 4 indent_size = 4
indent_style = tab
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true

View file

@ -1,6 +1,89 @@
# Release Notes # Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.9...dev) ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.9...dev)
### Added
- Resilient Media Storage ([#4665](https://github.com/pixelfed/pixelfed/pull/4665)) ([fb1deb6](https://github.com/pixelfed/pixelfed/commit/fb1deb6))
- Video WebP2P ([#4713](https://github.com/pixelfed/pixelfed/pull/4713)) ([0405ef12](https://github.com/pixelfed/pixelfed/commit/0405ef12))
- Added user:2fa command to easily disable 2FA for given account ([c6408fd7](https://github.com/pixelfed/pixelfed/commit/c6408fd7))
- Added `avatar:storage-deep-clean` command to dispatch remote avatar storage cleanup jobs ([c37b7cde](https://github.com/pixelfed/pixelfed/commit/c37b7cde))
- Added S3 command to rewrite media urls ([5b3a5610](https://github.com/pixelfed/pixelfed/commit/5b3a5610))
- Experimental home feed ([#4752](https://github.com/pixelfed/pixelfed/pull/4752)) ([c39b9afb](https://github.com/pixelfed/pixelfed/commit/c39b9afb))
- 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))
- Update AP Helpers, consume actor `indexable` attribute ([fbdcdd9d](https://github.com/pixelfed/pixelfed/commit/fbdcdd9d))
- ([](https://github.com/pixelfed/pixelfed/commit/))
### Updates
- Update FollowerService, add forget method to RelationshipService call to reduce load when mass purging ([347e4f59](https://github.com/pixelfed/pixelfed/commit/347e4f59))
- Update FollowServiceWarmCache, improve handling larger following/follower lists ([61a6d904](https://github.com/pixelfed/pixelfed/commit/61a6d904))
- Update StoryApiV1Controller, add viewers route to view story viewers ([941736ce](https://github.com/pixelfed/pixelfed/commit/941736ce))
- Update NotificationService, improve cache warming query ([2496386d](https://github.com/pixelfed/pixelfed/commit/2496386d))
- Update StatusService, hydrate accounts on request instead of caching them along with status objects ([223661ec](https://github.com/pixelfed/pixelfed/commit/223661ec))
- Update profile embed, fix resize ([dc23c21d](https://github.com/pixelfed/pixelfed/commit/dc23c21d))
- Update Status model, improve thumb logic ([d969a973](https://github.com/pixelfed/pixelfed/commit/d969a973))
- Update Status model, allow unlisted thumbnails ([1f0a45b7](https://github.com/pixelfed/pixelfed/commit/1f0a45b7))
- Update StatusTagsPipeline, fix object tags and slug normalization ([d295e605](https://github.com/pixelfed/pixelfed/commit/d295e605))
- Update Note and CreateNote transformers, include attachment blurhash, width and height ([ce1afe27](https://github.com/pixelfed/pixelfed/commit/ce1afe27))
- Update ap helpers, store media attachment width and height if present ([8c969191](https://github.com/pixelfed/pixelfed/commit/8c969191))
- Update Sign-in with Mastodon, allow usage when registrations are closed ([895dc4fa](https://github.com/pixelfed/pixelfed/commit/895dc4fa))
- Update profile embeds, filter sensitive posts ([ede5ec3b](https://github.com/pixelfed/pixelfed/commit/ede5ec3b))
- Update ApiV1Controller, hydrate reblog interactions. Fixes ([#4686](https://github.com/pixelfed/pixelfed/issues/4686)) ([135798eb](https://github.com/pixelfed/pixelfed/commit/135798eb))
- Update AdminReportController, add `profile_id` to group by. Fixes ([#4685](https://github.com/pixelfed/pixelfed/issues/4685)) ([e4d3b196](https://github.com/pixelfed/pixelfed/commit/e4d3b196))
- Update user:admin command, improve logic. Fixes ([#2465](https://github.com/pixelfed/pixelfed/issues/2465)) ([01bac511](https://github.com/pixelfed/pixelfed/commit/01bac511))
- Update AP helpers, adjust RemoteAvatarFetch ttl from 24h to 3 months ([36b23fe3](https://github.com/pixelfed/pixelfed/commit/36b23fe3))
- Update AvatarPipeline, improve refresh logic and garbage collection to purge old avatars ([82798b5e](https://github.com/pixelfed/pixelfed/commit/82798b5e))
- Update CreateAvatar job, add processing constraints and set `is_remote` attribute ([319ced40](https://github.com/pixelfed/pixelfed/commit/319ced40))
- Update RemoteStatusDelete and DecrementPostCount pipelines ([edbcf3ed](https://github.com/pixelfed/pixelfed/commit/edbcf3ed))
- Update lexer regex, fix mention regex and add more tests ([778e83d3](https://github.com/pixelfed/pixelfed/commit/778e83d3))
- Update StatusTransformer, generate autolink on request ([dfe2379b](https://github.com/pixelfed/pixelfed/commit/dfe2379b))
- Update ComposeModal component, fix multi filter bug and allow media re-ordering before upload/posting ([56e315f6](https://github.com/pixelfed/pixelfed/commit/56e315f6))
- Update ApiV1Dot1Controller, allow iar rate limits to be configurable ([28a80803](https://github.com/pixelfed/pixelfed/commit/28a80803))
- Update ApiV1Dot1Controller, add domain to iar redirect ([1f82d47c](https://github.com/pixelfed/pixelfed/commit/1f82d47c))
- Update ApiV1Dot1Controller, add configurable app confirm rate limit ttl ([4c6a0719](https://github.com/pixelfed/pixelfed/commit/4c6a0719))
- Update LikePipeline, dispatch to feed queue. Fixes ([#4723](https://github.com/pixelfed/pixelfed/issues/4723)) ([da510089](https://github.com/pixelfed/pixelfed/commit/da510089))
- Update AccountImport ([5a2d7e3e](https://github.com/pixelfed/pixelfed/commit/5a2d7e3e))
- Update ImportPostController, fix IG bug with missing spaces between hashtags ([9c24157a](https://github.com/pixelfed/pixelfed/commit/9c24157a))
- Update ApiV1Controller, fix mutes in home feed ([ddc21714](https://github.com/pixelfed/pixelfed/commit/ddc21714))
- Update AP helpers, improve preferredUsername validation ([21218c79](https://github.com/pixelfed/pixelfed/commit/21218c79))
- Update delete pipelines, properly invoke StatusHashtag delete events ([ce54d29c](https://github.com/pixelfed/pixelfed/commit/ce54d29c))
- Update mail config ([0e431271](https://github.com/pixelfed/pixelfed/commit/0e431271))
- Update hashtag following ([015b1b80](https://github.com/pixelfed/pixelfed/commit/015b1b80))
- Update IncrementPostCount job, prevent overlap ([b2c9cc23](https://github.com/pixelfed/pixelfed/commit/b2c9cc23))
- Update HashtagFollowService, fix cache invalidation bug ([84f4e885](https://github.com/pixelfed/pixelfed/commit/84f4e885))
- Update Experimental Home Feed, fix remote posts, shares and reblogs ([c6a6b3ae](https://github.com/pixelfed/pixelfed/commit/c6a6b3ae))
- Update HashtagService, improve count perf ([3327a008](https://github.com/pixelfed/pixelfed/commit/3327a008))
- Update StatusHashtagService, remove problematic cache layer ([e5401f85](https://github.com/pixelfed/pixelfed/commit/e5401f85))
- Update HomeFeedPipeline, fix tag filtering ([f105f4e8](https://github.com/pixelfed/pixelfed/commit/f105f4e8))
- Update HashtagService, reduce cached_count cache ttl ([15f29f7d](https://github.com/pixelfed/pixelfed/commit/15f29f7d))
- Update ApiV1Controller, fix include_reblogs param on timelines/home endpoint, and improve limit pagination logic ([287f903b](https://github.com/pixelfed/pixelfed/commit/287f903b))
- Update StoryApiV1Controller, add self-carousel endpoint. Fixes ([#4352](https://github.com/pixelfed/pixelfed/issues/4352)) ([bcb88d5b](https://github.com/pixelfed/pixelfed/commit/bcb88d5b))
- Update FollowServiceWarmCache, use more efficient query ([fe9b4c5a](https://github.com/pixelfed/pixelfed/commit/fe9b4c5a))
- Update HomeFeedPipeline, observe mutes/blocks during fanout ([8548294c](https://github.com/pixelfed/pixelfed/commit/8548294c))
- Update FederationController, add proper following/follower counts ([3204fb96](https://github.com/pixelfed/pixelfed/commit/3204fb96))
- Update FederationController, add proper statuses counts ([3204fb96](https://github.com/pixelfed/pixelfed/commit/3204fb96))
- Update Inbox handler, fix missing object_url and uri fields for direct statuses ([a0157fce](https://github.com/pixelfed/pixelfed/commit/a0157fce))
- Update DirectMessageController, deliver direct delete activities to user inbox instead of sharedInbox ([d848792a](https://github.com/pixelfed/pixelfed/commit/d848792a))
- Update DirectMessageController, dispatch deliver and delete actions to the job queue ([7f462a80](https://github.com/pixelfed/pixelfed/commit/7f462a80))
- Update Inbox, improve story attribute collection ([06bee36c](https://github.com/pixelfed/pixelfed/commit/06bee36c))
- Update DirectMessageController, dispatch local deletes to pipeline ([98186564](https://github.com/pixelfed/pixelfed/commit/98186564))
- Update StatusPipeline, fix Direct and Story notification deletion ([4c95306f](https://github.com/pixelfed/pixelfed/commit/4c95306f))
- Update Notifications.vue, fix deprecated DM action links for story activities ([4c3823b0](https://github.com/pixelfed/pixelfed/commit/4c3823b0))
- Update ComposeModal, fix missing alttext post state ([0a068119](https://github.com/pixelfed/pixelfed/commit/0a068119))
- Update PhotoAlbumPresenter.vue, fix fullscreen mode ([822e9888](https://github.com/pixelfed/pixelfed/commit/822e9888))
- Update Timeline.vue, improve CHT pagination ([9c43e7e2](https://github.com/pixelfed/pixelfed/commit/9c43e7e2))
- Update HomeFeedPipeline, fix StatusService validation ([041c0135](https://github.com/pixelfed/pixelfed/commit/041c0135))
- 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))
- Update AP ProfileTransformer, add published attribute ([adfaa2b1](https://github.com/pixelfed/pixelfed/commit/adfaa2b1))
- ([](https://github.com/pixelfed/pixelfed/commit/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9) ## [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,115 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Cache;
use Storage;
use App\Avatar;
use App\Jobs\AvatarPipeline\AvatarStorageCleanup;
class AvatarStorageDeepClean extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatar:storage-deep-clean';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Cleanup avatar storage';
protected $shouldKeepRunning = true;
protected $counter = 0;
/**
* Execute the console command.
*/
public function handle(): void
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Pixelfed Avatar Deep Cleaner');
$this->line(' ');
$this->info(' Purge/delete old and outdated avatars from remote accounts');
$this->line(' ');
$storage = [
'cloud' => boolval(config_cache('pixelfed.cloud_storage')),
'local' => boolval(config_cache('federation.avatars.store_local'))
];
if(!$storage['cloud'] && !$storage['local']) {
$this->error('Remote avatars are not cached locally, there is nothing to purge. Aborting...');
exit;
}
$start = 0;
if(!$this->confirm('Are you sure you want to proceed?')) {
$this->error('Aborting...');
exit;
}
if(!$this->activeCheck()) {
$this->info('Found existing deep cleaning job');
if(!$this->confirm('Do you want to continue where you left off?')) {
$this->error('Aborting...');
exit;
} else {
$start = Cache::has('cmd:asdp') ? (int) Cache::get('cmd:asdp') : (int) Storage::get('avatar-deep-clean.json');
if($start && $start < 1 || $start > PHP_INT_MAX) {
$this->error('Error fetching cached value');
$this->error('Aborting...');
exit;
}
}
}
$count = Avatar::whereNotNull('cdn_url')->where('is_remote', true)->where('id', '>', $start)->count();
$bar = $this->output->createProgressBar($count);
foreach(Avatar::whereNotNull('cdn_url')->where('is_remote', true)->where('id', '>', $start)->lazyById(10, 'id') as $avatar) {
usleep(random_int(50, 1000));
$this->counter++;
$this->handleAvatar($avatar);
$bar->advance();
}
$bar->finish();
}
protected function updateCache($id)
{
Cache::put('cmd:asdp', $id);
if($this->counter % 5 === 0) {
Storage::put('avatar-deep-clean.json', $id);
}
}
protected function activeCheck()
{
if(Storage::exists('avatar-deep-clean.json') || Cache::has('cmd:asdp')) {
return false;
}
return true;
}
protected function handleAvatar($avatar)
{
$this->updateCache($avatar->id);
$queues = ['feed', 'mmo', 'feed', 'mmo', 'feed', 'feed', 'mmo', 'low'];
$queue = $queues[random_int(0, 7)];
AvatarStorageCleanup::dispatch($avatar)->onQueue($queue);
}
}

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

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Hashtag;
use App\StatusHashtag;
use DB;
class HashtagCachedCountUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:hashtag-cached-count-update {--limit=100}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Update cached counter of hashtags';
/**
* Execute the console command.
*/
public function handle()
{
$limit = $this->option('limit');
$tags = Hashtag::whereNull('cached_count')->limit($limit)->get();
$count = count($tags);
if(!$count) {
return;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
foreach($tags as $tag) {
$count = DB::table('status_hashtags')->whereHashtagId($tag->id)->count();
if(!$count) {
$tag->cached_count = 0;
$tag->saveQuietly();
$bar->advance();
continue;
}
$tag->cached_count = $count;
$tag->saveQuietly();
$bar->advance();
}
$bar->finish();
$this->line(' ');
return;
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Hashtag;
use App\StatusHashtag;
use App\Models\HashtagRelated;
use App\Services\HashtagRelatedService;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\confirm;
class HashtagRelatedGenerate extends Command implements PromptsForMissingInput
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:hashtag-related-generate {tag}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Prompt for missing input arguments using the returned questions.
*
* @return array
*/
protected function promptForMissingArgumentsUsing()
{
return [
'tag' => 'Which hashtag should we generate related tags for?',
];
}
/**
* Execute the console command.
*/
public function handle()
{
$tag = $this->argument('tag');
$hashtag = Hashtag::whereName($tag)->orWhere('slug', $tag)->first();
if(!$hashtag) {
$this->error('Hashtag not found, aborting...');
exit;
}
$exists = HashtagRelated::whereHashtagId($hashtag->id)->exists();
if($exists) {
$confirmed = confirm('Found existing related tags, do you want to regenerate them?');
if(!$confirmed) {
$this->error('Aborting...');
exit;
}
}
$this->info('Looking up #' . $tag . '...');
$tags = StatusHashtag::whereHashtagId($hashtag->id)->count();
if(!$tags || $tags < 100) {
$this->error('Not enough posts found to generate related hashtags!');
exit;
}
$this->info('Found ' . $tags . ' posts that use that hashtag');
$related = collect(HashtagRelatedService::fetchRelatedTags($tag));
$selected = multiselect(
label: 'Which tags do you want to generate?',
options: $related->pluck('name'),
required: true,
);
$filtered = $related->filter(fn($i) => in_array($i['name'], $selected))->all();
$agg_score = $related->filter(fn($i) => in_array($i['name'], $selected))->sum('related_count');
HashtagRelated::updateOrCreate([
'hashtag_id' => $hashtag->id,
], [
'related_tags' => array_values($filtered),
'agg_score' => $agg_score,
'last_calculated_at' => now()
]);
$this->info('Finished!');
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use Cache, Storage;
use Illuminate\Contracts\Console\PromptsForMissingInput;
class MediaCloudUrlRewrite extends Command implements PromptsForMissingInput
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:cloud-url-rewrite {oldDomain} {newDomain}';
/**
* Prompt for missing input arguments using the returned questions.
*
* @return array
*/
protected function promptForMissingArgumentsUsing()
{
return [
'oldDomain' => 'The old S3 domain',
'newDomain' => 'The new S3 domain'
];
}
/**
* The console command description.
*
* @var string
*/
protected $description = 'Rewrite S3 media urls from local users';
/**
* Execute the console command.
*/
public function handle()
{
$this->preflightCheck();
$this->bootMessage();
$this->confirmCloudUrl();
}
protected function preflightCheck()
{
if(config_cache('pixelfed.cloud_storage') != true) {
$this->info('Error: Cloud storage is not enabled!');
$this->error('Aborting...');
exit;
}
}
protected function bootMessage()
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Media Cloud Url Rewrite Tool');
$this->info(' ===');
$this->info(' Old S3: ' . trim($this->argument('oldDomain')));
$this->info(' New S3: ' . trim($this->argument('newDomain')));
$this->info(' ');
}
protected function confirmCloudUrl()
{
$disk = Storage::disk(config('filesystems.cloud'))->url('test');
$domain = parse_url($disk, PHP_URL_HOST);
if(trim($this->argument('newDomain')) !== $domain) {
$this->error('Error: The new S3 domain you entered is not currently configured');
exit;
}
if(!$this->confirm('Confirm this is correct')) {
$this->error('Aborting...');
exit;
}
$this->updateUrls();
}
protected function updateUrls()
{
$this->info('Updating urls...');
$oldDomain = trim($this->argument('oldDomain'));
$newDomain = trim($this->argument('newDomain'));
$disk = Storage::disk(config('filesystems.cloud'));
$count = Media::whereNotNull('cdn_url')->count();
$bar = $this->output->createProgressBar($count);
$counter = 0;
$bar->start();
foreach(Media::whereNotNull('cdn_url')->lazyById(1000, 'id') as $media) {
if(strncmp($media->media_path, 'http', 4) === 0) {
$bar->advance();
continue;
}
$cdnHost = parse_url($media->cdn_url, PHP_URL_HOST);
if($oldDomain != $cdnHost || $newDomain == $cdnHost) {
$bar->advance();
continue;
}
$media->cdn_url = str_replace($oldDomain, $newDomain, $media->cdn_url);
if($media->thumbnail_url != null) {
$thumbHost = parse_url($media->thumbnail_url, PHP_URL_HOST);
if($thumbHost == $oldDomain) {
$thumbUrl = $disk->url($media->thumbnail_path);
$media->thumbnail_url = $thumbUrl;
}
}
if($media->optimized_url != null) {
$optiHost = parse_url($media->optimized_url, PHP_URL_HOST);
if($optiHost == $oldDomain) {
$optiUrl = str_replace($oldDomain, $newDomain, $media->optimized_url);
$media->optimized_url = $optiUrl;
}
}
$media->save();
$counter++;
$bar->advance();
}
$bar->finish();
$this->line(' ');
$this->info('Finished! Updated ' . $counter . ' total records!');
$this->line(' ');
$this->info('Tip: Run `php artisan cache:clear` to purge cached urls');
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\InternalPipeline\NotificationEpochUpdatePipeline;
class NotificationEpochUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:notification-epoch-update';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Update notification epoch';
/**
* Execute the console command.
*/
public function handle()
{
NotificationEpochUpdatePipeline::dispatch();
}
}

View file

@ -3,16 +3,17 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use App\User; use App\User;
class UserAdmin extends Command class UserAdmin extends Command implements PromptsForMissingInput
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
* @var string * @var string
*/ */
protected $signature = 'user:admin {id}'; protected $signature = 'user:admin {username}';
/** /**
* The console command description. * The console command description.
@ -22,13 +23,15 @@ class UserAdmin extends Command
protected $description = 'Make a user an admin, or remove admin privileges.'; protected $description = 'Make a user an admin, or remove admin privileges.';
/** /**
* Create a new command instance. * Prompt for missing input arguments using the returned questions.
* *
* @return void * @return array
*/ */
public function __construct() protected function promptForMissingArgumentsUsing()
{ {
parent::__construct(); return [
'username' => 'Which username should we toggle admin privileges for?',
];
} }
/** /**
@ -38,16 +41,15 @@ class UserAdmin extends Command
*/ */
public function handle() public function handle()
{ {
$id = $this->argument('id'); $id = $this->argument('username');
if(ctype_digit($id) == true) {
$user = User::find($id); $user = User::whereUsername($id)->first();
} else {
$user = User::whereUsername($id)->first();
}
if(!$user) { if(!$user) {
$this->error('Could not find any user with that username or id.'); $this->error('Could not find any user with that username or id.');
exit; exit;
} }
$this->info('Found username: ' . $user->username); $this->info('Found username: ' . $user->username);
$state = $user->is_admin ? 'Remove admin privileges from this user?' : 'Add admin privileges to this user?'; $state = $user->is_admin ? 'Remove admin privileges from this user?' : 'Add admin privileges to this user?';
$confirmed = $this->confirm($state); $confirmed = $this->confirm($state);

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use App\User;
class UserToggle2FA extends Command implements PromptsForMissingInput
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:2fa {username}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Disable two factor authentication for given username';
/**
* Prompt for missing input arguments using the returned questions.
*
* @return array
*/
protected function promptForMissingArgumentsUsing()
{
return [
'username' => 'Which username should we disable 2FA for?',
];
}
/**
* Execute the console command.
*/
public function handle()
{
$user = User::whereUsername($this->argument('username'))->first();
if(!$user) {
$this->error('Could not find any user with that username');
exit;
}
if(!$user->{'2fa_enabled'}) {
$this->info('User did not have 2FA enabled!');
return;
}
$user->{'2fa_enabled'} = false;
$user->{'2fa_secret'} = null;
$user->{'2fa_backup_codes'} = null;
$user->save();
$this->info('Successfully disabled 2FA on this account!');
}
}

View file

@ -43,6 +43,9 @@ class Kernel extends ConsoleKernel
$schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37)->onOneServer(); $schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37)->onOneServer();
$schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32)->onOneServer(); $schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32)->onOneServer();
} }
$schedule->command('app:notification-epoch-update')->weeklyOn(1, '2:21');
$schedule->command('app:hashtag-cached-count-update')->hourlyAt(25);
} }
/** /**

View file

@ -643,7 +643,7 @@ trait AdminReportController
$q->whereNull('admin_seen') : $q->whereNull('admin_seen') :
$q->whereNotNull('admin_seen'); $q->whereNotNull('admin_seen');
}) })
->groupBy(['object_id', 'object_type']) ->groupBy(['id', 'object_id', 'object_type', 'profile_id'])
->cursorPaginate(6) ->cursorPaginate(6)
->withQueryString() ->withQueryString()
); );

View file

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\AdminShadowFilter;
use App\Profile;
use App\Services\AccountService;
use App\Services\AdminShadowFilterService;
class AdminShadowFilterController extends Controller
{
public function __construct()
{
$this->middleware(['auth','admin']);
}
public function home(Request $request)
{
$filter = $request->input('filter');
$searchQuery = $request->input('q');
$filters = AdminShadowFilter::whereHas('profile')
->when($filter, function($q, $filter) {
if($filter == 'all') {
return $q;
} else if($filter == 'inactive') {
return $q->whereActive(false);
} else {
return $q;
}
}, function($q, $filter) {
return $q->whereActive(true);
})
->when($searchQuery, function($q, $searchQuery) {
$ids = Profile::where('username', 'like', '%' . $searchQuery . '%')
->limit(100)
->pluck('id')
->toArray();
return $q->where('item_type', 'App\Profile')->whereIn('item_id', $ids);
})
->latest()
->paginate(10)
->withQueryString();
return view('admin.asf.home', compact('filters'));
}
public function create(Request $request)
{
return view('admin.asf.create');
}
public function edit(Request $request, $id)
{
$filter = AdminShadowFilter::findOrFail($id);
$profile = AccountService::get($filter->item_id);
return view('admin.asf.edit', compact('filter', 'profile'));
}
public function store(Request $request)
{
$this->validate($request, [
'username' => 'required',
'active' => 'sometimes',
'note' => 'sometimes',
'hide_from_public_feeds' => 'sometimes'
]);
$profile = Profile::whereUsername($request->input('username'))->first();
if(!$profile) {
return back()->withErrors(['Invalid account']);
}
if($profile->user && $profile->user->is_admin) {
return back()->withErrors(['Cannot filter an admin account']);
}
$active = $request->has('active') && $request->has('hide_from_public_feeds');
AdminShadowFilter::updateOrCreate([
'item_id' => $profile->id,
'item_type' => get_class($profile)
], [
'is_local' => $profile->domain === null,
'note' => $request->input('note'),
'hide_from_public_feeds' => $request->has('hide_from_public_feeds'),
'admin_id' => $request->user()->profile_id,
'active' => $active
]);
AdminShadowFilterService::refresh();
return redirect('/i/admin/asf/home');
}
public function storeEdit(Request $request, $id)
{
$this->validate($request, [
'active' => 'sometimes',
'note' => 'sometimes',
'hide_from_public_feeds' => 'sometimes'
]);
$filter = AdminShadowFilter::findOrFail($id);
$profile = Profile::findOrFail($filter->item_id);
if($profile->user && $profile->user->is_admin) {
return back()->withErrors(['Cannot filter an admin account']);
}
$active = $request->has('active');
$filter->active = $active;
$filter->hide_from_public_feeds = $request->has('hide_from_public_feeds');
$filter->note = $request->input('note');
$filter->save();
AdminShadowFilterService::refresh();
return redirect('/i/admin/asf/home');
}
}

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\AccountLog; use App\AccountLog;
use App\EmailVerification; use App\EmailVerification;
use App\Follower;
use App\Place; use App\Place;
use App\Status; use App\Status;
use App\Report; use App\Report;
@ -19,8 +20,11 @@ use App\StatusArchived;
use App\User; use App\User;
use App\UserSetting; use App\UserSetting;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Services\ProfileStatusService; use App\Services\ProfileStatusService;
use App\Services\LikeService;
use App\Services\ReblogService;
use App\Services\PublicTimelineService; use App\Services\PublicTimelineService;
use App\Services\NetworkTimelineService; use App\Services\NetworkTimelineService;
use App\Util\Lexer\RestrictedNames; use App\Util\Lexer\RestrictedNames;
@ -470,7 +474,7 @@ class ApiV1Dot1Controller extends Controller
abort_if(BouncerService::checkIp($request->ip()), 404); abort_if(BouncerService::checkIp($request->ip()), 404);
} }
$rl = RateLimiter::attempt('pf:apiv1.1:iar:'.$request->ip(), 3, function(){}, 1800); $rl = RateLimiter::attempt('pf:apiv1.1:iar:'.$request->ip(), config('pixelfed.app_registration_rate_limit_attempts', 3), function(){}, config('pixelfed.app_registration_rate_limit_decay', 1800));
abort_if(!$rl, 400, 'Too many requests'); abort_if(!$rl, 400, 'Too many requests');
$this->validate($request, [ $this->validate($request, [
@ -543,10 +547,10 @@ class ApiV1Dot1Controller extends Controller
$user->password = Hash::make($password); $user->password = Hash::make($password);
$user->register_source = 'app'; $user->register_source = 'app';
$user->app_register_ip = $request->ip(); $user->app_register_ip = $request->ip();
$user->app_register_token = Str::random(32); $user->app_register_token = Str::random(40);
$user->save(); $user->save();
$rtoken = Str::random(mt_rand(64, 70)); $rtoken = Str::random(64);
$verify = new EmailVerification(); $verify = new EmailVerification();
$verify->user_id = $user->id; $verify->user_id = $user->id;
@ -555,7 +559,12 @@ class ApiV1Dot1Controller extends Controller
$verify->random_token = $rtoken; $verify->random_token = $rtoken;
$verify->save(); $verify->save();
$appUrl = url('/api/v1.1/auth/iarer?ut=' . $user->app_register_token . '&rt=' . $rtoken); $params = http_build_query([
'ut' => $user->app_register_token,
'rt' => $rtoken,
'ea' => base64_encode($user->email)
]);
$appUrl = url('/api/v1.1/auth/iarer?'. $params);
Mail::to($user->email)->send(new ConfirmAppEmail($verify, $appUrl)); Mail::to($user->email)->send(new ConfirmAppEmail($verify, $appUrl));
@ -568,14 +577,19 @@ class ApiV1Dot1Controller extends Controller
{ {
$this->validate($request, [ $this->validate($request, [
'ut' => 'required', 'ut' => 'required',
'rt' => 'required' 'rt' => 'required',
'ea' => 'required'
]); ]);
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$ut = $request->input('ut'); $ut = $request->input('ut');
$rt = $request->input('rt'); $rt = $request->input('rt');
$url = 'pixelfed://confirm-account/'. $ut . '?rt=' . $rt; $ea = $request->input('ea');
$params = http_build_query([
'ut' => $ut,
'rt' => $rt,
'domain' => config('pixelfed.domain.app'),
'ea' => $ea
]);
$url = 'pixelfed://confirm-account/'. $ut . '?' . $params;
return redirect()->away($url); return redirect()->away($url);
} }
@ -589,8 +603,8 @@ class ApiV1Dot1Controller extends Controller
abort_if(BouncerService::checkIp($request->ip()), 404); abort_if(BouncerService::checkIp($request->ip()), 404);
} }
$rl = RateLimiter::attempt('pf:apiv1.1:iarc:'.$request->ip(), 10, function(){}, 1800); $rl = RateLimiter::attempt('pf:apiv1.1:iarc:'.$request->ip(), config('pixelfed.app_registration_confirm_rate_limit_attempts', 20), function(){}, config('pixelfed.app_registration_confirm_rate_limit_decay', 1800));
abort_if(!$rl, 400, 'Too many requests'); abort_if(!$rl, 429, 'Too many requests');
$this->validate($request, [ $this->validate($request, [
'user_token' => 'required', 'user_token' => 'required',
@ -884,4 +898,19 @@ class ApiV1Dot1Controller extends Controller
return [200]; return [200];
} }
public function getMutualAccounts(Request $request, $id)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($id, true);
if(!$account || !isset($account['id'])) { return []; }
$res = collect(FollowerService::mutualAccounts($request->user()->profile_id, $id))
->map(function($accountId) {
return AccountService::get($accountId, true);
})
->filter()
->take(24)
->values();
return $this->json($res);
}
} }

View file

@ -34,6 +34,7 @@ use App\Transformer\Api\Mastodon\v1\{
use App\Transformer\Api\{ use App\Transformer\Api\{
RelationshipTransformer, RelationshipTransformer,
}; };
use App\Util\Site\Nodeinfo;
class ApiV2Controller extends Controller class ApiV2Controller extends Controller
{ {
@ -77,12 +78,7 @@ class ApiV2Controller extends Controller
'description' => config_cache('app.short_description'), 'description' => config_cache('app.short_description'),
'usage' => [ 'usage' => [
'users' => [ 'users' => [
'active_month' => (int) Cache::remember('api:nodeinfo:am', 172800, function() { 'active_month' => (int) Nodeinfo::activeUsersMonthly()
return User::select('last_active_at', 'created_at')
->where('last_active_at', '>', now()->subMonths(1))
->orWhere('created_at', '>', now()->subMonths(1))
->count();
})
] ]
], ],
'thumbnail' => [ 'thumbnail' => [

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

@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Hashtag;
use App\HashtagFollow;
use App\StatusHashtag;
use App\Services\AccountService;
use App\Services\HashtagService;
use App\Services\HashtagFollowService;
use App\Services\HashtagRelatedService;
use App\Http\Resources\MastoApi\FollowedTagResource;
use App\Jobs\HomeFeedPipeline\FeedWarmCachePipeline;
use App\Jobs\HomeFeedPipeline\HashtagUnfollowPipeline;
class TagsController extends Controller
{
const PF_API_ENTITY_KEY = "_pe";
public function json($res, $code = 200, $headers = [])
{
return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
}
/**
* GET /api/v1/tags/:id/related
*
*
* @return array
*/
public function relatedTags(Request $request, $tag)
{
abort_unless($request->user(), 403);
$tag = Hashtag::whereSlug($tag)->firstOrFail();
return HashtagRelatedService::get($tag->id);
}
/**
* POST /api/v1/tags/:id/follow
*
*
* @return object
*/
public function followHashtag(Request $request, $id)
{
abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$account = AccountService::get($pid);
$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
$tag = Hashtag::where('name', $operator, $id)
->orWhere('slug', $operator, $id)
->first();
abort_if(!$tag, 422, 'Unknown hashtag');
abort_if(
HashtagFollow::whereProfileId($pid)->count() >= HashtagFollow::MAX_LIMIT,
422,
'You cannot follow more than ' . HashtagFollow::MAX_LIMIT . ' hashtags.'
);
$follows = HashtagFollow::updateOrCreate(
[
'profile_id' => $account['id'],
'hashtag_id' => $tag->id
],
[
'user_id' => $request->user()->id
]
);
HashtagService::follow($pid, $tag->id);
HashtagFollowService::add($tag->id, $pid);
return response()->json(FollowedTagResource::make($follows)->toArray($request));
}
/**
* POST /api/v1/tags/:id/unfollow
*
*
* @return object
*/
public function unfollowHashtag(Request $request, $id)
{
abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$account = AccountService::get($pid);
$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
$tag = Hashtag::where('name', $operator, $id)
->orWhere('slug', $operator, $id)
->first();
abort_if(!$tag, 422, 'Unknown hashtag');
$follows = HashtagFollow::whereProfileId($pid)
->whereHashtagId($tag->id)
->first();
if(!$follows) {
return [
'name' => $tag->name,
'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
'history' => [],
'following' => false
];
}
if($follows) {
HashtagService::unfollow($pid, $tag->id);
HashtagFollowService::unfollow($tag->id, $pid);
HashtagUnfollowPipeline::dispatch($tag->id, $pid, $tag->slug)->onQueue('feed');
$follows->delete();
}
$res = FollowedTagResource::make($follows)->toArray($request);
$res['following'] = false;
return response()->json($res);
}
/**
* GET /api/v1/tags/:id
*
*
* @return object
*/
public function getHashtag(Request $request, $id)
{
abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$account = AccountService::get($pid);
$operator = config('database.default') == 'pgsql' ? 'ilike' : 'like';
$tag = Hashtag::where('name', $operator, $id)
->orWhere('slug', $operator, $id)
->first();
if(!$tag) {
return [
'name' => $id,
'url' => config('app.url') . '/i/web/hashtag/' . $id,
'history' => [],
'following' => false
];
}
$res = [
'name' => $tag->name,
'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug,
'history' => [],
'following' => HashtagService::isFollowing($pid, $tag->id)
];
if($request->has(self::PF_API_ENTITY_KEY)) {
$res['count'] = HashtagService::count($tag->id);
}
return $this->json($res);
}
/**
* GET /api/v1/followed_tags
*
*
* @return array
*/
public function getFollowedTags(Request $request)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($request->user()->profile_id);
$this->validate($request, [
'cursor' => 'sometimes',
'limit' => 'sometimes|integer|min:1|max:200'
]);
$limit = $request->input('limit', 100);
$res = HashtagFollow::whereProfileId($account['id'])
->orderByDesc('id')
->cursorPaginate($limit)
->withQueryString();
$pagination = false;
$prevPage = $res->nextPageUrl();
$nextPage = $res->previousPageUrl();
if($nextPage && $prevPage) {
$pagination = '<' . $nextPage . '>; rel="next", <' . $prevPage . '>; rel="prev"';
} else if($nextPage && !$prevPage) {
$pagination = '<' . $nextPage . '>; rel="next"';
} else if(!$nextPage && $prevPage) {
$pagination = '<' . $prevPage . '>; rel="prev"';
}
if($pagination) {
return response()->json(FollowedTagResource::collection($res)->collection)
->header('Link', $pagination);
}
return response()->json(FollowedTagResource::collection($res)->collection);
}
}

View file

@ -153,7 +153,7 @@ class CollectionController extends Controller
abort(400, 'You can only add '.$max.' posts per collection'); abort(400, 'You can only add '.$max.' posts per collection');
} }
$status = Status::whereScope('public') $status = Status::whereIn('scope', ['public', 'unlisted'])
->whereProfileId($profileId) ->whereProfileId($profileId)
->whereIn('type', ['photo', 'photo:album', 'video']) ->whereIn('type', ['photo', 'photo:album', 'video'])
->findOrFail($postId); ->findOrFail($postId);
@ -166,17 +166,13 @@ class CollectionController extends Controller
'order' => $count, 'order' => $count,
]); ]);
CollectionService::addItem( CollectionService::deleteCollection($collection->id);
$collection->id,
$status->id,
$count
);
$collection->updated_at = now(); $collection->updated_at = now();
$collection->save(); $collection->save();
CollectionService::setCollection($collection->id, $collection); CollectionService::setCollection($collection->id, $collection);
return StatusService::get($status->id); return StatusService::get($status->id, false);
} }
public function getCollection(Request $request, $id) public function getCollection(Request $request, $id)
@ -226,10 +222,10 @@ class CollectionController extends Controller
return collect($items) return collect($items)
->map(function($id) { ->map(function($id) {
return StatusService::get($id); return StatusService::get($id, false);
}) })
->filter(function($item) { ->filter(function($item) {
return $item && isset($item['account'], $item['media_attachments']); return $item && ($item['visibility'] == 'public' || $item['visibility'] == 'unlisted') && isset($item['account'], $item['media_attachments']);
}) })
->values(); ->values();
} }
@ -298,7 +294,7 @@ class CollectionController extends Controller
abort(400, 'You cannot delete the only post of a collection!'); abort(400, 'You cannot delete the only post of a collection!');
} }
$status = Status::whereScope('public') $status = Status::whereIn('scope', ['public', 'unlisted'])
->whereIn('type', ['photo', 'photo:album', 'video']) ->whereIn('type', ['photo', 'photo:album', 'video'])
->findOrFail($postId); ->findOrFail($postId);

View file

@ -415,7 +415,7 @@ class ComposeController extends Controller
$results = Profile::select('id','domain','username') $results = Profile::select('id','domain','username')
->whereNotIn('id', $blocked) ->whereNotIn('id', $blocked)
->where('username','like','%'.$q.'%') ->where('username','like','%'.$q.'%')
->groupBy('domain') ->groupBy('id', 'domain')
->limit(15) ->limit(15)
->get() ->get()
->map(function($profile) { ->map(function($profile) {

View file

@ -17,12 +17,15 @@ use App\{
use App\Services\MediaPathService; use App\Services\MediaPathService;
use App\Services\MediaBlocklistService; use App\Services\MediaBlocklistService;
use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\Jobs\StatusPipeline\StatusDelete;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Services\WebfingerService; use App\Services\WebfingerService;
use App\Models\Conversation; use App\Models\Conversation;
use App\Jobs\DirectPipeline\DirectDeletePipeline;
use App\Jobs\DirectPipeline\DirectDeliverPipeline;
class DirectMessageController extends Controller class DirectMessageController extends Controller
{ {
@ -500,6 +503,8 @@ class DirectMessageController extends Controller
if($recipient['local'] == false) { if($recipient['local'] == false) {
$dmc = $dm; $dmc = $dm;
$this->remoteDelete($dmc); $this->remoteDelete($dmc);
} else {
StatusDelete::dispatch($status)->onQueue('high');
} }
if(Conversation::whereStatusId($sid)->count()) { if(Conversation::whereStatusId($sid)->count()) {
@ -541,9 +546,7 @@ class DirectMessageController extends Controller
StatusService::del($status->id, true); StatusService::del($status->id, true);
$status->delete(); $status->forceDeleteQuietly();
$dm->delete();
return [200]; return [200];
} }
@ -829,7 +832,7 @@ class DirectMessageController extends Controller
] ]
]; ];
Helpers::sendSignedObject($profile, $url, $body); DirectDeliverPipeline::dispatch($profile, $url, $body)->onQueue('high');
} }
public function remoteDelete($dm) public function remoteDelete($dm)
@ -852,7 +855,6 @@ class DirectMessageController extends Controller
'type' => 'Tombstone' 'type' => 'Tombstone'
] ]
]; ];
DirectDeletePipeline::dispatch($profile, $url, $body)->onQueue('high');
Helpers::sendSignedObject($profile, $url, $body);
} }
} }

View file

@ -3,17 +3,17 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\InboxPipeline\{ use App\Jobs\InboxPipeline\{
DeleteWorker, DeleteWorker,
InboxWorker, InboxWorker,
InboxValidator InboxValidator
}; };
use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline; use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
use App\{ use App\{
AccountLog, AccountLog,
Like, Like,
Profile, Profile,
Status, Status,
User User
}; };
use App\Util\Lexer\Nickname; use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger; use App\Util\Webfinger\Webfinger;
@ -24,243 +24,251 @@ use Illuminate\Http\Request;
use League\Fractal; use League\Fractal;
use App\Util\Site\Nodeinfo; use App\Util\Site\Nodeinfo;
use App\Util\ActivityPub\{ use App\Util\ActivityPub\{
Helpers, Helpers,
HttpSignature, HttpSignature,
Outbox Outbox
}; };
use Zttp\Zttp; use Zttp\Zttp;
use App\Services\InstanceService; use App\Services\InstanceService;
use App\Services\AccountService;
class FederationController extends Controller class FederationController extends Controller
{ {
public function nodeinfoWellKnown() public function nodeinfoWellKnown()
{ {
abort_if(!config('federation.nodeinfo.enabled'), 404); abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES) return response()->json(Nodeinfo::wellKnown(), 200, [], JSON_UNESCAPED_SLASHES)
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function nodeinfo() public function nodeinfo()
{ {
abort_if(!config('federation.nodeinfo.enabled'), 404); abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES) return response()->json(Nodeinfo::get(), 200, [], JSON_UNESCAPED_SLASHES)
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function webfinger(Request $request) public function webfinger(Request $request)
{ {
if (!config('federation.webfinger.enabled') || if (!config('federation.webfinger.enabled') ||
!$request->has('resource') || !$request->has('resource') ||
!$request->filled('resource') !$request->filled('resource')
) { ) {
return response('', 400); return response('', 400);
} }
$resource = $request->input('resource'); $resource = $request->input('resource');
$domain = config('pixelfed.domain.app'); $domain = config('pixelfed.domain.app');
if(config('federation.activitypub.sharedInbox') && if(config('federation.activitypub.sharedInbox') &&
$resource == 'acct:' . $domain . '@' . $domain) { $resource == 'acct:' . $domain . '@' . $domain) {
$res = [ $res = [
'subject' => 'acct:' . $domain . '@' . $domain, 'subject' => 'acct:' . $domain . '@' . $domain,
'aliases' => [ 'aliases' => [
'https://' . $domain . '/i/actor' 'https://' . $domain . '/i/actor'
], ],
'links' => [ 'links' => [
[ [
'rel' => 'http://webfinger.net/rel/profile-page', 'rel' => 'http://webfinger.net/rel/profile-page',
'type' => 'text/html', 'type' => 'text/html',
'href' => 'https://' . $domain . '/site/kb/instance-actor' 'href' => 'https://' . $domain . '/site/kb/instance-actor'
], ],
[ [
'rel' => 'self', 'rel' => 'self',
'type' => 'application/activity+json', 'type' => 'application/activity+json',
'href' => 'https://' . $domain . '/i/actor' 'href' => 'https://' . $domain . '/i/actor'
] ]
] ]
]; ];
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
} }
$hash = hash('sha256', $resource); $hash = hash('sha256', $resource);
$key = 'federation:webfinger:sha256:' . $hash; $key = 'federation:webfinger:sha256:' . $hash;
if($cached = Cache::get($key)) { if($cached = Cache::get($key)) {
return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES); return response()->json($cached, 200, [], JSON_UNESCAPED_SLASHES);
} }
if(strpos($resource, $domain) == false) { if(strpos($resource, $domain) == false) {
return response('', 400); return response('', 400);
} }
$parsed = Nickname::normalizeProfileUrl($resource); $parsed = Nickname::normalizeProfileUrl($resource);
if(empty($parsed) || $parsed['domain'] !== $domain) { if(empty($parsed) || $parsed['domain'] !== $domain) {
return response('', 400); return response('', 400);
} }
$username = $parsed['username']; $username = $parsed['username'];
$profile = Profile::whereNull('domain')->whereUsername($username)->first(); $profile = Profile::whereNull('domain')->whereUsername($username)->first();
if(!$profile || $profile->status !== null) { if(!$profile || $profile->status !== null) {
return response('', 400); return response('', 400);
} }
$webfinger = (new Webfinger($profile))->generate(); $webfinger = (new Webfinger($profile))->generate();
Cache::put($key, $webfinger, 1209600); Cache::put($key, $webfinger, 1209600);
return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES) return response()->json($webfinger, 200, [], JSON_UNESCAPED_SLASHES)
->header('Access-Control-Allow-Origin','*'); ->header('Access-Control-Allow-Origin','*');
} }
public function hostMeta(Request $request) public function hostMeta(Request $request)
{ {
abort_if(!config('federation.webfinger.enabled'), 404); abort_if(!config('federation.webfinger.enabled'), 404);
$path = route('well-known.webfinger'); $path = route('well-known.webfinger');
$xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>'; $xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>';
return response($xml)->header('Content-Type', 'application/xrd+xml'); return response($xml)->header('Content-Type', 'application/xrd+xml');
} }
public function userOutbox(Request $request, $username) public function userOutbox(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config_cache('federation.activitypub.enabled'), 404);
if(!$request->wantsJson()) { if(!$request->wantsJson()) {
return redirect('/' . $username); return redirect('/' . $username);
} }
$res = [ $id = AccountService::usernameToId($username);
'@context' => 'https://www.w3.org/ns/activitystreams', abort_if(!$id, 404);
'id' => 'https://' . config('pixelfed.domain.app') . '/users/' . $username . '/outbox', $account = AccountService::get($id);
'type' => 'OrderedCollection', abort_if(!$account || !isset($account['statuses_count']), 404);
'totalItems' => 0, $res = [
'orderedItems' => [] '@context' => 'https://www.w3.org/ns/activitystreams',
]; 'id' => 'https://' . config('pixelfed.domain.app') . '/users/' . $username . '/outbox',
'type' => 'OrderedCollection',
'totalItems' => $account['statuses_count'] ?? 0,
];
return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json'); return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
} }
public function userInbox(Request $request, $username) public function userInbox(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config_cache('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.inbox'), 404); abort_if(!config('federation.activitypub.inbox'), 404);
$headers = $request->headers->all(); $headers = $request->headers->all();
$payload = $request->getContent(); $payload = $request->getContent();
if(!$payload || empty($payload)) { if(!$payload || empty($payload)) {
return; return;
} }
$obj = json_decode($payload, true, 8); $obj = json_decode($payload, true, 8);
if(!isset($obj['id'])) { if(!isset($obj['id'])) {
return; return;
} }
$domain = parse_url($obj['id'], PHP_URL_HOST); $domain = parse_url($obj['id'], PHP_URL_HOST);
if(in_array($domain, InstanceService::getBannedDomains())) { if(in_array($domain, InstanceService::getBannedDomains())) {
return; return;
} }
if(isset($obj['type']) && $obj['type'] === 'Delete') { if(isset($obj['type']) && $obj['type'] === 'Delete') {
if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) { if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
if($obj['object']['type'] === 'Person') { if($obj['object']['type'] === 'Person') {
if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) { if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox'); dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
return; return;
} }
} }
if($obj['object']['type'] === 'Tombstone') { if($obj['object']['type'] === 'Tombstone') {
if(Status::whereObjectUrl($obj['object']['id'])->exists()) { if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete'); dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return; return;
} }
} }
if($obj['object']['type'] === 'Story') { if($obj['object']['type'] === 'Story') {
dispatch(new DeleteWorker($headers, $payload))->onQueue('story'); dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
return; return;
} }
} }
return; return;
} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) { } else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('follow'); dispatch(new InboxValidator($username, $headers, $payload))->onQueue('follow');
} else { } else {
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high'); dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
} }
return; return;
} }
public function sharedInbox(Request $request) public function sharedInbox(Request $request)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config_cache('federation.activitypub.enabled'), 404);
abort_if(!config('federation.activitypub.sharedInbox'), 404); abort_if(!config('federation.activitypub.sharedInbox'), 404);
$headers = $request->headers->all(); $headers = $request->headers->all();
$payload = $request->getContent(); $payload = $request->getContent();
if(!$payload || empty($payload)) { if(!$payload || empty($payload)) {
return; return;
} }
$obj = json_decode($payload, true, 8); $obj = json_decode($payload, true, 8);
if(!isset($obj['id'])) { if(!isset($obj['id'])) {
return; return;
} }
$domain = parse_url($obj['id'], PHP_URL_HOST); $domain = parse_url($obj['id'], PHP_URL_HOST);
if(in_array($domain, InstanceService::getBannedDomains())) { if(in_array($domain, InstanceService::getBannedDomains())) {
return; return;
} }
if(isset($obj['type']) && $obj['type'] === 'Delete') { if(isset($obj['type']) && $obj['type'] === 'Delete') {
if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) { if(isset($obj['object']) && isset($obj['object']['type']) && isset($obj['object']['id'])) {
if($obj['object']['type'] === 'Person') { if($obj['object']['type'] === 'Person') {
if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) { if(Profile::whereRemoteUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox'); dispatch(new DeleteWorker($headers, $payload))->onQueue('inbox');
return; return;
} }
} }
if($obj['object']['type'] === 'Tombstone') { if($obj['object']['type'] === 'Tombstone') {
if(Status::whereObjectUrl($obj['object']['id'])->exists()) { if(Status::whereObjectUrl($obj['object']['id'])->exists()) {
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete'); dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
return; return;
} }
} }
if($obj['object']['type'] === 'Story') { if($obj['object']['type'] === 'Story') {
dispatch(new DeleteWorker($headers, $payload))->onQueue('story'); dispatch(new DeleteWorker($headers, $payload))->onQueue('story');
return; return;
} }
} }
return; return;
} else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) { } else if( isset($obj['type']) && in_array($obj['type'], ['Follow', 'Accept'])) {
dispatch(new InboxWorker($headers, $payload))->onQueue('follow'); dispatch(new InboxWorker($headers, $payload))->onQueue('follow');
} else { } else {
dispatch(new InboxWorker($headers, $payload))->onQueue('shared'); dispatch(new InboxWorker($headers, $payload))->onQueue('shared');
} }
return; return;
} }
public function userFollowing(Request $request, $username) public function userFollowing(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config_cache('federation.activitypub.enabled'), 404);
$obj = [ $id = AccountService::usernameToId($username);
'@context' => 'https://www.w3.org/ns/activitystreams', abort_if(!$id, 404);
'id' => $request->getUri(), $account = AccountService::get($id);
'type' => 'OrderedCollectionPage', abort_if(!$account || !isset($account['following_count']), 404);
'totalItems' => 0, $obj = [
'orderedItems' => [] '@context' => 'https://www.w3.org/ns/activitystreams',
]; 'id' => $request->getUri(),
return response()->json($obj); 'type' => 'OrderedCollection',
} 'totalItems' => $account['following_count'] ?? 0,
];
return response()->json($obj);
}
public function userFollowers(Request $request, $username) public function userFollowers(Request $request, $username)
{ {
abort_if(!config_cache('federation.activitypub.enabled'), 404); abort_if(!config_cache('federation.activitypub.enabled'), 404);
$id = AccountService::usernameToId($username);
$obj = [ abort_if(!$id, 404);
'@context' => 'https://www.w3.org/ns/activitystreams', $account = AccountService::get($id);
'id' => $request->getUri(), abort_if(!$account || !isset($account['followers_count']), 404);
'type' => 'OrderedCollectionPage', $obj = [
'totalItems' => 0, '@context' => 'https://www.w3.org/ns/activitystreams',
'orderedItems' => [] 'id' => $request->getUri(),
]; 'type' => 'OrderedCollection',
'totalItems' => $account['followers_count'] ?? 0,
return response()->json($obj); ];
} return response()->json($obj);
}
} }

View file

@ -83,6 +83,17 @@ class ImportPostController extends Controller
); );
} }
public function formatHashtags($val = false)
{
if(!$val || !strlen($val)) {
return null;
}
$groupedHashtagRegex = '/#\w+(?=#)/';
return preg_replace($groupedHashtagRegex, '$0 ', $val);
}
public function store(Request $request) public function store(Request $request)
{ {
abort_unless(config('import.instagram.enabled'), 404); abort_unless(config('import.instagram.enabled'), 404);
@ -128,11 +139,11 @@ class ImportPostController extends Controller
$ip->media = $c->map(function($m) { $ip->media = $c->map(function($m) {
return [ return [
'uri' => $m['uri'], 'uri' => $m['uri'],
'title' => $m['title'], 'title' => $this->formatHashtags($m['title']),
'creation_timestamp' => $m['creation_timestamp'] 'creation_timestamp' => $m['creation_timestamp']
]; ];
})->toArray(); })->toArray();
$ip->caption = $c->count() > 1 ? $file['title'] : $ip->media[0]['title']; $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
$ip->filename = last(explode('/', $ip->media[0]['uri'])); $ip->filename = last(explode('/', $ip->media[0]['uri']));
$ip->metadata = $c->map(function($m) { $ip->metadata = $c->map(function($m) {
return [ return [

View file

@ -25,8 +25,7 @@ class LikeController extends Controller
'item' => 'required|integer|min:1', 'item' => 'required|integer|min:1',
]); ]);
// API deprecated abort(422, 'Deprecated API Endpoint');
return;
$user = Auth::user(); $user = Auth::user();
$profile = $user->profile; $profile = $user->profile;
@ -34,7 +33,7 @@ class LikeController extends Controller
if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) { if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) {
$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail(); $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
UnlikePipeline::dispatch($like); UnlikePipeline::dispatch($like)->onQueue('feed');
} else { } else {
abort_if( abort_if(
Like::whereProfileId($user->profile_id) Like::whereProfileId($user->profile_id)
@ -60,7 +59,7 @@ class LikeController extends Controller
]) == false; ]) == false;
$like->save(); $like->save();
$status->save(); $status->save();
LikePipeline::dispatch($like); LikePipeline::dispatch($like)->onQueue('feed');
} }
} }

View file

@ -23,7 +23,13 @@ class RemoteAuthController extends Controller
{ {
public function start(Request $request) public function start(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
if($request->user()) { if($request->user()) {
return redirect('/'); return redirect('/');
} }
@ -37,7 +43,13 @@ class RemoteAuthController extends Controller
public function getAuthDomains(Request $request) public function getAuthDomains(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
if(config('remote-auth.mastodon.domains.only_custom')) { if(config('remote-auth.mastodon.domains.only_custom')) {
$res = config('remote-auth.mastodon.domains.custom'); $res = config('remote-auth.mastodon.domains.custom');
@ -69,7 +81,14 @@ class RemoteAuthController extends Controller
public function redirect(Request $request) public function redirect(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
$this->validate($request, ['domain' => 'required']); $this->validate($request, ['domain' => 'required']);
$domain = $request->input('domain'); $domain = $request->input('domain');
@ -158,6 +177,14 @@ class RemoteAuthController extends Controller
public function preflight(Request $request) public function preflight(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
if(!$request->filled('d') || !$request->filled('dsh') || !$request->session()->exists('oauth_redirect_to')) { if(!$request->filled('d') || !$request->filled('dsh') || !$request->session()->exists('oauth_redirect_to')) {
return redirect('/login'); return redirect('/login');
} }
@ -167,6 +194,14 @@ class RemoteAuthController extends Controller
public function handleCallback(Request $request) public function handleCallback(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
$domain = $request->session()->get('oauth_domain'); $domain = $request->session()->get('oauth_domain');
if($request->filled('code')) { if($request->filled('code')) {
@ -195,7 +230,13 @@ class RemoteAuthController extends Controller
public function onboarding(Request $request) public function onboarding(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
if($request->user()) { if($request->user()) {
return redirect('/'); return redirect('/');
} }
@ -204,6 +245,13 @@ class RemoteAuthController extends Controller
public function sessionCheck(Request $request) public function sessionCheck(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 403); abort_if($request->user(), 403);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -248,6 +296,13 @@ class RemoteAuthController extends Controller
public function sessionGetMastodonData(Request $request) public function sessionGetMastodonData(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 403); abort_if($request->user(), 403);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -279,6 +334,13 @@ class RemoteAuthController extends Controller
public function sessionValidateUsername(Request $request) public function sessionValidateUsername(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 403); abort_if($request->user(), 403);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -334,6 +396,13 @@ class RemoteAuthController extends Controller
public function sessionValidateEmail(Request $request) public function sessionValidateEmail(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 403); abort_if($request->user(), 403);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -359,6 +428,13 @@ class RemoteAuthController extends Controller
public function sessionGetMastodonFollowers(Request $request) public function sessionGetMastodonFollowers(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
abort_unless($request->session()->exists('oauth_remasto_id'), 403); abort_unless($request->session()->exists('oauth_remasto_id'), 403);
@ -386,6 +462,13 @@ class RemoteAuthController extends Controller
public function handleSubmit(Request $request) public function handleSubmit(Request $request)
{ {
abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
abort_unless($request->session()->exists('oauth_remasto_id'), 403); abort_unless($request->session()->exists('oauth_remasto_id'), 403);
@ -464,7 +547,13 @@ class RemoteAuthController extends Controller
public function storeBio(Request $request) public function storeBio(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_unless($request->user(), 404); abort_unless($request->user(), 404);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -483,7 +572,13 @@ class RemoteAuthController extends Controller
public function accountToId(Request $request) public function accountToId(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 404); abort_if($request->user(), 404);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);
@ -525,7 +620,13 @@ class RemoteAuthController extends Controller
public function storeAvatar(Request $request) public function storeAvatar(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_unless($request->user(), 404); abort_unless($request->user(), 404);
$this->validate($request, [ $this->validate($request, [
'avatar_url' => 'required|active_url', 'avatar_url' => 'required|active_url',
@ -547,7 +648,13 @@ class RemoteAuthController extends Controller
public function finishUp(Request $request) public function finishUp(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_unless($request->user(), 404); abort_unless($request->user(), 404);
$currentWebfinger = '@' . $request->user()->username . '@' . config('pixelfed.domain.app'); $currentWebfinger = '@' . $request->user()->username . '@' . config('pixelfed.domain.app');
@ -564,7 +671,13 @@ class RemoteAuthController extends Controller
public function handleLogin(Request $request) public function handleLogin(Request $request)
{ {
abort_unless(config_cache('pixelfed.open_registration') && config('remote-auth.mastodon.enabled'), 404); abort_unless((
config_cache('pixelfed.open_registration') &&
config('remote-auth.mastodon.enabled')
) || (
config('remote-auth.mastodon.ignore_closed_state') &&
config('remote-auth.mastodon.enabled')
), 404);
abort_if($request->user(), 404); abort_if($request->user(), 404);
abort_unless($request->session()->exists('oauth_domain'), 403); abort_unless($request->session()->exists('oauth_domain'), 403);
abort_unless($request->session()->exists('oauth_remote_session_token'), 403); abort_unless($request->session()->exists('oauth_remote_session_token'), 403);

View file

@ -14,19 +14,20 @@ use App\Util\Lexer\PrettyNumber;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use Auth, Cache, DB; use Auth, Cache, DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\UserDomainBlock;
trait PrivacySettings trait PrivacySettings
{ {
public function privacy() public function privacy()
{ {
$user = Auth::user(); $user = Auth::user();
$settings = $user->settings; $settings = $user->settings;
$profile = $user->profile; $profile = $user->profile;
$is_private = $profile->is_private; $is_private = $profile->is_private;
$settings['is_private'] = (bool) $is_private; $settings['is_private'] = (bool) $is_private;
return view('settings.privacy', compact('settings', 'profile')); return view('settings.privacy', compact('settings', 'profile'));
} }
public function privacyStore(Request $request) public function privacyStore(Request $request)
@ -39,11 +40,13 @@ trait PrivacySettings
'public_dm', 'public_dm',
'show_profile_follower_count', 'show_profile_follower_count',
'show_profile_following_count', 'show_profile_following_count',
'indexable',
'show_atom', 'show_atom',
]; ];
$profile->is_suggestable = $request->input('is_suggestable') == 'on'; $profile->indexable = $request->input('indexable') == 'on';
$profile->save(); $profile->is_suggestable = $request->input('is_suggestable') == 'on';
$profile->save();
foreach ($fields as $field) { foreach ($fields as $field) {
$form = $request->input($field); $form = $request->input($field);
@ -70,6 +73,8 @@ trait PrivacySettings
} else { } else {
$settings->{$field} = false; $settings->{$field} = false;
} }
} elseif ($field == 'indexable') {
} else { } else {
if ($form == 'on') { if ($form == 'on') {
$settings->{$field} = true; $settings->{$field} = true;
@ -145,47 +150,25 @@ trait PrivacySettings
public function blockedInstances() public function blockedInstances()
{ {
$pid = Auth::user()->profile->id; // deprecated
$filters = UserFilter::whereUserId($pid) abort(404);
->whereFilterableType('App\Instance') }
->whereFilterType('block')
->orderByDesc('id') public function domainBlocks()
->paginate(10); {
return view('settings.privacy.blocked-instances', compact('filters')); return view('settings.privacy.domain-blocks');
} }
public function blockedInstanceStore(Request $request) public function blockedInstanceStore(Request $request)
{ {
$this->validate($request, [ // deprecated
'domain' => 'required|url|min:1|max:120' abort(404);
]);
$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]);
} }
public function blockedInstanceUnblock(Request $request) public function blockedInstanceUnblock(Request $request)
{ {
$this->validate($request, [ // deprecated
'id' => 'required|integer|min:1' abort(404);
]);
$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'));
} }
public function blockedKeywords() public function blockedKeywords()

View file

@ -20,339 +20,486 @@ use App\Jobs\StoryPipeline\StoryViewDeliver;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\MediaPathService; use App\Services\MediaPathService;
use App\Services\StoryService; use App\Services\StoryService;
use App\Http\Resources\StoryView as StoryViewResource;
class StoryApiV1Controller extends Controller class StoryApiV1Controller extends Controller
{ {
public function carousel(Request $request) const RECENT_KEY = 'pf:stories:recent-by-id:';
{ const RECENT_TTL = 300;
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$pid = $request->user()->profile_id;
if(config('database.default') == 'pgsql') { public function carousel(Request $request)
$s = Story::select('stories.*', 'followers.following_id') {
->leftJoin('followers', 'followers.following_id', 'stories.profile_id') abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
->where('followers.profile_id', $pid) $pid = $request->user()->profile_id;
->where('stories.active', true)
->get();
} else {
$s = Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->orderBy('id')
->get();
}
$nodes = $s->map(function($s) use($pid) { if(config('database.default') == 'pgsql') {
$profile = AccountService::get($s->profile_id, true); $s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
if(!$profile || !isset($profile['id'])) { return Story::select('stories.*', 'followers.following_id')
return false; ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
} ->where('followers.profile_id', $pid)
->where('stories.active', true)
->map(function($s) {
$r = new \StdClass;
$r->id = $s->id;
$r->profile_id = $s->profile_id;
$r->type = $s->type;
$r->path = $s->path;
return $r;
})
->unique('profile_id');
});
} else {
$s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
return Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->orderBy('id')
->get();
});
}
return [ $nodes = $s->map(function($s) use($pid) {
'id' => (string) $s->id, $profile = AccountService::get($s->profile_id, true);
'pid' => (string) $s->profile_id, if(!$profile || !isset($profile['id'])) {
'type' => $s->type, return false;
'src' => url(Storage::url($s->path)), }
'duration' => $s->duration ?? 3,
'seen' => StoryService::hasSeen($pid, $s->id),
'created_at' => $s->created_at->format('c')
];
})
->filter()
->groupBy('pid')
->map(function($item) use($pid) {
$profile = AccountService::get($item[0]['pid'], true);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}");
return [
'id' => 'pfs:' . $profile['id'],
'user' => [
'id' => (string) $profile['id'],
'username' => $profile['username'],
'username_acct' => $profile['acct'],
'avatar' => $profile['avatar'],
'local' => $profile['local'],
'is_author' => $profile['id'] == $pid
],
'nodes' => $item,
'url' => $url,
'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
];
})
->sortBy('seen')
->values();
$res = [ return [
'self' => [], 'id' => (string) $s->id,
'nodes' => $nodes, 'pid' => (string) $s->profile_id,
]; 'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration ?? 3,
'seen' => StoryService::hasSeen($pid, $s->id),
'created_at' => $s->created_at->format('c')
];
})
->filter()
->groupBy('pid')
->map(function($item) use($pid) {
$profile = AccountService::get($item[0]['pid'], true);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}");
return [
'id' => 'pfs:' . $profile['id'],
'user' => [
'id' => (string) $profile['id'],
'username' => $profile['username'],
'username_acct' => $profile['acct'],
'avatar' => $profile['avatar'],
'local' => $profile['local'],
'is_author' => $profile['id'] == $pid
],
'nodes' => $item,
'url' => $url,
'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
];
})
->sortBy('seen')
->values();
if(Story::whereProfileId($pid)->whereActive(true)->exists()) { $res = [
$selfStories = Story::whereProfileId($pid) 'self' => [],
->whereActive(true) 'nodes' => $nodes,
->get() ];
->map(function($s) use($pid) {
return [
'id' => (string) $s->id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration,
'seen' => true,
'created_at' => $s->created_at->format('c')
];
})
->sortBy('id')
->values();
$selfProfile = AccountService::get($pid, true);
$res['self'] = [
'user' => [
'id' => (string) $selfProfile['id'],
'username' => $selfProfile['acct'],
'avatar' => $selfProfile['avatar'],
'local' => $selfProfile['local'],
'is_author' => true
],
'nodes' => $selfStories, if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
]; $selfStories = Story::whereProfileId($pid)
} ->whereActive(true)
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); ->get()
} ->map(function($s) use($pid) {
return [
'id' => (string) $s->id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration,
'seen' => true,
'created_at' => $s->created_at->format('c')
];
})
->sortBy('id')
->values();
$selfProfile = AccountService::get($pid, true);
$res['self'] = [
'user' => [
'id' => (string) $selfProfile['id'],
'username' => $selfProfile['acct'],
'avatar' => $selfProfile['avatar'],
'local' => $selfProfile['local'],
'is_author' => true
],
public function add(Request $request) 'nodes' => $selfStories,
{ ];
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); }
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
$this->validate($request, [ public function selfCarousel(Request $request)
'file' => function() { {
return [ abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
'required', $pid = $request->user()->profile_id;
'mimetypes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
'duration' => 'sometimes|integer|min:0|max:30'
]);
$user = $request->user(); if(config('database.default') == 'pgsql') {
$s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
return Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->map(function($s) {
$r = new \StdClass;
$r->id = $s->id;
$r->profile_id = $s->profile_id;
$r->type = $s->type;
$r->path = $s->path;
return $r;
})
->unique('profile_id');
});
} else {
$s = Cache::remember(self::RECENT_KEY . $pid, self::RECENT_TTL, function() use($pid) {
return Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->orderBy('id')
->get();
});
}
$count = Story::whereProfileId($user->profile_id) $nodes = $s->map(function($s) use($pid) {
->whereActive(true) $profile = AccountService::get($s->profile_id, true);
->where('expires_at', '>', now()) if(!$profile || !isset($profile['id'])) {
->count(); return false;
}
if($count >= Story::MAX_PER_DAY) { return [
abort(418, 'You have reached your limit for new Stories today.'); 'id' => (string) $s->id,
} 'pid' => (string) $s->profile_id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration ?? 3,
'seen' => StoryService::hasSeen($pid, $s->id),
'created_at' => $s->created_at->format('c')
];
})
->filter()
->groupBy('pid')
->map(function($item) use($pid) {
$profile = AccountService::get($item[0]['pid'], true);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}");
return [
'id' => 'pfs:' . $profile['id'],
'user' => [
'id' => (string) $profile['id'],
'username' => $profile['username'],
'username_acct' => $profile['acct'],
'avatar' => $profile['avatar'],
'local' => $profile['local'],
'is_author' => $profile['id'] == $pid
],
'nodes' => $item,
'url' => $url,
'seen' => StoryService::hasSeen($pid, StoryService::latest($profile['id'])),
];
})
->sortBy('seen')
->values();
$photo = $request->file('file'); $selfProfile = AccountService::get($pid, true);
$path = $this->storeMedia($photo, $user); $res = [
'self' => [
'user' => [
'id' => (string) $selfProfile['id'],
'username' => $selfProfile['acct'],
'avatar' => $selfProfile['avatar'],
'local' => $selfProfile['local'],
'is_author' => true
],
$story = new Story(); 'nodes' => [],
$story->duration = $request->input('duration', 3); ],
$story->profile_id = $user->profile_id; 'nodes' => $nodes,
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo'; ];
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->bearcap_token = str_random(64);
$story->expires_at = now()->addMinutes(1440);
$story->save();
$url = $story->path; if(Story::whereProfileId($pid)->whereActive(true)->exists()) {
$selfStories = Story::whereProfileId($pid)
->whereActive(true)
->get()
->map(function($s) use($pid) {
return [
'id' => (string) $s->id,
'type' => $s->type,
'src' => url(Storage::url($s->path)),
'duration' => $s->duration,
'seen' => true,
'created_at' => $s->created_at->format('c')
];
})
->sortBy('id')
->values();
$res['self']['nodes'] = $selfStories;
}
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
$res = [ public function add(Request $request)
'code' => 200, {
'msg' => 'Successfully added', abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
return $res; $this->validate($request, [
} 'file' => function() {
return [
'required',
'mimetypes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
'duration' => 'sometimes|integer|min:0|max:30'
]);
public function publish(Request $request) $user = $request->user();
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [ $count = Story::whereProfileId($user->profile_id)
'media_id' => 'required', ->whereActive(true)
'duration' => 'required|integer|min:0|max:30', ->where('expires_at', '>', now())
'can_reply' => 'required|boolean', ->count();
'can_react' => 'required|boolean'
]);
$id = $request->input('media_id'); if($count >= Story::MAX_PER_DAY) {
$user = $request->user(); abort(418, 'You have reached your limit for new Stories today.');
$story = Story::whereProfileId($user->profile_id) }
->findOrFail($id);
$story->active = true; $photo = $request->file('file');
$story->duration = $request->input('duration', 10); $path = $this->storeMedia($photo, $user);
$story->can_reply = $request->input('can_reply');
$story->can_react = $request->input('can_react');
$story->save();
StoryService::delLatest($story->profile_id); $story = new Story();
StoryFanout::dispatch($story)->onQueue('story'); $story->duration = $request->input('duration', 3);
StoryService::addRotateQueue($story->id); $story->profile_id = $user->profile_id;
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->bearcap_token = str_random(64);
$story->expires_at = now()->addMinutes(1440);
$story->save();
return [ $url = $story->path;
'code' => 200,
'msg' => 'Successfully published',
];
}
public function delete(Request $request, $id) $res = [
{ 'code' => 200,
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); 'msg' => 'Successfully added',
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
$user = $request->user(); return $res;
}
$story = Story::whereProfileId($user->profile_id) public function publish(Request $request)
->findOrFail($id); {
$story->active = false; abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$story->save();
StoryDelete::dispatch($story)->onQueue('story'); $this->validate($request, [
'media_id' => 'required',
'duration' => 'required|integer|min:0|max:30',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
return [ $id = $request->input('media_id');
'code' => 200, $user = $request->user();
'msg' => 'Successfully deleted' $story = Story::whereProfileId($user->profile_id)
]; ->findOrFail($id);
}
public function viewed(Request $request) $story->active = true;
{ $story->duration = $request->input('duration', 10);
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); $story->can_reply = $request->input('can_reply');
$story->can_react = $request->input('can_react');
$story->save();
$this->validate($request, [ StoryService::delLatest($story->profile_id);
'id' => 'required|min:1', StoryFanout::dispatch($story)->onQueue('story');
]); StoryService::addRotateQueue($story->id);
$id = $request->input('id');
$authed = $request->user()->profile; return [
'code' => 200,
'msg' => 'Successfully published',
];
}
$story = Story::with('profile') public function delete(Request $request, $id)
->findOrFail($id); {
$exp = $story->expires_at; abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = $story->profile; $user = $request->user();
if($story->profile_id == $authed->id) { $story = Story::whereProfileId($user->profile_id)
return []; ->findOrFail($id);
} $story->active = false;
$story->save();
$publicOnly = (bool) $profile->followedBy($authed); StoryDelete::dispatch($story)->onQueue('story');
abort_if(!$publicOnly, 403);
$v = StoryView::firstOrCreate([ return [
'story_id' => $id, 'code' => 200,
'profile_id' => $authed->id 'msg' => 'Successfully deleted'
]); ];
}
if($v->wasRecentlyCreated) { public function viewed(Request $request)
Story::findOrFail($story->id)->increment('view_count'); {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
if($story->local == false) { $this->validate($request, [
StoryViewDeliver::dispatch($story, $authed)->onQueue('story'); 'id' => 'required|min:1',
} ]);
} $id = $request->input('id');
Cache::forget('stories:recent:by_id:' . $authed->id); $authed = $request->user()->profile;
StoryService::addSeen($authed->id, $story->id);
return ['code' => 200];
}
public function comment(Request $request) $story = Story::with('profile')
{ ->findOrFail($id);
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); $exp = $story->expires_at;
$this->validate($request, [
'sid' => 'required',
'caption' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('caption');
$story = Story::findOrFail($request->input('sid')); $profile = $story->profile;
abort_if(!$story->can_reply, 422); if($story->profile_id == $authed->id) {
return [];
}
$status = new Status; $publicOnly = (bool) $profile->followedBy($authed);
$status->type = 'story:reply'; abort_if(!$publicOnly, 403);
$status->profile_id = $pid;
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id
]);
$status->save();
$dm = new DirectMessage; $v = StoryView::firstOrCreate([
$dm->to_id = $story->profile_id; 'story_id' => $id,
$dm->from_id = $pid; 'profile_id' => $authed->id
$dm->type = 'story:comment'; ]);
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
Conversation::updateOrInsert( if($v->wasRecentlyCreated) {
[ Story::findOrFail($story->id)->increment('view_count');
'to_id' => $story->profile_id,
'from_id' => $pid
],
[
'type' => 'story:comment',
'status_id' => $status->id,
'dm_id' => $dm->id,
'is_hidden' => false
]
);
if($story->local) { if($story->local == false) {
$n = new Notification; StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
$n->profile_id = $dm->to_id; }
$n->actor_id = $dm->from_id; }
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
}
return [ Cache::forget('stories:recent:by_id:' . $authed->id);
'code' => 200, StoryService::addSeen($authed->id, $story->id);
'msg' => 'Sent!' return ['code' => 200];
]; }
}
protected function storeMedia($photo, $user) public function comment(Request $request)
{ {
$mimes = explode(',', config_cache('pixelfed.media_types')); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
if(in_array($photo->getMimeType(), [ $this->validate($request, [
'image/jpeg', 'sid' => 'required',
'image/png', 'caption' => 'required|string'
'video/mp4' ]);
]) == false) { $pid = $request->user()->profile_id;
abort(400, 'Invalid media type'); $text = $request->input('caption');
return;
}
$storagePath = MediaPathService::story($user->profile); $story = Story::findOrFail($request->input('sid'));
$path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
return $path; abort_if(!$story->can_reply, 422);
}
$status = new Status;
$status->type = 'story:reply';
$status->profile_id = $pid;
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:comment';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
Conversation::updateOrInsert(
[
'to_id' => $story->profile_id,
'from_id' => $pid
],
[
'type' => 'story:comment',
'status_id' => $status->id,
'dm_id' => $dm->id,
'is_hidden' => false
]
);
if($story->local) {
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
}
return [
'code' => 200,
'msg' => 'Sent!'
];
}
protected function storeMedia($photo, $user)
{
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->storePubliclyAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
return $path;
}
public function viewers(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required|string|min:1|max:50'
]);
$pid = $request->user()->profile_id;
$sid = $request->input('sid');
$story = Story::whereProfileId($pid)
->whereActive(true)
->findOrFail($sid);
$viewers = StoryView::whereStoryId($story->id)
->orderByDesc('id')
->cursorPaginate(10);
return StoryViewResource::collection($viewers);
}
} }

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Services\AccountService;
class StoryView extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request)
{
return AccountService::get($this->profile_id, true);
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Jobs\AvatarPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\AvatarService;
use App\Avatar;
class AvatarStorageCleanup implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $avatar;
public $tries = 3;
public $maxExceptions = 3;
public $timeout = 900;
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 'avatar:storage:cleanup:' . $this->avatar->profile_id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("avatar-storage-cleanup:{$this->avatar->profile_id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct(Avatar $avatar)
{
$this->avatar = $avatar->withoutRelations();
}
/**
* Execute the job.
*/
public function handle(): void
{
AvatarService::cleanup($this->avatar, true);
return;
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Jobs\AvatarPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\AvatarService;
use App\Avatar;
use Illuminate\Support\Str;
class AvatarStorageLargePurge implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $avatar;
public $tries = 3;
public $maxExceptions = 3;
public $timeout = 900;
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 'avatar:storage:lg-purge:' . $this->avatar->profile_id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("avatar-storage-purge:{$this->avatar->profile_id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct(Avatar $avatar)
{
$this->avatar = $avatar->withoutRelations();
}
/**
* Execute the job.
*/
public function handle(): void
{
$avatar = $this->avatar;
$disk = AvatarService::disk();
$files = collect(AvatarService::storage($avatar));
$curFile = Str::of($avatar->cdn_url)->explode('/')->last();
$files = $files->filter(function($f) use($curFile) {
return !$curFile || !str_ends_with($f, $curFile);
})->each(function($name) use($disk) {
$disk->delete($name);
});
return;
}
}

View file

@ -2,19 +2,25 @@
namespace App\Jobs\AvatarPipeline; namespace App\Jobs\AvatarPipeline;
use App\Avatar;
use App\Profile;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Avatar;
use App\Profile;
class CreateAvatar implements ShouldQueue class CreateAvatar implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile; public $profile;
public $tries = 3;
public $maxExceptions = 3;
public $timeout = 900;
public $failOnTimeout = true;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
@ -22,6 +28,31 @@ class CreateAvatar implements ShouldQueue
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = 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 'avatar:create:' . $this->profile->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("avatar-create:{$this->profile->id}"))->shared()->dontRelease()];
}
/** /**
* Create a new job instance. * Create a new job instance.
@ -30,7 +61,7 @@ class CreateAvatar implements ShouldQueue
*/ */
public function __construct(Profile $profile) public function __construct(Profile $profile)
{ {
$this->profile = $profile; $this->profile = $profile->withoutRelations();
} }
/** /**
@ -41,12 +72,18 @@ class CreateAvatar implements ShouldQueue
public function handle() public function handle()
{ {
$profile = $this->profile; $profile = $this->profile;
$isRemote = (bool) $profile->private_key == null;
$path = 'public/avatars/default.jpg'; $path = 'public/avatars/default.jpg';
$avatar = new Avatar(); Avatar::updateOrCreate(
$avatar->profile_id = $profile->id; [
$avatar->media_path = $path; 'profile_id' => $profile->id,
$avatar->change_count = 0; ],
$avatar->last_processed_at = \Carbon\Carbon::now(); [
$avatar->save(); 'media_path' => $path,
'change_count' => 0,
'is_remote' => $isRemote,
'last_processed_at' => now()
]
);
} }
} }

View file

@ -108,7 +108,7 @@ class RemoteAvatarFetch implements ShouldQueue
$avatar->remote_url = $icon['url']; $avatar->remote_url = $icon['url'];
$avatar->save(); $avatar->save();
MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false); MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
return 1; return 1;
} }

View file

@ -89,7 +89,6 @@ class RemoteAvatarFetchFromUrl implements ShouldQueue
$avatar->save(); $avatar->save();
} }
MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true); MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
return 1; return 1;

View file

@ -57,7 +57,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue
$status = $this->status; $status = $this->status;
if(AccountService::get($status->profile_id, true)) { if(AccountService::get($status->profile_id, true)) {
DecrementPostCount::dispatch($status->profile_id)->onQueue('feed'); DecrementPostCount::dispatch($status->profile_id)->onQueue('low');
} }
NetworkTimelineService::del($status->id); NetworkTimelineService::del($status->id);

View file

@ -0,0 +1,42 @@
<?php
namespace App\Jobs\DirectPipeline;
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 App\Util\ActivityPub\Helpers;
class DirectDeletePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
protected $profile;
protected $url;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct($profile, $url, $payload)
{
$this->profile = $profile;
$this->url = $url;
$this->payload = $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
Helpers::sendSignedObject($this->profile, $this->url, $this->payload);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Jobs\DirectPipeline;
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 App\Util\ActivityPub\Helpers;
class DirectDeliverPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
protected $profile;
protected $url;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct($profile, $url, $payload)
{
$this->profile = $profile;
$this->url = $url;
$this->payload = $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
Helpers::sendSignedObject($this->profile, $this->url, $this->payload);
}
}

View file

@ -8,10 +8,13 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\FollowerService; use App\Services\FollowerService;
use Cache; use Cache;
use DB; use DB;
use Storage;
use App\Follower;
use App\Profile; use App\Profile;
class FollowServiceWarmCache implements ShouldQueue class FollowServiceWarmCache implements ShouldQueue
@ -23,6 +26,16 @@ class FollowServiceWarmCache implements ShouldQueue
public $timeout = 5000; public $timeout = 5000;
public $failOnTimeout = false; public $failOnTimeout = false;
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping($this->profileId))->dontRelease()];
}
/** /**
* Create a new job instance. * Create a new job instance.
* *
@ -42,6 +55,10 @@ class FollowServiceWarmCache implements ShouldQueue
{ {
$id = $this->profileId; $id = $this->profileId;
if(Cache::has(FollowerService::FOLLOWERS_SYNC_KEY . $id) && Cache::has(FollowerService::FOLLOWING_SYNC_KEY . $id)) {
return;
}
$account = AccountService::get($id, true); $account = AccountService::get($id, true);
if(!$account) { if(!$account) {
@ -50,25 +67,43 @@ class FollowServiceWarmCache implements ShouldQueue
return; return;
} }
DB::table('followers') $hasFollowerPostProcessing = false;
->select('id', 'following_id', 'profile_id') $hasFollowingPostProcessing = false;
->whereFollowingId($id)
->orderBy('id')
->chunk(200, function($followers) use($id) {
foreach($followers as $follow) {
FollowerService::add($follow->profile_id, $id);
}
});
DB::table('followers') if(Follower::whereProfileId($id)->orWhere('following_id', $id)->count()) {
->select('id', 'following_id', 'profile_id') $following = [];
->whereProfileId($id) $followers = [];
->orderBy('id') foreach(Follower::where('following_id', $id)->orWhere('profile_id', $id)->lazyById(500) as $follow) {
->chunk(200, function($followers) use($id) { if($follow->following_id != $id && $follow->profile_id != $id) {
foreach($followers as $follow) { continue;
FollowerService::add($id, $follow->following_id); }
} if($follow->profile_id == $id) {
}); $following[] = $follow->following_id;
} else {
$followers[] = $follow->profile_id;
}
}
if(count($followers) > 100) {
// store follower ids and process in another job
Storage::put('follow-warm-cache/' . $id . '/followers.json', json_encode($followers));
$hasFollowerPostProcessing = true;
} else {
foreach($followers as $follower) {
FollowerService::add($follower, $id);
}
}
if(count($following) > 100) {
// store following ids and process in another job
Storage::put('follow-warm-cache/' . $id . '/following.json', json_encode($following));
$hasFollowingPostProcessing = true;
} else {
foreach($following as $following) {
FollowerService::add($id, $following);
}
}
}
Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $id, 1, 604800); Cache::put(FollowerService::FOLLOWERS_SYNC_KEY . $id, 1, 604800);
Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $id, 1, 604800); Cache::put(FollowerService::FOLLOWING_SYNC_KEY . $id, 1, 604800);
@ -82,6 +117,14 @@ class FollowServiceWarmCache implements ShouldQueue
AccountService::del($id); AccountService::del($id);
if($hasFollowingPostProcessing) {
FollowServiceWarmCacheLargeIngestPipeline::dispatch($id, 'following')->onQueue('follow');
}
if($hasFollowerPostProcessing) {
FollowServiceWarmCacheLargeIngestPipeline::dispatch($id, 'followers')->onQueue('follow');
}
return; return;
} }
} }

View file

@ -0,0 +1,88 @@
<?php
namespace App\Jobs\FollowPipeline;
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 App\Services\AccountService;
use App\Services\FollowerService;
use Cache;
use DB;
use Storage;
use App\Follower;
use App\Profile;
class FollowServiceWarmCacheLargeIngestPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $profileId;
public $followType;
public $tries = 5;
public $timeout = 5000;
public $failOnTimeout = false;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($profileId, $followType = 'following')
{
$this->profileId = $profileId;
$this->followType = $followType;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$pid = $this->profileId;
$type = $this->followType;
if($type === 'followers') {
$key = 'follow-warm-cache/' . $pid . '/followers.json';
if(!Storage::exists($key)) {
return;
}
$file = Storage::get($key);
$json = json_decode($file, true);
foreach($json as $id) {
FollowerService::add($id, $pid, false);
usleep(random_int(500, 3000));
}
sleep(5);
Storage::delete($key);
}
if($type === 'following') {
$key = 'follow-warm-cache/' . $pid . '/following.json';
if(!Storage::exists($key)) {
return;
}
$file = Storage::get($key);
$json = json_decode($file, true);
foreach($json as $id) {
FollowerService::add($pid, $id, false);
usleep(random_int(500, 3000));
}
sleep(5);
Storage::delete($key);
}
sleep(random_int(2, 5));
$files = Storage::files('follow-warm-cache/' . $pid);
if(empty($files)) {
Storage::deleteDirectory('follow-warm-cache/' . $pid);
}
}
}

View file

@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Redis;
use App\Services\AccountService; use App\Services\AccountService;
use App\Services\FollowerService; use App\Services\FollowerService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
class UnfollowPipeline implements ShouldQueue class UnfollowPipeline implements ShouldQueue
{ {
@ -55,6 +56,8 @@ class UnfollowPipeline implements ShouldQueue
return; return;
} }
FeedUnfollowPipeline::dispatch($actor, $target)->onQueue('follow');
FollowerService::remove($actor, $target); FollowerService::remove($actor, $target);
$actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY . $actor); $actorProfileSync = Cache::get(FollowerService::FOLLOWING_SYNC_KEY . $actor);

View file

@ -0,0 +1,87 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\AccountService;
use App\Services\HomeTimelineService;
use App\Services\SnowflakeService;
use App\Status;
class FeedFollowPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $actorId;
protected $followingId;
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:insert:follows:aid:' . $this->actorId . ':fid:' . $this->followingId;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:insert:follows:aid:{$this->actorId}:fid:{$this->followingId}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($actorId, $followingId)
{
$this->actorId = $actorId;
$this->followingId = $followingId;
}
/**
* Execute the job.
*/
public function handle(): void
{
$actorId = $this->actorId;
$followingId = $this->followingId;
$minId = SnowflakeService::byDate(now()->subWeeks(6));
$ids = Status::where('id', '>', $minId)
->where('profile_id', $followingId)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderByDesc('id')
->limit(HomeTimelineService::FOLLOWER_FEED_POST_LIMIT)
->pluck('id');
foreach($ids as $id) {
HomeTimelineService::add($actorId, $id);
}
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\FollowerService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
class FeedInsertPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $sid;
protected $pid;
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:insert:sid:' . $this->sid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:insert:sid:{$this->sid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($sid, $pid)
{
$this->sid = $sid;
$this->pid = $pid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$sid = $this->sid;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
if(!in_array($status['pf_type'], ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) {
return;
}
HomeTimelineService::add($this->pid, $this->sid);
$ids = FollowerService::localFollowerIds($this->pid);
if(!$ids || !count($ids)) {
return;
}
$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)) {
HomeTimelineService::add($id, $this->sid);
}
}
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\FollowerService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
class FeedInsertRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $sid;
protected $pid;
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:insert:remote:sid:' . $this->sid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:insert:remote:sid:{$this->sid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($sid, $pid)
{
$this->sid = $sid;
$this->pid = $pid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$sid = $this->sid;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
if(!in_array($status['pf_type'], ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) {
return;
}
$ids = FollowerService::localFollowerIds($this->pid);
if(!$ids || !count($ids)) {
return;
}
$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)) {
HomeTimelineService::add($id, $this->sid);
}
}
}
}

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

@ -0,0 +1,76 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\FollowerService;
use App\Services\StatusService;
use App\Services\HomeTimelineService;
class FeedRemovePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $sid;
protected $pid;
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:sid:' . $this->sid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:remove:sid:{$this->sid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($sid, $pid)
{
$this->sid = $sid;
$this->pid = $pid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$ids = FollowerService::localFollowerIds($this->pid);
HomeTimelineService::rem($this->pid, $this->sid);
foreach($ids as $id) {
HomeTimelineService::rem($id, $this->sid);
}
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\FollowerService;
use App\Services\StatusService;
use App\Services\HomeTimelineService;
class FeedRemoveRemotePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $sid;
protected $pid;
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:remote:sid:' . $this->sid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:remove:remote:sid:{$this->sid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($sid, $pid)
{
$this->sid = $sid;
$this->pid = $pid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$ids = FollowerService::localFollowerIds($this->pid);
foreach($ids as $id) {
HomeTimelineService::rem($id, $this->sid);
}
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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\AccountService;
use App\Services\StatusService;
use App\Services\HomeTimelineService;
class FeedUnfollowPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $actorId;
protected $followingId;
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:follows:aid:' . $this->actorId . ':fid:' . $this->followingId;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hts:feed:remove:follows:aid:{$this->actorId}:fid:{$this->followingId}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($actorId, $followingId)
{
$this->actorId = $actorId;
$this->followingId = $followingId;
}
/**
* Execute the job.
*/
public function handle(): void
{
$actorId = $this->actorId;
$followingId = $this->followingId;
$ids = HomeTimelineService::get($actorId, 0, -1);
foreach($ids as $id) {
$status = StatusService::get($id, false);
if($status && isset($status['account'], $status['account']['id'])) {
if($status['account']['id'] == $followingId) {
HomeTimelineService::rem($actorId, $id);
}
}
}
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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 App\Services\HomeTimelineService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class FeedWarmCachePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pid;
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 'hfp:warm-cache:pid:' . $this->pid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hfp:warm-cache:pid:{$this->pid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($pid)
{
$this->pid = $pid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$pid = $this->pid;
HomeTimelineService::warmCache($pid, true, 400, true);
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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 App\Hashtag;
use App\StatusHashtag;
use App\UserFilter;
use App\Models\UserDomainBlock;
use App\Services\HashtagFollowService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class HashtagInsertFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $hashtag;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = 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 'hfp:hashtag:fanout:insert:' . $this->hashtag->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hfp:hashtag:fanout:insert:{$this->hashtag->id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct(StatusHashtag $hashtag)
{
$this->hashtag = $hashtag;
}
/**
* Execute the job.
*/
public function handle(): void
{
$hashtag = $this->hashtag;
$sid = $hashtag->status_id;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'], $status['url'])) {
return;
}
if(!in_array($status['pf_type'], ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) {
return;
}
$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);
if(!$ids || !count($ids)) {
return;
}
foreach($ids as $id) {
if(!in_array($id, $skipIds)) {
HomeTimelineService::add($id, $hashtag->status_id);
}
}
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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 App\Hashtag;
use App\StatusHashtag;
use App\Services\HashtagFollowService;
use App\Services\HomeTimelineService;
use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class HashtagRemoveFanoutPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $sid;
protected $hid;
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 'hfp:hashtag:fanout:remove:' . $this->hid . ':' . $this->sid;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("hfp:hashtag:fanout:remove:{$this->hid}:{$this->sid}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($sid, $hid)
{
$this->sid = $sid;
$this->hid = $hid;
}
/**
* Execute the job.
*/
public function handle(): void
{
$sid = $this->sid;
$hid = $this->hid;
$status = StatusService::get($sid, false);
if(!$status || !isset($status['account']) || !isset($status['account']['id'])) {
return;
}
if(!in_array($status['pf_type'], ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) {
return;
}
$ids = HashtagFollowService::getPidByHid($hid);
if(!$ids || !count($ids)) {
return;
}
foreach($ids as $id) {
HomeTimelineService::rem($id, $sid);
}
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Jobs\HomeFeedPipeline;
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 Illuminate\Support\Facades\Cache;
use App\Follower;
use App\Hashtag;
use App\StatusHashtag;
use App\Services\HashtagFollowService;
use App\Services\StatusService;
use App\Services\HomeTimelineService;
class HashtagUnfollowPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $pid;
protected $hid;
protected $slug;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
/**
* Create a new job instance.
*/
public function __construct($hid, $pid, $slug)
{
$this->hid = $hid;
$this->pid = $pid;
$this->slug = $slug;
}
/**
* Execute the job.
*/
public function handle(): void
{
$hid = $this->hid;
$pid = $this->pid;
$slug = strtolower($this->slug);
$statusIds = HomeTimelineService::get($pid, 0, -1);
$followingIds = Cache::remember('profile:following:'.$pid, 1209600, function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray();
});
foreach($statusIds as $id) {
$status = StatusService::get($id, false);
if(!$status || empty($status['tags'])) {
HomeTimelineService::rem($pid, $id);
continue;
}
$following = in_array((int) $status['account']['id'], $followingIds);
if($following === true) {
continue;
}
$tags = collect($status['tags'])->map(function($tag) {
return strtolower($tag['name']);
})->filter()->values()->toArray();
if(in_array($slug, $tags)) {
HomeTimelineService::rem($pid, $id);
}
}
}
}

View file

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

View file

@ -193,7 +193,7 @@ class InboxValidator implements ShouldQueue
} }
try { try {
$res = Http::timeout(20)->withHeaders([ $res = Http::withOptions(['allow_redirects' => false])->timeout(20)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org', 'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url); ])->get($actor->remote_url);

View file

@ -173,7 +173,7 @@ class InboxWorker implements ShouldQueue
} }
try { try {
$res = Http::timeout(20)->withHeaders([ $res = Http::withOptions(['allow_redirects' => false])->timeout(20)->withHeaders([
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org', 'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
])->get($actor->remote_url); ])->get($actor->remote_url);

View file

@ -0,0 +1,71 @@
<?php
namespace App\Jobs\InternalPipeline;
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 Cache;
use App\Services\NotificationService;
class NotificationEpochUpdatePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1500;
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 'ip:notification-epoch-update';
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping('ip:notification-epoch-update'))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
$rec = Notification::where('created_at', '>', now()->subMonths(6))->first();
$id = 1;
if($rec) {
$id = $rec->id;
}
Cache::put(NotificationService::EPOCH_CACHE_KEY . '6', $id, 1209600);
}
}

View file

@ -10,8 +10,11 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use App\Services\Media\MediaHlsService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class MediaDeletePipeline implements ShouldQueue class MediaDeletePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -20,8 +23,34 @@ class MediaDeletePipeline implements ShouldQueue
public $timeout = 300; public $timeout = 300;
public $tries = 3; public $tries = 3;
public $maxExceptions = 1; public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = 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 'media:purge-job:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:purge-job:id-{$this->media->id}"))->shared()->dontRelease()];
}
public function __construct(Media $media) public function __construct(Media $media)
{ {
$this->media = $media; $this->media = $media;
@ -63,9 +92,17 @@ class MediaDeletePipeline implements ShouldQueue
$disk->delete($thumb); $disk->delete($thumb);
} }
if($media->hls_path != null) {
$files = MediaHlsService::allFiles($media);
if($files && count($files)) {
foreach($files as $file) {
$disk->delete($file);
}
}
}
$media->delete(); $media->delete();
return 1; return 1;
} }
} }

View file

@ -43,15 +43,9 @@ class DecrementPostCount implements ShouldQueue
return 1; return 1;
} }
if($profile->updated_at && $profile->updated_at->lt(now()->subDays(30))) { $profile->status_count = $profile->status_count ? $profile->status_count - 1 : 0;
$profile->status_count = Status::whereProfileId($id)->whereNull(['in_reply_to_id', 'reblog_of_id'])->count(); $profile->save();
$profile->save(); AccountService::del($id);
AccountService::del($id);
} else {
$profile->status_count = $profile->status_count ? $profile->status_count - 1 : 0;
$profile->save();
AccountService::del($id);
}
return 1; return 1;
} }

View file

@ -8,16 +8,48 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use App\Profile; use App\Profile;
use App\Status; use App\Status;
use App\Services\AccountService; use App\Services\AccountService;
class IncrementPostCount implements ShouldQueue class IncrementPostCount implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $id; public $id;
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 'propipe:ipc:' . $this->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("propipe:ipc:{$this->id}"))->shared()->dontRelease()];
}
/** /**
* Create a new job instance. * Create a new job instance.
* *
@ -43,17 +75,11 @@ class IncrementPostCount implements ShouldQueue
return 1; return 1;
} }
if($profile->updated_at && $profile->updated_at->lt(now()->subDays(30))) { $profile->status_count = $profile->status_count + 1;
$profile->status_count = Status::whereProfileId($id)->whereNull(['in_reply_to_id', 'reblog_of_id'])->count(); $profile->last_status_at = now();
$profile->last_status_at = now(); $profile->save();
$profile->save(); AccountService::del($id);
AccountService::del($id); AccountService::get($id);
} else {
$profile->status_count = $profile->status_count + 1;
$profile->last_status_at = now();
$profile->save();
AccountService::del($id);
}
return 1; return 1;
} }

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

@ -17,6 +17,7 @@ use GuzzleHttp\{Pool, Client, Promise};
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use App\Services\ReblogService; use App\Services\ReblogService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Jobs\HomeFeedPipeline\FeedInsertPipeline;
class SharePipeline implements ShouldQueue class SharePipeline implements ShouldQueue
{ {
@ -82,6 +83,8 @@ class SharePipeline implements ShouldQueue
] ]
); );
FeedInsertPipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
return $this->remoteAnnounceDeliver(); return $this->remoteAnnounceDeliver();
} }

View file

@ -17,6 +17,7 @@ use GuzzleHttp\{Pool, Client, Promise};
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use App\Services\ReblogService; use App\Services\ReblogService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Jobs\HomeFeedPipeline\FeedRemovePipeline;
class UndoSharePipeline implements ShouldQueue class UndoSharePipeline implements ShouldQueue
{ {
@ -35,6 +36,8 @@ class UndoSharePipeline implements ShouldQueue
$actor = $status->profile; $actor = $status->profile;
$parent = Status::find($status->reblog_of_id); $parent = Status::find($status->reblog_of_id);
FeedRemovePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
if($parent) { if($parent) {
$target = $parent->profile_id; $target = $parent->profile_id;
ReblogService::removePostReblog($parent->profile_id, $status->id); ReblogService::removePostReblog($parent->profile_id, $status->id);

View file

@ -21,9 +21,11 @@ use App\{
}; };
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use League\Fractal; use League\Fractal;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -37,8 +39,10 @@ use App\Services\AccountService;
use App\Services\CollectionService; use App\Services\CollectionService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline;
use App\Jobs\ProfilePipeline\DecrementPostCount;
use App\Services\NotificationService;
class RemoteStatusDelete implements ShouldQueue class RemoteStatusDelete implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -51,9 +55,35 @@ class RemoteStatusDelete implements ShouldQueue
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
public $timeout = 90; public $tries = 3;
public $tries = 2; public $maxExceptions = 3;
public $maxExceptions = 1; public $timeout = 180;
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 'status:remote:delete:' . $this->status->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("status-remote-delete-{$this->status->id}"))->shared()->dontRelease()];
}
/** /**
* Create a new job instance. * Create a new job instance.
@ -62,7 +92,7 @@ class RemoteStatusDelete implements ShouldQueue
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status->withoutRelations();
} }
/** /**
@ -77,14 +107,10 @@ class RemoteStatusDelete implements ShouldQueue
if($status->deleted_at) { if($status->deleted_at) {
return; return;
} }
$profile = $this->status->profile;
StatusService::del($status->id, true); StatusService::del($status->id, true);
if($profile->status_count && $profile->status_count > 0) { DecrementPostCount::dispatch($status->profile_id)->onQueue('inbox');
$profile->status_count = $profile->status_count - 1;
$profile->save();
}
return $this->unlinkRemoveMedia($status); return $this->unlinkRemoveMedia($status);
} }
@ -112,14 +138,34 @@ class RemoteStatusDelete implements ShouldQueue
CollectionService::removeItem($col->collection_id, $col->object_id); CollectionService::removeItem($col->collection_id, $col->object_id);
$col->delete(); $col->delete();
}); });
DirectMessage::whereStatusId($status->id)->delete(); $dms = DirectMessage::whereStatusId($status->id)->get();
foreach($dms as $dm) {
$not = Notification::whereItemType('App\DirectMessage')
->whereItemId($dm->id)
->first();
if($not) {
NotificationService::del($not->profile_id, $not->id);
$not->forceDeleteQuietly();
}
$dm->delete();
}
Like::whereStatusId($status->id)->forceDelete(); Like::whereStatusId($status->id)->forceDelete();
Media::whereStatusId($status->id) Media::whereStatusId($status->id)
->get() ->get()
->each(function($media) { ->each(function($media) {
MediaDeletePipeline::dispatch($media)->onQueue('mmo'); MediaDeletePipeline::dispatch($media)->onQueue('mmo');
}); });
MediaTag::where('status_id', $status->id)->delete(); $mediaTags = MediaTag::where('status_id', $status->id)->get();
foreach($mediaTags as $mtag) {
$not = Notification::whereItemType('App\MediaTag')
->whereItemId($mtag->id)
->first();
if($not) {
NotificationService::del($not->profile_id, $not->id);
$not->forceDeleteQuietly();
}
$mtag->delete();
}
Mention::whereStatusId($status->id)->forceDelete(); Mention::whereStatusId($status->id)->forceDelete();
Notification::whereItemType('App\Status') Notification::whereItemType('App\Status')
->whereItemId($status->id) ->whereItemId($status->id)

View file

@ -35,6 +35,7 @@ use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use App\Services\CollectionService; use App\Services\CollectionService;
use App\Services\StatusService; use App\Services\StatusService;
use App\Services\NotificationService;
use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline;
class StatusDelete implements ShouldQueue class StatusDelete implements ShouldQueue
@ -115,10 +116,30 @@ class StatusDelete implements ShouldQueue
$col->delete(); $col->delete();
}); });
DirectMessage::whereStatusId($status->id)->delete(); $dms = DirectMessage::whereStatusId($status->id)->get();
foreach($dms as $dm) {
$not = Notification::whereItemType('App\DirectMessage')
->whereItemId($dm->id)
->first();
if($not) {
NotificationService::del($not->profile_id, $not->id);
$not->forceDeleteQuietly();
}
$dm->delete();
}
Like::whereStatusId($status->id)->delete(); Like::whereStatusId($status->id)->delete();
MediaTag::where('status_id', $status->id)->delete(); $mediaTags = MediaTag::where('status_id', $status->id)->get();
foreach($mediaTags as $mtag) {
$not = Notification::whereItemType('App\MediaTag')
->whereItemId($mtag->id)
->first();
if($not) {
NotificationService::del($not->profile_id, $not->id);
$not->forceDeleteQuietly();
}
$mtag->delete();
}
Mention::whereStatusId($status->id)->forceDelete(); Mention::whereStatusId($status->id)->forceDelete();
Notification::whereItemType('App\Status') Notification::whereItemType('App\Status')

View file

@ -19,168 +19,189 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use App\Services\StatusService;
use App\Services\UserFilterService; use App\Services\UserFilterService;
use App\Services\AdminShadowFilterService;
use App\Jobs\HomeFeedPipeline\FeedInsertPipeline;
use App\Jobs\HomeFeedPipeline\HashtagInsertFanoutPipeline;
class StatusEntityLexer implements ShouldQueue class StatusEntityLexer implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; protected $status;
protected $entities; protected $entities;
protected $autolink; protected $autolink;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct(Status $status) public function __construct(Status $status)
{ {
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$profile = $this->status->profile; $profile = $this->status->profile;
$status = $this->status; $status = $this->status;
if(in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) { if(in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])) {
$profile->status_count = $profile->status_count + 1; $profile->status_count = $profile->status_count + 1;
$profile->save(); $profile->save();
} }
if($profile->no_autolink == false) { if($profile->no_autolink == false) {
$this->parseEntities(); $this->parseEntities();
} }
} }
public function parseEntities() public function parseEntities()
{ {
$this->extractEntities(); $this->extractEntities();
} }
public function extractEntities() public function extractEntities()
{ {
$this->entities = Extractor::create()->extract($this->status->caption); $this->entities = Extractor::create()->extract($this->status->caption);
$this->autolinkStatus(); $this->autolinkStatus();
} }
public function autolinkStatus() public function autolinkStatus()
{ {
$this->autolink = Autolink::create()->autolink($this->status->caption); $this->autolink = Autolink::create()->autolink($this->status->caption);
$this->storeEntities(); $this->storeEntities();
} }
public function storeEntities() public function storeEntities()
{ {
$this->storeHashtags(); $this->storeHashtags();
DB::transaction(function () { DB::transaction(function () {
$status = $this->status; $status = $this->status;
$status->rendered = nl2br($this->autolink); $status->rendered = nl2br($this->autolink);
$status->save(); $status->save();
}); });
} }
public function storeHashtags() public function storeHashtags()
{ {
$tags = array_unique($this->entities['hashtags']); $tags = array_unique($this->entities['hashtags']);
$status = $this->status; $status = $this->status;
foreach ($tags as $tag) { foreach ($tags as $tag) {
if(mb_strlen($tag) > 124) { if(mb_strlen($tag) > 124) {
continue; continue;
} }
DB::transaction(function () use ($status, $tag) { DB::transaction(function () use ($status, $tag) {
$slug = str_slug($tag, '-', false); $slug = str_slug($tag, '-', false);
$hashtag = Hashtag::where('slug', $slug)->first();
if (!$hashtag) {
$hashtag = Hashtag::create(
['name' => $tag, 'slug' => $slug]
);
}
StatusHashtag::firstOrCreate( $hashtag = Hashtag::firstOrCreate([
[ 'slug' => $slug
'status_id' => $status->id, ], [
'hashtag_id' => $hashtag->id, 'name' => $tag
'profile_id' => $status->profile_id, ]);
'status_visibility' => $status->visibility,
]
);
});
}
$this->storeMentions();
}
public function storeMentions() StatusHashtag::firstOrCreate(
{ [
$mentions = array_unique($this->entities['mentions']); 'status_id' => $status->id,
$status = $this->status; 'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id,
'status_visibility' => $status->visibility,
]
);
});
}
$this->storeMentions();
}
foreach ($mentions as $mention) { public function storeMentions()
$mentioned = Profile::whereUsername($mention)->first(); {
$mentions = array_unique($this->entities['mentions']);
$status = $this->status;
if (empty($mentioned) || !isset($mentioned->id)) { foreach ($mentions as $mention) {
continue; $mentioned = Profile::whereUsername($mention)->first();
}
if (empty($mentioned) || !isset($mentioned->id)) {
continue;
}
$blocks = UserFilterService::blocks($mentioned->id); $blocks = UserFilterService::blocks($mentioned->id);
if($blocks && in_array($status->profile_id, $blocks)) { if($blocks && in_array($status->profile_id, $blocks)) {
continue; continue;
} }
DB::transaction(function () use ($status, $mentioned) { DB::transaction(function () use ($status, $mentioned) {
$m = new Mention(); $m = new Mention();
$m->status_id = $status->id; $m->status_id = $status->id;
$m->profile_id = $mentioned->id; $m->profile_id = $mentioned->id;
$m->save(); $m->save();
MentionPipeline::dispatch($status, $m); MentionPipeline::dispatch($status, $m);
}); });
} }
$this->deliver(); $this->fanout();
} }
public function deliver() public function fanout()
{ {
$status = $this->status; $status = $this->status;
$types = [ StatusService::refresh($status->id);
'photo',
'photo:album',
'video',
'video:album',
'photo:video:album'
];
if(config_cache('pixelfed.bouncer.enabled')) { if(config('exp.cached_home_timeline')) {
Bouncer::get($status); if( $status->in_reply_to_id === null &&
} in_array($status->scope, ['public', 'unlisted', 'private'])
) {
FeedInsertPipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
}
}
$this->deliver();
}
Cache::forget('pf:atom:user-feed:by-id:' . $status->profile_id); public function deliver()
$hideNsfw = config('instance.hide_nsfw_on_public_feeds'); {
if( $status->uri == null && $status = $this->status;
$status->scope == 'public' && $types = [
in_array($status->type, $types) && 'photo',
$status->in_reply_to_id === null && 'photo:album',
$status->reblog_of_id === null && 'video',
($hideNsfw ? $status->is_nsfw == false : true) 'video:album',
) { 'photo:video:album'
PublicTimelineService::add($status->id); ];
}
if(config_cache('federation.activitypub.enabled') == true && config('app.env') == 'production') { if(config_cache('pixelfed.bouncer.enabled')) {
StatusActivityPubDeliver::dispatch($status); Bouncer::get($status);
} }
}
Cache::forget('pf:atom:user-feed:by-id:' . $status->profile_id);
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
if( $status->uri == null &&
$status->scope == 'public' &&
in_array($status->type, $types) &&
$status->in_reply_to_id === null &&
$status->reblog_of_id === null &&
($hideNsfw ? $status->is_nsfw == false : true)
) {
if(AdminShadowFilterService::canAddToPublicFeedByProfileId($status->profile_id)) {
PublicTimelineService::add($status->id);
}
}
if(config_cache('federation.activitypub.enabled') == true && config('app.env') == 'production') {
StatusActivityPubDeliver::dispatch($status);
}
}
} }

View file

@ -90,7 +90,7 @@ class StatusRemoteUpdatePipeline implements ShouldQueue
]); ]);
$nm->each(function($n, $key) use($status) { $nm->each(function($n, $key) use($status) {
$res = Http::retry(3, 100, throw: false)->head($n['url']); $res = Http::withOptions(['allow_redirects' => false])->retry(3, 100, throw: false)->head($n['url']);
if(!$res->successful()) { if(!$res->successful()) {
return; return;

View file

@ -20,113 +20,119 @@ use App\Util\ActivityPub\Helpers;
class StatusTagsPipeline implements ShouldQueue class StatusTagsPipeline implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $activity; protected $activity;
protected $status; protected $status;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct($activity, $status) public function __construct($activity, $status)
{ {
$this->activity = $activity; $this->activity = $activity;
$this->status = $status; $this->status = $status;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$res = $this->activity; $res = $this->activity;
$status = $this->status; $status = $this->status;
$tags = collect($res['tag']);
// Emoji if(isset($res['tag']['type'], $res['tag']['name'])) {
$tags->filter(function($tag) { $res['tag'] = [$res['tag']];
return $tag && isset($tag['id'], $tag['icon'], $tag['name'], $tag['type']) && $tag['type'] == 'Emoji'; }
})
->map(function($tag) {
CustomEmojiService::import($tag['id'], $this->status->id);
});
// Hashtags $tags = collect($res['tag']);
$tags->filter(function($tag) {
return $tag && $tag['type'] == 'Hashtag' && isset($tag['href'], $tag['name']);
})
->map(function($tag) use($status) {
$name = substr($tag['name'], 0, 1) == '#' ?
substr($tag['name'], 1) : $tag['name'];
$banned = TrendingHashtagService::getBannedHashtagNames(); // Emoji
$tags->filter(function($tag) {
return $tag && isset($tag['id'], $tag['icon'], $tag['name'], $tag['type']) && $tag['type'] == 'Emoji';
})
->map(function($tag) {
CustomEmojiService::import($tag['id'], $this->status->id);
});
if(count($banned)) { // Hashtags
$tags->filter(function($tag) {
return $tag && $tag['type'] == 'Hashtag' && isset($tag['href'], $tag['name']);
})
->map(function($tag) use($status) {
$name = substr($tag['name'], 0, 1) == '#' ?
substr($tag['name'], 1) : $tag['name'];
$banned = TrendingHashtagService::getBannedHashtagNames();
if(count($banned)) {
if(in_array(strtolower($name), array_map('strtolower', $banned))) { if(in_array(strtolower($name), array_map('strtolower', $banned))) {
return; return;
} }
} }
if(config('database.default') === 'pgsql') { if(config('database.default') === 'pgsql') {
$hashtag = Hashtag::where('name', 'ilike', $name) $hashtag = Hashtag::where('name', 'ilike', $name)
->orWhere('slug', 'ilike', str_slug($name)) ->orWhere('slug', 'ilike', str_slug($name, '-', false))
->first(); ->first();
if(!$hashtag) { if(!$hashtag) {
$hashtag = new Hashtag; $hashtag = Hashtag::updateOrCreate([
$hashtag->name = $name; 'slug' => str_slug($name, '-', false),
$hashtag->slug = str_slug($name); 'name' => $name
$hashtag->save(); ]);
} }
} else { } else {
$hashtag = Hashtag::firstOrCreate([ $hashtag = Hashtag::updateOrCreate([
'slug' => str_slug($name) 'slug' => str_slug($name, '-', false),
], [ 'name' => $name
'name' => $name ]);
]);
} }
StatusHashtag::firstOrCreate([ StatusHashtag::firstOrCreate([
'status_id' => $status->id, 'status_id' => $status->id,
'hashtag_id' => $hashtag->id, 'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id, 'profile_id' => $status->profile_id,
'status_visibility' => $status->scope 'status_visibility' => $status->scope
]); ]);
}); });
// Mentions // Mentions
$tags->filter(function($tag) { $tags->filter(function($tag) {
return $tag && return $tag &&
$tag['type'] == 'Mention' && $tag['type'] == 'Mention' &&
isset($tag['href']) && isset($tag['href']) &&
substr($tag['href'], 0, 8) === 'https://'; substr($tag['href'], 0, 8) === 'https://';
}) })
->map(function($tag) use($status) { ->map(function($tag) use($status) {
if(Helpers::validateLocalUrl($tag['href'])) { if(Helpers::validateLocalUrl($tag['href'])) {
$parts = explode('/', $tag['href']); $parts = explode('/', $tag['href']);
if(!$parts) { if(!$parts) {
return; return;
} }
$pid = AccountService::usernameToId(end($parts)); $pid = AccountService::usernameToId(end($parts));
if(!$pid) { if(!$pid) {
return; return;
} }
} else { } else {
$acct = Helpers::profileFetch($tag['href']); $acct = Helpers::profileFetch($tag['href']);
if(!$acct) { if(!$acct) {
return; return;
} }
$pid = $acct->id; $pid = $acct->id;
} }
$mention = new Mention; $mention = new Mention;
$mention->status_id = $status->id; $mention->status_id = $status->id;
$mention->profile_id = $pid; $mention->profile_id = $pid;
$mention->save(); $mention->save();
MentionPipeline::dispatch($status, $mention); MentionPipeline::dispatch($status, $mention);
}); });
}
StatusService::refresh($status->id);
}
} }

View file

@ -0,0 +1,109 @@
<?php
namespace App\Jobs\VideoPipeline;
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 FFMpeg\Format\Video\X264;
use FFMpeg;
use Cache;
use App\Services\MediaService;
use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class VideoHlsPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = 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 'media:video-hls:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:video-hls:id-{$this->media->id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($media)
{
$this->media = $media;
}
/**
* Execute the job.
*/
public function handle(): void
{
$depCheck = Cache::rememberForever('video-pipeline:hls:depcheck', function() {
$bin = config('laravel-ffmpeg.ffmpeg.binaries');
$output = shell_exec($bin . ' -version');
if($output && preg_match('/ffmpeg version ([^\s]+)/', $output, $matches)) {
$version = $matches[1];
return (version_compare($version, config('laravel-ffmpeg.min_hls_version')) >= 0) ? 'ok' : false;
} else {
return false;
}
});
if(!$depCheck || $depCheck !== 'ok') {
return;
}
$media = $this->media;
$bitrate = (new X264)->setKiloBitrate(config('media.hls.bitrate') ?? 1000);
$mp4 = $media->media_path;
$man = str_replace('.mp4', '.m3u8', $mp4);
FFMpeg::fromDisk('local')
->open($mp4)
->exportForHLS()
->setSegmentLength(16)
->setKeyFrameInterval(48)
->addFormat($bitrate)
->save($man);
$media->hls_path = $man;
$media->hls_transcoded_at = now();
$media->save();
MediaService::del($media->status_id);
usleep(50000);
StatusService::del($media->status_id);
return;
}
}

View file

@ -16,13 +16,46 @@ use App\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Util\Media\Blurhash; use App\Util\Media\Blurhash;
use App\Services\MediaService; use App\Services\MediaService;
use App\Services\StatusService; use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class VideoThumbnail implements ShouldQueue class VideoThumbnail implements ShouldQueue, ShouldBeUniqueUntilProcessing
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media; protected $media;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = 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 'media:video-thumb:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:video-thumb:id-{$this->media->id}"))->shared()->dontRelease()];
}
/** /**
* Create a new job instance. * Create a new job instance.
* *
@ -54,7 +87,7 @@ class VideoThumbnail implements ShouldQueue
$path[$i] = $t; $path[$i] = $t;
$save = implode('/', $path); $save = implode('/', $path);
$video = FFMpeg::open($base) $video = FFMpeg::open($base)
->getFrameFromSeconds(0) ->getFrameFromSeconds(1)
->export() ->export()
->toDisk('local') ->toDisk('local')
->save($save); ->save($save);
@ -68,6 +101,9 @@ class VideoThumbnail implements ShouldQueue
$media->save(); $media->save();
} }
if(config('media.hls.enabled')) {
VideoHlsPipeline::dispatch($media)->onQueue('mmo');
}
} catch (Exception $e) { } catch (Exception $e) {
} }

View file

@ -0,0 +1,33 @@
<?php
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
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'created_at' => 'datetime'
];
public function account()
{
if($this->item_type === 'App\Profile') {
return AccountService::get($this->item_id, true);
}
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,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class HashtagRelated extends Model
{
use HasFactory;
protected $guarded = [];
/**
* The attributes that should be mutated to dates and other custom formats.
*
* @var array
*/
protected $casts = [
'related_tags' => 'array',
'last_calculated_at' => 'datetime',
'last_moderated_at' => 'datetime',
];
}

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

@ -5,6 +5,8 @@ namespace App\Observers;
use App\Follower; use App\Follower;
use App\Services\FollowerService; use App\Services\FollowerService;
use Cache; use Cache;
use App\Jobs\HomeFeedPipeline\FeedFollowPipeline;
use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
class FollowerObserver class FollowerObserver
{ {
@ -21,6 +23,7 @@ class FollowerObserver
} }
FollowerService::add($follower->profile_id, $follower->following_id); FollowerService::add($follower->profile_id, $follower->following_id);
FeedFollowPipeline::dispatch($follower->profile_id, $follower->following_id)->onQueue('follow');
} }
/** /**

View file

@ -0,0 +1,51 @@
<?php
namespace App\Observers;
use App\HashtagFollow;
use App\Services\HashtagFollowService;
use App\Jobs\HomeFeedPipeline\HashtagUnfollowPipeline;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
class HashtagFollowObserver implements ShouldHandleEventsAfterCommit
{
/**
* Handle the HashtagFollow "created" event.
*/
public function created(HashtagFollow $hashtagFollow): void
{
HashtagFollowService::add($hashtagFollow->hashtag_id, $hashtagFollow->profile_id);
}
/**
* Handle the HashtagFollow "updated" event.
*/
public function updated(HashtagFollow $hashtagFollow): void
{
//
}
/**
* Handle the HashtagFollow "deleting" event.
*/
public function deleting(HashtagFollow $hashtagFollow): void
{
HashtagFollowService::unfollow($hashtagFollow->hashtag_id, $hashtagFollow->profile_id);
}
/**
* Handle the HashtagFollow "restored" event.
*/
public function restored(HashtagFollow $hashtagFollow): void
{
//
}
/**
* Handle the HashtagFollow "force deleted" event.
*/
public function forceDeleted(HashtagFollow $hashtagFollow): void
{
HashtagFollowService::unfollow($hashtagFollow->hashtag_id, $hashtagFollow->profile_id);
}
}

View file

@ -5,32 +5,31 @@ namespace App\Observers;
use DB; use DB;
use App\StatusHashtag; use App\StatusHashtag;
use App\Services\StatusHashtagService; use App\Services\StatusHashtagService;
use App\Jobs\HomeFeedPipeline\HashtagInsertFanoutPipeline;
use App\Jobs\HomeFeedPipeline\HashtagRemoveFanoutPipeline;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
class StatusHashtagObserver class StatusHashtagObserver implements ShouldHandleEventsAfterCommit
{ {
/**
* Handle events after all transactions are committed.
*
* @var bool
*/
public $afterCommit = true;
/** /**
* Handle the notification "created" event. * Handle the notification "created" event.
* *
* @param \App\Notification $notification * @param \App\StatusHashtag $hashtag
* @return void * @return void
*/ */
public function created(StatusHashtag $hashtag) public function created(StatusHashtag $hashtag)
{ {
StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id); StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
DB::table('hashtags')->where('id', $hashtag->hashtag_id)->increment('cached_count'); DB::table('hashtags')->where('id', $hashtag->hashtag_id)->increment('cached_count');
if($hashtag->status_visibility && $hashtag->status_visibility === 'public') {
HashtagInsertFanoutPipeline::dispatch($hashtag)->onQueue('feed');
}
} }
/** /**
* Handle the notification "updated" event. * Handle the notification "updated" event.
* *
* @param \App\Notification $notification * @param \App\StatusHashtag $hashtag
* @return void * @return void
*/ */
public function updated(StatusHashtag $hashtag) public function updated(StatusHashtag $hashtag)
@ -41,19 +40,22 @@ class StatusHashtagObserver
/** /**
* Handle the notification "deleted" event. * Handle the notification "deleted" event.
* *
* @param \App\Notification $notification * @param \App\StatusHashtag $hashtag
* @return void * @return void
*/ */
public function deleted(StatusHashtag $hashtag) public function deleted(StatusHashtag $hashtag)
{ {
StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id); StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
DB::table('hashtags')->where('id', $hashtag->hashtag_id)->decrement('cached_count'); DB::table('hashtags')->where('id', $hashtag->hashtag_id)->decrement('cached_count');
if($hashtag->status_visibility && $hashtag->status_visibility === 'public') {
HashtagRemoveFanoutPipeline::dispatch($hashtag->status_id, $hashtag->hashtag_id)->onQueue('feed');
}
} }
/** /**
* Handle the notification "restored" event. * Handle the notification "restored" event.
* *
* @param \App\Notification $notification * @param \App\StatusHashtag $hashtag
* @return void * @return void
*/ */
public function restored(StatusHashtag $hashtag) public function restored(StatusHashtag $hashtag)
@ -64,7 +66,7 @@ class StatusHashtagObserver
/** /**
* Handle the notification "force deleted" event. * Handle the notification "force deleted" event.
* *
* @param \App\Notification $notification * @param \App\StatusHashtag $hashtag
* @return void * @return void
*/ */
public function forceDeleted(StatusHashtag $hashtag) public function forceDeleted(StatusHashtag $hashtag)

View file

@ -7,6 +7,8 @@ use App\Services\ProfileStatusService;
use Cache; use Cache;
use App\Models\ImportPost; use App\Models\ImportPost;
use App\Services\ImportService; use App\Services\ImportService;
use App\Jobs\HomeFeedPipeline\FeedRemovePipeline;
use App\Jobs\HomeFeedPipeline\FeedRemoveRemotePipeline;
class StatusObserver class StatusObserver
{ {
@ -63,6 +65,14 @@ class StatusObserver
ImportPost::whereProfileId($status->profile_id)->whereStatusId($status->id)->delete(); ImportPost::whereProfileId($status->profile_id)->whereStatusId($status->id)->delete();
ImportService::clearImportedFiles($status->profile_id); ImportService::clearImportedFiles($status->profile_id);
} }
if(config('exp.cached_home_timeline')) {
if($status->uri) {
FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
} else {
FeedRemovePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
}
}
} }
/** /**

View file

@ -4,6 +4,8 @@ namespace App\Observers;
use App\UserFilter; use App\UserFilter;
use App\Services\UserFilterService; use App\Services\UserFilterService;
use App\Jobs\HomeFeedPipeline\FeedFollowPipeline;
use App\Jobs\HomeFeedPipeline\FeedUnfollowPipeline;
class UserFilterObserver class UserFilterObserver
{ {
@ -78,10 +80,12 @@ class UserFilterObserver
switch ($userFilter->filter_type) { switch ($userFilter->filter_type) {
case 'mute': case 'mute':
UserFilterService::mute($userFilter->user_id, $userFilter->filterable_id); UserFilterService::mute($userFilter->user_id, $userFilter->filterable_id);
FeedUnfollowPipeline::dispatch($userFilter->user_id, $userFilter->filterable_id)->onQueue('feed');
break; break;
case 'block': case 'block':
UserFilterService::block($userFilter->user_id, $userFilter->filterable_id); UserFilterService::block($userFilter->user_id, $userFilter->filterable_id);
FeedUnfollowPipeline::dispatch($userFilter->user_id, $userFilter->filterable_id)->onQueue('feed');
break; break;
} }
} }
@ -96,10 +100,12 @@ class UserFilterObserver
switch ($userFilter->filter_type) { switch ($userFilter->filter_type) {
case 'mute': case 'mute':
UserFilterService::unmute($userFilter->user_id, $userFilter->filterable_id); UserFilterService::unmute($userFilter->user_id, $userFilter->filterable_id);
FeedFollowPipeline::dispatch($userFilter->user_id, $userFilter->filterable_id)->onQueue('feed');
break; break;
case 'block': case 'block':
UserFilterService::unblock($userFilter->user_id, $userFilter->filterable_id); UserFilterService::unblock($userFilter->user_id, $userFilter->filterable_id);
FeedFollowPipeline::dispatch($userFilter->user_id, $userFilter->filterable_id)->onQueue('feed');
break; break;
} }
} }

View file

@ -7,90 +7,52 @@ use App\Follower;
use App\Profile; use App\Profile;
use App\User; use App\User;
use App\UserSetting; use App\UserSetting;
use App\Services\UserFilterService;
use App\Models\DefaultDomainBlock;
use App\Models\UserDomainBlock;
use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\FollowPipeline\FollowPipeline;
use DB; use DB;
use App\Services\FollowerService; use App\Services\FollowerService;
class UserObserver class UserObserver
{ {
/** /**
* Listen to the User created event. * Handle the notification "created" event.
* *
* @param \App\User $user * @param \App\User $user
* * @return void
* @return void */
*/ public function created(User $user): void
public function saved(User $user) {
{ $this->handleUser($user);
if($user->status == 'deleted') { }
return;
}
if(Profile::whereUsername($user->username)->exists()) { /**
return; * Listen to the User saved event.
*
* @param \App\User $user
*
* @return void
*/
public function saved(User $user)
{
$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);
} }
}
if (empty($user->profile)) {
$profile = DB::transaction(function() use($user) {
$profile = new Profile();
$profile->user_id = $user->id;
$profile->username = $user->username;
$profile->name = $user->name;
$pkiConfig = [
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$pki = openssl_pkey_new($pkiConfig);
openssl_pkey_export($pki, $pki_private);
$pki_public = openssl_pkey_get_details($pki);
$pki_public = $pki_public['key'];
$profile->private_key = $pki_private;
$profile->public_key = $pki_public;
$profile->save();
return $profile;
});
DB::transaction(function() use($user, $profile) {
$user = User::findOrFail($user->id);
$user->profile_id = $profile->id;
$user->save();
CreateAvatar::dispatch($profile);
});
if(config_cache('account.autofollow') == true) {
$names = config_cache('account.autofollow_usernames');
$names = explode(',', $names);
if(!$names || !last($names)) {
return;
}
$profiles = Profile::whereIn('username', $names)->get();
if($profiles) {
foreach($profiles as $p) {
$follower = new Follower;
$follower->profile_id = $profile->id;
$follower->following_id = $p->id;
$follower->save();
FollowPipeline::dispatch($follower);
}
}
}
}
if (empty($user->settings)) {
DB::transaction(function() use($user) {
UserSetting::firstOrCreate([
'user_id' => $user->id
]);
});
}
}
/** /**
* Handle the user "deleted" event. * Handle the user "deleted" event.
@ -102,4 +64,97 @@ class UserObserver
{ {
FollowerService::delCache($user->profile_id); FollowerService::delCache($user->profile_id);
} }
protected function handleUser($user)
{
if(in_array($user->status, ['deleted', 'delete'])) {
return;
}
if(Profile::whereUsername($user->username)->exists()) {
return;
}
if (empty($user->profile)) {
$profile = DB::transaction(function() use($user) {
$profile = new Profile();
$profile->user_id = $user->id;
$profile->username = $user->username;
$profile->name = $user->name;
$pkiConfig = [
'digest_alg' => 'sha512',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$pki = openssl_pkey_new($pkiConfig);
openssl_pkey_export($pki, $pki_private);
$pki_public = openssl_pkey_get_details($pki);
$pki_public = $pki_public['key'];
$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;
$user->save();
CreateAvatar::dispatch($profile);
});
if(config_cache('account.autofollow') == true) {
$names = config_cache('account.autofollow_usernames');
$names = explode(',', $names);
if(!$names || !last($names)) {
return;
}
$profiles = Profile::whereIn('username', $names)->get();
if($profiles) {
foreach($profiles as $p) {
$follower = new Follower;
$follower->profile_id = $profile->id;
$follower->following_id = $p->id;
$follower->save();
FollowPipeline::dispatch($follower);
}
}
}
}
if (empty($user->settings)) {
DB::transaction(function() use($user) {
UserSetting::firstOrCreate([
'user_id' => $user->id
]);
});
}
}
protected function applyDefaultDomainBlocks($user)
{
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\Status;
use App\User; use App\User;
use App\UserSetting; use App\UserSetting;
use App\Models\UserDomainBlock;
use App\Transformer\Api\AccountTransformer; use App\Transformer\Api\AccountTransformer;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -15,209 +16,232 @@ use Illuminate\Support\Str;
class AccountService class AccountService
{ {
const CACHE_KEY = 'pf:services:account:'; const CACHE_KEY = 'pf:services:account:';
public static function get($id, $softFail = false) public static function get($id, $softFail = false)
{ {
$res = Cache::remember(self::CACHE_KEY . $id, 43200, function() use($id) { $res = Cache::remember(self::CACHE_KEY . $id, 43200, function() use($id) {
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer()); $fractal->setSerializer(new ArraySerializer());
$profile = Profile::find($id); $profile = Profile::find($id);
if(!$profile || $profile->status === 'delete') { if(!$profile || $profile->status === 'delete') {
return null; return null;
} }
$resource = new Fractal\Resource\Item($profile, new AccountTransformer()); $resource = new Fractal\Resource\Item($profile, new AccountTransformer());
return $fractal->createData($resource)->toArray(); return $fractal->createData($resource)->toArray();
}); });
if(!$res) { if(!$res) {
return $softFail ? null : abort(404); return $softFail ? null : abort(404);
} }
return $res; return $res;
} }
public static function getMastodon($id, $softFail = false) public static function getMastodon($id, $softFail = false)
{ {
$account = self::get($id, $softFail); $account = self::get($id, $softFail);
if(!$account) { if(!$account) {
return null; return null;
} }
if(config('exp.emc') == false) { if(config('exp.emc') == false) {
return $account; return $account;
} }
unset( unset(
$account['header_bg'], $account['header_bg'],
$account['is_admin'], $account['is_admin'],
$account['last_fetched_at'], $account['last_fetched_at'],
$account['local'], $account['local'],
$account['location'], $account['location'],
$account['note_text'], $account['note_text'],
$account['pronouns'], $account['pronouns'],
$account['website'] $account['website']
); );
$account['avatar_static'] = $account['avatar']; $account['avatar_static'] = $account['avatar'];
$account['bot'] = false; $account['bot'] = false;
$account['emojis'] = []; $account['emojis'] = [];
$account['fields'] = []; $account['fields'] = [];
$account['header'] = url('/storage/headers/missing.png'); $account['header'] = url('/storage/headers/missing.png');
$account['header_static'] = url('/storage/headers/missing.png'); $account['header_static'] = url('/storage/headers/missing.png');
$account['last_status_at'] = null; $account['last_status_at'] = null;
return $account; return $account;
} }
public static function del($id) public static function del($id)
{ {
Cache::forget('pf:activitypub:user-object:by-id:' . $id); Cache::forget('pf:activitypub:user-object:by-id:' . $id);
return Cache::forget(self::CACHE_KEY . $id); return Cache::forget(self::CACHE_KEY . $id);
} }
public static function settings($id) public static function settings($id)
{ {
return Cache::remember('profile:compose:settings:' . $id, 604800, function() use($id) { return Cache::remember('profile:compose:settings:' . $id, 604800, function() use($id) {
$settings = UserSetting::whereUserId($id)->first(); $settings = UserSetting::whereUserId($id)->first();
if(!$settings) { if(!$settings) {
return self::defaultSettings(); return self::defaultSettings();
} }
return collect($settings) return collect($settings)
->filter(function($item, $key) { ->filter(function($item, $key) {
return in_array($key, array_keys(self::defaultSettings())) == true; return in_array($key, array_keys(self::defaultSettings())) == true;
}) })
->map(function($item, $key) { ->map(function($item, $key) {
if($key == 'compose_settings') { if($key == 'compose_settings') {
$cs = self::defaultSettings()['compose_settings']; $cs = self::defaultSettings()['compose_settings'];
$ms = is_array($item) ? $item : []; $ms = is_array($item) ? $item : [];
return array_merge($cs, $ms); return array_merge($cs, $ms);
} }
if($key == 'other') { if($key == 'other') {
$other = self::defaultSettings()['other']; $other = self::defaultSettings()['other'];
$mo = is_array($item) ? $item : []; $mo = is_array($item) ? $item : [];
return array_merge($other, $mo); return array_merge($other, $mo);
} }
return $item; return $item;
}); });
}); });
} }
public static function canEmbed($id) public static function canEmbed($id)
{ {
return self::settings($id)['other']['disable_embeds'] == false; return self::settings($id)['other']['disable_embeds'] == false;
} }
public static function defaultSettings() public static function defaultSettings()
{ {
return [ return [
'crawlable' => true, 'crawlable' => true,
'public_dm' => false, 'public_dm' => false,
'reduce_motion' => false, 'reduce_motion' => false,
'high_contrast_mode' => false, 'high_contrast_mode' => false,
'video_autoplay' => false, 'video_autoplay' => false,
'show_profile_follower_count' => true, 'show_profile_follower_count' => true,
'show_profile_following_count' => true, 'show_profile_following_count' => true,
'compose_settings' => [ 'compose_settings' => [
'default_scope' => 'public', 'default_scope' => 'public',
'default_license' => 1, 'default_license' => 1,
'media_descriptions' => false 'media_descriptions' => false
], ],
'other' => [ 'other' => [
'advanced_atom' => false, 'advanced_atom' => false,
'disable_embeds' => false, 'disable_embeds' => false,
'mutual_mention_notifications' => false, 'mutual_mention_notifications' => false,
'hide_collections' => false, 'hide_collections' => false,
'hide_like_counts' => false, 'hide_like_counts' => false,
'hide_groups' => false, 'hide_groups' => false,
'hide_stories' => false, 'hide_stories' => false,
'disable_cw' => false, 'disable_cw' => false,
] ]
]; ];
} }
public static function syncPostCount($id) public static function syncPostCount($id)
{ {
$profile = Profile::find($id); $profile = Profile::find($id);
if(!$profile) { if(!$profile) {
return false; return false;
} }
$key = self::CACHE_KEY . 'pcs:' . $id; $key = self::CACHE_KEY . 'pcs:' . $id;
if(Cache::has($key)) { if(Cache::has($key)) {
return; return;
} }
$count = Status::whereProfileId($id) $count = Status::whereProfileId($id)
->whereNull('in_reply_to_id') ->whereNull('in_reply_to_id')
->whereNull('reblog_of_id') ->whereNull('reblog_of_id')
->whereIn('scope', ['public', 'unlisted', 'private']) ->whereIn('scope', ['public', 'unlisted', 'private'])
->count(); ->count();
$profile->status_count = $count; $profile->status_count = $count;
$profile->save(); $profile->save();
Cache::put($key, 1, 900); Cache::put($key, 1, 900);
return true; return true;
} }
public static function usernameToId($username) public static function usernameToId($username)
{ {
$key = self::CACHE_KEY . 'u2id:' . hash('sha256', $username); $key = self::CACHE_KEY . 'u2id:' . hash('sha256', $username);
return Cache::remember($key, 900, function() use($username) { return Cache::remember($key, 14400, function() use($username) {
$s = Str::of($username); $s = Str::of($username);
if($s->contains('@') && !$s->startsWith('@')) { if($s->contains('@') && !$s->startsWith('@')) {
$username = "@{$username}"; $username = "@{$username}";
} }
$profile = DB::table('profiles') $profile = DB::table('profiles')
->whereUsername($username) ->whereUsername($username)
->first(); ->first();
if(!$profile) { if(!$profile) {
return null; return null;
} }
return (string) $profile->id; return (string) $profile->id;
}); });
} }
public static function hiddenFollowers($id) public static function hiddenFollowers($id)
{ {
$account = self::get($id, true); $account = self::get($id, true);
if(!$account || !isset($account['local']) || $account['local'] == false) { if(!$account || !isset($account['local']) || $account['local'] == false) {
return false; return false;
} }
return Cache::remember('pf:acct:settings:hidden-followers:' . $id, 43200, function() use($id) { return Cache::remember('pf:acct:settings:hidden-followers:' . $id, 43200, function() use($id) {
$user = User::whereProfileId($id)->first(); $user = User::whereProfileId($id)->first();
if(!$user) { if(!$user) {
return false; return false;
} }
$settings = UserSetting::whereUserId($user->id)->first(); $settings = UserSetting::whereUserId($user->id)->first();
if($settings) { if($settings) {
return $settings->show_profile_follower_count == false; return $settings->show_profile_follower_count == false;
} }
return false; return false;
}); });
} }
public static function hiddenFollowing($id) public static function hiddenFollowing($id)
{ {
$account = self::get($id, true); $account = self::get($id, true);
if(!$account || !isset($account['local']) || $account['local'] == false) { if(!$account || !isset($account['local']) || $account['local'] == false) {
return false; return false;
} }
return Cache::remember('pf:acct:settings:hidden-following:' . $id, 43200, function() use($id) { return Cache::remember('pf:acct:settings:hidden-following:' . $id, 43200, function() use($id) {
$user = User::whereProfileId($id)->first(); $user = User::whereProfileId($id)->first();
if(!$user) { if(!$user) {
return false; return false;
} }
$settings = UserSetting::whereUserId($user->id)->first(); $settings = UserSetting::whereUserId($user->id)->first();
if($settings) { if($settings) {
return $settings->show_profile_following_count == false; return $settings->show_profile_following_count == false;
} }
return false; return false;
}); });
} }
public static function setLastActive($id = false)
{
if(!$id) { return; }
$key = 'user:last_active_at:id:' . $id;
if(!Cache::has($key)) {
$user = User::find($id);
if(!$user) { return; }
$user->last_active_at = now();
$user->save();
Cache::put($key, 1, 14400);
}
return;
}
public static function blocksDomain($pid, $domain = false)
{
if(!$domain) {
return;
}
return UserDomainBlock::whereProfileId($pid)->whereDomain($domain)->exists();
}
} }

View file

@ -28,7 +28,7 @@ class ActivityPubFetchService
$headers['User-Agent'] = 'PixelFedBot/1.0.0 (Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')'; $headers['User-Agent'] = 'PixelFedBot/1.0.0 (Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')';
try { try {
$res = Http::withHeaders($headers) $res = Http::withOptions(['allow_redirects' => false])->withHeaders($headers)
->timeout(30) ->timeout(30)
->connectTimeout(5) ->connectTimeout(5)
->retry(3, 500) ->retry(3, 500)

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services;
use App\Models\AdminShadowFilter;
use Cache;
class AdminShadowFilterService
{
const CACHE_KEY = 'pf:services:asfs:';
public static function queryFilter($name = 'hide_from_public_feeds')
{
return AdminShadowFilter::whereItemType('App\Profile')
->whereActive(1)
->where('hide_from_public_feeds', true)
->pluck('item_id')
->toArray();
}
public static function getHideFromPublicFeedsList($refresh = false)
{
$key = self::CACHE_KEY . 'list:hide_from_public_feeds';
if($refresh) {
Cache::forget($key);
}
return Cache::remember($key, 86400, function() {
return AdminShadowFilter::whereItemType('App\Profile')
->whereActive(1)
->where('hide_from_public_feeds', true)
->pluck('item_id')
->toArray();
});
}
public static function canAddToPublicFeedByProfileId($profileId)
{
return !in_array($profileId, self::getHideFromPublicFeedsList());
}
public static function refresh()
{
$keys = [
self::CACHE_KEY . 'list:hide_from_public_feeds'
];
foreach($keys as $key) {
Cache::forget($key);
}
}
}

View file

@ -3,21 +3,125 @@
namespace App\Services; namespace App\Services;
use Cache; use Cache;
use Storage;
use Illuminate\Support\Str;
use App\Avatar;
use App\Profile; use App\Profile;
use App\Jobs\AvatarPipeline\AvatarStorageLargePurge;
use League\Flysystem\UnableToCheckDirectoryExistence;
use League\Flysystem\UnableToRetrieveMetadata;
class AvatarService class AvatarService
{ {
public static function get($profile_id) public static function get($profile_id)
{ {
$exists = Cache::get('avatar:' . $profile_id); $exists = Cache::get('avatar:' . $profile_id);
if($exists) { if($exists) {
return $exists; return $exists;
} }
$profile = Profile::find($profile_id); $profile = Profile::find($profile_id);
if(!$profile) { if(!$profile) {
return config('app.url') . '/storage/avatars/default.jpg'; return config('app.url') . '/storage/avatars/default.jpg';
} }
return $profile->avatarUrl(); return $profile->avatarUrl();
} }
public static function disk()
{
$storage = [
'cloud' => boolval(config_cache('pixelfed.cloud_storage')),
'local' => boolval(config_cache('federation.avatars.store_local'))
];
if(!$storage['cloud'] && !$storage['local']) {
return false;
}
$driver = $storage['cloud'] == false ? 'local' : config('filesystems.cloud');
$disk = Storage::disk($driver);
return $disk;
}
public static function storage(Avatar $avatar)
{
$disk = self::disk();
if(!$disk) {
return;
}
$storage = [
'cloud' => boolval(config_cache('pixelfed.cloud_storage')),
'local' => boolval(config_cache('federation.avatars.store_local'))
];
$base = ($storage['cloud'] == false ? 'public/cache/' : 'cache/') . 'avatars/';
return $disk->allFiles($base . $avatar->profile_id);
}
public static function cleanup($avatar, $confirm = false)
{
if(!$avatar || !$confirm) {
return;
}
if($avatar->cdn_url == null) {
return;
}
$storage = [
'cloud' => boolval(config_cache('pixelfed.cloud_storage')),
'local' => boolval(config_cache('federation.avatars.store_local'))
];
if(!$storage['cloud'] && !$storage['local']) {
return;
}
$disk = self::disk();
if(!$disk) {
return;
}
$base = ($storage['cloud'] == false ? 'public/cache/' : 'cache/') . 'avatars/';
try {
$exists = $disk->directoryExists($base . $avatar->profile_id);
} catch (
UnableToRetrieveMetadata |
UnableToCheckDirectoryExistence |
Exception $e
) {
return;
}
if(!$exists) {
return;
}
$files = collect($disk->allFiles($base . $avatar->profile_id));
if(!$files || !$files->count() || $files->count() === 1) {
return;
}
if($files->count() > 5) {
AvatarStorageLargePurge::dispatch($avatar)->onQueue('mmo');
return;
}
$curFile = Str::of($avatar->cdn_url)->explode('/')->last();
$files = $files->filter(function($f) use($curFile) {
return !$curFile || !str_ends_with($f, $curFile);
})->each(function($name) use($disk) {
$disk->delete($name);
});
return;
}
} }

View file

@ -6,206 +6,245 @@ use Illuminate\Support\Facades\Redis;
use Cache; use Cache;
use DB; use DB;
use App\{ use App\{
Follower, Follower,
Profile, Profile,
User User
}; };
use App\Jobs\FollowPipeline\FollowServiceWarmCache; use App\Jobs\FollowPipeline\FollowServiceWarmCache;
class FollowerService class FollowerService
{ {
const CACHE_KEY = 'pf:services:followers:'; const CACHE_KEY = 'pf:services:followers:';
const FOLLOWERS_SYNC_KEY = 'pf:services:followers:sync-followers:'; const FOLLOWERS_SYNC_KEY = 'pf:services:followers:sync-followers:';
const FOLLOWING_SYNC_KEY = 'pf:services:followers:sync-following:'; const FOLLOWING_SYNC_KEY = 'pf:services:followers:sync-following:';
const FOLLOWING_KEY = 'pf:services:follow:following:id:'; const FOLLOWING_KEY = 'pf:services:follow:following:id:';
const FOLLOWERS_KEY = 'pf:services:follow:followers:id:'; const FOLLOWERS_KEY = 'pf:services:follow:followers:id:';
const FOLLOWERS_LOCAL_KEY = 'pf:services:follow:local-follower-ids:';
const FOLLOWERS_INTER_KEY = 'pf:services:follow:followers:inter:id:';
public static function add($actor, $target) public static function add($actor, $target, $refresh = true)
{ {
$ts = (int) microtime(true); $ts = (int) microtime(true);
RelationshipService::refresh($actor, $target); if($refresh) {
Redis::zadd(self::FOLLOWING_KEY . $actor, $ts, $target); RelationshipService::refresh($actor, $target);
Redis::zadd(self::FOLLOWERS_KEY . $target, $ts, $actor); } else {
Cache::forget('profile:following:' . $actor); RelationshipService::forget($actor, $target);
} }
Redis::zadd(self::FOLLOWING_KEY . $actor, $ts, $target);
Redis::zadd(self::FOLLOWERS_KEY . $target, $ts, $actor);
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::FOLLOWING_KEY . $actor, $target);
Redis::zrem(self::FOLLOWERS_KEY . $target, $actor); Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
Cache::forget('pf:services:follower:audience:' . $actor); if($silent !== true) {
Cache::forget('pf:services:follower:audience:' . $target); AccountService::del($actor);
AccountService::del($actor); AccountService::del($target);
AccountService::del($target); RelationshipService::refresh($actor, $target);
RelationshipService::refresh($actor, $target); Cache::forget('profile:following:' . $actor);
Cache::forget('profile:following:' . $actor); } else {
} RelationshipService::forget($actor, $target);
}
}
public static function followers($id, $start = 0, $stop = 10) public static function followers($id, $start = 0, $stop = 10)
{ {
self::cacheSyncCheck($id, 'followers'); self::cacheSyncCheck($id, 'followers');
return Redis::zrevrange(self::FOLLOWERS_KEY . $id, $start, $stop); return Redis::zrevrange(self::FOLLOWERS_KEY . $id, $start, $stop);
} }
public static function following($id, $start = 0, $stop = 10) public static function following($id, $start = 0, $stop = 10)
{ {
self::cacheSyncCheck($id, 'following'); self::cacheSyncCheck($id, 'following');
return Redis::zrevrange(self::FOLLOWING_KEY . $id, $start, $stop); return Redis::zrevrange(self::FOLLOWING_KEY . $id, $start, $stop);
} }
public static function followersPaginate($id, $page = 1, $limit = 10) public static function followersPaginate($id, $page = 1, $limit = 10)
{ {
$start = $page == 1 ? 0 : $page * $limit - $limit; $start = $page == 1 ? 0 : $page * $limit - $limit;
$end = $start + ($limit - 1); $end = $start + ($limit - 1);
return self::followers($id, $start, $end); return self::followers($id, $start, $end);
} }
public static function followingPaginate($id, $page = 1, $limit = 10) public static function followingPaginate($id, $page = 1, $limit = 10)
{ {
$start = $page == 1 ? 0 : $page * $limit - $limit; $start = $page == 1 ? 0 : $page * $limit - $limit;
$end = $start + ($limit - 1); $end = $start + ($limit - 1);
return self::following($id, $start, $end); return self::following($id, $start, $end);
} }
public static function followerCount($id, $warmCache = true) public static function followerCount($id, $warmCache = true)
{ {
if($warmCache) { if($warmCache) {
self::cacheSyncCheck($id, 'followers'); self::cacheSyncCheck($id, 'followers');
} }
return Redis::zCard(self::FOLLOWERS_KEY . $id); return Redis::zCard(self::FOLLOWERS_KEY . $id);
} }
public static function followingCount($id, $warmCache = true) public static function followingCount($id, $warmCache = true)
{ {
if($warmCache) { if($warmCache) {
self::cacheSyncCheck($id, 'following'); self::cacheSyncCheck($id, 'following');
} }
return Redis::zCard(self::FOLLOWING_KEY . $id); 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) { if($actor == $target) {
return false; return false;
} }
if(self::followerCount($target, false) && self::followingCount($actor, false)) { if($quickCheck) {
self::cacheSyncCheck($target, 'followers'); return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor);
return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor); }
} else {
self::cacheSyncCheck($target, 'followers');
self::cacheSyncCheck($actor, 'following');
return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
}
}
public static function cacheSyncCheck($id, $scope = 'followers') if(self::followerCount($target, false) && self::followingCount($actor, false)) {
{ self::cacheSyncCheck($target, 'followers');
if($scope === 'followers') { return (bool) Redis::zScore(self::FOLLOWERS_KEY . $target, $actor);
if(Cache::get(self::FOLLOWERS_SYNC_KEY . $id) != null) { } else {
return; self::cacheSyncCheck($target, 'followers');
} self::cacheSyncCheck($actor, 'following');
FollowServiceWarmCache::dispatch($id)->onQueue('low'); return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
} }
if($scope === 'following') { }
if(Cache::get(self::FOLLOWING_SYNC_KEY . $id) != null) {
return;
}
FollowServiceWarmCache::dispatch($id)->onQueue('low');
}
return;
}
public static function audience($profile, $scope = null) public static function cacheSyncCheck($id, $scope = 'followers')
{ {
return (new self)->getAudienceInboxes($profile, $scope); if($scope === 'followers') {
} if(Cache::get(self::FOLLOWERS_SYNC_KEY . $id) != null) {
return;
}
FollowServiceWarmCache::dispatch($id)->onQueue('low');
}
if($scope === 'following') {
if(Cache::get(self::FOLLOWING_SYNC_KEY . $id) != null) {
return;
}
FollowServiceWarmCache::dispatch($id)->onQueue('low');
}
return;
}
public static function softwareAudience($profile, $software = 'pixelfed') public static function audience($profile, $scope = null)
{ {
return collect(self::audience($profile)) return (new self)->getAudienceInboxes($profile, $scope);
->filter(function($inbox) use($software) { }
$domain = parse_url($inbox, PHP_URL_HOST);
if(!$domain) {
return false;
}
return InstanceService::software($domain) === strtolower($software);
})
->unique()
->values()
->toArray();
}
protected function getAudienceInboxes($pid, $scope = null) public static function softwareAudience($profile, $software = 'pixelfed')
{ {
$key = 'pf:services:follower:audience:' . $pid; return collect(self::audience($profile))
$domains = Cache::remember($key, 432000, function() use($pid) { ->filter(function($inbox) use($software) {
$profile = Profile::whereNull(['status', 'domain'])->find($pid); $domain = parse_url($inbox, PHP_URL_HOST);
if(!$profile) { if(!$domain) {
return []; return false;
} }
return $profile return InstanceService::software($domain) === strtolower($software);
->followers() })
->get() ->unique()
->map(function($follow) { ->values()
return $follow->sharedInbox ?? $follow->inbox_url; ->toArray();
}) }
->filter()
->unique()
->values();
});
if(!$domains || !$domains->count()) { protected function getAudienceInboxes($pid, $scope = null)
return []; {
} $key = 'pf:services:follower:audience:' . $pid;
$domains = Cache::remember($key, 432000, function() use($pid) {
$profile = Profile::whereNull(['status', 'domain'])->find($pid);
if(!$profile) {
return [];
}
return $profile
->followers()
->get()
->map(function($follow) {
return $follow->sharedInbox ?? $follow->inbox_url;
})
->filter()
->unique()
->values();
});
$banned = InstanceService::getBannedDomains(); if(!$domains || !$domains->count()) {
return [];
}
if(!$banned || count($banned) === 0) { $banned = InstanceService::getBannedDomains();
return $domains->toArray();
}
$res = $domains->filter(function($domain) use($banned) { if(!$banned || count($banned) === 0) {
$parsed = parse_url($domain, PHP_URL_HOST); return $domains->toArray();
return !in_array($parsed, $banned); }
})
->values()
->toArray();
return $res; $res = $domains->filter(function($domain) use($banned) {
} $parsed = parse_url($domain, PHP_URL_HOST);
return !in_array($parsed, $banned);
})
->values()
->toArray();
public static function mutualCount($pid, $mid) return $res;
{ }
return Cache::remember(self::CACHE_KEY . ':mutualcount:' . $pid . ':' . $mid, 3600, function() use($pid, $mid) {
return DB::table('followers as u')
->join('followers as s', 'u.following_id', '=', 's.following_id')
->where('s.profile_id', $mid)
->where('u.profile_id', $pid)
->count();
});
}
public static function mutualIds($pid, $mid, $limit = 3) public static function mutualCount($pid, $mid)
{ {
$key = self::CACHE_KEY . ':mutualids:' . $pid . ':' . $mid . ':limit_' . $limit; return Cache::remember(self::CACHE_KEY . ':mutualcount:' . $pid . ':' . $mid, 3600, function() use($pid, $mid) {
return Cache::remember($key, 3600, function() use($pid, $mid, $limit) { return DB::table('followers as u')
return DB::table('followers as u') ->join('followers as s', 'u.following_id', '=', 's.following_id')
->join('followers as s', 'u.following_id', '=', 's.following_id') ->where('s.profile_id', $mid)
->where('s.profile_id', $mid) ->where('u.profile_id', $pid)
->where('u.profile_id', $pid) ->count();
->limit($limit) });
->pluck('s.following_id') }
->toArray();
});
}
public static function delCache($id) public static function mutualIds($pid, $mid, $limit = 3)
{ {
Redis::del(self::CACHE_KEY . $id); $key = self::CACHE_KEY . ':mutualids:' . $pid . ':' . $mid . ':limit_' . $limit;
Redis::del(self::FOLLOWING_KEY . $id); return Cache::remember($key, 3600, function() use($pid, $mid, $limit) {
Redis::del(self::FOLLOWERS_KEY . $id); return DB::table('followers as u')
Cache::forget(self::FOLLOWERS_SYNC_KEY . $id); ->join('followers as s', 'u.following_id', '=', 's.following_id')
Cache::forget(self::FOLLOWING_SYNC_KEY . $id); ->where('s.profile_id', $mid)
} ->where('u.profile_id', $pid)
->limit($limit)
->pluck('s.following_id')
->toArray();
});
}
public static function mutualAccounts($actorId, $profileId)
{
if($actorId == $profileId) {
return [];
}
$actorKey = self::FOLLOWING_KEY . $actorId;
$profileKey = self::FOLLOWERS_KEY . $profileId;
$key = self::FOLLOWERS_INTER_KEY . $actorId . ':' . $profileId;
$res = Redis::zinterstore($key, [$actorKey, $profileKey]);
if($res) {
return Redis::zrange($key, 0, -1);
} else {
return [];
}
}
public static function delCache($id)
{
Redis::del(self::CACHE_KEY . $id);
Redis::del(self::FOLLOWING_KEY . $id);
Redis::del(self::FOLLOWERS_KEY . $id);
Cache::forget(self::FOLLOWERS_SYNC_KEY . $id);
Cache::forget(self::FOLLOWING_SYNC_KEY . $id);
}
public static function localFollowerIds($pid, $limit = 0)
{
$key = self::FOLLOWERS_LOCAL_KEY . $pid;
$res = Cache::remember($key, 7200, function() use($pid) {
return DB::table('followers')->whereFollowingId($pid)->whereLocalProfile(true)->pluck('profile_id')->sort();
});
return $limit ?
$res->take($limit)->values()->toArray() :
$res->values()->toArray();
}
} }

View file

@ -0,0 +1,72 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use App\Hashtag;
use App\StatusHashtag;
use App\HashtagFollow;
class HashtagFollowService
{
const FOLLOW_KEY = 'pf:services:hashtag-follows:v1:';
const CACHE_KEY = 'pf:services:hfs:byHid:';
const CACHE_WARMED = 'pf:services:hfs:wc:byHid';
public static function getPidByHid($hid)
{
if(!self::isWarm($hid)) {
return self::warmCache($hid);
}
return self::get($hid);
}
public static function unfollow($hid, $pid)
{
return Redis::zrem(self::CACHE_KEY . $hid, $pid);
}
public static function add($hid, $pid)
{
return Redis::zadd(self::CACHE_KEY . $hid, $pid, $pid);
}
public static function rem($hid, $pid)
{
return Redis::zrem(self::CACHE_KEY . $hid, $pid);
}
public static function get($hid)
{
return Redis::zrange(self::CACHE_KEY . $hid, 0, -1);
}
public static function count($hid)
{
return Redis::zcard(self::CACHE_KEY . $hid);
}
public static function warmCache($hid)
{
foreach(HashtagFollow::whereHashtagId($hid)->lazyById(20, 'id') as $h) {
if($h) {
self::add($h->hashtag_id, $h->profile_id);
}
}
self::setWarm($hid);
return self::get($hid);
}
public static function isWarm($hid)
{
return Redis::zcount(self::CACHE_KEY . $hid, 0, -1) ?? Redis::zscore(self::CACHE_WARMED, $hid) != null;
}
public static function setWarm($hid)
{
return Redis::zadd(self::CACHE_WARMED, $hid, $hid);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Services;
use DB;
use App\StatusHashtag;
use App\Models\HashtagRelated;
class HashtagRelatedService
{
public static function get($id)
{
$tag = HashtagRelated::whereHashtagId($id)->first();
if(!$tag) {
return [];
}
return $tag->related_tags;
}
public static function fetchRelatedTags($tag)
{
$res = StatusHashtag::query()
->select('h2.name', DB::raw('COUNT(*) as related_count'))
->join('status_hashtags as hs2', function ($join) {
$join->on('status_hashtags.status_id', '=', 'hs2.status_id')
->whereRaw('status_hashtags.hashtag_id != hs2.hashtag_id');
})
->join('hashtags as h1', 'status_hashtags.hashtag_id', '=', 'h1.id')
->join('hashtags as h2', 'hs2.hashtag_id', '=', 'h2.id')
->where('h1.name', '=', $tag)
->groupBy('h2.name')
->orderBy('related_count', 'desc')
->limit(30)
->get();
return $res;
}
}

View file

@ -8,65 +8,80 @@ use App\Hashtag;
use App\StatusHashtag; use App\StatusHashtag;
use App\HashtagFollow; use App\HashtagFollow;
class HashtagService { class HashtagService
{
const FOLLOW_KEY = 'pf:services:hashtag:following:v1:';
const FOLLOW_PIDS_KEY = 'pf:services:hashtag-follows:v1:';
const FOLLOW_KEY = 'pf:services:hashtag:following:'; public static function get($id)
{
return Cache::remember('services:hashtag:by_id:' . $id, 3600, function() use($id) {
$tag = Hashtag::find($id);
if(!$tag) {
return [];
}
return [
'name' => $tag->name,
'slug' => $tag->slug,
];
});
}
public static function get($id) public static function count($id)
{ {
return Cache::remember('services:hashtag:by_id:' . $id, 3600, function() use($id) { return Cache::remember('services:hashtag:total-count:by_id:' . $id, 300, function() use($id) {
$tag = Hashtag::find($id); $tag = Hashtag::find($id);
if(!$tag) { return $tag ? $tag->cached_count ?? 0 : 0;
return []; });
} }
return [
'name' => $tag->name,
'slug' => $tag->slug,
];
});
}
public static function count($id) public static function isFollowing($pid, $hid)
{ {
return Cache::remember('services:hashtag:public-count:by_id:' . $id, 86400, function() use($id) { $res = Redis::zscore(self::FOLLOW_KEY . $hid, $pid);
return StatusHashtag::whereHashtagId($id)->whereStatusVisibility('public')->count(); if($res) {
}); return true;
} }
public static function isFollowing($pid, $hid) $synced = Cache::get(self::FOLLOW_KEY . 'acct:' . $pid . ':synced');
{ if(!$synced) {
$res = Redis::zscore(self::FOLLOW_KEY . $pid, $hid); $tags = HashtagFollow::whereProfileId($pid)
if($res) { ->get()
return true; ->each(function($tag) use($pid) {
} self::follow($pid, $tag->hashtag_id);
});
Cache::set(self::FOLLOW_KEY . 'acct:' . $pid . ':synced', true, 1209600);
$synced = Cache::get(self::FOLLOW_KEY . $pid . ':synced'); return (bool) Redis::zscore(self::FOLLOW_KEY . $hid, $pid) >= 1;
if(!$synced) { }
$tags = HashtagFollow::whereProfileId($pid)
->get()
->each(function($tag) use($pid) {
self::follow($pid, $tag->hashtag_id);
});
Cache::set(self::FOLLOW_KEY . $pid . ':synced', true, 1209600);
return (bool) Redis::zscore(self::FOLLOW_KEY . $pid, $hid) > 1; return false;
} }
return false; public static function follow($pid, $hid)
} {
Cache::forget(self::FOLLOW_PIDS_KEY . $hid);
return Redis::zadd(self::FOLLOW_KEY . $hid, $pid, $pid);
}
public static function follow($pid, $hid) public static function unfollow($pid, $hid)
{ {
return Redis::zadd(self::FOLLOW_KEY . $pid, $hid, $hid); Cache::forget(self::FOLLOW_PIDS_KEY . $hid);
} return Redis::zrem(self::FOLLOW_KEY . $hid, $pid);
}
public static function unfollow($pid, $hid) public static function following($hid, $start = 0, $limit = 10)
{ {
return Redis::zrem(self::FOLLOW_KEY . $pid, $hid); $synced = Cache::get(self::FOLLOW_KEY . 'acct-following:' . $hid . ':synced');
} if(!$synced) {
$tags = HashtagFollow::whereHashtagId($hid)
->get()
->each(function($tag) use($hid) {
self::follow($tag->profile_id, $hid);
});
Cache::set(self::FOLLOW_KEY . 'acct-following:' . $hid . ':synced', true, 1209600);
public static function following($pid, $start = 0, $limit = 10) return Redis::zrevrange(self::FOLLOW_KEY . $hid, $start, $limit);
{ }
return Redis::zrevrange(self::FOLLOW_KEY . $pid, $start, $limit); return Redis::zrevrange(self::FOLLOW_KEY . $hid, $start, $limit);
} }
} }

View file

@ -0,0 +1,114 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use App\Follower;
use App\Status;
use App\Models\UserDomainBlock;
class HomeTimelineService
{
const CACHE_KEY = 'pf:services:timeline:home:';
const FOLLOWER_FEED_POST_LIMIT = 10;
public static function get($id, $start = 0, $stop = 10)
{
if($stop > 100) {
$stop = 100;
}
return Redis::zrevrange(self::CACHE_KEY . $id, $start, $stop);
}
public static function getRankedMaxId($id, $start = null, $limit = 10)
{
if(!$start) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $id, $start, '-inf', [
'withscores' => true,
'limit' => [1, $limit - 1]
]));
}
public static function getRankedMinId($id, $end = null, $limit = 10)
{
if(!$end) {
return [];
}
return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $id, '+inf', $end, [
'withscores' => true,
'limit' => [0, $limit]
]));
}
public static function add($id, $val)
{
if(self::count($id) >= 400) {
Redis::zpopmin(self::CACHE_KEY . $id);
}
return Redis::zadd(self::CACHE_KEY .$id, $val, $val);
}
public static function rem($id, $val)
{
return Redis::zrem(self::CACHE_KEY . $id, $val);
}
public static function count($id)
{
return Redis::zcard(self::CACHE_KEY . $id);
}
public static function warmCache($id, $force = false, $limit = 100, $returnIds = false)
{
if(self::count($id) == 0 || $force == true) {
Redis::del(self::CACHE_KEY . $id);
$following = Cache::remember('profile:following:'.$id, 1209600, function() use($id) {
$following = Follower::whereProfileId($id)->pluck('following_id');
return $following->push($id)->toArray();
});
$minId = SnowflakeService::byDate(now()->subMonths(6));
$filters = UserFilterService::filters($id);
if($filters && count($filters)) {
$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'])
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderByDesc('id')
->limit($limit)
->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);
}
return $returnIds ? $ids : 1;
}
return 0;
}
}

View file

@ -120,6 +120,9 @@ class InstanceService
$pixels[] = $row; $pixels[] = $row;
} }
// Free the allocated GdImage object from memory:
imagedestroy($image);
$components_x = 4; $components_x = 4;
$components_y = 4; $components_y = 4;
$blurhash = Blurhash::encode($pixels, $components_x, $components_y); $blurhash = Blurhash::encode($pixels, $components_x, $components_y);

View file

@ -9,17 +9,13 @@ use Illuminate\Support\Facades\Redis;
use App\Status; use App\Status;
use App\User; use App\User;
use App\Services\AccountService; use App\Services\AccountService;
use App\Util\Site\Nodeinfo;
class LandingService class LandingService
{ {
public static function get($json = true) public static function get($json = true)
{ {
$activeMonth = Cache::remember('api:nodeinfo:am', 172800, function() { $activeMonth = Nodeinfo::activeUsersMonthly();
return User::select('last_active_at')
->where('last_active_at', '>', now()->subMonths(1))
->orWhere('created_at', '>', now()->subMonths(1))
->count();
});
$totalUsers = Cache::remember('api:nodeinfo:users', 43200, function() { $totalUsers = Cache::remember('api:nodeinfo:users', 43200, function() {
return User::count(); return User::count();

View file

@ -13,7 +13,7 @@ class MarkerService
return Cache::get(self::CACHE_KEY . $timeline . ':' . $profileId); 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); $existing = self::get($profileId, $timeline);
$key = self::CACHE_KEY . $timeline . ':' . $profileId; $key = self::CACHE_KEY . $timeline . ':' . $profileId;

View file

@ -0,0 +1,27 @@
<?php
namespace App\Services\Media;
use Storage;
class MediaHlsService
{
public static function allFiles($media)
{
$path = $media->media_path;
if(!$path) { return; }
$parts = explode('/', $path);
$filename = array_pop($parts);
$dir = implode('/', $parts);
[$name, $ext] = explode('.', $filename);
$files = Storage::files($dir);
return collect($files)
->filter(function($p) use($dir, $name) {
return str_starts_with($p, $dir . '/' . $name);
})
->values()
->toArray();
}
}

View file

@ -18,7 +18,7 @@ class MediaService
public static function get($statusId) public static function get($statusId)
{ {
return Cache::remember(self::CACHE_KEY.$statusId, 86400, function() use($statusId) { return Cache::remember(self::CACHE_KEY.$statusId, 21600, function() use($statusId) {
$media = Media::whereStatusId($statusId)->orderBy('order')->get(); $media = Media::whereStatusId($statusId)->orderBy('order')->get();
if(!$media) { if(!$media) {
return []; return [];
@ -46,7 +46,8 @@ class MediaService
$media['orientation'], $media['orientation'],
$media['filter_name'], $media['filter_name'],
$media['filter_class'], $media['filter_class'],
$media['mime'] $media['mime'],
$media['hls_manifest']
); );
$media['type'] = $mime ? strtolower($mime[0]) : 'unknown'; $media['type'] = $mime ? strtolower($mime[0]) : 'unknown';

View file

@ -17,6 +17,7 @@ use App\Http\Controllers\AvatarController;
use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\RequestException;
use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use App\Jobs\AvatarPipeline\AvatarStorageCleanup;
class MediaStorageService { class MediaStorageService {
@ -29,9 +30,9 @@ class MediaStorageService {
return; return;
} }
public static function avatar($avatar, $local = false) public static function avatar($avatar, $local = false, $skipRecentCheck = false)
{ {
return (new self())->fetchAvatar($avatar, $local); return (new self())->fetchAvatar($avatar, $local, $skipRecentCheck);
} }
public static function head($url) public static function head($url)
@ -86,12 +87,11 @@ class MediaStorageService {
$thumbname = array_pop($pt); $thumbname = array_pop($pt);
$storagePath = implode('/', $p); $storagePath = implode('/', $p);
$disk = Storage::disk(config('filesystems.cloud')); $url = ResilientMediaStorageService::store($storagePath, $path, $name);
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); if($thumb) {
$url = $disk->url($file); $thumbUrl = ResilientMediaStorageService::store($storagePath, $thumb, $thumbname);
$thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public'); $media->thumbnail_url = $thumbUrl;
$thumbUrl = $disk->url($thumbFile); }
$media->thumbnail_url = $thumbUrl;
$media->cdn_url = $url; $media->cdn_url = $url;
$media->optimized_url = $url; $media->optimized_url = $url;
$media->replicated_at = now(); $media->replicated_at = now();
@ -183,6 +183,7 @@ class MediaStorageService {
protected function fetchAvatar($avatar, $local = false, $skipRecentCheck = false) protected function fetchAvatar($avatar, $local = false, $skipRecentCheck = false)
{ {
$queue = random_int(1, 15) > 5 ? 'mmo' : 'low';
$url = $avatar->remote_url; $url = $avatar->remote_url;
$driver = $local ? 'local' : config('filesystems.cloud'); $driver = $local ? 'local' : config('filesystems.cloud');
@ -206,7 +207,7 @@ class MediaStorageService {
$max_size = (int) config('pixelfed.max_avatar_size') * 1000; $max_size = (int) config('pixelfed.max_avatar_size') * 1000;
if(!$skipRecentCheck) { if(!$skipRecentCheck) {
if($avatar->last_fetched_at && $avatar->last_fetched_at->gt(now()->subDay())) { if($avatar->last_fetched_at && $avatar->last_fetched_at->gt(now()->subMonths(3))) {
return; return;
} }
} }
@ -262,6 +263,7 @@ class MediaStorageService {
Cache::forget('avatar:' . $avatar->profile_id); Cache::forget('avatar:' . $avatar->profile_id);
AccountService::del($avatar->profile_id); AccountService::del($avatar->profile_id);
AvatarStorageCleanup::dispatch($avatar)->onQueue($queue)->delay(now()->addMinutes(random_int(3, 15)));
unlink($tmpName); unlink($tmpName);
} }

View file

@ -12,10 +12,13 @@ use App\Transformer\Api\NotificationTransformer;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Jobs\InternalPipeline\NotificationEpochUpdatePipeline;
class NotificationService { class NotificationService {
const CACHE_KEY = 'pf:services:notifications:ids:'; const CACHE_KEY = 'pf:services:notifications:ids:';
const EPOCH_CACHE_KEY = 'pf:services:notifications:epoch-id:by-months:';
const ITEM_CACHE_TTL = 86400;
const MASTODON_TYPES = [ const MASTODON_TYPES = [
'follow', 'follow',
'follow_request', 'follow_request',
@ -44,11 +47,22 @@ class NotificationService {
return $res; return $res;
} }
public static function getEpochId($months = 6)
{
$epoch = Cache::get(self::EPOCH_CACHE_KEY . $months);
if(!$epoch) {
NotificationEpochUpdatePipeline::dispatch();
return 1;
}
return $epoch;
}
public static function coldGet($id, $start = 0, $stop = 400) public static function coldGet($id, $start = 0, $stop = 400)
{ {
$stop = $stop > 400 ? 400 : $stop; $stop = $stop > 400 ? 400 : $stop;
$ids = Notification::whereProfileId($id) $ids = Notification::where('id', '>', self::getEpochId())
->latest() ->where('profile_id', $id)
->orderByDesc('id')
->skip($start) ->skip($start)
->take($stop) ->take($stop)
->pluck('id'); ->pluck('id');
@ -227,7 +241,7 @@ class NotificationService {
public static function getNotification($id) public static function getNotification($id)
{ {
$notification = Cache::remember('service:notification:'.$id, 86400, function() use($id) { $notification = Cache::remember('service:notification:'.$id, self::ITEM_CACHE_TTL, function() use($id) {
$n = Notification::with('item')->find($id); $n = Notification::with('item')->find($id);
if(!$n) { if(!$n) {
@ -259,19 +273,20 @@ class NotificationService {
public static function setNotification(Notification $notification) public static function setNotification(Notification $notification)
{ {
return Cache::remember('service:notification:'.$notification->id, now()->addDays(3), function() use($notification) { return Cache::remember('service:notification:'.$notification->id, self::ITEM_CACHE_TTL, function() use($notification) {
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer()); $fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($notification, new NotificationTransformer()); $resource = new Fractal\Resource\Item($notification, new NotificationTransformer());
return $fractal->createData($resource)->toArray(); return $fractal->createData($resource)->toArray();
}); });
} }
public static function warmCache($id, $stop = 400, $force = false) public static function warmCache($id, $stop = 400, $force = false)
{ {
if(self::count($id) == 0 || $force == true) { if(self::count($id) == 0 || $force == true) {
$ids = Notification::whereProfileId($id) $ids = Notification::where('profile_id', $id)
->latest() ->where('id', '>', self::getEpochId())
->orderByDesc('id')
->limit($stop) ->limit($stop)
->pluck('id'); ->pluck('id');
foreach($ids as $key) { foreach($ids as $key) {

View file

@ -95,7 +95,7 @@ class PublicTimelineService {
if(self::count() == 0 || $force == true) { if(self::count() == 0 || $force == true) {
$hideNsfw = config('instance.hide_nsfw_on_public_feeds'); $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
Redis::del(self::CACHE_KEY); Redis::del(self::CACHE_KEY);
$minId = SnowflakeService::byDate(now()->subDays(14)); $minId = SnowflakeService::byDate(now()->subDays(90));
$ids = Status::where('id', '>', $minId) $ids = Status::where('id', '>', $minId)
->whereNull(['uri', 'in_reply_to_id', 'reblog_of_id']) ->whereNull(['uri', 'in_reply_to_id', 'reblog_of_id'])
->when($hideNsfw, function($q, $hideNsfw) { ->when($hideNsfw, function($q, $hideNsfw) {
@ -105,9 +105,11 @@ class PublicTimelineService {
->whereScope('public') ->whereScope('public')
->orderByDesc('id') ->orderByDesc('id')
->limit($limit) ->limit($limit)
->pluck('id'); ->pluck('id', 'profile_id');
foreach($ids as $id) { foreach($ids as $k => $id) {
self::add($id); if(AdminShadowFilterService::canAddToPublicFeedByProfileId($k)) {
self::add($id);
}
} }
return 1; return 1;
} }

View file

@ -66,6 +66,14 @@ class RelationshipService
return self::get($aid, $tid); return self::get($aid, $tid);
} }
public static function forget($aid, $tid)
{
Cache::forget('pf:services:follower:audience:' . $aid);
Cache::forget('pf:services:follower:audience:' . $tid);
self::delete($tid, $aid);
self::delete($aid, $tid);
}
public static function defaultRelation($tid) public static function defaultRelation($tid)
{ {
return [ return [

View file

@ -0,0 +1,66 @@
<?php
namespace App\Services;
use Storage;
use Illuminate\Http\File;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Aws\S3\Exception\S3Exception;
use GuzzleHttp\Exception\ConnectException;
use League\Flysystem\UnableToWriteFile;
class ResilientMediaStorageService
{
static $attempts = 0;
public static function store($storagePath, $path, $name)
{
return (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ?
self::handleResilientStore($storagePath, $path, $name) :
self::handleStore($storagePath, $path, $name);
}
public static function handleStore($storagePath, $path, $name)
{
return retry(3, function() use($storagePath, $path, $name) {
$baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local';
$disk = Storage::disk($baseDisk);
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
return $disk->url($file);
}, random_int(100, 500));
}
public static function handleResilientStore($storagePath, $path, $name)
{
$attempts = 0;
return retry(4, function() use($storagePath, $path, $name, $attempts) {
self::$attempts++;
usleep(100000);
$baseDisk = self::$attempts > 1 ? self::getAltDriver() : config('filesystems.cloud');
try {
$disk = Storage::disk($baseDisk);
$file = $disk->putFileAs($storagePath, new File($path), $name, 'public');
} catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {}
return $disk->url($file);
}, function (int $attempt, Exception $exception) {
return $attempt * 200;
});
}
public static function getAltDriver()
{
$drivers = [];
if(config('filesystems.disks.alt-primary.enabled')) {
$drivers[] = 'alt-primary';
}
if(config('filesystems.disks.alt-secondary.enabled')) {
$drivers[] = 'alt-secondary';
}
if(empty($drivers)) {
return false;
}
$key = array_rand($drivers, 1);
return $drivers[$key];
}
}

View file

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

View file

@ -84,18 +84,14 @@ class StatusHashtagService {
public static function statusTags($statusId) public static function statusTags($statusId)
{ {
$key = 'pf:services:sh:id:' . $statusId; $status = Status::with('hashtags')->find($statusId);
if(!$status) {
return [];
}
return Cache::remember($key, 604800, function() use($statusId) { $fractal = new Fractal\Manager();
$status = Status::find($statusId); $fractal->setSerializer(new ArraySerializer());
if(!$status) { $resource = new Fractal\Resource\Collection($status->hashtags, new HashtagTransformer());
return []; return $fractal->createData($resource)->toArray();
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($status->hashtags, new HashtagTransformer());
return $fractal->createData($resource)->toArray();
});
} }
} }

Some files were not shown because too many files have changed in this diff Show more