MediaWiki REL1_34
PageArchive.php
Go to the documentation of this file.
1<?php
24use Wikimedia\Assert\Assert;
27
33 protected $title;
34
36 protected $fileStatus;
37
39 protected $revisionStatus;
40
42 protected $config;
43
44 public function __construct( $title, Config $config = null ) {
45 if ( is_null( $title ) ) {
46 throw new MWException( __METHOD__ . ' given a null title.' );
47 }
48 $this->title = $title;
49 if ( $config === null ) {
50 wfDebug( __METHOD__ . ' did not have a Config object passed to it' );
51 $config = MediaWikiServices::getInstance()->getMainConfig();
52 }
53 $this->config = $config;
54 }
55
59 private function getRevisionStore() {
60 // TODO: Refactor: delete()/undelete() should live in a PageStore service;
61 // Methods in PageArchive and RevisionStore that deal with archive revisions
62 // should move into an ArchiveStore service (but could still be implemented
63 // together with RevisionStore).
64 return MediaWikiServices::getInstance()->getRevisionStore();
65 }
66
67 public function doesWrites() {
68 return true;
69 }
70
79 public static function listPagesBySearch( $term ) {
80 $title = Title::newFromText( $term );
81 if ( $title ) {
82 $ns = $title->getNamespace();
83 $termMain = $title->getText();
84 $termDb = $title->getDBkey();
85 } else {
86 // Prolly won't work too good
87 // @todo handle bare namespace names cleanly?
88 $ns = 0;
89 $termMain = $termDb = $term;
90 }
91
92 // Try search engine first
93 $engine = MediaWikiServices::getInstance()->newSearchEngine();
94 $engine->setLimitOffset( 100 );
95 $engine->setNamespaces( [ $ns ] );
96 $results = $engine->searchArchiveTitle( $termMain );
97 if ( !$results->isOK() ) {
98 $results = [];
99 } else {
100 $results = $results->getValue();
101 }
102
103 if ( !$results ) {
104 // Fall back to regular prefix search
105 return self::listPagesByPrefix( $term );
106 }
107
109 $condTitles = array_unique( array_map( function ( Title $t ) {
110 return $t->getDBkey();
111 }, $results ) );
112 $conds = [
113 'ar_namespace' => $ns,
114 $dbr->makeList( [ 'ar_title' => $condTitles ], LIST_OR ) . " OR ar_title " .
115 $dbr->buildLike( $termDb, $dbr->anyString() )
116 ];
117
118 return self::listPages( $dbr, $conds );
119 }
120
129 public static function listPagesByPrefix( $prefix ) {
131
132 $title = Title::newFromText( $prefix );
133 if ( $title ) {
134 $ns = $title->getNamespace();
135 $prefix = $title->getDBkey();
136 } else {
137 // Prolly won't work too good
138 // @todo handle bare namespace names cleanly?
139 $ns = 0;
140 }
141
142 $conds = [
143 'ar_namespace' => $ns,
144 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
145 ];
146
147 return self::listPages( $dbr, $conds );
148 }
149
155 protected static function listPages( $dbr, $condition ) {
156 return $dbr->select(
157 [ 'archive' ],
158 [
159 'ar_namespace',
160 'ar_title',
161 'count' => 'COUNT(*)'
162 ],
163 $condition,
164 __METHOD__,
165 [
166 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
167 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
168 'LIMIT' => 100,
169 ]
170 );
171 }
172
179 public function listRevisions() {
180 $revisionStore = $this->getRevisionStore();
181 $queryInfo = $revisionStore->getArchiveQueryInfo();
182
183 $conds = [
184 'ar_namespace' => $this->title->getNamespace(),
185 'ar_title' => $this->title->getDBkey(),
186 ];
187
188 // NOTE: ordering by ar_timestamp and ar_id, to remove ambiguity.
189 // XXX: Ideally, we would be ordering by ar_timestamp and ar_rev_id, but since we
190 // don't have an index on ar_rev_id, that causes a file sort.
191 $options = [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ];
192
194 $queryInfo['tables'],
195 $queryInfo['fields'],
196 $conds,
197 $queryInfo['joins'],
198 $options,
199 ''
200 );
201
203 return $dbr->select(
204 $queryInfo['tables'],
205 $queryInfo['fields'],
206 $conds,
207 __METHOD__,
208 $options,
209 $queryInfo['joins']
210 );
211 }
212
221 public function listFiles() {
222 if ( $this->title->getNamespace() != NS_FILE ) {
223 return null;
224 }
225
227 $fileQuery = ArchivedFile::getQueryInfo();
228 return $dbr->select(
229 $fileQuery['tables'],
230 $fileQuery['fields'],
231 [ 'fa_name' => $this->title->getDBkey() ],
232 __METHOD__,
233 [ 'ORDER BY' => 'fa_timestamp DESC' ],
234 $fileQuery['joins']
235 );
236 }
237
246 public function getRevision( $timestamp ) {
248 $rec = $this->getRevisionByConditions(
249 [ 'ar_timestamp' => $dbr->timestamp( $timestamp ) ]
250 );
251 return $rec ? new Revision( $rec ) : null;
252 }
253
260 public function getArchivedRevision( $revId ) {
261 // Protect against code switching from getRevision() passing in a timestamp.
262 Assert::parameterType( 'integer', $revId, '$revId' );
263
264 $rec = $this->getRevisionByConditions( [ 'ar_rev_id' => $revId ] );
265 return $rec ? new Revision( $rec ) : null;
266 }
267
274 private function getRevisionByConditions( array $conditions, array $options = [] ) {
276 $arQuery = $this->getRevisionStore()->getArchiveQueryInfo();
277
278 $conditions = $conditions + [
279 'ar_namespace' => $this->title->getNamespace(),
280 'ar_title' => $this->title->getDBkey(),
281 ];
282
283 $row = $dbr->selectRow(
284 $arQuery['tables'],
285 $arQuery['fields'],
286 $conditions,
287 __METHOD__,
288 $options,
289 $arQuery['joins']
290 );
291
292 if ( $row ) {
293 return $this->getRevisionStore()->newRevisionFromArchiveRow( $row, 0, $this->title );
294 }
295
296 return null;
297 }
298
309 public function getPreviousRevision( $timestamp ) {
311
312 // Check the previous deleted revision...
313 $row = $dbr->selectRow( 'archive',
314 [ 'ar_rev_id', 'ar_timestamp' ],
315 [ 'ar_namespace' => $this->title->getNamespace(),
316 'ar_title' => $this->title->getDBkey(),
317 'ar_timestamp < ' .
318 $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
319 __METHOD__,
320 [
321 'ORDER BY' => 'ar_timestamp DESC',
322 'LIMIT' => 1 ] );
323 $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
324 $prevDeletedId = $row ? intval( $row->ar_rev_id ) : null;
325
326 $row = $dbr->selectRow( [ 'page', 'revision' ],
327 [ 'rev_id', 'rev_timestamp' ],
328 [
329 'page_namespace' => $this->title->getNamespace(),
330 'page_title' => $this->title->getDBkey(),
331 'page_id = rev_page',
332 'rev_timestamp < ' .
333 $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
334 __METHOD__,
335 [
336 'ORDER BY' => 'rev_timestamp DESC',
337 'LIMIT' => 1 ] );
338 $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
339 $prevLiveId = $row ? intval( $row->rev_id ) : null;
340
341 if ( $prevLive && $prevLive > $prevDeleted ) {
342 // Most prior revision was live
343 $rec = $this->getRevisionStore()->getRevisionById( $prevLiveId );
344 $rec = $rec ? new Revision( $rec ) : null;
345 } elseif ( $prevDeleted ) {
346 // Most prior revision was deleted
347 $rec = $this->getArchivedRevision( $prevDeletedId );
348 } else {
349 $rec = null;
350 }
351
352 return $rec;
353 }
354
360 public function getLastRevisionId() {
362 $revId = $dbr->selectField(
363 'archive',
364 'ar_rev_id',
365 [ 'ar_namespace' => $this->title->getNamespace(),
366 'ar_title' => $this->title->getDBkey() ],
367 __METHOD__,
368 [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ]
369 );
370
371 return $revId ? intval( $revId ) : false;
372 }
373
380 public function isDeleted() {
382 $row = $dbr->selectRow(
383 [ 'archive' ],
384 '1', // We don't care about the value. Allow the database to optimize.
385 [ 'ar_namespace' => $this->title->getNamespace(),
386 'ar_title' => $this->title->getDBkey() ],
387 __METHOD__
388 );
389
390 return (bool)$row;
391 }
392
412 public function undelete( $timestamps, $comment = '', $fileVersions = [],
413 $unsuppress = false, User $user = null, $tags = null
414 ) {
415 // If both the set of text revisions and file revisions are empty,
416 // restore everything. Otherwise, just restore the requested items.
417 $restoreAll = empty( $timestamps ) && empty( $fileVersions );
418
419 $restoreText = $restoreAll || !empty( $timestamps );
420 $restoreFiles = $restoreAll || !empty( $fileVersions );
421
422 if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) {
424 $img = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
425 ->newFile( $this->title );
426 $img->load( File::READ_LATEST );
427 $this->fileStatus = $img->restore( $fileVersions, $unsuppress );
428 if ( !$this->fileStatus->isOK() ) {
429 return false;
430 }
431 $filesRestored = $this->fileStatus->successCount;
432 } else {
433 $filesRestored = 0;
434 }
435
436 if ( $restoreText ) {
437 $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
438 if ( !$this->revisionStatus->isOK() ) {
439 return false;
440 }
441
442 $textRestored = $this->revisionStatus->getValue();
443 } else {
444 $textRestored = 0;
445 }
446
447 // Touch the log!
448
449 if ( !$textRestored && !$filesRestored ) {
450 wfDebug( "Undelete: nothing undeleted...\n" );
451
452 return false;
453 }
454
455 if ( $user === null ) {
456 global $wgUser;
457 $user = $wgUser;
458 }
459
460 $logEntry = new ManualLogEntry( 'delete', 'restore' );
461 $logEntry->setPerformer( $user );
462 $logEntry->setTarget( $this->title );
463 $logEntry->setComment( $comment );
464 $logEntry->addTags( $tags );
465 $logEntry->setParameters( [
466 ':assoc:count' => [
467 'revisions' => $textRestored,
468 'files' => $filesRestored,
469 ],
470 ] );
471
472 Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
473
474 $logid = $logEntry->insert();
475 $logEntry->publish( $logid );
476
477 return [ $textRestored, $filesRestored, $comment ];
478 }
479
491 private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
492 if ( wfReadOnly() ) {
493 throw new ReadOnlyError();
494 }
495
496 $dbw = wfGetDB( DB_MASTER );
497 $dbw->startAtomic( __METHOD__ );
498
499 $restoreAll = empty( $timestamps );
500
501 # Does this page already exist? We'll have to update it...
502 $article = WikiPage::factory( $this->title );
503 # Load latest data for the current page (T33179)
504 $article->loadPageData( 'fromdbmaster' );
505 $oldcountable = $article->isCountable();
506
507 $page = $dbw->selectRow( 'page',
508 [ 'page_id', 'page_latest' ],
509 [ 'page_namespace' => $this->title->getNamespace(),
510 'page_title' => $this->title->getDBkey() ],
511 __METHOD__,
512 [ 'FOR UPDATE' ] // lock page
513 );
514
515 if ( $page ) {
516 $makepage = false;
517 # Page already exists. Import the history, and if necessary
518 # we'll update the latest revision field in the record.
519
520 # Get the time span of this page
521 $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
522 [ 'rev_id' => $page->page_latest ],
523 __METHOD__ );
524
525 if ( $previousTimestamp === false ) {
526 wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" );
527
528 $status = Status::newGood( 0 );
529 $status->warning( 'undeleterevision-missing' );
530 $dbw->endAtomic( __METHOD__ );
531
532 return $status;
533 }
534 } else {
535 # Have to create a new article...
536 $makepage = true;
537 $previousTimestamp = 0;
538 }
539
540 $oldWhere = [
541 'ar_namespace' => $this->title->getNamespace(),
542 'ar_title' => $this->title->getDBkey(),
543 ];
544 if ( !$restoreAll ) {
545 $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
546 }
547
548 $revisionStore = $this->getRevisionStore();
549 $queryInfo = $revisionStore->getArchiveQueryInfo();
550 $queryInfo['tables'][] = 'revision';
551 $queryInfo['fields'][] = 'rev_id';
552 $queryInfo['joins']['revision'] = [ 'LEFT JOIN', 'ar_rev_id=rev_id' ];
553
557 $result = $dbw->select(
558 $queryInfo['tables'],
559 $queryInfo['fields'],
560 $oldWhere,
561 __METHOD__,
562 /* options */
563 [ 'ORDER BY' => 'ar_timestamp' ],
564 $queryInfo['joins']
565 );
566
567 $rev_count = $result->numRows();
568 if ( !$rev_count ) {
569 wfDebug( __METHOD__ . ": no revisions to restore\n" );
570
571 $status = Status::newGood( 0 );
572 $status->warning( "undelete-no-results" );
573 $dbw->endAtomic( __METHOD__ );
574
575 return $status;
576 }
577
578 // We use ar_id because there can be duplicate ar_rev_id even for the same
579 // page. In this case, we may be able to restore the first one.
580 $restoreFailedArIds = [];
581
582 // Map rev_id to the ar_id that is allowed to use it. When checking later,
583 // if it doesn't match, the current ar_id can not be restored.
584
585 // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
586 // rev_id is taken before we even start the restore).
587 $allowedRevIdToArIdMap = [];
588
589 $latestRestorableRow = null;
590
591 foreach ( $result as $row ) {
592 if ( $row->ar_rev_id ) {
593 // rev_id is taken even before we start restoring.
594 if ( $row->ar_rev_id === $row->rev_id ) {
595 $restoreFailedArIds[] = $row->ar_id;
596 $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
597 } else {
598 // rev_id is not taken yet in the DB, but it might be taken
599 // by a prior revision in the same restore operation. If
600 // not, we need to reserve it.
601 if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
602 $restoreFailedArIds[] = $row->ar_id;
603 } else {
604 $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
605 $latestRestorableRow = $row;
606 }
607 }
608 } else {
609 // If ar_rev_id is null, there can't be a collision, and a
610 // rev_id will be chosen automatically.
611 $latestRestorableRow = $row;
612 }
613 }
614
615 $result->seek( 0 ); // move back
616
617 $oldPageId = 0;
618 if ( $latestRestorableRow !== null ) {
619 $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
620
621 // Grab the content to check consistency with global state before restoring the page.
622 // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
623 // certain things across all pages. There may be a better way to do that.
624 $revision = $revisionStore->newRevisionFromArchiveRow(
625 $latestRestorableRow,
626 0,
627 $this->title
628 );
629
630 // TODO: use User::newFromUserIdentity from If610c68f4912e
631 // TODO: The User isn't used for anything in prepareSave()! We should drop it.
632 $user = User::newFromName( $revision->getUser( RevisionRecord::RAW )->getName(), false );
633
634 foreach ( $revision->getSlotRoles() as $role ) {
635 $content = $revision->getContent( $role, RevisionRecord::RAW );
636
637 // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
638 $status = $content->prepareSave( $article, 0, -1, $user );
639 if ( !$status->isOK() ) {
640 $dbw->endAtomic( __METHOD__ );
641
642 return $status;
643 }
644 }
645 }
646
647 $newid = false; // newly created page ID
648 $restored = 0; // number of revisions restored
650 $revision = null;
651 $restoredPages = [];
652 // If there are no restorable revisions, we can skip most of the steps.
653 if ( $latestRestorableRow === null ) {
654 $failedRevisionCount = $rev_count;
655 } else {
656 if ( $makepage ) {
657 // Check the state of the newest to-be version...
658 if ( !$unsuppress
659 && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
660 ) {
661 $dbw->endAtomic( __METHOD__ );
662
663 return Status::newFatal( "undeleterevdel" );
664 }
665 // Safe to insert now...
666 $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id );
667 if ( $newid === false ) {
668 // The old ID is reserved; let's pick another
669 $newid = $article->insertOn( $dbw );
670 }
671 $pageId = $newid;
672 } else {
673 // Check if a deleted revision will become the current revision...
674 if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
675 // Check the state of the newest to-be version...
676 if ( !$unsuppress
677 && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
678 ) {
679 $dbw->endAtomic( __METHOD__ );
680
681 return Status::newFatal( "undeleterevdel" );
682 }
683 }
684
685 $newid = false;
686 $pageId = $article->getId();
687 }
688
689 foreach ( $result as $row ) {
690 // Check for key dupes due to needed archive integrity.
691 if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
692 continue;
693 }
694 // Insert one revision at a time...maintaining deletion status
695 // unless we are specifically removing all restrictions...
696 $revision = $revisionStore->newRevisionFromArchiveRow(
697 $row,
698 0,
699 $this->title,
700 [
701 'page_id' => $pageId,
702 'deleted' => $unsuppress ? 0 : $row->ar_deleted
703 ]
704 );
705
706 // This will also copy the revision to ip_changes if it was an IP edit.
707 $revisionStore->insertRevisionOn( $revision, $dbw );
708
709 $restored++;
710
711 $legacyRevision = new Revision( $revision );
712 Hooks::run( 'ArticleRevisionUndeleted',
713 [ &$this->title, $legacyRevision, $row->ar_page_id ] );
714 $restoredPages[$row->ar_page_id] = true;
715 }
716
717 // Now that it's safely stored, take it out of the archive
718 // Don't delete rows that we failed to restore
719 $toDeleteConds = $oldWhere;
720 $failedRevisionCount = count( $restoreFailedArIds );
721 if ( $failedRevisionCount > 0 ) {
722 $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
723 }
724
725 $dbw->delete( 'archive',
726 $toDeleteConds,
727 __METHOD__ );
728 }
729
730 $status = Status::newGood( $restored );
731
732 if ( $failedRevisionCount > 0 ) {
733 $status->warning(
734 wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
735 }
736
737 // Was anything restored at all?
738 if ( $restored ) {
739 $created = (bool)$newid;
740 // Attach the latest revision to the page...
741 // XXX: updateRevisionOn should probably move into a PageStore service.
742 $wasnew = $article->updateIfNewerOn( $dbw, $legacyRevision );
743 if ( $created || $wasnew ) {
744 // Update site stats, link tables, etc
745 // TODO: use DerivedPageDataUpdater from If610c68f4912e!
746 $article->doEditUpdates(
747 $legacyRevision,
748 User::newFromName( $revision->getUser( RevisionRecord::RAW )->getName(), false ),
749 [
750 'created' => $created,
751 'oldcountable' => $oldcountable,
752 'restored' => true
753 ]
754 );
755 }
756
757 Hooks::run( 'ArticleUndelete',
758 [ &$this->title, $created, $comment, $oldPageId, $restoredPages ] );
759
760 if ( $this->title->getNamespace() == NS_FILE ) {
762 $this->title,
763 'imagelinks',
764 [ 'causeAction' => 'file-restore' ]
765 );
766 JobQueueGroup::singleton()->lazyPush( $job );
767 }
768 }
769
770 $dbw->endAtomic( __METHOD__ );
771
772 return $status;
773 }
774
778 public function getFileStatus() {
779 return $this->fileStatus;
780 }
781
785 public function getRevisionStatus() {
787 }
788}
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfReadOnly()
Check whether the wiki is in read-only mode.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new archivedfile object.
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='')
Applies all tags-related changes to a query.
static newForBacklinks(Title $title, $table, $params=[])
MediaWiki exception.
Class for creating new log entries and inserting them into the database.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Page revision base class.
Service for looking up page revisions.
Used to show archived pages and eventually restore them.
listFiles()
List the deleted file revisions for this page, if it's a file page.
Config $config
Status $fileStatus
static listPages( $dbr, $condition)
static listPagesBySearch( $term)
List deleted pages recorded in the archive matching the given term, using search engine archive.
getLastRevisionId()
Returns the ID of the latest deleted revision.
getArchivedRevision( $revId)
Return the archived revision with the given ID.
listRevisions()
List the revisions of the given page.
undeleteRevisions( $timestamps, $unsuppress=false, $comment='')
This is the meaty bit – It restores archived revisions of the given page to the revision table.
undelete( $timestamps, $comment='', $fileVersions=[], $unsuppress=false, User $user=null, $tags=null)
Restore the given (or all) text and file revisions for the page.
static listPagesByPrefix( $prefix)
List deleted pages recorded in the archive table matching the given title prefix.
getRevision( $timestamp)
Return a Revision object containing data for the deleted revision.
Status $revisionStatus
__construct( $title, Config $config=null)
isDeleted()
Quick check if any archived revisions are present for the page.
getPreviousRevision( $timestamp)
Return the most-previous revision, either live or deleted, against the deleted revision given by time...
getRevisionByConditions(array $conditions, array $options=[])
Show an error when the wiki is locked/read-only and the user tries to do something that requires writ...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:40
Represents a title within MediaWiki.
Definition Title.php:42
getNamespace()
Get the namespace index, i.e.
Definition Title.php:1037
getDBkey()
Get the main part with underscores.
Definition Title.php:1013
getText()
Get the text form (spaces not underscores) of the main part.
Definition Title.php:995
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:51
const NS_FILE
Definition Defines.php:75
const LIST_OR
Definition Defines.php:51
Interface for configuration instances.
Definition Config.php:28
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Result wrapper for grabbing data queried from an IDatabase object.
const DB_REPLICA
Definition defines.php:25
const DB_MASTER
Definition defines.php:26
if(count( $args)< 1) $job
$content
Definition router.php:78
return true
Definition router.php:94