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))
- 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))
- 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 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 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 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/))
## [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\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
use App\HasSnowflakePrimary;
class Collection extends Model
{

View file

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

View file

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

View file

@ -7,6 +7,9 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Story;
use App\StoryView;
use App\Jobs\StoryPipeline\StoryExpire;
use App\Jobs\StoryPipeline\StoryRotateMedia;
use App\Services\StoryService;
class StoryGC extends Command
{
@ -41,89 +44,41 @@ class StoryGC extends Command
*/
public function handle()
{
$this->directoryScan();
$this->deleteViews();
$this->deleteStories();
$this->archiveExpiredStories();
$this->rotateMedia();
}
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;
}
$monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
$t1 = Storage::directories('public/_esm.t1');
$t2 = Storage::directories('public/_esm.t2');
$dirs = array_merge($t1, $t2);
foreach($dirs as $dir) {
$hash = last(explode('/', $dir));
if($hash != $monthHash) {
$this->info('Found directory to delete: ' . $dir);
$this->deleteDirectory($dir);
}
}
$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();
collect($queue)
->each(function($id) {
$story = StoryService::getById($id);
if(!$story) {
StoryService::removeRotateQueue($id);
return;
}
if($story->created_at->gt(now()->subMinutes(20))) {
return;
}
StoryRotateMedia::dispatch($story)->onQueue('story');
StoryService::removeRotateQueue($id);
});
}
$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,
Report,
Status,
Story,
User
};
use DB, Cache;
@ -27,6 +28,7 @@ use App\Http\Controllers\Admin\{
};
use Illuminate\Validation\Rule;
use App\Services\AdminStatsService;
use App\Services\StoryService;
class AdminController extends Controller
{
@ -465,4 +467,11 @@ class AdminController extends Controller
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_class' => 'nullable|alpha_dash|max:24',
'description' => 'nullable|string|max:420'
'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
]);
$user = $request->user();
@ -1091,6 +1091,17 @@ class ApiV1Controller extends Controller
$storagePath = MediaPathService::get($user, 2);
$path = $photo->store($storagePath);
$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);
@ -1105,6 +1116,9 @@ class ApiV1Controller extends Controller
$media->caption = $request->input('description');
$media->filter_class = $filterClass;
$media->filter_name = $filterName;
if($license) {
$media->license = $license;
}
$media->save();
switch ($media->mime) {
@ -1140,7 +1154,7 @@ class ApiV1Controller extends Controller
abort_if(!$request->user(), 403);
$this->validate($request, [
'description' => 'nullable|string|max:420'
'description' => 'nullable|string|max:' . config_cache('pixelfed.max_altext_length')
]);
$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?';
if($minId == $maxId) {

View file

@ -15,7 +15,8 @@ use App\{
Media,
Notification,
Profile,
Status
Status,
StatusArchived
};
use App\Transformer\Api\{
AccountTransformer,
@ -36,9 +37,11 @@ use App\Jobs\VideoPipeline\{
VideoPostProcess,
VideoThumbnail
};
use App\Services\AccountService;
use App\Services\NotificationService;
use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
use App\Services\StatusService;
class BaseApiController extends Controller
{
@ -54,26 +57,40 @@ class BaseApiController extends Controller
public function notifications(Request $request)
{
abort_if(!$request->user(), 403);
$pid = $request->user()->profile_id;
$pg = $request->input('pg');
if($pg == true) {
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::whereProfileId($pid)
->whereDate('created_at', '>', $timeago)
->latest()
->simplePaginate(10);
$resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer());
$res = $this->fractal->createData($resource)->toArray();
} else {
$this->validate($request, [
'page' => 'nullable|integer|min:1|max:10',
'limit' => 'nullable|integer|min:1|max:40'
]);
$limit = $request->input('limit') ?? 10;
$page = $request->input('page') ?? 1;
$end = (int) $page * $limit;
$start = (int) $end - $limit;
$res = NotificationService::get($pid, $start, $end);
$pid = $request->user()->profile_id;
$limit = $request->input('limit', 20);
$since = $request->input('since_id');
$min = $request->input('min_id');
$max = $request->input('max_id');
if(!$since && !$min && !$max) {
$min = 1;
}
$maxId = null;
$minId = null;
if($max) {
$res = NotificationService::getMax($pid, $max, $limit);
$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
if(!empty($ids)) {
$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);
@ -272,4 +289,74 @@ class BaseApiController extends Controller
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,
Place,
Status,
UserFilter
UserFilter,
UserSetting
};
use App\Models\Poll;
use App\Transformer\Api\{
MediaTransformer,
MediaDraftTransformer,
@ -41,7 +43,7 @@ use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
use App\Services\MediaStorageService;
use App\Services\MediaTagService;
use App\Services\ServiceService;
use App\Services\StatusService;
use Illuminate\Support\Str;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
@ -403,7 +405,7 @@ class ComposeController extends Controller
'media.*.id' => 'required|integer|min:1',
'media.*.filter_class' => 'nullable|alpha_dash|max:30',
'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',
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable',
@ -661,4 +663,73 @@ class ComposeController extends Controller
'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;
$status = Status::findOrFail($request->input('item'));
if ($status->likes()->whereProfileId($profile->id)->count() !== 0) {
if (Like::whereStatusId($status->id)->whereProfileId($profile->id)->exists()) {
$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
UnlikePipeline::dispatch($like);
} 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\UserFilter;
use League\Fractal;
use App\Services\FollowerService;
use App\Util\Lexer\Nickname;
use App\Util\Webfinger\Webfinger;
use App\Transformer\ActivityPub\ProfileOutbox;
@ -238,12 +239,12 @@ class ProfileController extends Controller
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
$pid = $profile->id;
$authed = Auth::user()->profile;
abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404);
$authed = Auth::user()->profile_id;
abort_if($pid != $authed && !FollowerService::follows($authed, $pid), 404);
$exists = Story::whereProfileId($pid)
->where('expires_at', '>', now())
->count();
abort_unless($exists > 0, 404);
->whereActive(true)
->exists();
abort_unless($exists, 404);
return view('profile.story', compact('pid', 'profile'));
}
}

View file

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

View file

@ -7,6 +7,7 @@ use App\Following;
use App\ProfileSponsor;
use App\Report;
use App\UserFilter;
use App\UserSetting;
use Auth, Cookie, DB, Cache, Purify;
use Illuminate\Support\Facades\Redis;
use Carbon\Carbon;
@ -21,6 +22,7 @@ use App\Http\Controllers\Settings\{
SecuritySettings
};
use App\Jobs\DeletePipeline\DeleteAccountPipeline;
use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
class SettingsController extends Controller
{
@ -221,7 +223,7 @@ class SettingsController extends Controller
$sponsors->sponsors = json_encode($res);
$sponsors->save();
$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)
@ -249,7 +251,69 @@ class SettingsController extends Controller
} else {
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\Support\Str;
use App\DirectMessage;
use App\Follower;
use App\Notification;
use App\Media;
use App\Profile;
use App\Status;
use App\Story;
use App\StoryView;
use App\Services\PollService;
use App\Services\ProfileService;
use App\Services\StoryService;
use Cache, Storage;
use Image as Intervention;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\MediaPathService;
use FFMpeg;
use FFMpeg\Coordinate\Dimension;
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);
$pid = $request->user()->profile_id;
$this->validate($request, [
'file' => function() {
return [
'required',
'mimes:image/jpeg,image/png,video/mp4',
'max:' . config_cache('pixelfed.max_photo_size'),
];
},
]);
$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)
$s = Story::select('stories.*', 'followers.following_id')
->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
->where('followers.profile_id', $pid)
->where('stories.active', true)
->groupBy('followers.following_id')
->orderByDesc('id')
->get();
} 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 [
'id' => (string) $s->id,
'photo' => $s->profile->avatarUrl(),
'name' => $s->profile->username,
'link' => $s->profile->url(),
'lastUpdated' => (int) $s->created_at->format('U'),
'seen' => $s->seen(),
'items' => [],
'pid' => (string) $s->profile->id
'pid' => $profile['id'],
'avatar' => $profile['avatar'],
'local' => $profile['local'],
'username' => $profile['acct'],
'url' => $url,
'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
'sid' => $s->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()
->map(function($s, $k) {
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()
];
->sortBy('seen')
->values();
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);
$authed = $request->user()->profile;
$authed = $request->user()->profile_id;
$profile = Profile::findOrFail($id);
if($id == $authed->id) {
$publicOnly = true;
} else {
$publicOnly = (bool) $profile->followedBy($authed);
if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
return [];
}
$stories = Story::whereProfileId($profile->id)
->whereActive(true)
->orderBy('expires_at')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get()
->map(function($s, $k) {
return [
'id' => $s->id,
'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
'length' => 10,
->map(function($s, $k) use($authed) {
$seen = StoryService::hasSeen($authed, $s->id);
$res = [
'id' => (string) $s->id,
'type' => $s->type,
'duration' => $s->duration,
'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' => $s->seen()
'created_at' => $s->created_at->toAtomString(),
'expires_at' => $s->expires_at->toAtomString(),
'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
'seen' => $seen,
'progress' => $seen ? 100 : 0,
'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();
if(count($stories) == 0) {
return [];
@ -342,32 +111,27 @@ class StoryController extends Controller
$cursor = count($stories) - 1;
$stories = [[
'id' => (string) $stories[$cursor]['id'],
'photo' => $profile->avatarUrl(),
'name' => $profile->username,
'link' => $profile->url(),
'lastUpdated' => (int) now()->format('U'),
'seen' => null,
'items' => $stories,
'nodes' => $stories,
'account' => AccountService::get($profile->id),
'pid' => (string) $profile->id
]];
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);
$this->validate($request, [
'id' => 'required|integer|min:1|exists:stories',
'id' => 'required|min:1',
]);
$id = $request->input('id');
$authed = $request->user()->profile;
$story = Story::with('profile')
->where('expires_at', '>', now())
->orderByDesc('expires_at')
->findOrFail($id);
$exp = $story->expires_at;
$profile = $story->profile;
@ -378,72 +142,32 @@ class StoryController extends Controller
$publicOnly = (bool) $profile->followedBy($authed);
abort_if(!$publicOnly, 403);
StoryView::firstOrCreate([
$v = StoryView::firstOrCreate([
'story_id' => $id,
'profile_id' => $authed->id
]);
$story->view_count = $story->view_count + 1;
$story->save();
if($v->wasRecentlyCreated) {
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];
}
public function apiV1Exists(Request $request, $id)
public function exists(Request $request, $id)
{
abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
$res = (bool) Story::whereProfileId($id)
return response()->json(Story::whereProfileId($id)
->whereActive(true)
->where('expires_at', '>', now())
->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');
->exists());
}
public function iRedirect(Request $request)
@ -455,4 +179,91 @@ class StoryController extends Controller
$username = $user->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
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $follower;
protected $follower;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($follower)
{
$this->follower = $follower;
}
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($follower)
{
$this->follower = $follower;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$follower = $this->follower;
$actor = $follower->actor;
$target = $follower->target;
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$follower = $this->follower;
$actor = $follower->actor;
$target = $follower->target;
if($target->domain || !$target->private_key) {
return;
}
Cache::forget('profile:following:' . $actor->id);
Cache::forget('profile:following:' . $target->id);
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();
if($target->domain || !$target->private_key) {
return;
}
$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->lpush($nkey, $notification->id);
} catch (Exception $e) {
Log::error($e);
}
}
$redis = Redis::connection();
$nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
$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\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature;
class StatusActivityPubDeliver implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
protected $status;
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$profile = $status->profile;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
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'])) {
// Return on profiles with no remote followers
return;
}
if($status->local == false || $status->url || $status->uri) {
return;
}
$audience = $status->profile->getAudienceInbox();
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
// Return on profiles with no remote followers
return;
}
switch($status->type) {
case 'poll':
$activitypubObject = new CreateQuestion();
break;
default:
$activitypubObject = new CreateNote();
break;
}
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new CreateNote());
$activity = $fractal->createData($resource)->toArray();
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, $activitypubObject);
$activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$payload = json_encode($activity);
$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
]
]);
};
}
};
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout')
]);
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
},
'rejected' => function ($reason, $index) {
}
]);
$promise = $pool->promise();
$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,
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()
{
$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 $casts = [
'srcset' => 'array'
];
public function status()
{
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;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
class Place extends Model
{

View file

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

View file

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

View file

@ -4,12 +4,13 @@ namespace App\Services;
use Cache;
use App\Profile;
use App\Status;
use App\Transformer\Api\AccountTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
class AccountService {
class AccountService
{
const CACHE_KEY = 'pf:services:account:';
public static function get($id)
@ -19,7 +20,7 @@ class AccountService {
}
$key = self::CACHE_KEY . $id;
$ttl = now()->addMinutes(15);
$ttl = now()->addHours(12);
return Cache::remember($key, $ttl, function() use($id) {
$fractal = new Fractal\Manager();
@ -35,4 +36,30 @@ class AccountService {
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;
use Illuminate\Support\Facades\Redis;
use Cache;
use App\{
Follower,
Profile,
@ -25,6 +25,8 @@ class FollowerService
{
Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
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)
@ -42,46 +44,34 @@ class FollowerService
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 $profile
->profile
->followers()
->whereLocalProfile(false)
->get()
->map(function($follow) {
return $follow->sharedInbox ?? $follow->inbox_url;
})
->unique()
->values()
->toArray();
}
return collect(self::audience($profile))
->filter(function($inbox) use($software) {
$domain = parse_url($inbox, PHP_URL_HOST);
if(!$domain) {
return false;
}
return InstanceService::software($domain) === strtolower($software);
})
->unique()
->values()
->toArray();
}
if($profile instanceOf Profile) {
return $profile
->followers()
->whereLocalProfile(false)
->get()
->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);
protected function getAudienceInboxes($pid, $scope = null)
{
$key = 'pf:services:follow:audience:' . $pid;
return Cache::remember($key, 86400, function() use($pid) {
$profile = Profile::find($pid);
if(!$profile) {
return [];
}
return $profile
->followers()
->whereLocalProfile(false)
@ -92,9 +82,7 @@ class FollowerService
->unique()
->values()
->toArray();
}
return [];
});
}
}

View file

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

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;
use Cache;
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 {
class ProfileService
{
public static function get($id)
{
$key = 'profile:model:' . $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;
return AccountService::get($id);
}
public static function del($id)
{
return AccountService::del($id);
}
}

View file

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

View file

@ -3,8 +3,10 @@
namespace App;
use Auth;
use Storage;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
use App\HasSnowflakePrimary;
use App\Util\Lexer\Bearcap;
class Story extends Model
{
@ -19,14 +21,11 @@ class Story extends Model
*/
public $incrementing = false;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['published_at', 'expires_at'];
protected $casts = [
'expires_at' => 'datetime'
];
protected $fillable = ['profile_id'];
protected $fillable = ['profile_id', 'view_count'];
protected $visible = ['id'];
@ -51,6 +50,42 @@ class Story extends Model
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 League\Fractal;
use App\Services\MediaService;
class StatusTransformer extends Fractal\TransformerAbstract
{
@ -45,15 +46,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'sensitive' => (bool) $status->is_nsfw,
'atomUri' => $status->url(),
'inReplyToAtomUri' => null,
'attachment' => $status->media->map(function ($media) {
return [
'type' => 'Document',
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => $media->caption,
'blurhash' => $media->blurhash
];
}),
'attachment' => MediaService::activitypub($status->id),
'tag' => [],
'location' => $status->place_id ? [
'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 League\Fractal;
use Cache;
use App\Services\MediaService;
use App\Services\ProfileService;
use App\Services\StatusHashtagService;
class StatusTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'media_attachments',
'mentions',
'tags',
];
public function transform(Status $status)
{
return [
'id' => (string) $status->id,
'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,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope,
'language' => 'en',
'uri' => $status->url(),
'url' => $status->url(),
'replies_count' => 0,
'reblogs_count' => $status->reblogs_count ?? 0,
'favourites_count' => $status->likes_count ?? 0,
'reblogged' => $status->shared(),
'favourited' => $status->liked(),
'muted' => false,
'bookmarked' => false,
'pinned' => false,
'content' => $status->rendered ?? $status->caption ?? '',
'reblog' => null,
'application' => [
'name' => 'web',
'website' => null
],
'mentions' => [],
'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());
}
}
public function transform(Status $status)
{
return [
'id' => (string) $status->id,
'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,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope,
'language' => 'en',
'uri' => $status->url(),
'url' => $status->url(),
'replies_count' => 0,
'reblogs_count' => $status->reblogs_count ?? 0,
'favourites_count' => $status->likes_count ?? 0,
'reblogged' => $status->shared(),
'favourited' => $status->liked(),
'muted' => false,
'bookmarked' => false,
'pinned' => false,
'content' => $status->rendered ?? $status->caption ?? '',
'reblog' => null,
'application' => [
'name' => 'web',
'website' => null
],
'mentions' => [],
'tags' => [],
'emojis' => [],
'card' => null,
'poll' => null,
'media_attachments' => MediaService::get($status->id),
'account' => ProfileService::get($status->profile_id),
'tags' => StatusHashtagService::statusTags($status->id),
];
}
}

View file

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

View file

@ -7,21 +7,19 @@ use League\Fractal;
use Cache;
use App\Services\HashidService;
use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService;
use App\Services\ProfileService;
use App\Services\PollService;
class StatusStatelessTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'tags',
'media_attachments',
];
public function transform(Status $status)
{
$taggedPeople = MediaTagService::get($status->id);
$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
return [
'_v' => 1,
@ -29,8 +27,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'shortcode' => HashidService::encode($status->id),
'uri' => $status->url(),
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id,
'in_reply_to_account_id' => $status->in_reply_to_profile_id,
'in_reply_to_id' => (string) $status->in_reply_to_id,
'in_reply_to_account_id' => (string) $status->in_reply_to_profile_id,
'reblog' => null,
'content' => $status->rendered ?? $status->caption,
'content_text' => $status->caption,
@ -43,7 +41,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope,
'visibility' => $status->scope ?? $status->visibility,
'application' => [
'name' => 'web',
'website' => null
@ -62,31 +60,11 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'local' => (bool) $status->local,
'taggedPeople' => $taggedPeople,
'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 App\Services\HashidService;
use App\Services\LikeService;
use App\Services\MediaService;
use App\Services\MediaTagService;
use App\Services\StatusHashtagService;
use App\Services\StatusLabelService;
use App\Services\ProfileService;
use Illuminate\Support\Str;
use App\Services\PollService;
class StatusTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'tags',
'media_attachments',
];
public function transform(Status $status)
{
$taggedPeople = MediaTagService::get($status->id);
$poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null;
return [
'_v' => 1,
@ -45,7 +43,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary ?? '',
'visibility' => $status->visibility ?? $status->scope,
'visibility' => $status->scope ?? $status->visibility,
'application' => [
'name' => 'web',
'website' => null
@ -64,31 +62,11 @@ class StatusTransformer extends Fractal\TransformerAbstract
'local' => (bool) $status->local,
'taggedPeople' => $taggedPeople,
'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\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
use App\Util\Media\License;
use App\Models\Poll;
class Helpers {
@ -269,7 +271,7 @@ class Helpers {
$res = self::fetchFromUrl($url);
if(!$res || empty($res) || isset($res['error']) ) {
if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
return;
}
@ -330,7 +332,6 @@ class Helpers {
$idDomain = parse_url($id, PHP_URL_HOST);
$urlDomain = parse_url($url, PHP_URL_HOST);
if(!self::validateUrl($id)) {
return;
}
@ -367,6 +368,7 @@ class Helpers {
$cw = true;
}
$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
$status = Cache::lock($statusLockKey)
->get(function () use(
@ -379,6 +381,19 @@ class Helpers {
$scope,
$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) {
$status = new Status;
$status->profile_id = $profile->id;
@ -408,6 +423,55 @@ class Helpers {
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)
{
return self::statusFirstOrFetch($url);
@ -428,6 +492,7 @@ class Helpers {
$type = $media['mediaType'];
$url = $media['url'];
$blurhash = isset($media['blurhash']) ? $media['blurhash'] : null;
$license = isset($media['license']) ? License::nameToId($media['license']) : null;
$valid = self::validateUrl($url);
if(in_array($type, $allowed) == false || $valid == false) {
continue;
@ -441,6 +506,9 @@ class Helpers {
$media->user_id = null;
$media->media_path = $url;
$media->remote_url = $url;
if($license) {
$media->license = $license;
}
$media->mime = $type;
$media->version = 3;
$media->save();
@ -495,9 +563,12 @@ class Helpers {
$profile = Profile::whereRemoteUrl($res['id'])->first();
if(!$profile) {
Instance::firstOrCreate([
$instance = Instance::firstOrCreate([
'domain' => $domain
]);
if($instance->wasRecentlyCreated == true) {
\App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)->onQueue('low');
}
$profileLockKey = 'helpers:profile-lock:' . hash('sha256', $res['id']);
$profile = Cache::lock($profileLockKey)->get(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;
use Cache, DB, Log, Purify, Redis, Validator;
use Cache, DB, Log, Purify, Redis, Storage, Validator;
use App\{
Activity,
DirectMessage,
@ -14,6 +14,8 @@ use App\{
Profile,
Status,
StatusHashtag,
Story,
StoryView,
UserFilter
};
use Carbon\Carbon;
@ -22,6 +24,8 @@ use Illuminate\Support\Str;
use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\FollowPipeline\FollowPipeline;
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\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\UndoFollow as UndoFollowValidator;
use App\Services\PollService;
use App\Services\FollowerService;
class Inbox
{
protected $headers;
@ -47,16 +54,7 @@ class Inbox
public function handle()
{
$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;
}
public function handleVerb()
@ -105,6 +103,18 @@ class Inbox
$this->handleUndoActivity();
break;
case 'View':
$this->handleViewActivity();
break;
case 'Story:Reaction':
$this->handleStoryReactionActivity();
break;
case 'Story:Reply':
$this->handleStoryReplyActivity();
break;
default:
// TODO: decide how to handle invalid verbs.
break;
@ -136,6 +146,30 @@ class Inbox
public function handleAddActivity()
{
// 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()
@ -147,6 +181,12 @@ class Inbox
}
$to = $activity['to'];
$cc = isset($activity['cc']) ? $activity['cc'] : [];
if($activity['type'] == 'Question') {
$this->handlePollCreate();
return;
}
if(count($to) == 1 &&
count($cc) == 0 &&
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
@ -154,10 +194,11 @@ class Inbox
$this->handleDirectMessage();
return;
}
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
$this->handleNoteReply();
} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
} elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) {
if(!$this->verifyNoteAttachment()) {
return;
}
@ -180,6 +221,18 @@ class Inbox
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()
{
$activity = $this->payload['object'];
@ -188,6 +241,16 @@ class Inbox
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) {
return;
}
@ -200,6 +263,51 @@ class Inbox
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()
{
$activity = $this->payload['object'];
@ -420,7 +528,6 @@ class Inbox
public function handleAcceptActivity()
{
$actor = $this->payload['object']['actor'];
$obj = $this->payload['object']['object'];
$type = $this->payload['object']['type'];
@ -480,7 +587,7 @@ class Inbox
return;
} else {
$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) {
return;
}
@ -520,6 +627,13 @@ class Inbox
return;
break;
case 'Story':
$story = Story::whereObjectId($id)
->first();
if($story) {
StoryExpire::dispatch($story)->onQueue('story');
}
default:
return;
break;
@ -558,10 +672,8 @@ class Inbox
return;
}
public function handleRejectActivity()
{
}
public function handleUndoActivity()
@ -631,4 +743,250 @@ class Inbox
}
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()
->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 {
const CACHE_KEY = 'api:site:configuration:_v0.3';
const CACHE_KEY = 'api:site:configuration:_v0.4';
public static function get() {
return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
@ -37,7 +37,8 @@ class Config {
'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
'loops' => config('exp.loops'),
'top' => config('exp.top')
'top' => config('exp.top'),
'polls' => config('exp.polls')
],
'site' => [

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ return [
'optimizers' => [
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
'--all-progressive', // this will make sure the resulting image is a progressive one
],

View file

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

View file

@ -278,4 +278,6 @@ return [
|
*/
'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