From 8ee104363ae931004b5a7478ad0c110bcf33e11e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Thu, 10 Jun 2021 22:54:31 -0600 Subject: [PATCH 1/3] Update Profile, add linkified bio, joined date, follows you label and improved website handling --- app/Http/Controllers/Api/ApiV1Controller.php | 4113 +++++++++-------- .../Controllers/Settings/HomeSettings.php | 295 +- resources/assets/js/components/Profile.vue | 37 +- resources/views/settings/home.blade.php | 2 +- 4 files changed, 2236 insertions(+), 2211 deletions(-) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index f48602ae9..4e7ad2bb8 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -10,28 +10,28 @@ use App\Util\Media\Filter; use Laravel\Passport\Passport; use Auth, Cache, DB, URL; use App\{ - Bookmark, - Follower, - FollowRequest, - Hashtag, - Like, - Media, - Notification, - Profile, - Status, - StatusHashtag, - User, - UserFilter, + Bookmark, + Follower, + FollowRequest, + Hashtag, + Like, + Media, + Notification, + Profile, + Status, + StatusHashtag, + User, + UserFilter, }; use League\Fractal; use App\Transformer\Api\Mastodon\v1\{ - AccountTransformer, - MediaTransformer, - NotificationTransformer, - StatusTransformer, + AccountTransformer, + MediaTransformer, + NotificationTransformer, + StatusTransformer, }; use App\Transformer\Api\{ - RelationshipTransformer, + RelationshipTransformer, }; use App\Http\Controllers\FollowerController; use League\Fractal\Serializer\ArraySerializer; @@ -44,17 +44,18 @@ use App\Jobs\StatusPipeline\StatusDelete; use App\Jobs\FollowPipeline\FollowPipeline; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\VideoPipeline\{ - VideoOptimize, - VideoPostProcess, - VideoThumbnail + VideoOptimize, + VideoPostProcess, + VideoThumbnail }; use App\Services\{ - NotificationService, - MediaPathService, - SearchApiV2Service, - StatusService, - MediaBlocklistService + NotificationService, + MediaPathService, + SearchApiV2Service, + StatusService, + MediaBlocklistService }; +use App\Util\Lexer\Autolink; class ApiV1Controller extends Controller { @@ -77,78 +78,78 @@ class ApiV1Controller extends Controller 'website' => 'nullable' ]); - $uris = implode(',', explode('\n', $request->redirect_uris)); + $uris = implode(',', explode('\n', $request->redirect_uris)); - $client = Passport::client()->forceFill([ - 'user_id' => null, - 'name' => e($request->client_name), - 'secret' => Str::random(40), - 'redirect' => $uris, - 'personal_access_client' => false, - 'password_client' => false, - 'revoked' => false, - ]); + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => e($request->client_name), + 'secret' => Str::random(40), + 'redirect' => $uris, + 'personal_access_client' => false, + 'password_client' => false, + 'revoked' => false, + ]); - $client->save(); + $client->save(); - $res = [ - 'id' => $client->id, - 'name' => $client->name, - 'website' => null, - 'redirect_uri' => $client->redirect, - 'client_id' => $client->id, - 'client_secret' => $client->secret, - 'vapid_key' => null - ]; + $res = [ + 'id' => $client->id, + 'name' => $client->name, + 'website' => null, + 'redirect_uri' => $client->redirect, + 'client_id' => $client->id, + 'client_secret' => $client->secret, + 'vapid_key' => null + ]; - return response()->json($res, 200, [ - 'Access-Control-Allow-Origin' => '*' - ]); + return response()->json($res, 200, [ + 'Access-Control-Allow-Origin' => '*' + ]); } - /** - * GET /api/v1/accounts/verify_credentials - * - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function verifyCredentials(Request $request) - { - abort_if(!$request->user(), 403); - $id = $request->user()->id; + /** + * GET /api/v1/accounts/verify_credentials + * + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function verifyCredentials(Request $request) + { + abort_if(!$request->user(), 403); + $id = $request->user()->id; - if($request->user()->last_active_at) { - $key = 'user:last_active_at:id:'.$id; - $ttl = now()->addMinutes(5); - Cache::remember($key, $ttl, function() use($id) { - $user = User::findOrFail($id); - $user->last_active_at = now(); - $user->save(); - return; - }); - } + if($request->user()->last_active_at) { + $key = 'user:last_active_at:id:'.$id; + $ttl = now()->addMinutes(5); + Cache::remember($key, $ttl, function() use($id) { + $user = User::findOrFail($id); + $user->last_active_at = now(); + $user->save(); + return; + }); + } - $profile = Profile::whereNull('status')->whereUserId($id)->firstOrFail(); - $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - $res['source'] = [ - 'privacy' => $profile->is_private ? 'private' : 'public', - 'sensitive' => $profile->cw ? true : false, - 'language' => null, - 'note' => '', - 'fields' => [] - ]; + $profile = Profile::whereNull('status')->whereUserId($id)->firstOrFail(); + $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + $res['source'] = [ + 'privacy' => $profile->is_private ? 'private' : 'public', + 'sensitive' => $profile->cw ? true : false, + 'language' => null, + 'note' => '', + 'fields' => [] + ]; - return response()->json($res); - } + return response()->json($res); + } - /** - * GET /api/v1/accounts/{id} - * - * @param integer $id - * - * @return \App\Transformer\Api\AccountTransformer - */ + /** + * GET /api/v1/accounts/{id} + * + * @param integer $id + * + * @return \App\Transformer\Api\AccountTransformer + */ public function accountById(Request $request, $id) { $profile = Profile::whereNull('status')->findOrFail($id); @@ -158,1864 +159,1864 @@ class ApiV1Controller extends Controller return response()->json($res); } - /** - * PATCH /api/v1/accounts/update_credentials - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountUpdateCredentials(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'display_name' => 'nullable|string', - 'note' => 'nullable|string', - 'locked' => 'nullable', - // 'source.privacy' => 'nullable|in:unlisted,public,private', - // 'source.sensitive' => 'nullable|boolean' - ]); - - $user = $request->user(); - $profile = $user->profile; - - $displayName = $request->input('display_name'); - $note = $request->input('note'); - $locked = $request->input('locked'); - // $privacy = $request->input('source.privacy'); - // $sensitive = $request->input('source.sensitive'); - - $changes = false; - - if($displayName !== $user->name) { - $user->name = $displayName; - $profile->name = $displayName; - $changes = true; - } - - if($note !== $profile->bio) { - $profile->bio = e($note); - $changes = true; - } - - if(!is_null($locked)) { - $profile->is_private = $locked; - $changes = true; - } - - if($changes) { - $user->save(); - $profile->save(); - } - - $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * GET /api/v1/accounts/{id}/followers - * - * @param integer $id - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountFollowersById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $profile = Profile::whereNull('status')->findOrFail($id); - $limit = $request->input('limit') ?? 40; - - if($profile->domain) { - $res = []; - } else { - if($profile->id == $user->profile_id) { - $followers = $profile->followers()->paginate($limit); - $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - } else { - if($profile->is_private) { - abort_if(!$profile->followedBy($user->profile), 403); - } - $settings = $profile->user->settings; - if( in_array($user->profile_id, $profile->blockedIds()->toArray()) || - $settings->show_profile_followers == false - ) { - $res = []; - } else { - $followers = $profile->followers()->paginate($limit); - $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - } - } - } - return response()->json($res); - } - - /** - * GET /api/v1/accounts/{id}/following - * - * @param integer $id - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountFollowingById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $profile = Profile::whereNull('status')->findOrFail($id); - $limit = $request->input('limit') ?? 40; - - if($profile->domain) { - $res = []; - } else { - if($profile->id == $user->profile_id) { - $following = $profile->following()->paginate($limit); - $resource = new Fractal\Resource\Collection($following, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - } else { - if($profile->is_private) { - abort_if(!$profile->followedBy($user->profile), 403); - } - $settings = $profile->user->settings; - if( in_array($user->profile_id, $profile->blockedIds()->toArray()) || - $settings->show_profile_following == false - ) { - $res = []; - } else { - $following = $profile->following()->paginate($limit); - $resource = new Fractal\Resource\Collection($following, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - } - } - } - - - return response()->json($res); - } - - /** - * GET /api/v1/accounts/{id}/statuses - * - * @param integer $id - * - * @return \App\Transformer\Api\StatusTransformer - */ - public function accountStatusesById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'only_media' => 'nullable', - 'pinned' => 'nullable', - 'exclude_replies' => 'nullable', - 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'limit' => 'nullable|integer|min:1|max:80' - ]); - - $profile = Profile::whereNull('status')->findOrFail($id); - - $limit = $request->limit ?? 20; - $max_id = $request->max_id; - $min_id = $request->min_id; - $pid = $request->user()->profile_id; - $scope = $request->only_media == true ? - ['photo', 'photo:album', 'video', 'video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply']; - - if($pid == $profile->id) { - $visibility = ['public', 'unlisted', 'private']; - } else if($profile->is_private) { - $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 { - $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'] : ['public', 'unlisted']; - } - - if($min_id || $max_id) { - $dir = $min_id ? '>' : '<'; - $id = $min_id ?? $max_id; - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->where('id', $dir, $id) - ->whereIn('visibility', $visibility) - ->latest() - ->limit($limit) - ->get(); - } else { - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereProfileId($profile->id) - ->whereIn('type', $scope) - ->whereIn('visibility', $visibility) - ->latest() - ->limit($limit) - ->get(); - } - - $resource = new Fractal\Resource\Collection($timeline, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/follow - * - * @param integer $id - * - * @return \App\Transformer\Api\RelationshipTransformer - */ - public function accountFollowById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $target = Profile::where('id', '!=', $user->profile_id) - ->whereNull('status') - ->findOrFail($id); - - $private = (bool) $target->is_private; - $remote = (bool) $target->domain; - $blocked = UserFilter::whereUserId($target->id) - ->whereFilterType('block') - ->whereFilterableId($user->profile_id) - ->whereFilterableType('App\Profile') - ->exists(); - - if($blocked == true) { - abort(400, 'You cannot follow this user.'); - } - - $isFollowing = Follower::whereProfileId($user->profile_id) - ->whereFollowingId($target->id) - ->exists(); - - // Following already, return empty relationship - if($isFollowing == true) { - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - // Rate limits, max 7500 followers per account - if($user->profile->following()->count() >= Follower::MAX_FOLLOWING) { - abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts'); - } - - // Rate limits, follow 30 accounts per hour max - if($user->profile->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) { - abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour'); - } - - if($private == true) { - $follow = FollowRequest::firstOrCreate([ - 'follower_id' => $user->profile_id, - 'following_id' => $target->id - ]); - if($remote == true && config('federation.activitypub.remoteFollow') == true) { - (new FollowerController())->sendFollow($user->profile, $target); - } - } else { - $follower = new Follower(); - $follower->profile_id = $user->profile_id; - $follower->following_id = $target->id; - $follower->save(); - - if($remote == true && config('federation.activitypub.remoteFollow') == true) { - (new FollowerController())->sendFollow($user->profile, $target); - } - FollowPipeline::dispatch($follower); - } - - Cache::forget('profile:following:'.$target->id); - Cache::forget('profile:followers:'.$target->id); - Cache::forget('profile:following:'.$user->profile_id); - Cache::forget('profile:followers:'.$user->profile_id); - Cache::forget('api:local:exp:rec:'.$user->profile_id); - Cache::forget('user:account:id:'.$target->user_id); - Cache::forget('user:account:id:'.$user->id); - Cache::forget('profile:follower_count:'.$target->id); - Cache::forget('profile:follower_count:'.$user->profile_id); - Cache::forget('profile:following_count:'.$target->id); - Cache::forget('profile:following_count:'.$user->profile_id); - - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/unfollow - * - * @param integer $id - * - * @return \App\Transformer\Api\RelationshipTransformer - */ - public function accountUnfollowById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $target = Profile::where('id', '!=', $user->profile_id) - ->whereNull('status') - ->findOrFail($id); - - $private = (bool) $target->is_private; - $remote = (bool) $target->domain; - - $isFollowing = Follower::whereProfileId($user->profile_id) - ->whereFollowingId($target->id) - ->exists(); - - if($isFollowing == false) { - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - // Rate limits, follow 30 accounts per hour max - if($user->profile->following()->where('followers.updated_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) { - abort(400, 'You can only follow or unfollow ' . Follower::FOLLOW_PER_HOUR . ' users per hour'); - } - - FollowRequest::whereFollowerId($user->profile_id) - ->whereFollowingId($target->id) - ->delete(); - - Follower::whereProfileId($user->profile_id) - ->whereFollowingId($target->id) - ->delete(); - - if($remote == true && config('federation.activitypub.remoteFollow') == true) { - (new FollowerController())->sendUndoFollow($user->profile, $target); - } - - Cache::forget('profile:following:'.$target->id); - Cache::forget('profile:followers:'.$target->id); - Cache::forget('profile:following:'.$user->profile_id); - Cache::forget('profile:followers:'.$user->profile_id); - Cache::forget('api:local:exp:rec:'.$user->profile_id); - Cache::forget('user:account:id:'.$target->user_id); - Cache::forget('user:account:id:'.$user->id); - - $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * GET /api/v1/accounts/relationships - * - * @param array|integer $id - * - * @return \App\Transformer\Api\RelationshipTransformer - */ - public function accountRelationshipsById(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'id' => 'required|array|min:1|max:20', - 'id.*' => 'required|integer|min:1|max:' . PHP_INT_MAX - ]); - $pid = $request->user()->profile_id ?? $request->user()->profile->id; - $ids = collect($request->input('id')); - $filtered = $ids->filter(function($v) use($pid) { - return $v != $pid; - }); - $relations = Profile::whereNull('status')->findOrFail($filtered->values()); - $fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer()); - $res = $this->fractal->createData($fractal)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/accounts/search - * - * - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountSearch(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'q' => 'required|string|min:1|max:255', - 'limit' => 'nullable|integer|min:1|max:40', - 'resolve' => 'nullable' - ]); - - $user = $request->user(); - $query = $request->input('q'); - $limit = $request->input('limit') ?? 20; - $resolve = (bool) $request->input('resolve', false); - $q = '%' . $query . '%'; - - $profiles = Profile::whereNull('status') - ->where('username', 'like', $q) - ->orWhere('name', 'like', $q) - ->limit($limit) - ->get(); - - $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/blocks - * - * - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountBlocks(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:40', - 'page' => 'nullable|integer|min:1|max:10' - ]); - - $user = $request->user(); - $limit = $request->input('limit') ?? 40; - - $blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id') - ->whereUserId($user->profile_id) - ->whereFilterableType('App\Profile') - ->whereFilterType('block') - ->simplePaginate($limit) - ->pluck('filterable_id'); - - $profiles = Profile::findOrFail($blocked); - $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/block - * - * @param integer $id - * - * @return \App\Transformer\Api\RelationshipTransformer - */ - public function accountBlockById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $pid = $user->profile_id ?? $user->profile->id; - - if($id == $pid) { - abort(400, 'You cannot block yourself'); - } - - $profile = Profile::findOrFail($id); - - if($profile->user->is_admin == true) { - abort(400, 'You cannot block an admin'); - } - - Follower::whereProfileId($profile->id)->whereFollowingId($pid)->delete(); - Follower::whereProfileId($pid)->whereFollowingId($profile->id)->delete(); - Notification::whereProfileId($pid)->whereActorId($profile->id)->delete(); - - $filter = UserFilter::firstOrCreate([ - 'user_id' => $pid, - 'filterable_id' => $profile->id, - 'filterable_type' => 'App\Profile', - 'filter_type' => 'block', - ]); - - Cache::forget("user:filter:list:$pid"); - Cache::forget("api:local:exp:rec:$pid"); - - $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/unblock - * - * @param integer $id - * - * @return \App\Transformer\Api\RelationshipTransformer - */ - public function accountUnblockById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $pid = $user->profile_id ?? $user->profile->id; - - if($id == $pid) { - abort(400, 'You cannot unblock yourself'); - } - - $profile = Profile::findOrFail($id); - - UserFilter::whereUserId($pid) - ->whereFilterableId($profile->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('block') - ->delete(); - - Cache::forget("user:filter:list:$pid"); - Cache::forget("api:local:exp:rec:$pid"); - - $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * GET /api/v1/custom_emojis - * - * Return empty array, we don't support custom emoji - * - * @return array - */ - public function customEmojis() - { - return response()->json([]); - } - - /** - * GET /api/v1/domain_blocks - * - * Return empty array - * - * @return array - */ - public function accountDomainBlocks(Request $request) - { - abort_if(!$request->user(), 403); - return response()->json([]); - } - - /** - * GET /api/v1/endorsements - * - * Return empty array - * - * @return array - */ - public function accountEndorsements(Request $request) - { - abort_if(!$request->user(), 403); - return response()->json([]); - } - - /** - * GET /api/v1/favourites - * - * Returns collection of liked statuses - * - * @return \App\Transformer\Api\StatusTransformer - */ - public function accountFavourites(Request $request) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $limit = $request->input('limit') ?? 20; - $favourites = Like::whereProfileId($user->profile_id) - ->latest() - ->simplePaginate($limit) - ->pluck('status_id'); - - $statuses = Status::findOrFail($favourites); - $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/statuses/{id}/favourite - * - * @param integer $id - * - * @return \App\Transformer\Api\StatusTransformer - */ - public function statusFavouriteById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $like = Like::firstOrCreate([ - 'profile_id' => $user->profile_id, - 'status_id' => $status->id - ]); - - if($like->wasRecentlyCreated == true) { - $status->likes_count = $status->likes()->count(); - $status->save(); - LikePipeline::dispatch($like); - } - - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/statuses/{id}/unfavourite - * - * @param integer $id - * - * @return \App\Transformer\Api\StatusTransformer - */ - public function statusUnfavouriteById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $like = Like::whereProfileId($user->profile_id) - ->whereStatusId($status->id) - ->first(); - - if($like) { - $like->forceDelete(); - $status->likes_count = $status->likes()->count(); - $status->save(); - } - - StatusService::del($status->id); - - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/filters - * - * Return empty response since we filter server side - * - * @return array - */ - public function accountFilters(Request $request) - { - abort_if(!$request->user(), 403); - - return response()->json([]); - } - - /** - * GET /api/v1/follow_requests - * - * Return array of Accounts that have sent follow requests - * - * @return \App\Transformer\Api\AccountTransformer - */ - public function accountFollowRequests(Request $request) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $followRequests = FollowRequest::whereFollowingId($user->profile->id)->pluck('follower_id'); - - $profiles = Profile::find($followRequests); - - $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/follow_requests/{id}/authorize - * - * @param integer $id - * - * @return null - */ - public function accountFollowRequestAccept(Request $request, $id) - { - abort_if(!$request->user(), 403); - - // todo - - return response()->json([]); - } - - /** - * POST /api/v1/follow_requests/{id}/reject - * - * @param integer $id - * - * @return null - */ - public function accountFollowRequestReject(Request $request, $id) - { - abort_if(!$request->user(), 403); - - // todo - - return response()->json([]); - } - - /** - * GET /api/v1/suggestions - * - * Return empty array as we don't support suggestions - * - * @return null - */ - public function accountSuggestions(Request $request) - { - abort_if(!$request->user(), 403); - - // todo - - return response()->json([]); - } - - /** - * GET /api/v1/instance - * - * Information about the server. - * - * @return Instance - */ - public function instance(Request $request) - { - $res = Cache::remember('api:v1:instance-data', now()->addMinutes(15), function () { - $rules = config_cache('app.rules') ? collect(json_decode(config_cache('app.rules'), true)) - ->map(function($rule, $key) { - $id = $key + 1; - return [ - 'id' => "{$id}", - 'text' => $rule - ]; - }) - ->toArray() : []; - $res = [ - 'approval_required' => false, - 'contact_account' => null, - 'description' => config_cache('app.description'), - 'email' => config('instance.email'), - 'invites_enabled' => false, - 'rules' => $rules, - 'short_description' => 'Pixelfed - Photo sharing for everyone', - 'languages' => ['en'], - 'max_toot_chars' => (int) config('pixelfed.max_caption_length'), - 'registrations' => (bool) config_cache('pixelfed.open_registration'), - 'stats' => [ - 'user_count' => 0, - 'status_count' => 0, - 'domain_count' => 0 - ], - 'thumbnail' => config('app.url') . '/img/pixelfed-icon-color.png', - 'title' => config_cache('app.name'), - 'uri' => config('pixelfed.domain.app'), - 'urls' => [], - 'version' => '2.7.2 (compatible; Pixelfed ' . config('pixelfed.version') . ')', - 'environment' => [ - 'max_photo_size' => (int) config_cache('pixelfed.max_photo_size'), - 'max_avatar_size' => (int) config('pixelfed.max_avatar_size'), - 'max_caption_length' => (int) config('pixelfed.max_caption_length'), - 'max_bio_length' => (int) config('pixelfed.max_bio_length'), - 'max_album_length' => (int) config_cache('pixelfed.max_album_length'), - 'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled') - - ] - ]; - return $res; - }); - return response()->json($res); - } - - /** - * GET /api/v1/lists - * - * Return empty array as we don't support lists - * - * @return null - */ - public function accountLists(Request $request) - { - abort_if(!$request->user(), 403); - - return response()->json([]); - } - - /** - * GET /api/v1/accounts/{id}/lists - * - * @param integer $id - * - * @return null - */ - public function accountListsById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - return response()->json([]); - } - - /** - * POST /api/v1/media - * - * - * @return MediaTransformer - */ - public function mediaUpload(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'file.*' => function() { - return [ - 'required', - 'mimes:' . config_cache('pixelfed.media_types'), - 'max:' . config_cache('pixelfed.max_photo_size'), - ]; - }, - 'filter_name' => 'nullable|string|max:24', - 'filter_class' => 'nullable|alpha_dash|max:24', - 'description' => 'nullable|string|max:420' - ]); - - $user = $request->user(); - - if($user->last_active_at == null) { - return []; - } - - $limitKey = 'compose:rate-limit:media-upload:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); - - return $dailyLimit >= 250; - }); - abort_if($limitReached == true, 429); - - $profile = $user->profile; - - if(config_cache('pixelfed.enforce_account_limit') == true) { - $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { - return Media::whereUserId($user->id)->sum('size') / 1000; - }); - $limit = (int) config_cache('pixelfed.max_account_size'); - if ($size >= $limit) { - abort(403, 'Account size limit reached.'); - } - } - - $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; - $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; - - $photo = $request->file('file'); - - $mimes = explode(',', config_cache('pixelfed.media_types')); - if(in_array($photo->getMimeType(), $mimes) == false) { - abort(403, 'Invalid or unsupported mime type.'); - } - - $storagePath = MediaPathService::get($user, 2); - $path = $photo->store($storagePath); - $hash = \hash_file('sha256', $photo); - - abort_if(MediaBlocklistService::exists($hash) == true, 451); - - $media = new Media(); - $media->status_id = null; - $media->profile_id = $profile->id; - $media->user_id = $user->id; - $media->media_path = $path; - $media->original_sha256 = $hash; - $media->size = $photo->getSize(); - $media->mime = $photo->getMimeType(); - $media->caption = $request->input('description'); - $media->filter_class = $filterClass; - $media->filter_name = $filterName; - $media->save(); - - switch ($media->mime) { - case 'image/jpeg': - case 'image/png': - ImageOptimize::dispatch($media); - break; - - case 'video/mp4': - VideoThumbnail::dispatch($media); - $preview_url = '/storage/no-preview.png'; - $url = '/storage/no-preview.png'; - break; - } - - Cache::forget($limitKey); - $resource = new Fractal\Resource\Item($media, new MediaTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - $res['preview_url'] = $media->url(). '?cb=1&_v=' . time(); - $res['url'] = $media->url(). '?cb=1&_v=' . time(); - return response()->json($res); - } - - /** - * PUT /api/v1/media/{id} - * - * @param integer $id - * - * @return MediaTransformer - */ - public function mediaUpdate(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'description' => 'nullable|string|max:420' - ]); - - $user = $request->user(); - - $media = Media::whereUserId($user->id) - ->whereNull('status_id') - ->findOrFail($id); - - $media->caption = $request->input('description'); - $media->save(); - - $resource = new Fractal\Resource\Item($media, new MediaTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - $res['preview_url'] = url('/storage/no-preview.png'); - $res['url'] = url('/storage/no-preview.png'); - return response()->json($res); - } - - /** - * GET /api/v1/mutes - * - * - * @return AccountTransformer - */ - public function accountMutes(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:40' - ]); - - $user = $request->user(); - $limit = $request->input('limit') ?? 40; - - $mutes = UserFilter::whereUserId($user->profile_id) - ->whereFilterableType('App\Profile') - ->whereFilterType('mute') - ->simplePaginate($limit) - ->pluck('filterable_id'); - - $accounts = Profile::find($mutes); - - $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/mute - * - * @param integer $id - * - * @return RelationshipTransformer - */ - public function accountMuteById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $pid = $user->profile_id; - - $account = Profile::findOrFail($id); - - $filter = UserFilter::firstOrCreate([ - 'user_id' => $pid, - 'filterable_id' => $account->id, - 'filterable_type' => 'App\Profile', - 'filter_type' => 'mute', - ]); - - Cache::forget("user:filter:list:$pid"); - Cache::forget("feature:discover:posts:$pid"); - Cache::forget("api:local:exp:rec:$pid"); - - $resource = new Fractal\Resource\Item($account, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/accounts/{id}/unmute - * - * @param integer $id - * - * @return RelationshipTransformer - */ - public function accountUnmuteById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $pid = $user->profile_id; - - $account = Profile::findOrFail($id); - - $filter = UserFilter::whereUserId($pid) - ->whereFilterableId($account->id) - ->whereFilterableType('App\Profile') - ->whereFilterType('mute') - ->first(); - - if($filter) { - $filter->delete(); - Cache::forget("user:filter:list:$pid"); - Cache::forget("feature:discover:posts:$pid"); - Cache::forget("api:local:exp:rec:$pid"); - } - - $resource = new Fractal\Resource\Item($account, new RelationshipTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/notifications - * - * - * @return NotificationTransformer - */ - public function accountNotifications(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:80', - 'min_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, - 'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, - 'since_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, - ]); - - $pid = $request->user()->profile_id; - $limit = $request->input('limit', 20); - $timeago = now()->subMonths(6); - - $since = $request->input('since_id'); - $min = $request->input('min_id'); - $max = $request->input('max_id'); - - if(!$since && !$min && !$max) { - $min = 1; - } - - $dir = $since ? '>' : ($min ? '>=' : '<'); - $id = $since ?? $min ?? $max; - - $notifications = Notification::whereProfileId($pid) - ->where('id', $dir, $id) - ->whereDate('created_at', '>', $timeago) - ->orderByDesc('id') - ->limit($limit) - ->get(); - - $minId = $notifications->min('id'); - $maxId = $notifications->max('id'); - - $resource = new Fractal\Resource\Collection( - $notifications, - new NotificationTransformer() - ); - - $res = $this->fractal - ->createData($resource) - ->toArray(); - - $baseUrl = config('app.url') . '/api/v1/notifications?'; - - if($minId == $maxId) { - $minId = null; - } - - if($maxId) { - $link = '<'.$baseUrl.'max_id='.$maxId.'>; rel="next"'; - } - - if($minId) { - $link = '<'.$baseUrl.'min_id='.$minId.'>; rel="prev"'; - } - - if($maxId && $minId) { - $link = '<'.$baseUrl.'max_id='.$maxId.'>; rel="next",<'.$baseUrl.'min_id='.$minId.'>; rel="prev"'; - } - - $res = response()->json($res); - - if(isset($link)) { - $res->withHeaders([ - 'Link' => $link, - ]); - } - - return $res; - } - - /** - * GET /api/v1/timelines/home - * - * - * @return StatusTransformer - */ - public function timelineHome(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request,[ - 'page' => 'nullable|integer|max:40', - 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'limit' => 'nullable|integer|max:80' - ]); - - $page = $request->input('page'); - $min = $request->input('min_id'); - $max = $request->input('max_id'); - $limit = $request->input('limit') ?? 3; - $user = $request->user(); - - if($user->last_active_at) { - $key = 'user:last_active_at:id:'.$user->id; - $ttl = now()->addMinutes(5); - Cache::remember($key, $ttl, function() use($user) { - $user->last_active_at = now(); - $user->save(); - return; - }); - } - - $pid = $request->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(); - }); - - if($min || $max) { - $dir = $min ? '>' : '<'; - $id = $min ?? $max; - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'reply_count', - 'likes_count', - 'reblogs_count', - 'comments_disabled', - 'place_id', - 'created_at', - 'updated_at' - )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) - ->with('profile', 'hashtags', 'mentions') - ->where('id', $dir, $id) - ->whereIn('profile_id', $following) - ->whereIn('visibility',['public', 'unlisted', 'private']) - ->latest() - ->limit($limit) - ->get(); - } else { - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'reply_count', - 'comments_disabled', - 'likes_count', - 'reblogs_count', - 'place_id', - 'created_at', - 'updated_at' - )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) - ->with('profile', 'hashtags', 'mentions') - ->whereIn('profile_id', $following) - ->whereIn('visibility',['public', 'unlisted', 'private']) - ->latest() - ->simplePaginate($limit); - } - - $fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer()); - $res = $this->fractal->createData($fractal)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/conversations - * - * Not implemented - * - * @return array - */ - public function conversations(Request $request) - { - abort_if(!$request->user(), 403); - - return response()->json([]); - } - - /** - * GET /api/v1/timelines/public - * - * - * @return StatusTransformer - */ - public function timelinePublic(Request $request) - { - $this->validate($request,[ - 'page' => 'nullable|integer|max:40', - 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'limit' => 'nullable|integer|max:80' - ]); - - $page = $request->input('page'); - $min = $request->input('min_id'); - $max = $request->input('max_id'); - $limit = $request->input('limit') ?? 3; - $user = $request->user(); - - if($user) { - $key = 'user:last_active_at:id:'.$user->id; - $ttl = now()->addMinutes(5); - Cache::remember($key, $ttl, function() use($user) { - $user->last_active_at = now(); - $user->save(); - return; - }); - } - - if($min || $max) { - $dir = $min ? '>' : '<'; - $id = $min ?? $max; - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'reply_count', - 'comments_disabled', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereNull('uri') - ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) - ->with('profile', 'hashtags', 'mentions') - ->where('id', $dir, $id) - ->whereScope('public') - ->where('created_at', '>', now()->subDays(14)) - ->latest() - ->limit($limit) - ->get(); - } else { - $timeline = Status::select( - 'id', - 'uri', - 'caption', - 'rendered', - 'profile_id', - 'type', - 'in_reply_to_id', - 'reblog_of_id', - 'is_nsfw', - 'scope', - 'local', - 'reply_count', - 'comments_disabled', - 'place_id', - 'likes_count', - 'reblogs_count', - 'created_at', - 'updated_at' - )->whereNull('uri') - ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) - ->with('profile', 'hashtags', 'mentions') - ->whereScope('public') - ->where('created_at', '>', now()->subDays(14)) - ->latest() - ->simplePaginate($limit); - } - - $fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer()); - $res = $this->fractal->createData($fractal)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/statuses/{id} - * - * @param integer $id - * - * @return StatusTransformer - */ - public function statusById(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - return response()->json($res); - } - - /** - * GET /api/v1/statuses/{id}/context - * - * @param integer $id - * - * @return StatusTransformer - */ - public function statusContext(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - if($status->comments_disabled) { - $res = [ - 'ancestors' => [], - 'descendants' => [] - ]; - } else { - $ancestors = $status->parent(); - if($ancestors) { - $ares = new Fractal\Resource\Item($ancestors, new StatusTransformer()); - $ancestors = [ - $this->fractal->createData($ares)->toArray() - ]; - } else { - $ancestors = []; - } - $descendants = Status::whereInReplyToId($id)->latest()->limit(20)->get(); - $dres = new Fractal\Resource\Collection($descendants, new StatusTransformer()); - $descendants = $this->fractal->createData($dres)->toArray(); - $res = [ - 'ancestors' => $ancestors, - 'descendants' => $descendants - ]; - } - - return response()->json($res); - } - - /** - * GET /api/v1/statuses/{id}/card - * - * @param integer $id - * - * @return StatusTransformer - */ - public function statusCard(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - // Return empty response since we don't handle support cards - $res = []; - - return response()->json($res); - } - - /** - * GET /api/v1/statuses/{id}/reblogged_by - * - * @param integer $id - * - * @return AccountTransformer - */ - public function statusRebloggedBy(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'page' => 'nullable|integer|min:1|max:40', - 'limit' => 'nullable|integer|min:1|max:80' - ]); - - $limit = $request->input('limit') ?? 40; - $user = $request->user(); - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $shared = $status->sharedBy()->latest()->simplePaginate($limit); - $resource = new Fractal\Resource\Collection($shared, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - $url = $request->url(); - $page = $request->input('page', 1); - $next = $page < 40 ? $page + 1 : 40; - $prev = $page > 1 ? $page - 1 : 1; - $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; - - return response()->json($res, 200, ['Link' => $links]); - } - - /** - * GET /api/v1/statuses/{id}/favourited_by - * - * @param integer $id - * - * @return AccountTransformer - */ - public function statusFavouritedBy(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'page' => 'nullable|integer|min:1|max:40', - 'limit' => 'nullable|integer|min:1|max:80' - ]); - - $limit = $request->input('limit') ?? 40; - $user = $request->user(); - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $liked = $status->likedBy()->latest()->simplePaginate($limit); - $resource = new Fractal\Resource\Collection($liked, new AccountTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - - $url = $request->url(); - $page = $request->input('page', 1); - $next = $page < 40 ? $page + 1 : 40; - $prev = $page > 1 ? $page - 1 : 1; - $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; - - return response()->json($res, 200, ['Link' => $links]); - } - - /** - * POST /api/v1/statuses - * - * - * @return StatusTransformer - */ - public function statusCreate(Request $request) - { - abort_if(!$request->user(), 403); - - $this->validate($request, [ - 'status' => 'nullable|string', - 'in_reply_to_id' => 'nullable|integer', - 'media_ids' => 'array|max:' . config_cache('pixelfed.max_album_length'), - 'media_ids.*' => 'integer|min:1', - 'sensitive' => 'nullable|boolean', - 'visibility' => 'string|in:private,unlisted,public', - ]); - - if(config('costar.enabled') == true) { - $blockedKeywords = config('costar.keyword.block'); - if($blockedKeywords !== null && $request->status) { - $keywords = config('costar.keyword.block'); - foreach($keywords as $kw) { - if(Str::contains($request->status, $kw) == true) { - abort(400, 'Invalid object. Contains banned keyword.'); - } - } - } - } - - if(!$request->filled('media_ids') && !$request->filled('in_reply_to_id')) { - abort(403, 'Empty statuses are not allowed'); - } - - $ids = $request->input('media_ids'); - $in_reply_to_id = $request->input('in_reply_to_id'); - $user = $request->user(); - $profile = $user->profile; - - $limitKey = 'compose:rate-limit:store:' . $user->id; - $limitTtl = now()->addMinutes(15); - $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { - $dailyLimit = Status::whereProfileId($user->profile_id) - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->where('created_at', '>', now()->subDays(1)) - ->count(); - - return $dailyLimit >= 100; - }); - - abort_if($limitReached == true, 429); - - $visibility = $profile->is_private ? 'private' : ( - $profile->unlisted == true && - $request->input('visibility', 'public') == 'public' ? - 'unlisted' : - $request->input('visibility', 'public')); - - if($user->last_active_at == null) { - return []; - } - - if($in_reply_to_id) { - $parent = Status::findOrFail($in_reply_to_id); - - $status = new Status; - $status->caption = strip_tags($request->input('status')); - $status->scope = $visibility; - $status->visibility = $visibility; - $status->profile_id = $user->profile_id; - $status->is_nsfw = $user->profile->cw == true ? true : $request->input('sensitive', false); - $status->in_reply_to_id = $parent->id; - $status->in_reply_to_profile_id = $parent->profile_id; - $status->save(); - StatusService::del($parent->id); - } else if($ids) { - if(Media::whereUserId($user->id) - ->whereNull('status_id') - ->find($ids) - ->count() == 0 - ) { - abort(400, 'Invalid media_ids'); - } - $status = new Status; - $status->caption = strip_tags($request->input('status')); - $status->profile_id = $user->profile_id; - $status->scope = 'draft'; - $status->is_nsfw = $user->profile->cw == true ? true : $request->input('sensitive', false); - $status->save(); - - $mimes = []; - - foreach($ids as $k => $v) { - if($k + 1 > config_cache('pixelfed.max_album_length')) { - continue; - } - $m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v); - if($m->profile_id !== $user->profile_id || $m->status_id) { - abort(403, 'Invalid media id'); - } - $m->status_id = $status->id; - $m->save(); - array_push($mimes, $m->mime); - } - - if(empty($mimes)) { - $status->delete(); - abort(400, 'Invalid media ids'); - } - - $status->scope = $visibility; - $status->visibility = $visibility; - $status->type = StatusController::mimeTypeCheck($mimes); - $status->save(); - } - - if(!$status) { - abort(500, 'An error occured.'); - } - - NewStatusPipeline::dispatch($status); - Cache::forget('user:account:id:'.$user->id); - Cache::forget('_api:statuses:recent_9:'.$user->profile_id); - Cache::forget('profile:status_count:'.$user->profile_id); - Cache::forget($user->storageUsedKey()); - Cache::forget('profile:embed:' . $status->profile_id); - Cache::forget($limitKey); - - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * DELETE /api/v1/statuses - * - * @param integer $id - * - * @return null - */ - public function statusDelete(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $status = Status::whereProfileId($request->user()->profile->id) - ->findOrFail($id); - - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - - Cache::forget('profile:status_count:'.$status->profile_id); - StatusDelete::dispatch($status); - - $res = $this->fractal->createData($resource)->toArray(); - $res['text'] = $res['content']; - unset($res['content']); - return response()->json($res); - } - - /** - * POST /api/v1/statuses/{id}/reblog - * - * @param integer $id - * - * @return StatusTransformer - */ - public function statusShare(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - $share = Status::firstOrCreate([ - 'profile_id' => $user->profile_id, - 'reblog_of_id' => $status->id, - 'in_reply_to_profile_id' => $status->profile_id, - 'scope' => 'public', - 'visibility' => 'public' - ]); - - if($share->wasRecentlyCreated == true) { - $status->reblogs_count = $status->shares()->count(); - $status->save(); - SharePipeline::dispatch($share); - } - - StatusService::del($status->id); - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * POST /api/v1/statuses/{id}/unreblog - * - * @param integer $id - * - * @return StatusTransformer - */ - public function statusUnshare(Request $request, $id) - { - abort_if(!$request->user(), 403); - - $user = $request->user(); - $status = Status::findOrFail($id); - - if($status->profile_id !== $user->profile_id) { - if($status->scope == 'private') { - abort_if(!$status->profile->followedBy($user->profile), 403); - } else { - abort_if(!in_array($status->scope, ['public','unlisted']), 403); - } - } - - Status::whereProfileId($user->profile_id) - ->whereReblogOfId($status->id) - ->delete(); - $status->reblogs_count = $status->shares()->count(); - $status->save(); - - StatusService::del($status->id); - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } - - /** - * GET /api/v1/timelines/tag/{hashtag} - * - * @param string $hashtag - * - * @return StatusTransformer - */ - public function timelineHashtag(Request $request, $hashtag) - { - $this->validate($request,[ - 'page' => 'nullable|integer|max:40', - 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'limit' => 'nullable|integer|max:40' - ]); - - $tag = Hashtag::whereName($hashtag) - ->orWhere('slug', $hashtag) - ->first(); - - if(!$tag) { - return response()->json([]); - } - - $min = $request->input('min_id'); - $max = $request->input('max_id'); - $limit = $request->input('limit', 20); - - if(!$min && !$max) { - $id = 1; - $dir = '>'; - } else { - $dir = $min ? '>' : '<'; - $id = $min ?? $max; - } - - $res = StatusHashtag::whereHashtagId($tag->id) + /** + * PATCH /api/v1/accounts/update_credentials + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountUpdateCredentials(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'display_name' => 'nullable|string', + 'note' => 'nullable|string', + 'locked' => 'nullable', + // 'source.privacy' => 'nullable|in:unlisted,public,private', + // 'source.sensitive' => 'nullable|boolean' + ]); + + $user = $request->user(); + $profile = $user->profile; + + $displayName = $request->input('display_name'); + $note = $request->input('note'); + $locked = $request->input('locked'); + // $privacy = $request->input('source.privacy'); + // $sensitive = $request->input('source.sensitive'); + + $changes = false; + + if($displayName !== $user->name) { + $user->name = $displayName; + $profile->name = $displayName; + $changes = true; + } + + if($note !== strip_tags($profile->bio)) { + $profile->bio = Autolink::create()->autolink(strip_tags($note)); + $changes = true; + } + + if(!is_null($locked)) { + $profile->is_private = $locked; + $changes = true; + } + + if($changes) { + $user->save(); + $profile->save(); + } + + $resource = new Fractal\Resource\Item($profile, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * GET /api/v1/accounts/{id}/followers + * + * @param integer $id + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountFollowersById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $profile = Profile::whereNull('status')->findOrFail($id); + $limit = $request->input('limit') ?? 40; + + if($profile->domain) { + $res = []; + } else { + if($profile->id == $user->profile_id) { + $followers = $profile->followers()->paginate($limit); + $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } else { + if($profile->is_private) { + abort_if(!$profile->followedBy($user->profile), 403); + } + $settings = $profile->user->settings; + if( in_array($user->profile_id, $profile->blockedIds()->toArray()) || + $settings->show_profile_followers == false + ) { + $res = []; + } else { + $followers = $profile->followers()->paginate($limit); + $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } + } + } + return response()->json($res); + } + + /** + * GET /api/v1/accounts/{id}/following + * + * @param integer $id + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountFollowingById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $profile = Profile::whereNull('status')->findOrFail($id); + $limit = $request->input('limit') ?? 40; + + if($profile->domain) { + $res = []; + } else { + if($profile->id == $user->profile_id) { + $following = $profile->following()->paginate($limit); + $resource = new Fractal\Resource\Collection($following, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } else { + if($profile->is_private) { + abort_if(!$profile->followedBy($user->profile), 403); + } + $settings = $profile->user->settings; + if( in_array($user->profile_id, $profile->blockedIds()->toArray()) || + $settings->show_profile_following == false + ) { + $res = []; + } else { + $following = $profile->following()->paginate($limit); + $resource = new Fractal\Resource\Collection($following, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } + } + } + + + return response()->json($res); + } + + /** + * GET /api/v1/accounts/{id}/statuses + * + * @param integer $id + * + * @return \App\Transformer\Api\StatusTransformer + */ + public function accountStatusesById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'only_media' => 'nullable', + 'pinned' => 'nullable', + 'exclude_replies' => 'nullable', + 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'since_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'limit' => 'nullable|integer|min:1|max:80' + ]); + + $profile = Profile::whereNull('status')->findOrFail($id); + + $limit = $request->limit ?? 20; + $max_id = $request->max_id; + $min_id = $request->min_id; + $pid = $request->user()->profile_id; + $scope = $request->only_media == true ? + ['photo', 'photo:album', 'video', 'video:album'] : + ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply']; + + if($pid == $profile->id) { + $visibility = ['public', 'unlisted', 'private']; + } else if($profile->is_private) { + $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 { + $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'] : ['public', 'unlisted']; + } + + if($min_id || $max_id) { + $dir = $min_id ? '>' : '<'; + $id = $min_id ?? $max_id; + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'place_id', + 'likes_count', + 'reblogs_count', + 'created_at', + 'updated_at' + )->whereProfileId($profile->id) + ->whereIn('type', $scope) + ->where('id', $dir, $id) + ->whereIn('visibility', $visibility) + ->latest() + ->limit($limit) + ->get(); + } else { + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'place_id', + 'likes_count', + 'reblogs_count', + 'created_at', + 'updated_at' + )->whereProfileId($profile->id) + ->whereIn('type', $scope) + ->whereIn('visibility', $visibility) + ->latest() + ->limit($limit) + ->get(); + } + + $resource = new Fractal\Resource\Collection($timeline, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/follow + * + * @param integer $id + * + * @return \App\Transformer\Api\RelationshipTransformer + */ + public function accountFollowById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $target = Profile::where('id', '!=', $user->profile_id) + ->whereNull('status') + ->findOrFail($id); + + $private = (bool) $target->is_private; + $remote = (bool) $target->domain; + $blocked = UserFilter::whereUserId($target->id) + ->whereFilterType('block') + ->whereFilterableId($user->profile_id) + ->whereFilterableType('App\Profile') + ->exists(); + + if($blocked == true) { + abort(400, 'You cannot follow this user.'); + } + + $isFollowing = Follower::whereProfileId($user->profile_id) + ->whereFollowingId($target->id) + ->exists(); + + // Following already, return empty relationship + if($isFollowing == true) { + $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + // Rate limits, max 7500 followers per account + if($user->profile->following()->count() >= Follower::MAX_FOLLOWING) { + abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts'); + } + + // Rate limits, follow 30 accounts per hour max + if($user->profile->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) { + abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour'); + } + + if($private == true) { + $follow = FollowRequest::firstOrCreate([ + 'follower_id' => $user->profile_id, + 'following_id' => $target->id + ]); + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendFollow($user->profile, $target); + } + } else { + $follower = new Follower(); + $follower->profile_id = $user->profile_id; + $follower->following_id = $target->id; + $follower->save(); + + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendFollow($user->profile, $target); + } + FollowPipeline::dispatch($follower); + } + + Cache::forget('profile:following:'.$target->id); + Cache::forget('profile:followers:'.$target->id); + Cache::forget('profile:following:'.$user->profile_id); + Cache::forget('profile:followers:'.$user->profile_id); + Cache::forget('api:local:exp:rec:'.$user->profile_id); + Cache::forget('user:account:id:'.$target->user_id); + Cache::forget('user:account:id:'.$user->id); + Cache::forget('profile:follower_count:'.$target->id); + Cache::forget('profile:follower_count:'.$user->profile_id); + Cache::forget('profile:following_count:'.$target->id); + Cache::forget('profile:following_count:'.$user->profile_id); + + $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/unfollow + * + * @param integer $id + * + * @return \App\Transformer\Api\RelationshipTransformer + */ + public function accountUnfollowById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $target = Profile::where('id', '!=', $user->profile_id) + ->whereNull('status') + ->findOrFail($id); + + $private = (bool) $target->is_private; + $remote = (bool) $target->domain; + + $isFollowing = Follower::whereProfileId($user->profile_id) + ->whereFollowingId($target->id) + ->exists(); + + if($isFollowing == false) { + $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + // Rate limits, follow 30 accounts per hour max + if($user->profile->following()->where('followers.updated_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) { + abort(400, 'You can only follow or unfollow ' . Follower::FOLLOW_PER_HOUR . ' users per hour'); + } + + FollowRequest::whereFollowerId($user->profile_id) + ->whereFollowingId($target->id) + ->delete(); + + Follower::whereProfileId($user->profile_id) + ->whereFollowingId($target->id) + ->delete(); + + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendUndoFollow($user->profile, $target); + } + + Cache::forget('profile:following:'.$target->id); + Cache::forget('profile:followers:'.$target->id); + Cache::forget('profile:following:'.$user->profile_id); + Cache::forget('profile:followers:'.$user->profile_id); + Cache::forget('api:local:exp:rec:'.$user->profile_id); + Cache::forget('user:account:id:'.$target->user_id); + Cache::forget('user:account:id:'.$user->id); + + $resource = new Fractal\Resource\Item($target, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * GET /api/v1/accounts/relationships + * + * @param array|integer $id + * + * @return \App\Transformer\Api\RelationshipTransformer + */ + public function accountRelationshipsById(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|array|min:1|max:20', + 'id.*' => 'required|integer|min:1|max:' . PHP_INT_MAX + ]); + $pid = $request->user()->profile_id ?? $request->user()->profile->id; + $ids = collect($request->input('id')); + $filtered = $ids->filter(function($v) use($pid) { + return $v != $pid; + }); + $relations = Profile::whereNull('status')->findOrFail($filtered->values()); + $fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer()); + $res = $this->fractal->createData($fractal)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/accounts/search + * + * + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountSearch(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'q' => 'required|string|min:1|max:255', + 'limit' => 'nullable|integer|min:1|max:40', + 'resolve' => 'nullable' + ]); + + $user = $request->user(); + $query = $request->input('q'); + $limit = $request->input('limit') ?? 20; + $resolve = (bool) $request->input('resolve', false); + $q = '%' . $query . '%'; + + $profiles = Profile::whereNull('status') + ->where('username', 'like', $q) + ->orWhere('name', 'like', $q) + ->limit($limit) + ->get(); + + $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/blocks + * + * + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountBlocks(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'limit' => 'nullable|integer|min:1|max:40', + 'page' => 'nullable|integer|min:1|max:10' + ]); + + $user = $request->user(); + $limit = $request->input('limit') ?? 40; + + $blocked = UserFilter::select('filterable_id','filterable_type','filter_type','user_id') + ->whereUserId($user->profile_id) + ->whereFilterableType('App\Profile') + ->whereFilterType('block') + ->simplePaginate($limit) + ->pluck('filterable_id'); + + $profiles = Profile::findOrFail($blocked); + $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/block + * + * @param integer $id + * + * @return \App\Transformer\Api\RelationshipTransformer + */ + public function accountBlockById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $pid = $user->profile_id ?? $user->profile->id; + + if($id == $pid) { + abort(400, 'You cannot block yourself'); + } + + $profile = Profile::findOrFail($id); + + if($profile->user->is_admin == true) { + abort(400, 'You cannot block an admin'); + } + + Follower::whereProfileId($profile->id)->whereFollowingId($pid)->delete(); + Follower::whereProfileId($pid)->whereFollowingId($profile->id)->delete(); + Notification::whereProfileId($pid)->whereActorId($profile->id)->delete(); + + $filter = UserFilter::firstOrCreate([ + 'user_id' => $pid, + 'filterable_id' => $profile->id, + 'filterable_type' => 'App\Profile', + 'filter_type' => 'block', + ]); + + Cache::forget("user:filter:list:$pid"); + Cache::forget("api:local:exp:rec:$pid"); + + $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/unblock + * + * @param integer $id + * + * @return \App\Transformer\Api\RelationshipTransformer + */ + public function accountUnblockById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $pid = $user->profile_id ?? $user->profile->id; + + if($id == $pid) { + abort(400, 'You cannot unblock yourself'); + } + + $profile = Profile::findOrFail($id); + + UserFilter::whereUserId($pid) + ->whereFilterableId($profile->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('block') + ->delete(); + + Cache::forget("user:filter:list:$pid"); + Cache::forget("api:local:exp:rec:$pid"); + + $resource = new Fractal\Resource\Item($profile, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * GET /api/v1/custom_emojis + * + * Return empty array, we don't support custom emoji + * + * @return array + */ + public function customEmojis() + { + return response()->json([]); + } + + /** + * GET /api/v1/domain_blocks + * + * Return empty array + * + * @return array + */ + public function accountDomainBlocks(Request $request) + { + abort_if(!$request->user(), 403); + return response()->json([]); + } + + /** + * GET /api/v1/endorsements + * + * Return empty array + * + * @return array + */ + public function accountEndorsements(Request $request) + { + abort_if(!$request->user(), 403); + return response()->json([]); + } + + /** + * GET /api/v1/favourites + * + * Returns collection of liked statuses + * + * @return \App\Transformer\Api\StatusTransformer + */ + public function accountFavourites(Request $request) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $limit = $request->input('limit') ?? 20; + $favourites = Like::whereProfileId($user->profile_id) + ->latest() + ->simplePaginate($limit) + ->pluck('status_id'); + + $statuses = Status::findOrFail($favourites); + $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/statuses/{id}/favourite + * + * @param integer $id + * + * @return \App\Transformer\Api\StatusTransformer + */ + public function statusFavouriteById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $like = Like::firstOrCreate([ + 'profile_id' => $user->profile_id, + 'status_id' => $status->id + ]); + + if($like->wasRecentlyCreated == true) { + $status->likes_count = $status->likes()->count(); + $status->save(); + LikePipeline::dispatch($like); + } + + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/statuses/{id}/unfavourite + * + * @param integer $id + * + * @return \App\Transformer\Api\StatusTransformer + */ + public function statusUnfavouriteById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $like = Like::whereProfileId($user->profile_id) + ->whereStatusId($status->id) + ->first(); + + if($like) { + $like->forceDelete(); + $status->likes_count = $status->likes()->count(); + $status->save(); + } + + StatusService::del($status->id); + + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/filters + * + * Return empty response since we filter server side + * + * @return array + */ + public function accountFilters(Request $request) + { + abort_if(!$request->user(), 403); + + return response()->json([]); + } + + /** + * GET /api/v1/follow_requests + * + * Return array of Accounts that have sent follow requests + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountFollowRequests(Request $request) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $followRequests = FollowRequest::whereFollowingId($user->profile->id)->pluck('follower_id'); + + $profiles = Profile::find($followRequests); + + $resource = new Fractal\Resource\Collection($profiles, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/follow_requests/{id}/authorize + * + * @param integer $id + * + * @return null + */ + public function accountFollowRequestAccept(Request $request, $id) + { + abort_if(!$request->user(), 403); + + // todo + + return response()->json([]); + } + + /** + * POST /api/v1/follow_requests/{id}/reject + * + * @param integer $id + * + * @return null + */ + public function accountFollowRequestReject(Request $request, $id) + { + abort_if(!$request->user(), 403); + + // todo + + return response()->json([]); + } + + /** + * GET /api/v1/suggestions + * + * Return empty array as we don't support suggestions + * + * @return null + */ + public function accountSuggestions(Request $request) + { + abort_if(!$request->user(), 403); + + // todo + + return response()->json([]); + } + + /** + * GET /api/v1/instance + * + * Information about the server. + * + * @return Instance + */ + public function instance(Request $request) + { + $res = Cache::remember('api:v1:instance-data', now()->addMinutes(15), function () { + $rules = config_cache('app.rules') ? collect(json_decode(config_cache('app.rules'), true)) + ->map(function($rule, $key) { + $id = $key + 1; + return [ + 'id' => "{$id}", + 'text' => $rule + ]; + }) + ->toArray() : []; + $res = [ + 'approval_required' => false, + 'contact_account' => null, + 'description' => config_cache('app.description'), + 'email' => config('instance.email'), + 'invites_enabled' => false, + 'rules' => $rules, + 'short_description' => 'Pixelfed - Photo sharing for everyone', + 'languages' => ['en'], + 'max_toot_chars' => (int) config('pixelfed.max_caption_length'), + 'registrations' => (bool) config_cache('pixelfed.open_registration'), + 'stats' => [ + 'user_count' => 0, + 'status_count' => 0, + 'domain_count' => 0 + ], + 'thumbnail' => config('app.url') . '/img/pixelfed-icon-color.png', + 'title' => config_cache('app.name'), + 'uri' => config('pixelfed.domain.app'), + 'urls' => [], + 'version' => '2.7.2 (compatible; Pixelfed ' . config('pixelfed.version') . ')', + 'environment' => [ + 'max_photo_size' => (int) config_cache('pixelfed.max_photo_size'), + 'max_avatar_size' => (int) config('pixelfed.max_avatar_size'), + 'max_caption_length' => (int) config('pixelfed.max_caption_length'), + 'max_bio_length' => (int) config('pixelfed.max_bio_length'), + 'max_album_length' => (int) config_cache('pixelfed.max_album_length'), + 'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled') + + ] + ]; + return $res; + }); + return response()->json($res); + } + + /** + * GET /api/v1/lists + * + * Return empty array as we don't support lists + * + * @return null + */ + public function accountLists(Request $request) + { + abort_if(!$request->user(), 403); + + return response()->json([]); + } + + /** + * GET /api/v1/accounts/{id}/lists + * + * @param integer $id + * + * @return null + */ + public function accountListsById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + return response()->json([]); + } + + /** + * POST /api/v1/media + * + * + * @return MediaTransformer + */ + public function mediaUpload(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'file.*' => function() { + return [ + 'required', + 'mimes:' . config_cache('pixelfed.media_types'), + 'max:' . config_cache('pixelfed.max_photo_size'), + ]; + }, + 'filter_name' => 'nullable|string|max:24', + 'filter_class' => 'nullable|alpha_dash|max:24', + 'description' => 'nullable|string|max:420' + ]); + + $user = $request->user(); + + if($user->last_active_at == null) { + return []; + } + + $limitKey = 'compose:rate-limit:media-upload:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Media::whereUserId($user->id)->where('created_at', '>', now()->subDays(1))->count(); + + return $dailyLimit >= 250; + }); + abort_if($limitReached == true, 429); + + $profile = $user->profile; + + if(config_cache('pixelfed.enforce_account_limit') == true) { + $size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) { + return Media::whereUserId($user->id)->sum('size') / 1000; + }); + $limit = (int) config_cache('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + + $filterClass = in_array($request->input('filter_class'), Filter::classes()) ? $request->input('filter_class') : null; + $filterName = in_array($request->input('filter_name'), Filter::names()) ? $request->input('filter_name') : null; + + $photo = $request->file('file'); + + $mimes = explode(',', config_cache('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } + + $storagePath = MediaPathService::get($user, 2); + $path = $photo->store($storagePath); + $hash = \hash_file('sha256', $photo); + + abort_if(MediaBlocklistService::exists($hash) == true, 451); + + $media = new Media(); + $media->status_id = null; + $media->profile_id = $profile->id; + $media->user_id = $user->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->caption = $request->input('description'); + $media->filter_class = $filterClass; + $media->filter_name = $filterName; + $media->save(); + + switch ($media->mime) { + case 'image/jpeg': + case 'image/png': + ImageOptimize::dispatch($media); + break; + + case 'video/mp4': + VideoThumbnail::dispatch($media); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; + } + + Cache::forget($limitKey); + $resource = new Fractal\Resource\Item($media, new MediaTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + $res['preview_url'] = $media->url(). '?cb=1&_v=' . time(); + $res['url'] = $media->url(). '?cb=1&_v=' . time(); + return response()->json($res); + } + + /** + * PUT /api/v1/media/{id} + * + * @param integer $id + * + * @return MediaTransformer + */ + public function mediaUpdate(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'description' => 'nullable|string|max:420' + ]); + + $user = $request->user(); + + $media = Media::whereUserId($user->id) + ->whereNull('status_id') + ->findOrFail($id); + + $media->caption = $request->input('description'); + $media->save(); + + $resource = new Fractal\Resource\Item($media, new MediaTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + $res['preview_url'] = url('/storage/no-preview.png'); + $res['url'] = url('/storage/no-preview.png'); + return response()->json($res); + } + + /** + * GET /api/v1/mutes + * + * + * @return AccountTransformer + */ + public function accountMutes(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'limit' => 'nullable|integer|min:1|max:40' + ]); + + $user = $request->user(); + $limit = $request->input('limit') ?? 40; + + $mutes = UserFilter::whereUserId($user->profile_id) + ->whereFilterableType('App\Profile') + ->whereFilterType('mute') + ->simplePaginate($limit) + ->pluck('filterable_id'); + + $accounts = Profile::find($mutes); + + $resource = new Fractal\Resource\Collection($accounts, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/mute + * + * @param integer $id + * + * @return RelationshipTransformer + */ + public function accountMuteById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $pid = $user->profile_id; + + $account = Profile::findOrFail($id); + + $filter = UserFilter::firstOrCreate([ + 'user_id' => $pid, + 'filterable_id' => $account->id, + 'filterable_type' => 'App\Profile', + 'filter_type' => 'mute', + ]); + + Cache::forget("user:filter:list:$pid"); + Cache::forget("feature:discover:posts:$pid"); + Cache::forget("api:local:exp:rec:$pid"); + + $resource = new Fractal\Resource\Item($account, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/accounts/{id}/unmute + * + * @param integer $id + * + * @return RelationshipTransformer + */ + public function accountUnmuteById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $pid = $user->profile_id; + + $account = Profile::findOrFail($id); + + $filter = UserFilter::whereUserId($pid) + ->whereFilterableId($account->id) + ->whereFilterableType('App\Profile') + ->whereFilterType('mute') + ->first(); + + if($filter) { + $filter->delete(); + Cache::forget("user:filter:list:$pid"); + Cache::forget("feature:discover:posts:$pid"); + Cache::forget("api:local:exp:rec:$pid"); + } + + $resource = new Fractal\Resource\Item($account, new RelationshipTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/notifications + * + * + * @return NotificationTransformer + */ + public function accountNotifications(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'limit' => 'nullable|integer|min:1|max:80', + 'min_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, + 'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, + 'since_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX, + ]); + + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 20); + $timeago = now()->subMonths(6); + + $since = $request->input('since_id'); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + + if(!$since && !$min && !$max) { + $min = 1; + } + + $dir = $since ? '>' : ($min ? '>=' : '<'); + $id = $since ?? $min ?? $max; + + $notifications = Notification::whereProfileId($pid) + ->where('id', $dir, $id) + ->whereDate('created_at', '>', $timeago) + ->orderByDesc('id') + ->limit($limit) + ->get(); + + $minId = $notifications->min('id'); + $maxId = $notifications->max('id'); + + $resource = new Fractal\Resource\Collection( + $notifications, + new NotificationTransformer() + ); + + $res = $this->fractal + ->createData($resource) + ->toArray(); + + $baseUrl = config('app.url') . '/api/v1/notifications?'; + + if($minId == $maxId) { + $minId = null; + } + + if($maxId) { + $link = '<'.$baseUrl.'max_id='.$maxId.'>; rel="next"'; + } + + if($minId) { + $link = '<'.$baseUrl.'min_id='.$minId.'>; rel="prev"'; + } + + if($maxId && $minId) { + $link = '<'.$baseUrl.'max_id='.$maxId.'>; rel="next",<'.$baseUrl.'min_id='.$minId.'>; rel="prev"'; + } + + $res = response()->json($res); + + if(isset($link)) { + $res->withHeaders([ + 'Link' => $link, + ]); + } + + return $res; + } + + /** + * GET /api/v1/timelines/home + * + * + * @return StatusTransformer + */ + public function timelineHome(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request,[ + 'page' => 'nullable|integer|max:40', + 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'limit' => 'nullable|integer|max:80' + ]); + + $page = $request->input('page'); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + $limit = $request->input('limit') ?? 3; + $user = $request->user(); + + if($user->last_active_at) { + $key = 'user:last_active_at:id:'.$user->id; + $ttl = now()->addMinutes(5); + Cache::remember($key, $ttl, function() use($user) { + $user->last_active_at = now(); + $user->save(); + return; + }); + } + + $pid = $request->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(); + }); + + if($min || $max) { + $dir = $min ? '>' : '<'; + $id = $min ?? $max; + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'reply_count', + 'likes_count', + 'reblogs_count', + 'comments_disabled', + 'place_id', + 'created_at', + 'updated_at' + )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->where('id', $dir, $id) + ->whereIn('profile_id', $following) + ->whereIn('visibility',['public', 'unlisted', 'private']) + ->latest() + ->limit($limit) + ->get(); + } else { + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'reply_count', + 'comments_disabled', + 'likes_count', + 'reblogs_count', + 'place_id', + 'created_at', + 'updated_at' + )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->whereIn('profile_id', $following) + ->whereIn('visibility',['public', 'unlisted', 'private']) + ->latest() + ->simplePaginate($limit); + } + + $fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer()); + $res = $this->fractal->createData($fractal)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/conversations + * + * Not implemented + * + * @return array + */ + public function conversations(Request $request) + { + abort_if(!$request->user(), 403); + + return response()->json([]); + } + + /** + * GET /api/v1/timelines/public + * + * + * @return StatusTransformer + */ + public function timelinePublic(Request $request) + { + $this->validate($request,[ + 'page' => 'nullable|integer|max:40', + 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'limit' => 'nullable|integer|max:80' + ]); + + $page = $request->input('page'); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + $limit = $request->input('limit') ?? 3; + $user = $request->user(); + + if($user) { + $key = 'user:last_active_at:id:'.$user->id; + $ttl = now()->addMinutes(5); + Cache::remember($key, $ttl, function() use($user) { + $user->last_active_at = now(); + $user->save(); + return; + }); + } + + if($min || $max) { + $dir = $min ? '>' : '<'; + $id = $min ?? $max; + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'reply_count', + 'comments_disabled', + 'place_id', + 'likes_count', + 'reblogs_count', + 'created_at', + 'updated_at' + )->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->where('id', $dir, $id) + ->whereScope('public') + ->where('created_at', '>', now()->subDays(14)) + ->latest() + ->limit($limit) + ->get(); + } else { + $timeline = Status::select( + 'id', + 'uri', + 'caption', + 'rendered', + 'profile_id', + 'type', + 'in_reply_to_id', + 'reblog_of_id', + 'is_nsfw', + 'scope', + 'local', + 'reply_count', + 'comments_disabled', + 'place_id', + 'likes_count', + 'reblogs_count', + 'created_at', + 'updated_at' + )->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->whereScope('public') + ->where('created_at', '>', now()->subDays(14)) + ->latest() + ->simplePaginate($limit); + } + + $fractal = new Fractal\Resource\Collection($timeline, new StatusTransformer()); + $res = $this->fractal->createData($fractal)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/statuses/{id} + * + * @param integer $id + * + * @return StatusTransformer + */ + public function statusById(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * GET /api/v1/statuses/{id}/context + * + * @param integer $id + * + * @return StatusTransformer + */ + public function statusContext(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + if($status->comments_disabled) { + $res = [ + 'ancestors' => [], + 'descendants' => [] + ]; + } else { + $ancestors = $status->parent(); + if($ancestors) { + $ares = new Fractal\Resource\Item($ancestors, new StatusTransformer()); + $ancestors = [ + $this->fractal->createData($ares)->toArray() + ]; + } else { + $ancestors = []; + } + $descendants = Status::whereInReplyToId($id)->latest()->limit(20)->get(); + $dres = new Fractal\Resource\Collection($descendants, new StatusTransformer()); + $descendants = $this->fractal->createData($dres)->toArray(); + $res = [ + 'ancestors' => $ancestors, + 'descendants' => $descendants + ]; + } + + return response()->json($res); + } + + /** + * GET /api/v1/statuses/{id}/card + * + * @param integer $id + * + * @return StatusTransformer + */ + public function statusCard(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + // Return empty response since we don't handle support cards + $res = []; + + return response()->json($res); + } + + /** + * GET /api/v1/statuses/{id}/reblogged_by + * + * @param integer $id + * + * @return AccountTransformer + */ + public function statusRebloggedBy(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'page' => 'nullable|integer|min:1|max:40', + 'limit' => 'nullable|integer|min:1|max:80' + ]); + + $limit = $request->input('limit') ?? 40; + $user = $request->user(); + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $shared = $status->sharedBy()->latest()->simplePaginate($limit); + $resource = new Fractal\Resource\Collection($shared, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + $url = $request->url(); + $page = $request->input('page', 1); + $next = $page < 40 ? $page + 1 : 40; + $prev = $page > 1 ? $page - 1 : 1; + $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; + + return response()->json($res, 200, ['Link' => $links]); + } + + /** + * GET /api/v1/statuses/{id}/favourited_by + * + * @param integer $id + * + * @return AccountTransformer + */ + public function statusFavouritedBy(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'page' => 'nullable|integer|min:1|max:40', + 'limit' => 'nullable|integer|min:1|max:80' + ]); + + $limit = $request->input('limit') ?? 40; + $user = $request->user(); + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $liked = $status->likedBy()->latest()->simplePaginate($limit); + $resource = new Fractal\Resource\Collection($liked, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + $url = $request->url(); + $page = $request->input('page', 1); + $next = $page < 40 ? $page + 1 : 40; + $prev = $page > 1 ? $page - 1 : 1; + $links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"'; + + return response()->json($res, 200, ['Link' => $links]); + } + + /** + * POST /api/v1/statuses + * + * + * @return StatusTransformer + */ + public function statusCreate(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'status' => 'nullable|string', + 'in_reply_to_id' => 'nullable|integer', + 'media_ids' => 'array|max:' . config_cache('pixelfed.max_album_length'), + 'media_ids.*' => 'integer|min:1', + 'sensitive' => 'nullable|boolean', + 'visibility' => 'string|in:private,unlisted,public', + ]); + + if(config('costar.enabled') == true) { + $blockedKeywords = config('costar.keyword.block'); + if($blockedKeywords !== null && $request->status) { + $keywords = config('costar.keyword.block'); + foreach($keywords as $kw) { + if(Str::contains($request->status, $kw) == true) { + abort(400, 'Invalid object. Contains banned keyword.'); + } + } + } + } + + if(!$request->filled('media_ids') && !$request->filled('in_reply_to_id')) { + abort(403, 'Empty statuses are not allowed'); + } + + $ids = $request->input('media_ids'); + $in_reply_to_id = $request->input('in_reply_to_id'); + $user = $request->user(); + $profile = $user->profile; + + $limitKey = 'compose:rate-limit:store:' . $user->id; + $limitTtl = now()->addMinutes(15); + $limitReached = Cache::remember($limitKey, $limitTtl, function() use($user) { + $dailyLimit = Status::whereProfileId($user->profile_id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->where('created_at', '>', now()->subDays(1)) + ->count(); + + return $dailyLimit >= 100; + }); + + abort_if($limitReached == true, 429); + + $visibility = $profile->is_private ? 'private' : ( + $profile->unlisted == true && + $request->input('visibility', 'public') == 'public' ? + 'unlisted' : + $request->input('visibility', 'public')); + + if($user->last_active_at == null) { + return []; + } + + if($in_reply_to_id) { + $parent = Status::findOrFail($in_reply_to_id); + + $status = new Status; + $status->caption = strip_tags($request->input('status')); + $status->scope = $visibility; + $status->visibility = $visibility; + $status->profile_id = $user->profile_id; + $status->is_nsfw = $user->profile->cw == true ? true : $request->input('sensitive', false); + $status->in_reply_to_id = $parent->id; + $status->in_reply_to_profile_id = $parent->profile_id; + $status->save(); + StatusService::del($parent->id); + } else if($ids) { + if(Media::whereUserId($user->id) + ->whereNull('status_id') + ->find($ids) + ->count() == 0 + ) { + abort(400, 'Invalid media_ids'); + } + $status = new Status; + $status->caption = strip_tags($request->input('status')); + $status->profile_id = $user->profile_id; + $status->scope = 'draft'; + $status->is_nsfw = $user->profile->cw == true ? true : $request->input('sensitive', false); + $status->save(); + + $mimes = []; + + foreach($ids as $k => $v) { + if($k + 1 > config_cache('pixelfed.max_album_length')) { + continue; + } + $m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v); + if($m->profile_id !== $user->profile_id || $m->status_id) { + abort(403, 'Invalid media id'); + } + $m->status_id = $status->id; + $m->save(); + array_push($mimes, $m->mime); + } + + if(empty($mimes)) { + $status->delete(); + abort(400, 'Invalid media ids'); + } + + $status->scope = $visibility; + $status->visibility = $visibility; + $status->type = StatusController::mimeTypeCheck($mimes); + $status->save(); + } + + if(!$status) { + abort(500, 'An error occured.'); + } + + NewStatusPipeline::dispatch($status); + Cache::forget('user:account:id:'.$user->id); + Cache::forget('_api:statuses:recent_9:'.$user->profile_id); + Cache::forget('profile:status_count:'.$user->profile_id); + Cache::forget($user->storageUsedKey()); + Cache::forget('profile:embed:' . $status->profile_id); + Cache::forget($limitKey); + + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * DELETE /api/v1/statuses + * + * @param integer $id + * + * @return null + */ + public function statusDelete(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $status = Status::whereProfileId($request->user()->profile->id) + ->findOrFail($id); + + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + + Cache::forget('profile:status_count:'.$status->profile_id); + StatusDelete::dispatch($status); + + $res = $this->fractal->createData($resource)->toArray(); + $res['text'] = $res['content']; + unset($res['content']); + return response()->json($res); + } + + /** + * POST /api/v1/statuses/{id}/reblog + * + * @param integer $id + * + * @return StatusTransformer + */ + public function statusShare(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + $share = Status::firstOrCreate([ + 'profile_id' => $user->profile_id, + 'reblog_of_id' => $status->id, + 'in_reply_to_profile_id' => $status->profile_id, + 'scope' => 'public', + 'visibility' => 'public' + ]); + + if($share->wasRecentlyCreated == true) { + $status->reblogs_count = $status->shares()->count(); + $status->save(); + SharePipeline::dispatch($share); + } + + StatusService::del($status->id); + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * POST /api/v1/statuses/{id}/unreblog + * + * @param integer $id + * + * @return StatusTransformer + */ + public function statusUnshare(Request $request, $id) + { + abort_if(!$request->user(), 403); + + $user = $request->user(); + $status = Status::findOrFail($id); + + if($status->profile_id !== $user->profile_id) { + if($status->scope == 'private') { + abort_if(!$status->profile->followedBy($user->profile), 403); + } else { + abort_if(!in_array($status->scope, ['public','unlisted']), 403); + } + } + + Status::whereProfileId($user->profile_id) + ->whereReblogOfId($status->id) + ->delete(); + $status->reblogs_count = $status->shares()->count(); + $status->save(); + + StatusService::del($status->id); + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } + + /** + * GET /api/v1/timelines/tag/{hashtag} + * + * @param string $hashtag + * + * @return StatusTransformer + */ + public function timelineHashtag(Request $request, $hashtag) + { + $this->validate($request,[ + 'page' => 'nullable|integer|max:40', + 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, + 'limit' => 'nullable|integer|max:40' + ]); + + $tag = Hashtag::whereName($hashtag) + ->orWhere('slug', $hashtag) + ->first(); + + if(!$tag) { + return response()->json([]); + } + + $min = $request->input('min_id'); + $max = $request->input('max_id'); + $limit = $request->input('limit', 20); + + if(!$min && !$max) { + $id = 1; + $dir = '>'; + } else { + $dir = $min ? '>' : '<'; + $id = $min ?? $max; + } + + $res = StatusHashtag::whereHashtagId($tag->id) ->whereStatusVisibility('public') ->whereHas('media') ->where('status_id', $dir, $id) @@ -2027,131 +2028,131 @@ class ApiV1Controller extends Controller }) ->all(); - return response()->json($res, 200, [], JSON_PRETTY_PRINT); - } + return response()->json($res, 200, [], JSON_PRETTY_PRINT); + } - /** - * GET /api/v1/bookmarks - * - * - * - * @return StatusTransformer - */ - public function bookmarks(Request $request) - { - abort_if(!$request->user(), 403); + /** + * GET /api/v1/bookmarks + * + * + * + * @return StatusTransformer + */ + public function bookmarks(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'limit' => 'nullable|integer|min:1|max:40', - 'max_id' => 'nullable|integer|min:0', - 'since_id' => 'nullable|integer|min:0', - 'min_id' => 'nullable|integer|min:0' - ]); + $this->validate($request, [ + 'limit' => 'nullable|integer|min:1|max:40', + 'max_id' => 'nullable|integer|min:0', + 'since_id' => 'nullable|integer|min:0', + 'min_id' => 'nullable|integer|min:0' + ]); - $pid = $request->user()->profile_id; - $limit = $request->input('limit') ?? 20; - $max_id = $request->input('max_id'); - $since_id = $request->input('since_id'); - $min_id = $request->input('min_id'); + $pid = $request->user()->profile_id; + $limit = $request->input('limit') ?? 20; + $max_id = $request->input('max_id'); + $since_id = $request->input('since_id'); + $min_id = $request->input('min_id'); - $dir = $min_id ? '>' : '<'; - $id = $min_id ?? $max_id; + $dir = $min_id ? '>' : '<'; + $id = $min_id ?? $max_id; - if($id) { - $bookmarks = Bookmark::whereProfileId($pid) - ->where('status_id', $dir, $id) - ->limit($limit) - ->pluck('status_id'); - } else { - $bookmarks = Bookmark::whereProfileId($pid) - ->latest() - ->limit($limit) - ->pluck('status_id'); - } + if($id) { + $bookmarks = Bookmark::whereProfileId($pid) + ->where('status_id', $dir, $id) + ->limit($limit) + ->pluck('status_id'); + } else { + $bookmarks = Bookmark::whereProfileId($pid) + ->latest() + ->limit($limit) + ->pluck('status_id'); + } - $res = []; - foreach($bookmarks as $id) { - $res[] = \App\Services\StatusService::get($id); - } - return $res; - } + $res = []; + foreach($bookmarks as $id) { + $res[] = \App\Services\StatusService::get($id); + } + return $res; + } - /** - * POST /api/v1/statuses/{id}/bookmark - * - * - * - * @return StatusTransformer - */ - public function bookmarkStatus(Request $request, $id) - { - abort_if(!$request->user(), 403); + /** + * POST /api/v1/statuses/{id}/bookmark + * + * + * + * @return StatusTransformer + */ + public function bookmarkStatus(Request $request, $id) + { + abort_if(!$request->user(), 403); - $status = Status::whereNull('uri') - ->whereScope('public') - ->findOrFail($id); + $status = Status::whereNull('uri') + ->whereScope('public') + ->findOrFail($id); - Bookmark::firstOrCreate([ - 'status_id' => $status->id, - 'profile_id' => $request->user()->profile_id - ]); - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } + Bookmark::firstOrCreate([ + 'status_id' => $status->id, + 'profile_id' => $request->user()->profile_id + ]); + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } - /** - * POST /api/v1/statuses/{id}/unbookmark - * - * - * - * @return StatusTransformer - */ - public function unbookmarkStatus(Request $request, $id) - { - abort_if(!$request->user(), 403); + /** + * POST /api/v1/statuses/{id}/unbookmark + * + * + * + * @return StatusTransformer + */ + public function unbookmarkStatus(Request $request, $id) + { + abort_if(!$request->user(), 403); - $status = Status::whereNull('uri') - ->whereScope('public') - ->findOrFail($id); + $status = Status::whereNull('uri') + ->whereScope('public') + ->findOrFail($id); - Bookmark::firstOrCreate([ - 'status_id' => $status->id, - 'profile_id' => $request->user()->profile_id - ]); - $bookmark = Bookmark::whereStatusId($status->id) - ->whereProfileId($request->user()->profile_id) - ->firstOrFail(); - $bookmark->delete(); + Bookmark::firstOrCreate([ + 'status_id' => $status->id, + 'profile_id' => $request->user()->profile_id + ]); + $bookmark = Bookmark::whereStatusId($status->id) + ->whereProfileId($request->user()->profile_id) + ->firstOrFail(); + $bookmark->delete(); - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); - $res = $this->fractal->createData($resource)->toArray(); - return response()->json($res); - } + $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($res); + } - /** - * GET /api/v2/search - * - * - * @return array - */ - public function searchV2(Request $request) - { - abort_if(!$request->user(), 403); + /** + * GET /api/v2/search + * + * + * @return array + */ + public function searchV2(Request $request) + { + abort_if(!$request->user(), 403); - $this->validate($request, [ - 'q' => 'required|string|min:1|max:80', - 'account_id' => 'nullable|string', - 'max_id' => 'nullable|string', - 'min_id' => 'nullable|string', - 'type' => 'nullable|in:accounts,hashtags,statuses', - 'exclude_unreviewed' => 'nullable', - 'resolve' => 'nullable', - 'limit' => 'nullable|integer|max:40', - 'offset' => 'nullable|integer', - 'following' => 'nullable' - ]); + $this->validate($request, [ + 'q' => 'required|string|min:1|max:80', + 'account_id' => 'nullable|string', + 'max_id' => 'nullable|string', + 'min_id' => 'nullable|string', + 'type' => 'nullable|in:accounts,hashtags,statuses', + 'exclude_unreviewed' => 'nullable', + 'resolve' => 'nullable', + 'limit' => 'nullable|integer|max:40', + 'offset' => 'nullable|integer', + 'following' => 'nullable' + ]); - return SearchApiV2Service::query($request); - } + return SearchApiV2Service::query($request); + } } diff --git a/app/Http/Controllers/Settings/HomeSettings.php b/app/Http/Controllers/Settings/HomeSettings.php index 39e3edd87..23d434d30 100644 --- a/app/Http/Controllers/Settings/HomeSettings.php +++ b/app/Http/Controllers/Settings/HomeSettings.php @@ -8,6 +8,7 @@ use App\Media; use App\Profile; use App\User; use App\UserFilter; +use App\Util\Lexer\Autolink; use App\Util\Lexer\PrettyNumber; use Auth; use Cache; @@ -21,23 +22,23 @@ use App\Services\PronounService; trait HomeSettings { - public function home() - { - $id = Auth::user()->profile->id; - $storage = []; - $used = Media::whereProfileId($id)->sum('size'); - $storage['limit'] = config_cache('pixelfed.max_account_size') * 1024; - $storage['used'] = $used; - $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100); - $storage['limitPretty'] = PrettyNumber::size($storage['limit']); - $storage['usedPretty'] = PrettyNumber::size($storage['used']); - $pronouns = PronounService::get($id); + public function home() + { + $id = Auth::user()->profile->id; + $storage = []; + $used = Media::whereProfileId($id)->sum('size'); + $storage['limit'] = config_cache('pixelfed.max_account_size') * 1024; + $storage['used'] = $used; + $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100); + $storage['limitPretty'] = PrettyNumber::size($storage['limit']); + $storage['usedPretty'] = PrettyNumber::size($storage['used']); + $pronouns = PronounService::get($id); - return view('settings.home', compact('storage', 'pronouns')); - } + return view('settings.home', compact('storage', 'pronouns')); + } - public function homeUpdate(Request $request) - { + public function homeUpdate(Request $request) + { $this->validate($request, [ 'name' => 'required|string|max:'.config('pixelfed.max_name_length'), 'bio' => 'nullable|string|max:'.config('pixelfed.max_bio_length'), @@ -46,164 +47,164 @@ trait HomeSettings 'pronouns' => 'nullable|array|max:4' ]); - $changes = false; - $name = strip_tags(Purify::clean($request->input('name'))); - $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; - $website = $request->input('website'); - $language = $request->input('language'); - $user = Auth::user(); - $profile = $user->profile; - $pronouns = $request->input('pronouns'); - $existingPronouns = PronounService::get($profile->id); - $layout = $request->input('profile_layout'); - if($layout) { - $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout; - } + $changes = false; + $name = strip_tags(Purify::clean($request->input('name'))); + $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; + $website = $request->input('website'); + $language = $request->input('language'); + $user = Auth::user(); + $profile = $user->profile; + $pronouns = $request->input('pronouns'); + $existingPronouns = PronounService::get($profile->id); + $layout = $request->input('profile_layout'); + if($layout) { + $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout; + } - $enforceEmailVerification = config_cache('pixelfed.enforce_email_verification'); + $enforceEmailVerification = config_cache('pixelfed.enforce_email_verification'); - // Only allow email to be updated if not yet verified - if (!$enforceEmailVerification || !$changes && $user->email_verified_at) { - if ($profile->name != $name) { - $changes = true; - $user->name = $name; - $profile->name = $name; - } + // Only allow email to be updated if not yet verified + if (!$enforceEmailVerification || !$changes && $user->email_verified_at) { + if ($profile->name != $name) { + $changes = true; + $user->name = $name; + $profile->name = $name; + } - if ($profile->website != $website) { - $changes = true; - $profile->website = $website; - } + if ($profile->website != $website) { + $changes = true; + $profile->website = $website; + } - if ($profile->bio != $bio) { - $changes = true; - $profile->bio = $bio; - } + if (strip_tags($profile->bio) != $bio) { + $changes = true; + $profile->bio = Autolink::create()->autolink($bio); + } - if($user->language != $language && - in_array($language, \App\Util\Localization\Localization::languages()) - ) { - $changes = true; - $user->language = $language; - session()->put('locale', $language); - } + if($user->language != $language && + in_array($language, \App\Util\Localization\Localization::languages()) + ) { + $changes = true; + $user->language = $language; + session()->put('locale', $language); + } - if($existingPronouns != $pronouns) { - if($pronouns && in_array('Select Pronoun(s)', $pronouns)) { - PronounService::clear($profile->id); - } else { - PronounService::put($profile->id, $pronouns); - } - } - } + if($existingPronouns != $pronouns) { + if($pronouns && in_array('Select Pronoun(s)', $pronouns)) { + PronounService::clear($profile->id); + } else { + PronounService::put($profile->id, $pronouns); + } + } + } - if ($changes === true) { - Cache::forget('user:account:id:'.$user->id); - $user->save(); - $profile->save(); + if ($changes === true) { + Cache::forget('user:account:id:'.$user->id); + $user->save(); + $profile->save(); - return redirect('/settings/home')->with('status', 'Profile successfully updated!'); - } + return redirect('/settings/home')->with('status', 'Profile successfully updated!'); + } - return redirect('/settings/home'); - } + return redirect('/settings/home'); + } - public function password() - { - return view('settings.password'); - } + public function password() + { + return view('settings.password'); + } - public function passwordUpdate(Request $request) - { - $this->validate($request, [ - 'current' => 'required|string', - 'password' => 'required|string', - 'password_confirmation' => 'required|string', - ]); + public function passwordUpdate(Request $request) + { + $this->validate($request, [ + 'current' => 'required|string', + 'password' => 'required|string', + 'password_confirmation' => 'required|string', + ]); - $current = $request->input('current'); - $new = $request->input('password'); - $confirm = $request->input('password_confirmation'); + $current = $request->input('current'); + $new = $request->input('password'); + $confirm = $request->input('password_confirmation'); - $user = Auth::user(); + $user = Auth::user(); - if (password_verify($current, $user->password) && $new === $confirm) { - $user->password = bcrypt($new); - $user->save(); + if (password_verify($current, $user->password) && $new === $confirm) { + $user->password = bcrypt($new); + $user->save(); - $log = new AccountLog(); - $log->user_id = $user->id; - $log->item_id = $user->id; - $log->item_type = 'App\User'; - $log->action = 'account.edit.password'; - $log->message = 'Password changed'; - $log->link = null; - $log->ip_address = $request->ip(); - $log->user_agent = $request->userAgent(); - $log->save(); + $log = new AccountLog(); + $log->user_id = $user->id; + $log->item_id = $user->id; + $log->item_type = 'App\User'; + $log->action = 'account.edit.password'; + $log->message = 'Password changed'; + $log->link = null; + $log->ip_address = $request->ip(); + $log->user_agent = $request->userAgent(); + $log->save(); - Mail::to($request->user())->send(new PasswordChange($user)); - return redirect('/settings/home')->with('status', 'Password successfully updated!'); - } else { - return redirect()->back()->with('error', 'There was an error with your request! Please try again.'); - } + Mail::to($request->user())->send(new PasswordChange($user)); + return redirect('/settings/home')->with('status', 'Password successfully updated!'); + } else { + return redirect()->back()->with('error', 'There was an error with your request! Please try again.'); + } - } + } - public function email() - { - return view('settings.email'); - } + public function email() + { + return view('settings.email'); + } - public function emailUpdate(Request $request) - { - $this->validate($request, [ - 'email' => 'required|email', - ]); - $changes = false; - $email = $request->input('email'); - $user = Auth::user(); - $profile = $user->profile; + public function emailUpdate(Request $request) + { + $this->validate($request, [ + 'email' => 'required|email', + ]); + $changes = false; + $email = $request->input('email'); + $user = Auth::user(); + $profile = $user->profile; - $validate = config_cache('pixelfed.enforce_email_verification'); + $validate = config_cache('pixelfed.enforce_email_verification'); - if ($user->email != $email) { - $changes = true; - $user->email = $email; + if ($user->email != $email) { + $changes = true; + $user->email = $email; - if ($validate) { - $user->email_verified_at = null; - // Prevent old verifications from working - EmailVerification::whereUserId($user->id)->delete(); - } + if ($validate) { + $user->email_verified_at = null; + // Prevent old verifications from working + EmailVerification::whereUserId($user->id)->delete(); + } - $log = new AccountLog(); - $log->user_id = $user->id; - $log->item_id = $user->id; - $log->item_type = 'App\User'; - $log->action = 'account.edit.email'; - $log->message = 'Email changed'; - $log->link = null; - $log->ip_address = $request->ip(); - $log->user_agent = $request->userAgent(); - $log->save(); - } + $log = new AccountLog(); + $log->user_id = $user->id; + $log->item_id = $user->id; + $log->item_type = 'App\User'; + $log->action = 'account.edit.email'; + $log->message = 'Email changed'; + $log->link = null; + $log->ip_address = $request->ip(); + $log->user_agent = $request->userAgent(); + $log->save(); + } - if ($changes === true) { - Cache::forget('user:account:id:'.$user->id); - $user->save(); - $profile->save(); + if ($changes === true) { + Cache::forget('user:account:id:'.$user->id); + $user->save(); + $profile->save(); - return redirect('/settings/home')->with('status', 'Email successfully updated!'); - } else { - return redirect('/settings/email'); - } + return redirect('/settings/home')->with('status', 'Email successfully updated!'); + } else { + return redirect('/settings/email'); + } - } + } - public function avatar() - { - return view('settings.avatar'); - } + public function avatar() + { + return view('settings.avatar'); + } } diff --git a/resources/assets/js/components/Profile.vue b/resources/assets/js/components/Profile.vue index 35d2f4e4b..897ea2494 100644 --- a/resources/assets/js/components/Profile.vue +++ b/resources/assets/js/components/Profile.vue @@ -103,10 +103,6 @@
{{profile.username}} - - - - Message @@ -144,12 +140,21 @@
-

+

{{profile.display_name}} {{profile.pronouns.join('/')}}

-
-

{{truncate(profile.website,24)}}

+

+

{{formatWebsite(profile.website)}}

+

+ + Admin + + Follows You + + Joined {{joinedAtFormat(profile.created_at)}} + +

@@ -1316,6 +1321,24 @@ return _.truncate(str, { length: len }); + }, + + formatWebsite(site) { + if(site.slice(0, 8) === 'https://') { + site = site.substr(8); + } else if(site.slice(0, 7) === 'http://') { + site = site.substr(7); + } else { + this.profile.website = null; + return; + } + + return this.truncate(site, 60); + }, + + joinedAtFormat(created) { + let d = new Date(created); + return d.toDateString(); } } } diff --git a/resources/views/settings/home.blade.php b/resources/views/settings/home.blade.php index 286828fb1..08bd8d727 100644 --- a/resources/views/settings/home.blade.php +++ b/resources/views/settings/home.blade.php @@ -51,7 +51,7 @@
- +

0/{{config('pixelfed.max_bio_length')}}

From afe901fffac425d5e8149bb5b6b4c9656f255d9d Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Thu, 10 Jun 2021 22:57:15 -0600 Subject: [PATCH 2/3] Update compiled assets --- public/js/profile.js | Bin 112240 -> 112739 bytes public/mix-manifest.json | Bin 2207 -> 2207 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/js/profile.js b/public/js/profile.js index abda1aae215fd035aabf33958fac4c160d43570a..c10af47825df777a4ddf80608db67e97d94ab793 100644 GIT binary patch delta 1410 zcmZWpT}&KR6waYAyKJGdTM7sY(@~olnO$ZnuykP%umO}*7O*ZqE;ze81JlmVPG|4J zTC)BmzG%`$yiLZ0#5A!n@xi3J(N&2l#iYg<8(SZ$4-Fl#1k_lGbBLP3kP-mQ;mh<`FVd!IlK`nOMcYcYg>dhDP=_b?SKFnl3OO{A zGsv4rBRP)Wo2|8k)9?uXa`*ray--yfm-IQ=q@zgprV_OAvR9xJruQgbe?EvGzf^^r z#t)SB#S^NI*CI8z_l-LIdZu+dn=cgoUow;Br;%@Vfn^4gnwDHl(UZwEgGZ+t@%p>9 z_=kwIJ_^gCDt{Gp6)RGxWA%bs=2>AMtJ2xGDE98u~ z3$4*kLBgA#JW4Tx!!2hSeC5~r(y5_v(vl+ZX3mxSuI4`US3-yNI|T=KvMg|bum{oB zqyyZ0LXFnjdqN*N;9`+5>V&o;I_HGBBKq42-9^+}4lPBrSPoZP;qI3oH6wzu0n5FhLTkdV5*upk)|9*SjS!0Og{vAW1B*S%WzcX6N{dV(n z7btjTV+NGmy^UKXVB~K8ashJg+bfUdy_LY1l^CLRW(;cbXFTvHfRaB|4SGGXKl|V> z0K@uG0G=|qB||iip11;sHOjmm1ST#2Ouh>Pl6Q%Nd5_{Q}1u)-pLHoNxTl z;#OFRX9R{9_`*9Rr;b)QPT8r%A8mzCDMMr!UddM+1s7QTZP4HekxPVRli^sIkMg=* z^XWF|03(044ZbP?#o7!&T|N{5FAp2)JQ+Sgztf3K@BK_VUfz!l16oLj~nnS?tGkhuQ z-XJtt3n8c_!Sf+_!f9~982N&X{Avh#D5oK6)BXsrSP+IMiC{hqGlXh{;bH4ixKL{? UTv+pKJXBkoVQ}U>6EHLLA2hGZqW}N^ delta 1091 zcmYk4U2NM_6vuV8Bu$Ix+Gb>eio)@gxnR6V=u)KgT zRXSM7)T`QNe|1*byr4X{eF%3i?DMA$I*dp4FxH>T;g8NAwl_|mZp8O)`0<4?2DhJN zaLb!M{Mwl%{6;l|)#(YSlhz9C!LJSn8>(`#Oz*~br$6kr%!=kZ<*b-t>PS)L*#Bjr zH_7w-B>ATbVSN6j?uFwa`*`W%NH8hIB~(ly{N4MV_FIczhJEhyTpEZ3I9Ad}kJ+7H ztp>v=OW!SRjp!_&K~%$1bd51=`1XS)F!1%Cr@*iu{N?rzK=$3=E`!gV#IV@zvuZ{e zM;2eUU;O>*{{M)?pM?~@wjA1$URYu9ugfjlIKD0K!cVWXZZS4iUiV<{YSM$T)fo>y zTkZFt;pQO^ayLKmpkb}6-w7BBpoyMInhzymF@UlxD;UnbBs2jSq$vdlNn;8+$VdwIQ8RNX_?Vg@ zKcr!=b0iH>`fZ$NGE}k4$d+P~s>qskN|@cs3mGw6uk{(u=lvkLwz(Wce8?3)mV-~} zKFyCpl4?0e2A~EH(1j#b7R_PJlFf6X%DJu1+92F!@@vZ3K-OSUv70GSgA*8l(j delta 33 ocmbO)IA3ssDw~L Date: Thu, 10 Jun 2021 22:57:28 -0600 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3056b0f10..40444097b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Updated Timeline, implement suggested post opt out. ([66750d34](https://github.com/pixelfed/pixelfed/commit/66750d34)) - Updated Notification component, add at (@) symbol for remote profiles and local urls for remote posts and profile. ([aafd6a21](https://github.com/pixelfed/pixelfed/commit/aafd6a21)) - Updated Activity component, add at (@) symbol for remote profiles and local urls for remote posts and profile. ([a2211815](https://github.com/pixelfed/pixelfed/commit/a2211815)) +- Updated Profile, add linkified bio, joined date, follows you label and improved website handling. ([8ee10436](https://github.com/pixelfed/pixelfed/commit/8ee10436)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)