diff --git a/CHANGELOG.md b/CHANGELOG.md index 033727ba0..9031fca7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.6...dev) +### API Changes +- Added [/api/v1/followed_tags](https://docs.joinmastodon.org/methods/followed_tags/) api endpoint ([175a8486](https://github.com/pixelfed/pixelfed/commit/175a8486)) +- Added [/api/v1/tags/:id/follow](https://docs.joinmastodon.org/methods/tags/#follow) and [/api/v1/tags/:id/unfollow](https://docs.joinmastodon.org/methods/tags/#unfollow) api endpoints ([4d997bb9](https://github.com/pixelfed/pixelfed/commit/4d997bb9)) +- Added [/api/v1/tags/:id](https://docs.joinmastodon.org/methods/tags/) api endpoint ([521b3b4c](https://github.com/pixelfed/pixelfed/commit/521b3b4c)) +- Added `only_media` support to /api/v1/timelines/tag/:id api endpoint ([b5fe956a](https://github.com/pixelfed/pixelfed/commit/b5fe956a)) + ### Added - Added store remote media on S3 config setting, disabled by default ([51768083](https://github.com/pixelfed/pixelfed/commit/51768083)) @@ -14,6 +20,14 @@ - Update instance config, enable config cache by default ([970f77b0](https://github.com/pixelfed/pixelfed/commit/970f77b0)) - Update Admin Dashboard, allow admins to designate an admin account for the landing page and instance api endpoint ([6ea2bdc7](https://github.com/pixelfed/pixelfed/commit/6ea2bdc7)) - Update config, enable oauth by default ([6a2e9e8f](https://github.com/pixelfed/pixelfed/commit/6a2e9e8f)) +- Update StatusService, fix missing account condition ([f48daab3](https://github.com/pixelfed/pixelfed/commit/f48daab3)) +- Update ProfileService, add softFail param ([6bc20a37](https://github.com/pixelfed/pixelfed/commit/6bc20a37)) +- Update MediaTagService, fix ProfileService to soft fail on missing or deleted accounts ([df444851](https://github.com/pixelfed/pixelfed/commit/df444851)) +- Update LikeService, improve likedBy logic to soft fail on missing or deleted accounts ([91ba1398](https://github.com/pixelfed/pixelfed/commit/91ba1398)) +- Update StatusTransformers, fix ProfileService to soft fail on missing or deleted accounts ([43d3aa2b](https://github.com/pixelfed/pixelfed/commit/43d3aa2b)) +- Update ApiV1Controller, fix hashtag timeline ([fc1a385c](https://github.com/pixelfed/pixelfed/commit/fc1a385c)) +- Update settings view, add fallback avatar ([1a83c585](https://github.com/pixelfed/pixelfed/commit/1a83c585)) +- Update HashtagFollow model, add MAX_LIMIT of 250 tags per account ([ed352141](https://github.com/pixelfed/pixelfed/commit/ed352141)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.6 (2023-05-03)](https://github.com/pixelfed/pixelfed/compare/v0.11.5...v0.11.6) diff --git a/app/HashtagFollow.php b/app/HashtagFollow.php index 0503330b0..126701fe1 100644 --- a/app/HashtagFollow.php +++ b/app/HashtagFollow.php @@ -12,6 +12,8 @@ class HashtagFollow extends Model 'hashtag_id' ]; + const MAX_LIMIT = 250; + public function hashtag() { return $this->belongsTo(Hashtag::class); diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 1ba73ae8f..af46f8af3 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -19,6 +19,7 @@ use App\{ Follower, FollowRequest, Hashtag, + HashtagFollow, Instance, Like, Media, @@ -69,6 +70,7 @@ use App\Services\{ BouncerService, CollectionService, FollowerService, + HashtagService, InstanceService, LikeService, NetworkTimelineService, @@ -99,6 +101,7 @@ use App\Jobs\FollowPipeline\FollowRejectPipeline; use Illuminate\Support\Facades\RateLimiter; use Purify; use Carbon\Carbon; +use App\Http\Resources\MastoApi\FollowedTagResource; class ApiV1Controller extends Controller { @@ -3245,7 +3248,9 @@ class ApiV1Controller extends Controller 'page' => 'nullable|integer|max:40', 'min_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, 'max_id' => 'nullable|integer|min:0|max:' . PHP_INT_MAX, - 'limit' => 'nullable|integer|max:100' + 'limit' => 'nullable|integer|max:100', + 'only_media' => 'sometimes|boolean', + '_pe' => 'sometimes' ]); if(config('database.default') === 'pgsql') { @@ -3269,6 +3274,18 @@ class ApiV1Controller extends Controller $min = $request->input('min_id'); $max = $request->input('max_id'); $limit = $request->input('limit', 20); + $onlyMedia = $request->input('only_media', true); + $pe = $request->has(self::PF_API_ENTITY_KEY); + + if($min || $max) { + $minMax = SnowflakeService::byDate(now()->subMonths(6)); + if($min && intval($min) < $minMax) { + return []; + } + if($max && intval($max) < $minMax) { + return []; + } + } if(!$min && !$max) { $id = 1; @@ -3281,15 +3298,19 @@ class ApiV1Controller extends Controller $res = StatusHashtag::whereHashtagId($tag->id) ->whereStatusVisibility('public') ->where('status_id', $dir, $id) - ->latest() + ->orderBy('status_id', 'desc') ->limit($limit) ->pluck('status_id') - ->map(function ($i) { - if($i) { - return StatusService::getMastodon($i); - } + ->map(function ($i) use($pe) { + return $pe ? StatusService::get($i) : StatusService::getMastodon($i); }) - ->filter(function($i) { + ->filter(function($i) use($onlyMedia) { + if(!$i) { + return false; + } + if($onlyMedia && !isset($i['media_attachments']) || !count($i['media_attachments'])) { + return false; + } return $i && isset($i['account']); }) ->values() @@ -3636,7 +3657,7 @@ class ApiV1Controller extends Controller return $this->json(StatusService::getState($status->id, $pid)); } - /** + /** * GET /api/v1.1/discover/accounts/popular * * @@ -3794,4 +3815,181 @@ class ApiV1Controller extends Controller return $this->json([]); } + + /** + * GET /api/v1/followed_tags + * + * + * @return array + */ + public function getFollowedTags(Request $request) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + + $account = AccountService::get($request->user()->profile_id); + + $this->validate($request, [ + 'cursor' => 'sometimes', + 'limit' => 'sometimes|integer|min:1|max:200' + ]); + $limit = $request->input('limit', 100); + + $res = HashtagFollow::whereProfileId($account['id']) + ->orderByDesc('id') + ->cursorPaginate($limit)->withQueryString(); + + $pagination = false; + $prevPage = $res->nextPageUrl(); + $nextPage = $res->previousPageUrl(); + if($nextPage && $prevPage) { + $pagination = '<' . $nextPage . '>; rel="next", <' . $prevPage . '>; rel="prev"'; + } else if($nextPage && !$prevPage) { + $pagination = '<' . $nextPage . '>; rel="next"'; + } else if(!$nextPage && $prevPage) { + $pagination = '<' . $prevPage . '>; rel="prev"'; + } + + if($pagination) { + return response()->json(FollowedTagResource::collection($res)->collection) + ->header('Link', $pagination); + } + return response()->json(FollowedTagResource::collection($res)->collection); + } + + /** + * POST /api/v1/tags/:id/follow + * + * + * @return object + */ + public function followHashtag(Request $request, $id) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + $pid = $request->user()->profile_id; + $account = AccountService::get($pid); + + $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like'; + $tag = Hashtag::where('name', $operator, $id) + ->orWhere('slug', $operator, $id) + ->first(); + + abort_if(!$tag, 422, 'Unknown hashtag'); + + abort_if( + HashtagFollow::whereProfileId($pid)->count() >= HashtagFollow::MAX_LIMIT, + 422, + 'You cannot follow more than ' . HashtagFollow::MAX_LIMIT . ' hashtags.' + ); + + $follows = HashtagFollow::updateOrCreate( + [ + 'profile_id' => $account['id'], + 'hashtag_id' => $tag->id + ], + [ + 'user_id' => $request->user()->id + ] + ); + + HashtagService::follow($pid, $tag->id); + + return response()->json(FollowedTagResource::make($follows)->toArray($request)); + } + + /** + * POST /api/v1/tags/:id/unfollow + * + * + * @return object + */ + public function unfollowHashtag(Request $request, $id) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + $pid = $request->user()->profile_id; + $account = AccountService::get($pid); + + $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like'; + $tag = Hashtag::where('name', $operator, $id) + ->orWhere('slug', $operator, $id) + ->first(); + + abort_if(!$tag, 422, 'Unknown hashtag'); + + $follows = HashtagFollow::whereProfileId($pid) + ->whereHashtagId($tag->id) + ->first(); + + if(!$follows) { + return [ + 'name' => $tag->name, + 'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug, + 'history' => [], + 'following' => false + ]; + } + + if($follows) { + HashtagService::unfollow($pid, $tag->id); + $follows->delete(); + } + + $res = FollowedTagResource::make($follows)->toArray($request); + $res['following'] = false; + return response()->json($res); + } + + /** + * GET /api/v1/tags/:id + * + * + * @return object + */ + public function getHashtag(Request $request, $id) + { + abort_if(!$request->user(), 403); + + if(config('pixelfed.bouncer.cloud_ips.ban_api')) { + abort_if(BouncerService::checkIp($request->ip()), 404); + } + $pid = $request->user()->profile_id; + $account = AccountService::get($pid); + $operator = config('database.default') == 'pgsql' ? 'ilike' : 'like'; + $tag = Hashtag::where('name', $operator, $id) + ->orWhere('slug', $operator, $id) + ->first(); + + if(!$tag) { + return [ + 'name' => $id, + 'url' => config('app.url') . '/i/web/hashtag/' . $id, + 'history' => [], + 'following' => false + ]; + } + + $res = [ + 'name' => $tag->name, + 'url' => config('app.url') . '/i/web/hashtag/' . $tag->slug, + 'history' => [], + 'following' => HashtagService::isFollowing($pid, $tag->id) + ]; + + if($request->has(self::PF_API_ENTITY_KEY)) { + $res['count'] = HashtagService::count($tag->id); + } + + return $this->json($res); + } } diff --git a/app/Http/Resources/MastoApi/FollowedTagResource.php b/app/Http/Resources/MastoApi/FollowedTagResource.php new file mode 100644 index 000000000..3c690d05a --- /dev/null +++ b/app/Http/Resources/MastoApi/FollowedTagResource.php @@ -0,0 +1,33 @@ +hashtag_id); + + if(!$tag || !isset($tag['name'])) { + return []; + } + + return [ + 'name' => $tag['name'], + 'url' => config('app.url') . '/i/web/hashtag/' . $tag['slug'], + 'history' => [], + 'following' => true, + ]; + } +} diff --git a/app/Services/HashtagService.php b/app/Services/HashtagService.php index de58fae59..87f895a65 100644 --- a/app/Services/HashtagService.php +++ b/app/Services/HashtagService.php @@ -28,8 +28,8 @@ class HashtagService { public static function count($id) { - return Cache::remember('services:hashtag:count:by_id:' . $id, 3600, function() use($id) { - return StatusHashtag::whereHashtagId($id)->count(); + return Cache::remember('services:hashtag:public-count:by_id:' . $id, 86400, function() use($id) { + return StatusHashtag::whereHashtagId($id)->whereStatusVisibility('public')->count(); }); } @@ -64,4 +64,9 @@ class HashtagService { { return Redis::zrem(self::FOLLOW_KEY . $pid, $hid); } + + public static function following($pid, $start = 0, $limit = 10) + { + return Redis::zrevrange(self::FOLLOW_KEY . $pid, $start, $limit); + } } diff --git a/app/Services/LikeService.php b/app/Services/LikeService.php index bc6b876ae..34a2417d0 100644 --- a/app/Services/LikeService.php +++ b/app/Services/LikeService.php @@ -85,7 +85,10 @@ class LikeService { return $empty; } $id = $like->profile_id; - $profile = ProfileService::get($id); + $profile = ProfileService::get($id, true); + if(!$profile) { + return []; + } $profileUrl = "/i/web/profile/{$profile['id']}"; $res = [ 'id' => (string) $profile['id'], diff --git a/app/Services/MediaTagService.php b/app/Services/MediaTagService.php index d2457b39d..ef436ec0a 100644 --- a/app/Services/MediaTagService.php +++ b/app/Services/MediaTagService.php @@ -57,7 +57,7 @@ class MediaTagService protected function idToUsername($id) { - $profile = ProfileService::get($id); + $profile = ProfileService::get($id, true); if(!$profile) { return 'unavailable'; diff --git a/app/Services/ProfileService.php b/app/Services/ProfileService.php index 43f2ff0e4..abc50d84a 100644 --- a/app/Services/ProfileService.php +++ b/app/Services/ProfileService.php @@ -4,9 +4,9 @@ namespace App\Services; class ProfileService { - public static function get($id) + public static function get($id, $softFail = false) { - return AccountService::get($id); + return AccountService::get($id, $softFail); } public static function del($id) diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 2b3bca274..05f8939b3 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -47,6 +47,10 @@ class StatusService return null; } + if(!isset($status['account'])) { + return null; + } + $status['replies_count'] = $status['reply_count']; if(config('exp.emc') == false) { diff --git a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php index 3467654af..bfbc3d58b 100644 --- a/app/Transformer/Api/Mastodon/v1/StatusTransformer.php +++ b/app/Transformer/Api/Mastodon/v1/StatusTransformer.php @@ -42,7 +42,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'card' => null, 'poll' => null, 'media_attachments' => MediaService::get($status->id), - 'account' => ProfileService::get($status->profile_id), + 'account' => ProfileService::get($status->profile_id, true), 'tags' => StatusHashtagService::statusTags($status->id), ]; } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index c44257f81..61c5f875b 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -66,7 +66,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'label' => StatusLabelService::get($status), 'liked_by' => LikeService::likedBy($status), 'media_attachments' => MediaService::get($status->id), - 'account' => ProfileService::get($status->profile_id), + 'account' => ProfileService::get($status->profile_id, true), 'tags' => StatusHashtagService::statusTags($status->id), 'poll' => $poll, 'bookmarked' => BookmarkService::get($pid, $status->id), diff --git a/public/css/spa.css b/public/css/spa.css index 954390148..8d2e31958 100644 Binary files a/public/css/spa.css and b/public/css/spa.css differ diff --git a/public/js/discover~hashtag.bundle.279c3460159d3af7.js b/public/js/discover~hashtag.bundle.279c3460159d3af7.js new file mode 100644 index 000000000..f5a6ff81e Binary files /dev/null and b/public/js/discover~hashtag.bundle.279c3460159d3af7.js differ diff --git a/public/js/discover~hashtag.bundle.76807a8ff71bd205.js b/public/js/discover~hashtag.bundle.76807a8ff71bd205.js deleted file mode 100644 index cd23b2045..000000000 Binary files a/public/js/discover~hashtag.bundle.76807a8ff71bd205.js and /dev/null differ diff --git a/public/js/manifest.js b/public/js/manifest.js index a91c64b9d..e8f53e356 100644 Binary files a/public/js/manifest.js and b/public/js/manifest.js differ diff --git a/public/js/spa.js b/public/js/spa.js index 9cc2b3aa3..83593e774 100644 Binary files a/public/js/spa.js and b/public/js/spa.js differ diff --git a/public/js/spa.js.LICENSE.txt b/public/js/spa.js.LICENSE.txt index dec739529..ba8e2aeda 100644 --- a/public/js/spa.js.LICENSE.txt +++ b/public/js/spa.js.LICENSE.txt @@ -1,3 +1 @@ /*! @source http://purl.eligrey.com/github/canvas-toBlob.js/blob/master/canvas-toBlob.js */ - -/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 997acc915..d2367576e 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/views/settings/home.blade.php b/resources/views/settings/home.blade.php index 702373c83..2c02cdde9 100644 --- a/resources/views/settings/home.blade.php +++ b/resources/views/settings/home.blade.php @@ -8,7 +8,7 @@
{{Auth::user()->username}}
diff --git a/routes/api.php b/routes/api.php index f05489c7f..b3746a85c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -89,6 +89,11 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('announcements', 'Api\ApiV1Controller@getAnnouncements')->middleware($middleware); Route::get('markers', 'Api\ApiV1Controller@getMarkers')->middleware($middleware); Route::post('markers', 'Api\ApiV1Controller@setMarkers')->middleware($middleware); + + Route::get('followed_tags', 'Api\ApiV1Controller@getFollowedTags')->middleware($middleware); + Route::post('tags/{id}/follow', 'Api\ApiV1Controller@followHashtag')->middleware($middleware); + Route::post('tags/{id}/unfollow', 'Api\ApiV1Controller@unfollowHashtag')->middleware($middleware); + Route::get('tags/{id}', 'Api\ApiV1Controller@getHashtag')->middleware($middleware); }); Route::group(['prefix' => 'v2'], function() use($middleware) { diff --git a/routes/web.php b/routes/web.php index 6f5d1fc33..73bc7c24c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -407,6 +407,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('web/username/{id}', 'SpaController@usernameRedirect'); Route::get('web/post/{id}', 'SpaController@webPost'); Route::get('web/profile/{id}', 'SpaController@webProfile'); + Route::get('web/{q}', 'SpaController@index')->where('q', '.*'); Route::get('web', 'SpaController@index'); });