1<?php
2/**
3 * Contains a class for dealing with manual log entries
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @author Niklas Laxström
22 * @license GPL-2.0-or-later
23 * @since 1.19
24 */
25
26use MediaWiki\ChangeTags\Taggable;
27use MediaWiki\Linker\LinkTarget;
28use MediaWiki\Page\PageReference;
29use MediaWiki\User\UserIdentity;
30use Wikimedia\Assert\Assert;
31use Wikimedia\IPUtils;
32use Wikimedia\Rdbms\IDatabase;
33
34/**
35 * Class for creating new log entries and inserting them into the database.
36 *
37 * @newable
38 * @note marked as newable in 1.35 for lack of a better alternative,
39 *       but should be changed to use the builder pattern or the
40 *       command pattern.
41 * @since 1.19
42 * @see https://www.mediawiki.org/wiki/Manual:Logging_to_Special:Log
43 */
44class ManualLogEntry extends LogEntryBase implements Taggable {
45	/** @var string Type of log entry */
46	protected $type;
47
48	/** @var string Sub type of log entry */
49	protected $subtype;
50
51	/** @var array Parameters for log entry */
52	protected $parameters = [];
53
54	/** @var array */
55	protected $relations = [];
56
57	/** @var UserIdentity Performer of the action for the log entry */
58	protected $performer;
59
60	/** @var Title Target title for the log entry */
61	protected $target;
62
63	/** @var string Timestamp of creation of the log entry */
64	protected $timestamp;
65
66	/** @var string Comment for the log entry */
67	protected $comment = '';
68
69	/** @var int A rev id associated to the log entry */
70	protected $revId = 0;
71
72	/** @var string[] Change tags add to the log entry */
73	protected $tags = [];
74
75	/** @var int Deletion state of the log entry */
76	protected $deleted;
77
78	/** @var int ID of the log entry */
79	protected $id;
80
81	/** @var bool Can this log entry be patrolled? */
82	protected $isPatrollable = false;
83
84	/** @var bool Whether this is a legacy log entry */
85	protected $legacy = false;
86
87	/**
88	 * @stable to call
89	 * @since 1.19
90	 * @param string $type Log type. Should match $wgLogTypes.
91	 * @param string $subtype Log subtype (action). Should match $wgLogActions or
92	 *   (together with $type) $wgLogActionsHandlers.
93	 * @note
94	 */
95	public function __construct( $type, $subtype ) {
96		$this->type = $type;
97		$this->subtype = $subtype;
98	}
99
100	/**
101	 * Set extra log parameters.
102	 *
103	 * You can pass params to the log action message by prefixing the keys with
104	 * a number and optional type, using colons to separate the fields. The
105	 * numbering should start with number 4 (matching the $4 message parameter),
106	 * the first three parameters are hardcoded for every message ($1 is a link
107	 * to the username and user talk page of the performing user, $2 is just the
108	 * username (for determining gender), $3 is a link to the target page).
109	 *
110	 * Typically, these parameters will be used in the logentry-<type>-<subtype>
111	 * message, but custom formatters, declared via $wgLogActionsHandlers, can
112	 * override that.
113	 *
114	 * If you want to store stuff that should not be available in messages, don't
115	 * prefix the array key with a number and don't use the colons. Parameters
116	 * which should be searchable need to be set with setRelations() instead.
117	 *
118	 * Example:
119	 *   $entry->setParameters(
120	 *     '4::color' => 'blue',
121	 *     '5:number:count' => 3000,
122	 *     'animal' => 'dog'
123	 *   );
124	 *
125	 * @since 1.19
126	 * @param array $parameters Associative array
127	 * @see LogFormatter::formatParameterValue for valid parameter types and
128	 *   their meanings
129	 */
130	public function setParameters( $parameters ) {
131		$this->parameters = $parameters;
132	}
133
134	/**
135	 * Declare arbitrary tag/value relations to this log entry.
136	 * These can be used to filter log entries later on.
137	 *
138	 * @param array $relations Map of (tag => (list of values|value))
139	 * @since 1.22
140	 */
141	public function setRelations( array $relations ) {
142		$this->relations = $relations;
143	}
144
145	/**
146	 * Set the user that performed the action being logged.
147	 *
148	 * @since 1.19
149	 * @param UserIdentity $performer
150	 */
151	public function setPerformer( UserIdentity $performer ) {
152		$this->performer = $performer;
153	}
154
155	/**
156	 * Set the title of the object changed.
157	 *
158	 * @param LinkTarget|PageReference $target calling with LinkTarget
159	 *   is deprecated since 1.37
160	 * @since 1.19
161	 */
162	public function setTarget( $target ) {
163		if ( $target instanceof PageReference ) {
164			$this->target = Title::castFromPageReference( $target );
165		} elseif ( $target instanceof LinkTarget ) {
166			$this->target = Title::newFromLinkTarget( $target );
167		} else {
168			throw new InvalidArgumentException( "Invalid target provided" );
169		}
170	}
171
172	/**
173	 * Set the timestamp of when the logged action took place.
174	 *
175	 * @since 1.19
176	 * @param string $timestamp
177	 */
178	public function setTimestamp( $timestamp ) {
179		$this->timestamp = $timestamp;
180	}
181
182	/**
183	 * Set a comment associated with the action being logged.
184	 *
185	 * @since 1.19
186	 * @param string $comment
187	 */
188	public function setComment( $comment ) {
189		$this->comment = $comment;
190	}
191
192	/**
193	 * Set an associated revision id.
194	 *
195	 * For example, the ID of the revision that was inserted to mark a page move
196	 * or protection, file upload, etc.
197	 *
198	 * @since 1.27
199	 * @param int $revId
200	 */
201	public function setAssociatedRevId( $revId ) {
202		$this->revId = $revId;
203	}
204
205	/**
206	 * Set change tags for the log entry.
207	 *
208	 * Passing `null` means the same as empty array,
209	 * for compatibility with WikiPage::doUpdateRestrictions().
210	 *
211	 * @since 1.27
212	 * @param string|string[]|null $tags
213	 * @deprecated since 1.33 Please use addTags() instead
214	 */
215	public function setTags( $tags ) {
216		if ( $this->tags ) {
217			wfDebug( 'Overwriting existing ManualLogEntry tags' );
218		}
219		$this->tags = [];
220		$this->addTags( $tags );
221	}
222
223	/**
224	 * Add change tags for the log entry
225	 *
226	 * @since 1.33
227	 * @param string|string[]|null $tags Tags to apply
228	 */
229	public function addTags( $tags ) {
230		if ( $tags === null ) {
231			return;
232		}
233
234		if ( is_string( $tags ) ) {
235			$tags = [ $tags ];
236		}
237		Assert::parameterElementType( 'string', $tags, 'tags' );
238		$this->tags = array_unique( array_merge( $this->tags, $tags ) );
239	}
240
241	/**
242	 * Set whether this log entry should be made patrollable
243	 * This shouldn't depend on config, only on whether there is full support
244	 * in the software for patrolling this log entry.
245	 * False by default
246	 *
247	 * @since 1.27
248	 * @param bool $patrollable
249	 */
250	public function setIsPatrollable( $patrollable ) {
251		$this->isPatrollable = (bool)$patrollable;
252	}
253
254	/**
255	 * Set the 'legacy' flag
256	 *
257	 * @since 1.25
258	 * @param bool $legacy
259	 */
260	public function setLegacy( $legacy ) {
261		$this->legacy = $legacy;
262	}
263
264	/**
265	 * Set the 'deleted' flag.
266	 *
267	 * @since 1.19
268	 * @param int $deleted One of LogPage::DELETED_* bitfield constants
269	 */
270	public function setDeleted( $deleted ) {
271		$this->deleted = $deleted;
272	}
273
274	/**
275	 * Insert the entry into the `logging` table.
276	 *
277	 * @param IDatabase|null $dbw
278	 * @return int ID of the log entry
279	 * @throws MWException
280	 */
281	public function insert( IDatabase $dbw = null ) {
282		$dbw = $dbw ?: wfGetDB( DB_PRIMARY );
283
284		if ( $this->timestamp === null ) {
285			$this->timestamp = wfTimestampNow();
286		}
287
288		$actorId = \MediaWiki\MediaWikiServices::getInstance()->getActorStore()
289			->acquireActorId( $this->getPerformerIdentity(), $dbw );
290
291		// Trim spaces on user supplied text
292		$comment = trim( $this->getComment() );
293
294		$params = $this->getParameters();
295		$relations = $this->relations;
296
297		// Additional fields for which there's no space in the database table schema
298		$revId = $this->getAssociatedRevId();
299		if ( $revId ) {
300			$params['associated_rev_id'] = $revId;
301			$relations['associated_rev_id'] = $revId;
302		}
303
304		$data = [
305			'log_type' => $this->getType(),
306			'log_action' => $this->getSubtype(),
307			'log_timestamp' => $dbw->timestamp( $this->getTimestamp() ),
308			'log_actor' => $actorId,
309			'log_namespace' => $this->getTarget()->getNamespace(),
310			'log_title' => $this->getTarget()->getDBkey(),
311			'log_page' => $this->getTarget()->getArticleID(),
312			'log_params' => LogEntryBase::makeParamBlob( $params ),
313		];
314		if ( isset( $this->deleted ) ) {
315			$data['log_deleted'] = $this->deleted;
316		}
317		$data += CommentStore::getStore()->insert( $dbw, 'log_comment', $comment );
318
319		$dbw->insert( 'logging', $data, __METHOD__ );
320		$this->id = $dbw->insertId();
321
322		$rows = [];
323		foreach ( $relations as $tag => $values ) {
324			if ( !strlen( $tag ) ) {
325				throw new MWException( "Got empty log search tag." );
326			}
327
328			if ( !is_array( $values ) ) {
329				$values = [ $values ];
330			}
331
332			foreach ( $values as $value ) {
333				$rows[] = [
334					'ls_field' => $tag,
335					'ls_value' => $value,
336					'ls_log_id' => $this->id
337				];
338			}
339		}
340		if ( count( $rows ) ) {
341			$dbw->insert( 'log_search', $rows, __METHOD__, [ 'IGNORE' ] );
342		}
343
344		return $this->id;
345	}
346
347	/**
348	 * Get a RecentChanges object for the log entry
349	 *
350	 * @param int $newId
351	 * @return RecentChange
352	 * @since 1.23
353	 */
354	public function getRecentChange( $newId = 0 ) {
355		$formatter = LogFormatter::newFromEntry( $this );
356		$context = RequestContext::newExtraneousContext( $this->getTarget() );
357		$formatter->setContext( $context );
358
359		$logpage = SpecialPage::getTitleFor( 'Log', $this->getType() );
360		$user = $this->getPerformerIdentity();
361		$ip = "";
362		if ( !$user->isRegistered() ) {
363			// "MediaWiki default" and friends may have
364			// no IP address in their name
365			if ( IPUtils::isIPAddress( $user->getName() ) ) {
366				$ip = $user->getName();
367			}
368		}
369
370		return RecentChange::newLogEntry(
371			$this->getTimestamp(),
372			$logpage,
373			$user,
374			$formatter->getPlainActionText(),
375			$ip,
376			$this->getType(),
377			$this->getSubtype(),
378			$this->getTarget(),
379			$this->getComment(),
380			LogEntryBase::makeParamBlob( $this->getParameters() ),
381			$newId,
382			$formatter->getIRCActionComment(), // Used for IRC feeds
383			$this->getAssociatedRevId(), // Used for e.g. moves and uploads
384			$this->getIsPatrollable()
385		);
386	}
387
388	/**
389	 * Publish the log entry.
390	 *
391	 * @param int $newId Id of the log entry.
392	 * @param string $to One of: rcandudp (default), rc, udp
393	 */
394	public function publish( $newId, $to = 'rcandudp' ) {
395		$canAddTags = true;
396		// FIXME: this code should be removed once all callers properly call publish()
397		if ( $to === 'udp' && !$newId && !$this->getAssociatedRevId() ) {
398			\MediaWiki\Logger\LoggerFactory::getInstance( 'logging' )->warning(
399				'newId and/or revId must be set when calling ManualLogEntry::publish()',
400				[
401					'newId' => $newId,
402					'to' => $to,
403					'revId' => $this->getAssociatedRevId(),
404					// pass a new exception to register the stack trace
405					'exception' => new RuntimeException()
406				]
407			);
408			$canAddTags = false;
409		}
410
411		DeferredUpdates::addCallableUpdate(
412			function () use ( $newId, $to, $canAddTags ) {
413				$log = new LogPage( $this->getType() );
414				if ( !$log->isRestricted() ) {
415					Hooks::runner()->onManualLogEntryBeforePublish( $this );
416					$rc = $this->getRecentChange( $newId );
417
418					if ( $to === 'rc' || $to === 'rcandudp' ) {
419						// save RC, passing tags so they are applied there
420						$rc->addTags( $this->getTags() );
421						$rc->save( $rc::SEND_NONE );
422					} else {
423						$tags = $this->getTags();
424						if ( $tags && $canAddTags ) {
425							$revId = $this->getAssociatedRevId();
426							ChangeTags::addTags(
427								$tags,
428								null,
429								$revId > 0 ? $revId : null,
430								$newId > 0 ? $newId : null
431							);
432						}
433					}
434
435					if ( $to === 'udp' || $to === 'rcandudp' ) {
436						$rc->notifyRCFeeds();
437					}
438				}
439			},
440			DeferredUpdates::POSTSEND,
441			wfGetDB( DB_PRIMARY )
442		);
443	}
444
445	/**
446	 * @return string
447	 */
448	public function getType() {
449		return $this->type;
450	}
451
452	/**
453	 * @return string
454	 */
455	public function getSubtype() {
456		return $this->subtype;
457	}
458
459	/**
460	 * @return array
461	 */
462	public function getParameters() {
463		return $this->parameters;
464	}
465
466	/**
467	 * @return UserIdentity
468	 */
469	public function getPerformerIdentity(): UserIdentity {
470		return $this->performer;
471	}
472
473	/**
474	 * @return Title
475	 */
476	public function getTarget() {
477		return $this->target;
478	}
479
480	/**
481	 * @return string|false
482	 */
483	public function getTimestamp() {
484		$ts = $this->timestamp ?? wfTimestampNow();
485
486		return wfTimestamp( TS_MW, $ts );
487	}
488
489	/**
490	 * @return string
491	 */
492	public function getComment() {
493		return $this->comment;
494	}
495
496	/**
497	 * @since 1.27
498	 * @return int
499	 */
500	public function getAssociatedRevId() {
501		return $this->revId;
502	}
503
504	/**
505	 * @since 1.27
506	 * @return string[]
507	 */
508	public function getTags() {
509		return $this->tags;
510	}
511
512	/**
513	 * Whether this log entry is patrollable
514	 *
515	 * @since 1.27
516	 * @return bool
517	 */
518	public function getIsPatrollable() {
519		return $this->isPatrollable;
520	}
521
522	/**
523	 * @since 1.25
524	 * @return bool
525	 */
526	public function isLegacy() {
527		return $this->legacy;
528	}
529
530	/**
531	 * @return int
532	 */
533	public function getDeleted() {
534		return (int)$this->deleted;
535	}
536}
537