Merge pull request #2895 from pixelfed/staging

Archives, Polls and Stories
This commit is contained in:
daniel 2021-09-03 22:52:17 -06:00 committed by GitHub
commit f593e2b709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
157 changed files with 7388 additions and 2269 deletions

View file

@ -6,6 +6,11 @@
- Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540)) - Auto Following support for admins ([68aa2540](https://github.com/pixelfed/pixelfed/commit/68aa2540))
- Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86)) - Mark as spammer mod tool, unlists and applies content warning to existing and future post ([6d956a86](https://github.com/pixelfed/pixelfed/commit/6d956a86))
- Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc)) - Diagnostics for error page and admin dashboard ([64725ecc](https://github.com/pixelfed/pixelfed/commit/64725ecc))
- Default media licenses and media license sync ([ea0fc90c](https://github.com/pixelfed/pixelfed/commit/ea0fc90c))
- Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
- Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a))
- Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88))
- Polls ([77092200](https://github.com/pixelfed/pixelfed/commit/77092200))
### Updated ### Updated
- Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b))
@ -73,6 +78,28 @@
- Updated RemotePost.vue, improve text only post UI. ([b0257be2](https://github.com/pixelfed/pixelfed/commit/b0257be2)) - Updated RemotePost.vue, improve text only post UI. ([b0257be2](https://github.com/pixelfed/pixelfed/commit/b0257be2))
- Updated Timeline, make text-only posts opt-in by default. ([0153ed6d](https://github.com/pixelfed/pixelfed/commit/0153ed6d)) - Updated Timeline, make text-only posts opt-in by default. ([0153ed6d](https://github.com/pixelfed/pixelfed/commit/0153ed6d))
- Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad)) - Updated LikeController, add UndoLikePipeline and federate Undo Like activities. ([8ac8fcad](https://github.com/pixelfed/pixelfed/commit/8ac8fcad))
- Updated Settings, add default license and enforced media descriptions. ([67e3f604](https://github.com/pixelfed/pixelfed/commit/67e3f604))
- Updated Compose Apis, make media descriptions/alt text length limit configurable. Default length: 1000. ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1))
- Updated ApiV1Controller, add default license support. ([2a791f19](https://github.com/pixelfed/pixelfed/commit/2a791f19))
- Updated StatusTransformers, remove includes and use cached services. ([09d5198c](https://github.com/pixelfed/pixelfed/commit/09d5198c))
- Updated RemotePost component, update likes reaction bar. ([1060dd23](https://github.com/pixelfed/pixelfed/commit/1060dd23))
- Updated FollowPipeline, fix cache invalidation bug. ([c1f14f89](https://github.com/pixelfed/pixelfed/commit/c1f14f89))
- Updated PublicApiController, improve accountStatuses api perf. ([bce8edd9](https://github.com/pixelfed/pixelfed/commit/bce8edd9))
- Updated ApiControllers, use NotificationService. ([f9516ac3](https://github.com/pixelfed/pixelfed/commit/f9516ac3))
- Updated Notification components, fix old notifications with missing attributes. ([b6e226ae](https://github.com/pixelfed/pixelfed/commit/b6e226ae))
- Updated LikeController, improve query perf. ([f3d6023e](https://github.com/pixelfed/pixelfed/commit/f3d6023e))
- Updated License util, add nameToId method. ([f6131ed7](https://github.com/pixelfed/pixelfed/commit/f6131ed7))
- Updated RemoteProfile, add warning about potentially out of date information. ([7274574c](https://github.com/pixelfed/pixelfed/commit/7274574c))
- Updated NotifcationCard.vue component, add refresh button for cold notification cache. ([0e178a33](https://github.com/pixelfed/pixelfed/commit/0e178a33))
- Updated RemoteProfile component, add follower modals. ([c4146a30](https://github.com/pixelfed/pixelfed/commit/c4146a30))
- Updated FollowerService, cache audience. ([22257cc2](https://github.com/pixelfed/pixelfed/commit/22257cc2))
- Updated StatusService, add non-public option and improve cache invalidation. ([15c4fdd9](https://github.com/pixelfed/pixelfed/commit/15c4fdd9))
- Updated ContactAdmin mail, set New Support Message subject. ([bc3add05](https://github.com/pixelfed/pixelfed/commit/bc3add05))
- Updated StatusTransformer, prioritize scope over deprecated visibility attribute. ([6e45021f](https://github.com/pixelfed/pixelfed/commit/6e45021f))
- Updated StatusService, invalidate profile embed cache on deletion. ([acaf630d](https://github.com/pixelfed/pixelfed/commit/acaf630d))
- Updated status.reply view, fix archived post leakage. ([4fb3d1fa](https://github.com/pixelfed/pixelfed/commit/4fb3d1fa))
- Updated PostComponents, re-add time to timestamp. ([c5281dcd](https://github.com/pixelfed/pixelfed/commit/c5281dcd))
- Updated follow intent, fix follower count leak. ([03199e2f](https://github.com/pixelfed/pixelfed/commit/03199e2f))
- ([](https://github.com/pixelfed/pixelfed/commit/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)

View file

@ -4,7 +4,7 @@ namespace App;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary; use App\HasSnowflakePrimary;
class Collection extends Model class Collection extends Model
{ {

View file

@ -3,7 +3,7 @@
namespace App; namespace App;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary; use App\HasSnowflakePrimary;
class CollectionItem extends Model class CollectionItem extends Model
{ {

View file

@ -40,7 +40,7 @@ class FailedJobGC extends Command
{ {
FailedJob::chunk(50, function($jobs) { FailedJob::chunk(50, function($jobs) {
foreach($jobs as $job) { foreach($jobs as $job) {
if($job->failed_at->lt(now()->subMonth())) { if($job->failed_at->lt(now()->subHours(48))) {
$job->delete(); $job->delete();
} }
} }

View file

@ -7,6 +7,9 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use App\Story; use App\Story;
use App\StoryView; use App\StoryView;
use App\Jobs\StoryPipeline\StoryExpire;
use App\Jobs\StoryPipeline\StoryRotateMedia;
use App\Services\StoryService;
class StoryGC extends Command class StoryGC extends Command
{ {
@ -41,89 +44,41 @@ class StoryGC extends Command
*/ */
public function handle() public function handle()
{ {
$this->directoryScan(); $this->archiveExpiredStories();
$this->deleteViews(); $this->rotateMedia();
$this->deleteStories();
} }
protected function directoryScan() protected function archiveExpiredStories()
{ {
$day = now()->day; $stories = Story::whereActive(true)
->where('expires_at', '<', now())
->get();
if($day !== 3) { foreach($stories as $story) {
StoryExpire::dispatch($story)->onQueue('story');
}
}
protected function rotateMedia()
{
$queue = StoryService::rotateQueue();
if(!$queue || count($queue) == 0) {
return; return;
} }
$monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12); collect($queue)
->each(function($id) {
$t1 = Storage::directories('public/_esm.t1'); $story = StoryService::getById($id);
$t2 = Storage::directories('public/_esm.t2'); if(!$story) {
StoryService::removeRotateQueue($id);
$dirs = array_merge($t1, $t2); return;
}
foreach($dirs as $dir) { if($story->created_at->gt(now()->subMinutes(20))) {
$hash = last(explode('/', $dir)); return;
if($hash != $monthHash) { }
$this->info('Found directory to delete: ' . $dir); StoryRotateMedia::dispatch($story)->onQueue('story');
$this->deleteDirectory($dir); StoryService::removeRotateQueue($id);
}
}
$mh = hash('sha256', date('Y').'-.-'.date('m'));
$monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6);
$dirs = Storage::directories('public/_esm.t3');
foreach($dirs as $dir) {
$hash = last(explode('/', $dir));
if($hash != $monthHash) {
$this->info('Found directory to delete: ' . $dir);
$this->deleteDirectory($dir);
}
}
}
protected function deleteDirectory($path)
{
Storage::deleteDirectory($path);
}
protected function deleteViews()
{
StoryView::where('created_at', '<', now()->subMinutes(1441))->delete();
}
protected function deleteStories()
{
$stories = Story::where('created_at', '>', now()->subMinutes(30))
->whereNull('active')
->get();
foreach($stories as $story) {
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
DB::transaction(function() use($story) {
StoryView::whereStoryId($story->id)->delete();
$story->delete();
}); });
}
$stories = Story::where('created_at', '<', now()
->subMinutes(1441))
->get();
if($stories->count() == 0) {
exit;
}
foreach($stories as $story) {
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
DB::transaction(function() use($story) {
StoryView::whereStoryId($story->id)->delete();
$story->delete();
});
}
} }
} }

View file

@ -0,0 +1,19 @@
<?php
namespace App;
use App\Services\SnowflakeService;
trait HasSnowflakePrimary
{
public static function bootHasSnowflakePrimary()
{
static::saving(function ($model) {
if (is_null($model->getKey())) {
$keyName = $model->getKeyName();
$id = SnowflakeService::next();
$model->setAttribute($keyName, $id);
}
});
}
}

View file

@ -11,6 +11,7 @@ use App\{
Profile, Profile,
Report, Report,
Status, Status,
Story,
User User
}; };
use DB, Cache; use DB, Cache;
@ -27,6 +28,7 @@ use App\Http\Controllers\Admin\{
}; };
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Services\AdminStatsService; use App\Services\AdminStatsService;
use App\Services\StoryService;
class AdminController extends Controller class AdminController extends Controller
{ {
@ -465,4 +467,11 @@ class AdminController extends Controller
return response()->json($res); return response()->json($res);
} }
public function stories(Request $request)
{
$stories = Story::with('profile')->latest()->paginate(10);
$stats = StoryService::adminStats();
return view('admin.stories.home', compact('stories', 'stats'));
}
} }

View file

@ -1048,7 +1048,7 @@ class ApiV1Controller extends Controller
}, },
'filter_name' => 'nullable|string|max:24', 'filter_name' => 'nullable|string|max:24',
'filter_class' => 'nullable|alpha_dash|max:24', 'filter_class' => 'nullable|alpha_dash|max:24',
'description' => 'nullable|string|max:420' 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
]); ]);
$user = $request->user(); $user = $request->user();
@ -1091,6 +1091,17 @@ class ApiV1Controller extends Controller
$storagePath = MediaPathService::get($user, 2); $storagePath = MediaPathService::get($user, 2);
$path = $photo->store($storagePath); $path = $photo->store($storagePath);
$hash = \hash_file('sha256', $photo); $hash = \hash_file('sha256', $photo);
$license = null;
$settings = UserSetting::whereUserId($user->id)->first();
if($settings && !empty($settings->compose_settings)) {
$compose = json_decode($settings->compose_settings, true);
if(isset($compose['default_license']) && $compose['default_license'] != 1) {
$license = $compose['default_license'];
}
}
abort_if(MediaBlocklistService::exists($hash) == true, 451); abort_if(MediaBlocklistService::exists($hash) == true, 451);
@ -1105,6 +1116,9 @@ class ApiV1Controller extends Controller
$media->caption = $request->input('description'); $media->caption = $request->input('description');
$media->filter_class = $filterClass; $media->filter_class = $filterClass;
$media->filter_name = $filterName; $media->filter_name = $filterName;
if($license) {
$media->license = $license;
}
$media->save(); $media->save();
switch ($media->mime) { switch ($media->mime) {
@ -1140,7 +1154,7 @@ class ApiV1Controller extends Controller
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$this->validate($request, [ $this->validate($request, [
'description' => 'nullable|string|max:420' 'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
]); ]);
$user = $request->user(); $user = $request->user();
@ -1302,6 +1316,11 @@ class ApiV1Controller extends Controller
} }
} }
if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
NotificationService::warmCache($pid, 400, true);
}
$baseUrl = config('app.url') . '/api/v1/notifications?'; $baseUrl = config('app.url') . '/api/v1/notifications?';
if($minId == $maxId) { if($minId == $maxId) {

View file

@ -15,7 +15,8 @@ use App\{
Media, Media,
Notification, Notification,
Profile, Profile,
Status Status,
StatusArchived
}; };
use App\Transformer\Api\{ use App\Transformer\Api\{
AccountTransformer, AccountTransformer,
@ -36,9 +37,11 @@ use App\Jobs\VideoPipeline\{
VideoPostProcess, VideoPostProcess,
VideoThumbnail VideoThumbnail
}; };
use App\Services\AccountService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\MediaPathService; use App\Services\MediaPathService;
use App\Services\MediaBlocklistService; use App\Services\MediaBlocklistService;
use App\Services\StatusService;
class BaseApiController extends Controller class BaseApiController extends Controller
{ {
@ -54,26 +57,40 @@ class BaseApiController extends Controller
public function notifications(Request $request) public function notifications(Request $request)
{ {
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$pg = $request->input('pg'); $pid = $request->user()->profile_id;
if($pg == true) { $limit = $request->input('limit', 20);
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::whereProfileId($pid) $since = $request->input('since_id');
->whereDate('created_at', '>', $timeago) $min = $request->input('min_id');
->latest() $max = $request->input('max_id');
->simplePaginate(10);
$resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer()); if(!$since && !$min && !$max) {
$res = $this->fractal->createData($resource)->toArray(); $min = 1;
} else { }
$this->validate($request, [
'page' => 'nullable|integer|min:1|max:10', $maxId = null;
'limit' => 'nullable|integer|min:1|max:40' $minId = null;
]);
$limit = $request->input('limit') ?? 10; if($max) {
$page = $request->input('page') ?? 1; $res = NotificationService::getMax($pid, $max, $limit);
$end = (int) $page * $limit; $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
$start = (int) $end - $limit; if(!empty($ids)) {
$res = NotificationService::get($pid, $start, $end); $maxId = max($ids);
$minId = min($ids);
}
} else {
$res = NotificationService::getMin($pid, $min ?? $since, $limit);
$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
if(!empty($ids)) {
$maxId = max($ids);
$minId = min($ids);
}
}
if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
NotificationService::warmCache($pid, 400, true);
} }
return response()->json($res); return response()->json($res);
@ -272,4 +289,74 @@ class BaseApiController extends Controller
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
public function archive(Request $request, $id)
{
abort_if(!$request->user(), 403);
$status = Status::whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
if($status->scope === 'archived') {
return [200];
}
$archive = new StatusArchived;
$archive->status_id = $status->id;
$archive->profile_id = $status->profile_id;
$archive->original_scope = $status->scope;
$archive->save();
$status->scope = 'archived';
$status->visibility = 'draft';
$status->save();
StatusService::del($status->id);
AccountService::syncPostCount($status->profile_id);
return [200];
}
public function unarchive(Request $request, $id)
{
abort_if(!$request->user(), 403);
$status = Status::whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereProfileId($request->user()->profile_id)
->findOrFail($id);
if($status->scope !== 'archived') {
return [200];
}
$archive = StatusArchived::whereStatusId($status->id)
->whereProfileId($status->profile_id)
->firstOrFail();
$status->scope = $archive->original_scope;
$status->visibility = $archive->original_scope;
$status->save();
$archive->delete();
StatusService::del($status->id);
AccountService::syncPostCount($status->profile_id);
return [200];
}
public function archivedPosts(Request $request)
{
abort_if(!$request->user(), 403);
$statuses = Status::whereProfileId($request->user()->profile_id)
->whereScope('archived')
->orderByDesc('id')
->simplePaginate(10);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
return $fractal->createData($resource)->toArray();
}
} }

View file

@ -15,8 +15,10 @@ use App\{
Profile, Profile,
Place, Place,
Status, Status,
UserFilter UserFilter,
UserSetting
}; };
use App\Models\Poll;
use App\Transformer\Api\{ use App\Transformer\Api\{
MediaTransformer, MediaTransformer,
MediaDraftTransformer, MediaDraftTransformer,
@ -41,7 +43,7 @@ use App\Services\MediaPathService;
use App\Services\MediaBlocklistService; use App\Services\MediaBlocklistService;
use App\Services\MediaStorageService; use App\Services\MediaStorageService;
use App\Services\MediaTagService; use App\Services\MediaTagService;
use App\Services\ServiceService; use App\Services\StatusService;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Util\Lexer\Autolink; use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor; use App\Util\Lexer\Extractor;
@ -403,7 +405,7 @@ class ComposeController extends Controller
'media.*.id' => 'required|integer|min:1', 'media.*.id' => 'required|integer|min:1',
'media.*.filter_class' => 'nullable|alpha_dash|max:30', 'media.*.filter_class' => 'nullable|alpha_dash|max:30',
'media.*.license' => 'nullable|string|max:140', 'media.*.license' => 'nullable|string|max:140',
'media.*.alt' => 'nullable|string|max:140', 'media.*.alt' => 'nullable|string|max:'.config_cache('pixelfed.max_altext_length'),
'cw' => 'nullable|boolean', 'cw' => 'nullable|boolean',
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10', 'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable', 'place' => 'nullable',
@ -661,4 +663,73 @@ class ComposeController extends Controller
'finished' => $finished 'finished' => $finished
]; ];
} }
public function composeSettings(Request $request)
{
$uid = $request->user()->id;
$default = [
'default_license' => 1,
'media_descriptions' => false,
'max_altext_length' => config_cache('pixelfed.max_altext_length')
];
return array_merge($default, Cache::remember('profile:compose:settings:' . $uid, now()->addHours(12), function() use($uid) {
$res = UserSetting::whereUserId($uid)->first();
if(!$res || empty($res->compose_settings)) {
return [];
}
return json_decode($res->compose_settings, true);
}));
}
public function createPoll(Request $request)
{
$this->validate($request, [
'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500),
'cw' => 'nullable|boolean',
'visibility' => 'required|string|in:public,private',
'comments_disabled' => 'nullable',
'expiry' => 'required|in:60,360,1440,10080',
'pollOptions' => 'required|array|min:1|max:4'
]);
abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled');
abort_if(Status::whereType('poll')
->whereProfileId($request->user()->profile_id)
->whereCaption($request->input('caption'))
->where('created_at', '>', now()->subDays(2))
->exists()
, 422, 'Duplicate detected.');
$status = new Status;
$status->profile_id = $request->user()->profile_id;
$status->caption = $request->input('caption');
$status->rendered = Autolink::create()->autolink($status->caption);
$status->visibility = 'draft';
$status->scope = 'draft';
$status->type = 'poll';
$status->local = true;
$status->save();
$poll = new Poll;
$poll->status_id = $status->id;
$poll->profile_id = $status->profile_id;
$poll->poll_options = $request->input('pollOptions');
$poll->expires_at = now()->addMinutes($request->input('expiry'));
$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
return 0;
})->toArray();
$poll->save();
$status->visibility = $request->input('visibility');
$status->scope = $request->input('visibility');
$status->save();
NewStatusPipeline::dispatch($status);
return ['url' => $status->url()];
}
} }

View file

@ -29,8 +29,7 @@ class LikeController extends Controller
$profile = $user->profile; $profile = $user->profile;
$status = Status::findOrFail($request->input('item')); $status = Status::findOrFail($request->input('item'));
if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) {
if ($status->likes()->whereProfileId($profile->id)->count() !== 0) {
$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail(); $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
UnlikePipeline::dispatch($like); UnlikePipeline::dispatch($like);
} else { } else {

View file

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Status;
use App\Models\Poll;
use App\Models\PollVote;
use App\Services\PollService;
use App\Services\FollowerService;
class PollController extends Controller
{
public function __construct()
{
abort_if(!config_cache('instance.polls.enabled'), 404);
}
public function getPoll(Request $request, $id)
{
$poll = Poll::findOrFail($id);
$status = Status::findOrFail($poll->status_id);
if($status->scope != 'public') {
abort_if(!$request->user(), 403);
if($request->user()->profile_id != $status->profile_id) {
abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404);
}
}
$pid = $request->user() ? $request->user()->profile_id : false;
$poll = PollService::getById($id, $pid);
return $poll;
}
public function vote(Request $request, $id)
{
abort_unless($request->user(), 403);
$this->validate($request, [
'choices' => 'required|array'
]);
$pid = $request->user()->profile_id;
$poll_id = $id;
$choices = $request->input('choices');
// todo: implement multiple choice
$choice = $choices[0];
$poll = Poll::findOrFail($poll_id);
abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.');
abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.');
$vote = new PollVote;
$vote->status_id = $poll->status_id;
$vote->profile_id = $pid;
$vote->poll_id = $poll->id;
$vote->choice = $choice;
$vote->save();
$poll->votes_count = $poll->votes_count + 1;
$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) {
return $choice == $key ? $tally + 1 : $tally;
})->toArray();
$poll->save();
PollService::del($poll->status_id);
$res = PollService::get($poll->status_id, $pid);
return $res;
}
}

View file

@ -13,6 +13,7 @@ use App\Story;
use App\User; use App\User;
use App\UserFilter; use App\UserFilter;
use League\Fractal; use League\Fractal;
use App\Services\FollowerService;
use App\Util\Lexer\Nickname; use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger; use App\Util\Webfinger\Webfinger;
use App\Transformer\ActivityPub\ProfileOutbox; use App\Transformer\ActivityPub\ProfileOutbox;
@ -238,12 +239,12 @@ class ProfileController extends Controller
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail(); $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
$pid = $profile->id; $pid = $profile->id;
$authed = Auth::user()->profile; $authed = Auth::user()->profile_id;
abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404); abort_if($pid != $authed && !FollowerService::follows($authed, $pid), 404);
$exists = Story::whereProfileId($pid) $exists = Story::whereProfileId($pid)
->where('expires_at', '>', now()) ->whereActive(true)
->count(); ->exists();
abort_unless($exists > 0, 404); abort_unless($exists, 404);
return view('profile.story', compact('pid', 'profile')); return view('profile.story', compact('pid', 'profile'));
} }
} }

View file

@ -29,6 +29,7 @@ use App\Services\{
AccountService, AccountService,
LikeService, LikeService,
PublicTimelineService, PublicTimelineService,
ProfileService,
StatusService, StatusService,
SnowflakeService, SnowflakeService,
UserFilterService UserFilterService
@ -92,20 +93,15 @@ class PublicApiController extends Controller
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($postid); $status = Status::whereProfileId($profile->id)->findOrFail($postid);
$this->scopeCheck($profile, $status); $this->scopeCheck($profile, $status);
if(!Auth::check()) { if(!$request->user()) {
$res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) { $res = ['status' => StatusService::get($status->id)];
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); } else {
$res = [ $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
'status' => $this->fractal->createData($item)->toArray(), $res = [
]; 'status' => $this->fractal->createData($item)->toArray(),
return $res; ];
});
return response()->json($res);
} }
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
$res = [
'status' => $this->fractal->createData($item)->toArray(),
];
return response()->json($res); return response()->json($res);
} }
@ -402,11 +398,22 @@ class PublicApiController extends Controller
} }
$filtered = $user ? UserFilterService::filters($user->profile_id) : []; $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
$textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid); $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
$textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
$types = $textOnlyPosts ? $textOnlyReplies = false;
['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] :
['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']; if(config('exp.top')) {
$textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
$textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
if($textOnlyPosts) {
array_push($types, 'text');
}
}
if(config('exp.polls') == true) {
array_push($types, 'poll');
}
if($min || $max) { if($min || $max) {
$dir = $min ? '>' : '<'; $dir = $min ? '>' : '<';
@ -432,7 +439,7 @@ class PublicApiController extends Controller
'updated_at' 'updated_at'
) )
->whereIn('type', $types) ->whereIn('type', $types)
->when(!$textOnlyReplies, function($q, $textOnlyReplies) { ->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
return $q->whereNull('in_reply_to_id'); return $q->whereNull('in_reply_to_id');
}) })
->with('profile', 'hashtags', 'mentions') ->with('profile', 'hashtags', 'mentions')
@ -591,17 +598,27 @@ class PublicApiController extends Controller
public function accountFollowers(Request $request, $id) public function accountFollowers(Request $request, $id)
{ {
abort_unless(Auth::check(), 403); abort_unless(Auth::check(), 403);
$profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id); $profile = Profile::with('user')->whereNull('status')->findOrFail($id);
$owner = Auth::id() == $profile->user_id; $owner = Auth::id() == $profile->user_id;
if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) {
if(Auth::id() != $profile->user_id && $profile->is_private) {
return response()->json([]); return response()->json([]);
} }
if(!$profile->domain && !$profile->user->settings->show_profile_followers) {
return response()->json([]);
}
if(!$owner && $request->page > 5) { if(!$owner && $request->page > 5) {
return []; return [];
} }
$followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10);
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); $res = Follower::select('id', 'profile_id', 'following_id')
$res = $this->fractal->createData($resource)->toArray(); ->whereFollowingId($profile->id)
->orderByDesc('id')
->simplePaginate(10)
->map(function($follower) {
return ProfileService::get($follower['profile_id']);
})
->toArray();
return response()->json($res); return response()->json($res);
} }
@ -612,7 +629,6 @@ class PublicApiController extends Controller
$profile = Profile::with('user') $profile = Profile::with('user')
->whereNull('status') ->whereNull('status')
->whereNull('domain')
->findOrFail($id); ->findOrFail($id);
// filter by username // filter by username
@ -621,7 +637,10 @@ class PublicApiController extends Controller
$filter = ($owner == true) && ($search != null); $filter = ($owner == true) && ($search != null);
abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404); abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404);
abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
if(!$profile->domain) {
abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
}
if(!$owner && $request->page > 5) { if(!$owner && $request->page > 5) {
return []; return [];
@ -656,28 +675,27 @@ class PublicApiController extends Controller
'limit' => 'nullable|integer|min:1|max:24' 'limit' => 'nullable|integer|min:1|max:24'
]); ]);
$user = $request->user();
$profile = Profile::whereNull('status')->findOrFail($id); $profile = Profile::whereNull('status')->findOrFail($id);
$limit = $request->limit ?? 9; $limit = $request->limit ?? 9;
$max_id = $request->max_id; $max_id = $request->max_id;
$min_id = $request->min_id; $min_id = $request->min_id;
$scope = $request->only_media == true ? $scope = ['photo', 'photo:album', 'video', 'video:album'];
['photo', 'photo:album', 'video', 'video:album'] :
['photo', 'photo:album', 'video', 'video:album', 'share', 'reply'];
if($profile->is_private) { if($profile->is_private) {
if(!Auth::check()) { if(!$user) {
return response()->json([]); return response()->json([]);
} }
$pid = Auth::user()->profile->id; $pid = $user->profile_id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); $following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray(); return $following->push($pid)->toArray();
}); });
$visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : []; $visibility = true == in_array($profile->id, $following) ? ['public', 'unlisted', 'private'] : [];
} else { } else {
if(Auth::check()) { if($user) {
$pid = Auth::user()->profile->id; $pid = $user->profile_id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) { $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id'); $following = Follower::whereProfileId($pid)->pluck('following_id');
return $following->push($pid)->toArray(); return $following->push($pid)->toArray();
@ -688,84 +706,42 @@ class PublicApiController extends Controller
} }
} }
$tag = in_array('private', $visibility) ? 'private' : 'public';
if($min_id == 1 && $limit == 9 && $tag == 'public') {
$limit = 9;
$scope = ['photo', 'photo:album', 'video', 'video:album'];
$key = '_api:statuses:recent_9:'.$profile->id;
$res = Cache::remember($key, now()->addHours(24), function() use($profile, $scope, $visibility, $limit) {
$dir = '>';
$id = 1;
$timeline = Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'likes_count',
'reblogs_count',
'scope',
'visibility',
'local',
'place_id',
'comments_disabled',
'cw_summary',
'created_at',
'updated_at'
)->whereProfileId($profile->id)
->whereIn('type', $scope)
->where('id', $dir, $id)
->whereIn('visibility', $visibility)
->limit($limit)
->orderByDesc('id')
->get();
$resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
});
return $res;
}
$dir = $min_id ? '>' : '<'; $dir = $min_id ? '>' : '<';
$id = $min_id ?? $max_id; $id = $min_id ?? $max_id;
$timeline = Status::select( $res = Status::select(
'id', 'id',
'uri',
'caption',
'rendered',
'profile_id', 'profile_id',
'type', 'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'likes_count',
'reblogs_count',
'scope', 'scope',
'visibility',
'local', 'local',
'place_id', 'created_at'
'comments_disabled', )
'cw_summary', ->whereProfileId($profile->id)
'created_at', ->whereNull('in_reply_to_id')
'updated_at' ->whereNull('reblog_of_id')
)->whereProfileId($profile->id) ->whereIn('type', $scope)
->whereIn('type', $scope) ->where('id', $dir, $id)
->where('id', $dir, $id) ->whereIn('scope', $visibility)
->whereIn('visibility', $visibility) ->limit($limit)
->limit($limit) ->orderByDesc('id')
->orderByDesc('id') ->get()
->get(); ->map(function($s) use($user) {
try {
$status = StatusService::get($s->id, false);
} catch (\Exception $e) {
$status = false;
}
if($user && $status) {
$status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
}
return $status;
})
->filter(function($s) {
return $s;
})
->values();
$resource = new Fractal\Resource\Collection($timeline, new StatusStatelessTransformer()); return response()->json($res);
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
} }

View file

@ -7,6 +7,7 @@ use App\Following;
use App\ProfileSponsor; use App\ProfileSponsor;
use App\Report; use App\Report;
use App\UserFilter; use App\UserFilter;
use App\UserSetting;
use Auth, Cookie, DB, Cache, Purify; use Auth, Cookie, DB, Cache, Purify;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Carbon\Carbon; use Carbon\Carbon;
@ -21,6 +22,7 @@ use App\Http\Controllers\Settings\{
SecuritySettings SecuritySettings
}; };
use App\Jobs\DeletePipeline\DeleteAccountPipeline; use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
class SettingsController extends Controller class SettingsController extends Controller
{ {
@ -221,7 +223,7 @@ class SettingsController extends Controller
$sponsors->sponsors = json_encode($res); $sponsors->sponsors = json_encode($res);
$sponsors->save(); $sponsors->save();
$sponsors = $res; $sponsors = $res;
return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');; return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');
} }
public function timelineSettings(Request $request) public function timelineSettings(Request $request)
@ -249,7 +251,69 @@ class SettingsController extends Controller
} else { } else {
Redis::zrem('pf:tl:replies', $pid); Redis::zrem('pf:tl:replies', $pid);
} }
return redirect(route('settings.timeline')); return redirect(route('settings'))->with('status', 'Timeline settings successfully updated!');;
}
public function mediaSettings(Request $request)
{
$setting = UserSetting::whereUserId($request->user()->id)->firstOrFail();
$compose = $setting->compose_settings ? json_decode($setting->compose_settings, true) : [
'default_license' => null,
'media_descriptions' => false
];
return view('settings.media', compact('compose'));
}
public function updateMediaSettings(Request $request)
{
$this->validate($request, [
'default' => 'required|int|min:1|max:16',
'sync' => 'nullable',
'media_descriptions' => 'nullable'
]);
$license = $request->input('default');
$sync = $request->input('sync') == 'on';
$media_descriptions = $request->input('media_descriptions') == 'on';
$uid = $request->user()->id;
$setting = UserSetting::whereUserId($uid)->firstOrFail();
$compose = json_decode($setting->compose_settings, true);
$changed = false;
if($sync) {
$key = 'pf:settings:mls_recently:'.$uid;
if(Cache::get($key) == 2) {
$msg = 'You can only sync licenses twice per 24 hours. Try again later.';
return redirect(route('settings'))
->with('error', $msg);
}
}
if(!isset($compose['default_license']) || $compose['default_license'] !== $license) {
$compose['default_license'] = (int) $license;
$changed = true;
}
if(!isset($compose['media_descriptions']) || $compose['media_descriptions'] !== $media_descriptions) {
$compose['media_descriptions'] = $media_descriptions;
$changed = true;
}
if($changed) {
$setting->compose_settings = json_encode($compose);
$setting->save();
Cache::forget('profile:compose:settings:' . $request->user()->id);
}
if($sync) {
$val = Cache::has($key) ? 2 : 1;
Cache::put($key, $val, 86400);
MediaSyncLicensePipeline::dispatch($uid, $license);
return redirect(route('settings'))->with('status', 'Media licenses successfully synced! It may take a few minutes to take effect for every post.');
}
return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
} }
} }

View file

@ -0,0 +1,501 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Media;
use App\Profile;
use App\Report;
use App\DirectMessage;
use App\Notification;
use App\Status;
use App\Story;
use App\StoryView;
use App\Models\Poll;
use App\Models\PollVote;
use App\Services\ProfileService;
use App\Services\StoryService;
use Cache, Storage;
use Image as Intervention;
use App\Services\FollowerService;
use App\Services\MediaPathService;
use FFMpeg;
use FFMpeg\Coordinate\Dimension;
use FFMpeg\Format\Video\X264;
use App\Jobs\StoryPipeline\StoryReactionDeliver;
use App\Jobs\StoryPipeline\StoryReplyDeliver;
use App\Jobs\StoryPipeline\StoryFanout;
use App\Jobs\StoryPipeline\StoryDelete;
use ImageOptimizer;
class StoryComposeController extends Controller
{
public function apiV1Add(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'file' => function() {
return [
'required',
'mimes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
]);
$user = $request->user();
$count = Story::whereProfileId($user->profile_id)
->whereActive(true)
->where('expires_at', '>', now())
->count();
if($count >= Story::MAX_PER_DAY) {
abort(418, 'You have reached your limit for new Stories today.');
}
$photo = $request->file('file');
$path = $this->storePhoto($photo, $user);
$story = new Story();
$story->duration = 3;
$story->profile_id = $user->profile_id;
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->bearcap_token = str_random(64);
$story->save();
$url = $story->path;
$res = [
'code' => 200,
'msg' => 'Successfully added',
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
if($story->type === 'video') {
$video = FFMpeg::open($path);
$duration = $video->getDurationInSeconds();
$res['media_duration'] = $duration;
if($duration > 500) {
Storage::delete($story->path);
$story->delete();
return response()->json([
'message' => 'Video duration cannot exceed 60 seconds'
], 422);
}
}
return $res;
}
protected function storePhoto($photo, $user)
{
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
$fpath = storage_path('app/' . $path);
$img = Intervention::make($fpath);
$img->orientate();
$img->save($fpath, config_cache('pixelfed.image_quality'));
$img->destroy();
}
return $path;
}
public function cropPhoto(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required|integer|min:1',
'width' => 'required',
'height' => 'required',
'x' => 'required',
'y' => 'required'
]);
$user = $request->user();
$id = $request->input('media_id');
$width = round($request->input('width'));
$height = round($request->input('height'));
$x = round($request->input('x'));
$y = round($request->input('y'));
$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
$path = storage_path('app/' . $story->path);
if(!is_file($path)) {
abort(400, 'Invalid or missing media.');
}
if($story->type === 'photo') {
$img = Intervention::make($path);
$img->crop($width, $height, $x, $y);
$img->resize(1080, 1920, function ($constraint) {
$constraint->aspectRatio();
});
$img->save($path, config_cache('pixelfed.image_quality'));
}
return [
'code' => 200,
'msg' => 'Successfully cropped',
];
}
public function publishStory(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required',
'duration' => 'required|integer|min:3|max:120',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
$id = $request->input('media_id');
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = true;
$story->expires_at = now()->addMinutes(1440);
$story->duration = $request->input('duration', 10);
$story->can_reply = $request->input('can_reply');
$story->can_react = $request->input('can_react');
$story->save();
StoryService::delLatest($story->profile_id);
StoryFanout::dispatch($story)->onQueue('story');
StoryService::addRotateQueue($story->id);
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function apiV1Delete(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = false;
$story->save();
StoryDelete::dispatch($story)->onQueue('story');
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function compose(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
return view('stories.compose');
}
public function createPoll(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
abort_if(!config_cache('instance.polls.enabled'), 404);
return $request->all();
}
public function publishStoryPoll(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'question' => 'required|string|min:6|max:140',
'options' => 'required|array|min:2|max:4',
'can_reply' => 'required|boolean',
'can_react' => 'required|boolean'
]);
$pid = $request->user()->profile_id;
$count = Story::whereProfileId($pid)
->whereActive(true)
->where('expires_at', '>', now())
->count();
if($count >= Story::MAX_PER_DAY) {
abort(418, 'You have reached your limit for new Stories today.');
}
$story = new Story;
$story->type = 'poll';
$story->story = json_encode([
'question' => $request->input('question'),
'options' => $request->input('options')
]);
$story->public = false;
$story->local = true;
$story->profile_id = $pid;
$story->expires_at = now()->addMinutes(1440);
$story->duration = 30;
$story->can_reply = false;
$story->can_react = false;
$story->save();
$poll = new Poll;
$poll->story_id = $story->id;
$poll->profile_id = $pid;
$poll->poll_options = $request->input('options');
$poll->expires_at = $story->expires_at;
$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
return 0;
})->toArray();
$poll->save();
$story->active = true;
$story->save();
StoryService::delLatest($story->profile_id);
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function storyPollVote(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'ci' => 'required|integer|min:0|max:3'
]);
$pid = $request->user()->profile_id;
$ci = $request->input('ci');
$story = Story::findOrFail($request->input('sid'));
abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
$poll = Poll::whereStoryId($story->id)->firstOrFail();
$vote = new PollVote;
$vote->profile_id = $pid;
$vote->poll_id = $poll->id;
$vote->story_id = $story->id;
$vote->status_id = null;
$vote->choice = $ci;
$vote->save();
$poll->votes_count = $poll->votes_count + 1;
$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
return $ci == $key ? $tally + 1 : $tally;
})->toArray();
$poll->save();
return 200;
}
public function storeReport(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1',
]);
$pid = $request->user()->profile_id;
$sid = $request->input('id');
$type = $request->input('type');
$types = [
// original 3
'spam',
'sensitive',
'abusive',
// new
'underage',
'copyright',
'impersonation',
'scam',
'terrorism'
];
abort_if(!in_array($type, $types), 422, 'Invalid story report type');
$story = Story::findOrFail($sid);
abort_if($story->profile_id == $pid, 422, 'Cannot report your own story');
abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
if( Report::whereProfileId($pid)
->whereObjectType('App\Story')
->whereObjectId($story->id)
->exists()
) {
return response()->json(['error' => [
'code' => 409,
'message' => 'Cannot report the same story again'
]], 409);
}
$report = new Report;
$report->profile_id = $pid;
$report->user_id = $request->user()->id;
$report->object_id = $story->id;
$report->object_type = 'App\Story';
$report->reported_profile_id = $story->profile_id;
$report->type = $type;
$report->message = null;
$report->save();
return [200];
}
public function react(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'reaction' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('reaction');
$story = Story::findOrFail($request->input('sid'));
abort_if(!$story->can_react, 422);
abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
$status = new Status;
$status->profile_id = $pid;
$status->type = 'story:reaction';
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id,
'reaction' => $text
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:react';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'reaction' => $text
]);
$dm->save();
if($story->local) {
// generate notification
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story: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');
}
StoryService::reactIncrement($story->id, $pid);
return 200;
}
public function comment(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required',
'caption' => 'required|string'
]);
$pid = $request->user()->profile_id;
$text = $request->input('caption');
$story = Story::findOrFail($request->input('sid'));
abort_if(!$story->can_reply, 422);
$status = new Status;
$status->type = 'story:reply';
$status->profile_id = $pid;
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $pid;
$dm->type = 'story:comment';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $story->profile->username,
'story_actor_username' => $request->user()->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
if($story->local) {
// generate notification
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$request->user()->username} commented on story";
$n->rendered = "{$request->user()->username} commented on story";
$n->save();
} else {
StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
}
return 200;
}
}

View file

@ -4,337 +4,106 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\DirectMessage;
use App\Follower;
use App\Notification;
use App\Media; use App\Media;
use App\Profile; use App\Profile;
use App\Status;
use App\Story; use App\Story;
use App\StoryView; use App\StoryView;
use App\Services\PollService;
use App\Services\ProfileService;
use App\Services\StoryService; use App\Services\StoryService;
use Cache, Storage; use Cache, Storage;
use Image as Intervention; use Image as Intervention;
use App\Services\AccountService;
use App\Services\FollowerService; use App\Services\FollowerService;
use App\Services\MediaPathService; use App\Services\MediaPathService;
use FFMpeg; use FFMpeg;
use FFMpeg\Coordinate\Dimension; use FFMpeg\Coordinate\Dimension;
use FFMpeg\Format\Video\X264; use FFMpeg\Format\Video\X264;
use League\Fractal\Manager;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Resource\Item;
use App\Transformer\ActivityPub\Verb\StoryVerb;
use App\Jobs\StoryPipeline\StoryViewDeliver;
class StoryController extends Controller class StoryController extends StoryComposeController
{ {
public function apiV1Add(Request $request) public function recent(Request $request)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$pid = $request->user()->profile_id;
$this->validate($request, [ $s = Story::select('stories.*', 'followers.following_id')
'file' => function() { ->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
return [ ->where('followers.profile_id', $pid)
'required', ->where('stories.active', true)
'mimes:image/jpeg,image/png,video/mp4', ->groupBy('followers.following_id')
'max:' . config_cache('pixelfed.max_photo_size'), ->orderByDesc('id')
];
},
]);
$user = $request->user();
if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) {
abort(400, 'You have reached your limit for new Stories today.');
}
$photo = $request->file('file');
$path = $this->storePhoto($photo, $user);
$story = new Story();
$story->duration = 3;
$story->profile_id = $user->profile_id;
$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
$story->mime = $photo->getMimeType();
$story->path = $path;
$story->local = true;
$story->size = $photo->getSize();
$story->save();
$url = $story->path;
if($story->type === 'video') {
$video = FFMpeg::open($path);
$width = $video->getVideoStream()->get('width');
$height = $video->getVideoStream()->get('height');
if($width !== 1080 || $height !== 1920) {
Storage::delete($story->path);
$story->delete();
abort(422, 'Invalid video dimensions, must be 1080x1920');
}
}
return [
'code' => 200,
'msg' => 'Successfully added',
'media_id' => (string) $story->id,
'media_url' => url(Storage::url($url)) . '?v=' . time(),
'media_type' => $story->type
];
}
protected function storePhoto($photo, $user)
{
$mimes = explode(',', config_cache('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png',
'video/mp4'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = MediaPathService::story($user->profile);
$path = $photo->store($storagePath);
if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
$fpath = storage_path('app/' . $path);
$img = Intervention::make($fpath);
$img->orientate();
$img->save($fpath, config_cache('pixelfed.image_quality'));
$img->destroy();
}
return $path;
}
public function cropPhoto(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required|integer|min:1',
'width' => 'required',
'height' => 'required',
'x' => 'required',
'y' => 'required'
]);
$user = $request->user();
$id = $request->input('media_id');
$width = round($request->input('width'));
$height = round($request->input('height'));
$x = round($request->input('x'));
$y = round($request->input('y'));
$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
$path = storage_path('app/' . $story->path);
if(!is_file($path)) {
abort(400, 'Invalid or missing media.');
}
if($story->type === 'photo') {
$img = Intervention::make($path);
$img->crop($width, $height, $x, $y);
$img->resize(1080, 1920, function ($constraint) {
$constraint->aspectRatio();
});
$img->save($path, config_cache('pixelfed.image_quality'));
}
return [
'code' => 200,
'msg' => 'Successfully cropped',
];
}
public function publishStory(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'media_id' => 'required',
'duration' => 'required|integer|min:3|max:10'
]);
$id = $request->input('media_id');
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
$story->active = true;
$story->duration = $request->input('duration', 10);
$story->expires_at = now()->addMinutes(1450);
$story->save();
return [
'code' => 200,
'msg' => 'Successfully published',
];
}
public function apiV1Delete(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
$story->delete();
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function apiV1Recent(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
$following = $profile->following->pluck('id')->toArray();
if(config('database.default') == 'pgsql') {
$db = Story::with('profile')
->whereActive(true)
->whereIn('profile_id', $following)
->where('expires_at', '>', now())
->distinct('profile_id')
->take(9)
->get(); ->get();
} else {
$db = Story::with('profile')
->whereActive(true)
->whereIn('profile_id', $following)
->where('created_at', '>', now()->subDay())
->orderByDesc('expires_at')
->groupBy('profile_id')
->take(9)
->get();
}
$stories = $db->map(function($s, $k) { $res = $s->map(function($s) use($pid) {
$profile = AccountService::get($s->profile_id);
$url = $profile['local'] ? url("/stories/{$profile['username']}") :
url("/i/rs/{$profile['id']}");
return [ return [
'id' => (string) $s->id, 'pid' => $profile['id'],
'photo' => $s->profile->avatarUrl(), 'avatar' => $profile['avatar'],
'name' => $s->profile->username, 'local' => $profile['local'],
'link' => $s->profile->url(), 'username' => $profile['acct'],
'lastUpdated' => (int) $s->created_at->format('U'), 'url' => $url,
'seen' => $s->seen(), 'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
'items' => [], 'sid' => $s->id
'pid' => (string) $s->profile->id
]; ];
});
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Fetch(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile;
$profile = Profile::findOrFail($id);
if($id == $authed->id) {
$publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
}
$stories = Story::whereProfileId($profile->id)
->whereActive(true)
->orderBy('expires_at', 'desc')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
}) })
->get() ->sortBy('seen')
->map(function($s, $k) { ->values();
return [
'id' => (string) $s->id,
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'created_ago' => $s->created_at->diffForHumans(null, true, true),
'seen' => $s->seen()
];
})->toArray();
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Item(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile;
$story = Story::with('profile')
->whereActive(true)
->where('expires_at', '>', now())
->findOrFail($id);
$profile = $story->profile;
if($story->profile_id == $authed->id) {
$publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
}
abort_if(!$publicOnly, 403);
$res = [
'id' => (string) $story->id,
'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo',
'length' => 10,
'src' => url(Storage::url($story->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $story->created_at->format('U'),
'expires_at' => (int) $story->expires_at->format('U'),
'seen' => $story->seen()
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
public function apiV1Profile(Request $request, $id) public function profile(Request $request, $id)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile; $authed = $request->user()->profile_id;
$profile = Profile::findOrFail($id); $profile = Profile::findOrFail($id);
if($id == $authed->id) {
$publicOnly = true; if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
} else { return [];
$publicOnly = (bool) $profile->followedBy($authed);
} }
$stories = Story::whereProfileId($profile->id) $stories = Story::whereProfileId($profile->id)
->whereActive(true) ->whereActive(true)
->orderBy('expires_at') ->orderBy('expires_at')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get() ->get()
->map(function($s, $k) { ->map(function($s, $k) use($authed) {
return [ $seen = StoryService::hasSeen($authed, $s->id);
'id' => $s->id, $res = [
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo', 'id' => (string) $s->id,
'length' => 10, 'type' => $s->type,
'duration' => $s->duration,
'src' => url(Storage::url($s->path)), 'src' => url(Storage::url($s->path)),
'preview' => null, 'created_at' => $s->created_at->toAtomString(),
'link' => null, 'expires_at' => $s->expires_at->toAtomString(),
'linkText' => null, 'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
'time' => $s->created_at->format('U'), 'seen' => $seen,
'expires_at' => (int) $s->expires_at->format('U'), 'progress' => $seen ? 100 : 0,
'seen' => $s->seen() 'can_reply' => (bool) $s->can_reply,
'can_react' => (bool) $s->can_react
]; ];
if($s->type == 'poll') {
$res['question'] = json_decode($s->story, true)['question'];
$res['options'] = json_decode($s->story, true)['options'];
$res['voted'] = PollService::votedStory($s->id, $authed);
if($res['voted']) {
$res['voted_index'] = PollService::storyChoice($s->id, $authed);
}
}
return $res;
})->toArray(); })->toArray();
if(count($stories) == 0) { if(count($stories) == 0) {
return []; return [];
@ -342,32 +111,27 @@ class StoryController extends Controller
$cursor = count($stories) - 1; $cursor = count($stories) - 1;
$stories = [[ $stories = [[
'id' => (string) $stories[$cursor]['id'], 'id' => (string) $stories[$cursor]['id'],
'photo' => $profile->avatarUrl(), 'nodes' => $stories,
'name' => $profile->username, 'account' => AccountService::get($profile->id),
'link' => $profile->url(),
'lastUpdated' => (int) now()->format('U'),
'seen' => null,
'items' => $stories,
'pid' => (string) $profile->id 'pid' => (string) $profile->id
]]; ]];
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
} }
public function apiV1Viewed(Request $request) public function viewed(Request $request)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [ $this->validate($request, [
'id' => 'required|integer|min:1|exists:stories', 'id' => 'required|min:1',
]); ]);
$id = $request->input('id'); $id = $request->input('id');
$authed = $request->user()->profile; $authed = $request->user()->profile;
$story = Story::with('profile') $story = Story::with('profile')
->where('expires_at', '>', now())
->orderByDesc('expires_at')
->findOrFail($id); ->findOrFail($id);
$exp = $story->expires_at;
$profile = $story->profile; $profile = $story->profile;
@ -378,72 +142,32 @@ class StoryController extends Controller
$publicOnly = (bool) $profile->followedBy($authed); $publicOnly = (bool) $profile->followedBy($authed);
abort_if(!$publicOnly, 403); abort_if(!$publicOnly, 403);
StoryView::firstOrCreate([
$v = StoryView::firstOrCreate([
'story_id' => $id, 'story_id' => $id,
'profile_id' => $authed->id 'profile_id' => $authed->id
]); ]);
$story->view_count = $story->view_count + 1; if($v->wasRecentlyCreated) {
$story->save(); Story::findOrFail($story->id)->increment('view_count');
if($story->local == false) {
StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
}
}
Cache::forget('stories:recent:by_id:' . $authed->id);
StoryService::addSeen($authed->id, $story->id);
return ['code' => 200]; return ['code' => 200];
} }
public function apiV1Exists(Request $request, $id) public function exists(Request $request, $id)
{ {
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404); abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$res = (bool) Story::whereProfileId($id) return response()->json(Story::whereProfileId($id)
->whereActive(true) ->whereActive(true)
->where('expires_at', '>', now()) ->exists());
->count();
return response()->json($res);
}
public function apiV1Me(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
$stories = Story::whereProfileId($profile->id)
->whereActive(true)
->orderBy('expires_at')
->where('expires_at', '>', now())
->get()
->map(function($s, $k) {
return [
'id' => $s->id,
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'seen' => true
];
})->toArray();
$ts = count($stories) ? last($stories)['time'] : null;
$res = [
'id' => (string) $profile->id,
'photo' => $profile->avatarUrl(),
'name' => $profile->username,
'link' => $profile->url(),
'lastUpdated' => $ts,
'seen' => true,
'items' => $stories
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function compose(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
return view('stories.compose');
} }
public function iRedirect(Request $request) public function iRedirect(Request $request)
@ -455,4 +179,91 @@ class StoryController extends Controller
$username = $user->username; $username = $user->username;
return redirect("/stories/{$username}"); return redirect("/stories/{$username}");
} }
public function viewers(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required|string'
]);
$pid = $request->user()->profile_id;
$sid = $request->input('sid');
$story = Story::whereProfileId($pid)
->whereActive(true)
->findOrFail($sid);
$viewers = StoryView::whereStoryId($story->id)
->latest()
->simplePaginate(10)
->map(function($view) {
return AccountService::get($view->profile_id);
})
->values();
return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function remoteStory(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::findOrFail($id);
if($profile->user_id != null || $profile->domain == null) {
return redirect('/stories/' . $profile->username);
}
$pid = $profile->id;
return view('stories.show_remote', compact('pid'));
}
public function pollResults(Request $request)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'sid' => 'required|string'
]);
$pid = $request->user()->profile_id;
$sid = $request->input('sid');
$story = Story::whereProfileId($pid)
->whereActive(true)
->findOrFail($sid);
return PollService::storyResults($sid);
}
public function getActivityObject(Request $request, $username, $id)
{
abort_if(!config_cache('instance.stories.enabled'), 404);
if(!$request->wantsJson()) {
return redirect('/stories/' . $username);
}
abort_if(!$request->hasHeader('Authorization'), 404);
$profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
$story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
abort_if($story->bearcap_token == null, 404);
abort_if(now()->gt($story->expires_at), 404);
$token = substr($request->header('Authorization'), 7);
abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
$fractal = new Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Item($story, new StoryVerb());
$res = $fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function showSystemStory()
{
// return view('stories.system');
}
} }

View file

@ -14,59 +14,62 @@ use Illuminate\Support\Facades\Redis;
class FollowPipeline implements ShouldQueue class FollowPipeline implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $follower; protected $follower;
/** /**
* Delete the job if its models no longer exist. * Delete the job if its models no longer exist.
* *
* @var bool * @var bool
*/ */
public $deleteWhenMissingModels = true; public $deleteWhenMissingModels = true;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @return void * @return void
*/ */
public function __construct($follower) public function __construct($follower)
{ {
$this->follower = $follower; $this->follower = $follower;
} }
/** /**
* Execute the job. * Execute the job.
* *
* @return void * @return void
*/ */
public function handle() public function handle()
{ {
$follower = $this->follower; $follower = $this->follower;
$actor = $follower->actor; $actor = $follower->actor;
$target = $follower->target; $target = $follower->target;
if($target->domain || !$target->private_key) { Cache::forget('profile:following:' . $actor->id);
return; Cache::forget('profile:following:' . $target->id);
}
try { if($target->domain || !$target->private_key) {
$notification = new Notification(); return;
$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();
$redis = Redis::connection(); try {
$notification = new Notification();
$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();
$nkey = config('cache.prefix').':user.'.$target->id.'.notifications'; $redis = Redis::connection();
$redis->lpush($nkey, $notification->id);
} catch (Exception $e) { $nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
Log::error($e); $redis->lpush($nkey, $notification->id);
} } catch (Exception $e) {
} Log::error($e);
}
}
} }

View file

@ -0,0 +1,56 @@
<?php
namespace App\Jobs\InstancePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use App\Instance;
use App\Profile;
use App\Services\NodeinfoService;
class FetchNodeinfoPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $instance;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Instance $instance)
{
$this->instance = $instance;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$instance = $this->instance;
$ni = NodeinfoService::get($instance->domain);
if($ni) {
if(isset($ni['software']) && is_array($ni['software']) && isset($ni['software']['name'])) {
$software = $ni['software']['name'];
$instance->software = strtolower(strip_tags($software));
$instance->last_crawled_at = now();
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->save();
}
} else {
$instance->user_count = Profile::whereDomain($instance->domain)->count();
$instance->last_crawled_at = now();
$instance->save();
}
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\InstancePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use App\Instance;
use App\Profile;
use App\Services\NodeinfoService;
class InstanceCrawlPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
Instance::whereNull('last_crawled_at')->whereNull('software')->chunk(50, function($instances) use($headers) {
foreach($instances as $instance) {
FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
}
});
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Jobs\MediaPipeline;
use App\Media;
use App\User;
use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Services\StatusService;
class MediaSyncLicensePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $userId;
protected $licenseId;
public function __construct($userId, $licenseId)
{
$this->userId = $userId;
$this->licenseId = $licenseId;
}
public function handle()
{
$licenseId = $this->licenseId;
if(!$licenseId || !$this->userId) {
return 1;
}
Media::whereUserId($this->userId)
->chunk(100, function($medias) use($licenseId) {
foreach($medias as $media) {
$media->license = $licenseId;
$media->save();
Cache::forget('status:transformer:media:attachments:'. $media->status_id);
StatusService::del($media->status_id);
}
});
}
}

View file

@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateNote; use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool; use GuzzleHttp\Pool;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature;
class StatusActivityPubDeliver implements ShouldQueue class StatusActivityPubDeliver implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status; 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. * Delete the job if its models no longer exist.
* *
* @return void * @var bool
*/ */
public function handle() public $deleteWhenMissingModels = true;
{
$status = $this->status;
$profile = $status->profile;
if($status->local == false || $status->url || $status->uri) { /**
return; * Create a new job instance.
} *
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
$audience = $status->profile->getAudienceInbox(); /**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$profile = $status->profile;
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { if($status->local == false || $status->url || $status->uri) {
// Return on profiles with no remote followers return;
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':
$activitypubObject = new CreateQuestion();
break;
default:
$activitypubObject = new CreateNote();
break;
}
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer()); $fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new CreateNote()); $resource = new Fractal\Resource\Item($status, $activitypubObject);
$activity = $fractal->createData($resource)->toArray(); $activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) { $client = new Client([
foreach($audience as $url) { 'timeout' => config('federation.activitypub.delivery.timeout')
$headers = HttpSignature::sign($profile, $url, $activity); ]);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$pool = new Pool($client, $requests($audience), [ $requests = function($audience) use ($client, $activity, $profile, $payload) {
'concurrency' => config('federation.activitypub.delivery.concurrency'), foreach($audience as $url) {
'fulfilled' => function ($response, $index) { $headers = HttpSignature::sign($profile, $url, $activity);
}, yield function() use ($client, $url, $headers, $payload) {
'rejected' => function ($reason, $index) { return $client->postAsync($url, [
} 'curl' => [
]); CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
$promise = $pool->promise(); CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
]
]);
};
}
};
$promise->wait(); $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,136 @@
<?php
namespace App\Jobs\StoryPipeline;
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 Storage;
use App\Story;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryDelete implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == false) {
return;
}
StoryService::removeRotateQueue($story->id);
StoryService::delLatest($story->profile_id);
StoryService::delById($story->id);
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
$story->views()->delete();
$profile = $story->profile;
$activity = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $story->url() . '#delete',
'type' => 'Delete',
'actor' => $profile->permalink(),
'object' => [
'id' => $story->url(),
'type' => 'Story',
],
];
$this->fanoutExpiry($profile, $activity);
// delete notifications
// delete polls
// delete reports
$story->delete();
return;
}
protected function fanoutExpiry($profile, $activity)
{
$audience = FollowerService::softwareAudience($profile->id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$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,169 @@
<?php
namespace App\Jobs\StoryPipeline;
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 Storage;
use App\Story;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryExpire implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == false) {
$this->handleRemoteExpiry();
return;
}
if($story->active == false) {
return;
}
if($story->expires_at->gt(now())) {
return;
}
$story->active = false;
$story->save();
$this->rotateMediaPath();
$this->fanoutExpiry();
StoryService::delLatest($story->profile_id);
}
protected function rotateMediaPath()
{
$story = $this->story;
$date = date('Y').date('m');
$old = $story->path;
$base = "story_archives/{$story->profile_id}/{$date}/";
$paths = explode('/', $old);
$path = array_pop($paths);
$newPath = $base . $path;
if(Storage::exists($old) == true) {
$dir = implode('/', $paths);
Storage::move($old, $newPath);
Storage::delete($old);
$story->bearcap_token = null;
$story->path = $newPath;
$story->save();
Storage::deleteDirectory($dir);
}
}
protected function fanoutExpiry()
{
$story = $this->story;
$profile = $story->profile;
if($story->local == false || $story->remote_url) {
return;
}
$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($story, new DeleteStory());
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$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();
}
protected function handleRemoteExpiry()
{
$story = $this->story;
$story->active = false;
$story->save();
$path = $story->path;
if(Storage::exists($path) == true) {
Storage::delete($path);
}
$story->views()->delete();
$story->delete();
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Jobs\StoryPipeline;
use Cache, Log;
use App\Story;
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\CreateStory;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use App\Util\ActivityPub\HttpSignature;
use App\Services\FollowerService;
use App\Services\StoryService;
class StoryFanout implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$profile = $story->profile;
if($story->local == false || $story->remote_url) {
return;
}
StoryService::delLatest($story->profile_id);
$audience = FollowerService::softwareAudience($story->profile_id, 'pixelfed');
if(empty($audience)) {
// Return on profiles with no remote followers
return;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($story, new CreateStory());
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$requests = function($audience) use ($client, $activity, $profile, $payload) {
foreach($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity);
yield function() use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true
]
]);
};
}
};
$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,144 @@
<?php
namespace App\Jobs\StoryPipeline;
use Cache, Log;
use App\Story;
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 App\Services\FollowerService;
use App\Util\Lexer\Bearcap;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\ConnectionException;
use App\Util\ActivityPub\Validator\StoryValidator;
use App\Services\StoryService;
use App\Services\MediaPathService;
use Illuminate\Support\Str;
use Illuminate\Http\File;
use Illuminate\Support\Facades\Storage;
class StoryFetch implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $activity;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$activity = $this->activity;
$activityId = $activity['id'];
$activityActor = $activity['actor'];
if(parse_url($activityId, PHP_URL_HOST) !== parse_url($activityActor, PHP_URL_HOST)) {
return;
}
$bearcap = Bearcap::decode($activity['object']['object']);
if(!$bearcap) {
return;
}
$url = $bearcap['url'];
$token = $bearcap['token'];
if(parse_url($activityId, PHP_URL_HOST) !== parse_url($url, PHP_URL_HOST)) {
return;
}
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
];
try {
$res = Http::withHeaders($headers)
->timeout(30)
->get($url);
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (\Exception $e) {
return false;
}
$payload = $res->json();
if(StoryValidator::validate($payload) == false) {
return;
}
if(Helpers::validateUrl($payload['attachment']['url']) == false) {
return;
}
$type = $payload['attachment']['type'] == 'Image' ? 'photo' : 'video';
$profile = Helpers::profileFetch($payload['attributedTo']);
$ext = pathinfo($payload['attachment']['url'], PATHINFO_EXTENSION);
$storagePath = MediaPathService::story($profile);
$fileName = Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $ext;
$contextOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peername' => false
]
];
$ctx = stream_context_create($contextOptions);
$data = file_get_contents($payload['attachment']['url'], false, $ctx);
$tmpBase = storage_path('app/remcache/');
$tmpPath = $profile->id . '-' . $fileName;
$tmpName = $tmpBase . $tmpPath;
file_put_contents($tmpName, $data);
$disk = Storage::disk(config('filesystems.default'));
$path = $disk->putFileAs($storagePath, new File($tmpName), $fileName, 'public');
$size = filesize($tmpName);
unlink($tmpName);
$story = new Story;
$story->profile_id = $profile->id;
$story->object_id = $payload['id'];
$story->size = $size;
$story->mime = $payload['attachment']['mediaType'];
$story->duration = $payload['duration'];
$story->media_url = $payload['attachment']['url'];
$story->type = $type;
$story->public = false;
$story->local = false;
$story->active = true;
$story->path = $path;
$story->view_count = 0;
$story->can_reply = $payload['can_reply'];
$story->can_react = $payload['can_react'];
$story->created_at = now()->parse($payload['published']);
$story->expires_at = now()->parse($payload['expiresAt']);
$story->save();
StoryService::delLatest($story->profile_id);
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Jobs\StoryPipeline;
use App\Story;
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 App\Util\ActivityPub\Helpers;
class StoryReactionDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
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(Story $story, Status $status)
{
$this->story = $story;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$status = $this->status;
if($story->local == true) {
return;
}
$target = $story->profile;
$actor = $status->profile;
$to = $target->inbox_url;
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Story:Reaction',
'to' => $target->permalink(),
'actor' => $actor->permalink(),
'content' => $status->caption,
'inReplyTo' => $story->object_id,
'published' => $status->created_at->toAtomString()
];
Helpers::sendSignedObject($actor, $to, $payload);
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Jobs\StoryPipeline;
use App\Story;
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 App\Util\ActivityPub\Helpers;
class StoryReplyDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
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(Story $story, Status $status)
{
$this->story = $story;
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
$status = $this->status;
if($story->local == true) {
return;
}
$target = $story->profile;
$actor = $status->profile;
$to = $target->inbox_url;
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Story:Reply',
'to' => $target->permalink(),
'actor' => $actor->permalink(),
'content' => $status->caption,
'inReplyTo' => $story->object_id,
'published' => $status->created_at->toAtomString()
];
Helpers::sendSignedObject($actor, $to, $payload);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Jobs\StoryPipeline;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Story;
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;
class StoryRotateMedia implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story)
{
$this->story = $story;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == false) {
return;
}
$paths = explode('/', $story->path);
$name = array_pop($paths);
$oldPath = $story->path;
$ext = pathinfo($name, PATHINFO_EXTENSION);
$new = Str::random(13) . '_' . Str::random(24) . '_' . Str::random(3) . '.' . $ext;
array_push($paths, $new);
$newPath = implode('/', $paths);
if(Storage::exists($oldPath)) {
Storage::copy($oldPath, $newPath);
$story->path = $newPath;
$story->bearcap_token = null;
$story->save();
Storage::delete($oldPath);
}
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Jobs\StoryPipeline;
use App\Story;
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;
class StoryViewDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $story;
protected $profile;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Story $story, Profile $profile)
{
$this->story = $story;
$this->profile = $profile;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$story = $this->story;
if($story->local == true) {
return;
}
$actor = $this->profile;
$target = $story->profile;
$to = $target->inbox_url;
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $actor->permalink('#stories/' . $story->id . '/view'),
'type' => 'View',
'to' => $target->permalink(),
'actor' => $actor->permalink(),
'object' => [
'type' => 'Story',
'object' => $story->object_id
]
];
Helpers::sendSignedObject($actor, $to, $payload);
}
}

View file

@ -32,6 +32,6 @@ class ContactAdmin extends Mailable
public function build() public function build()
{ {
$contact = $this->contact; $contact = $this->contact;
return $this->markdown('emails.contact.admin')->with(compact('contact')); return $this->subject('New Support Message')->markdown('emails.contact.admin')->with(compact('contact'));
} }
} }

View file

@ -18,6 +18,10 @@ class Media extends Model
*/ */
protected $dates = ['deleted_at']; protected $dates = ['deleted_at'];
protected $casts = [
'srcset' => 'array'
];
public function status() public function status()
{ {
return $this->belongsTo(Status::class); return $this->belongsTo(Status::class);

35
app/Models/Poll.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\HasSnowflakePrimary;
class Poll extends Model
{
use HasSnowflakePrimary, HasFactory;
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
protected $casts = [
'poll_options' => 'array',
'cached_tallies' => 'array',
'expires_at' => 'datetime'
];
public function votes()
{
return $this->hasMany(PollVote::class);
}
public function getTallies()
{
return $this->cached_tallies;
}
}

11
app/Models/PollVote.php Normal file
View file

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

View file

@ -0,0 +1,64 @@
<?php
namespace App\Observers;
use App\Follower;
use App\Services\FollowerService;
class FollowerObserver
{
/**
* Handle the Follower "created" event.
*
* @param \App\Models\Follower $follower
* @return void
*/
public function created(Follower $follower)
{
FollowerService::add($follower->profile_id, $follower->following_id);
}
/**
* Handle the Follower "updated" event.
*
* @param \App\Models\Follower $follower
* @return void
*/
public function updated(Follower $follower)
{
FollowerService::add($follower->profile_id, $follower->following_id);
}
/**
* Handle the Follower "deleted" event.
*
* @param \App\Models\Follower $follower
* @return void
*/
public function deleted(Follower $follower)
{
FollowerService::remove($follower->profile_id, $follower->following_id);
}
/**
* Handle the Follower "restored" event.
*
* @param \App\Models\Follower $follower
* @return void
*/
public function restored(Follower $follower)
{
FollowerService::add($follower->profile_id, $follower->following_id);
}
/**
* Handle the Follower "force deleted" event.
*
* @param \App\Models\Follower $follower
* @return void
*/
public function forceDeleted(Follower $follower)
{
FollowerService::remove($follower->profile_id, $follower->following_id);
}
}

View file

@ -3,7 +3,6 @@
namespace App; namespace App;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
class Place extends Model class Place extends Model
{ {

View file

@ -2,333 +2,326 @@
namespace App; namespace App;
use Auth, Cache, Storage; use Auth, Cache, DB, Storage;
use App\Util\Lexer\PrettyNumber; use App\Util\Lexer\PrettyNumber;
use Pixelfed\Snowflake\HasSnowflakePrimary; use App\HasSnowflakePrimary;
use Illuminate\Database\Eloquent\{Model, SoftDeletes}; use Illuminate\Database\Eloquent\{Model, SoftDeletes};
use App\Services\FollowerService;
class Profile extends Model class Profile extends Model
{ {
use HasSnowflakePrimary, SoftDeletes; use HasSnowflakePrimary, SoftDeletes;
/** /**
* Indicates if the IDs are auto-incrementing. * Indicates if the IDs are auto-incrementing.
* *
* @var bool * @var bool
*/ */
public $incrementing = false; public $incrementing = false;
protected $dates = [
'deleted_at',
'last_fetched_at'
];
protected $hidden = ['private_key'];
protected $visible = ['id', 'user_id', 'username', 'name'];
protected $fillable = ['user_id'];
public function user() protected $dates = [
{ 'deleted_at',
return $this->belongsTo(User::class); 'last_fetched_at'
} ];
protected $hidden = ['private_key'];
protected $visible = ['id', 'user_id', 'username', 'name'];
protected $fillable = ['user_id'];
public function url($suffix = null) public function user()
{ {
return $this->remote_url ?? url($this->username . $suffix); return $this->belongsTo(User::class);
} }
public function localUrl($suffix = null) public function url($suffix = null)
{ {
return url($this->username . $suffix); return $this->remote_url ?? url($this->username . $suffix);
} }
public function permalink($suffix = null) public function localUrl($suffix = null)
{ {
return $this->remote_url ?? url('users/' . $this->username . $suffix); return url($this->username . $suffix);
} }
public function emailUrl() public function permalink($suffix = null)
{ {
if($this->domain) { return $this->remote_url ?? url('users/' . $this->username . $suffix);
return $this->username; }
}
$domain = parse_url(config('app.url'), PHP_URL_HOST);
return $this->username.'@'.$domain; public function emailUrl()
} {
if($this->domain) {
return $this->username;
}
public function statuses() $domain = parse_url(config('app.url'), PHP_URL_HOST);
{
return $this->hasMany(Status::class);
}
public function followingCount($short = false) return $this->username.'@'.$domain;
{ }
$count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() {
if($this->domain == null && $this->user->settings->show_profile_following_count == false) {
return 0;
}
$count = $this->following()->count();
if($this->following_count != $count) {
$this->following_count = $count;
$this->save();
}
return $count;
});
return $short ? PrettyNumber::convert($count) : $count; public function statuses()
} {
return $this->hasMany(Status::class);
}
public function followerCount($short = false) public function followingCount($short = false)
{ {
$count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() { $count = Cache::remember('profile:following_count:'.$this->id, now()->addMonths(1), function() {
if($this->domain == null && $this->user->settings->show_profile_follower_count == false) { if($this->domain == null && $this->user->settings->show_profile_following_count == false) {
return 0; return 0;
} }
$count = $this->followers()->count(); $count = DB::table('followers')->select('following_id')->where('following_id', $this->id)->count();
if($this->followers_count != $count) { if($this->following_count != $count) {
$this->followers_count = $count; $this->following_count = $count;
$this->save(); $this->save();
} }
return $count; return $count;
}); });
return $short ? PrettyNumber::convert($count) : $count;
}
public function statusCount() return $short ? PrettyNumber::convert($count) : $count;
{ }
return $this->status_count;
}
public function following() public function followerCount($short = false)
{ {
return $this->belongsToMany( $count = Cache::remember('profile:follower_count:'.$this->id, now()->addMonths(1), function() {
self::class, if($this->domain == null && $this->user->settings->show_profile_follower_count == false) {
'followers', return 0;
'profile_id', }
'following_id' $count = $this->followers()->count();
); if($this->followers_count != $count) {
} $this->followers_count = $count;
$this->save();
}
return $count;
});
return $short ? PrettyNumber::convert($count) : $count;
}
public function followers() public function statusCount()
{ {
return $this->belongsToMany( return $this->status_count;
self::class, }
'followers',
'following_id',
'profile_id'
);
}
public function follows($profile) : bool public function following()
{ {
return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists(); return $this->belongsToMany(
} self::class,
'followers',
'profile_id',
'following_id'
);
}
public function followedBy($profile) : bool public function followers()
{ {
return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists(); return $this->belongsToMany(
} self::class,
'followers',
'following_id',
'profile_id'
);
}
public function bookmarks() public function follows($profile) : bool
{ {
return $this->belongsToMany( return Follower::whereProfileId($this->id)->whereFollowingId($profile->id)->exists();
Status::class, }
'bookmarks',
'profile_id',
'status_id'
);
}
public function likes() public function followedBy($profile) : bool
{ {
return $this->hasMany(Like::class); return Follower::whereProfileId($profile->id)->whereFollowingId($this->id)->exists();
} }
public function avatar() public function bookmarks()
{ {
return $this->hasOne(Avatar::class)->withDefault([ return $this->belongsToMany(
'media_path' => 'public/avatars/default.jpg', Status::class,
'change_count' => 0 'bookmarks',
]); 'profile_id',
} 'status_id'
);
}
public function avatarUrl() public function likes()
{ {
$url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () { return $this->hasMany(Like::class);
$avatar = $this->avatar; }
if($avatar->cdn_url) { public function avatar()
return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); {
} return $this->hasOne(Avatar::class)->withDefault([
'media_path' => 'public/avatars/default.jpg',
'change_count' => 0
]);
}
if($avatar->is_remote) { public function avatarUrl()
return $avatar->cdn_url ?? url('/storage/avatars/default.jpg'); {
} $url = Cache::remember('avatar:'.$this->id, now()->addYears(1), function () {
$avatar = $this->avatar;
$path = $avatar->media_path;
$path = "{$path}?v={$avatar->change_count}";
return config('app.url') . Storage::url($path); if($avatar->cdn_url) {
}); return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
}
return $url; if($avatar->is_remote) {
} return $avatar->cdn_url ?? url('/storage/avatars/default.jpg');
}
$path = $avatar->media_path;
$path = "{$path}?v={$avatar->change_count}";
// deprecated return config('app.url') . Storage::url($path);
public function recommendFollowers() });
{
return collect([]);
}
public function keyId() return $url;
{ }
if ($this->remote_url) {
return;
}
return $this->permalink('#main-key'); // deprecated
} public function recommendFollowers()
{
return collect([]);
}
public function mutedIds() public function keyId()
{ {
return UserFilter::whereUserId($this->id) if ($this->remote_url) {
->whereFilterableType('App\Profile') return;
->whereFilterType('mute') }
->pluck('filterable_id');
}
public function blockedIds() return $this->permalink('#main-key');
{ }
return UserFilter::whereUserId($this->id)
->whereFilterableType('App\Profile')
->whereFilterType('block')
->pluck('filterable_id');
}
public function mutedProfileUrls() public function mutedIds()
{ {
$ids = $this->mutedIds(); return UserFilter::whereUserId($this->id)
return $this->whereIn('id', $ids)->get()->map(function($i) { ->whereFilterableType('App\Profile')
return $i->url(); ->whereFilterType('mute')
}); ->pluck('filterable_id');
} }
public function blockedProfileUrls() public function blockedIds()
{ {
$ids = $this->blockedIds(); return UserFilter::whereUserId($this->id)
return $this->whereIn('id', $ids)->get()->map(function($i) { ->whereFilterableType('App\Profile')
return $i->url(); ->whereFilterType('block')
}); ->pluck('filterable_id');
} }
public function reports() public function mutedProfileUrls()
{ {
return $this->hasMany(Report::class, 'profile_id'); $ids = $this->mutedIds();
} return $this->whereIn('id', $ids)->get()->map(function($i) {
return $i->url();
});
}
public function media() public function blockedProfileUrls()
{ {
return $this->hasMany(Media::class, 'profile_id'); $ids = $this->blockedIds();
} return $this->whereIn('id', $ids)->get()->map(function($i) {
return $i->url();
});
}
public function inboxUrl() public function reports()
{ {
return $this->inbox_url ?? $this->permalink('/inbox'); return $this->hasMany(Report::class, 'profile_id');
} }
public function outboxUrl() public function media()
{ {
return $this->outbox_url ?? $this->permalink('/outbox'); return $this->hasMany(Media::class, 'profile_id');
} }
public function sharedInbox() public function inboxUrl()
{ {
return $this->sharedInbox ?? $this->inboxUrl(); return $this->inbox_url ?? $this->permalink('/inbox');
} }
public function getDefaultScope() public function outboxUrl()
{ {
return $this->is_private == true ? 'private' : 'public'; return $this->outbox_url ?? $this->permalink('/outbox');
} }
public function getAudience($scope = false) public function sharedInbox()
{ {
if($this->remote_url) { return $this->sharedInbox ?? $this->inboxUrl();
return []; }
}
$scope = $scope ?? $this->getDefaultScope();
$audience = [];
switch ($scope) {
case 'public':
$audience = [
'to' => [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc' => [
$this->permalink('/followers')
]
];
break;
}
return $audience;
}
public function getAudienceInbox($scope = 'public') public function getDefaultScope()
{ {
return $this return $this->is_private == true ? 'private' : 'public';
->followers() }
->whereLocalProfile(false)
->get()
->map(function($follow) {
return $follow->sharedInbox ?? $follow->inbox_url;
})
->unique()
->toArray();
}
public function circles() public function getAudience($scope = false)
{ {
return $this->hasMany(Circle::class); if($this->remote_url) {
} return [];
}
$scope = $scope ?? $this->getDefaultScope();
$audience = [];
switch ($scope) {
case 'public':
$audience = [
'to' => [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc' => [
$this->permalink('/followers')
]
];
break;
}
return $audience;
}
public function hashtags() public function getAudienceInbox($scope = 'public')
{ {
return $this->hasManyThrough( return FollowerService::audience($this->id, $scope);
Hashtag::class, }
StatusHashtag::class,
'profile_id',
'id',
'id',
'hashtag_id'
);
}
public function hashtagFollowing() public function circles()
{ {
return $this->hasMany(HashtagFollow::class); return $this->hasMany(Circle::class);
} }
public function collections() public function hashtags()
{ {
return $this->hasMany(Collection::class); return $this->hasManyThrough(
} Hashtag::class,
StatusHashtag::class,
'profile_id',
'id',
'id',
'hashtag_id'
);
}
public function hasFollowRequestById(int $id) public function hashtagFollowing()
{ {
return FollowRequest::whereFollowerId($id) return $this->hasMany(HashtagFollow::class);
->whereFollowingId($this->id) }
->exists();
}
public function stories() public function collections()
{ {
return $this->hasMany(Story::class); return $this->hasMany(Collection::class);
} }
public function hasFollowRequestById(int $id)
{
return FollowRequest::whereFollowerId($id)
->whereFollowingId($this->id)
->exists();
}
public function stories()
{
return $this->hasMany(Story::class);
}
public function reported() public function reported()
{ {
return $this->hasMany(Report::class, 'object_id'); return $this->hasMany(Report::class, 'object_id');
} }
} }

View file

@ -4,6 +4,7 @@ namespace App\Providers;
use App\Observers\{ use App\Observers\{
AvatarObserver, AvatarObserver,
FollowerObserver,
LikeObserver, LikeObserver,
NotificationObserver, NotificationObserver,
ModLogObserver, ModLogObserver,
@ -14,6 +15,7 @@ use App\Observers\{
}; };
use App\{ use App\{
Avatar, Avatar,
Follower,
Like, Like,
Notification, Notification,
ModLog, ModLog,
@ -48,6 +50,7 @@ class AppServiceProvider extends ServiceProvider
StatusHashtag::observe(StatusHashtagObserver::class); StatusHashtag::observe(StatusHashtagObserver::class);
User::observe(UserObserver::class); User::observe(UserObserver::class);
UserFilter::observe(UserFilterObserver::class); UserFilter::observe(UserFilterObserver::class);
Follower::observe(FollowerObserver::class);
Horizon::auth(function ($request) { Horizon::auth(function ($request) {
return Auth::check() && $request->user()->is_admin; return Auth::check() && $request->user()->is_admin;
}); });

View file

@ -4,12 +4,13 @@ namespace App\Services;
use Cache; use Cache;
use App\Profile; use App\Profile;
use App\Status;
use App\Transformer\Api\AccountTransformer; use App\Transformer\Api\AccountTransformer;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
class AccountService { class AccountService
{
const CACHE_KEY = 'pf:services:account:'; const CACHE_KEY = 'pf:services:account:';
public static function get($id) public static function get($id)
@ -19,7 +20,7 @@ class AccountService {
} }
$key = self::CACHE_KEY . $id; $key = self::CACHE_KEY . $id;
$ttl = now()->addMinutes(15); $ttl = now()->addHours(12);
return Cache::remember($key, $ttl, function() use($id) { return Cache::remember($key, $ttl, function() use($id) {
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
@ -35,4 +36,30 @@ class AccountService {
return Cache::forget(self::CACHE_KEY . $id); return Cache::forget(self::CACHE_KEY . $id);
} }
} public static function syncPostCount($id)
{
$profile = Profile::find($id);
if(!$profile) {
return false;
}
$key = self::CACHE_KEY . 'pcs:' . $id;
if(Cache::has($key)) {
return;
}
$count = Status::whereProfileId($id)
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereIn('scope', ['public', 'unlisted', 'private'])
->count();
$profile->status_count = $count;
$profile->save();
Cache::put($key, 1, 900);
return true;
}
}

View file

@ -3,7 +3,7 @@
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Cache;
use App\{ use App\{
Follower, Follower,
Profile, Profile,
@ -25,6 +25,8 @@ class FollowerService
{ {
Redis::zrem(self::FOLLOWING_KEY . $actor, $target); Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
Redis::zrem(self::FOLLOWERS_KEY . $target, $actor); Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
Cache::forget('pf:services:follow:audience:' . $actor);
Cache::forget('pf:services:follow:audience:' . $target);
} }
public static function followers($id, $start = 0, $stop = 10) public static function followers($id, $start = 0, $stop = 10)
@ -42,46 +44,34 @@ class FollowerService
return Follower::whereProfileId($actor)->whereFollowingId($target)->exists(); return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
} }
public static function audience($profile) public static function audience($profile, $scope = null)
{ {
return (new self)->getAudienceInboxes($profile); return (new self)->getAudienceInboxes($profile, $scope);
} }
protected function getAudienceInboxes($profile) public static function softwareAudience($profile, $software = 'pixelfed')
{ {
if($profile instanceOf User) { return collect(self::audience($profile))
return $profile ->filter(function($inbox) use($software) {
->profile $domain = parse_url($inbox, PHP_URL_HOST);
->followers() if(!$domain) {
->whereLocalProfile(false) return false;
->get() }
->map(function($follow) { return InstanceService::software($domain) === strtolower($software);
return $follow->sharedInbox ?? $follow->inbox_url; })
}) ->unique()
->unique() ->values()
->values() ->toArray();
->toArray(); }
}
if($profile instanceOf Profile) { protected function getAudienceInboxes($pid, $scope = null)
return $profile {
->followers() $key = 'pf:services:follow:audience:' . $pid;
->whereLocalProfile(false) return Cache::remember($key, 86400, function() use($pid) {
->get() $profile = Profile::find($pid);
->map(function($follow) {
return $follow->sharedInbox ?? $follow->inbox_url;
})
->unique()
->values()
->toArray();
}
if(is_string($profile) || is_integer($profile)) {
$profile = Profile::whereNull('domain')->find($profile);
if(!$profile) { if(!$profile) {
return []; return [];
} }
return $profile return $profile
->followers() ->followers()
->whereLocalProfile(false) ->whereLocalProfile(false)
@ -92,9 +82,7 @@ class FollowerService
->unique() ->unique()
->values() ->values()
->toArray(); ->toArray();
} });
return [];
} }
} }

View file

@ -27,4 +27,16 @@ class InstanceService
return Instance::whereAutoCw(true)->pluck('domain')->toArray(); return Instance::whereAutoCw(true)->pluck('domain')->toArray();
}); });
} }
public static function software($domain)
{
$key = 'instances:software:' . strtolower($domain);
return Cache::remember($key, 86400, function() use($domain) {
$instance = Instance::whereDomain($domain)->first();
if(!$instance) {
return;
}
return $instance->software;
});
}
} }

View file

@ -80,4 +80,9 @@ class LikeService {
return $res; return $res;
} }
public static function count($id)
{
return Like::whereStatusId($id)->count();
}
} }

View file

@ -8,6 +8,7 @@ use Illuminate\Support\Str;
use App\Media; use App\Media;
use App\Profile; use App\Profile;
use App\User; use App\User;
use App\Services\HashidService;
class MediaPathService { class MediaPathService {
@ -51,27 +52,27 @@ class MediaPathService {
public static function story($account, $version = 1) public static function story($account, $version = 1)
{ {
$mh = hash('sha256', date('Y').'-.-'.date('m')); $mh = hash('sha256', date('Y').'-.-'.date('m'));
$monthHash = date('Y').date('m').substr($mh, 0, 6).substr($mh, 58, 6); $monthHash = HashidService::encode(date('Y').date('m'));
$random = '03'.Str::random(random_int(6,9)).'_'.Str::random(random_int(6,17)); $random = date('d').Str::random(32);
if($account instanceOf User) { if($account instanceOf User) {
switch ($version) { switch ($version) {
case 1: case 1:
$userHash = $account->profile_id; $userHash = HashidService::encode($account->profile_id);
$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
break; break;
default: default:
$userHash = $account->profile_id; $userHash = HashidService::encode($account->profile_id);
$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
break; break;
} }
} }
if($account instanceOf Profile) { if($account instanceOf Profile) {
$userHash = $account->id; $userHash = HashidService::encode($account->id);
$path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}"; $path = "public/_esm.t3/{$monthHash}/{$userHash}/{$random}";
} }
return $path; return $path;
} }
} }

View file

@ -0,0 +1,62 @@
<?php
namespace App\Services;
use Cache;
use Illuminate\Support\Facades\Redis;
use App\Media;
use App\Status;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Transformer\Api\MediaTransformer;
use App\Util\Media\License;
class MediaService
{
const CACHE_KEY = 'status:transformer:media:attachments:';
public static function get($statusId)
{
$status = Status::find($statusId);
$ttl = $status->created_at->lt(now()->subMinutes(30)) ? 129600 : 30;
return Cache::remember(self::CACHE_KEY.$statusId, $ttl, function() use($status) {
if(!$status) {
return [];
}
if(in_array($status->type, ['group:post', 'photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
$media = Media::whereStatusId($status->id)->orderBy('order')->get();
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($media, new MediaTransformer());
return $fractal->createData($resource)->toArray();
}
return [];
});
}
public static function del($statusId)
{
return Cache::forget(self::CACHE_KEY . $statusId);
}
public static function activitypub($statusId)
{
$status = self::get($statusId);
if(!$status) {
return [];
}
return collect($status)->map(function($s) {
$license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null;
return [
'type' => 'Document',
'mediaType' => $s['mime'],
'url' => $s['url'],
'name' => $s['description'],
'blurhash' => $s['blurhash'],
'license' => $license
];
});
}
}

View file

@ -70,7 +70,7 @@ class MediaStorageService {
protected function localToCloud($media) protected function localToCloud($media)
{ {
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
$thumb = storage_path('app/'.$media->thumbnail_path); $thumb = storage_path('app/'.$media->thumbnail_path);
$p = explode('/', $media->media_path); $p = explode('/', $media->media_path);
$name = array_pop($p); $name = array_pop($p);

View file

@ -0,0 +1,76 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\ConnectionException;
class NodeinfoService
{
public static function get($domain)
{
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = [
'Accept' => 'application/json',
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
];
$url = 'https://' . $domain;
$wk = $url . '/.well-known/nodeinfo';
try {
$res = Http::withHeaders($headers)
->timeout(5)
->get($wk);
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (\Exception $e) {
return false;
}
if(!$res) {
return false;
}
$json = $res->json();
if( !isset($json['links'])) {
return false;
}
if(is_array($json['links'])) {
if(isset($json['links']['href'])) {
$href = $json['links']['href'];
} else {
$href = $json['links'][0]['href'];
}
} else {
return false;
}
$domain = parse_url($url, PHP_URL_HOST);
$hrefDomain = parse_url($href, PHP_URL_HOST);
if($domain !== $hrefDomain) {
return 60;
}
try {
$res = Http::withHeaders($headers)
->timeout(5)
->get($href);
} catch (RequestException $e) {
return false;
} catch (ConnectionException $e) {
return false;
} catch (\Exception $e) {
return false;
}
return $res->json();
}
}

View file

@ -46,7 +46,7 @@ class NotificationService {
return $ids; return $ids;
} }
public static function getMax($id = false, $start, $limit = 10) public static function getMax($id = false, $start = 0, $limit = 10)
{ {
$ids = self::getRankedMaxId($id, $start, $limit); $ids = self::getRankedMaxId($id, $start, $limit);
@ -61,7 +61,7 @@ class NotificationService {
return $res->toArray(); return $res->toArray();
} }
public static function getMin($id = false, $start, $limit = 10) public static function getMin($id = false, $start = 0, $limit = 10)
{ {
$ids = self::getRankedMinId($id, $start, $limit); $ids = self::getRankedMinId($id, $start, $limit);

View file

@ -0,0 +1,97 @@
<?php
namespace App\Services;
use App\Models\Poll;
use App\Models\PollVote;
use App\Status;
use Illuminate\Support\Facades\Cache;
class PollService
{
const CACHE_KEY = 'pf:services:poll:status_id:';
public static function get($id, $profileId = false)
{
$key = self::CACHE_KEY . $id;
$res = Cache::remember($key, 1800, function() use($id) {
$poll = Poll::whereStatusId($id)->firstOrFail();
return [
'id' => (string) $poll->id,
'expires_at' => $poll->expires_at->format('c'),
'expired' => null,
'multiple' => $poll->multiple,
'votes_count' => $poll->votes_count,
'voters_count' => null,
'voted' => false,
'own_votes' => [],
'options' => collect($poll->poll_options)->map(function($option, $key) use($poll) {
$tally = $poll->cached_tallies && isset($poll->cached_tallies[$key]) ? $poll->cached_tallies[$key] : 0;
return [
'title' => $option,
'votes_count' => $tally
];
})->toArray(),
'emojis' => []
];
});
if($profileId) {
$res['voted'] = self::voted($id, $profileId);
$res['own_votes'] = self::ownVotes($id, $profileId);
}
return $res;
}
public static function getById($id, $pid)
{
$poll = Poll::findOrFail($id);
return self::get($poll->status_id, $pid);
}
public static function del($id)
{
Cache::forget(self::CACHE_KEY . $id);
}
public static function voted($id, $profileId = false)
{
return !$profileId ? false : PollVote::whereStatusId($id)
->whereProfileId($profileId)
->exists();
}
public static function votedStory($id, $profileId = false)
{
return !$profileId ? false : PollVote::whereStoryId($id)
->whereProfileId($profileId)
->exists();
}
public static function storyResults($sid)
{
$key = self::CACHE_KEY . 'story_poll_results:' . $sid;
return Cache::remember($key, 60, function() use($sid) {
return Poll::whereStoryId($sid)
->firstOrFail()
->cached_tallies;
});
}
public static function storyChoice($id, $profileId = false)
{
return !$profileId ? false : PollVote::whereStoryId($id)
->whereProfileId($profileId)
->pluck('choice')
->first();
}
public static function ownVotes($id, $profileId = false)
{
return !$profileId ? [] : PollVote::whereStatusId($id)
->whereProfileId($profileId)
->pluck('choice') ?? [];
}
}

View file

@ -2,31 +2,15 @@
namespace App\Services; namespace App\Services;
use Cache; class ProfileService
use Illuminate\Support\Facades\Redis; {
use App\Transformer\Api\AccountTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Profile;
class ProfileService {
public static function get($id) public static function get($id)
{ {
$key = 'profile:model:' . $id; return AccountService::get($id);
$ttl = now()->addHours(4);
$res = Cache::remember($key, $ttl, function() use($id) {
$profile = Profile::find($id);
if(!$profile) {
return false;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($profile, new AccountTransformer());
return $fractal->createData($resource)->toArray();
});
return $res;
} }
public static function del($id)
{
return AccountService::del($id);
}
} }

View file

@ -3,16 +3,44 @@
namespace App\Services; namespace App\Services;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Cache;
class SnowflakeService { class SnowflakeService {
public static function byDate(Carbon $ts = null) public static function byDate(Carbon $ts = null)
{ {
$ts = $ts ? now()->parse($ts)->timestamp : microtime(true); if($ts instanceOf Carbon) {
$ts = now()->parse($ts)->timestamp;
} else {
return self::next();
}
return ((round($ts * 1000) - 1549756800000) << 22) return ((round($ts * 1000) - 1549756800000) << 22)
| (1 << 17) | (random_int(1,31) << 17)
| (1 << 12) | (random_int(1,31) << 12)
| 0; | 0;
} }
} public static function next()
{
$seq = Cache::get('snowflake:seq');
if(!$seq) {
Cache::put('snowflake:seq', 1);
$seq = 1;
} else {
Cache::increment('snowflake:seq');
}
if($seq >= 4095) {
Cache::put('snowflake:seq', 0);
$seq = 0;
}
return ((round(microtime(true) * 1000) - 1549756800000) << 22)
| (random_int(1,31) << 17)
| (random_int(1,31) << 12)
| $seq;
}
}

View file

@ -6,6 +6,7 @@ use Cache;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use App\{Status, StatusHashtag}; use App\{Status, StatusHashtag};
use App\Transformer\Api\StatusHashtagTransformer; use App\Transformer\Api\StatusHashtagTransformer;
use App\Transformer\Api\HashtagTransformer;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
@ -78,4 +79,21 @@ class StatusHashtagService {
{ {
return ['status' => StatusService::get($statusId)]; return ['status' => StatusService::get($statusId)];
} }
public static function statusTags($statusId)
{
$key = 'pf:services:sh:id:' . $statusId;
return Cache::remember($key, 604800, function() use($statusId) {
$status = Status::find($statusId);
if(!$status) {
return [];
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($status->hashtags, new HashtagTransformer());
return $fractal->createData($resource)->toArray();
});
}
} }

View file

@ -16,15 +16,20 @@ class StatusService {
const CACHE_KEY = 'pf:services:status:'; const CACHE_KEY = 'pf:services:status:';
public static function key($id) public static function key($id, $publicOnly = true)
{ {
return self::CACHE_KEY . $id; $p = $publicOnly ? '' : 'all:';
return self::CACHE_KEY . $p . $id;
} }
public static function get($id) public static function get($id, $publicOnly = true)
{ {
return Cache::remember(self::key($id), now()->addDays(7), function() use($id) { return Cache::remember(self::key($id, $publicOnly), now()->addDays(7), function() use($id, $publicOnly) {
$status = Status::whereScope('public')->find($id); if($publicOnly) {
$status = Status::whereScope('public')->find($id);
} else {
$status = Status::whereIn('scope', ['public', 'private', 'unlisted', 'group'])->find($id);
}
if(!$status) { if(!$status) {
return null; return null;
} }
@ -37,7 +42,17 @@ class StatusService {
public static function del($id) public static function del($id)
{ {
$status = self::get($id);
if($status && isset($status['account']) && isset($status['account']['id'])) {
Cache::forget('profile:embed:' . $status['account']['id']);
}
Cache::forget('status:transformer:media:attachments:' . $id);
MediaService::del($id);
Cache::forget('status:thumb:nsfw0' . $id);
Cache::forget('status:thumb:nsfw1' . $id);
Cache::forget('pf:services:sh:id:' . $id);
PublicTimelineService::rem($id); PublicTimelineService::rem($id);
Cache::forget(self::key($id, false));
return Cache::forget(self::key($id)); return Cache::forget(self::key($id));
} }
} }

View file

@ -0,0 +1,162 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use App\Story;
use App\StoryView;
class StoryService
{
const STORY_KEY = 'pf:services:stories:v1:';
public static function get($id)
{
$account = AccountService::get($id);
if(!$account) {
return false;
}
$res = [
'profile' => [
'id' => (string) $account['id'],
'avatar' => $account['avatar'],
'username' => $account['username'],
'url' => $account['url']
]
];
$res['stories'] = self::getStories($id);
return $res;
}
public static function getById($id)
{
return Cache::remember(self::STORY_KEY . 'by-id:id-' . $id, 3600, function() use ($id) {
return Story::find($id);
});
}
public static function delById($id)
{
return Cache::forget(self::STORY_KEY . 'by-id:id-' . $id);
}
public static function getStories($id, $pid)
{
return Story::whereProfileId($id)
->latest()
->get()
->map(function($s) use($pid) {
return [
'id' => (string) $s->id,
'type' => $s->type,
'duration' => 10,
'seen' => in_array($pid, self::views($s->id)),
'created_at' => $s->created_at->toAtomString(),
'expires_at' => $s->expires_at->toAtomString(),
'media' => url(Storage::url($s->path)),
'can_reply' => (bool) $s->can_reply,
'can_react' => (bool) $s->can_react,
'poll' => $s->type == 'poll' ? PollService::storyPoll($s->id) : null
];
})
->toArray();
}
public static function views($id)
{
return StoryView::whereStoryId($id)
->pluck('profile_id')
->toArray();
}
public static function hasSeen($pid, $sid)
{
$key = self::STORY_KEY . 'seen:' . $pid . ':' . $sid;
return Cache::remember($key, 3600, function() use($pid, $sid) {
return StoryView::whereStoryId($sid)
->whereProfileId($pid)
->exists();
});
}
public static function latest($pid)
{
return Cache::remember(self::STORY_KEY . 'latest:pid-' . $pid, 3600, function() use ($pid) {
return Story::whereProfileId($pid)
->latest()
->first()
->id;
});
}
public static function delLatest($pid)
{
return Cache::forget(self::STORY_KEY . 'latest:pid-' . $pid);
}
public static function addSeen($pid, $sid)
{
return Cache::put(self::STORY_KEY . 'seen:' . $pid . ':' . $sid, true, 86400);
}
public static function adminStats()
{
return Cache::remember('pf:admin:stories:stats', 300, function() {
$total = Story::count();
return [
'active' => [
'today' => Story::whereDate('created_at', now()->today())->count(),
'month' => Story::whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count()
],
'total' => $total,
'remote' => [
'today' => Story::whereLocal(false)->whereDate('created_at', now()->today())->count(),
'month' => Story::whereLocal(false)->whereMonth('created_at', now()->month)->whereYear('created_at', now()->year)->count()
],
'storage' => [
'sum' => (int) Story::sum('size'),
'average' => (int) Story::avg('size')
],
'avg_spu' => (int) ($total / Story::groupBy('profile_id')->pluck('profile_id')->count()),
'avg_duration' => (int) floor(Story::avg('duration')),
'avg_type' => Story::selectRaw('type, count(id) as count')->groupBy('type')->orderByDesc('count')->first()->type
];
});
}
public static function rotateQueue()
{
return Redis::smembers('pf:stories:rotate-queue');
}
public static function addRotateQueue($id)
{
return Redis::sadd('pf:stories:rotate-queue', $id);
}
public static function removeRotateQueue($id)
{
self::delById($id);
return Redis::srem('pf:stories:rotate-queue', $id);
}
public static function reactIncrement($storyId, $profileId)
{
$key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId;
if(Redis::get($key) == null) {
Redis::setex($key, 86400, 1);
} else {
return Redis::incr($key);
}
}
public static function reactCounter($storyId, $profileId)
{
$key = 'pf:stories:react-counter:storyid-' . $storyId . ':profileid-' . $profileId;
return (int) Redis::get($key) ?? 0;
}
}

View file

@ -4,414 +4,419 @@ namespace App;
use Auth, Cache, Hashids, Storage; use Auth, Cache, Hashids, Storage;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary; use App\HasSnowflakePrimary;
use App\Http\Controllers\StatusController; use App\Http\Controllers\StatusController;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Poll;
class Status extends Model class Status extends Model
{ {
use HasSnowflakePrimary, SoftDeletes; use HasSnowflakePrimary, SoftDeletes;
/** /**
* Indicates if the IDs are auto-incrementing. * Indicates if the IDs are auto-incrementing.
* *
* @var bool * @var bool
*/ */
public $incrementing = false; public $incrementing = false;
/** /**
* The attributes that should be mutated to dates. * The attributes that should be mutated to dates.
* *
* @var array * @var array
*/ */
protected $dates = ['deleted_at']; protected $dates = ['deleted_at'];
protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type']; protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id', 'type'];
const STATUS_TYPES = [ const STATUS_TYPES = [
'text', 'text',
'photo', 'photo',
'photo:album', 'photo:album',
'video', 'video',
'video:album', 'video:album',
'photo:video:album', 'photo:video:album',
'share', 'share',
'reply', 'reply',
'story', 'story',
'story:reply', 'story:reply',
'story:reaction', 'story:reaction',
'story:live', 'story:live',
'loop' 'loop'
]; ];
const MAX_MENTIONS = 5; const MAX_MENTIONS = 5;
const MAX_HASHTAGS = 30; const MAX_HASHTAGS = 30;
const MAX_LINKS = 0; const MAX_LINKS = 2;
public function profile() public function profile()
{ {
return $this->belongsTo(Profile::class); return $this->belongsTo(Profile::class);
} }
public function media() public function media()
{ {
return $this->hasMany(Media::class); return $this->hasMany(Media::class);
} }
public function firstMedia() public function firstMedia()
{ {
return $this->hasMany(Media::class)->orderBy('order', 'asc')->first(); return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
} }
public function viewType() public function viewType()
{ {
if($this->type) { if($this->type) {
return $this->type; return $this->type;
} }
return $this->setType(); return $this->setType();
} }
public function setType() public function setType()
{ {
if(in_array($this->type, self::STATUS_TYPES)) { if(in_array($this->type, self::STATUS_TYPES)) {
return $this->type; return $this->type;
} }
$mimes = $this->media->pluck('mime')->toArray(); $mimes = $this->media->pluck('mime')->toArray();
$type = StatusController::mimeTypeCheck($mimes); $type = StatusController::mimeTypeCheck($mimes);
if($type) { if($type) {
$this->type = $type; $this->type = $type;
$this->save(); $this->save();
return $type; return $type;
} }
} }
public function thumb($showNsfw = false) public function thumb($showNsfw = false)
{ {
$key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id; $key = $showNsfw ? 'status:thumb:nsfw1'.$this->id : 'status:thumb:nsfw0'.$this->id;
return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) { return Cache::remember($key, now()->addMinutes(15), function() use ($showNsfw) {
$type = $this->type ?? $this->setType(); $type = $this->type ?? $this->setType();
$is_nsfw = !$showNsfw ? $this->is_nsfw : false; $is_nsfw = !$showNsfw ? $this->is_nsfw : false;
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) { if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) {
return url(Storage::url('public/no-preview.png')); return url(Storage::url('public/no-preview.png'));
} }
return url(Storage::url($this->firstMedia()->thumbnail_path)); return url(Storage::url($this->firstMedia()->thumbnail_path));
}); });
} }
public function url() public function url($forceLocal = false)
{ {
if($this->uri) { if($this->uri) {
return $this->uri; return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri;
} else { } else {
$id = $this->id; $id = $this->id;
$username = $this->profile->username; $username = $this->profile->username;
$path = url(config('app.url')."/p/{$username}/{$id}"); $path = url(config('app.url')."/p/{$username}/{$id}");
return $path; return $path;
} }
} }
public function permalink($suffix = '/activity') public function permalink($suffix = '/activity')
{ {
$id = $this->id; $id = $this->id;
$username = $this->profile->username; $username = $this->profile->username;
$path = config('app.url')."/p/{$username}/{$id}{$suffix}"; $path = config('app.url')."/p/{$username}/{$id}{$suffix}";
return url($path); return url($path);
} }
public function editUrl() public function editUrl()
{ {
return $this->url().'/edit'; return $this->url().'/edit';
} }
public function mediaUrl() public function mediaUrl()
{ {
$media = $this->firstMedia(); $media = $this->firstMedia();
$path = $media->media_path; $path = $media->media_path;
$hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at); $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
$url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}"); $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
return $url; return $url;
} }
public function likes() public function likes()
{ {
return $this->hasMany(Like::class); return $this->hasMany(Like::class);
} }
public function liked() : bool public function liked() : bool
{ {
if(!Auth::check()) { if(!Auth::check()) {
return false; return false;
} }
$pid = Auth::user()->profile_id; $pid = Auth::user()->profile_id;
return Like::select('status_id', 'profile_id') return Like::select('status_id', 'profile_id')
->whereStatusId($this->id) ->whereStatusId($this->id)
->whereProfileId($pid) ->whereProfileId($pid)
->exists(); ->exists();
} }
public function likedBy() public function likedBy()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Like::class, Like::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function comments() public function comments()
{ {
return $this->hasMany(self::class, 'in_reply_to_id'); return $this->hasMany(self::class, 'in_reply_to_id');
} }
public function bookmarked() public function bookmarked()
{ {
if (!Auth::check()) { if (!Auth::check()) {
return false; return false;
} }
$profile = Auth::user()->profile; $profile = Auth::user()->profile;
return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count(); return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
} }
public function shares() public function shares()
{ {
return $this->hasMany(self::class, 'reblog_of_id'); return $this->hasMany(self::class, 'reblog_of_id');
} }
public function shared() : bool public function shared() : bool
{ {
if(!Auth::check()) { if(!Auth::check()) {
return false; return false;
} }
$pid = Auth::user()->profile_id; $pid = Auth::user()->profile_id;
return $this->select('profile_id', 'reblog_of_id') return $this->select('profile_id', 'reblog_of_id')
->whereProfileId($pid) ->whereProfileId($pid)
->whereReblogOfId($this->id) ->whereReblogOfId($this->id)
->exists(); ->exists();
} }
public function sharedBy() public function sharedBy()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Status::class, Status::class,
'reblog_of_id', 'reblog_of_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function parent() public function parent()
{ {
$parent = $this->in_reply_to_id ?? $this->reblog_of_id; $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if (!empty($parent)) { if (!empty($parent)) {
return $this->findOrFail($parent); return $this->findOrFail($parent);
} else { } else {
return false; return false;
} }
} }
public function conversation() public function conversation()
{ {
return $this->hasOne(Conversation::class); return $this->hasOne(Conversation::class);
} }
public function hashtags() public function hashtags()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Hashtag::class, Hashtag::class,
StatusHashtag::class, StatusHashtag::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'hashtag_id' 'hashtag_id'
); );
} }
public function mentions() public function mentions()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Mention::class, Mention::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function reportUrl() public function reportUrl()
{ {
return route('report.form')."?type=post&id={$this->id}"; return route('report.form')."?type=post&id={$this->id}";
} }
public function toActivityStream() public function toActivityStream()
{ {
$media = $this->media; $media = $this->media;
$mediaCollection = []; $mediaCollection = [];
foreach ($media as $image) { foreach ($media as $image) {
$mediaCollection[] = [ $mediaCollection[] = [
'type' => 'Link', 'type' => 'Link',
'href' => $image->url(), 'href' => $image->url(),
'mediaType' => $image->mime, 'mediaType' => $image->mime,
]; ];
} }
$obj = [ $obj = [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Image', 'type' => 'Image',
'name' => null, 'name' => null,
'url' => $mediaCollection, 'url' => $mediaCollection,
]; ];
return $obj; return $obj;
} }
public function replyToText() public function replyToText()
{ {
$actorName = $this->profile->username; $actorName = $this->profile->username;
return "{$actorName} ".__('notification.commented'); return "{$actorName} ".__('notification.commented');
} }
public function replyToHtml() public function replyToHtml()
{ {
$actorName = $this->profile->username; $actorName = $this->profile->username;
$actorUrl = $this->profile->url(); $actorUrl = $this->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ". return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.commented'); __('notification.commented');
} }
public function shareToText() public function shareToText()
{ {
$actorName = $this->profile->username; $actorName = $this->profile->username;
return "{$actorName} ".__('notification.shared'); return "{$actorName} ".__('notification.shared');
} }
public function shareToHtml() public function shareToHtml()
{ {
$actorName = $this->profile->username; $actorName = $this->profile->username;
$actorUrl = $this->profile->url(); $actorUrl = $this->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ". return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> ".
__('notification.shared'); __('notification.shared');
} }
public function recentComments() public function recentComments()
{ {
return $this->comments()->orderBy('created_at', 'desc')->take(3); return $this->comments()->orderBy('created_at', 'desc')->take(3);
} }
public function toActivityPubObject() public function toActivityPubObject()
{ {
if($this->local == false) { if($this->local == false) {
return; return;
} }
$profile = $this->profile; $profile = $this->profile;
$to = $this->scopeToAudience('to'); $to = $this->scopeToAudience('to');
$cc = $this->scopeToAudience('cc'); $cc = $this->scopeToAudience('cc');
return [ return [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $this->permalink(), 'id' => $this->permalink(),
'type' => 'Create', 'type' => 'Create',
'actor' => $profile->permalink(), 'actor' => $profile->permalink(),
'published' => $this->created_at->format('c'), 'published' => $this->created_at->format('c'),
'to' => $to, 'to' => $to,
'cc' => $cc, 'cc' => $cc,
'object' => [ 'object' => [
'id' => $this->url(), 'id' => $this->url(),
'type' => 'Note', 'type' => 'Note',
'summary' => null, 'summary' => null,
'inReplyTo' => null, 'inReplyTo' => null,
'published' => $this->created_at->format('c'), 'published' => $this->created_at->format('c'),
'url' => $this->url(), 'url' => $this->url(),
'attributedTo' => $this->profile->url(), 'attributedTo' => $this->profile->url(),
'to' => $to, 'to' => $to,
'cc' => $cc, 'cc' => $cc,
'sensitive' => (bool) $this->is_nsfw, 'sensitive' => (bool) $this->is_nsfw,
'content' => $this->rendered, 'content' => $this->rendered,
'attachment' => $this->media->map(function($media) { 'attachment' => $this->media->map(function($media) {
return [ return [
'type' => 'Document', 'type' => 'Document',
'mediaType' => $media->mime, 'mediaType' => $media->mime,
'url' => $media->url(), 'url' => $media->url(),
'name' => null 'name' => null
]; ];
})->toArray() })->toArray()
] ]
]; ];
} }
public function scopeToAudience($audience) public function scopeToAudience($audience)
{ {
if(!in_array($audience, ['to', 'cc']) || $this->local == false) { if(!in_array($audience, ['to', 'cc']) || $this->local == false) {
return; return;
} }
$res = []; $res = [];
$res['to'] = []; $res['to'] = [];
$res['cc'] = []; $res['cc'] = [];
$scope = $this->scope; $scope = $this->scope;
$mentions = $this->mentions->map(function ($mention) { $mentions = $this->mentions->map(function ($mention) {
return $mention->permalink(); return $mention->permalink();
})->toArray(); })->toArray();
if($this->in_reply_to_id != null) { if($this->in_reply_to_id != null) {
$parent = $this->parent(); $parent = $this->parent();
if($parent) { if($parent) {
$mentions = array_merge([$parent->profile->permalink()], $mentions); $mentions = array_merge([$parent->profile->permalink()], $mentions);
} }
} }
switch ($scope) { switch ($scope) {
case 'public': case 'public':
$res['to'] = [ $res['to'] = [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
]; ];
$res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
break; break;
case 'unlisted': case 'unlisted':
$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
$res['cc'] = [ $res['cc'] = [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
]; ];
break; break;
case 'private': case 'private':
$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
$res['cc'] = []; $res['cc'] = [];
break; break;
// TODO: Update scope when DMs are supported // TODO: Update scope when DMs are supported
case 'direct': case 'direct':
$res['to'] = []; $res['to'] = [];
$res['cc'] = []; $res['cc'] = [];
break; break;
} }
return $res[$audience]; return $res[$audience];
} }
public function place() public function place()
{ {
return $this->belongsTo(Place::class); return $this->belongsTo(Place::class);
} }
public function directMessage() public function directMessage()
{ {
return $this->hasOne(DirectMessage::class); return $this->hasOne(DirectMessage::class);
} }
public function poll()
{
return $this->hasOne(Poll::class);
}
} }

View file

@ -3,8 +3,10 @@
namespace App; namespace App;
use Auth; use Auth;
use Storage;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary; use App\HasSnowflakePrimary;
use App\Util\Lexer\Bearcap;
class Story extends Model class Story extends Model
{ {
@ -19,14 +21,11 @@ class Story extends Model
*/ */
public $incrementing = false; public $incrementing = false;
/** protected $casts = [
* The attributes that should be mutated to dates. 'expires_at' => 'datetime'
* ];
* @var array
*/
protected $dates = ['published_at', 'expires_at'];
protected $fillable = ['profile_id']; protected $fillable = ['profile_id', 'view_count'];
protected $visible = ['id']; protected $visible = ['id'];
@ -51,6 +50,42 @@ class Story extends Model
public function permalink() public function permalink()
{ {
return url("/story/$this->id"); $username = $this->profile->username;
return url("/stories/{$username}/{$this->id}/activity");
}
public function url()
{
$username = $this->profile->username;
return url("/stories/{$username}/{$this->id}");
}
public function mediaUrl()
{
return url(Storage::url($this->path));
}
public function bearcapUrl()
{
return Bearcap::encode($this->url(), $this->bearcap_token);
}
public function scopeToAudience($scope)
{
$res = [];
switch ($scope) {
case 'to':
$res = [
$this->profile->permalink('/followers')
];
break;
default:
$res = [];
break;
}
return $res;
} }
} }

View file

@ -4,6 +4,7 @@ namespace App\Transformer\ActivityPub;
use App\Status; use App\Status;
use League\Fractal; use League\Fractal;
use App\Services\MediaService;
class StatusTransformer extends Fractal\TransformerAbstract class StatusTransformer extends Fractal\TransformerAbstract
{ {
@ -45,15 +46,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'sensitive' => (bool) $status->is_nsfw, 'sensitive' => (bool) $status->is_nsfw,
'atomUri' => $status->url(), 'atomUri' => $status->url(),
'inReplyToAtomUri' => null, 'inReplyToAtomUri' => null,
'attachment' => $status->media->map(function ($media) { 'attachment' => MediaService::activitypub($status->id),
return [
'type' => 'Document',
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => $media->caption,
'blurhash' => $media->blurhash
];
}),
'tag' => [], 'tag' => [],
'location' => $status->place_id ? [ 'location' => $status->place_id ? [
'type' => 'Place', 'type' => 'Place',

View file

@ -0,0 +1,46 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Status;
use League\Fractal;
use Illuminate\Support\Str;
class CreateQuestion extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'object',
];
public function transform(Status $status)
{
return [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
'commentsEnabled' => 'sc:Boolean',
'capabilities' => [
'announce' => ['@type' => '@id'],
'like' => ['@type' => '@id'],
'reply' => ['@type' => '@id']
]
]
],
'id' => $status->permalink(),
'type' => 'Create',
'actor' => $status->profile->permalink(),
'published' => $status->created_at->toAtomString(),
'to' => $status->scopeToAudience('to'),
'cc' => $status->scopeToAudience('cc'),
];
}
public function includeObject(Status $status)
{
return $this->item($status, new Question());
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use Storage;
use App\Story;
use League\Fractal;
use Illuminate\Support\Str;
class CreateStory extends Fractal\TransformerAbstract
{
public function transform(Story $story)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $story->permalink(),
'type' => 'Add',
'actor' => $story->profile->permalink(),
'to' => [
$story->profile->permalink('/followers')
],
'object' => [
'id' => $story->url(),
'type' => 'Story',
'object' => $story->bearcapUrl(),
]
];
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use Storage;
use App\Story;
use League\Fractal;
use Illuminate\Support\Str;
class DeleteStory extends Fractal\TransformerAbstract
{
public function transform(Story $story)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $story->url() . '#delete',
'type' => 'Delete',
'actor' => $story->profile->permalink(),
'object' => [
'id' => $story->url(),
'type' => 'Story',
],
];
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Status;
use League\Fractal;
use Illuminate\Support\Str;
class Question 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();
$hashtags = $status->hashtags->map(function ($hashtag) {
return [
'type' => 'Hashtag',
'href' => $hashtag->url(),
'name' => "#{$hashtag->name}",
];
})->toArray();
$tags = array_merge($mentions, $hashtags);
return [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
'commentsEnabled' => 'sc:Boolean',
'capabilities' => [
'announce' => ['@type' => '@id'],
'like' => ['@type' => '@id'],
'reply' => ['@type' => '@id']
]
]
],
'id' => $status->url(),
'type' => 'Question',
'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' => [],
'tag' => $tags,
'commentsEnabled' => (bool) !$status->comments_disabled,
'capabilities' => [
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
'like' => 'https://www.w3.org/ns/activitystreams#Public',
'reply' => $status->comments_disabled == true ? null : '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,
'endTime' => $status->poll->expires_at->toAtomString(),
'oneOf' => collect($status->poll->poll_options)->map(function($option, $index) use($status) {
return [
'type' => 'Note',
'name' => $option,
'replies' => [
'type' => 'Collection',
'totalItems' => $status->poll->cached_tallies[$index]
]
];
})
];
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use Storage;
use App\Story;
use League\Fractal;
use Illuminate\Support\Str;
class StoryVerb extends Fractal\TransformerAbstract
{
public function transform(Story $story)
{
$type = $story->type == 'photo' ? 'Image' :
( $story->type == 'video' ? 'Video' :
'Document' );
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $story->url(),
'type' => 'Story',
'to' => [
$story->profile->permalink('/followers')
],
'cc' => [],
'attributedTo' => $story->profile->permalink(),
'published' => $story->created_at->toAtomString(),
'expiresAt' => $story->expires_at->toAtomString(),
'duration' => $story->duration,
'can_reply' => (bool) $story->can_reply,
'can_react' => (bool) $story->can_react,
'attachment' => [
'type' => $type,
'url' => url(Storage::url($story->path)),
'mediaType' => $story->mime,
],
];
}
}

View file

@ -5,81 +5,47 @@ namespace App\Transformer\Api\Mastodon\v1;
use App\Status; use App\Status;
use League\Fractal; use League\Fractal;
use Cache; use Cache;
use App\Services\MediaService;
use App\Services\ProfileService;
use App\Services\StatusHashtagService;
class StatusTransformer extends Fractal\TransformerAbstract class StatusTransformer extends Fractal\TransformerAbstract
{ {
protected $defaultIncludes = [ public function transform(Status $status)
'account', {
'media_attachments', return [
'mentions', 'id' => (string) $status->id,
'tags', 'created_at' => $status->created_at->toJSON(),
]; '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,
public function transform(Status $status) 'sensitive' => (bool) $status->is_nsfw,
{ 'spoiler_text' => $status->cw_summary ?? '',
return [ 'visibility' => $status->visibility ?? $status->scope,
'id' => (string) $status->id, 'language' => 'en',
'created_at' => $status->created_at->toJSON(), 'uri' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id ? (string) $status->in_reply_to_id : null, 'url' => $status->url(),
'in_reply_to_account_id' => $status->in_reply_to_profile_id, 'replies_count' => 0,
'sensitive' => (bool) $status->is_nsfw, 'reblogs_count' => $status->reblogs_count ?? 0,
'spoiler_text' => $status->cw_summary ?? '', 'favourites_count' => $status->likes_count ?? 0,
'visibility' => $status->visibility ?? $status->scope, 'reblogged' => $status->shared(),
'language' => 'en', 'favourited' => $status->liked(),
'uri' => $status->url(), 'muted' => false,
'url' => $status->url(), 'bookmarked' => false,
'replies_count' => 0, 'pinned' => false,
'reblogs_count' => $status->reblogs_count ?? 0, 'content' => $status->rendered ?? $status->caption ?? '',
'favourites_count' => $status->likes_count ?? 0, 'reblog' => null,
'reblogged' => $status->shared(), 'application' => [
'favourited' => $status->liked(), 'name' => 'web',
'muted' => false, 'website' => null
'bookmarked' => false, ],
'pinned' => false, 'mentions' => [],
'content' => $status->rendered ?? $status->caption ?? '', 'tags' => [],
'reblog' => null, 'emojis' => [],
'application' => [ 'card' => null,
'name' => 'web', 'poll' => null,
'website' => null 'media_attachments' => MediaService::get($status->id),
], 'account' => ProfileService::get($status->profile_id),
'mentions' => [], 'tags' => StatusHashtagService::statusTags($status->id),
'tags' => [], ];
'emojis' => [], }
'card' => null, }
'poll' => null,
];
}
public function includeAccount(Status $status)
{
$account = $status->profile;
return $this->item($account, new AccountTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('mastoapi:status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
if(in_array($status->type, ['photo', 'video', 'photo:album', 'loop', 'photo:video:album'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
} else {
return $this->collection([], new MediaTransformer());
}
});
}
public function includeMentions(Status $status)
{
$mentions = $status->mentions;
return $this->collection($mentions, new MentionTransformer());
}
public function includeTags(Status $status)
{
$hashtags = $status->hashtags;
return $this->collection($hashtags, new HashtagTransformer());
}
}

View file

@ -59,7 +59,10 @@ class NotificationTransformer extends Fractal\TransformerAbstract
'like' => 'favourite', 'like' => 'favourite',
'comment' => 'comment', 'comment' => 'comment',
'admin.user.modlog.comment' => 'modlog', 'admin.user.modlog.comment' => 'modlog',
'tagged' => 'tagged' 'tagged' => 'tagged',
'group:comment' => 'group:comment',
'story:react' => 'story:react',
'story:comment' => 'story:comment'
]; ];
return $verbs[$verb]; return $verbs[$verb];
} }
@ -90,7 +93,6 @@ class NotificationTransformer extends Fractal\TransformerAbstract
} }
} }
public function includeTagged(Notification $notification) public function includeTagged(Notification $notification)
{ {
$n = $notification; $n = $notification;

View file

@ -7,21 +7,19 @@ use League\Fractal;
use Cache; use Cache;
use App\Services\HashidService; use App\Services\HashidService;
use App\Services\LikeService; use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService; use App\Services\MediaTagService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService; use App\Services\StatusLabelService;
use App\Services\ProfileService; use App\Services\ProfileService;
use App\Services\PollService;
class StatusStatelessTransformer extends Fractal\TransformerAbstract class StatusStatelessTransformer extends Fractal\TransformerAbstract
{ {
protected $defaultIncludes = [
'account',
'tags',
'media_attachments',
];
public function transform(Status $status) public function transform(Status $status)
{ {
$taggedPeople = MediaTagService::get($status->id); $taggedPeople = MediaTagService::get($status->id);
$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
return [ return [
'_v' => 1, '_v' => 1,
@ -29,8 +27,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'shortcode' => HashidService::encode($status->id), 'shortcode' => HashidService::encode($status->id),
'uri' => $status->url(), 'uri' => $status->url(),
'url' => $status->url(), 'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id, 'in_reply_to_id' => (string) $status->in_reply_to_id,
'in_reply_to_account_id' => $status->in_reply_to_profile_id, 'in_reply_to_account_id' => (string) $status->in_reply_to_profile_id,
'reblog' => null, 'reblog' => null,
'content' => $status->rendered ?? $status->caption, 'content' => $status->rendered ?? $status->caption,
'content_text' => $status->caption, 'content_text' => $status->caption,
@ -43,7 +41,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'muted' => null, 'muted' => null,
'sensitive' => (bool) $status->is_nsfw, 'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '', 'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope, 'visibility' => $status->scope ?? $status->visibility,
'application' => [ 'application' => [
'name' => 'web', 'name' => 'web',
'website' => null 'website' => null
@ -62,31 +60,11 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'local' => (bool) $status->local, 'local' => (bool) $status->local,
'taggedPeople' => $taggedPeople, 'taggedPeople' => $taggedPeople,
'label' => StatusLabelService::get($status), 'label' => StatusLabelService::get($status),
'liked_by' => LikeService::likedBy($status) 'liked_by' => LikeService::likedBy($status),
'media_attachments' => MediaService::get($status->id),
'account' => ProfileService::get($status->profile_id),
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll
]; ];
} }
public function includeAccount(Status $status)
{
$account = $status->profile;
return $this->item($account, new AccountTransformer());
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}
} }

View file

@ -8,22 +8,20 @@ use League\Fractal;
use Cache; use Cache;
use App\Services\HashidService; use App\Services\HashidService;
use App\Services\LikeService; use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService; use App\Services\MediaTagService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService; use App\Services\StatusLabelService;
use App\Services\ProfileService; use App\Services\ProfileService;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Services\PollService;
class StatusTransformer extends Fractal\TransformerAbstract class StatusTransformer extends Fractal\TransformerAbstract
{ {
protected $defaultIncludes = [
'account',
'tags',
'media_attachments',
];
public function transform(Status $status) public function transform(Status $status)
{ {
$taggedPeople = MediaTagService::get($status->id); $taggedPeople = MediaTagService::get($status->id);
$poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null;
return [ return [
'_v' => 1, '_v' => 1,
@ -45,7 +43,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'muted' => null, 'muted' => null,
'sensitive' => (bool) $status->is_nsfw, 'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '', 'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope, 'visibility' => $status->scope ?? $status->visibility,
'application' => [ 'application' => [
'name' => 'web', 'name' => 'web',
'website' => null 'website' => null
@ -64,31 +62,11 @@ class StatusTransformer extends Fractal\TransformerAbstract
'local' => (bool) $status->local, 'local' => (bool) $status->local,
'taggedPeople' => $taggedPeople, 'taggedPeople' => $taggedPeople,
'label' => StatusLabelService::get($status), 'label' => StatusLabelService::get($status),
'liked_by' => LikeService::likedBy($status) 'liked_by' => LikeService::likedBy($status),
'media_attachments' => MediaService::get($status->id),
'account' => ProfileService::get($status->profile_id),
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll,
]; ];
} }
public function includeAccount(Status $status)
{
$account = $status->profile;
return $this->item($account, new AccountTransformer());
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(14), function() use($status) {
if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}
} }

View file

@ -32,6 +32,8 @@ use App\Services\MediaPathService;
use App\Services\MediaStorageService; use App\Services\MediaStorageService;
use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Jobs\AvatarPipeline\RemoteAvatarFetch; use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
use App\Util\Media\License;
use App\Models\Poll;
class Helpers { class Helpers {
@ -269,7 +271,7 @@ class Helpers {
$res = self::fetchFromUrl($url); $res = self::fetchFromUrl($url);
if(!$res || empty($res) || isset($res['error']) ) { if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
return; return;
} }
@ -330,7 +332,6 @@ class Helpers {
$idDomain = parse_url($id, PHP_URL_HOST); $idDomain = parse_url($id, PHP_URL_HOST);
$urlDomain = parse_url($url, PHP_URL_HOST); $urlDomain = parse_url($url, PHP_URL_HOST);
if(!self::validateUrl($id)) { if(!self::validateUrl($id)) {
return; return;
} }
@ -367,6 +368,7 @@ class Helpers {
$cw = true; $cw = true;
} }
$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']); $statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
$status = Cache::lock($statusLockKey) $status = Cache::lock($statusLockKey)
->get(function () use( ->get(function () use(
@ -379,6 +381,19 @@ class Helpers {
$scope, $scope,
$id $id
) { ) {
if($res['type'] === 'Question') {
$status = self::storePoll(
$profile,
$res,
$url,
$ts,
$reply_to,
$cw,
$scope,
$id
);
return $status;
}
return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) { return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
$status = new Status; $status = new Status;
$status->profile_id = $profile->id; $status->profile_id = $profile->id;
@ -408,6 +423,55 @@ class Helpers {
return $status; return $status;
} }
private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id)
{
if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) {
return;
}
$options = collect($res['oneOf'])->map(function($option) {
return $option['name'];
})->toArray();
$cachedTallies = collect($res['oneOf'])->map(function($option) {
return $option['replies']['totalItems'] ?? 0;
})->toArray();
$status = new Status;
$status->profile_id = $profile->id;
$status->url = isset($res['url']) ? $res['url'] : $url;
$status->uri = isset($res['url']) ? $res['url'] : $url;
$status->object_url = $id;
$status->caption = strip_tags($res['content']);
$status->rendered = Purify::clean($res['content']);
$status->created_at = Carbon::parse($ts);
$status->in_reply_to_id = null;
$status->local = false;
$status->is_nsfw = $cw;
$status->scope = 'draft';
$status->visibility = 'draft';
$status->cw_summary = $cw == true && isset($res['summary']) ?
Purify::clean(strip_tags($res['summary'])) : null;
$status->save();
$poll = new Poll;
$poll->status_id = $status->id;
$poll->profile_id = $status->profile_id;
$poll->poll_options = $options;
$poll->cached_tallies = $cachedTallies;
$poll->votes_count = array_sum($cachedTallies);
$poll->expires_at = now()->parse($res['endTime']);
$poll->last_fetched_at = now();
$poll->save();
$status->type = 'poll';
$status->scope = $scope;
$status->visibility = $scope;
$status->save();
return $status;
}
public static function statusFetch($url) public static function statusFetch($url)
{ {
return self::statusFirstOrFetch($url); return self::statusFirstOrFetch($url);
@ -428,6 +492,7 @@ class Helpers {
$type = $media['mediaType']; $type = $media['mediaType'];
$url = $media['url']; $url = $media['url'];
$blurhash = isset($media['blurhash']) ? $media['blurhash'] : null; $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null;
$license = isset($media['license']) ? License::nameToId($media['license']) : null;
$valid = self::validateUrl($url); $valid = self::validateUrl($url);
if(in_array($type, $allowed) == false || $valid == false) { if(in_array($type, $allowed) == false || $valid == false) {
continue; continue;
@ -441,6 +506,9 @@ class Helpers {
$media->user_id = null; $media->user_id = null;
$media->media_path = $url; $media->media_path = $url;
$media->remote_url = $url; $media->remote_url = $url;
if($license) {
$media->license = $license;
}
$media->mime = $type; $media->mime = $type;
$media->version = 3; $media->version = 3;
$media->save(); $media->save();
@ -495,9 +563,12 @@ class Helpers {
$profile = Profile::whereRemoteUrl($res['id'])->first(); $profile = Profile::whereRemoteUrl($res['id'])->first();
if(!$profile) { if(!$profile) {
Instance::firstOrCreate([ $instance = Instance::firstOrCreate([
'domain' => $domain 'domain' => $domain
]); ]);
if($instance->wasRecentlyCreated == true) {
\App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
}
$profileLockKey = 'helpers:profile-lock:' . hash('sha256', $res['id']); $profileLockKey = 'helpers:profile-lock:' . hash('sha256', $res['id']);
$profile = Cache::lock($profileLockKey)->get(function () use($domain, $webfinger, $res, $runJobs) { $profile = Cache::lock($profileLockKey)->get(function () use($domain, $webfinger, $res, $runJobs) {
return DB::transaction(function() use($domain, $webfinger, $res, $runJobs) { return DB::transaction(function() use($domain, $webfinger, $res, $runJobs) {

View file

@ -2,7 +2,7 @@
namespace App\Util\ActivityPub; namespace App\Util\ActivityPub;
use Cache, DB, Log, Purify, Redis, Validator; use Cache, DB, Log, Purify, Redis, Storage, Validator;
use App\{ use App\{
Activity, Activity,
DirectMessage, DirectMessage,
@ -14,6 +14,8 @@ use App\{
Profile, Profile,
Status, Status,
StatusHashtag, StatusHashtag,
Story,
StoryView,
UserFilter UserFilter
}; };
use Carbon\Carbon; use Carbon\Carbon;
@ -22,6 +24,8 @@ use Illuminate\Support\Str;
use App\Jobs\LikePipeline\LikePipeline; use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\FollowPipeline\FollowPipeline;
use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use App\Jobs\StoryPipeline\StoryExpire;
use App\Jobs\StoryPipeline\StoryFetch;
use App\Util\ActivityPub\Validator\Accept as AcceptValidator; use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
use App\Util\ActivityPub\Validator\Add as AddValidator; use App\Util\ActivityPub\Validator\Add as AddValidator;
@ -30,6 +34,9 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator;
use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\Like as LikeValidator;
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
use App\Services\PollService;
use App\Services\FollowerService;
class Inbox class Inbox
{ {
protected $headers; protected $headers;
@ -47,16 +54,7 @@ class Inbox
public function handle() public function handle()
{ {
$this->handleVerb(); $this->handleVerb();
// if(!Activity::where('data->id', $this->payload['id'])->exists()) {
// (new Activity())->create([
// 'to_id' => $this->profile->id,
// 'data' => json_encode($this->payload)
// ]);
// }
return; return;
} }
public function handleVerb() public function handleVerb()
@ -105,6 +103,18 @@ class Inbox
$this->handleUndoActivity(); $this->handleUndoActivity();
break; break;
case 'View':
$this->handleViewActivity();
break;
case 'Story:Reaction':
$this->handleStoryReactionActivity();
break;
case 'Story:Reply':
$this->handleStoryReplyActivity();
break;
default: default:
// TODO: decide how to handle invalid verbs. // TODO: decide how to handle invalid verbs.
break; break;
@ -136,6 +146,30 @@ class Inbox
public function handleAddActivity() public function handleAddActivity()
{ {
// stories ;) // stories ;)
if(!isset(
$this->payload['actor'],
$this->payload['object']
)) {
return;
}
$actor = $this->payload['actor'];
$obj = $this->payload['object'];
if(!Helpers::validateUrl($actor)) {
return;
}
if(!isset($obj['type'])) {
return;
}
switch($obj['type']) {
case 'Story':
StoryFetch::dispatch($this->payload)->onQueue('story');
break;
}
} }
public function handleCreateActivity() public function handleCreateActivity()
@ -147,6 +181,12 @@ class Inbox
} }
$to = $activity['to']; $to = $activity['to'];
$cc = isset($activity['cc']) ? $activity['cc'] : []; $cc = isset($activity['cc']) ? $activity['cc'] : [];
if($activity['type'] == 'Question') {
$this->handlePollCreate();
return;
}
if(count($to) == 1 && if(count($to) == 1 &&
count($cc) == 0 && count($cc) == 0 &&
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app') parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
@ -154,10 +194,11 @@ class Inbox
$this->handleDirectMessage(); $this->handleDirectMessage();
return; return;
} }
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) { if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
$this->handleNoteReply(); $this->handleNoteReply();
} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) { } elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) {
if(!$this->verifyNoteAttachment()) { if(!$this->verifyNoteAttachment()) {
return; return;
} }
@ -180,6 +221,18 @@ class Inbox
return; return;
} }
public function handlePollCreate()
{
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
if(!$actor || $actor->domain == null) {
return;
}
$url = isset($activity['url']) ? $activity['url'] : $activity['id'];
Helpers::statusFirstOrFetch($url);
return;
}
public function handleNoteCreate() public function handleNoteCreate()
{ {
$activity = $this->payload['object']; $activity = $this->payload['object'];
@ -188,6 +241,16 @@ class Inbox
return; return;
} }
if( isset($activity['inReplyTo']) &&
isset($activity['name']) &&
!isset($activity['content']) &&
!isset($activity['attachment']) &&
Helpers::validateLocalUrl($activity['inReplyTo'])
) {
$this->handlePollVote();
return;
}
if($actor->followers()->count() == 0) { if($actor->followers()->count() == 0) {
return; return;
} }
@ -200,6 +263,51 @@ class Inbox
return; return;
} }
public function handlePollVote()
{
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
$status = Helpers::statusFetch($activity['inReplyTo']);
$poll = $status->poll;
if(!$status || !$poll) {
return;
}
if(now()->gt($poll->expires_at)) {
return;
}
$choices = $poll->poll_options;
$choice = array_search($activity['name'], $choices);
if($choice === false) {
return;
}
if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) {
return;
}
$vote = new PollVote;
$vote->status_id = $status->id;
$vote->profile_id = $actor->id;
$vote->poll_id = $poll->id;
$vote->choice = $choice;
$vote->uri = isset($activity['id']) ? $activity['id'] : null;
$vote->save();
$tallies = $poll->cached_tallies;
$tallies[$choice] = $tallies[$choice] + 1;
$poll->cached_tallies = $tallies;
$poll->votes_count = array_sum($tallies);
$poll->save();
PollService::del($status->id);
return;
}
public function handleDirectMessage() public function handleDirectMessage()
{ {
$activity = $this->payload['object']; $activity = $this->payload['object'];
@ -420,7 +528,6 @@ class Inbox
public function handleAcceptActivity() public function handleAcceptActivity()
{ {
$actor = $this->payload['object']['actor']; $actor = $this->payload['object']['actor'];
$obj = $this->payload['object']['object']; $obj = $this->payload['object']['object'];
$type = $this->payload['object']['type']; $type = $this->payload['object']['type'];
@ -480,7 +587,7 @@ class Inbox
return; return;
} else { } else {
$type = $this->payload['object']['type']; $type = $this->payload['object']['type'];
$typeCheck = in_array($type, ['Person', 'Tombstone']); $typeCheck = in_array($type, ['Person', 'Tombstone', 'Story']);
if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) { if(!Helpers::validateUrl($actor) || !Helpers::validateUrl($obj['id']) || !$typeCheck) {
return; return;
} }
@ -520,6 +627,13 @@ class Inbox
return; return;
break; break;
case 'Story':
$story = Story::whereObjectId($id)
->first();
if($story) {
StoryExpire::dispatch($story)->onQueue('story');
}
default: default:
return; return;
break; break;
@ -558,10 +672,8 @@ class Inbox
return; return;
} }
public function handleRejectActivity() public function handleRejectActivity()
{ {
} }
public function handleUndoActivity() public function handleUndoActivity()
@ -631,4 +743,250 @@ class Inbox
} }
return; return;
} }
public function handleViewActivity()
{
if(!isset(
$this->payload['actor'],
$this->payload['object']
)) {
return;
}
$actor = $this->payload['actor'];
$obj = $this->payload['object'];
if(!Helpers::validateUrl($actor)) {
return;
}
if(!$obj || !is_array($obj)) {
return;
}
if(!isset($obj['type']) || !isset($obj['object']) || $obj['type'] != 'Story') {
return;
}
if(!Helpers::validateLocalUrl($obj['object'])) {
return;
}
$profile = Helpers::profileFetch($actor);
$storyId = Str::of($obj['object'])->explode('/')->last();
$story = Story::whereActive(true)
->whereLocal(true)
->find($storyId);
if(!$story) {
return;
}
if(!FollowerService::follows($profile->id, $story->profile_id)) {
return;
}
$view = StoryView::firstOrCreate([
'story_id' => $story->id,
'profile_id' => $profile->id
]);
if($view->wasRecentlyCreated == true) {
$story->view_count++;
$story->save();
}
}
public function handleStoryReactionActivity()
{
if(!isset(
$this->payload['actor'],
$this->payload['id'],
$this->payload['inReplyTo'],
$this->payload['content']
)) {
return;
}
$id = $this->payload['id'];
$actor = $this->payload['actor'];
$storyUrl = $this->payload['inReplyTo'];
$to = $this->payload['to'];
$text = Purify::clean($this->payload['content']);
if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
return;
}
if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) {
return;
}
if(!Helpers::validateLocalUrl($storyUrl)) {
return;
}
if(!Helpers::validateLocalUrl($to)) {
return;
}
if(Status::whereObjectUrl($id)->exists()) {
return;
}
$storyId = Str::of($storyUrl)->explode('/')->last();
$targetProfile = Helpers::profileFetch($to);
$story = Story::whereProfileId($targetProfile->id)
->find($storyId);
if(!$story) {
return;
}
if($story->can_react == false) {
return;
}
$actorProfile = Helpers::profileFetch($actor);
if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
return;
}
$status = new Status;
$status->profile_id = $actorProfile->id;
$status->type = 'story:reaction';
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id,
'reaction' => $text
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $actorProfile->id;
$dm->type = 'story:react';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $targetProfile->username,
'story_actor_username' => $actorProfile->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'reaction' => $text
]);
$dm->save();
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:react';
$n->message = "{$actorProfile->username} reacted to your story";
$n->rendered = "{$actorProfile->username} reacted to your story";
$n->save();
}
public function handleStoryReplyActivity()
{
if(!isset(
$this->payload['actor'],
$this->payload['id'],
$this->payload['inReplyTo'],
$this->payload['content']
)) {
return;
}
$id = $this->payload['id'];
$actor = $this->payload['actor'];
$storyUrl = $this->payload['inReplyTo'];
$to = $this->payload['to'];
$text = Purify::clean($this->payload['content']);
if(parse_url($id, PHP_URL_HOST) !== parse_url($actor, PHP_URL_HOST)) {
return;
}
if(!Helpers::validateUrl($id) || !Helpers::validateUrl($actor)) {
return;
}
if(!Helpers::validateLocalUrl($storyUrl)) {
return;
}
if(!Helpers::validateLocalUrl($to)) {
return;
}
if(Status::whereObjectUrl($id)->exists()) {
return;
}
$storyId = Str::of($storyUrl)->explode('/')->last();
$targetProfile = Helpers::profileFetch($to);
$story = Story::whereProfileId($targetProfile->id)
->find($storyId);
if(!$story) {
return;
}
if($story->can_react == false) {
return;
}
$actorProfile = Helpers::profileFetch($actor);
if(!FollowerService::follows($actorProfile->id, $targetProfile->id)) {
return;
}
$status = new Status;
$status->profile_id = $actorProfile->id;
$status->type = 'story:reply';
$status->caption = $text;
$status->rendered = $text;
$status->scope = 'direct';
$status->visibility = 'direct';
$status->in_reply_to_profile_id = $story->profile_id;
$status->entities = json_encode([
'story_id' => $story->id,
'caption' => $text
]);
$status->save();
$dm = new DirectMessage;
$dm->to_id = $story->profile_id;
$dm->from_id = $actorProfile->id;
$dm->type = 'story:comment';
$dm->status_id = $status->id;
$dm->meta = json_encode([
'story_username' => $targetProfile->username,
'story_actor_username' => $actorProfile->username,
'story_id' => $story->id,
'story_media_url' => url(Storage::url($story->path)),
'caption' => $text
]);
$dm->save();
$n = new Notification;
$n->profile_id = $dm->to_id;
$n->actor_id = $dm->from_id;
$n->item_id = $dm->id;
$n->item_type = 'App\DirectMessage';
$n->action = 'story:comment';
$n->message = "{$actorProfile->username} commented on story";
$n->rendered = "{$actorProfile->username} commented on story";
$n->save();
}
} }

View file

@ -0,0 +1,34 @@
<?php
namespace App\Util\ActivityPub\Validator;
use Validator;
use Illuminate\Validation\Rule;
class StoryValidator {
public static function validate($payload)
{
$valid = Validator::make($payload, [
'@context' => 'required',
'id' => 'required|string',
'type' => [
'required',
Rule::in(['Story'])
],
'to' => 'required',
'attributedTo' => 'required|url',
'published' => 'required|date',
'expiresAt' => 'required|date',
'duration' => 'required|integer|min:1|max:300',
'can_react' => 'required|boolean',
'can_reply' => 'required|boolean',
'attachment' => 'required',
'attachment.type' => 'required|in:Image,Video',
'attachment.url' => 'required|url',
'attachment.mediaType' => 'required'
])->passes();
return $valid;
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Util\Lexer;
use Illuminate\Support\Str;
use App\Util\ActivityPub\Helpers;
class Bearcap
{
public static function encode($url, $token)
{
return "bear:?t={$token}&u={$url}";
}
public static function decode($str)
{
if(!Str::startsWith($str, 'bear:')) {
return false;
}
$query = parse_url($str, PHP_URL_QUERY);
if(!$query) {
return false;
}
$res = [];
$parts = Str::of($str)->substr(6)->explode('&')->toArray();
foreach($parts as $part) {
if(Str::startsWith($part, 't=')) {
$res['token'] = substr($part, 2);
}
if(Str::startsWith($part, 'u=')) {
$res['url'] = substr($part, 2);
}
}
if( !isset($res['token']) ||
!isset($res['url'])
) {
return false;
}
$url = $res['url'];
if(mb_substr($url, 0, 8) !== 'https://') {
return false;
}
$valid = filter_var($url, FILTER_VALIDATE_URL);
if(!$valid) {
return false;
}
return $res;
}
}

View file

@ -120,4 +120,19 @@ class License {
->values() ->values()
->toArray(); ->toArray();
} }
public static function nameToId($name)
{
$license = collect(self::get())
->filter(function($l) use($name) {
return $l['title'] == $name;
})
->first();
if(!$license || $license['id'] < 2) {
return null;
}
return $license['id'];
}
} }

View file

@ -7,7 +7,7 @@ use Illuminate\Support\Str;
class Config { class Config {
const CACHE_KEY = 'api:site:configuration:_v0.3'; const CACHE_KEY = 'api:site:configuration:_v0.4';
public static function get() { public static function get() {
return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() { return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
@ -37,7 +37,8 @@ class Config {
'lc' => config('exp.lc'), 'lc' => config('exp.lc'),
'rec' => config('exp.rec'), 'rec' => config('exp.rec'),
'loops' => config('exp.loops'), 'loops' => config('exp.loops'),
'top' => config('exp.top') 'top' => config('exp.top'),
'polls' => config('exp.polls')
], ],
'site' => [ 'site' => [

View file

@ -1,7 +1,8 @@
<?php <?php
return [ use Illuminate\Database\DBAL\TimestampType;
return [
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Default Database Connection Name | Default Database Connection Name
@ -119,4 +120,9 @@ return [
], ],
'dbal' => [
'types' => [
'timestamp' => TimestampType::class,
],
],
]; ];

View file

@ -6,4 +6,5 @@ return [
'rec' => false, 'rec' => false,
'loops' => false, 'loops' => false,
'top' => env('EXP_TOP', false), 'top' => env('EXP_TOP', false),
'polls' => env('EXP_POLLS', false)
]; ];

View file

@ -81,6 +81,7 @@ return [
'waits' => [ 'waits' => [
'redis:feed' => 30, 'redis:feed' => 30,
'redis:default' => 30, 'redis:default' => 30,
'redis:low' => 30,
'redis:high' => 30, 'redis:high' => 30,
'redis:delete' => 30 'redis:delete' => 30
], ],
@ -166,7 +167,7 @@ return [
'production' => [ 'production' => [
'supervisor-1' => [ 'supervisor-1' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['high', 'default', 'feed', 'delete'], 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'],
'balance' => 'auto', 'balance' => 'auto',
'maxProcesses' => 20, 'maxProcesses' => 20,
'memory' => 128, 'memory' => 128,
@ -178,7 +179,7 @@ return [
'local' => [ 'local' => [
'supervisor-1' => [ 'supervisor-1' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['high', 'default', 'feed', 'delete'], 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'],
'balance' => 'auto', 'balance' => 'auto',
'maxProcesses' => 20, 'maxProcesses' => 20,
'memory' => 128, 'memory' => 128,

View file

@ -14,7 +14,7 @@ return [
'optimizers' => [ 'optimizers' => [
Jpegoptim::class => [ Jpegoptim::class => [
'-m75', // set maximum quality to 75% '-m' . (int) env('IMAGE_QUALITY', 80),
'--strip-all', // this strips out all text information such as comments and EXIF data '--strip-all', // this strips out all text information such as comments and EXIF data
'--all-progressive', // this will make sure the resulting image is a progressive one '--all-progressive', // this will make sure the resulting image is a progressive one
], ],

View file

@ -47,6 +47,10 @@ return [
] ]
], ],
'polls' => [
'enabled' => false
],
'stories' => [ 'stories' => [
'enabled' => env('STORIES_ENABLED', false), 'enabled' => env('STORIES_ENABLED', false),
], ],

View file

@ -278,4 +278,6 @@ return [
| |
*/ */
'media_fast_process' => env('PF_MEDIA_FAST_PROCESS', true), 'media_fast_process' => env('PF_MEDIA_FAST_PROCESS', true),
'max_altext_length' => env('PF_MEDIA_MAX_ALTTEXT_LENGTH', 1000),
]; ];

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddComposeSettingsToUserSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('user_settings', function (Blueprint $table) {
$table->json('compose_settings')->nullable();
});
Schema::table('media', function (Blueprint $table) {
$table->text('caption')->change();
$table->index('profile_id');
$table->index('mime');
$table->index('license');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('compose_settings');
});
Schema::table('media', function (Blueprint $table) {
$table->string('caption')->change();
$table->dropIndex('profile_id');
$table->dropIndex('mime');
$table->dropIndex('license');
});
}
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePollsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('polls', function (Blueprint $table) {
$table->bigInteger('id')->unsigned()->primary();
$table->bigInteger('story_id')->unsigned()->nullable()->index();
$table->bigInteger('status_id')->unsigned()->nullable()->index();
$table->bigInteger('group_id')->unsigned()->nullable()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->json('poll_options')->nullable();
$table->json('cached_tallies')->nullable();
$table->boolean('multiple')->default(false);
$table->boolean('hide_totals')->default(false);
$table->unsignedInteger('votes_count')->default(0);
$table->timestamp('last_fetched_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('polls');
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePollVotesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('poll_votes', function (Blueprint $table) {
$table->id();
$table->bigInteger('story_id')->unsigned()->nullable()->index();
$table->bigInteger('status_id')->unsigned()->nullable()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->bigInteger('poll_id')->unsigned()->index();
$table->unsignedInteger('choice')->default(0)->index();
$table->string('uri')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('poll_votes');
}
}

View file

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateStoriesTableFixExpiresAtColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('stories', function (Blueprint $table) {
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$doctrineTable = $sm->listTableDetails('stories');
if($doctrineTable->hasIndex('stories_expires_at_index')) {
$table->dropIndex('stories_expires_at_index');
}
$table->timestamp('expires_at')->default(null)->index()->nullable()->change();
$table->boolean('can_reply')->default(true);
$table->boolean('can_react')->default(true);
$table->string('object_id')->nullable()->unique();
$table->string('object_uri')->nullable()->unique();
$table->string('bearcap_token')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('stories', function (Blueprint $table) {
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$doctrineTable = $sm->listTableDetails('stories');
if($doctrineTable->hasIndex('stories_expires_at_index')) {
$table->dropIndex('stories_expires_at_index');
}
$table->timestamp('expires_at')->default(null)->index()->nullable()->change();
$table->dropColumn('can_reply');
$table->dropColumn('can_react');
$table->dropColumn('object_id');
$table->dropColumn('object_uri');
$table->dropColumn('bearcap_token');
});
}
}

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Jobs\InstancePipeline\InstanceCrawlPipeline;
class AddSoftwareColumnToInstancesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('instances', function (Blueprint $table) {
$table->string('software')->nullable()->index();
$table->unsignedInteger('user_count')->nullable();
$table->unsignedInteger('status_count')->nullable();
$table->timestamp('last_crawled_at')->nullable();
});
$this->runPostMigration();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('software');
$table->dropColumn('user_count');
$table->dropColumn('status_count');
$table->dropColumn('last_crawled_at');
});
}
protected function runPostMigration()
{
InstanceCrawlPipeline::dispatch();
}
}

BIN
public/css/app.css vendored

Binary file not shown.

BIN
public/css/appdark.css vendored

Binary file not shown.

BIN
public/css/landing.css vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 797 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

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