Merge branch 'pixelfed:dev' into main

This commit is contained in:
Felipe Mateus 2023-07-11 20:31:21 -03:00 committed by GitHub
commit cc26bfadfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
293 changed files with 27519 additions and 10607 deletions

View file

@ -41,10 +41,10 @@ jobs:
- vendor
- run: cp .env.testing .env
- run: php artisan config:cache
- run: php artisan route:clear
- run: php artisan storage:link
- run: php artisan key:generate
- run: php artisan config:clear
# run tests with phpunit or codecept
- run: ./vendor/bin/phpunit

View file

@ -65,3 +65,5 @@ CS_UNLISTED_DOMAINS='example.org,example.net,example.com'
## Optional
#HORIZON_DARKMODE=false # Horizon theme darkmode
#HORIZON_EMBED=false # Single Docker Container mode
ENABLE_CONFIG_CACHE=false

View file

@ -1,8 +1,107 @@
# Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.6...dev)
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.8...dev)
### Added
- Import from Instagram ([#4466](https://github.com/pixelfed/pixelfed/pull/4466)) ([cf3078c5](https://github.com/pixelfed/pixelfed/commit/cf3078c5))
### Updates
- Update Notifications.vue component, fix filtering logic to prevent endless spinner ([3df9b53f](https://github.com/pixelfed/pixelfed/commit/3df9b53f))
- Update Direct Messages, fix api endpoint ([fe8728c0](https://github.com/pixelfed/pixelfed/commit/fe8728c0))
- Update nginx config ([fbdc6358](https://github.com/pixelfed/pixelfed/commit/fbdc6358))
- Update api routes, add DeprecatedEndpoint middleware. For more info, visit [pixelfed.org/kb/10404](https://pixelfed.org/kb/10404) ([a8453e77](https://github.com/pixelfed/pixelfed/commit/a8453e77))
- Update admin dashboard, improve users section ([36b6bf48](https://github.com/pixelfed/pixelfed/commit/36b6bf48))
- Update AdminApiController, add instance stats endpoint ([89c3710d](https://github.com/pixelfed/pixelfed/commit/89c3710d))
- Update config, re-add `PF_MAX_USERS` .env variable to limit max users to 1000 by default ([a6d10f03](https://github.com/pixelfed/pixelfed/commit/a6d10f03))
- Update AdminApiController, fix stats ([5c5541fc](https://github.com/pixelfed/pixelfed/commit/5c5541fc))
- Update AdminApiController, include more data for getUser method ([4f850e54](https://github.com/pixelfed/pixelfed/commit/4f850e54))
- Update AdminApiController, improve admin moderation tools ([763ce19a](https://github.com/pixelfed/pixelfed/commit/763ce19a))
- Update ActivityPubFetchService, fix authorized_fetch compatibility. Closes #1850, #2713, #2935 ([63a7879c](https://github.com/pixelfed/pixelfed/commit/63a7879c))
- Update IG Import commands, fix stalled import queue ([b18f3fba](https://github.com/pixelfed/pixelfed/commit/b18f3fba))
- Update TransformImports command, improve handling of imported posts that already exist or are from deleted accounts ([892907d5](https://github.com/pixelfed/pixelfed/commit/892907d5))
- Update console kernel, add import upload gc ([afe6948d](https://github.com/pixelfed/pixelfed/commit/afe6948d))
- Update ImportService, filter deleted posts from getImportedPosts endpoint ([10dd348c](https://github.com/pixelfed/pixelfed/commit/10dd348c))
- Update FixStatusCount, improve command and support remote count resync ([04f4f8ba](https://github.com/pixelfed/pixelfed/commit/04f4f8ba))
- Update StatusRemoteUpdatePipeline, fix missing mime and size attributes that cause empty media previews on our mobile app ([ea54413e](https://github.com/pixelfed/pixelfed/commit/ea54413e))
- Update ComposeModal.vue, fix scroll issue and dont hide scrollbar ([2d959fb3](https://github.com/pixelfed/pixelfed/commit/2d959fb3))
- Update AccountImport, add select first 100 posts button ([625a76a5](https://github.com/pixelfed/pixelfed/commit/625a76a5))
- Update ApiV1Controller, add include_reblogs attribute to home timeline ([37fd0342](https://github.com/pixelfed/pixelfed/commit/37fd0342))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.8 (2023-05-29)](https://github.com/pixelfed/pixelfed/compare/v0.11.7...v0.11.8)
### API Changes
- Added `following_since` attribute to `/api/v1/accounts/relationships` endpoint when `_pe=1` (pixelfed entity) parameter is present ([992d910b](https://github.com/pixelfed/pixelfed/commit/992d910b))
- Added `/api/v1.1/accounts/app/settings` endpoint and UserAppSettings model to store app specific settings ([a2305d5f](https://github.com/pixelfed/pixelfed/commit/a2305d5f))
### Added
- Post edits ([#4416](https://github.com/pixelfed/pixelfed/pull/4416)) ([98cf8f3](https://github.com/pixelfed/pixelfed/commit/98cf8f3))
### Updates
- Update StatusService, fix bug in getFull method ([4d8b4dcf](https://github.com/pixelfed/pixelfed/commit/4d8b4dcf))
- Update Config, bump version for post edit support without having to clear cache ([c0190d84](https://github.com/pixelfed/pixelfed/commit/c0190d84))
- Update EditHistoryModal, fix caption rendering ([0f803446](https://github.com/pixelfed/pixelfed/commit/0f803446))
- Update StatusRemoteUpdatePipeline, fix typo ([109d0419](https://github.com/pixelfed/pixelfed/commit/109d0419))
- Update StatusActivityPubDeliver, fix delivery addressing ([1f2183ee](https://github.com/pixelfed/pixelfed/commit/1f2183ee))
- Update UpdateStatusService, fix formatting issue. Fixes #4423 ([4479055e](https://github.com/pixelfed/pixelfed/commit/4479055e))
- Update nginx config ([ee3b6e09](https://github.com/pixelfed/pixelfed/commit/ee3b6e09))
- Update Status model, increase max mentions, hashtags and links ([1430f532](https://github.com/pixelfed/pixelfed/commit/1430f532))
## [v0.11.7 (2023-05-24)](https://github.com/pixelfed/pixelfed/compare/v0.11.6...v0.11.7)
### API Changes
- Added [/api/v1/followed_tags](https://docs.joinmastodon.org/methods/followed_tags/) api endpoint ([175a8486](https://github.com/pixelfed/pixelfed/commit/175a8486))
- Added [/api/v1/tags/:id/follow](https://docs.joinmastodon.org/methods/tags/#follow) and [/api/v1/tags/:id/unfollow](https://docs.joinmastodon.org/methods/tags/#unfollow) api endpoints ([4d997bb9](https://github.com/pixelfed/pixelfed/commit/4d997bb9))
- Added [/api/v1/tags/:id](https://docs.joinmastodon.org/methods/tags/) api endpoint ([521b3b4c](https://github.com/pixelfed/pixelfed/commit/521b3b4c))
- Added `only_media` support to /api/v1/timelines/tag/:id api endpoint ([b5fe956a](https://github.com/pixelfed/pixelfed/commit/b5fe956a))
- Added /api/v2/instance api endpoint ([167dbcdd](https://github.com/pixelfed/pixelfed/commit/167dbcdd))
- Removed api endpoint cloud ip block logic ([6a2daf1f](https://github.com/pixelfed/pixelfed/commit/6a2daf1f))
- Added idempotency-key support to /api/v1/statuses endpoint ([c54cdd3e](https://github.com/pixelfed/pixelfed/commit/c54cdd3e))
### Added
- Added store remote media on S3 config setting, disabled by default ([51768083](https://github.com/pixelfed/pixelfed/commit/51768083))
- Added Autospam Advanced Detection ([132a58de](https://github.com/pixelfed/pixelfed/commit/132a58de))
### Updates
- Update admin dashboard, fix search and dropdown menu ([dac0d083](https://github.com/pixelfed/pixelfed/commit/dac0d083))
- Update sudo mode view, fix trusted device checkbox ([8ef900bf](https://github.com/pixelfed/pixelfed/commit/8ef900bf))
- Update SearchApiV2Service, improve postgres support ([666e5732](https://github.com/pixelfed/pixelfed/commit/666e5732))
- Update StoryController, show active self stories on home timeline ([633351f6](https://github.com/pixelfed/pixelfed/commit/633351f6))
- Update ApiV1Controller, fix trending accounts format. Closes #4356 ([37bd2ee5](https://github.com/pixelfed/pixelfed/commit/37bd2ee5))
- Update instance config, enable config cache by default ([970f77b0](https://github.com/pixelfed/pixelfed/commit/970f77b0))
- Update Admin Dashboard, allow admins to designate an admin account for the landing page and instance api endpoint ([6ea2bdc7](https://github.com/pixelfed/pixelfed/commit/6ea2bdc7))
- Update config, enable oauth by default ([6a2e9e8f](https://github.com/pixelfed/pixelfed/commit/6a2e9e8f))
- Update StatusService, fix missing account condition ([f48daab3](https://github.com/pixelfed/pixelfed/commit/f48daab3))
- Update ProfileService, add softFail param ([6bc20a37](https://github.com/pixelfed/pixelfed/commit/6bc20a37))
- Update MediaTagService, fix ProfileService to soft fail on missing or deleted accounts ([df444851](https://github.com/pixelfed/pixelfed/commit/df444851))
- Update LikeService, improve likedBy logic to soft fail on missing or deleted accounts ([91ba1398](https://github.com/pixelfed/pixelfed/commit/91ba1398))
- Update StatusTransformers, fix ProfileService to soft fail on missing or deleted accounts ([43d3aa2b](https://github.com/pixelfed/pixelfed/commit/43d3aa2b))
- Update ApiV1Controller, fix hashtag timeline ([fc1a385c](https://github.com/pixelfed/pixelfed/commit/fc1a385c))
- Update settings view, add fallback avatar ([1a83c585](https://github.com/pixelfed/pixelfed/commit/1a83c585))
- Update HashtagFollow model, add MAX_LIMIT of 250 tags per account ([ed352141](https://github.com/pixelfed/pixelfed/commit/ed352141))
- Update Notification logic, remove message and rendered fields ([6cdb5bc6](https://github.com/pixelfed/pixelfed/commit/6cdb5bc6))
- Update InstanceService, fix banner blurhash memory bug ([3aad75ab](https://github.com/pixelfed/pixelfed/commit/3aad75ab))
- Update models, remove deprecated toText and toHtml method ([ea943333](https://github.com/pixelfed/pixelfed/commit/ea943333))
- Update Notification components, add autospam notification support ([0d3b4bc2](https://github.com/pixelfed/pixelfed/commit/0d3b4bc2))
- Update AutoSpam Bouncer, generate notification on positive detections ([d5f63f8a](https://github.com/pixelfed/pixelfed/commit/d5f63f8a))
- Update admin autospam apis, remove autospam warning notifications when appropriate ([588ca653](https://github.com/pixelfed/pixelfed/commit/588ca653))
- Update StatusEntityLexer, stop saving entities ([a91a5e48](https://github.com/pixelfed/pixelfed/commit/a91a5e48))
- Update UserCreate command, fix is_admin flag ([ad25ed67](https://github.com/pixelfed/pixelfed/commit/ad25ed67))
- Update Bouncer, adjust advanced Autospam logic ([18cddd43](https://github.com/pixelfed/pixelfed/commit/18cddd43))
- Update atom view, fix atom feed bug ([63b72c42](https://github.com/pixelfed/pixelfed/commit/63b72c42))
- Update StatusController, disable post embeds from spam accounts ([c167af43](https://github.com/pixelfed/pixelfed/commit/c167af43))
- Update ProfileController, require login to view spam accounts, and disable profile embeds and atom feeds for spam accounts ([dd2f5bb9](https://github.com/pixelfed/pixelfed/commit/dd2f5bb9))
- Update Settings, allow users to disable atom feeds ([3662d3de](https://github.com/pixelfed/pixelfed/commit/3662d3de))
- Update ApiV1Controller, filter muted/blocked accounts from tag timeline ([f42c1140](https://github.com/pixelfed/pixelfed/commit/f42c1140))
- Update admin moderation logic, only re-add top level posts ([c6ffda96](https://github.com/pixelfed/pixelfed/commit/c6ffda96))
- Update admin dashboard, add mass account deletes ([b8426cce](https://github.com/pixelfed/pixelfed/commit/b8426cce))
- Update scheduler, fix S3 media garbage collection not being executed when cloud storage is enabled via dashboard without .env/config being enabled ([adb070f1](https://github.com/pixelfed/pixelfed/commit/adb070f1))
- Update MediaController, add fallback for local files that are later stored on S3 but still are referenced in cached objects remotely ([4973cb46](https://github.com/pixelfed/pixelfed/commit/4973cb46))
- Update PublicTimelineService, improve warmCache query ([9f901d65](https://github.com/pixelfed/pixelfed/commit/9f901d65))
- Update AP Inbox, fix delete handling ([2800c888](https://github.com/pixelfed/pixelfed/commit/2800c888))
- Update login/register views and captcha config, enable login or register captchas or both ([c071c719](https://github.com/pixelfed/pixelfed/commit/c071c719))
- Update login form, allow admins to enable captcha after X failed attempts. Admins can set the number of attempts before captcha is shown, default is 2 attempts before captcha is required ([221ddce0](https://github.com/pixelfed/pixelfed/commit/221ddce0))
## [v0.11.6 (2023-05-03)](https://github.com/pixelfed/pixelfed/compare/v0.11.5...v0.11.6)
### Added

View file

@ -0,0 +1,56 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use Illuminate\Support\Facades\Http;
use App\Services\MediaService;
use App\Services\StatusService;
class FetchMissingMediaMimeType extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:fetch-missing-media-mime-type';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
foreach(Media::whereNotNull(['remote_url', 'status_id'])->whereNull('mime')->lazyByIdDesc(50, 'id') as $media) {
$res = Http::retry(2, 100, throw: false)->head($media->remote_url);
if(!$res->successful()) {
continue;
}
if(!in_array($res->header('content-type'), explode(',',config('pixelfed.media_types')))) {
continue;
}
$media->mime = $res->header('content-type');
if($res->hasHeader('content-length')) {
$media->size = $res->header('content-length');
}
$media->save();
MediaService::del($media->status_id);
StatusService::del($media->status_id);
$this->info('mid:'.$media->id . ' (' . $res->header('content-type') . ':' . $res->header('content-length') . ' bytes)');
}
}
}

View file

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Profile;
use App\Services\AccountService;
class FixStatusCount extends Command
{
@ -12,7 +13,7 @@ class FixStatusCount extends Command
*
* @var string
*/
protected $signature = 'fix:statuscount';
protected $signature = 'fix:statuscount {--remote} {--resync} {--remote-only} {--dlog}';
/**
* The console command description.
@ -38,18 +39,100 @@ class FixStatusCount extends Command
*/
public function handle()
{
Profile::whereNull('domain')
->chunk(50, function($profiles) {
foreach($profiles as $profile) {
$profile->status_count = $profile->statuses()
->getQuery()
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->count();
$profile->save();
if(!$this->confirm('Are you sure you want to run the fix status command?')) {
return;
}
});
$this->line(' ');
$this->info('Running fix status command...');
$now = now();
$nulls = ['domain', 'status', 'last_fetched_at'];
$resync = $this->option('resync');
$resync24hours = false;
if($resync) {
$resyncChoices = ['Only resync accounts that havent been synced in 24 hours', 'Resync all accounts'];
$rsc = $this->choice(
'Do you want to resync all accounts, or just accounts that havent been resynced for 24 hours?',
$resyncChoices,
0
);
$rsci = array_search($rsc, $resyncChoices);
if($rsci === 0) {
$resync24hours = true;
$nulls = ['status', 'domain', 'last_fetched_at'];
} else {
$resync24hours = false;
$nulls = ['status', 'domain'];
}
}
$remote = $this->option('remote');
if($remote) {
$ni = array_search('domain', $nulls);
unset($nulls[$ni]);
$ni = array_search('last_fetched_at', $nulls);
unset($nulls[$ni]);
}
$remoteOnly = $this->option('remote-only');
if($remoteOnly) {
$ni = array_search('domain', $nulls);
unset($nulls[$ni]);
$ni = array_search('last_fetched_at', $nulls);
unset($nulls[$ni]);
$nulls[] = 'user_id';
}
$dlog = $this->option('dlog');
$nulls = array_values($nulls);
foreach(
Profile::when($resync24hours, function($query, $resync24hours) use($nulls) {
if(in_array('domain', $nulls)) {
return $query->whereNull('domain')
->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours(24));
} else {
return $query->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours(24));
}
})
->when($remoteOnly, function($query, $remoteOnly) {
return $query->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours(24));
})
->whereNull($nulls)
->lazyById(50, 'id') as $profile
) {
$ogc = $profile->status_count;
$upc = $profile->statuses()
->getQuery()
->whereIn('scope', ['public', 'private', 'unlisted'])
->count();
if($ogc != $upc) {
$profile->status_count = $upc;
$profile->last_fetched_at = $now;
$profile->save();
AccountService::del($profile->id);
if($dlog) {
$this->info($profile->id . ':' . $profile->username . ' : ' . $upc);
}
} else {
$profile->last_fetched_at = $now;
$profile->save();
if($dlog) {
$this->info($profile->id . ':' . $profile->username . ' : ' . $upc);
}
}
}
$this->line(' ');
$this->info('Finished fix status count command!');
return 0;
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use App\User;
use App\Models\ImportPost;
use App\Services\ImportService;
class ImportRemoveDeletedAccounts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:import-remove-deleted-accounts';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
const CACHE_KEY = 'pf:services:import:gc-accounts:skip_min_id';
/**
* Execute the console command.
*/
public function handle()
{
$skipMinId = Cache::remember(self::CACHE_KEY, 864000, function() {
return 1;
});
$deletedIds = User::withTrashed()
->whereNotNull('status')
->whereIn('status', ['deleted', 'delete'])
->where('id', '>', $skipMinId)
->limit(500)
->pluck('id');
if(!$deletedIds || !$deletedIds->count()) {
return;
}
foreach($deletedIds as $did) {
if(Storage::exists('imports/' . $did)) {
Storage::deleteDirectory('imports/' . $did);
}
ImportPost::where('user_id', $did)->delete();
$skipMinId = $did;
}
Cache::put(self::CACHE_KEY, $skipMinId, 864000);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ImportPost;
use Storage;
use App\Services\ImportService;
use App\User;
class ImportUploadCleanStorage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:import-upload-clean-storage';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
$dirs = Storage::allDirectories('imports');
foreach($dirs as $dir) {
$uid = last(explode('/', $dir));
$skip = User::whereNull('status')->find($uid);
if(!$skip) {
Storage::deleteDirectory($dir);
}
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ImportPost;
use Storage;
use App\Services\ImportService;
class ImportUploadGarbageCollection extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:import-upload-garbage-collection';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
if(!config('import.instagram.enabled')) {
return;
}
$ips = ImportPost::whereNull('status_id')->where('skip_missing_media', true)->take(100)->get();
if(!$ips->count()) {
return;
}
foreach($ips as $ip) {
$pid = $ip->profile_id;
$ip->delete();
ImportService::getPostCount($pid, true);
ImportService::clearAttempts($pid);
ImportService::getImportedFiles($pid, true);
}
}
}

View file

@ -45,7 +45,7 @@ class MediaS3GarbageCollector extends Command
*/
public function handle()
{
$enabled = config('pixelfed.cloud_storage');
$enabled = in_array(config_cache('pixelfed.cloud_storage'), ['1', true, 'true']);
if(!$enabled) {
$this->error('Cloud storage not enabled. Exiting...');
return;

View file

@ -0,0 +1,142 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ImportPost;
use App\Services\ImportService;
use App\Media;
use App\Profile;
use App\Status;
use Storage;
use App\Services\MediaPathService;
use Illuminate\Support\Str;
use App\Util\Lexer\Autolink;
class TransformImports extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:transform-imports';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Transform imports into statuses';
/**
* Execute the console command.
*/
public function handle()
{
if(!config('import.instagram.enabled')) {
return;
}
$ips = ImportPost::whereNull('status_id')->where('skip_missing_media', '!=', true)->take(200)->get();
if(!$ips->count()) {
return;
}
foreach($ips as $ip) {
$id = $ip->user_id;
$pid = $ip->profile_id;
$profile = Profile::find($pid);
if(!$profile) {
$ip->skip_missing_media = true;
$ip->save();
continue;
}
$exists = ImportPost::whereUserId($id)
->whereNotNull('status_id')
->where('filename', $ip->filename)
->where('creation_year', $ip->creation_year)
->where('creation_month', $ip->creation_month)
->where('creation_day', $ip->creation_day)
->exists();
if($exists == true) {
$ip->skip_missing_media = true;
$ip->save();
continue;
}
$idk = ImportService::getId($ip->user_id, $ip->creation_year, $ip->creation_month, $ip->creation_day);
if(Storage::exists('imports/' . $id . '/' . $ip->filename) === false) {
ImportService::clearAttempts($profile->id);
ImportService::getPostCount($profile->id, true);
$ip->skip_missing_media = true;
$ip->save();
continue;
}
$missingMedia = false;
foreach($ip->media as $ipm) {
$fileName = last(explode('/', $ipm['uri']));
$og = 'imports/' . $id . '/' . $fileName;
if(!Storage::exists($og)) {
$missingMedia = true;
}
}
if($missingMedia === true) {
$ip->skip_missing_media = true;
$ip->save();
continue;
}
$caption = $ip->caption;
$status = new Status;
$status->profile_id = $pid;
$status->caption = $caption;
$status->rendered = strlen(trim($caption)) ? Autolink::create()->autolink($ip->caption) : null;
$status->type = $ip->post_type;
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->id = $idk['id'];
$status->created_at = now()->parse($ip->creation_date);
$status->save();
foreach($ip->media as $ipm) {
$fileName = last(explode('/', $ipm['uri']));
$ext = last(explode('.', $fileName));
$basePath = MediaPathService::get($profile);
$og = 'imports/' . $id . '/' . $fileName;
if(!Storage::exists($og)) {
$ip->skip_missing_media = true;
$ip->save();
continue;
}
$size = Storage::size($og);
$mime = Storage::mimeType($og);
$newFile = Str::random(40) . '.' . $ext;
$np = $basePath . '/' . $newFile;
Storage::move($og, $np);
$media = new Media;
$media->profile_id = $pid;
$media->user_id = $id;
$media->status_id = $status->id;
$media->media_path = $np;
$media->mime = $mime;
$media->size = $size;
$media->save();
}
$ip->status_id = $status->id;
$ip->creation_id = $idk['incr'];
$ip->save();
ImportService::clearAttempts($profile->id);
ImportService::getPostCount($profile->id, true);
}
}
}

View file

@ -52,7 +52,7 @@ class UserCreate extends Command
$user->name = $o['name'];
$user->email = $o['email'];
$user->password = bcrypt($o['password']);
$user->is_admin = (bool) $o['is_admin'];
$user->is_admin = $o['is_admin'] == 'true';
$user->email_verified_at = $o['confirm_email'] ? now() : null;
$user->save();

View file

@ -33,9 +33,16 @@ class Kernel extends ConsoleKernel
$schedule->command('gc:passwordreset')->dailyAt('09:41');
$schedule->command('gc:sessions')->twiceDaily(13, 23);
if(config('pixelfed.cloud_storage') && config('media.delete_local_after_cloud')) {
if(in_array(config_cache('pixelfed.cloud_storage'), ['1', true, 'true']) && config('media.delete_local_after_cloud')) {
$schedule->command('media:s3gc')->hourlyAt(15);
}
if(config('import.instagram.enabled')) {
$schedule->command('app:transform-imports')->everyFourMinutes();
$schedule->command('app:import-upload-garbage-collection')->hourlyAt(51);
$schedule->command('app:import-remove-deleted-accounts')->hourlyAt(37);
$schedule->command('app:import-upload-clean-storage')->twiceDailyAt(1, 13, 32);
}
}
/**

View file

@ -31,20 +31,4 @@ class DirectMessage extends Model
{
return Auth::user()->profile->id === $this->from_id;
}
public function toText()
{
$actorName = $this->author->username;
return "{$actorName} sent a direct message.";
}
public function toHtml()
{
$actorName = $this->author->username;
$actorUrl = $this->author->url();
$url = $this->url();
return "{$actorName} sent a direct message.";
}
}

View file

@ -32,20 +32,4 @@ class Follower extends Model
$path = $this->actor->permalink("#accepts/follows/{$this->id}{$append}");
return url($path);
}
public function toText()
{
$actorName = $this->actor->username;
return "{$actorName} ".__('notification.startedFollowingYou');
}
public function toHtml()
{
$actorName = $this->actor->username;
$actorUrl = $this->actor->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.startedFollowingYou');
}
}

View file

@ -12,6 +12,8 @@ class HashtagFollow extends Model
'hashtag_id'
];
const MAX_LIMIT = 250;
public function hashtag()
{
return $this->belongsTo(Hashtag::class);

View file

@ -0,0 +1,255 @@
<?php
namespace App\Http\Controllers\Admin;
use DB, Cache;
use App\{
AccountInterstitial,
DiscoverCategory,
DiscoverCategoryHashtag,
Hashtag,
Media,
Profile,
Status,
StatusHashtag,
User
};
use App\Models\ConfigCache;
use App\Models\AutospamCustomTokens;
use App\Services\AccountService;
use App\Services\ConfigCacheService;
use App\Services\StatusService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use League\ISO3166\ISO3166;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Http;
use App\Http\Controllers\PixelfedDirectoryController;
use \DateInterval;
use \DatePeriod;
use App\Http\Resources\AdminSpamReport;
use App\Util\Lexer\Classifier;
use App\Jobs\AutospamPipeline\AutospamPretrainPipeline;
use App\Jobs\AutospamPipeline\AutospamPretrainNonSpamPipeline;
use App\Jobs\AutospamPipeline\AutospamUpdateCachedDataPipeline;
use Illuminate\Support\Facades\URL;
use App\Services\AutospamService;
trait AdminAutospamController
{
public function autospamHome(Request $request)
{
return view('admin.autospam.home');
}
public function getAutospamConfigApi(Request $request)
{
$open = Cache::remember('admin-dash:reports:spam-count', 3600, function() {
return AccountInterstitial::whereType('post.autospam')->whereNull('appeal_handled_at')->count();
});
$closed = Cache::remember('admin-dash:reports:spam-count-closed', 3600, function() {
return AccountInterstitial::whereType('post.autospam')->whereNotNull('appeal_handled_at')->count();
});
$thisWeek = Cache::remember('admin-dash:reports:spam-count-stats-this-week ', 86400, function() {
$sr = config('database.default') == 'pgsql' ? "to_char(created_at, 'MM-YYYY')" : "DATE_FORMAT(created_at, '%m-%Y')";
$gb = config('database.default') == 'pgsql' ? [DB::raw($sr)] : DB::raw($sr);
$s = AccountInterstitial::select(
DB::raw('count(id) as count'),
DB::raw($sr . " as month_year")
)
->where('created_at', '>=', now()->subWeeks(52))
->groupBy($gb)
->get()
->map(function($s) {
$dt = now()->parse('01-' . $s->month_year);
return [
'id' => $dt->format('Ym'),
'x' => $dt->format('M Y'),
'y' => $s->count
];
})
->sortBy('id')
->values()
->toArray();
return $s;
});
$files = [
'spam' => [
'exists' => Storage::exists(AutospamService::MODEL_SPAM_PATH),
'size' => 0
],
'ham' => [
'exists' => Storage::exists(AutospamService::MODEL_HAM_PATH),
'size' => 0
],
'combined' => [
'exists' => Storage::exists(AutospamService::MODEL_FILE_PATH),
'size' => 0
]
];
if($files['spam']['exists']) {
$files['spam']['size'] = Storage::size(AutospamService::MODEL_SPAM_PATH);
}
if($files['ham']['exists']) {
$files['ham']['size'] = Storage::size(AutospamService::MODEL_HAM_PATH);
}
if($files['combined']['exists']) {
$files['combined']['size'] = Storage::size(AutospamService::MODEL_FILE_PATH);
}
return [
'autospam_enabled' => (bool) config_cache('pixelfed.bouncer.enabled') ?? false,
'nlp_enabled' => (bool) AutospamService::active(),
'files' => $files,
'open' => $open,
'closed' => $closed,
'graph' => collect($thisWeek)->map(fn($s) => $s['y'])->values(),
'graphLabels' => collect($thisWeek)->map(fn($s) => $s['x'])->values()
];
}
public function getAutospamReportsClosedApi(Request $request)
{
$appeals = AdminSpamReport::collection(
AccountInterstitial::orderBy('id', 'desc')
->whereType('post.autospam')
->whereIsSpam(true)
->whereNotNull('appeal_handled_at')
->cursorPaginate(6)
->withQueryString()
);
return $appeals;
}
public function postAutospamTrainSpamApi(Request $request)
{
$aiCount = AccountInterstitial::whereItemType('App\Status')
->whereIsSpam(true)
->count();
abort_if($aiCount < 100, 422, 'You don\'t have enough data to pre-train against.');
$existing = Cache::get('pf:admin:autospam:pretrain:recent');
abort_if($existing, 422, 'You\'ve already run this recently, please wait 30 minutes before pre-training again');
AutospamPretrainPipeline::dispatch();
Cache::put('pf:admin:autospam:pretrain:recent', 1, 1440);
return [
'msg' => 'Success!'
];
}
public function postAutospamTrainNonSpamSearchApi(Request $request)
{
$this->validate($request, [
'q' => 'required|string|min:1'
]);
$q = $request->input('q');
$res = Profile::whereNull(['status', 'domain'])
->where('username', 'like', '%' . $q . '%')
->orderByDesc('followers_count')
->take(10)
->get()
->map(function($p) {
$acct = AccountService::get($p->id, true);
return [
'id' => (string) $p->id,
'avatar' => $acct['avatar'],
'username' => $p->username
];
})
->values();
return $res;
}
public function postAutospamTrainNonSpamSubmitApi(Request $request)
{
$this->validate($request, [
'accounts' => 'required|array|min:1|max:10'
]);
$accts = $request->input('accounts');
$accounts = Profile::whereNull(['domain', 'status'])->find(collect($accts)->map(function($a) { return $a['id'];}));
abort_if(!$accounts || !$accounts->count(), 422, 'One or more of the selected accounts are not valid');
AutospamPretrainNonSpamPipeline::dispatch($accounts);
return $accounts;
}
public function getAutospamCustomTokensApi(Request $request)
{
return AutospamCustomTokens::latest()->cursorPaginate(6);
}
public function saveNewAutospamCustomTokensApi(Request $request)
{
$this->validate($request, [
'token' => 'required|unique:autospam_custom_tokens,token',
]);
$ct = new AutospamCustomTokens;
$ct->token = $request->input('token');
$ct->weight = $request->input('weight');
$ct->category = $request->input('category') === 'spam' ? 'spam' : 'ham';
$ct->note = $request->input('note');
$ct->active = $request->input('active');
$ct->save();
AutospamUpdateCachedDataPipeline::dispatch();
return $ct;
}
public function updateAutospamCustomTokensApi(Request $request)
{
$this->validate($request, [
'id' => 'required',
'token' => 'required',
'category' => 'required|in:spam,ham',
'active' => 'required|boolean'
]);
$ct = AutospamCustomTokens::findOrFail($request->input('id'));
$ct->weight = $request->input('weight');
$ct->category = $request->input('category');
$ct->note = $request->input('note');
$ct->active = $request->input('active');
$ct->save();
AutospamUpdateCachedDataPipeline::dispatch();
return $ct;
}
public function exportAutospamCustomTokensApi(Request $request)
{
abort_if(!Storage::exists(AutospamService::MODEL_SPAM_PATH), 422, 'Autospam Dataset does not exist, please train spam before attempting to export');
return Storage::download(AutospamService::MODEL_SPAM_PATH);
}
public function enableAutospamApi(Request $request)
{
ConfigCacheService::put('autospam.nlp.enabled', true);
Cache::forget(AutospamService::CHCKD_CACHE_KEY);
return ['msg' => 'Success'];
}
public function disableAutospamApi(Request $request)
{
ConfigCacheService::put('autospam.nlp.enabled', false);
Cache::forget(AutospamService::CHCKD_CACHE_KEY);
return ['msg' => 'Success'];
}
}

View file

@ -14,6 +14,7 @@ use App\{
Contact,
Hashtag,
Newsroom,
Notification,
OauthClient,
Profile,
Report,
@ -30,6 +31,7 @@ use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
use App\Jobs\StatusPipeline\StatusDelete;
use App\Http\Resources\AdminReport;
use App\Http\Resources\AdminSpamReport;
use App\Services\NotificationService;
use App\Services\PublicTimelineService;
use App\Services\NetworkTimelineService;
@ -1101,7 +1103,6 @@ trait AdminReportController
Cache::forget('admin-dash:reports:spam-count');
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id);
PublicTimelineService::warmCache(true, 400);
return [$action, $report];
}
@ -1113,6 +1114,7 @@ trait AdminReportController
$appeal->is_spam = true;
$appeal->appeal_handled_at = now();
$appeal->save();
PublicTimelineService::del($appeal->item_id);
}
if($action == 'mark-not-spam') {
@ -1126,7 +1128,19 @@ trait AdminReportController
$appeal->appeal_handled_at = now();
$appeal->save();
Notification::whereAction('autospam.warning')
->whereProfileId($appeal->user->profile_id)
->get()
->each(function($n) use($appeal) {
NotificationService::del($appeal->user->profile_id, $n->id);
$n->forceDelete();
});
StatusService::del($status->id);
StatusService::get($status->id);
if($status->in_reply_to_id == null && $status->reblog_of_id == null) {
PublicTimelineService::add($status->id);
}
}
if($action == 'mark-all-read') {
@ -1157,6 +1171,13 @@ trait AdminReportController
$status->save();
StatusService::del($status->id);
}
Notification::whereAction('autospam.warning')
->whereProfileId($report->user->profile_id)
->get()
->each(function($n) use($report) {
NotificationService::del($report->user->profile_id, $n->id);
$n->forceDelete();
});
});
}

View file

@ -10,8 +10,10 @@ use App\Models\InstanceActor;
use App\Http\Controllers\Controller;
use App\Util\Lexer\PrettyNumber;
use App\Models\ConfigCache;
use App\Services\AccountService;
use App\Services\ConfigCacheService;
use App\Util\Site\Config;
use Illuminate\Support\Str;
trait AdminSettingsController
{
@ -28,6 +30,9 @@ trait AdminSettingsController
$mp4 = in_array('video/mp4', $types);
$webp = in_array('image/webp', $types);
$availableAdmins = User::whereIsAdmin(true)->get();
$currentAdmin = config_cache('instance.admin.pid') ? AccountService::get(config_cache('instance.admin.pid'), true) : null;
// $system = [
// 'permissions' => is_writable(base_path('storage')) && is_writable(base_path('bootstrap')),
// 'max_upload_size' => ini_get('post_max_size'),
@ -45,6 +50,8 @@ trait AdminSettingsController
'cloud_storage',
'cloud_disk',
'cloud_ready',
'availableAdmins',
'currentAdmin'
// 'system'
));
}
@ -63,8 +70,14 @@ trait AdminSettingsController
'type_gif' => 'nullable',
'type_mp4' => 'nullable',
'type_webp' => 'nullable',
'admin_account_id' => 'nullable',
]);
if($request->filled('admin_account_id')) {
ConfigCacheService::put('instance.admin.pid', $request->admin_account_id);
Cache::forget('api:v1:instance-data:contact');
Cache::forget('api:v1:instance-data-response-v1');
}
if($request->filled('rule_delete')) {
$index = (int) $request->input('rule_delete');
$rules = ConfigCacheService::get('app.rules');
@ -141,8 +154,8 @@ trait AdminSettingsController
'show_custom_js' => 'uikit.show_custom.js',
'cloud_storage' => 'pixelfed.cloud_storage',
'account_autofollow' => 'account.autofollow',
'show_directory' => 'landing.show_directory',
'show_explore_feed' => 'landing.show_explore_feed',
'show_directory' => 'instance.landing.show_directory',
'show_explore_feed' => 'instance.landing.show_explore',
];
foreach ($bools as $key => $value) {

View file

@ -11,6 +11,7 @@ use App\Mail\AdminMessage;
use Illuminate\Support\Facades\Mail;
use App\Services\ModLogService;
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Services\AccountService;
trait AdminUserController
{
@ -25,7 +26,7 @@ trait AdminUserController
'next' => $offset + 1,
'query' => $search ? '&a=search&q=' . $search : null
];
$users = User::select('id', 'username', 'status', 'profile_id')
$users = User::select('id', 'username', 'status', 'profile_id', 'is_admin')
->orderBy($col, $dir)
->when($search, function($q, $search) {
return $q->where('username', 'like', "%{$search}%");
@ -34,7 +35,11 @@ trait AdminUserController
return $q->offset(($offset * 10));
})
->limit(10)
->get();
->get()
->map(function($u) {
$u['account'] = AccountService::get($u->profile_id, true);
return $u;
});
return view('admin.users.home', compact('users', 'pagination'));
}

View file

@ -21,6 +21,7 @@ use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use App\Http\Controllers\Admin\{
AdminAutospamController,
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,
@ -43,6 +44,7 @@ use App\Models\CustomEmoji;
class AdminController extends Controller
{
use AdminReportController,
AdminAutospamController,
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,

View file

@ -11,22 +11,30 @@ use App\{
AccountInterstitial,
Instance,
Like,
Notification,
Media,
Profile,
Report,
Status,
User
};
use App\Models\Conversation;
use App\Models\RemoteReport;
use App\Services\AccountService;
use App\Services\AdminStatsService;
use App\Services\ConfigCacheService;
use App\Services\InstanceService;
use App\Services\ModLogService;
use App\Services\SnowflakeService;
use App\Services\StatusService;
use App\Services\PublicTimelineService;
use App\Services\NetworkTimelineService;
use App\Services\NotificationService;
use App\Http\Resources\AdminInstance;
use App\Http\Resources\AdminUser;
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
class AdminApiController extends Controller
{
@ -91,7 +99,7 @@ class AdminApiController extends Controller
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'action' => 'required|in:dismiss,approve,dismiss-all,approve-all',
'action' => 'required|in:dismiss,approve,dismiss-all,approve-all,delete-post,delete-account',
'id' => 'required'
]);
@ -103,14 +111,53 @@ class AdminApiController extends Controller
$now = now();
$res = ['status' => 'success'];
$meta = json_decode($appeal->meta);
$user = $appeal->user;
$profile = $user->profile;
if($action == 'dismiss') {
$appeal->is_spam = true;
$appeal->appeal_handled_at = $now;
$appeal->save();
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $profile->id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $profile->id);
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'delete-post') {
$appeal->appeal_handled_at = now();
$appeal->is_spam = true;
$appeal->save();
ModLogService::boot()
->objectUid($profile->id)
->objectId($appeal->status->id)
->objectType('App\Status::class')
->user($request->user())
->action('admin.status.delete')
->accessLevel('admin')
->save();
PublicTimelineService::deleteByProfileId($profile->id);
StatusDelete::dispatch($appeal->status)->onQueue('high');
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
if($action == 'delete-account') {
abort_if($user->is_admin, 400, 'Cannot delete an admin account.');
$appeal->appeal_handled_at = now();
$appeal->is_spam = true;
$appeal->save();
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\User::class')
->user($request->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
PublicTimelineService::deleteByProfileId($profile->id);
DeleteAccountPipeline::dispatch($appeal->user)->onQueue('high');
Cache::forget('admin-dash:reports:spam-count');
return $res;
}
@ -140,6 +187,14 @@ class AdminApiController extends Controller
StatusService::del($status->id);
Notification::whereAction('autospam.warning')
->whereProfileId($appeal->user->profile_id)
->get()
->each(function($n) use($appeal) {
NotificationService::del($appeal->user->profile_id, $n->id);
$n->forceDelete();
});
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
Cache::forget('admin-dash:reports:spam-count');
@ -164,6 +219,14 @@ class AdminApiController extends Controller
$status->save();
StatusService::del($status->id, true);
}
Notification::whereAction('autospam.warning')
->whereProfileId($report->user->profile_id)
->get()
->each(function($n) use($report) {
NotificationService::del($report->user->profile_id, $n->id);
$n->forceDelete();
});
});
Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $appeal->user->profile_id);
Cache::forget('pf:bouncer_v0:recent_by_pid:' . $appeal->user->profile_id);
@ -387,6 +450,9 @@ class AdminApiController extends Controller
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin == 1, 404);
$this->validate($request, [
'sort' => 'sometimes|in:asc,desc',
]);
$q = $request->input('q');
$sort = $request->input('sort', 'desc') === 'asc' ? 'asc' : 'desc';
$res = User::whereNull('status')
@ -404,17 +470,29 @@ class AdminApiController extends Controller
abort_unless($request->user()->is_admin == 1, 404);
$id = $request->input('user_id');
$key = 'pf-admin-api:getUser:byId:' . $id;
if($request->has('refresh')) {
Cache::forget($key);
}
return Cache::remember($key, 86400, function() use($id) {
$user = User::findOrFail($id);
$profile = $user->profile;
$account = AccountService::get($user->profile_id, true);
return (new AdminUser($user))->additional(['meta' => [
$res = (new AdminUser($user))->additional(['meta' => [
'cached_at' => str_replace('+00:00', 'Z', now()->format(DATE_RFC3339_EXTENDED)),
'account' => $account,
'dms_sent' => Conversation::whereFromId($profile->id)->count(),
'report_count' => Report::where('object_id', $profile->id)->orWhere('reported_profile_id', $profile->id)->count(),
'remote_report_count' => RemoteReport::whereAccountId($profile->id)->count(),
'moderation' => [
'unlisted' => (bool) $profile->unlisted,
'cw' => (bool) $profile->cw,
'no_autolink' => (bool) $profile->no_autolink
]
]]);
return $res;
});
}
public function userAdminAction(Request $request)
@ -424,7 +502,7 @@ class AdminApiController extends Controller
$this->validate($request, [
'id' => 'required',
'action' => 'required|in:unlisted,cw,no_autolink,refresh_stats,verify_email',
'action' => 'required|in:unlisted,cw,no_autolink,refresh_stats,verify_email,delete',
'value' => 'sometimes'
]);
@ -435,7 +513,59 @@ class AdminApiController extends Controller
abort_if($user->is_admin == true && $action !== 'refresh_stats', 400, 'Cannot moderate admin accounts');
if($action === 'refresh_stats') {
if($action === 'delete') {
if(config('pixelfed.account_deletion') == false) {
abort(404);
}
abort_if($user->is_admin, 400, 'Cannot delete an admin account.');
$ts = now()->addMonth();
$user->status = 'delete';
$user->delete_after = $ts;
$user->save();
$profile->status = 'delete';
$profile->delete_after = $ts;
$profile->save();
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user($request->user())
->action('admin.user.delete')
->accessLevel('admin')
->save();
PublicTimelineService::deleteByProfileId($profile->id);
NetworkTimelineService::deleteByProfileId($profile->id);
if($profile->user_id) {
DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
$user->email = $user->id;
$user->password = '';
$user->status = 'delete';
$user->save();
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteAccountPipeline::dispatch($user)->onQueue('high');
} else {
$profile->status = 'delete';
$profile->delete_after = now()->addMonth();
$profile->save();
AccountService::del($profile->id);
DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
}
return [
'status' => 200,
'msg' => 'deleted',
];
} else if($action === 'refresh_stats') {
$profile->following_count = DB::table('followers')->whereProfileId($user->profile_id)->count();
$profile->followers_count = DB::table('followers')->whereFollowingId($user->profile_id)->count();
$statusCount = Status::whereProfileId($user->profile_id)
@ -461,6 +591,51 @@ class AdminApiController extends Controller
])
->accessLevel('admin')
->save();
} else if($action === 'unlisted') {
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => $action,
'message' => 'Success!'
])
->accessLevel('admin')
->save();
$profile->unlisted = !$profile->unlisted;
$profile->save();
} else if($action === 'cw') {
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => $action,
'message' => 'Success!'
])
->accessLevel('admin')
->save();
$profile->cw = !$profile->cw;
$profile->save();
} else if($action === 'no_autolink') {
ModLogService::boot()
->objectUid($profile->id)
->objectId($profile->id)
->objectType('App\Profile::class')
->user($request->user())
->action('admin.user.moderate')
->metadata([
'action' => $action,
'message' => 'Success!'
])
->accessLevel('admin')
->save();
$profile->no_autolink = !$profile->no_autolink;
$profile->save();
} else {
$profile->{$action} = filter_var($request->input('value'), FILTER_VALIDATE_BOOLEAN);
$profile->save();
@ -582,4 +757,62 @@ class AdminApiController extends Controller
return new AdminInstance($instance);
}
public function getAllStats(Request $request)
{
abort_if(!$request->user(), 404);
abort_unless($request->user()->is_admin === 1, 404);
if($request->has('refresh')) {
Cache::forget('admin-api:instance-all-stats-v1');
}
return Cache::remember('admin-api:instance-all-stats-v1', 1209600, function() {
$days = range(1, 7);
$res = [
'cached_at' => now()->format('c'),
];
$minStatusId = SnowflakeService::byDate(now()->subDays(7));
foreach($days as $day) {
$label = now()->subDays($day)->format('D');
$labelShort = substr($label, 0, 1);
$res['users']['days'][] = [
'date' => now()->subDays($day)->format('M j Y'),
'label_full' => $label,
'label' => $labelShort,
'count' => User::whereDate('created_at', now()->subDays($day))->count()
];
$res['posts']['days'][] = [
'date' => now()->subDays($day)->format('M j Y'),
'label_full' => $label,
'label' => $labelShort,
'count' => Status::whereNull('uri')->where('id', '>', $minStatusId)->whereDate('created_at', now()->subDays($day))->count()
];
$res['instances']['days'][] = [
'date' => now()->subDays($day)->format('M j Y'),
'label_full' => $label,
'label' => $labelShort,
'count' => Instance::whereDate('created_at', now()->subDays($day))->count()
];
}
$res['users']['total'] = DB::table('users')->count();
$res['users']['min'] = collect($res['users']['days'])->min('count');
$res['users']['max'] = collect($res['users']['days'])->max('count');
$res['users']['change'] = collect($res['users']['days'])->sum('count');;
$res['posts']['total'] = DB::table('statuses')->whereNull('uri')->count();
$res['posts']['min'] = collect($res['posts']['days'])->min('count');
$res['posts']['max'] = collect($res['posts']['days'])->max('count');
$res['posts']['change'] = collect($res['posts']['days'])->sum('count');
$res['instances']['total'] = DB::table('instances')->count();
$res['instances']['min'] = collect($res['instances']['days'])->min('count');
$res['instances']['max'] = collect($res['instances']['days'])->max('count');
$res['instances']['change'] = collect($res['instances']['days'])->sum('count');
return $res;
});
}
}

View file

@ -19,6 +19,7 @@ use App\{
Follower,
FollowRequest,
Hashtag,
HashtagFollow,
Instance,
Like,
Media,
@ -69,6 +70,7 @@ use App\Services\{
BouncerService,
CollectionService,
FollowerService,
HashtagService,
InstanceService,
LikeService,
NetworkTimelineService,
@ -99,6 +101,7 @@ use App\Jobs\FollowPipeline\FollowRejectPipeline;
use Illuminate\Support\Facades\RateLimiter;
use Purify;
use Carbon\Carbon;
use App\Http\Resources\MastoApi\FollowedTagResource;
class ApiV1Controller extends Controller
{
@ -116,26 +119,12 @@ class ApiV1Controller extends Controller
return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
}
public function getWebsocketConfig()
{
return config('broadcasting.default') === 'pusher' ? [
'host' => config('broadcasting.connections.pusher.options.host'),
'port' => config('broadcasting.connections.pusher.options.port'),
'key' => config('broadcasting.connections.pusher.key'),
'cluster' => config('broadcasting.connections.pusher.options.cluster')
] : [];
}
public function getApp(Request $request)
{
if(!$request->user()) {
return response('', 403);
}
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$client = $request->user()->token()->client;
$res = [
'name' => $client->name,
@ -155,10 +144,6 @@ class ApiV1Controller extends Controller
'redirect_uris' => 'required'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$uris = implode(',', explode('\n', $request->redirect_uris));
$client = Passport::client()->forceFill([
@ -201,10 +186,6 @@ class ApiV1Controller extends Controller
abort_if(!$user, 403);
abort_if($user->status != null, 403);
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($user->profile_id) : AccountService::getMastodon($user->profile_id);
$res['source'] = [
@ -227,10 +208,6 @@ class ApiV1Controller extends Controller
*/
public function accountById(Request $request, $id)
{
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$res = $request->has(self::PF_API_ENTITY_KEY) ? AccountService::get($id, true) : AccountService::getMastodon($id, true);
if(!$res) {
return response()->json(['error' => 'Record not found'], 404);
@ -489,10 +466,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$account = AccountService::get($id);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
@ -585,10 +558,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$account = AccountService::get($id);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
@ -679,10 +648,6 @@ class ApiV1Controller extends Controller
*/
public function accountStatusesById(Request $request, $id)
{
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$this->validate($request, [
@ -784,10 +749,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$target = Profile::where('id', '!=', $user->profile_id)
@ -872,10 +833,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$target = Profile::where('id', '!=', $user->profile_id)
@ -944,21 +901,20 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'id' => 'required|array|min:1|max:20',
'id.*' => 'required|integer|min:1|max:' . PHP_INT_MAX
]);
$napi = $request->has(self::PF_API_ENTITY_KEY);
$pid = $request->user()->profile_id ?? $request->user()->profile->id;
$res = collect($request->input('id'))
->filter(function($id) use($pid) {
return intval($id) !== intval($pid);
})
->map(function($id) use($pid) {
return RelationshipService::get($pid, $id);
->map(function($id) use($pid, $napi) {
return $napi ?
RelationshipService::getWithDate($pid, $id) :
RelationshipService::get($pid, $id);
});
return $this->json($res);
}
@ -980,10 +936,6 @@ class ApiV1Controller extends Controller
'resolve' => 'nullable'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$query = $request->input('q');
$limit = $request->input('limit') ?? 20;
@ -1023,10 +975,6 @@ class ApiV1Controller extends Controller
'page' => 'nullable|integer|min:1|max:10'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$limit = $request->input('limit') ?? 40;
@ -1059,10 +1007,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$pid = $user->profile_id ?? $user->profile->id;
@ -1155,10 +1099,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$pid = $user->profile_id ?? $user->profile->id;
@ -1238,10 +1178,6 @@ class ApiV1Controller extends Controller
'limit' => 'sometimes|integer|min:1|max:20'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$maxId = $request->input('max_id');
$minId = $request->input('min_id');
@ -1295,10 +1231,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$status = StatusService::getMastodon($id, false);
@ -1358,10 +1290,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$status = Status::findOrFail($id);
@ -1419,10 +1347,6 @@ class ApiV1Controller extends Controller
'limit' => 'sometimes|integer|min:1|max:100'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$res = FollowRequest::whereFollowingId($user->profile->id)
@ -1554,6 +1478,9 @@ class ApiV1Controller extends Controller
{
$res = Cache::remember('api:v1:instance-data-response-v1', 1800, function () {
$contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
if(config_cache('instance.admin.pid')) {
return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
}
$admin = User::whereIsAdmin(true)->first();
return $admin && isset($admin->profile_id) ?
AccountService::getMastodon($admin->profile_id, true) :
@ -1663,10 +1590,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'file.*' => [
'required_without:file',
@ -1800,10 +1723,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
]);
@ -1854,10 +1773,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$media = Media::whereUserId($user->id)
@ -1879,10 +1794,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'file.*' => [
'required_without:file',
@ -2056,10 +1967,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$pid = $user->profile_id;
@ -2113,10 +2020,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$pid = $user->profile_id;
@ -2153,10 +2056,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'limit' => 'nullable|integer|min:1|max:100',
'min_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
@ -2233,19 +2132,23 @@ class ApiV1Controller extends Controller
'page' => 'sometimes|integer|max:40',
'min_id' => 'sometimes|integer|min:0|max:' . PHP_INT_MAX,
'max_id' => 'sometimes|integer|min:0|max:' . PHP_INT_MAX,
'limit' => 'sometimes|integer|min:1|max:100'
'limit' => 'sometimes|integer|min:1|max:100',
'include_reblogs' => 'sometimes',
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$napi = $request->has(self::PF_API_ENTITY_KEY);
$page = $request->input('page');
$min = $request->input('min_id');
$max = $request->input('max_id');
$limit = $request->input('limit') ?? 20;
$pid = $request->user()->profile_id;
$includeReblogs = $request->filled('include_reblogs');
$nullFields = $includeReblogs ?
['in_reply_to_id'] :
['in_reply_to_id', 'reblog_of_id'];
$inTypes = $includeReblogs ?
['photo', 'photo:album', 'video', 'video:album', 'photo:video:album', 'share'] :
['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
$following = Cache::remember('profile:following:'.$pid, 1209600, function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
@ -2264,9 +2167,9 @@ class ApiV1Controller extends Controller
'reblog_of_id'
)
->where('id', $dir, $id)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereNull($nullFields)
->whereIntegerInRaw('profile_id', $following)
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereIn('type', $inTypes)
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderByDesc('id')
->take(($limit * 2))
@ -2307,9 +2210,9 @@ class ApiV1Controller extends Controller
'in_reply_to_id',
'reblog_of_id',
)
->whereNull(['in_reply_to_id', 'reblog_of_id'])
->whereNull($nullFields)
->whereIntegerInRaw('profile_id', $following)
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereIn('type', $inTypes)
->whereIn('visibility',['public', 'unlisted', 'private'])
->orderByDesc('id')
->take(($limit * 2))
@ -2387,10 +2290,6 @@ class ApiV1Controller extends Controller
'local' => 'sometimes'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$napi = $request->has(self::PF_API_ENTITY_KEY);
$min = $request->input('min_id');
$max = $request->input('max_id');
@ -2518,10 +2417,6 @@ class ApiV1Controller extends Controller
'scope' => 'nullable|in:inbox,sent,requests'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$limit = $request->input('limit', 20);
$scope = $request->input('scope', 'inbox');
$pid = $request->user()->profile_id;
@ -2560,14 +2455,17 @@ class ApiV1Controller extends Controller
'id' => $dm->id,
'unread' => false,
'accounts' => [
AccountService::getMastodon($from)
AccountService::getMastodon($from, true)
],
'last_status' => StatusService::getDirectMessage($dm->status_id)
];
return $res;
})
->filter(function($dm) {
return isset($dm['accounts']) && count($dm['accounts']) && !empty($dm['last_status']);
if(!$dm || empty($dm['last_status']) || !isset($dm['accounts']) || !count($dm['accounts']) || !isset($dm['accounts'][0]) || !isset($dm['accounts'][0]['id'])) {
return false;
}
return true;
})
->unique(function($item, $key) {
return $item['accounts'][0]['id'];
@ -2588,10 +2486,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$res = $request->has(self::PF_API_ENTITY_KEY) ? StatusService::get($id, false) : StatusService::getMastodon($id, false);
@ -2628,10 +2522,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api_strict_mode')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$pid = $user->profile_id;
$status = StatusService::getMastodon($id, false);
@ -2717,10 +2607,6 @@ class ApiV1Controller extends Controller
'limit' => 'sometimes|integer|min:1|max:80'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$limit = $request->input('limit', 10);
$user = $request->user();
$pid = $user->profile_id;
@ -2813,10 +2699,6 @@ class ApiV1Controller extends Controller
'limit' => 'nullable|integer|min:1|max:80'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$limit = $request->input('limit', 10);
$user = $request->user();
$pid = $user->profile_id;
@ -2906,10 +2788,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'status' => 'nullable|string',
'in_reply_to_id' => 'nullable',
@ -2922,6 +2800,13 @@ class ApiV1Controller extends Controller
'comments_disabled' => 'sometimes|boolean',
]);
if($request->hasHeader('idempotency-key')) {
$key = 'pf:api:v1:status:idempotency-key:' . $request->user()->id . ':' . hash('sha1', $request->header('idempotency-key'));
$exists = Cache::has($key);
abort_if($exists, 400, 'Duplicate idempotency key.');
Cache::put($key, 1, 3600);
}
if(config('costar.enabled') == true) {
$blockedKeywords = config('costar.keyword.block');
if($blockedKeywords !== null && $request->status) {
@ -3109,10 +2994,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$status = Status::whereProfileId($request->user()->profile->id)
->findOrFail($id);
@ -3139,10 +3020,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$status = Status::whereScope('public')->findOrFail($id);
@ -3189,10 +3066,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$user = $request->user();
$status = Status::whereScope('public')->findOrFail($id);
@ -3234,15 +3107,13 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request,[
'page' => 'nullable|integer|max:40',
'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX,
'limit' => 'nullable|integer|max:100'
'limit' => 'nullable|integer|max:100',
'only_media' => 'sometimes|boolean',
'_pe' => 'sometimes'
]);
if(config('database.default') === 'pgsql') {
@ -3266,6 +3137,20 @@ class ApiV1Controller extends Controller
$min = $request->input('min_id');
$max = $request->input('max_id');
$limit = $request->input('limit', 20);
$onlyMedia = $request->input('only_media', true);
$pe = $request->has(self::PF_API_ENTITY_KEY);
if($min || $max) {
$minMax = SnowflakeService::byDate(now()->subMonths(6));
if($min && intval($min) < $minMax) {
return [];
}
if($max && intval($max) < $minMax) {
return [];
}
}
$filters = UserFilterService::filters($request->user()->profile_id);
if(!$min && !$max) {
$id = 1;
@ -3278,17 +3163,24 @@ class ApiV1Controller extends Controller
$res = StatusHashtag::whereHashtagId($tag->id)
->whereStatusVisibility('public')
->where('status_id', $dir, $id)
->latest()
->orderBy('status_id', 'desc')
->limit($limit)
->pluck('status_id')
->map(function ($i) {
if($i) {
return StatusService::getMastodon($i);
}
->map(function ($i) use($pe) {
return $pe ? StatusService::get($i) : StatusService::getMastodon($i);
})
->filter(function($i) {
->filter(function($i) use($onlyMedia) {
if(!$i) {
return false;
}
if($onlyMedia && !isset($i['media_attachments']) || !count($i['media_attachments'])) {
return false;
}
return $i && isset($i['account']);
})
->filter(function($i) use($filters) {
return !in_array($i['account']['id'], $filters);
})
->values()
->toArray();
@ -3306,10 +3198,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'limit' => 'nullable|integer|min:1|max:40',
'max_id' => 'nullable|integer|min:0',
@ -3377,10 +3265,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$status = Status::findOrFail($id);
$pid = $request->user()->profile_id;
@ -3420,10 +3304,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$status = Status::findOrFail($id);
$pid = $request->user()->profile_id;
@ -3445,37 +3325,6 @@ class ApiV1Controller extends Controller
return $this->json($res);
}
/**
* GET /api/v2/search
*
*
* @return array
*/
public function searchV2(Request $request)
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'q' => 'required|string|min:1|max:100',
'account_id' => 'nullable|string',
'max_id' => 'nullable|string',
'min_id' => 'nullable|string',
'type' => 'nullable|in:accounts,hashtags,statuses',
'exclude_unreviewed' => 'nullable',
'resolve' => 'nullable',
'limit' => 'nullable|integer|max:40',
'offset' => 'nullable|integer',
'following' => 'nullable'
]);
$mastodonMode = !$request->has('_pe');
return $this->json(SearchApiV2Service::query($request, $mastodonMode));
}
/**
* GET /api/v1/discover/posts
*
@ -3486,10 +3335,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$this->validate($request, [
'limit' => 'integer|min:1|max:40'
]);
@ -3527,10 +3372,6 @@ class ApiV1Controller extends Controller
'sort' => 'in:all,newest,popular'
]);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$limit = $request->input('limit', 3);
$pid = $request->user()->profile_id;
$status = StatusService::getMastodon($id, false);
@ -3622,10 +3463,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$status = Status::findOrFail($id);
$pid = $request->user()->profile_id;
abort_if(!in_array($status->scope, ['public', 'unlisted', 'private']), 404);
@ -3643,10 +3480,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$pid = $request->user()->profile_id;
$ids = Cache::remember('api:v1.1:discover:accounts:popular', 86400, function() {
@ -3675,7 +3508,8 @@ class ApiV1Controller extends Controller
->filter(function($post) {
return $post && isset($post['id']);
})
->take(3);
->take(3)
->values();
$profile['recent_posts'] = $ids;
return $profile;
})
@ -3695,10 +3529,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$pid = $request->user()->profile_id;
$account = AccountService::get($pid);
@ -3747,10 +3577,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$type = $request->input('timeline');
if(is_array($type)) {
$type = $type[0];
@ -3772,10 +3598,6 @@ class ApiV1Controller extends Controller
{
abort_if(!$request->user(), 403);
if(config('pixelfed.bouncer.cloud_ips.ban_api')) {
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$pid = $request->user()->profile_id;
$home = $request->input('home.last_read_id');
$notifications = $request->input('notifications.last_read_id');
@ -3790,4 +3612,168 @@ class ApiV1Controller extends Controller
return $this->json([]);
}
/**
* 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);
}
/**
* 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);
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);
$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);
}
}

View file

@ -800,11 +800,15 @@ class ApiV1Dot1Controller extends Controller
StatusService::del($status->id, true);
if($state !== 'public') {
if($status->uri) {
if($status->in_reply_to_id == null && $status->reblog_of_id == null) {
NetworkTimelineService::add($status->id);
}
} else {
if($status->in_reply_to_id == null && $status->reblog_of_id == null) {
PublicTimelineService::add($status->id);
}
}
}
} else if ($action == 'mark-unlisted') {
$state = $status->scope;
$status->scope = 'unlisted';

View file

@ -0,0 +1,324 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Media;
use App\UserSetting;
use App\User;
use Illuminate\Support\Facades\Cache;
use App\Services\AccountService;
use App\Services\BouncerService;
use App\Services\InstanceService;
use App\Services\MediaBlocklistService;
use App\Services\MediaPathService;
use App\Services\SearchApiV2Service;
use App\Util\Media\Filter;
use App\Jobs\MediaPipeline\MediaDeletePipeline;
use App\Jobs\VideoPipeline\{
VideoOptimize,
VideoPostProcess,
VideoThumbnail
};
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\Mastodon\v1\{
AccountTransformer,
MediaTransformer,
NotificationTransformer,
StatusTransformer,
};
use App\Transformer\Api\{
RelationshipTransformer,
};
class ApiV2Controller extends Controller
{
const PF_API_ENTITY_KEY = "_pe";
public function json($res, $code = 200, $headers = [])
{
return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
}
public function instance(Request $request)
{
$contact = Cache::remember('api:v1:instance-data:contact', 604800, function () {
if(config_cache('instance.admin.pid')) {
return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
}
$admin = User::whereIsAdmin(true)->first();
return $admin && isset($admin->profile_id) ?
AccountService::getMastodon($admin->profile_id, true) :
null;
});
$rules = Cache::remember('api:v1:instance-data:rules', 604800, function () {
return config_cache('app.rules') ?
collect(json_decode(config_cache('app.rules'), true))
->map(function($rule, $key) {
$id = $key + 1;
return [
'id' => "{$id}",
'text' => $rule
];
})
->toArray() : [];
});
$res = [
'domain' => config('pixelfed.domain.app'),
'title' => config_cache('app.name'),
'version' => config('pixelfed.version'),
'source_url' => 'https://github.com/pixelfed/pixelfed',
'description' => config_cache('app.short_description'),
'usage' => [
'users' => [
'active_month' => (int) Cache::remember('api:nodeinfo:am', 172800, function() {
return User::select('last_active_at', 'created_at')
->where('last_active_at', '>', now()->subMonths(1))
->orWhere('created_at', '>', now()->subMonths(1))
->count();
})
]
],
'thumbnail' => [
'url' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
'blurhash' => InstanceService::headerBlurhash(),
'versions' => [
'@1x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg')),
'@2x' => config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg'))
]
],
'languages' => [config('app.locale')],
'configuration' => [
'urls' => [
'streaming' => 'wss://' . config('pixelfed.domain.app'),
'status' => null
],
'accounts' => [
'max_featured_tags' => 0,
],
'statuses' => [
'max_characters' => (int) config('pixelfed.max_caption_length'),
'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'),
'characters_reserved_per_url' => 23
],
'media_attachments' => [
'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
'image_matrix_limit' => 3686400,
'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
'video_frame_rate_limit' => 240,
'video_matrix_limit' => 3686400
],
'polls' => [
'max_options' => 4,
'max_characters_per_option' => 50,
'min_expiration' => 300,
'max_expiration' => 2629746,
],
'translation' => [
'enabled' => false,
],
],
'registrations' => [
'enabled' => (bool) config_cache('pixelfed.open_registration'),
'approval_required' => false,
'message' => null
],
'contact' => [
'email' => config('instance.email'),
'account' => $contact
],
'rules' => $rules
];
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
}
/**
* GET /api/v2/search
*
*
* @return array
*/
public function search(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'q' => 'required|string|min:1|max:100',
'account_id' => 'nullable|string',
'max_id' => 'nullable|string',
'min_id' => 'nullable|string',
'type' => 'nullable|in:accounts,hashtags,statuses',
'exclude_unreviewed' => 'nullable',
'resolve' => 'nullable',
'limit' => 'nullable|integer|max:40',
'offset' => 'nullable|integer',
'following' => 'nullable'
]);
$mastodonMode = !$request->has('_pe');
return $this->json(SearchApiV2Service::query($request, $mastodonMode));
}
/**
* GET /api/v2/streaming/config
*
*
* @return object
*/
public function getWebsocketConfig()
{
return config('broadcasting.default') === 'pusher' ? [
'host' => config('broadcasting.connections.pusher.options.host'),
'port' => config('broadcasting.connections.pusher.options.port'),
'key' => config('broadcasting.connections.pusher.key'),
'cluster' => config('broadcasting.connections.pusher.options.cluster')
] : [];
}
/**
* POST /api/v2/media
*
*
* @return MediaTransformer
*/
public function mediaUploadV2(Request $request)
{
abort_if(!$request->user(), 403);
$this->validate($request, [
'file.*' => [
'required_without:file',
'mimetypes:' . config_cache('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'),
],
'file' => [
'required_without:file.*',
'mimetypes:' . config_cache('pixelfed.media_types'),
'max:' . config_cache('pixelfed.max_photo_size'),
],
'filter_name' => 'nullable|string|max:24',
'filter_class' => 'nullable|alpha_dash|max:24',
'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length'),
'replace_id' => 'sometimes'
]);
$user = $request->user();
if($user->last_active_at == null) {
return [];
}
if(empty($request->file('file'))) {
return response('', 422);
}
$limitKey = 'compose:rate-limit:media-upload:' . $user->id;
$limitTtl = now()->addMinutes(15);
$limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) {
$dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count();
return $dailyLimit >= 250;
});
abort_if($limitReached == true, 429);
$profile = $user->profile;
if(config_cache('pixelfed.enforce_account_limit') == true) {
$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
return Media::whereUserId($user->id)->sum('size') / 1000;
});
$limit = (int) config_cache('pixelfed.max_account_size');
if ($size >= $limit) {
abort(403, 'Account size limit reached.');
}
}
$filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null;
$filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null;
$photo = $request->file('file');
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), $mimes) == false) {
abort(403, 'Invalid or unsupported mime type.');
}
$storagePath = MediaPathService::get($user, 2);
$path = $photo->storePublicly($storagePath);
$hash = \hash_file('sha256', $photo);
$license = null;
$mime = $photo->getMimeType();
$settings = UserSetting::whereUserId($user->id)->first();
if($settings && !empty($settings->compose_settings)) {
$compose = $settings->compose_settings;
if(isset($compose['default_license']) && $compose['default_license'] != 1) {
$license = $compose['default_license'];
}
}
abort_if(MediaBlocklistService::exists($hash) == true, 451);
if($request->has('replace_id')) {
$rpid = $request->input('replace_id');
$removeMedia = Media::whereNull('status_id')
->whereUserId($user->id)
->whereProfileId($profile->id)
->where('created_at', '>', now()->subHours(2))
->find($rpid);
if($removeMedia) {
MediaDeletePipeline::dispatch($removeMedia)
->onQueue('mmo')
->delay(now()->addMinutes(15));
}
}
$media = new Media();
$media->status_id = null;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->original_sha256 = $hash;
$media->size = $photo->getSize();
$media->mime = $mime;
$media->caption = $request->input('description');
$media->filter_class = $filterClass;
$media->filter_name = $filterName;
if($license) {
$media->license = $license;
}
$media->save();
switch ($media->mime) {
case 'image/jpeg':
case 'image/png':
ImageOptimize::dispatch($media)->onQueue('mmo');
break;
case 'video/mp4':
VideoThumbnail::dispatch($media)->onQueue('mmo');
$preview_url = '/storage/no-preview.png';
$url = '/storage/no-preview.png';
break;
}
Cache::forget($limitKey);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($media, new MediaTransformer());
$res = $fractal->createData($resource)->toArray();
$res['preview_url'] = $media->url(). '?v=' . time();
$res['url'] = null;
return $this->json($res, 202);
}
}

View file

@ -7,6 +7,8 @@ use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use App\Services\BouncerService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
@ -70,8 +72,16 @@ class LoginController extends Controller
'password' => 'required|string|min:6',
];
if(config('captcha.enabled')) {
$rules['h-captcha-response'] = 'required|captcha';
if(
config('captcha.enabled') ||
config('captcha.active.login') ||
(
config('captcha.triggers.login.enabled') &&
request()->session()->has('login_attempts') &&
request()->session()->get('login_attempts') >= config('captcha.triggers.login.attempts')
)
) {
$rules['h-captcha-response'] = 'required|filled|captcha|min:5';
}
$this->validate($request, $rules);
@ -102,4 +112,28 @@ class LoginController extends Controller
$log->user_agent = $request->userAgent();
$log->save();
}
/**
* Get the failed login response instance.
*
* @param \Illuminate\Http\Request $request
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function sendFailedLoginResponse(Request $request)
{
if(config('captcha.triggers.login.enabled')) {
if ($request->session()->has('login_attempts')) {
$ct = $request->session()->get('login_attempts');
$request->session()->put('login_attempts', $ct + 1);
} else {
$request->session()->put('login_attempts', 1);
}
}
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
]);
}
}

View file

@ -137,7 +137,7 @@ class RegisterController extends Controller
'password' => 'required|string|min:'.config('pixelfed.min_password_length').'|confirmed',
];
if(config('captcha.enabled')) {
if(config('captcha.enabled') || config('captcha.active.register')) {
$rules['h-captcha-response'] = 'required|captcha';
}
@ -178,8 +178,9 @@ class RegisterController extends Controller
if(config('pixelfed.bouncer.cloud_ips.ban_signups')) {
abort_if(BouncerService::checkIp(request()->ip()), 404);
}
$hasLimit = config('pixelfed.enforce_max_users');
if($hasLimit) {
$limit = config('pixelfed.max_users');
if($limit) {
$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
if($limit <= $count) {
return redirect(route('help.instance-max-users-limit'));
@ -208,12 +209,16 @@ class RegisterController extends Controller
abort_if(BouncerService::checkIp($request->ip()), 404);
}
$hasLimit = config('pixelfed.enforce_max_users');
if($hasLimit) {
$count = User::where(function($q){ return $q->whereNull('status')->orWhereNotIn('status', ['deleted','delete']); })->count();
$limit = config('pixelfed.max_users');
if(false == config_cache('pixelfed.open_registration') || $limit && $limit <= $count) {
if($limit && $limit <= $count) {
return redirect(route('help.instance-max-users-limit'));
}
}
$this->validator($request->all())->validate();

View file

@ -673,7 +673,7 @@ class ComposeController extends Controller
$status->caption = strip_tags($request->caption);
$status->profile_id = $profile->id;
$entities = Extractor::create()->extract($status->caption);
$entities = [];
$visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
$cw = $profile->cw == true ? true : $cw;
$status->is_nsfw = $cw;

View file

@ -368,8 +368,6 @@ class DirectMessageController extends Controller
$notification->profile_id = $recipient->id;
$notification->actor_id = $profile->id;
$notification->action = 'dm';
$notification->message = $dm->toText();
$notification->rendered = $dm->toHtml();
$notification->item_id = $dm->id;
$notification->item_type = "App\DirectMessage";
$notification->save();

View file

@ -0,0 +1,298 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ImportPost;
use App\Services\ImportService;
use App\Services\StatusService;
use App\Http\Resources\ImportStatus;
use App\Follower;
use App\User;
class ImportPostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function getConfig(Request $request)
{
return [
'enabled' => config('import.instagram.enabled'),
'limits' => [
'max_posts' => config('import.instagram.limits.max_posts'),
'max_attempts' => config('import.instagram.limits.max_attempts'),
],
'allow_video_posts' => config('import.instagram.allow_video_posts'),
'permissions' => [
'admins_only' => config('import.instagram.permissions.admins_only'),
'admin_follows_only' => config('import.instagram.permissions.admin_follows_only'),
'min_account_age' => config('import.instagram.permissions.min_account_age'),
'min_follower_count' => config('import.instagram.permissions.min_follower_count'),
],
'allowed' => $this->checkPermissions($request, false)
];
}
public function getProcessingCount(Request $request)
{
abort_unless(config('import.instagram.enabled'), 404);
$processing = ImportPost::whereProfileId($request->user()->profile_id)
->whereNull('status_id')
->whereSkipMissingMedia(false)
->count();
$finished = ImportPost::whereProfileId($request->user()->profile_id)
->whereNotNull('status_id')
->whereSkipMissingMedia(false)
->count();
return response()->json([
'processing_count' => $processing,
'finished_count' => $finished,
]);
}
public function getImportedFiles(Request $request)
{
abort_unless(config('import.instagram.enabled'), 404);
return response()->json(
ImportService::getImportedFiles($request->user()->profile_id),
200,
[],
JSON_UNESCAPED_SLASHES
);
}
public function getImportedPosts(Request $request)
{
abort_unless(config('import.instagram.enabled'), 404);
return ImportStatus::collection(
ImportPost::whereProfileId($request->user()->profile_id)
->has('status')
->cursorPaginate(9)
);
}
public function store(Request $request)
{
abort_unless(config('import.instagram.enabled'), 404);
$this->checkPermissions($request);
$uid = $request->user()->id;
$pid = $request->user()->profile_id;
foreach($request->input('files') as $file) {
$media = $file['media'];
$c = collect($media);
$postHash = hash('sha256', $c->toJson());
$exts = $c->map(function($m) {
$fn = last(explode('/', $m['uri']));
return last(explode('.', $fn));
});
$postType = 'photo';
if($exts->count() > 1) {
if($exts->contains('mp4')) {
if($exts->contains('jpg', 'png')) {
$postType = 'photo:video:album';
} else {
$postType = 'video:album';
}
} else {
$postType = 'photo:album';
}
} else {
if(in_array($exts[0], ['jpg', 'png'])) {
$postType = 'photo';
} else if(in_array($exts[0], ['mp4'])) {
$postType = 'video';
}
}
$ip = new ImportPost;
$ip->user_id = $uid;
$ip->profile_id = $pid;
$ip->post_hash = $postHash;
$ip->service = 'instagram';
$ip->post_type = $postType;
$ip->media_count = $c->count();
$ip->media = $c->map(function($m) {
return [
'uri' => $m['uri'],
'title' => $m['title'],
'creation_timestamp' => $m['creation_timestamp']
];
})->toArray();
$ip->caption = $c->count() > 1 ? $file['title'] : $ip->media[0]['title'];
$ip->filename = last(explode('/', $ip->media[0]['uri']));
$ip->metadata = $c->map(function($m) {
return [
'uri' => $m['uri'],
'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
];
})->toArray();
$ip->creation_date = $c->count() > 1 ? now()->parse($file['creation_timestamp']) : now()->parse($media[0]['creation_timestamp']);
$ip->creation_year = now()->parse($ip->creation_date)->format('y');
$ip->creation_month = now()->parse($ip->creation_date)->format('m');
$ip->creation_day = now()->parse($ip->creation_date)->format('d');
$ip->save();
ImportService::getImportedFiles($pid, true);
ImportService::getPostCount($pid, true);
}
return [
'msg' => 'Success'
];
}
public function storeMedia(Request $request)
{
abort_unless(config('import.instagram.enabled'), 404);
$this->checkPermissions($request);
$mimes = config('import.instagram.allow_video_posts') ? 'mimetypes:image/png,image/jpeg,video/mp4' : 'mimetypes:image/png,image/jpeg';
$this->validate($request, [
'file' => 'required|array|max:10',
'file.*' => [
'required',
'file',
$mimes,
'max:' . config('pixelfed.max_photo_size')
]
]);
foreach($request->file('file') as $file) {
$fileName = $file->getClientOriginalName();
$file->storeAs('imports/' . $request->user()->id . '/', $fileName);
}
ImportService::getImportedFiles($request->user()->profile_id, true);
return [
'msg' => 'Success'
];
}
protected function checkPermissions($request, $abortOnFail = true)
{
$user = $request->user();
if($abortOnFail) {
abort_unless(config('import.instagram.enabled'), 404);
}
if($user->is_admin) {
if(!$abortOnFail) {
return true;
} else {
return;
}
}
$admin = User::whereIsAdmin(true)->first();
if(config('import.instagram.permissions.admins_only')) {
if($abortOnFail) {
abort_unless($user->is_admin, 404, 'Only admins can use this feature.');
} else {
if(!$user->is_admin) {
return false;
}
}
}
if(config('import.instagram.permissions.admin_follows_only')) {
$exists = Follower::whereProfileId($admin->profile_id)
->whereFollowingId($user->profile_id)
->exists();
if($abortOnFail) {
abort_unless(
$exists,
404,
'Only admins, and accounts they follow can use this feature'
);
} else {
if(!$exists) {
return false;
}
}
}
if(config('import.instagram.permissions.min_account_age')) {
$res = $user->created_at->lt(
now()->subDays(config('import.instagram.permissions.min_account_age'))
);
if($abortOnFail) {
abort_unless(
$res,
404,
'Your account is too new to use this feature'
);
} else {
if(!$res) {
return false;
}
}
}
if(config('import.instagram.permissions.min_follower_count')) {
$res = Follower::whereFollowingId($user->profile_id)->count() >= config('import.instagram.permissions.min_follower_count');
if($abortOnFail) {
abort_unless(
$res,
404,
'You don\'t have enough followers to use this feature'
);
} else {
if(!$res) {
return false;
}
}
}
if(intval(config('import.instagram.limits.max_posts')) > 0) {
$res = ImportService::getPostCount($user->profile_id) >= intval(config('import.instagram.limits.max_posts'));
if($abortOnFail) {
abort_if(
$res,
404,
'You have reached the limit of post imports and cannot import any more posts'
);
} else {
if($res) {
return false;
}
}
}
if(intval(config('import.instagram.limits.max_attempts')) > 0) {
$res = ImportService::getAttempts($user->profile_id) >= intval(config('import.instagram.limits.max_attempts'));
if($abortOnFail) {
abort_if(
$res,
404,
'You have reached the limit of post import attempts and cannot import any more posts'
);
} else {
if($res) {
return false;
}
}
}
if(!$abortOnFail) {
return true;
}
}
}

View file

@ -25,7 +25,61 @@ class InstanceActorController extends Controller
public function outbox()
{
$res = json_encode([
'@context' => 'https://www.w3.org/ns/activitystreams',
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
[
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"toot" => "http://joinmastodon.org/ns#",
"featured" => [
"@id" => "toot:featured",
"@type" => "@id"
],
"featuredTags" => [
"@id" => "toot:featuredTags",
"@type" => "@id"
],
"alsoKnownAs" => [
"@id" => "as:alsoKnownAs",
"@type" => "@id"
],
"movedTo" => [
"@id" => "as:movedTo",
"@type" => "@id"
],
"schema" => "http://schema.org#",
"PropertyValue" => "schema:PropertyValue",
"value" => "schema:value",
"discoverable" => "toot:discoverable",
"Device" => "toot:Device",
"Ed25519Signature" => "toot:Ed25519Signature",
"Ed25519Key" => "toot:Ed25519Key",
"Curve25519Key" => "toot:Curve25519Key",
"EncryptedMessage" => "toot:EncryptedMessage",
"publicKeyBase64" => "toot:publicKeyBase64",
"deviceId" => "toot:deviceId",
"claim" => [
"@type" => "@id",
"@id" => "toot:claim"
],
"fingerprintKey" => [
"@type" => "@id",
"@id" => "toot:fingerprintKey"
],
"identityKey" => [
"@type" => "@id",
"@id" => "toot:identityKey"
],
"devices" => [
"@type" => "@id",
"@id" => "toot:devices"
],
"messageFranking" => "toot:messageFranking",
"messageType" => "toot:messageType",
"cipherText" => "toot:cipherText",
"suspended" => "toot:suspended"
]
],
'id' => config('app.url') . '/i/actor/outbox',
'type' => 'OrderedCollection',
'totalItems' => 0,

View file

@ -15,7 +15,7 @@ class LandingController extends Controller
return redirect('/');
}
abort_if(config('instance.landing.show_directory') == false, 404);
abort_if(config_cache('instance.landing.show_directory') == false, 404);
return view('site.index');
}
@ -26,14 +26,14 @@ class LandingController extends Controller
return redirect('/');
}
abort_if(config('instance.landing.show_explore') == false, 404);
abort_if(config_cache('instance.landing.show_explore') == false, 404);
return view('site.index');
}
public function getDirectoryApi(Request $request)
{
abort_if(config('instance.landing.show_directory') == false, 404);
abort_if(config_cache('instance.landing.show_directory') == false, 404);
return DirectoryProfile::collection(
Profile::whereNull('domain')

View file

@ -3,18 +3,10 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth, Storage, URL;
use App\Media;
use Image as Intervention;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
class MediaController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(Request $request)
{
//return view('settings.drive.index');
@ -24,4 +16,16 @@ class MediaController extends Controller
{
abort(400, 'Endpoint deprecated');
}
public function fallbackRedirect(Request $request, $pid, $mhash, $uhash, $f)
{
abort_if(!config_cache('pixelfed.cloud_storage'), 404);
$path = 'public/m/_v2/' . $pid . '/' . $mhash . '/' . $uhash . '/' . $f;
$media = Media::whereProfileId($pid)
->whereMediaPath($path)
->whereNotNull('cdn_url')
->firstOrFail();
return redirect()->away($media->cdn_url);
}
}

View file

@ -7,11 +7,13 @@ use Auth;
use Cache;
use DB;
use View;
use App\AccountInterstitial;
use App\Follower;
use App\FollowRequest;
use App\Profile;
use App\Story;
use App\User;
use App\UserSetting;
use App\UserFilter;
use League\Fractal;
use App\Services\AccountService;
@ -42,9 +44,22 @@ class ProfileController extends Controller
->whereUsername($username)
->firstOrFail();
if($request->wantsJson() && config_cache('federation.activitypub.enabled')) {
return $this->showActivityPub($request, $user);
}
$aiCheck = Cache::remember('profile:ai-check:spam-login:' . $user->id, 86400, function() use($user) {
$exists = AccountInterstitial::whereUserId($user->user_id)->where('is_spam', 1)->count();
if($exists) {
return true;
}
return false;
});
if($aiCheck) {
return redirect('/login');
}
return $this->buildProfile($request, $user);
}
@ -207,7 +222,37 @@ class ProfileController extends Controller
abort_if(!$profile || $profile['locked'] || !$profile['local'], 404);
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 43200, function() use($pid, $profile) {
$aiCheck = Cache::remember('profile:ai-check:spam-login:' . $profile['id'], 86400, function() use($profile) {
$uid = User::whereProfileId($profile['id'])->first();
if(!$uid) {
return true;
}
$exists = AccountInterstitial::whereUserId($uid->id)->where('is_spam', 1)->count();
if($exists) {
return true;
}
return false;
});
abort_if($aiCheck, 404);
$enabled = Cache::remember('profile:atom:enabled:' . $profile['id'], 84600, function() use ($profile) {
$uid = User::whereProfileId($profile['id'])->first();
if(!$uid) {
return false;
}
$settings = UserSetting::whereUserId($uid->id)->first();
if(!$settings) {
return false;
}
return $settings->show_atom;
});
abort_if(!$enabled, 404);
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 900, function() use($pid, $profile) {
$items = DB::table('statuses')
->whereProfileId($pid)
->whereVisibility('public')
@ -234,7 +279,7 @@ class ProfileController extends Controller
return compact('items', 'permalink', 'headers');
});
abort_if(!$data, 404);
abort_if(!$data || !isset($data['items']) || !isset($data['permalink']), 404);
return response()
->view('atom.user',
[
@ -274,6 +319,19 @@ class ProfileController extends Controller
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
$aiCheck = Cache::remember('profile:ai-check:spam-login:' . $profile->id, 86400, function() use($profile) {
$exists = AccountInterstitial::whereUserId($profile->user_id)->where('is_spam', 1)->count();
if($exists) {
return true;
}
return false;
});
if($aiCheck) {
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
if(AccountService::canEmbed($profile->user_id) == false) {
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}

View file

@ -20,11 +20,13 @@ trait PrivacySettings
public function privacy()
{
$settings = Auth::user()->settings;
$is_private = Auth::user()->profile->is_private;
$user = Auth::user();
$settings = $user->settings;
$profile = $user->profile;
$is_private = $profile->is_private;
$settings['is_private'] = (bool) $is_private;
return view('settings.privacy', compact('settings'));
return view('settings.privacy', compact('settings', 'profile'));
}
public function privacyStore(Request $request)
@ -37,6 +39,7 @@ trait PrivacySettings
'public_dm',
'show_profile_follower_count',
'show_profile_following_count',
'show_atom',
];
$profile->is_suggestable = $request->input('is_suggestable') == 'on';
@ -80,6 +83,7 @@ trait PrivacySettings
Cache::forget('user:account:id:' . $profile->user_id);
Cache::forget('profile:follower_count:' . $profile->id);
Cache::forget('profile:following_count:' . $profile->id);
Cache::forget('profile:atom:enabled:' . $profile->id);
Cache::forget('profile:embed:' . $profile->id);
Cache::forget('pf:acct:settings:hidden-followers:' . $profile->id);
Cache::forget('pf:acct:settings:hidden-following:' . $profile->id);

View file

@ -81,14 +81,12 @@ class SettingsController extends Controller
public function dataImport()
{
abort_if(!config_cache('pixelfed.import.instagram.enabled'), 404);
return view('settings.import.home');
}
public function dataImportInstagram()
{
abort_if(!config_cache('pixelfed.import.instagram.enabled'), 404);
return view('settings.import.instagram.home');
abort(404);
}
public function developers()

View file

@ -115,10 +115,25 @@ class StatusController extends Controller
->whereIsPrivate(false)
->whereUsername($username)
->first();
if(!$profile) {
$content = view('status.embed-removed');
return response($content)->header('X-Frame-Options', 'ALLOWALL');
}
$aiCheck = Cache::remember('profile:ai-check:spam-login:' . $profile->id, 86400, function() use($profile) {
$exists = AccountInterstitial::whereUserId($profile->user_id)->where('is_spam', 1)->count();
if($exists) {
return true;
}
return false;
});
if($aiCheck) {
$res = view('status.embed-removed');
return response($res)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
$status = Status::whereProfileId($profile->id)
->whereNull('uri')
->whereScope('public')

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Requests\Status\StoreStatusEditRequest;
use App\Status;
use App\Models\StatusEdit;
use Purify;
use App\Services\Status\UpdateStatusService;
use App\Services\StatusService;
use App\Util\Lexer\Autolink;
use App\Jobs\StatusPipeline\StatusLocalUpdateActivityPubDeliverPipeline;
class StatusEditController extends Controller
{
public function __construct()
{
$this->middleware('auth');
abort_if(!config('exp.pue'), 404, 'Post editing is not enabled on this server.');
}
public function store(StoreStatusEditRequest $request, $id)
{
$validated = $request->validated();
$status = Status::findOrFail($id);
abort_if(StatusEdit::whereStatusId($status->id)->count() >= 10, 400, 'You cannot edit your post more than 10 times.');
$res = UpdateStatusService::call($status, $validated);
$status = Status::findOrFail($id);
StatusLocalUpdateActivityPubDeliverPipeline::dispatch($status)->delay(now()->addMinutes(1));
return $res;
}
public function history(Request $request, $id)
{
abort_if(!$request->user(), 403);
$status = Status::whereNull('reblog_of_id')->findOrFail($id);
abort_if(!in_array($status->scope, ['public', 'unlisted']), 403);
if(!$status->edits()->count()) {
return [];
}
$cached = StatusService::get($status->id, false);
$res = $status->edits->map(function($edit) use($cached) {
$caption = nl2br(strip_tags(str_replace('</p>', "\n", $edit->caption)));
return [
'content' => Autolink::create()->autolink($caption),
'spoiler_text' => $edit->spoiler_text,
'sensitive' => (bool) $edit->is_nsfw,
'created_at' => str_replace('+00:00', 'Z', $edit->created_at->format(DATE_RFC3339_EXTENDED)),
'account' => $cached['account'],
'media_attachments' => $cached['media_attachments'],
'emojis' => $cached['emojis'],
];
})->reverse()->values()->toArray();
return $res;
}
}

View file

@ -328,8 +328,6 @@ class StoryApiV1Controller extends Controller
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$request->user()->username} commented on story";
$n->rendered = "{$request->user()->username} commented on story";
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');

View file

@ -442,8 +442,6 @@ class StoryComposeController extends Controller
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:react';
$n->message = "{$request->user()->username} reacted to your story";
$n->rendered = "{$request->user()->username} reacted to your story";
$n->save();
} else {
StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
@ -516,8 +514,6 @@ class StoryComposeController extends Controller
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$request->user()->username} commented on story";
$n->rendered = "{$request->user()->username} commented on story";
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');

View file

@ -37,7 +37,8 @@ class StoryController extends StoryComposeController
$pid = $request->user()->profile_id;
if(config('database.default') == 'pgsql') {
$s = Story::select('stories.*', 'followers.following_id')
$s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, 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)
@ -51,14 +52,38 @@ class StoryController extends StoryComposeController
return $r;
})
->unique('profile_id');
});
} else {
$s = Story::select('stories.*', 'followers.following_id')
$s = Cache::remember('pf:stories:recent-by-id:' . $pid, 900, 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)
->groupBy('followers.following_id')
->orderByDesc('id')
->get();
});
}
$self = Cache::remember('pf:stories:recent-self:' . $pid, 21600, function() use($pid) {
return Story::whereProfileId($pid)
->whereActive(true)
->orderByDesc('id')
->limit(1)
->get()
->map(function($s) use($pid) {
$r = new \StdClass;
$r->id = $s->id;
$r->profile_id = $pid;
$r->type = $s->type;
$r->path = $s->path;
return $r;
});
});
if($self->count()) {
$s->prepend($self->first());
}
$res = $s->map(function($s) use($pid) {
@ -93,7 +118,7 @@ class StoryController extends StoryComposeController
$profile = Profile::findOrFail($id);
if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
return [];
return abort([], 403);
}
$stories = Story::whereProfileId($profile->id)
@ -164,7 +189,6 @@ class StoryController extends StoryComposeController
$publicOnly = (bool) $profile->followedBy($authed);
abort_if(!$publicOnly, 403);
$v = StoryView::firstOrCreate([
'story_id' => $id,
'profile_id' => $authed->id

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\Account\AccountAppSettingsService;
use App\Http\Requests\StoreUserAppSettings;
use App\Models\UserAppSettings;
use App\Http\Resources\UserAppSettingsResource;
class UserAppSettingsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function get(Request $request)
{
abort_if(!$request->user(), 403);
$settings = UserAppSettings::whereUserId($request->user()->id)->first();
if(!$settings) {
return [
'id' => (string) $request->user()->profile_id,
'username' => $request->user()->username,
'updated_at' => null,
'common' => AccountAppSettingsService::default(),
];
}
return new UserAppSettingsResource($settings);
}
public function store(StoreUserAppSettings $request)
{
$res = UserAppSettings::updateOrCreate([
'user_id' => $request->user()->id,
],[
'profile_id' => $request->user()->profile_id,
'common' => $request->common,
]
);
return new UserAppSettingsResource($res);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DeprecatedEndpoint
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
abort_if(now()->gt('Jan 01, 2024'), 404);
$response = $next($request);
$link = $response->headers->has('link') ? $response->headers->get('link') . ',<https://pixelfed.org/kb/10404>; rel="deprecation"' : '<https://pixelfed.org/kb/10404>; rel="deprecation"';
$response->withHeaders([
'Deprecation' => 'Sat, 01 Jul 2023 00:00:00 GMT',
'Sunset' => 'Mon, 01 Jan 2024 00:00:00 GMT',
'Link' => $link
]);
return $response;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Requests\Status;
use Illuminate\Foundation\Http\FormRequest;
use App\Media;
use App\Status;
use Closure;
class StoreStatusEditRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$profile = $this->user()->profile;
if($profile->status != null) {
return false;
}
if($profile->unlisted == true && $profile->cw == true) {
return false;
}
$types = [
"photo",
"photo:album",
"photo:video:album",
"reply",
"text",
"video",
"video:album"
];
$scopes = ['public', 'unlisted', 'private'];
$status = Status::whereNull('reblog_of_id')->whereIn('type', $types)->whereIn('scope', $scopes)->find($this->route('id'));
return $status && $this->user()->profile_id === $status->profile_id;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'status' => 'sometimes|max:'.config('pixelfed.max_caption_length', 500),
'spoiler_text' => 'nullable|string|max:140',
'sensitive' => 'sometimes|boolean',
'media_ids' => [
'nullable',
'required_without:status',
'array',
'max:' . config('pixelfed.max_album_length'),
function (string $attribute, mixed $value, Closure $fail) {
Media::whereProfileId($this->user()->profile_id)
->where(function($query) {
return $query->whereNull('status_id')
->orWhere('status_id', '=', $this->route('id'));
})
->findOrFail($value);
},
],
'location' => 'sometimes|nullable',
'location.id' => 'sometimes|integer|min:1|max:128769',
'location.country' => 'required_with:location.id',
'location.name' => 'required_with:location.id',
];
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserAppSettings extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
if(!$this->user() || $this->user()->status) {
return false;
}
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'common' => 'required|array',
'common.timelines.show_public' => 'required|boolean',
'common.timelines.show_network' => 'required|boolean',
'common.timelines.hide_likes_shares' => 'required|boolean',
'common.media.hide_public_behind_cw' => 'required|boolean',
'common.media.always_show_cw' => 'required|boolean',
'common.media.show_alt_text' => 'required|boolean',
'common.appearance.links_use_in_app_browser' => 'required|boolean',
'common.appearance.theme' => 'required|string|in:light,dark,system',
];
}
/**
* Prepare inputs for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'common' => array_merge(
$this->input('common'),
[
'timelines' => [
'show_public' => $this->toBoolean($this->input('common.timelines.show_public')),
'show_network' => $this->toBoolean($this->input('common.timelines.show_network')),
'hide_likes_shares' => $this->toBoolean($this->input('common.timelines.hide_likes_shares'))
],
'media' => [
'hide_public_behind_cw' => $this->toBoolean($this->input('common.media.hide_public_behind_cw')),
'always_show_cw' => $this->toBoolean($this->input('common.media.always_show_cw')),
'show_alt_text' => $this->toBoolean($this->input('common.media.show_alt_text')),
],
'appearance' => [
'links_use_in_app_browser' => $this->toBoolean($this->input('common.appearance.links_use_in_app_browser')),
'theme' => $this->input('common.appearance.theme'),
]
]
)
]);
}
/**
* Convert to boolean
*
* @param $booleable
* @return boolean
*/
private function toBoolean($booleable)
{
return filter_var($booleable, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
}

View file

@ -23,9 +23,11 @@ class AdminUser extends JsonResource
'name' => $this->name,
'username' => $this->username,
'is_admin' => (bool) $this->is_admin,
'email' => $this->email,
'email_verified_at' => $this->email_verified_at,
'two_factor_enabled' => (bool) $this->{'2fa_enabled'},
'register_source' => $this->register_source,
'app_register_ip' => $this->app_register_ip,
'last_active_at' => $this->last_active_at,
'created_at' => $this->created_at,
];

View file

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

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources\MastoApi;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\JsonResponse;
use Cache;
use App\Services\HashtagService;
class FollowedTagResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$tag = HashtagService::get($this->hashtag_id);
if(!$tag || !isset($tag['name'])) {
return [];
}
return [
'name' => $tag['name'],
'url' => config('app.url') . '/i/web/hashtag/' . $tag['slug'],
'history' => [],
'following' => true,
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserAppSettingsResource extends JsonResource
{
public static $wrap = null;
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => (string) $this->profile_id,
'username' => $request->user()->username,
'updated_at' => str_replace('+00:00', 'Z', $this->updated_at->format(DATE_RFC3339_EXTENDED)),
'common' => $this->common,
];
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Jobs\AutospamPipeline;
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\Lexer\Classifier;
use App\AccountInterstitial;
use App\Profile;
use App\Status;
use Illuminate\Support\Facades\Storage;
use App\Services\AutospamService;
class AutospamPretrainNonSpamPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $classifier;
public $accounts;
/**
* Create a new job instance.
*/
public function __construct($accounts)
{
$this->accounts = $accounts;
$this->classifier = new Classifier();
}
/**
* Execute the job.
*/
public function handle(): void
{
$classifier = $this->classifier;
$accounts = $this->accounts;
foreach($accounts as $acct) {
Status::whereNotNull('caption')
->whereScope('public')
->whereProfileId($acct->id)
->inRandomOrder()
->take(400)
->pluck('caption')
->each(function($c) use ($classifier) {
$classifier->learn($c, 'ham');
});
}
Storage::put(AutospamService::MODEL_HAM_PATH, $classifier->export());
AutospamUpdateCachedDataPipeline::dispatch()->delay(5);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Jobs\AutospamPipeline;
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\Lexer\Classifier;
use App\AccountInterstitial;
use App\Status;
use Illuminate\Support\Facades\Storage;
use App\Services\AutospamService;
class AutospamPretrainPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $classifier;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->classifier = new Classifier();
}
/**
* Execute the job.
*/
public function handle(): void
{
$classifier = $this->classifier;
$aiCount = AccountInterstitial::whereItemType('App\Status')
->whereIsSpam(true)
->count();
if($aiCount < 100) {
return;
}
AccountInterstitial::whereItemType('App\Status')
->whereIsSpam(true)
->inRandomOrder()
->take(config('autospam.nlp.spam_sample_limit'))
->pluck('item_id')
->each(function ($ai) use($classifier) {
$status = Status::whereNotNull('caption')->find($ai);
if(!$status) {
return;
}
$classifier->learn($status->caption, 'spam');
});
Storage::put(AutospamService::MODEL_SPAM_PATH, $classifier->export());
AutospamUpdateCachedDataPipeline::dispatch()->delay(5);
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace App\Jobs\AutospamPipeline;
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\Models\AutospamCustomTokens;
use Illuminate\Support\Facades\Storage;
use App\Services\AutospamService;
use Cache;
class AutospamUpdateCachedDataPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct()
{
}
/**
* Execute the job.
*/
public function handle(): void
{
$spamExists = Storage::exists(AutospamService::MODEL_SPAM_PATH);
if($spamExists) {
$spam = json_decode(Storage::get(AutospamService::MODEL_SPAM_PATH), true);
} else {
$spam = [
'documents' => [
'spam' => 0
],
'words' => [
'spam' => []
]
];
}
$newSpam = AutospamCustomTokens::whereCategory('spam')->get();
foreach($newSpam as $ns) {
$key = strtolower($ns->token);
if(isset($spam['words']['spam'][$key])) {
$spam['words']['spam'][$key] = $spam['words']['spam'][$key] + $ns->weight;
} else {
$spam['words']['spam'][$key] = $ns->weight;
}
}
$newSpamCount = count($spam['words']['spam']);
if($newSpamCount) {
$spam['documents']['spam'] = $newSpamCount;
arsort($spam['words']['spam']);
Storage::put(AutospamService::MODEL_SPAM_PATH, json_encode($spam, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT));
}
$hamExists = Storage::exists(AutospamService::MODEL_HAM_PATH);
if($hamExists) {
$ham = json_decode(Storage::get(AutospamService::MODEL_HAM_PATH), true);
} else {
$ham = [
'documents' => [
'ham' => 0
],
'words' => [
'ham' => []
]
];
}
$newHam = AutospamCustomTokens::whereCategory('ham')->get();
foreach($newHam as $ns) {
$key = strtolower($ns->token);
if(isset($spam['words']['ham'][$key])) {
$ham['words']['ham'][$key] = $ham['words']['ham'][$key] + $ns->weight;
} else {
$ham['words']['ham'][$key] = $ns->weight;
}
}
$newHamCount = count($ham['words']['ham']);
if($newHamCount) {
$ham['documents']['ham'] = $newHamCount;
arsort($ham['words']['ham']);
Storage::put(AutospamService::MODEL_HAM_PATH, json_encode($ham, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT));
}
if($newSpamCount && $newHamCount) {
$combined = [
'documents' => [
'spam' => $newSpamCount,
'ham' => $newHamCount,
],
'words' => [
'spam' => $spam['words']['spam'],
'ham' => $ham['words']['ham']
]
];
Storage::put(AutospamService::MODEL_FILE_PATH, json_encode($combined, JSON_PRETTY_PRINT,JSON_UNESCAPED_SLASHES));
}
Cache::forget(AutospamService::MODEL_CACHE_KEY);
Cache::forget(AutospamService::CHCKD_CACHE_KEY);
}
}

View file

@ -60,7 +60,7 @@ class RemoteAvatarFetch implements ShouldQueue
{
$profile = $this->profile;
if(config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) {
if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
return 1;
}
@ -108,7 +108,7 @@ class RemoteAvatarFetch implements ShouldQueue
$avatar->remote_url = $icon['url'];
$avatar->save();
MediaStorageService::avatar($avatar, config_cache('pixelfed.cloud_storage') == false);
MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false);
return 1;
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Jobs\AvatarPipeline;
use App\Avatar;
use App\Profile;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Str;
use Zttp\Zttp;
use App\Http\Controllers\AvatarController;
use Cache;
use Storage;
use Log;
use Illuminate\Http\File;
use App\Services\AccountService;
use App\Services\MediaStorageService;
use App\Services\ActivityPubFetchService;
class RemoteAvatarFetchFromUrl implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile;
protected $url;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 1;
public $timeout = 300;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Profile $profile, $url)
{
$this->profile = $profile;
$this->url = $url;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$profile = $this->profile;
Cache::forget('avatar:' . $profile->id);
AccountService::del($profile->id);
if(boolval(config_cache('pixelfed.cloud_storage')) == false && boolval(config_cache('federation.avatars.store_local')) == false) {
return 1;
}
if($profile->domain == null || $profile->private_key) {
return 1;
}
$avatar = Avatar::whereProfileId($profile->id)->first();
if(!$avatar) {
$avatar = new Avatar;
$avatar->profile_id = $profile->id;
$avatar->is_remote = true;
$avatar->remote_url = $this->url;
$avatar->save();
} else {
$avatar->remote_url = $this->url;
$avatar->is_remote = true;
$avatar->save();
}
MediaStorageService::avatar($avatar, boolval(config_cache('pixelfed.cloud_storage')) == false, true);
return 1;
}
}

View file

@ -60,10 +60,12 @@ class CommentPipeline implements ShouldQueue
$actor = $comment->profile;
if(config('database.default') === 'mysql') {
$exp = DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)");
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$count = DB::select($expQuery, [ 'kid' => $status->id ]);
$status->reply_count = count($count);
// todo: refactor
// $exp = DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)");
// $expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
// $count = DB::select($expQuery, [ 'kid' => $status->id ]);
// $status->reply_count = count($count);
$status->reply_count = $status->reply_count + 1;
$status->save();
} else {
$status->reply_count = $status->reply_count + 1;
@ -94,8 +96,6 @@ class CommentPipeline implements ShouldQueue
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'comment';
$notification->message = $comment->replyToText();
$notification->rendered = $comment->replyToHtml();
$notification->item_id = $comment->id;
$notification->item_type = "App\Status";
$notification->save();

View file

@ -97,8 +97,6 @@ class FollowPipeline implements ShouldQueue
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'follow';
$notification->message = $follower->toText();
$notification->rendered = $follower->toHtml();
$notification->item_id = $target->id;
$notification->item_type = "App\Profile";
$notification->save();

View file

@ -84,8 +84,6 @@ class LikePipeline implements ShouldQueue
$notification->profile_id = $status->profile_id;
$notification->actor_id = $actor->id;
$notification->action = 'like';
$notification->message = $like->toText($status->in_reply_to_id ? 'comment' : 'post');
$notification->rendered = $like->toHtml($status->in_reply_to_id ? 'comment' : 'post');
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();

View file

@ -67,10 +67,6 @@ class MentionPipeline implements ShouldQueue
'action' => 'mention',
'item_type' => 'App\Status',
'item_id' => $status->id,
],
[
'message' => $mention->toText(),
'rendered' => $mention->toHtml()
]
);

View file

@ -0,0 +1,94 @@
<?php
namespace App\Jobs\ProfilePipeline;
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\Avatar;
use App\Profile;
use App\Util\ActivityPub\Helpers;
use Cache;
use Purify;
use App\Jobs\AvatarPipeline\RemoteAvatarFetchFromUrl;
use App\Util\Lexer\Autolink;
class HandleUpdateActivity implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $payload;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($payload)
{
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
$payload = $this->payload;
if(empty($payload) || !isset($payload['actor'])) {
return;
}
$profile = Profile::whereRemoteUrl($payload['actor'])->first();
if(!$profile || $profile->domain === null || $profile->private_key) {
return;
}
if($profile->sharedInbox == null || $profile->sharedInbox != $payload['object']['endpoints']['sharedInbox']) {
$profile->sharedInbox = $payload['object']['endpoints']['sharedInbox'];
}
if($profile->public_key !== $payload['object']['publicKey']['publicKeyPem']) {
$profile->public_key = $payload['object']['publicKey']['publicKeyPem'];
}
if($profile->bio !== $payload['object']['summary']) {
$len = strlen(strip_tags($payload['object']['summary']));
if($len) {
if($len > 500) {
$updated = strip_tags($payload['object']['summary']);
$updated = substr($updated, 0, config('pixelfed.max_bio_length'));
$profile->bio = Autolink::create()->autolink($updated);
} else {
$profile->bio = Purify::clean($payload['object']['summary']);
}
} else {
$profile->bio = null;
}
}
if($profile->name !== $payload['object']['name']) {
$profile->name = Purify::clean(substr($payload['object']['name'], 0, config('pixelfed.max_name_length')));
}
if($profile->isDirty()) {
$profile->save();
}
if(isset($payload['object']['icon']) && isset($payload['object']['icon']['url'])) {
RemoteAvatarFetchFromUrl::dispatch($profile, $payload['object']['icon']['url'])->onQueue('low');
} else {
$profile->avatar->update(['remote_url' => null]);
Cache::forget('avatar:' . $profile->id);
}
return;
}
}

View file

@ -76,10 +76,6 @@ class SharePipeline implements ShouldQueue
'action' => 'share',
'item_type' => 'App\Status',
'item_id' => $status->reblog_of_id ?? $status->id,
],
[
'message' => $status->shareToText(),
'rendered' => $status->shareToHtml()
]
);

View file

@ -3,6 +3,7 @@
namespace App\Jobs\StatusPipeline;
use Cache, Log;
use App\Profile;
use App\Status;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -52,12 +53,36 @@ class StatusActivityPubDeliver implements ShouldQueue
$status = $this->status;
$profile = $status->profile;
// ignore group posts
// if($status->group_id != null) {
// return;
// }
if($status->local == false || $status->url || $status->uri) {
return;
}
$audience = $status->profile->getAudienceInbox();
$parentInbox = [];
$mentions = $status->mentions
->filter(function($f) { return $f->domain !== null;})
->values()
->map(function($m) { return $m->sharedInbox ?? $m->inbox_url; })
->toArray();
if($status->in_reply_to_profile_id) {
$parent = Profile::find($status->in_reply_to_profile_id);
if($parent && $parent->domain !== null) {
$parentInbox = [
$parent->sharedInbox ?? $parent->inbox_url
];
}
}
$audience = array_values(array_unique(array_merge($audience, $mentions, $parentInbox)));
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
// Return on profiles with no remote followers
return;

View file

@ -89,7 +89,6 @@ class StatusEntityLexer implements ShouldQueue
DB::transaction(function () {
$status = $this->status;
$status->rendered = nl2br($this->autolink);
$status->entities = json_encode($this->entities);
$status->save();
});
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Jobs\StatusPipeline;
use Cache, Log;
use App\Status;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\UpdateNote;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
class StatusLocalUpdateActivityPubDeliverPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$profile = $status->profile;
// ignore group posts
// if($status->group_id != null) {
// return;
// }
if($status->local == false || $status->url || $status->uri) {
return;
}
$audience = $status->profile->getAudienceInbox();
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
// Return on profiles with no remote followers
return;
}
switch($status->type) {
case 'poll':
// Polls not yet supported
return;
break;
default:
$activitypubObject = new UpdateNote();
break;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, $activitypubObject);
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$requests = function($audience) use ($client, $activity, $profile, $payload, $userAgent) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$promise->wait();
}
}

View file

@ -0,0 +1,173 @@
<?php
namespace App\Jobs\StatusPipeline;
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\Media;
use App\ModLog;
use App\Profile;
use App\Status;
use App\Models\StatusEdit;
use App\Services\StatusService;
use Purify;
use Illuminate\Support\Facades\Http;
class StatusRemoteUpdatePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $activity;
/**
* Create a new job instance.
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Execute the job.
*/
public function handle(): void
{
$activity = $this->activity;
$status = Status::with('media')->whereObjectUrl($activity['id'])->first();
if(!$status) {
return;
}
$this->createPreviousEdit($status);
$this->updateMedia($status, $activity);
$this->updateImmediateAttributes($status, $activity);
$this->createEdit($status, $activity);
}
protected function createPreviousEdit($status)
{
if(!$status->edits()->count()) {
StatusEdit::create([
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'caption' => $status->caption,
'spoiler_text' => $status->cw_summary,
'is_nsfw' => $status->is_nsfw,
'ordered_media_attachment_ids' => $status->media()->orderBy('order')->pluck('id')->toArray(),
'created_at' => $status->created_at
]);
}
}
protected function updateMedia($status, $activity)
{
if(!isset($activity['attachment'])) {
return;
}
$ogm = $status->media->count() ? $status->media()->orderBy('order')->get() : collect([]);
$nm = collect($activity['attachment'])->filter(function($nm) {
return isset(
$nm['type'],
$nm['mediaType'],
$nm['url']
) &&
in_array($nm['type'], ['Document', 'Image', 'Video']) &&
in_array($nm['mediaType'], explode(',', config('pixelfed.media_types')));
});
// Skip when no media
if(!$ogm->count() && !$nm->count()) {
return;
}
Media::whereProfileId($status->profile_id)
->whereStatusId($status->id)
->update([
'status_id' => null
]);
$nm->each(function($n, $key) use($status) {
$res = Http::retry(3, 100, throw: false)->head($n['url']);
if(!$res->successful()) {
return;
}
if(!in_array($res->header('content-type'), explode(',',config('pixelfed.media_types')))) {
return;
}
$m = new Media;
$m->status_id = $status->id;
$m->profile_id = $status->profile_id;
$m->remote_media = true;
$m->media_path = $n['url'];
$m->mime = $res->header('content-type');
$m->size = $res->hasHeader('content-length') ? $res->header('content-length') : null;
$m->caption = isset($n['name']) && !empty($n['name']) ? Purify::clean($n['name']) : null;
$m->remote_url = $n['url'];
$m->blurhash = isset($n['blurhash']) && (strlen($n['blurhash']) < 50) ? $n['blurhash'] : null;
$m->width = isset($n['width']) && !empty($n['width']) ? $n['width'] : null;
$m->height = isset($n['height']) && !empty($n['height']) ? $n['height'] : null;
$m->skip_optimize = true;
$m->order = $key + 1;
$m->save();
});
}
protected function updateImmediateAttributes($status, $activity)
{
if(isset($activity['content'])) {
$status->caption = strip_tags($activity['content']);
$status->rendered = Purify::clean($activity['content']);
}
if(isset($activity['sensitive'])) {
if((bool) $activity['sensitive'] == false) {
$status->is_nsfw = false;
$exists = ModLog::whereObjectType('App\Status::class')
->whereObjectId($status->id)
->whereAction('admin.status.moderate')
->exists();
if($exists == true) {
$status->is_nsfw = true;
}
$profile = Profile::find($status->profile_id);
if(!$profile || $profile->cw == true) {
$status->is_nsfw = true;
}
} else {
$status->is_nsfw = true;
}
}
if(isset($activity['summary'])) {
$status->cw_summary = Purify::clean($activity['summary']);
} else {
$status->cw_summary = null;
}
$status->edited_at = now();
$status->save();
StatusService::del($status->id);
}
protected function createEdit($status, $activity)
{
$cleaned = isset($activity['content']) ? Purify::clean($activity['content']) : null;
$spoiler_text = isset($activity['summary']) ? Purify::clean($activity['summary']) : null;
$sensitive = isset($activity['sensitive']) ? $activity['sensitive'] : null;
$mids = $status->media()->count() ? $status->media()->orderBy('order')->pluck('id')->toArray() : null;
StatusEdit::create([
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'caption' => $cleaned,
'spoiler_text' => $spoiler_text,
'is_nsfw' => $sensitive,
'ordered_media_attachment_ids' => $mids
]);
}
}

View file

@ -70,10 +70,12 @@ class StatusReplyPipeline implements ShouldQueue
}
if(config('database.default') === 'mysql') {
$exp = DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)");
$expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
$count = DB::select($expQuery, [ 'kid' => $reply->id ]);
$reply->reply_count = count($count);
// todo: refactor
// $exp = DB::raw("select id, in_reply_to_id from statuses, (select @pv := :kid) initialisation where id > @pv and find_in_set(in_reply_to_id, @pv) > 0 and @pv := concat(@pv, ',', id)");
// $expQuery = $exp->getValue(DB::connection()->getQueryGrammar());
// $count = DB::select($expQuery, [ 'kid' => $reply->id ]);
// $reply->reply_count = count($count);
$reply->reply_count = $reply->reply_count + 1;
$reply->save();
} else {
$reply->reply_count = $reply->reply_count + 1;
@ -90,8 +92,6 @@ class StatusReplyPipeline implements ShouldQueue
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'comment';
$notification->message = $status->replyToText();
$notification->rendered = $status->replyToHtml();
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();

View file

@ -31,21 +31,4 @@ class Like extends Model
{
return $this->belongsTo(Status::class);
}
public function toText($type = 'post')
{
$actorName = $this->actor->username;
$msg = $type == 'post' ? __('notification.likedPhoto') : __('notification.likedComment');
return "{$actorName} ".$msg;
}
public function toHtml($type = 'post')
{
$actorName = $this->actor->username;
$actorUrl = $this->actor->url();
$msg = $type == 'post' ? __('notification.likedPhoto') : __('notification.likedComment');
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".$msg;
}
}

View file

@ -29,20 +29,4 @@ class Mention extends Model
{
return $this->belongsTo(Status::class, 'status_id', 'id');
}
public function toText()
{
$actorName = $this->status->profile->username;
return "{$actorName} ".__('notification.mentionedYou');
}
public function toHtml()
{
$actorName = $this->status->profile->username;
$actorUrl = $this->status->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.mentionedYou');
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AutospamCustomTokens extends Model
{
use HasFactory;
}

23
app/Models/ImportPost.php Normal file
View file

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Status;
class ImportPost extends Model
{
use HasFactory;
protected $casts = [
'media' => 'array',
'creation_date' => 'datetime',
'metadata' => 'json'
];
public function status()
{
return $this->hasOne(Status::class, 'id', 'status_id');
}
}

View file

@ -23,7 +23,61 @@ class InstanceActor extends Model
public function getActor()
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
[
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"toot" => "http://joinmastodon.org/ns#",
"featured" => [
"@id" => "toot:featured",
"@type" => "@id"
],
"featuredTags" => [
"@id" => "toot:featuredTags",
"@type" => "@id"
],
"alsoKnownAs" => [
"@id" => "as:alsoKnownAs",
"@type" => "@id"
],
"movedTo" => [
"@id" => "as:movedTo",
"@type" => "@id"
],
"schema" => "http://schema.org#",
"PropertyValue" => "schema:PropertyValue",
"value" => "schema:value",
"discoverable" => "toot:discoverable",
"Device" => "toot:Device",
"Ed25519Signature" => "toot:Ed25519Signature",
"Ed25519Key" => "toot:Ed25519Key",
"Curve25519Key" => "toot:Curve25519Key",
"EncryptedMessage" => "toot:EncryptedMessage",
"publicKeyBase64" => "toot:publicKeyBase64",
"deviceId" => "toot:deviceId",
"claim" => [
"@type" => "@id",
"@id" => "toot:claim"
],
"fingerprintKey" => [
"@type" => "@id",
"@id" => "toot:fingerprintKey"
],
"identityKey" => [
"@type" => "@id",
"@id" => "toot:identityKey"
],
"devices" => [
"@type" => "@id",
"@id" => "toot:devices"
],
"messageFranking" => "toot:messageFranking",
"messageType" => "toot:messageType",
"cipherText" => "toot:cipherText",
"suspended" => "toot:suspended"
]
],
'id' => $this->permalink(),
'type' => 'Application',
'inbox' => $this->permalink('/inbox'),

19
app/Models/StatusEdit.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StatusEdit extends Model
{
use HasFactory;
protected $casts = [
'ordered_media_attachment_ids' => 'array',
'media_descriptions' => 'array',
'poll_options' => 'array'
];
protected $guarded = [];
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\User;
class UserAppSettings extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'common' => 'json',
'custom' => 'json',
'common.timelines.show_public' => 'boolean',
'common.timelines.show_network' => 'boolean',
'common.timelines.hide_likes_shares' => 'boolean',
'common.media.hide_public_behind_cw' => 'boolean',
'common.media.always_show_cw' => 'boolean',
'common.media.show_alt_text' => 'boolean',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View file

@ -5,6 +5,8 @@ namespace App\Observers;
use App\Status;
use App\Services\ProfileStatusService;
use Cache;
use App\Models\ImportPost;
use App\Services\ImportService;
class StatusObserver
{
@ -56,6 +58,11 @@ class StatusObserver
}
ProfileStatusService::delete($status->profile_id, $status->id);
if($status->uri == null) {
ImportPost::whereProfileId($status->profile_id)->whereStatusId($status->id)->delete();
ImportService::clearImportedFiles($status->profile_id);
}
}
/**

View file

@ -178,6 +178,13 @@ class Profile extends Model
return url('/storage/avatars/default.jpg');
}
if( $avatar->is_remote &&
$avatar->remote_url &&
boolval(config_cache('federation.avatars.store_local')) == true
) {
return $avatar->remote_url;
}
if($path === 'public/avatars/default.jpg') {
return url('/storage/avatars/default.jpg');
}

View file

@ -24,7 +24,7 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot()
{
if(config_cache('pixelfed.oauth_enabled') == true) {
if(config('app.env') === 'production' && config('pixelfed.oauth_enabled') == true) {
Passport::tokensExpireIn(now()->addDays(config('instance.oauth.token_expiration', 356)));
Passport::refreshTokensExpireIn(now()->addDays(config('instance.oauth.refresh_expiration', 400)));
Passport::enableImplicitGrant();

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services\Account;
use App\Models\UserAppSettings;
class AccountAppSettingsService
{
public static function default()
{
return [
'timelines' => [
// Show public timeline feed
'show_public' => false,
// Show network timeline feed
'show_network' => false,
// Hide likes and share counts
'hide_likes_shares' => false,
],
'media' => [
// Hide media on Public/Network timelines behind CW
'hide_public_behind_cw' => true,
// Always show media with CW
'always_show_cw' => false,
// Show alt text if present below media
'show_alt_text' => false,
],
'appearance' => [
// Use in-app browser when opening links
'links_use_in_app_browser' => true,
// App theme, can be 'light', 'dark' or 'system'
'theme' => 'system',
]
];
}
}

View file

@ -19,12 +19,11 @@ class ActivityPubFetchService
$baseHeaders = [
'Accept' => 'application/activity+json, application/ld+json',
'User-Agent' => '(Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')'
];
$headers = HttpSignature::instanceActorSign($url, false, $baseHeaders);
$headers = HttpSignature::instanceActorSign($url, false, $baseHeaders, 'get');
$headers['Accept'] = 'application/activity+json, application/ld+json';
$headers['User-Agent'] = '(Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')';
$headers['User-Agent'] = 'PixelFedBot/1.0.0 (Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')';
try {
$res = Http::withHeaders($headers)

View file

@ -0,0 +1,78 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use App\Util\Lexer\Classifier;
class AutospamService
{
const CHCKD_CACHE_KEY = 'pf:services:autospam:nlp:checked';
const MODEL_CACHE_KEY = 'pf:services:autospam:nlp:model-cache';
const MODEL_FILE_PATH = 'nlp/active-training-data.json';
const MODEL_SPAM_PATH = 'nlp/spam.json';
const MODEL_HAM_PATH = 'nlp/ham.json';
public static function check($text)
{
if(!$text || strlen($text) == 0) {
false;
}
if(!self::active()) {
return null;
}
$model = self::getCachedModel();
$classifier = new Classifier;
$classifier->import($model['documents'], $model['words']);
return $classifier->most($text) === 'spam';
}
public static function eligible()
{
return Cache::remember(self::CHCKD_CACHE_KEY, 86400, function() {
if(!config_cache('pixelfed.bouncer.enabled') || !config('autospam.enabled')) {
return false;
}
if(!Storage::exists(self::MODEL_SPAM_PATH)) {
return false;
}
if(!Storage::exists(self::MODEL_HAM_PATH)) {
return false;
}
if(!Storage::exists(self::MODEL_FILE_PATH)) {
return false;
} else {
if(Storage::size(self::MODEL_FILE_PATH) < 1000) {
return false;
}
}
return true;
});
}
public static function active()
{
return config_cache('autospam.nlp.enabled') && self::eligible();
}
public static function getCachedModel()
{
if(!self::active()) {
return null;
}
return Cache::remember(self::MODEL_CACHE_KEY, 86400, function() {
$res = Storage::get(self::MODEL_FILE_PATH);
if(!$res || empty($res)) {
return null;
}
return json_decode($res, true);
});
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Services;
use Cache;
use App\Profile;
class AvatarService
{
public static function get($profile_id)
{
$exists = Cache::get('avatar:' . $profile_id);
if($exists) {
return $exists;
}
$profile = Profile::find($profile_id);
if(!$profile) {
return config('app.url') . '/storage/avatars/default.jpg';
}
return $profile->avatarUrl();
}
}

View file

@ -65,6 +65,13 @@ class ConfigCacheService
'pixelfed.directory.latest_response',
'pixelfed.directory.is_synced',
'pixelfed.directory.testimonials',
'instance.landing.show_directory',
'instance.landing.show_explore',
'instance.admin.pid',
'instance.banner.blurhash',
'autospam.nlp.enabled',
// 'system.user_mode'
];

View file

@ -28,8 +28,8 @@ class HashtagService {
public static function count($id)
{
return Cache::remember('services:hashtag:count:by_id:' . $id, 3600, function() use($id) {
return StatusHashtag::whereHashtagId($id)->count();
return Cache::remember('services:hashtag:public-count:by_id:' . $id, 86400, function() use($id) {
return StatusHashtag::whereHashtagId($id)->whereStatusVisibility('public')->count();
});
}
@ -64,4 +64,9 @@ class HashtagService {
{
return Redis::zrem(self::FOLLOW_KEY . $pid, $hid);
}
public static function following($pid, $start = 0, $limit = 10)
{
return Redis::zrevrange(self::FOLLOW_KEY . $pid, $start, $limit);
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Services;
use App\Models\ImportPost;
use Cache;
class ImportService
{
const CACHE_KEY = 'pf:import-service:';
public static function getId($userId, $year, $month, $day)
{
if($userId > 999999) {
return;
}
if($year < 9 || $year > 23) {
return;
}
if($month < 1 || $month > 12) {
return;
}
if($day < 1 || $day > 31) {
return;
}
$start = 1;
$key = self::CACHE_KEY . 'getIdRange:incr:byUserId:' . $userId . ':y-' . $year . ':m-' . $month . ':d-' . $day;
$incr = Cache::increment($key, random_int(5, 19));
if($incr > 999) {
$daysInMonth = now()->parse($day . '-' . $month . '-' . $year)->daysInMonth;
if($month == 12) {
$year = $year + 1;
$month = 1;
$day = 0;
}
if($day + 1 >= $daysInMonth) {
$day = 1;
$month = $month + 1;
} else {
$day = $day + 1;
}
return self::getId($userId, $year, $month, $day);
}
$uid = str_pad($userId, 6, 0, STR_PAD_LEFT);
$year = str_pad($year, 2, 0, STR_PAD_LEFT);
$month = str_pad($month, 2, 0, STR_PAD_LEFT);
$day = str_pad($day, 2, 0, STR_PAD_LEFT);
$zone = $year . $month . $day . str_pad($incr, 3, 0, STR_PAD_LEFT);
return [
'id' => $start . $uid . $zone,
'year' => $year,
'month' => $month,
'day' => $day,
'incr' => $incr,
];
}
public static function getPostCount($profileId, $refresh = false)
{
$key = self::CACHE_KEY . 'totalPostCountByProfileId:' . $profileId;
if($refresh) {
Cache::forget($key);
}
return intval(Cache::remember($key, 21600, function() use($profileId) {
return ImportPost::whereProfileId($profileId)->whereSkipMissingMedia(false)->count();
}));
}
public static function getAttempts($profileId)
{
$key = self::CACHE_KEY . 'attemptsByProfileId:' . $profileId;
return intval(Cache::remember($key, 21600, function() use($profileId) {
return ImportPost::whereProfileId($profileId)
->whereSkipMissingMedia(false)
->get()
->groupBy(function($item) {
return $item->created_at->format('Y-m-d');
})
->count();
}));
}
public static function clearAttempts($profileId)
{
$key = self::CACHE_KEY . 'attemptsByProfileId:' . $profileId;
return Cache::forget($key);
}
public static function getImportedFiles($profileId, $refresh = false)
{
$key = self::CACHE_KEY . 'importedPostsByProfileId:' . $profileId;
if($refresh) {
Cache::forget($key);
}
return Cache::remember($key, 21600, function() use($profileId) {
return ImportPost::whereProfileId($profileId)
->get()
->filter(function($ip) {
return StatusService::get($ip->status_id) == null;
})
->map(function($ip) {
return collect($ip->media)->map(function($m) { return $m['uri']; });
})->values()->flatten();
});
}
public static function clearImportedFiles($profileId)
{
$key = self::CACHE_KEY . 'importedPostsByProfileId:' . $profileId;
return Cache::forget($key);
}
}

View file

@ -4,6 +4,8 @@ namespace App\Services;
use Cache;
use App\Instance;
use App\Util\Blurhash\Blurhash;
use App\Services\ConfigCacheService;
class InstanceService
{
@ -12,6 +14,12 @@ class InstanceService
const CACHE_KEY_UNLISTED_DOMAINS = 'instances:unlisted:domains';
const CACHE_KEY_NSFW_DOMAINS = 'instances:auto_cw:domains';
const CACHE_KEY_STATS = 'pf:services:instances:stats';
const CACHE_KEY_BANNER_BLURHASH = 'pf:services:instance:header-blurhash:v1';
public function __construct()
{
ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
}
public static function getByDomain($domain)
{
@ -78,4 +86,50 @@ class InstanceService
return true;
}
public static function headerBlurhash()
{
return Cache::rememberForever(self::CACHE_KEY_BANNER_BLURHASH, function() {
if(str_ends_with(config_cache('app.banner_image'), 'headers/default.jpg')) {
return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt';
}
$cached = config_cache('instance.banner.blurhash');
if($cached) {
return $cached;
}
$file = config_cache('app.banner_image') ?? url(Storage::url('public/headers/default.jpg'));
$image = imagecreatefromstring(file_get_contents($file));
if(!$image) {
return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt';
}
$width = imagesx($image);
$height = imagesy($image);
$pixels = [];
for ($y = 0; $y < $height; ++$y) {
$row = [];
for ($x = 0; $x < $width; ++$x) {
$index = imagecolorat($image, $x, $y);
$colors = imagecolorsforindex($image, $index);
$row[] = [$colors['red'], $colors['green'], $colors['blue']];
}
$pixels[] = $row;
}
$components_x = 4;
$components_y = 4;
$blurhash = Blurhash::encode($pixels, $components_x, $components_y);
if(strlen($blurhash) > 191) {
return 'UzJR]l{wHZRjM}R%XRkCH?X9xaWEjZj]kAjt';
}
ConfigCacheService::put('instance.banner.blurhash', $blurhash);
return $blurhash;
});
}
}

View file

@ -30,6 +30,9 @@ class LandingService
});
$contactAccount = Cache::remember('api:v1:instance-data:contact', 604800, function () {
if(config_cache('instance.admin.pid')) {
return AccountService::getMastodon(config_cache('instance.admin.pid'), true);
}
$admin = User::whereIsAdmin(true)->first();
return $admin && isset($admin->profile_id) ?
AccountService::getMastodon($admin->profile_id, true) :
@ -53,8 +56,8 @@ class LandingService
'name' => config_cache('app.name'),
'url' => config_cache('app.url'),
'domain' => config('pixelfed.domain.app'),
'show_directory' => config('instance.landing.show_directory'),
'show_explore_feed' => config('instance.landing.show_explore'),
'show_directory' => config_cache('instance.landing.show_directory'),
'show_explore_feed' => config_cache('instance.landing.show_explore'),
'open_registration' => config_cache('pixelfed.open_registration') == 1,
'version' => config('pixelfed.version'),
'about' => [

View file

@ -85,7 +85,10 @@ class LikeService {
return $empty;
}
$id = $like->profile_id;
$profile = ProfileService::get($id);
$profile = ProfileService::get($id, true);
if(!$profile) {
return [];
}
$profileUrl = "/i/web/profile/{$profile['id']}";
$res = [
'id' => (string) $profile['id'],

View file

@ -77,7 +77,9 @@ class MediaStorageService {
protected function cloudStore($media)
{
if($media->remote_media == true) {
if(config('media.storage.remote.cloud')) {
(new self())->remoteToCloud($media);
}
} else {
(new self())->localToCloud($media);
}
@ -189,7 +191,7 @@ class MediaStorageService {
unlink($tmpName);
}
protected function fetchAvatar($avatar, $local = false)
protected function fetchAvatar($avatar, $local = false, $skipRecentCheck = false)
{
$url = $avatar->remote_url;
$driver = $local ? 'local' : config('filesystems.cloud');
@ -213,9 +215,14 @@ class MediaStorageService {
$mime = $head['mime'];
$max_size = (int) config('pixelfed.max_avatar_size') * 1000;
if(!$skipRecentCheck) {
if($avatar->last_fetched_at && $avatar->last_fetched_at->gt(now()->subDay())) {
return;
}
}
Cache::forget('avatar:' . $avatar->profile_id);
AccountService::del($avatar->profile_id);
// handle pleroma edge case
if(Str::endsWith($mime, '; charset=utf-8')) {
@ -264,7 +271,7 @@ class MediaStorageService {
$avatar->save();
Cache::forget('avatar:' . $avatar->profile_id);
Cache::forget(AccountService::CACHE_KEY . $avatar->profile_id);
AccountService::del($avatar->profile_id);
unlink($tmpName);
}

View file

@ -57,7 +57,7 @@ class MediaTagService
protected function idToUsername($id)
{
$profile = ProfileService::get($id);
$profile = ProfileService::get($id, true);
if(!$profile) {
return 'unavailable';
@ -74,16 +74,13 @@ class MediaTagService
{
$p = $tag->status->profile;
$actor = $p->username;
$message = "{$actor} tagged you in a post.";
$rendered = "<a href='/{$actor}' class='profile-link'>{$actor}</a> tagged you in a post.";
$n = new Notification;
$n->profile_id = $tag->profile_id;
$n->actor_id = $p->id;
$n->item_id = $tag->id;
$n->item_type = 'App\MediaTag';
$n->action = 'tagged';
$n->message = $message;
$n->rendered = $rendered;
$n->save();
return;
}

View file

@ -108,8 +108,6 @@ class ModLogService {
{
$log = $this->log;
$msg = "{$log->user_username} commented on a modlog";
$rendered = "<span class='font-weight-bold'>{$log->user_username}</span> commented on a <a href='/i/admin/users/modlogs/{$log->user_id}}' class='font-weight-bold text-decoration-none'>modlog</a>";
$item_id = $log->id;
$item_type = 'App\ModLog';
$action = 'admin.user.modlog.comment';
@ -127,8 +125,6 @@ class ModLogService {
$n->item_id = $item_id;
$n->item_type = $item_type;
$n->action = $action;
$n->message = $msg;
$n->rendered = $rendered;
$n->save();
}
}

View file

@ -72,6 +72,26 @@ class NetworkTimelineService
return Redis::zcard(self::CACHE_KEY);
}
public static function deleteByProfileId($profileId)
{
$res = Redis::zrange(self::CACHE_KEY, 0, '-1');
if(!$res) {
return;
}
foreach($res as $postId) {
$s = StatusService::get($postId);
if(!$s) {
self::rem($postId);
continue;
}
if($s['account']['id'] == $profileId) {
self::rem($postId);
}
}
return;
}
public static function warmCache($force = false, $limit = 100)
{
if(self::count() == 0 || $force == true) {

View file

@ -4,9 +4,9 @@ namespace App\Services;
class ProfileService
{
public static function get($id)
public static function get($id, $softFail = false)
{
return AccountService::get($id);
return AccountService::get($id, $softFail);
}
public static function del($id)

View file

@ -72,17 +72,37 @@ class PublicTimelineService {
return Redis::zcard(self::CACHE_KEY);
}
public static function deleteByProfileId($profileId)
{
$res = Redis::zrange(self::CACHE_KEY, 0, '-1');
if(!$res) {
return;
}
foreach($res as $postId) {
$s = StatusService::get($postId);
if(!$s) {
self::rem($postId);
continue;
}
if($s['account']['id'] == $profileId) {
self::rem($postId);
}
}
return;
}
public static function warmCache($force = false, $limit = 100)
{
if(self::count() == 0 || $force == true) {
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
Redis::del(self::CACHE_KEY);
$ids = Status::whereNull('uri')
->whereNull('in_reply_to_id')
$minId = SnowflakeService::byDate(now()->subDays(14));
$ids = Status::where('id', '>', $minId)
->whereNull(['uri', 'in_reply_to_id', 'reblog_of_id'])
->when($hideNsfw, function($q, $hideNsfw) {
return $q->where('is_nsfw', false);
})
->whereNull('reblog_of_id')
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
->whereScope('public')
->orderByDesc('id')

View file

@ -52,6 +52,7 @@ class RelationshipService
public static function delete($aid, $tid)
{
Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
}
@ -85,4 +86,24 @@ class RelationshipService
{
return self::CACHE_KEY . $suffix;
}
public static function getWithDate($aid, $tid)
{
$res = self::get($aid, $tid);
if(!$res || !$res['following']) {
$res['following_since'] = null;
return $res;
}
return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid, $res) {
$tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
if(!$tmp) {
$res['following_since'] = null;
return $res;
}
$res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
return $res;
});
}
}

View file

@ -96,9 +96,10 @@ class SearchApiV2Service
$webfingerQuery = '@' . $webfingerQuery;
}
$banned = InstanceService::getBannedDomains();
$operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
$results = Profile::select('username', 'id', 'followers_count', 'domain')
->where('username', 'like', $query)
->orWhere('webfinger', 'like', $webfingerQuery)
->where('username', $operator, $query)
->orWhere('webfinger', $operator, $webfingerQuery)
->orderByDesc('profiles.followers_count')
->offset($offset)
->limit($limit)
@ -160,23 +161,8 @@ class SearchApiV2Service
protected function statusesById()
{
$mastodonMode = self::$mastodonMode;
$accountId = $this->query->input('account_id');
$limit = $this->query->input('limit', 20);
$query = '%' . $this->query->input('q') . '%';
$results = Status::where('caption', 'like', $query)
->whereProfileId($accountId)
->limit($limit)
->get()
->map(function($status) use($mastodonMode) {
return $mastodonMode ?
StatusService::getMastodon($status->id) :
StatusService::get($status->id);
})
->filter(function($status) {
return $status && isset($status['account']);
});
return $results;
// Removed until we provide more relevent sorting/results
return [];
}
protected function resolveQuery()

View file

@ -0,0 +1,137 @@
<?php
namespace App\Services\Status;
use App\Media;
use App\ModLog;
use App\Status;
use App\Models\StatusEdit;
use Purify;
use App\Util\Lexer\Autolink;
use App\Services\MediaService;
use App\Services\MediaStorageService;
use App\Services\StatusService;
class UpdateStatusService
{
public static function call(Status $status, $attributes)
{
self::createPreviousEdit($status);
self::updateMediaAttachements($status, $attributes);
self::handleImmediateAttributes($status, $attributes);
self::createEdit($status, $attributes);
return StatusService::get($status->id);
}
public static function updateMediaAttachements(Status $status, $attributes)
{
$count = $status->media()->count();
if($count === 0 || $count === 1) {
return;
}
$oids = $status->media()->orderBy('order')->pluck('id')->map(function($m) { return (string) $m; });
$nids = collect($attributes['media_ids']);
if($oids->toArray() === $nids->toArray()) {
return;
}
foreach($oids->diff($nids)->values()->toArray() as $mid) {
$media = Media::find($mid);
if(!$media) {
continue;
}
$media->status_id = null;
$media->save();
MediaStorageService::delete($media, true);
}
$nids->each(function($nid, $idx) {
$media = Media::find($nid);
if(!$media) {
return;
}
$media->order = $idx;
$media->save();
});
MediaService::del($status->id);
}
public static function handleImmediateAttributes(Status $status, $attributes)
{
if(isset($attributes['status'])) {
$cleaned = Purify::clean($attributes['status']);
$status->caption = $cleaned;
$status->rendered = nl2br(Autolink::create()->autolink($cleaned));
} else {
$status->caption = null;
$status->rendered = null;
}
if(isset($attributes['sensitive'])) {
if($status->is_nsfw != (bool) $attributes['sensitive'] &&
(bool) $attributes['sensitive'] == false)
{
$exists = ModLog::whereObjectType('App\Status::class')
->whereObjectId($status->id)
->whereAction('admin.status.moderate')
->exists();
if(!$exists) {
$status->is_nsfw = (bool) $attributes['sensitive'];
}
} else {
$status->is_nsfw = (bool) $attributes['sensitive'];
}
}
if(isset($attributes['spoiler_text'])) {
$status->cw_summary = Purify::clean($attributes['spoiler_text']);
} else {
$status->cw_summary = null;
}
if(isset($attributes['location'])) {
if (isset($attributes['location']['id'])) {
$status->place_id = $attributes['location']['id'];
} else {
$status->place_id = null;
}
}
if($status->cw_summary && !$status->is_nsfw) {
$status->cw_summary = null;
}
$status->edited_at = now();
$status->save();
StatusService::del($status->id);
}
public static function createPreviousEdit(Status $status)
{
if(!$status->edits()->count()) {
StatusEdit::create([
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'caption' => $status->caption,
'spoiler_text' => $status->cw_summary,
'is_nsfw' => $status->is_nsfw,
'ordered_media_attachment_ids' => $status->media()->orderBy('order')->pluck('id')->toArray(),
'created_at' => $status->created_at
]);
}
}
public static function createEdit(Status $status, $attributes)
{
$cleaned = isset($attributes['status']) ? Purify::clean($attributes['status']) : null;
$spoiler_text = isset($attributes['spoiler_text']) ? Purify::clean($attributes['spoiler_text']) : null;
$sensitive = isset($attributes['sensitive']) ? $attributes['sensitive'] : null;
$mids = $status->media()->count() ? $status->media()->orderBy('order')->pluck('id')->toArray() : null;
StatusEdit::create([
'status_id' => $status->id,
'profile_id' => $status->profile_id,
'caption' => $cleaned,
'spoiler_text' => $spoiler_text,
'is_nsfw' => $sensitive,
'ordered_media_attachment_ids' => $mids
]);
}
}

View file

@ -47,6 +47,10 @@ class StatusService
return null;
}
if(!isset($status['account'])) {
return null;
}
$status['replies_count'] = $status['reply_count'];
if(config('exp.emc') == false) {
@ -117,6 +121,9 @@ class StatusService
public static function getFull($id, $pid, $publicOnly = true)
{
$res = self::get($id, $publicOnly);
if(!$res || !isset($res['account']) || !isset($res['account']['id'])) {
return $res;
}
$res['relationship'] = RelationshipService::get($pid, $res['account']['id']);
return $res;
}

View file

@ -95,7 +95,8 @@ class StoryService
public static function delLatest($pid)
{
return Cache::forget(self::STORY_KEY . 'latest:pid-' . $pid);
Cache::forget(self::STORY_KEY . 'latest:pid-' . $pid);
return Cache::forget('pf:stories:recent-self:' . $pid);
}
public static function addSeen($pid, $sid)

View file

@ -9,6 +9,7 @@ use App\Http\Controllers\StatusController;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Poll;
use App\Services\AccountService;
use App\Models\StatusEdit;
class Status extends Model
{
@ -27,7 +28,8 @@ class Status extends Model
* @var array
*/
protected $casts = [
'deleted_at' => 'datetime'
'deleted_at' => 'datetime',
'edited_at' => 'datetime'
];
protected $guarded = [];
@ -48,11 +50,11 @@ class Status extends Model
'loop'
];
const MAX_MENTIONS = 5;
const MAX_MENTIONS = 20;
const MAX_HASHTAGS = 30;
const MAX_HASHTAGS = 60;
const MAX_LINKS = 2;
const MAX_LINKS = 5;
public function profile()
{
@ -285,38 +287,6 @@ class Status extends Model
return $obj;
}
public function replyToText()
{
$actorName = $this->profile->username;
return "{$actorName} ".__('notification.commented');
}
public function replyToHtml()
{
$actorName = $this->profile->username;
$actorUrl = $this->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.commented');
}
public function shareToText()
{
$actorName = $this->profile->username;
return "{$actorName} ".__('notification.shared');
}
public function shareToHtml()
{
$actorName = $this->profile->username;
$actorUrl = $this->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.shared');
}
public function recentComments()
{
return $this->comments()->orderBy('created_at', 'desc')->take(3);
@ -425,4 +395,9 @@ class Status extends Model
{
return $this->hasOne(Poll::class);
}
public function edits()
{
return $this->hasMany(StatusEdit::class);
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Status;
use League\Fractal;
use App\Models\CustomEmoji;
use Illuminate\Support\Str;
class UpdateNote extends Fractal\TransformerAbstract
{
public function transform(Status $status)
{
$mentions = $status->mentions->map(function ($mention) {
$webfinger = $mention->emailUrl();
$name = Str::startsWith($webfinger, '@') ?
$webfinger :
'@' . $webfinger;
return [
'type' => 'Mention',
'href' => $mention->permalink(),
'name' => $name
];
})->toArray();
if($status->in_reply_to_id != null) {
$parent = $status->parent()->profile;
if($parent) {
$webfinger = $parent->emailUrl();
$name = Str::startsWith($webfinger, '@') ?
$webfinger :
'@' . $webfinger;
$reply = [
'type' => 'Mention',
'href' => $parent->permalink(),
'name' => $name
];
$mentions = array_merge($reply, $mentions);
}
}
$hashtags = $status->hashtags->map(function ($hashtag) {
return [
'type' => 'Hashtag',
'href' => $hashtag->url(),
'name' => "#{$hashtag->name}",
];
})->toArray();
$emojis = CustomEmoji::scan($status->caption, true) ?? [];
$emoji = array_merge($emojis, $mentions);
$tags = array_merge($emoji, $hashtags);
$latestEdit = $status->edits()->latest()->first();
return [
'@context' => [
'https://w3id.org/security/v1',
'https://www.w3.org/ns/activitystreams',
[
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
'schema' => 'http://schema.org/',
'pixelfed' => 'http://pixelfed.org/ns#',
'commentsEnabled' => [
'@id' => 'pixelfed:commentsEnabled',
'@type' => 'schema:Boolean'
],
'capabilities' => [
'@id' => 'pixelfed:capabilities',
'@container' => '@set'
],
'announce' => [
'@id' => 'pixelfed:canAnnounce',
'@type' => '@id'
],
'like' => [
'@id' => 'pixelfed:canLike',
'@type' => '@id'
],
'reply' => [
'@id' => 'pixelfed:canReply',
'@type' => '@id'
],
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji'
]
],
'id' => $status->permalink('#updates/' . $latestEdit->id),
'type' => 'Update',
'actor' => $status->profile->permalink(),
'published' => $latestEdit->created_at->toAtomString(),
'to' => $status->scopeToAudience('to'),
'cc' => $status->scopeToAudience('cc'),
'object' => [
'id' => $status->url(),
'type' => 'Note',
'summary' => $status->is_nsfw ? $status->cw_summary : null,
'content' => $status->rendered ?? $status->caption,
'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null,
'published' => $status->created_at->toAtomString(),
'url' => $status->url(),
'attributedTo' => $status->profile->permalink(),
'to' => $status->scopeToAudience('to'),
'cc' => $status->scopeToAudience('cc'),
'sensitive' => (bool) $status->is_nsfw,
'attachment' => $status->media()->orderBy('order')->get()->map(function ($media) {
return [
'type' => $media->activityVerb(),
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => $media->caption,
];
})->toArray(),
'tag' => $tags,
'commentsEnabled' => (bool) !$status->comments_disabled,
'updated' => $latestEdit->created_at->toAtomString(),
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
'like' => 'https://www.w3.org/ns/activitystreams#Public',
'reply' => $status->comments_disabled == true ? '[]' : 'https://www.w3.org/ns/activitystreams#Public'
],
'location' => $status->place_id ? [
'type' => 'Place',
'name' => $status->place->name,
'longitude' => $status->place->long,
'latitude' => $status->place->lat,
'country' => $status->place->country
] : null,
]
];
}
}

View file

@ -42,7 +42,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'card' => null,
'poll' => null,
'media_attachments' => MediaService::get($status->id),
'account' => ProfileService::get($status->profile_id),
'account' => ProfileService::get($status->profile_id, true),
'tags' => StatusHashtagService::statusTags($status->id),
];
}

View file

@ -23,8 +23,10 @@ class NotificationTransformer extends Fractal\TransformerAbstract
if($n->actor_id) {
$res['account'] = AccountService::get($n->actor_id);
if($n->profile_id != $n->actor_id) {
$res['relationship'] = RelationshipService::get($n->actor_id, $n->profile_id);
}
}
if($n->item_id && $n->item_type == 'App\Status') {
$res['status'] = StatusService::get($n->item_id, false);
@ -66,11 +68,8 @@ class NotificationTransformer extends Fractal\TransformerAbstract
'comment' => 'comment',
'admin.user.modlog.comment' => 'modlog',
'tagged' => 'tagged',
'group:comment' => 'group:comment',
'story:react' => 'story:react',
'story:comment' => 'story:comment',
'group:join:approved' => 'group:join:approved',
'group:join:rejected' => 'group:join:rejected'
];
if(!isset($verbs[$verb])) {

View file

@ -10,6 +10,7 @@ use App\Services\HashidService;
use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService;
use App\Services\StatusService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService;
use App\Services\StatusMentionService;
@ -32,7 +33,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null,
'in_reply_to_account_id' => $status->in_reply_to_profile_id ? (string) $status->in_reply_to_profile_id : null,
'reblog' => null,
'reblog' => $status->reblog_of_id ? StatusService::get($status->reblog_of_id) : null,
'content' => $status->rendered ?? $status->caption,
'content_text' => $status->caption,
'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)),
@ -65,7 +66,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'media_attachments' => MediaService::get($status->id),
'account' => AccountService::get($status->profile_id, true),
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll
'poll' => $poll,
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
];
}
}

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