diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e865b260..baf201a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,66 @@ ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.10.5...dev) ### Added +- Added ```/api/v1/accounts/update_credentials``` endpoint [6afd6970](https://github.com/pixelfed/pixelfed/commit/6afd6970) +- Added ```/api/v1/accounts/{id}/followers``` endpoint [41c91cba](https://github.com/pixelfed/pixelfed/commit/41c91cba) +- Added ```/api/v1/accounts/{id}/following``` endpoint [607eb51b](https://github.com/pixelfed/pixelfed/commit/607eb51b) +- Added ```/api/v1/accounts/{id}/statuses``` endpoint [8ce6c1f2](https://github.com/pixelfed/pixelfed/commit/8ce6c1f2) +- Added ```/api/v1/accounts/{id}/follow``` endpoint [f3839026](https://github.com/pixelfed/pixelfed/commit/f3839026) +- Added ```/api/v1/accounts/{id}/unfollow``` endpoint [fadc96b2](https://github.com/pixelfed/pixelfed/commit/fadc96b2) +- Added ```/api/v1/accounts/relationships``` endpoint [4b9f7d6b](https://github.com/pixelfed/pixelfed/commit/4b9f7d6b) +- Added ```/api/v1/accounts/search``` endpoint [b1fccf6d](https://github.com/pixelfed/pixelfed/commit/b1fccf6d) +- Added ```/api/v1/blocks``` endpoint [ac9f1bc0](https://github.com/pixelfed/pixelfed/commit/ac9f1bc0) +- Added ```/api/v1/accounts/{id}/block``` endpoint [c6b1ed97](https://github.com/pixelfed/pixelfed/commit/c6b1ed97) +- Added ```/api/v1/accounts/{id}/unblock``` endpoint [35226c99](https://github.com/pixelfed/pixelfed/commit/35226c99) +- Added ```/api/v1/custom_emojis``` endpoint [6e43431a](https://github.com/pixelfed/pixelfed/commit/6e43431a) +- Added ```/api/v1/domain_blocks``` endpoint [83a6313f](https://github.com/pixelfed/pixelfed/commit/83a6313f) +- Added ```/api/v1/endorsements``` endpoint [1f16221e](https://github.com/pixelfed/pixelfed/commit/1f16221e) +- Added ```/api/v1/favourites``` endpoint [b9cc06da](https://github.com/pixelfed/pixelfed/commit/b9cc06da) +- Added ```/api/v1/statuses/{id}/favourite``` endpoint [4edeba17](https://github.com/pixelfed/pixelfed/commit/4edeba17) +- Added ```/api/v1/statuses/{id}/unfavourite``` endpoint [437e18e3](https://github.com/pixelfed/pixelfed/commit/437e18e3) +- Added ```/api/v1/filters``` endpoint [b3d82edd](https://github.com/pixelfed/pixelfed/commit/b3d82edd) +- Added ```/api/v1/follow_requests``` endpoint [97269136](https://github.com/pixelfed/pixelfed/commit/97269136) +- Added ```/api/v1/follow_requests/{id}/authorize``` endpoint [7bdd9b2a](https://github.com/pixelfed/pixelfed/commit/7bdd9b2a) +- Added ```/api/v1/follow_requests/{id}/reject``` endpoint [62aa922a](https://github.com/pixelfed/pixelfed/commit/62aa922a) +- Added ```/api/v1/suggestions``` endpoint [e52aeeed](https://github.com/pixelfed/pixelfed/commit/e52aeeed) +- Added ```/api/v1/lists``` endpoint [2a106c4e](https://github.com/pixelfed/pixelfed/commit/2a106c4e) +- Added ```/api/v1/accounts/{id}/lists``` endpoint [dba172df](https://github.com/pixelfed/pixelfed/commit/dba172df) +- Added ```/api/v1/lists/{id}/accounts``` endpoint [dba172df](https://github.com/pixelfed/pixelfed/commit/dba172df) +- Added ```/api/v1/media``` endpoint [39f3e313](https://github.com/pixelfed/pixelfed/commit/39f3e313) +- Added ```/api/v1/media/{id}``` endpoint [fcf231f4](https://github.com/pixelfed/pixelfed/commit/fcf231f4) +- Added ```/api/v1/mutes``` endpoint [b280d183](https://github.com/pixelfed/pixelfed/commit/b280d183) +- Added ```/api/v1/accounts/{id}/mute``` endpoint [3e98dce4](https://github.com/pixelfed/pixelfed/commit/3e98dce4) +- Added ```/api/v1/accounts/{id}/unmute``` endpoint [41c96ddd](https://github.com/pixelfed/pixelfed/commit/41c96ddd) +- Added ```/api/v1/notifications``` endpoint [39449f36](https://github.com/pixelfed/pixelfed/commit/39449f36) +- Added ```/api/v1/timelines/home``` endpoint [cf3405d8](https://github.com/pixelfed/pixelfed/commit/cf3405d8) +- Added ```/api/v1/conversations``` endpoint [336f9069](https://github.com/pixelfed/pixelfed/commit/336f9069) +- Added ```/api/v1/timelines/public``` endpoint [f3eeb9c9](https://github.com/pixelfed/pixelfed/commit/f3eeb9c9) +- Added ```/api/v1/statuses/{id}/card``` endpoint [92251208](https://github.com/pixelfed/pixelfed/commit/92251208) +- Added ```/api/v1/statuses/{id}/reblogged_by``` endpoint [118006ed](https://github.com/pixelfed/pixelfed/commit/118006ed) +- Added ```/api/v1/statuses/{id}/favourited_by``` endpoint [5cdff57d](https://github.com/pixelfed/pixelfed/commit/5cdff57d) +- Added POST ```/api/v1/statuses``` endpoint [3aa729a3](https://github.com/pixelfed/pixelfed/commit/3aa729a3) +- Added DELETE ```/api/v1/statuses``` endpoint [0a20b832](https://github.com/pixelfed/pixelfed/commit/0a20b832) +- Added POST ```/api/v1/statuses/{id}/reblog``` endpoint [43cef282](https://github.com/pixelfed/pixelfed/commit/43cef282) +- Added POST ```/api/v1/statuses/{id}/unreblog``` endpoint [3147fe5c](https://github.com/pixelfed/pixelfed/commit/3147fe5c) +- Added GET ```/api/v1/timelines/tag/{hashtag}``` endpoint [2ff53be4](https://github.com/pixelfed/pixelfed/commit/2ff53be4) ### Fixed +- Update developer settings pages, fix vue bug [cd365ab3](https://github.com/pixelfed/pixelfed/commit/cd365ab3) +- Update User model, fix filter relationship [5a0c295e](https://github.com/pixelfed/pixelfed/commit/5a0c295e) ### Changed +- Updated Inbox Accept.Follow to use id of remote object [#1715](https://github.com/pixelfed/pixelfed/pull/1715) +- Update StatusTransformer, make spoiler_text non-nullable [b66cf9cd](https://github.com/pixelfed/pixelfed/commit/b66cf9cd) +- Update FollowerController, make follow and unfollow methods public [6237897d](https://github.com/pixelfed/pixelfed/commit/6237897d) +- Update DiscoverComponent, change api namespace [35275572](https://github.com/pixelfed/pixelfed/commit/35275572) + +## Deprecated +- Removed deprecated AttachmentTransformer, superceeded by MediaTransformer [9b5aac4f](https://github.com/pixelfed/pixelfed/commit/9b5aac4f) + +### To enable mobile app support +- Run ```php artisan passport:keys``` +- Add ```OAUTH_ENABLED=true``` to .env +- Run ```php artisan config:cache``` ## [v0.10.5 (2019-09-24)](https://github.com/pixelfed/pixelfed/compare/v0.10.4...v0.10.5) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 3cc31514e..a90327f4d 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -5,24 +5,45 @@ namespace App\Http\Controllers\Api; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use Illuminate\Support\Str; -use App\Jobs\StatusPipeline\StatusDelete; +use App\Util\ActivityPub\Helpers; +use App\Util\Media\Filter; use Laravel\Passport\Passport; -use Auth, Cache, DB; +use Auth, Cache, DB, URL; use App\{ + Follower, + FollowRequest, Like, Media, + Notification, Profile, - Status + Status, + UserFilter, }; use League\Fractal; -use App\Transformer\Api\{ +use App\Transformer\Api\Mastodon\v1\{ AccountTransformer, - RelationshipTransformer, + MediaTransformer, + NotificationTransformer, StatusTransformer, }; +use App\Transformer\Api\{ + RelationshipTransformer, +}; +use App\Http\Controllers\FollowerController; use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; - +use App\Http\Controllers\StatusController; +use App\Jobs\LikePipeline\LikePipeline; +use App\Jobs\SharePipeline\SharePipeline; +use App\Jobs\StatusPipeline\NewStatusPipeline; +use App\Jobs\StatusPipeline\StatusDelete; +use App\Jobs\FollowPipeline\FollowPipeline; +use App\Jobs\ImageOptimizePipeline\ImageOptimize; +use App\Jobs\VideoPipeline\{ + VideoOptimize, + VideoPostProcess, + VideoThumbnail +}; use App\Services\NotificationService; class ApiV1Controller extends Controller @@ -34,6 +55,7 @@ class ApiV1Controller extends Controller $this->fractal = new Fractal\Manager(); $this->fractal->setSerializer(new ArraySerializer()); } + public function apps(Request $request) { abort_if(!config('pixelfed.oauth_enabled'), 404); @@ -66,9 +88,46 @@ class ApiV1Controller extends Controller 'client_secret' => $client->secret, 'vapid_key' => null ]; - return $res; + 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; + + //$res = Cache::remember('mastoapi:user:account:id:'.$id, now()->addHours(6), function() use($id) { + $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 $res; + // }); + + return response()->json($res); + } + + /** + * 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); @@ -78,15 +137,744 @@ class ApiV1Controller extends Controller return response()->json($res); } - public function statusById(Request $request, $id) + /** + * PATCH /api/v1/accounts/update_credentials + * + * @return \App\Transformer\Api\AccountTransformer + */ + public function accountUpdateCredentials(Request $request) { - $status = Status::whereVisibility('public')->findOrFail($id); - $resource = new Fractal\Resource\Item($status, new StatusTransformer()); + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'display_name' => 'nullable|string', + 'note' => 'nullable|string', + 'locked' => 'nullable|boolean', + // '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); + $profile = Profile::whereNull('status')->findOrFail($id); + + if($profile->domain) { + $res = []; + } else { + $settings = $profile->user->settings; + if($settings->show_profile_followers == true) { + $limit = $request->input('limit') ?? 40; + $followers = $profile->followers()->paginate($limit); + $resource = new Fractal\Resource\Collection($followers, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } else { + $res = []; + } + } + 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); + $profile = Profile::whereNull('status')->findOrFail($id); + + if($profile->domain) { + $res = []; + } else { + $settings = $profile->user->settings; + if($settings->show_profile_following == true) { + $limit = $request->input('limit') ?? 40; + $following = $profile->following()->paginate($limit); + $resource = new Fractal\Resource\Collection($following, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + } else { + $res = []; + } + } + + 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:40' + ]); + + $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', + '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', + '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->id) + ->whereNull('status') + ->findOrFail($item); + + $private = (bool) $target->is_private; + $remote = (bool) $target->domain; + $blocked = UserFilter::whereUserId($target->id) + ->whereFilterType('block') + ->whereFilterableId($user->id) + ->whereFilterableType('App\Profile') + ->exists(); + + if($blocked == true) { + abort(400, 'You cannot follow this user.'); + } + + $isFollowing = Follower::whereProfileId($user->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->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->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->id, + 'following_id' => $target->id + ]); + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendFollow($user, $target); + } + } else { + $follower = new Follower(); + $follower->profile_id = $user->id; + $follower->following_id = $target->id; + $follower->save(); + + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendFollow($user, $target); + } + FollowPipeline::dispatch($follower); + } + + Cache::forget('profile:following:'.$target->id); + Cache::forget('profile:followers:'.$target->id); + Cache::forget('profile:following:'.$user->id); + Cache::forget('profile:followers:'.$user->id); + Cache::forget('api:local:exp:rec:'.$user->id); + Cache::forget('user:account:id:'.$target->user_id); + Cache::forget('user:account:id:'.$user->user_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->id) + ->whereNull('status') + ->findOrFail($item); + + $private = (bool) $target->is_private; + $remote = (bool) $target->domain; + + $isFollowing = Follower::whereProfileId($user->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->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->id) + ->whereFollowingId($target->id) + ->delete(); + + Follower::whereProfileId($user->id) + ->whereFollowingId($target->id) + ->delete(); + + if($remote == true && config('federation.activitypub.remoteFollow') == true) { + (new FollowerController())->sendUndoFollow($user, $target); + } + + Cache::forget('profile:following:'.$target->id); + Cache::forget('profile:followers:'.$target->id); + Cache::forget('profile:following:'.$user->id); + Cache::forget('profile:followers:'.$user->id); + Cache::forget('api:local:exp:rec:'.$user->id); + Cache::forget('user:account:id:'.$target->user_id); + Cache::forget('user:account:id:'.$user->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); + + 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); + + $like = Like::firstOrCreate([ + 'profile_id' => $user->profile_id, + 'status_id' => $status->id + ]); + + if($like->wasRecentlyCreated == true) { + 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); + + $like = Like::whereProfileId($user->profile_id) + ->whereStatusId($status->id) + ->first(); + + if($like) { + $like->delete(); + } + + $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 = [ @@ -109,15 +897,497 @@ class ApiV1Controller extends Controller return response()->json($res, 200, [], JSON_PRETTY_PRINT); } - public function filters(Request $request) + /** + * GET /api/v1/lists + * + * Return empty array as we don't support lists + * + * @return null + */ + public function accountLists(Request $request) { - // Pixelfed does not yet support keyword filters + 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('pixelfed.media_types'), + 'max:' . config('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(); + $profile = $user->profile; + + if(config('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('pixelfed.max_account_size'); + if ($size >= $limit) { + abort(403, 'Account size limit reached.'); + } + } + + $monthHash = hash('sha1', date('Y').date('m')); + $userHash = hash('sha1', $user->id . (string) $user->created_at); + + $photo = $request->file('file'); + + $mimes = explode(',', config('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + abort(403, 'Invalid or unsupported mime type.'); + } + + $storagePath = "public/m/{$monthHash}/{$userHash}"; + $path = $photo->store($storagePath); + $hash = \hash_file('sha256', $photo); + + $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 = $request->input('filter_class'); + $media->filter_name = $request->input('filter_name'); + $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; + } + + $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); + } + + /** + * 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, [ + 'page' => 'nullable|integer|min:1|max:10', + 'limit' => 'nullable|integer|min:1|max:80', + 'max_id' => 'nullable|integer|min:1', + 'min_id' => 'nullable|integer|min:0', + ]); + $pid = $request->user()->profile_id; + $limit = $request->input('limit') ?? 20; + $timeago = now()->subMonths(6); + $min = $request->input('min_id'); + $max = $request->input('max_id'); + if($min || $max) { + $dir = $min ? '>' : '<'; + $id = $min ?? $max; + $notifications = Notification::whereProfileId($pid) + ->whereDate('created_at', '>', $timeago) + ->latest() + ->where('id', $dir, $id) + ->limit($limit) + ->get(); + } else { + $notifications = Notification::whereProfileId($pid) + ->whereDate('created_at', '>', $timeago) + ->latest() + ->simplePaginate($limit); + } + $resource = new Fractal\Resource\Collection($notifications, new NotificationTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + return response()->json($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; + + $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', + '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', + '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([]); } - public function context(Request $request) + /** + * GET /api/v1/timelines/public + * + * + * @return StatusTransformer + */ + public function timelinePublic(Request $request) { - // todo + $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; + + 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', + 'created_at', + 'updated_at' + )->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->where('id', $dir, $id) + ->whereVisibility('public') + ->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', + 'created_at', + 'updated_at' + )->whereNull('uri') + ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album']) + ->with('profile', 'hashtags', 'mentions') + ->whereVisibility('public') + ->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); + + $status = Status::whereVisibility('public')->findOrFail($id); + $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); + + $status = Status::whereVisibility('public')->findOrFail($id); + + // Return empty response since we don't handle threading like this $res = [ 'ancestors' => [], 'descendants' => [] @@ -125,4 +1395,262 @@ class ApiV1Controller extends Controller 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); + + $status = Status::whereVisibility('public')->findOrFail($id); + + // 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, [ + 'limit' => 'nullable|integer|min:1|max:80' + ]); + + $limit = $request->input('limit') ?? 40; + $status = Status::whereVisibility('public')->findOrFail($id); + $shared = $status->sharedBy()->latest()->simplePaginate($limit); + $resource = new Fractal\Resource\Collection($shared, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * 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, [ + 'limit' => 'nullable|integer|min:1|max:80' + ]); + + $limit = $request->input('limit') ?? 40; + $status = Status::whereVisibility('public')->findOrFail($id); + $liked = $status->likedBy()->latest()->simplePaginate($limit); + $resource = new Fractal\Resource\Collection($liked, new AccountTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res); + } + + /** + * 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('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(); + + if($in_reply_to_id) { + $parent = Status::findOrFail($in_reply_to_id); + + $status = new Status; + $status->caption = strip_tags($request->input('status')); + $status->scope = $request->input('visibility'); + $status->visibility = $request->input('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(); + } else if($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('pixelfed.max_album_length')) { + continue; + } + $m = Media::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(500, 'Invalid media ids'); + } + + $status->scope = $request->input('visibility'); + $status->visibility = $request->input('visibility'); + $status->type = StatusController::mimeTypeCheck($mimes); + $status->save(); + } + + if(!$status) { + $oops = 'An error occured. RefId: '.time().'-'.$user->profile_id.':'.Str::random(5).':'.Str::random(10); + abort(500, $oops); + } + + NewStatusPipeline::dispatch($status); + Cache::forget('user:account:id:'.$user->id); + Cache::forget('profile:status_count:'.$user->profile_id); + Cache::forget($user->storageUsedKey()); + + $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); + + Cache::forget('profile:status_count:'.$status->profile_id); + StatusDelete::dispatch($status); + + return response()->json(['Status successfully deleted.']); + } + + /** + * 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); + + $share = Status::firstOrCreate([ + 'profile_id' => $user->profile_id, + 'reblog_of_id' => $status->id, + 'in_reply_to_profile_id' => $status->profile_id + ]); + + if($share->wasRecentlyCreated == true) { + SharePipeline::dispatch($share); + } + + $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); + + Status::whereProfileId($user->profile_id) + ->whereReblogOfId($status->id) + ->delete(); + $count = $status->reblogs_count; + $status->reblogs_count = $count > 0 ? $count - 1 : 0; + $status->save(); + + $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) + { + abort_if(!$request->user(), 403); + + // todo + $res = []; + return response()->json($res); + } } \ No newline at end of file diff --git a/app/Http/Controllers/FollowerController.php b/app/Http/Controllers/FollowerController.php index 97a3700d2..b3b6b2e4a 100644 --- a/app/Http/Controllers/FollowerController.php +++ b/app/Http/Controllers/FollowerController.php @@ -109,7 +109,7 @@ class FollowerController extends Controller Cache::forget('user:account:id:'.$user->user_id); } - protected function sendFollow($user, $target) + public function sendFollow($user, $target) { if($target->domain == null || $user->domain != null) { return; @@ -117,7 +117,7 @@ class FollowerController extends Controller $payload = [ '@context' => 'https://www.w3.org/ns/activitystreams', - 'id' => $user->permalink('#follow/'.$target->id.''), + 'id' => $user->permalink('#follow/'.$target->id), 'type' => 'Follow', 'actor' => $user->permalink(), 'object' => $target->permalink() @@ -128,7 +128,7 @@ class FollowerController extends Controller Helpers::sendSignedObject($user, $inbox, $payload); } - protected function sendUndoFollow($user, $target) + public function sendUndoFollow($user, $target) { if($target->domain == null || $user->domain != null) { return; diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 0c13b8548..f44fd7ac4 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + '/api/v1/*' ]; } diff --git a/app/Transformer/Api/AttachmentTransformer.php b/app/Transformer/Api/AttachmentTransformer.php deleted file mode 100644 index 76c5b6ae3..000000000 --- a/app/Transformer/Api/AttachmentTransformer.php +++ /dev/null @@ -1,28 +0,0 @@ - (string) $media->id, - 'type' => $media->activityVerb(), - 'url' => $media->url(), - 'remote_url' => null, - 'preview_url' => $media->thumbnailUrl(), - 'text_url' => null, - 'meta' => null, - 'description' => $media->caption, - 'license' => $media->license, - 'is_nsfw' => $media->is_nsfw, - 'orientation' => $media->orientation, - 'filter_name' => $media->filter_name, - 'filter_class' => $media->filter_class, - 'mime' => $media->mime, - ]; - } -} diff --git a/app/Transformer/Api/Mastodon/v1/AccountTransformer.php b/app/Transformer/Api/Mastodon/v1/AccountTransformer.php new file mode 100644 index 000000000..0d062851e --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/AccountTransformer.php @@ -0,0 +1,40 @@ +domain == null; + $is_admin = !$local ? false : $profile->user->is_admin; + $acct = $local ? $profile->username . '@' . config('pixelfed.domain.app') : substr($profile->username, 1); + $username = $local ? $profile->username : explode('@', $acct)[0]; + return [ + 'id' => (string) $profile->id, + 'username' => $username, + 'acct' => $acct, + 'display_name' => $profile->name, + 'locked' => (bool) $profile->is_private, + 'created_at' => $profile->created_at->toJSON(), + 'followers_count' => $profile->followerCount(), + 'following_count' => $profile->followingCount(), + 'statuses_count' => (int) $profile->statusCount(), + 'note' => $profile->bio ?? '', + 'url' => $profile->url(), + 'avatar' => $profile->avatarUrl(), + 'avatar_static' => $profile->avatarUrl(), + 'header' => '', + 'header_static' => '', + 'emojis' => [], + 'moved' => null, + 'fields' => null, + 'bot' => null, + 'software' => 'pixelfed', + 'is_admin' => (bool) $is_admin, + ]; + } +} diff --git a/app/Transformer/Api/Mastodon/v1/HashtagTransformer.php b/app/Transformer/Api/Mastodon/v1/HashtagTransformer.php new file mode 100644 index 000000000..7956a8adc --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/HashtagTransformer.php @@ -0,0 +1,17 @@ + $hashtag->name, + 'url' => $hashtag->url(), + ]; + } +} diff --git a/app/Transformer/Api/Mastodon/v1/MediaTransformer.php b/app/Transformer/Api/Mastodon/v1/MediaTransformer.php new file mode 100644 index 000000000..ceb96abec --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/MediaTransformer.php @@ -0,0 +1,23 @@ + (string) $media->id, + 'type' => lcfirst($media->activityVerb()), + 'url' => $media->url(), + 'remote_url' => null, + 'preview_url' => $media->thumbnailUrl(), + 'text_url' => null, + 'meta' => null, + 'description' => $media->caption + ]; + } +} \ No newline at end of file diff --git a/app/Transformer/Api/Mastodon/v1/MentionTransformer.php b/app/Transformer/Api/Mastodon/v1/MentionTransformer.php new file mode 100644 index 000000000..774f4122e --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/MentionTransformer.php @@ -0,0 +1,19 @@ + (string) $profile->id, + 'url' => $profile->url(), + 'username' => $profile->username, + 'acct' => $profile->username, + ]; + } +} diff --git a/app/Transformer/Api/Mastodon/v1/NotificationTransformer.php b/app/Transformer/Api/Mastodon/v1/NotificationTransformer.php new file mode 100644 index 000000000..595ac17df --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/NotificationTransformer.php @@ -0,0 +1,58 @@ + (string) $notification->id, + 'type' => $this->replaceTypeVerb($notification->action), + 'created_at' => (string) $notification->created_at->toJSON(), + ]; + } + + public function includeAccount(Notification $notification) + { + return $this->item($notification->actor, new AccountTransformer()); + } + + public function includeStatus(Notification $notification) + { + $item = $notification; + if($item->item_id && $item->item_type == 'App\Status') { + $status = Status::with('media')->find($item->item_id); + if($status) { + return $this->item($status, new StatusTransformer()); + } else { + return null; + } + } else { + return null; + } + } + + public function replaceTypeVerb($verb) + { + $verbs = [ + 'follow' => 'follow', + 'mention' => 'mention', + 'share' => 'reblog', + 'like' => 'favourite', + 'comment' => 'mention', + ]; + return $verbs[$verb]; + } +} diff --git a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php new file mode 100644 index 000000000..2cd6f123f --- /dev/null +++ b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php @@ -0,0 +1,82 @@ + (string) $status->id, + 'uri' => $status->url(), + 'url' => $status->url(), + 'in_reply_to_id' => $status->in_reply_to_id, + 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'reblog' => null, + 'content' => $status->rendered ?? $status->caption, + 'created_at' => $status->created_at->toJSON(), + 'emojis' => [], + 'replies_count' => 0, + 'reblogs_count' => $status->reblogs_count != 0 ? $status->reblogs_count: $status->shares()->count(), + 'favourites_count' => $status->likes_count != 0 ? $status->likes_count: $status->likes()->count(), + 'reblogged' => null, + 'favourited' => null, + 'muted' => null, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary ?? '', + 'visibility' => $status->visibility ?? $status->scope, + 'mentions' => [], + 'tags' => [], + 'card' => null, + 'poll' => null, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'language' => null, + 'pinned' => null, + ]; + } + + public function includeAccount(Status $status) + { + $account = $status->profile; + + return $this->item($account, new AccountTransformer()); + } + + public function includeMediaAttachments(Status $status) + { + return Cache::remember('mastoapi:status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) { + if(in_array($status->type, ['photo', 'video', 'photo:album', 'loop', 'photo:video:album'])) { + $media = $status->media()->orderBy('order')->get(); + return $this->collection($media, new MediaTransformer()); + } + }); + } + + public function includeMentions(Status $status) + { + $mentions = $status->mentions; + + return $this->collection($mentions, new MentionTransformer()); + } + + public function includeTags(Status $status) + { + $hashtags = $status->hashtags; + + return $this->collection($hashtags, new HashtagTransformer()); + } +} \ No newline at end of file diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 27f3f555f..5b251501e 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -31,7 +31,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'favourited' => $status->liked(), 'muted' => null, 'sensitive' => (bool) $status->is_nsfw, - 'spoiler_text' => $status->cw_summary, + 'spoiler_text' => $status->cw_summary ?? '', 'visibility' => $status->visibility ?? $status->scope, 'application' => [ 'name' => 'web', diff --git a/app/User.php b/app/User.php index 896ad12d6..72963400f 100644 --- a/app/User.php +++ b/app/User.php @@ -65,7 +65,7 @@ class User extends Authenticatable public function filters() { - return $this->hasMany(UserFilter::class); + return $this->hasMany(UserFilter::class, 'user_id', 'profile_id'); } public function receivesBroadcastNotificationsOn() diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 52205bb7d..fdd39d61b 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -197,7 +197,7 @@ class Inbox 'type' => 'Accept', 'actor' => $target->permalink(), 'object' => [ - 'id' => $actor->permalink('#follows/' . $follower->id), + 'id' => $this->payload['id'], 'actor' => $actor->permalink(), 'type' => 'Follow', 'object' => $target->permalink() diff --git a/composer.json b/composer.json index 8aebac042..93aad5904 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "ext-bcmath": "*", "ext-ctype": "*", "ext-curl": "*", + "ext-intl": "*", "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", @@ -18,7 +19,7 @@ "intervention/image": "^2.4", "jenssegers/agent": "^2.6", "laravel/framework": "5.8.*", - "laravel/horizon": "^3.1", + "laravel/horizon": "^3.3", "laravel/passport": "^7.0", "laravel/tinker": "^1.0", "league/flysystem-aws-s3-v3": "~1.0", diff --git a/config/pixelfed.php b/config/pixelfed.php index ed10f11fc..a9710a2aa 100644 --- a/config/pixelfed.php +++ b/config/pixelfed.php @@ -23,7 +23,7 @@ return [ | This value is the version of your Pixelfed instance. | */ - 'version' => '0.10.5', + 'version' => '0.10.6', /* |-------------------------------------------------------------------------- diff --git a/public/js/discover.js b/public/js/discover.js index b62111b0f..a483b7997 100644 Binary files a/public/js/discover.js and b/public/js/discover.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 344c2149b..abdb49fdb 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/js/components/DiscoverComponent.vue b/resources/assets/js/components/DiscoverComponent.vue index 7b5df69c5..a22c3b0d3 100644 --- a/resources/assets/js/components/DiscoverComponent.vue +++ b/resources/assets/js/components/DiscoverComponent.vue @@ -80,7 +80,7 @@ methods: { fetchData() { - axios.get('/api/v2/discover/posts') + axios.get('/api/pixelfed/v2/discover/posts') .then((res) => { this.posts = res.data.posts; this.loaded = true; diff --git a/resources/views/settings/applications.blade.php b/resources/views/settings/applications.blade.php index be40dfdf7..912b108b6 100644 --- a/resources/views/settings/applications.blade.php +++ b/resources/views/settings/applications.blade.php @@ -16,9 +16,4 @@ @push('scripts') - @endpush \ No newline at end of file diff --git a/resources/views/settings/developers.blade.php b/resources/views/settings/developers.blade.php index 14f80094e..ade660d77 100644 --- a/resources/views/settings/developers.blade.php +++ b/resources/views/settings/developers.blade.php @@ -16,9 +16,4 @@ @push('scripts') - @endpush \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 65380a01a..78c263701 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,8 +9,5 @@ Route::group(['prefix' => 'api'], function() { Route::group(['prefix' => 'v1'], function() { Route::post('apps', 'Api\ApiV1Controller@apps'); Route::get('instance', 'Api\ApiV1Controller@instance'); - Route::get('filters', 'Api\ApiV1Controller@filters'); - Route::get('statuses/{id}', 'Api\ApiV1Controller@statusById'); - Route::get('statuses/{id}/context', 'Api\ApiV1Controller@context'); }); }); diff --git a/routes/web.php b/routes/web.php index 5f8d730fe..fc9c685d5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -77,25 +77,67 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo'); Route::group(['prefix' => 'v1'], function () { - Route::get('accounts/verify_credentials', 'ApiController@verifyCredentials')->middleware('auth:api'); - Route::get('accounts/relationships', 'PublicApiController@relationships')->middleware('auth:api'); - Route::get('accounts/{id}/statuses', 'PublicApiController@accountStatuses')->middleware('auth:api'); - Route::get('accounts/{id}/following', 'PublicApiController@accountFollowing')->middleware('auth:api'); - Route::get('accounts/{id}/followers', 'PublicApiController@accountFollowers')->middleware('auth:api'); - // Route::get('accounts/{id}', 'PublicApiController@account'); - Route::get('accounts/{id}', 'Api\ApiV1Controller@accountById'); + Route::get('accounts/verify_credentials', 'Api\ApiV1Controller@verifyCredentials')->middleware('auth:api'); + Route::patch('accounts/update_credentials', 'Api\ApiV1Controller@accountUpdateCredentials')->middleware('auth:api'); + Route::get('accounts/relationships', 'Api\ApiV1Controller@accountRelationshipsById')->middleware('auth:api'); + Route::get('accounts/search', 'Api\ApiV1Controller@accountSearch')->middleware('auth:api'); + Route::get('accounts/{id}/statuses', 'Api\ApiV1Controller@accountStatusesById')->middleware('auth:api'); + Route::get('accounts/{id}/following', 'Api\ApiV1Controller@accountFollowingById')->middleware('auth:api'); + Route::get('accounts/{id}/followers', 'Api\ApiV1Controller@accountFollowersById')->middleware('auth:api'); + Route::post('accounts/{id}/follow', 'Api\ApiV1Controller@accountFollowById')->middleware('auth:api'); + Route::post('accounts/{id}/unfollow', 'Api\ApiV1Controller@accountUnfollowById')->middleware('auth:api'); + Route::post('accounts/{id}/block', 'Api\ApiV1Controller@accountBlockById')->middleware('auth:api'); + Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById')->middleware('auth:api'); + Route::post('accounts/{id}/pin', 'Api\ApiV1Controller@accountEndorsements')->middleware('auth:api'); + Route::post('accounts/{id}/unpin', 'Api\ApiV1Controller@accountEndorsements')->middleware('auth:api'); + Route::post('accounts/{id}/mute', 'Api\ApiV1Controller@accountMuteById')->middleware('auth:api'); + Route::post('accounts/{id}/unmute', 'Api\ApiV1Controller@accountUnmuteById')->middleware('auth:api'); + Route::get('accounts/{id}/lists', 'Api\ApiV1Controller@accountListsById')->middleware('auth:api'); + Route::get('lists/{id}/accounts', 'Api\ApiV1Controller@accountListsById')->middleware('auth:api'); + Route::get('accounts/{id}', 'Api\ApiV1Controller@accountById')->middleware('auth:api'); + Route::post('avatar/update', 'ApiController@avatarUpdate')->middleware('auth:api'); - Route::get('likes', 'ApiController@hydrateLikes'); - Route::post('media', 'ApiController@uploadMedia')->middleware('auth:api'); - Route::delete('media', 'ApiController@deleteMedia')->middleware('auth:api'); - Route::get('notifications', 'ApiController@notifications')->middleware('auth:api'); - Route::get('timelines/public', 'PublicApiController@publicTimelineApi'); - Route::get('timelines/home', 'PublicApiController@homeTimelineApi')->middleware('auth:api'); + Route::get('blocks', 'Api\ApiV1Controller@accountBlocks')->middleware('auth:api'); + Route::get('conversations', 'Api\ApiV1Controller@conversations')->middleware('auth:api'); + Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis'); + Route::get('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware('auth:api'); + Route::post('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware('auth:api'); + Route::delete('domain_blocks', 'Api\ApiV1Controller@accountDomainBlocks')->middleware('auth:api'); + Route::get('endorsements', 'Api\ApiV1Controller@accountEndorsements')->middleware('auth:api'); + Route::get('favourites', 'Api\ApiV1Controller@accountFavourites')->middleware('auth:api'); + Route::get('filters', 'Api\ApiV1Controller@accountFilters')->middleware('auth:api'); + Route::get('follow_requests', 'Api\ApiV1Controller@accountFollowRequests')->middleware('auth:api'); + Route::post('follow_requests/{id}/authorize', 'Api\ApiV1Controller@accountFollowRequestAccept')->middleware('auth:api'); + Route::post('follow_requests/{id}/reject', 'Api\ApiV1Controller@accountFollowRequestReject')->middleware('auth:api'); + Route::get('lists', 'Api\ApiV1Controller@accountLists')->middleware('auth:api'); + Route::post('media', 'Api\ApiV1Controller@mediaUpload')->middleware('auth:api'); + Route::put('media/{id}', 'Api\ApiV1Controller@mediaUpdate')->middleware('auth:api'); + Route::get('mutes', 'Api\ApiV1Controller@accountMutes')->middleware('auth:api'); + Route::get('notifications', 'Api\ApiV1Controller@accountNotifications')->middleware('auth:api'); + Route::get('suggestions', 'Api\ApiV1Controller@accountSuggestions')->middleware('auth:api'); + + Route::post('statuses/{id}/favourite', 'Api\ApiV1Controller@statusFavouriteById')->middleware('auth:api'); + Route::post('statuses/{id}/unfavourite', 'Api\ApiV1Controller@statusUnfavouriteById')->middleware('auth:api'); + Route::get('statuses/{id}/context', 'Api\ApiV1Controller@statusContext')->middleware('auth:api'); + Route::get('statuses/{id}/card', 'Api\ApiV1Controller@statusCard')->middleware('auth:api'); + Route::get('statuses/{id}/reblogged_by', 'Api\ApiV1Controller@statusRebloggedBy')->middleware('auth:api'); + Route::get('statuses/{id}/favourited_by', 'Api\ApiV1Controller@statusFavouritedBy')->middleware('auth:api'); + Route::post('statuses/{id}/reblog', 'Api\ApiV1Controller@statusShare')->middleware('auth:api'); + Route::post('statuses/{id}/unreblog', 'Api\ApiV1Controller@statusUnshare')->middleware('auth:api'); + Route::delete('statuses/{id}', 'Api\ApiV1Controller@statusDelete')->middleware('auth:api'); + Route::get('statuses/{id}', 'Api\ApiV1Controller@statusById')->middleware('auth:api'); + Route::post('statuses', 'Api\ApiV1Controller@statusCreate')->middleware('auth:api'); + + + Route::get('timelines/home', 'Api\ApiV1Controller@timelineHome')->middleware('auth:api'); + Route::get('timelines/public', 'Api\ApiV1Controller@timelinePublic'); + Route::get('timelines/tag/{hashtag}', 'Api\ApiV1Controller@timelineHashtag')->middleware('auth:api'); + }); Route::group(['prefix' => 'v2'], function() { Route::get('config', 'ApiController@siteConfiguration'); Route::get('discover', 'InternalApiController@discover'); - Route::get('discover/posts', 'InternalApiController@discoverPosts'); + Route::get('discover/posts', 'InternalApiController@discoverPosts')->middleware('auth:api'); Route::get('profile/{username}/status/{postid}', 'PublicApiController@status'); Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments'); Route::get('likes/profile/{username}/status/{id}', 'PublicApiController@statusLikes'); @@ -111,12 +153,16 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::group(['prefix' => 'pixelfed'], function() { Route::group(['prefix' => 'v1'], function() { Route::get('accounts/verify_credentials', 'ApiController@verifyCredentials'); - Route::get('accounts/relationships', 'PublicApiController@relationships'); + Route::get('accounts/relationships', 'Api\ApiV1Controller@accountRelationshipsById'); + Route::get('accounts/search', 'Api\ApiV1Controller@accountSearch'); Route::get('accounts/{id}/statuses', 'PublicApiController@accountStatuses'); Route::get('accounts/{id}/following', 'PublicApiController@accountFollowing'); Route::get('accounts/{id}/followers', 'PublicApiController@accountFollowers'); + Route::post('accounts/{id}/block', 'Api\ApiV1Controller@accountBlockById'); + Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById'); Route::get('accounts/{id}', 'PublicApiController@account'); Route::post('avatar/update', 'ApiController@avatarUpdate'); + Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis'); Route::get('likes', 'ApiController@hydrateLikes'); Route::post('media', 'ApiController@uploadMedia'); Route::delete('media', 'ApiController@deleteMedia'); @@ -124,6 +170,23 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('timelines/public', 'PublicApiController@publicTimelineApi'); Route::get('timelines/home', 'PublicApiController@homeTimelineApi'); }); + + Route::group(['prefix' => 'v2'], function() { + Route::get('config', 'ApiController@siteConfiguration'); + Route::get('discover', 'InternalApiController@discover'); + Route::get('discover/posts', 'InternalApiController@discoverPosts'); + Route::get('profile/{username}/status/{postid}', 'PublicApiController@status'); + Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments'); + Route::get('likes/profile/{username}/status/{id}', 'PublicApiController@statusLikes'); + Route::get('shares/profile/{username}/status/{id}', 'PublicApiController@statusShares'); + Route::get('status/{id}/replies', 'InternalApiController@statusReplies'); + Route::post('moderator/action', 'InternalApiController@modAction'); + Route::get('discover/categories', 'InternalApiController@discoverCategories'); + Route::get('loops', 'DiscoverController@loopsApi'); + Route::post('loops/watch', 'DiscoverController@loopWatch'); + Route::get('discover/tag', 'DiscoverController@getHashtags'); + Route::post('status/compose', 'InternalApiController@composePost')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440'); + }); }); Route::group(['prefix' => 'local'], function () { // Route::get('accounts/verify_credentials', 'ApiController@verifyCredentials');