the browser-facing portion of osu!
at master 464 lines 13 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\Wiki; 7 8use App\Exceptions\GitHubNotFoundException; 9use App\Libraries\Elasticsearch\BoolQuery; 10use App\Libraries\Elasticsearch\Es; 11use App\Libraries\Elasticsearch\QueryHelper; 12use App\Libraries\Elasticsearch\Sort; 13use App\Libraries\LocaleMeta; 14use App\Libraries\Markdown\OsuMarkdown; 15use App\Libraries\OsuWiki; 16use App\Libraries\Search\BasicSearch; 17use App\Libraries\Wiki\MainPageRenderer; 18use App\Libraries\Wiki\MarkdownRenderer; 19use App\Models\Traits; 20use App\Traits\Memoizes; 21use Carbon\Carbon; 22use Ds\Set; 23use Exception; 24use Log; 25 26class Page implements WikiObject 27{ 28 use Memoizes, Traits\Es\WikiPageSearch; 29 30 const CACHE_DURATION = 5 * 60 * 60; 31 const VERSION = 9; 32 33 const TEMPLATES = [ 34 'markdown_page' => 'wiki.show', 35 'main_page' => 'wiki.main', 36 ]; 37 38 const RENDERERS = [ 39 'markdown_page' => MarkdownRenderer::class, 40 'main_page' => MainPageRenderer::class, 41 ]; 42 43 public $locale; 44 public $path; 45 public $requestedLocale; 46 47 private $defaultSubtitle; 48 private $defaultTitle; 49 private $source; 50 private $page; 51 private $parent = false; 52 53 public static function cleanupPath($path) 54 { 55 return strtolower(str_replace(['-', '/', '_'], ' ', $path)); 56 } 57 58 public static function fromEs($hit) 59 { 60 $source = $hit->source(); 61 $path = $source['path']; 62 $locale = $source['locale']; 63 64 if ($path === null || $locale === null) { 65 $pagePath = static::parsePagePath($hit['_id']); 66 67 $path = $pagePath['path']; 68 $locale = $pagePath['locale']; 69 } 70 71 $page = new static($path, $locale); 72 $page->setSource($source); 73 74 return $page; 75 } 76 77 public static function lookup($path, $locale, $requestedLocale = null) 78 { 79 $page = new static($path, $locale, $requestedLocale); 80 $page->esFetch(); 81 82 return $page; 83 } 84 85 public static function lookupForController($path, $locale) 86 { 87 $page = static::lookup($path, $locale)->sync(); 88 89 if (!$page->isVisible() && $page->isTranslation()) { 90 $page = static::lookup($path, $GLOBALS['cfg']['app']['fallback_locale'], $locale)->sync(); 91 } 92 93 return $page; 94 } 95 96 public static function parsePagePath($pagePath) 97 { 98 $matches = null; 99 preg_match('#^(?<path>.+)/(?<locale>[^/]+)\.md$#', $pagePath, $matches); 100 101 return [ 102 'path' => $matches['path'] ?? null, 103 'locale' => $matches['locale'] ?? null, 104 ]; 105 } 106 107 public static function searchPath($path, $locale) 108 { 109 $searchPath = static::cleanupPath($path); 110 111 $currentLocaleQuery = 112 ['constant_score' => [ 113 'boost' => 1000, 114 'filter' => [ 115 'match' => [ 116 'locale' => $locale ?? app()->getLocale(), 117 ], 118 ], 119 ]]; 120 121 $fallbackLocaleQuery = 122 ['constant_score' => [ 123 'filter' => [ 124 'match' => [ 125 'locale' => $GLOBALS['cfg']['app']['fallback_locale'], 126 ], 127 ], 128 ]]; 129 130 $query = (new BoolQuery()) 131 ->must(QueryHelper::queryString($searchPath, ['path_clean'], 'and')) 132 ->must(['exists' => ['field' => 'page']]) 133 ->should($currentLocaleQuery) 134 ->should($fallbackLocaleQuery) 135 ->shouldMatch(1); 136 137 $search = (new BasicSearch(static::esIndexName(), 'wiki_searchpath')) 138 ->source('path') 139 ->query($query); 140 141 $response = $search->response(); 142 143 if ($response->total() === 0) { 144 return; 145 } 146 147 foreach ($response as $hit) { 148 $resultPath = static::cleanupPath($hit->source('path')); 149 150 if ($resultPath === $searchPath) { 151 return $hit->source('path'); 152 } 153 } 154 } 155 156 public function __construct($path, $locale, $requestedLocale = null) 157 { 158 $this->path = OsuWiki::cleanPath($path); 159 $this->locale = $locale; 160 $this->requestedLocale = $requestedLocale ?? $locale; 161 162 $defaultTitles = explode('/', str_replace('_', ' ', $this->path)); 163 $this->defaultTitle = array_pop($defaultTitles); 164 $this->defaultSubtitle = array_pop($defaultTitles); 165 } 166 167 public function availableLocales(): Set 168 { 169 return $this->memoize(__FUNCTION__, function () { 170 $locales = new Set(); 171 172 if (!$this->isVisible()) { 173 return $locales; 174 } 175 176 $query = (new BoolQuery()) 177 ->must(['term' => ['path.keyword' => $this->path]]) 178 ->must(['exists' => ['field' => 'page']]); 179 $search = (new BasicSearch(static::esIndexName(), 'wiki_searchlocales')) 180 ->source('locale') 181 ->sort(new Sort('locale.keyword', 'asc')) 182 ->query($query); 183 $response = $search->response(); 184 185 foreach ($response->hits() as $hit) { 186 $locale = $hit['_source']['locale'] ?? null; 187 if (LocaleMeta::isValid($locale)) { 188 $locales[] = $locale; 189 } 190 } 191 $locales->sort(); 192 193 return $locales; 194 }); 195 } 196 197 public function editUrl() 198 { 199 return 'https://github.com/'.OsuWiki::user().'/'.OsuWiki::repository().'/tree/'.OsuWiki::branch().'/wiki/'.$this->pagePath(); 200 } 201 202 public function esDeleteDocument(array $options = []) 203 { 204 $this->log('delete document'); 205 206 return Es::getClient()->delete([ 207 'client' => ['ignore' => 404], 208 'id' => $this->pagePath(), 209 'index' => $options['index'] ?? static::esIndexName(), 210 ]); 211 } 212 213 public function esIndexDocument(array $options = []) 214 { 215 if ($this->page === null) { 216 $this->log('index document empty'); 217 } else { 218 $this->log('index document'); 219 } 220 221 return Es::getClient()->index([ 222 'body' => $this->source, 223 'id' => $this->pagePath(), 224 'index' => $options['index'] ?? static::esIndexName(), 225 ]); 226 } 227 228 public function esFetch() 229 { 230 $response = (new BasicSearch(static::esIndexName(), 'wiki_page_lookup')) 231 ->source(['markdown', 'page', 'page_text', 'indexed_at', 'version']) 232 ->query([ 233 'term' => [ 234 '_id' => $this->pagePath(), 235 ], 236 ]) 237 ->response(); 238 239 if ($response->total() > 0) { 240 $this->setSource($response[0]->source()); 241 } 242 } 243 244 public function get() 245 { 246 return $this->page; 247 } 248 249 public function getMarkdown() 250 { 251 return $this->source['markdown'] ?? null; 252 } 253 254 public function getTextPreview() 255 { 256 return html_excerpt($this->source['page_text']); 257 } 258 259 public function hasParent() 260 { 261 return $this->parent() !== null; 262 } 263 264 public function needsCleanup(): bool 265 { 266 return $this->page['header']['needs_cleanup'] ?? false; 267 } 268 269 public function parent() 270 { 271 if ($this->parent === false) { 272 $parentPath = $this->parentPath(); 273 274 if ($parentPath === null) { 275 $parent = null; 276 } else { 277 $parent = static::lookup($this->parentPath(), $this->requestedLocale); 278 279 if (!$parent->isVisible()) { 280 $parent = null; 281 } 282 } 283 284 $this->parent = $parent; 285 } 286 287 return $this->parent; 288 } 289 290 public function isLegalTranslation(): bool 291 { 292 return $this->isTranslation() 293 && ($this->page['header']['legal'] ?? false); 294 } 295 296 public function isOutdated(): bool 297 { 298 return $this->page['header']['outdated'] ?? false; 299 } 300 301 public function isOutdatedTranslation(): bool 302 { 303 return $this->isTranslation() 304 && ($this->page['header']['outdated_translation'] ?? false); 305 } 306 307 public function isStub(): bool 308 { 309 return $this->page['header']['stub'] ?? false; 310 } 311 312 public function isTranslation(): bool 313 { 314 return $this->locale !== $GLOBALS['cfg']['app']['fallback_locale']; 315 } 316 317 public function isVisible() 318 { 319 return $this->page !== null; 320 } 321 322 public function layout($layout = null) 323 { 324 $layout = presence($layout) 325 ?? presence($this->page['header']['layout'] ?? null) 326 ?? 'markdown_page'; 327 328 if (!array_key_exists($layout, static::RENDERERS)) { 329 throw new Exception('Invalid wiki page type'); 330 } 331 332 return $layout; 333 } 334 335 public function needsSync() 336 { 337 return $this->source === null 338 || Carbon::parse($this->source['indexed_at']) 339 ->addSeconds(static::CACHE_DURATION) 340 ->isPast() 341 || $this->source['version'] !== static::VERSION; 342 } 343 344 public function pagePath() 345 { 346 return $this->path.'/'.$this->locale.'.md'; 347 } 348 349 public function parentPath() 350 { 351 if (($pos = strrpos($this->path, '/')) !== false) { 352 return substr($this->path, 0, $pos); 353 } 354 } 355 356 public function setSource($source) 357 { 358 $this->source = $source; 359 $page = $source['page'] ?? null; 360 361 if ($page !== null) { 362 $this->page = json_decode($source['page'], true); 363 } 364 } 365 366 public function subtitle() 367 { 368 if ($this->page === null) { 369 return; 370 } 371 372 if ($this->parent() !== null) { 373 return $this->parent()->title(); 374 } 375 376 return presence($this->page['header']['subtitle'] ?? null) ?? $this->defaultSubtitle; 377 } 378 379 public function sync($force = false, $indexName = null) 380 { 381 if (!$force && !$this->needsSync()) { 382 return $this; 383 } 384 385 try { 386 $this->log('fetch'); 387 388 $content = OsuWiki::fetchContent('wiki/'.$this->pagePath()); 389 } catch (GitHubNotFoundException $e) { 390 $this->log('not found'); 391 } catch (Exception $e) { 392 // log and do nothing 393 log_error($e); 394 395 return $this; 396 } 397 398 $source = [ 399 'locale' => $this->locale, 400 'page' => null, 401 'page_text' => null, 402 'path' => $this->path, 403 'path_clean' => static::cleanupPath($this->path), 404 'tags' => [], 405 'title' => null, 406 'indexed_at' => json_time(now()), 407 'version' => static::VERSION, 408 ]; 409 410 if (isset($content)) { 411 $layout = OsuMarkdown::parseYamlHeader($content)['header']['layout'] ?? null; 412 $layout = $this->layout($layout); 413 $rendererClass = static::RENDERERS[$layout]; 414 $contentRenderer = (new $rendererClass($this, $content)); 415 416 $this->page = $contentRenderer->render(); 417 $pageIndex = $contentRenderer->renderIndexable(); 418 419 $source['markdown'] = $content; 420 $source['page'] = json_encode($this->page); 421 $source['page_text'] = $pageIndex; 422 $source['title'] = strip_tags($this->title()); 423 $source['tags'] = $this->tags(); 424 $source['layout'] = $layout; 425 } 426 427 $this->source = $source; 428 $this->esIndexDocument(['index' => $indexName]); 429 430 return $this; 431 } 432 433 public function tags() 434 { 435 return $this->page['header']['tags'] ?? []; 436 } 437 438 public function template() 439 { 440 return $this->page === null 441 ? static::TEMPLATES['markdown_page'] 442 : static::TEMPLATES[$this->layout()]; 443 } 444 445 public function title($withSubtitle = false) 446 { 447 if ($this->page === null) { 448 return osu_trans('wiki.show.missing_title'); 449 } 450 451 $title = presence($this->page['header']['title'] ?? null) ?? $this->defaultTitle; 452 453 if ($withSubtitle && present($this->subtitle())) { 454 $title = $this->subtitle().' / '.$title; 455 } 456 457 return $title; 458 } 459 460 private function log($action) 461 { 462 Log::info("wiki ({$action}): {$this->pagePath()}"); 463 } 464}