1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Http\Controllers;
7
8use App\Libraries\CommentBundle;
9use App\Models\NewsPost;
10
11/**
12 * @group News
13 */
14class NewsController extends Controller
15{
16 /**
17 * Get News Listing
18 *
19 * Returns a list of news posts and related metadata.
20 *
21 * ---
22 *
23 * ### Response Format
24 *
25 * Field | Type | Notes
26 * ------------------------- | ----------------------------- | -----
27 * cursor_string | [CursorString](#cursorstring) | |
28 * news_posts | [NewsPost](#newspost)[] | Includes `preview`.
29 * news_sidebar.current_year | integer | Year of the first post's publish time, or current year if no posts returned.
30 * news_sidebar.news_posts | [NewsPost](#newspost)[] | All posts published during `current_year`.
31 * news_sidebar.years | integer[] | All years during which posts have been published.
32 * search.limit | integer | Clamped limit input.
33 * search.sort | string | Always `published_desc`.
34 *
35 * <aside class="notice">
36 * <a href="#newspost">NewsPost</a> collections queried by year will also include posts published in November and December of the previous year if the current date is the same year and before April.
37 * </aside>
38 *
39 * @usesCursor
40 * @queryParam limit integer Maximum number of posts (12 default, 1 minimum, 21 maximum). No-example
41 * @queryParam year integer Year to return posts from. No-example
42 * @response {
43 * "news_posts": [
44 * {
45 * "id": 964,
46 * "author": "RockRoller",
47 * "edit_url": "https://github.com/ppy/osu-wiki/tree/master/news/2021-05-27-skinning-contest-results.md",
48 * "first_image": "https://i.ppy.sh/d431ff921955d5c8792dc9bae40ac082d4e53131/68747470733a2f2f6f73752e7070792e73682f77696b692f696d616765732f7368617265642f6e6577732f323032312d30352d32372d736b696e6e696e672d636f6e746573742d726573756c74732f736b696e6e696e675f636f6e746573745f62616e6e65722e6a7067",
49 * "published_at": "2021-05-27T12:00:00+00:00",
50 * "updated_at": "2021-05-28T17:11:35+00:00",
51 * "slug": "2021-05-27-skinning-contest-results",
52 * "title": "Skinning Contest: Results Out",
53 * "preview": "The ship full of skins is now back with your votes. Check out the results for our first-ever official skinning contest right here!"
54 * },
55 * // ...
56 * ],
57 * "news_sidebar": {
58 * "current_year": 2021,
59 * "news_posts": [
60 * {
61 * "id": 964,
62 * "author": "RockRoller",
63 * "edit_url": "https://github.com/ppy/osu-wiki/tree/master/news/2021-05-27-skinning-contest-results.md",
64 * "first_image": "https://i.ppy.sh/d431ff921955d5c8792dc9bae40ac082d4e53131/68747470733a2f2f6f73752e7070792e73682f77696b692f696d616765732f7368617265642f6e6577732f323032312d30352d32372d736b696e6e696e672d636f6e746573742d726573756c74732f736b696e6e696e675f636f6e746573745f62616e6e65722e6a7067",
65 * "published_at": "2021-05-27T12:00:00+00:00",
66 * "updated_at": "2021-05-28T17:11:35+00:00",
67 * "slug": "2021-05-27-skinning-contest-results",
68 * "title": "Skinning Contest: Results Out"
69 * },
70 * // ...
71 * ],
72 * "years": [2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013]
73 * },
74 * "search": {
75 * "limit": 12,
76 * "sort": "published_desc"
77 * },
78 * "cursor_string": "WyJodHRwczpcL1wvd3d3LnlvdXR1YmUuY29tXC93YXRjaD92PWRRdzR3OVdnWGNRIl0"
79 * }
80 */
81 public function index()
82 {
83 $params = request()->all();
84 $format = $params['format'] ?? null;
85 $isFeed = $format === 'atom' || $format === 'rss';
86 $limit = $isFeed ? 20 : 12;
87
88 $search = NewsPost::search(array_merge(compact('limit'), $params));
89
90 [$posts, $hasMore] = $search['query']->getWithHasMore();
91
92 if ($isFeed) {
93 return ext_view("news.index-{$format}", compact('posts'), $format);
94 }
95
96 $postsJson = [
97 'news_posts' => json_collection($posts, 'NewsPost', ['preview']),
98 'news_sidebar' => $this->sidebarMeta($posts[0] ?? null),
99 'search' => $search['params'],
100 ...cursor_for_response($search['cursorHelper']->next($posts, $hasMore)),
101 ];
102
103 if (is_json_request()) {
104 return $postsJson;
105 } else {
106 // force current year search parameter for html
107 $postsJson['search']['year'] = $postsJson['news_sidebar']['current_year'];
108
109 return ext_view('news.index', [
110 'atom' => [
111 'url' => route('news.index', ['format' => 'atom']),
112 'title' => 'osu!news Feed',
113 ],
114 'postsJson' => $postsJson,
115 ]);
116 }
117 }
118
119 public function redirect($tumblrId)
120 {
121 $post = NewsPost::where('tumblr_id', $tumblrId)->firstOrFail();
122
123 return ujs_redirect(route('news.show', $post->slug));
124 }
125
126 /**
127 * Get News Post
128 *
129 * Returns details of the specified news post.
130 *
131 * ---
132 *
133 * ### Response Format
134 *
135 * Returns a [NewsPost](#newspost) with `content` and `navigation` included.
136 *
137 * @urlParam news string required News post slug or ID. Example: 2021-04-27-results-a-labour-of-love
138 * @queryParam key string Unset to query by slug, or `id` to query by ID. No-example
139 * @response {
140 * "id": 943,
141 * "author": "pishifat",
142 * "edit_url": "https://github.com/ppy/osu-wiki/tree/master/news/2021-04-27-results-a-labour-of-love.md",
143 * "first_image": "https://i.ppy.sh/65c9c2eb2f8d9bc6008b95aba7d0ef45e1414c1e/68747470733a2f2f6f73752e7070792e73682f77696b692f696d616765732f7368617265642f6e6577732f323032302d31312d33302d612d6c61626f75722d6f662d6c6f76652f616c6f6c5f636f7665722e6a7067",
144 * "published_at": "2021-04-27T20:00:00+00:00",
145 * "updated_at": "2021-04-27T20:25:57+00:00",
146 * "slug": "2021-04-27-results-a-labour-of-love",
147 * "title": "Results - A Labour of Love",
148 * "content": "<div class='osu-md osu-md--news'>...</div>",
149 * "navigation": {
150 * "newer": {
151 * "id": 944,
152 * "author": "pishifat",
153 * "edit_url": "https://github.com/ppy/osu-wiki/tree/master/news/2021-04-28-new-featured-artist-emilles-moonlight-serenade.md",
154 * "first_image": "https://i.ppy.sh/7e22cc5f4755c21574d999d8ce3a2f40a3268e84/68747470733a2f2f6173736574732e7070792e73682f617274697374732f3136302f6865616465722e6a7067",
155 * "published_at": "2021-04-28T08:00:00+00:00",
156 * "updated_at": "2021-04-28T09:51:28+00:00",
157 * "slug": "2021-04-28-new-featured-artist-emilles-moonlight-serenade",
158 * "title": "New Featured Artist: Emille's Moonlight Serenade"
159 * },
160 * "older": {
161 * "id": 942,
162 * "author": "pishifat",
163 * "edit_url": "https://github.com/ppy/osu-wiki/tree/master/news/2021-04-24-new-featured-artist-grynpyret.md",
164 * "first_image": "https://i.ppy.sh/acdce813b71371b95e8240f9249c916285fdc5a0/68747470733a2f2f6173736574732e7070792e73682f617274697374732f3135392f6865616465722e6a7067",
165 * "published_at": "2021-04-24T08:00:00+00:00",
166 * "updated_at": "2021-04-24T10:23:59+00:00",
167 * "slug": "2021-04-24-new-featured-artist-grynpyret",
168 * "title": "New Featured Artist: Grynpyret"
169 * }
170 * }
171 * }
172 */
173 public function show($slug)
174 {
175 if (request('key') === 'id') {
176 $post = NewsPost::findOrFail($slug);
177
178 if (!is_api_request()) {
179 return ujs_redirect(route('news.show', $post->slug));
180 }
181 } else {
182 $post = NewsPost::lookup($slug);
183 }
184
185 $post->sync();
186
187 if (!$post->isVisible()) {
188 abort(404);
189 }
190
191 $postJson = json_item($post, 'NewsPost', ['content', 'navigation']);
192
193 if (is_json_request()) {
194 return $postJson;
195 }
196
197 set_opengraph($post);
198
199 return ext_view('news.show', [
200 'commentBundle' => CommentBundle::forEmbed($post),
201 'post' => $post,
202 'postJson' => $postJson,
203 'sidebarMeta' => $this->sidebarMeta($post),
204 ]);
205 }
206
207 public function store()
208 {
209 priv_check('NewsIndexUpdate')->ensureCan();
210
211 NewsPost::syncAll();
212
213 return ['message' => osu_trans('news.store.ok')];
214 }
215
216 public function update($id)
217 {
218 priv_check('NewsPostUpdate')->ensureCan();
219
220 NewsPost::findOrFail($id)->sync(true);
221
222 return ['message' => osu_trans('news.update.ok')];
223 }
224
225 private function sidebarMeta($post)
226 {
227 if ($post !== null && $post->published_at !== null) {
228 $currentYear = $post->published_at->year;
229 }
230
231 $currentYear = $currentYear ?? date('Y');
232 $latestPost = NewsPost::select('updated_at')->default()->first();
233 $lastUpdate = $latestPost === null ? 0 : $latestPost->updated_at->timestamp;
234
235 return cache_remember_with_fallback(
236 "news_sidebar_meta_{$currentYear}_{$lastUpdate}",
237 3600,
238 function () use ($currentYear) {
239 $years = NewsPost::selectRaw('DISTINCT YEAR(published_at) year')
240 ->whereNotNull('published_at')
241 ->orderBy('year', 'DESC')
242 ->pluck('year')
243 ->toArray();
244
245 $posts = NewsPost::default()->year($currentYear)->get();
246
247 return [
248 'current_year' => $currentYear,
249 'news_posts' => json_collection($posts, 'NewsPost'),
250 'years' => $years,
251 ];
252 }
253 );
254 }
255}