diff --git a/app/Http/Controllers/Forum/ForumsController.php b/app/Http/Controllers/Forum/ForumsController.php index 85e0d903737..6f882d11072 100644 --- a/app/Http/Controllers/Forum/ForumsController.php +++ b/app/Http/Controllers/Forum/ForumsController.php @@ -12,13 +12,43 @@ use App\Transformers\Forum\ForumCoverTransformer; use Auth; +/** + * @group Forum + */ class ForumsController extends Controller { public function __construct() { parent::__construct(); + + $this->middleware('require-scopes:public', ['only' => ['index', 'show']]); } + /** + * Get Forum Listing + * + * Get top-level forums, their subforums (max 2 deep), and their last topics. + * + * --- + * + * ### Response Format + * + * Field | Type | + * ----------- | ---------------------------- | + * forums | [Forum](#forum)[] | + * last_topics | [ForumTopic](#forum-topic)[] | + * + * @response { + * "forums": [ + * { "forum_id": 1, "...": "..." }, + * { "forum_id": 2, "...": "..." } + * ], + * "last_topics": [ + * { "id": 1, "...": "..." }, + * { "id": 2, "...": "..." } + * ] + * } + */ public function index() { $forums = Forum @@ -33,6 +63,13 @@ public function index() return priv_check('ForumView', $forum)->can(); }); + if (is_api_request()) { + return [ + 'forums' => json_collection($forums, 'Forum/Forum', ['subforums.subforums']), + 'last_topics' => json_collection($lastTopics, 'Forum/Topic'), + ]; + } + return ext_view('forum.forums.index', compact('forums', 'lastTopics')); } @@ -53,6 +90,40 @@ public function markAsRead() return ext_view('layout.ujs-reload', [], 'js'); } + /** + * Get Forum and Topics + * + * Get a forum by id, its pinned topics, recent topics, its subforums, and the subforums' last topics. + * + * --- + * + * ### Response Format + * + * Field | Type | + * ------------- | ---------------------------- | + * forum | [Forum](#forum) | + * topics | [ForumTopic](#forum-topic)[] | + * last_topics | [ForumTopic](#forum-topic)[] | + * pinned_topics | [ForumTopic](#forum-topic)[] | + * + * @urlParam forum integer required Id of the forum. Example: 1 + * + * @response { + * "forum": { "id": 1, "...": "..." }, + * "topics": [ + * { "id": 1, "...": "..." }, + * { "id": 2, "...": "..." }, + * ], + * "last_topics": [ + * { "id": 1, "...": "..." }, + * { "id": 2, "...": "..." }, + * ], + * "pinned_topics": [ + * { "id": 1, "...": "..." }, + * { "id": 2, "...": "..." }, + * ] + * } + */ public function show($id) { $params = get_params(request()->all(), null, [ @@ -70,11 +141,6 @@ public function show($id) priv_check('ForumView', $forum)->ensureCan(); - $cover = json_item( - $forum->cover()->firstOrNew([]), - new ForumCoverTransformer() - ); - $showDeleted = priv_check('ForumModerate', $forum)->can(); $pinnedTopics = $forum->topics() @@ -88,8 +154,20 @@ public function show($id) ->with('forum') ->normal() ->showDeleted($showDeleted) - ->recent(compact('sort', 'withReplies')) - ->paginate(); + ->recent(compact('sort', 'withReplies')); + + if (is_api_request()) { + $topics->limit(Topic::PER_PAGE); + + return [ + 'forum' => json_item($forum, 'Forum/Forum', ['subforums.subforums']), + 'topics' => json_collection($topics, 'Forum/Topic'), + 'last_topics' => json_collection($lastTopics, 'Forum/Topic'), + 'pinned_topics' => json_collection($pinnedTopics, 'Forum/Topic'), + ]; + } + + $topics = $topics->paginate(); $allTopics = array_merge($pinnedTopics->all(), $topics->all()); $topicReadStatus = TopicTrack::readStatus($user, $allTopics); @@ -103,6 +181,11 @@ public function show($id) ->get() ->keyBy('topic_id'); + $cover = json_item( + $forum->cover()->firstOrNew([]), + new ForumCoverTransformer() + ); + $noindex = !$forum->enable_indexing; set_opengraph($forum); diff --git a/app/Http/Controllers/Forum/TopicsController.php b/app/Http/Controllers/Forum/TopicsController.php index d8c4e34b628..7a6136734f5 100644 --- a/app/Http/Controllers/Forum/TopicsController.php +++ b/app/Http/Controllers/Forum/TopicsController.php @@ -50,7 +50,7 @@ public function __construct() 'store', ]]); - $this->middleware('require-scopes:public', ['only' => ['show']]); + $this->middleware('require-scopes:public', ['only' => ['index', 'show']]); $this->middleware('require-scopes:forum.write', ['only' => ['reply', 'store', 'update']]); } @@ -279,6 +279,55 @@ public function reply($id) } } + /** + * Get Topic Listing + * + * Get a sorted list of topics, optionally from a specific forum + * + * --- + * + * ### Response Format + * + * Field | Type | Notes + * ------------- | ----------------------------- | ----- + * topics | [ForumTopic](#forum-topic)[] | | + * cursor_string | [CursorString](#cursorstring) | | + * + * @usesCursor + * @queryParam forum_id Id of a specific forum to get topics from. No-example + * @queryParam sort Topic sorting option. Valid values are `new` (default) and `old`. Both sort by the topic's last post time. No-example + * @queryParam limit Maximum number of topics to be returned (50 at most and by default). No-example + * + * @response { + * "topics": [ + * { "id": 1, "...": "..." }, + * { "id": 2, "...": "..." } + * ], + * "cursor_string": "eyJoZWxsbyI6IndvcmxkIn0" + * } + */ + public function index() + { + $params = request()->all(); + $limit = \Number::clamp(get_int($params['limit'] ?? null) ?? Topic::PER_PAGE, 1, Topic::PER_PAGE); + $cursorHelper = Topic::makeDbCursorHelper($params['sort'] ?? null); + + $topics = Topic::cursorSort($cursorHelper, cursor_from_params($params)) + ->limit($limit); + + $forum_id = get_int($params['forum_id'] ?? null) ?? null; + if ($forum_id !== null) { + $topics->where('forum_id', $forum_id); + } + + [$topics, $hasMore] = $topics->getWithHasMore(); + + return [ + 'topics' => json_collection($topics, 'Forum/Topic'), + ...cursor_for_response($cursorHelper->next($topics, $hasMore)), + ]; + } + /** * Get Topic and Posts * diff --git a/app/Models/Forum/Topic.php b/app/Models/Forum/Topic.php index 2ad4fd54c62..7be2aabb99a 100644 --- a/app/Models/Forum/Topic.php +++ b/app/Models/Forum/Topic.php @@ -13,6 +13,7 @@ use App\Models\Beatmapset; use App\Models\Log; use App\Models\Notification; +use App\Models\Traits\WithDbCursorHelper; use App\Models\User; use App\Traits\Memoizes; use App\Traits\Validatable; @@ -78,8 +79,20 @@ class Topic extends Model implements AfterCommit use SoftDeletes { restore as private origRestore; } + use WithDbCursorHelper; const DEFAULT_SORT = 'new'; + const SORTS = [ + 'new' => [ + // type 'timestamp' because the values are stored as integer in the database + ['column' => 'topic_last_post_time', 'order' => 'DESC', 'type' => 'timestamp'], + ['column' => 'topic_last_post_id', 'order' => 'DESC'] + ], + 'old' => [ + ['column' => 'topic_last_post_time', 'order' => 'ASC', 'type' => 'timestamp'], + ['column' => 'topic_last_post_id', 'order' => 'ASC'] + ] + ]; const STATUS_LOCKED = 1; const STATUS_UNLOCKED = 0; diff --git a/app/Transformers/Forum/ForumTransformer.php b/app/Transformers/Forum/ForumTransformer.php new file mode 100644 index 00000000000..b5672457586 --- /dev/null +++ b/app/Transformers/Forum/ForumTransformer.php @@ -0,0 +1,34 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace App\Transformers\Forum; + +use App\Models\Forum\Forum; +use App\Transformers\TransformerAbstract; +use League\Fractal\Resource\ResourceInterface; + +class ForumTransformer extends TransformerAbstract +{ + protected array $availableIncludes = [ + 'subforums', + ]; + + public function transform(Forum $forum): array + { + return [ + 'id' => $forum->forum_id, + 'name' => $forum->forum_name, + 'description' => $forum->forum_desc, + ]; + } + + public function includeSubforums(Forum $forum): ResourceInterface + { + return $this->collection( + $forum->subforums, + new ForumTransformer() + ); + } +} diff --git a/app/helpers.php b/app/helpers.php index 5258929f7f3..8c197f9b3f4 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1608,6 +1608,8 @@ function get_param_value($input, $type) return get_arr($input, 'get_int'); case 'time': return parse_time_to_carbon($input); + case 'timestamp': + return parse_time_to_timestamp($input); default: return presence(get_string($input)); } @@ -1746,6 +1748,15 @@ function parse_time_to_carbon($value) } } +function parse_time_to_timestamp($value) +{ + $time = parse_time_to_carbon($value); + + if ($time instanceof Carbon\Carbon) { + return $time->timestamp; + } +} + function format_duration_for_display(int $seconds) { return floor($seconds / 60).':'.str_pad((string) ($seconds % 60), 2, '0', STR_PAD_LEFT); diff --git a/resources/views/docs/_structures/forum.md b/resources/views/docs/_structures/forum.md new file mode 100644 index 00000000000..4c9765cfbb4 --- /dev/null +++ b/resources/views/docs/_structures/forum.md @@ -0,0 +1,10 @@ +
+ +## Forum + +Field | Type | Notes +------------|---------------------------|------- +id | integer | +name | string | +description | string | +subforums | [Forum](#forum-object)[]? | Maximum 2 layers of subforums from the top-level Forum diff --git a/routes/web.php b/routes/web.php index c65e4547bc1..487702f87f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -478,9 +478,10 @@ Route::group(['as' => 'forum.', 'namespace' => 'Forum'], function () { Route::group(['prefix' => 'forums'], function () { Route::post('topics/{topic}/reply', 'TopicsController@reply')->name('topics.reply'); - Route::resource('topics', 'TopicsController', ['only' => ['show', 'store', 'update']]); + Route::resource('topics', 'TopicsController', ['only' => ['index', 'show', 'store', 'update']]); Route::resource('posts', 'PostsController', ['only' => ['update']]); }); + Route::resource('forums', 'ForumsController', ['only' => ['index', 'show']]); }); Route::resource('matches', 'MatchesController', ['only' => ['index', 'show']]); diff --git a/tests/api_routes.json b/tests/api_routes.json index 68fe32aac33..494a8df1127 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -751,6 +751,22 @@ "forum.write" ] }, + { + "uri": "api/v2/forums/topics", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\Forum\\TopicsController@index", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, { "uri": "api/v2/forums/topics/{topic}", "methods": [ @@ -799,6 +815,38 @@ "forum.write" ] }, + { + "uri": "api/v2/forums", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\Forum\\ForumsController@index", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, + { + "uri": "api/v2/forums/{forum}", + "methods": [ + "GET", + "HEAD" + ], + "controller": "App\\Http\\Controllers\\Forum\\ForumsController@show", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:public" + ], + "scopes": [ + "public" + ] + }, { "uri": "api/v2/matches", "methods": [