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\Models;
7
8use App\Exceptions\GitHubNotFoundException;
9use App\Libraries\Commentable;
10use App\Libraries\Markdown\OsuMarkdown;
11use App\Libraries\OsuWiki;
12use App\Traits\Memoizes;
13use Carbon\Carbon;
14use Exception;
15
16/**
17 * @property string $commentable_identifier
18 * @property Comment $comments
19 * @property \Carbon\Carbon|null $created_at
20 * @property string|null $hash
21 * @property int $id
22 * @property array|null $page
23 * @property \Carbon\Carbon|null $published_at
24 * @property string $slug
25 * @property string|null $tumblr_id
26 * @property \Carbon\Carbon|null $updated_at
27 * @property string|null $version
28 */
29class NewsPost extends Model implements Commentable, Wiki\WikiObject
30{
31 use Memoizes, Traits\CommentableDefaults, Traits\WithDbCursorHelper;
32
33 // in minutes
34 const CACHE_DURATION = 86400;
35 const VERSION = 3;
36 // should be higher than landing limit
37 const DASHBOARD_LIMIT = 8;
38 // also for number of large posts in user dashboard
39 const LANDING_LIMIT = 4;
40
41 const SORTS = [
42 'published_asc' => [
43 ['column' => 'published_at', 'order' => 'ASC', 'type' => 'time'],
44 ['column' => 'id', 'order' => 'ASC'],
45 ],
46 'published_desc' => [
47 ['column' => 'published_at', 'order' => 'DESC', 'type' => 'time'],
48 ['column' => 'id', 'order' => 'DESC'],
49 ],
50 ];
51
52 const DEFAULT_SORT = 'published_desc';
53
54 protected $casts = [
55 'page' => 'array',
56 'published_at' => 'datetime',
57 ];
58
59 public static function lookup($slug)
60 {
61 return static::firstOrNew(compact('slug'));
62 }
63
64 public static function pageVersion()
65 {
66 return static::VERSION.'.'.OsuMarkdown::VERSION;
67 }
68
69 public static function search($params)
70 {
71 $query = static::published();
72
73 $limit = \Number::clamp(get_int($params['limit'] ?? null) ?? 20, 1, 21);
74
75 $cursorHelper = static::makeDbCursorHelper();
76 $cursor = cursor_from_params($params);
77 $query->cursorSort($cursorHelper, $cursor);
78
79 $year = get_int($params['year'] ?? null);
80 $query->year($year);
81
82 $query->limit($limit);
83
84 return [
85 'cursorHelper' => $cursorHelper,
86 'query' => $query,
87 'params' => [
88 'limit' => $limit,
89 'sort' => $cursorHelper->getSortName(),
90 'year' => $year,
91 ],
92 ];
93 }
94
95 public static function syncAll()
96 {
97 $baseEntries = OsuWiki::getTree(null, false)['tree'];
98 foreach ($baseEntries as $entry) {
99 if ($entry['path'] === 'news') {
100 $rootHash = $entry['sha'];
101 break;
102 }
103 }
104 // Something is terribly wrong if $rootHash is unset.
105 $entries = OsuWiki::getTree($rootHash)['tree'];
106
107 $latestSlugs = [];
108
109 foreach ($entries as $entry) {
110 if (($entry['type'] ?? null) === 'blob' && substr($entry['path'], -3) === '.md') {
111 $trimStartPos = strpos($entry['path'], '/');
112 $slug = substr($entry['path'], $trimStartPos === false ? 0 : $trimStartPos + 1, -3);
113 $hash = $entry['sha'];
114
115 $latestSlugs[$slug] = $hash;
116 }
117 }
118
119 foreach (static::all() as $post) {
120 if (array_key_exists($post->slug, $latestSlugs)) {
121 if ($post->published_at === null || $latestSlugs[$post->slug] !== $post->hash) {
122 $post->sync(true);
123 }
124
125 unset($latestSlugs[$post->slug]);
126 } else {
127 $post->update(['published_at' => null]);
128 }
129 }
130
131 // prevent time-based expiration
132 // FIXME: should use its own column instead so we can tell whether or not it's actually updated
133 static::select()->update(['updated_at' => Carbon::now()]);
134
135 foreach (array_keys($latestSlugs) as $newSlug) {
136 try {
137 static::create(['slug' => $newSlug])->sync();
138 } catch (Exception $e) {
139 if (is_sql_unique_exception($e)) {
140 continue;
141 }
142
143 throw $e;
144 }
145 }
146 }
147
148 public function scopeDefault($query)
149 {
150 return $query->published()->orderBy('published_at', 'DESC');
151 }
152
153 public function scopePublished($query)
154 {
155 return $query->whereNotNull('published_at')
156 ->where('published_at', '<=', Carbon::now());
157 }
158
159 public function scopeYear($query, $year)
160 {
161 if ($year === null) {
162 return;
163 }
164
165 $baseStart = Carbon::create($year);
166 $currentDate = now();
167
168 // show extra months in first three months of current year
169 if ($currentDate->year === $baseStart->year && $currentDate->month < 4) {
170 $start = $currentDate->startOfYear()->subMonths(2);
171 }
172
173 $end = Carbon::create($year + 1);
174
175 return $query
176 ->where('published_at', '>=', $start ?? $baseStart)
177 ->where('published_at', '<', $end);
178 }
179
180 public function author()
181 {
182 if (!isset($this->page['header']['author']) && !isset($this->page['author'])) {
183 $authorLine = html_entity_decode_better(
184 array_last(
185 explode("\n", trim(
186 strip_tags($this->bodyHtml())
187 ))
188 )
189 );
190
191 if (preg_match('/^[—–][^—–]/', $authorLine) === false) {
192 $author = 'osu!news Team';
193 } else {
194 $author = mb_substr($authorLine, 1);
195 }
196
197 $this->update(['page' => array_merge($this->page, compact('author'))]);
198 }
199
200 return $this->page['author'];
201 }
202
203 public function commentLocked(): bool
204 {
205 return false;
206 }
207
208 public function commentableTitle()
209 {
210 return $this->title();
211 }
212
213 public function filename($perYearDirectory = true)
214 {
215 $slug = $this->slug;
216 $prefix = $perYearDirectory
217 ? substr($slug, 0, 4).'/'
218 : '';
219
220 return "{$prefix}{$slug}.md";
221 }
222
223 public function isVisible()
224 {
225 return $this->page !== null && $this->published_at !== null && $this->published_at->isPast();
226 }
227
228 public function needsSync()
229 {
230 return $this->page === null ||
231 $this->version !== static::pageVersion() ||
232 $this->updated_at < Carbon::now()->subMinutes(static::CACHE_DURATION);
233 }
234
235 public function notificationCover()
236 {
237 return $this->firstImage();
238 }
239
240 public function bodyHtml()
241 {
242 return $this->page['output'];
243 }
244
245 public function editUrl()
246 {
247 return 'https://github.com/'.OsuWiki::user().'/'.OsuWiki::repository().'/tree/'.OsuWiki::branch().'/news/'.$this->filename();
248 }
249
250 public function firstImage($absolute = false)
251 {
252 $url = $this->page['firstImage'];
253
254 if ($url === null) {
255 return;
256 }
257
258 if ($absolute && !is_http($url)) {
259 if ($url[0] === '/') {
260 $url = $GLOBALS['cfg']['app']['url'].$url;
261 } else {
262 $url = "{$this->url()}/{$url}";
263 }
264 }
265
266 return $url;
267 }
268
269 public function firstImageWith2x(): array
270 {
271 $url = presence($this->firstImage());
272
273 if ($url !== null) {
274 $origUrl = proxy_media_original_url($url);
275 $url2x = proxy_media(retinaify($origUrl));
276 }
277
278 return [
279 '1x' => $url,
280 '2x' => $url2x ?? null,
281 ];
282 }
283
284 public function newer()
285 {
286 return $this->memoize(__FUNCTION__, function () {
287 return static::default()->cursorSort('published_asc', $this)->first();
288 });
289 }
290
291 public function older()
292 {
293 return $this->memoize(__FUNCTION__, function () {
294 return static::default()->cursorSort('published_desc', $this)->first();
295 });
296 }
297
298 public function sync($force = false)
299 {
300 if (!$force && !$this->needsSync()) {
301 return $this;
302 }
303
304 $postMissingKey = "osu_wiki:not_found:{$this->slug}";
305
306 if (!$force && cache()->get($postMissingKey) !== null) {
307 return $this;
308 }
309
310 try {
311 try {
312 $file = new OsuWiki("news/{$this->filename()}");
313 } catch (GitHubNotFoundException $e) {
314 $file = new OsuWiki("news/{$this->filename(false)}");
315 }
316 } catch (GitHubNotFoundException $e) {
317 if ($this->exists) {
318 $this->update(['published_at' => null]);
319 } else {
320 cache()->put($postMissingKey, 1, 300);
321 }
322
323 return $this;
324 } catch (Exception $e) {
325 log_error($e);
326
327 return $this;
328 }
329
330 $rawPage = $file->content();
331
332 $this->page = (new OsuMarkdown(
333 'news',
334 osuExtensionConfig: ['relative_url_root' => route('news.show', $this->slug)]
335 ))->load($rawPage)->toArray();
336
337 $this->version = static::pageVersion();
338 $this->published_at = $this->pagePublishedAt();
339 $this->tumblr_id = $this->pageTumblrId();
340 $this->hash = $file->data['sha'];
341
342 $this->save();
343
344 return $this;
345 }
346
347 public function pagePublishedAt()
348 {
349 $date = $this->page['header']['date'] ?? null;
350
351 if ($date === null) {
352 $date = substr($this->slug, 0, 10);
353
354 if (preg_match('/^\d{4}-\d{2}-\d{2}/', $date) !== 1) {
355 $date = null;
356 }
357 }
358
359 if ($date !== null) {
360 return Carbon::parse($date);
361 }
362 }
363
364 public function pageTumblrId()
365 {
366 $tumblrUrl = $this->page['header']['tumblr_url'] ?? null;
367
368 if (present($tumblrUrl)) {
369 preg_match('#^.*/post/(?<id>[^/]+)/.*$#', $tumblrUrl, $matches);
370
371 return $matches['id'];
372 } else {
373 return;
374 }
375 }
376
377 public function previewText()
378 {
379 return first_paragraph($this->bodyHtml());
380 }
381
382 public function title()
383 {
384 return $this->page['header']['title'] ?? 'Title-less news post';
385 }
386
387 public function url()
388 {
389 return route('news.show', $this->slug);
390 }
391}