the browser-facing portion of osu!
at master 391 lines 11 kB view raw
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}