Add permission to manage own articles
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / data / article / ArticleAction.class.php
1 <?php
2 namespace wcf\data\article;
3 use wcf\data\article\category\ArticleCategory;
4 use wcf\data\article\content\ArticleContent;
5 use wcf\data\article\content\ArticleContentAction;
6 use wcf\data\article\content\ArticleContentEditor;
7 use wcf\data\language\Language;
8 use wcf\data\AbstractDatabaseObjectAction;
9 use wcf\system\clipboard\ClipboardHandler;
10 use wcf\system\comment\CommentHandler;
11 use wcf\system\exception\PermissionDeniedException;
12 use wcf\system\exception\UserInputException;
13 use wcf\system\language\LanguageFactory;
14 use wcf\system\like\LikeHandler;
15 use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
16 use wcf\system\request\LinkHandler;
17 use wcf\system\search\SearchIndexManager;
18 use wcf\system\tagging\TagEngine;
19 use wcf\system\user\storage\UserStorageHandler;
20 use wcf\system\version\VersionTracker;
21 use wcf\system\visitTracker\VisitTracker;
22 use wcf\system\WCF;
23
24 /**
25 * Executes article related actions.
26 *
27 * @author Marcel Werk
28 * @copyright 2001-2018 WoltLab GmbH
29 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
30 * @package WoltLabSuite\Core\Data\Article
31 * @since 3.0
32 *
33 * @method ArticleEditor[] getObjects()
34 * @method ArticleEditor getSingleObject()
35 */
36 class ArticleAction extends AbstractDatabaseObjectAction {
37 /**
38 * article editor instance
39 * @var ArticleEditor
40 */
41 public $articleEditor;
42
43 /**
44 * language object
45 * @var Language
46 */
47 public $language;
48
49 /**
50 * @inheritDoc
51 */
52 protected $className = ArticleEditor::class;
53
54 /**
55 * @inheritDoc
56 */
57 protected $permissionsCreate = ['admin.content.article.canManageArticle'];
58
59 /**
60 * @inheritDoc
61 */
62 protected $permissionsDelete = ['admin.content.article.canManageArticle'];
63
64 /**
65 * @inheritDoc
66 */
67 protected $permissionsUpdate = ['admin.content.article.canManageArticle'];
68
69 /**
70 * @inheritDoc
71 */
72 protected $requireACP = ['create', 'delete', 'restore', 'toggleI18n', 'trash', 'update'];
73
74 /**
75 * @inheritDoc
76 */
77 protected $allowGuestAccess = ['markAllAsRead'];
78
79 /**
80 * @inheritDoc
81 * @return Article
82 */
83 public function create() {
84 /** @var Article $article */
85 $article = parent::create();
86
87 // save article content
88 if (!empty($this->parameters['content'])) {
89 foreach ($this->parameters['content'] as $languageID => $content) {
90 if (!empty($content['htmlInputProcessor'])) {
91 /** @noinspection PhpUndefinedMethodInspection */
92 $content['content'] = $content['htmlInputProcessor']->getHtml();
93 }
94
95 /** @var ArticleContent $articleContent */
96 $articleContent = ArticleContentEditor::create([
97 'articleID' => $article->articleID,
98 'languageID' => $languageID ?: null,
99 'title' => $content['title'],
100 'teaser' => $content['teaser'],
101 'content' => $content['content'],
102 'imageID' => $content['imageID'],
103 'teaserImageID' => $content['teaserImageID']
104 ]);
105 $articleContentEditor = new ArticleContentEditor($articleContent);
106
107 // save tags
108 if (!empty($content['tags'])) {
109 TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
110 }
111
112 // update search index
113 SearchIndexManager::getInstance()->set(
114 'com.woltlab.wcf.article',
115 $articleContent->articleContentID,
116 $articleContent->content,
117 $articleContent->title,
118 $article->time,
119 $article->userID,
120 $article->username,
121 $languageID ?: null,
122 $articleContent->teaser
123 );
124
125 // save embedded objects
126 if (!empty($content['htmlInputProcessor'])) {
127 /** @noinspection PhpUndefinedMethodInspection */
128 $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
129 if (MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
130 $articleContentEditor->update(['hasEmbeddedObjects' => 1]);
131 }
132 }
133 }
134 }
135
136 // reset storage
137 if (ARTICLE_ENABLE_VISIT_TRACKING) {
138 UserStorageHandler::getInstance()->resetAll('unreadArticles');
139 }
140
141 if ($article->publicationStatus == Article::PUBLISHED) {
142 ArticleEditor::updateArticleCounter([$article->userID => 1]);
143 }
144
145 return $article;
146 }
147
148 /**
149 * @inheritDoc
150 */
151 public function update() {
152 parent::update();
153
154 $isRevert = (!empty($this->parameters['isRevert']));
155
156 // update article content
157 if (!empty($this->parameters['content'])) {
158 foreach ($this->getObjects() as $article) {
159 $versionData = [];
160 $hasChanges = false;
161
162 foreach ($this->parameters['content'] as $languageID => $content) {
163 if (!empty($content['htmlInputProcessor'])) {
164 /** @noinspection PhpUndefinedMethodInspection */
165 $content['content'] = $content['htmlInputProcessor']->getHtml();
166 }
167
168 $articleContent = ArticleContent::getArticleContent($article->articleID, ($languageID ?: null));
169 $articleContentEditor = null;
170 if ($articleContent !== null) {
171 // update
172 $articleContentEditor = new ArticleContentEditor($articleContent);
173 $articleContentEditor->update([
174 'title' => $content['title'],
175 'teaser' => $content['teaser'],
176 'content' => $content['content'],
177 'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
178 'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID']
179 ]);
180
181 $versionData[] = $articleContent;
182 if ($articleContent->content != $content['content'] || $articleContent->teaser != $content['teaser'] || $articleContent->title != $content['title']) {
183 $hasChanges = true;
184 }
185
186 // delete tags
187 if (!$isRevert && empty($content['tags'])) {
188 TagEngine::getInstance()->deleteObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, ($languageID ?: null));
189 }
190 }
191 else {
192 /** @var ArticleContent $articleContent */
193 $articleContent = ArticleContentEditor::create([
194 'articleID' => $article->articleID,
195 'languageID' => $languageID ?: null,
196 'title' => $content['title'],
197 'teaser' => $content['teaser'],
198 'content' => $content['content'],
199 'imageID' => ($isRevert) ? null : $content['imageID'],
200 'teaserImageID' => ($isRevert) ? null : $content['teaserImageID']
201 ]);
202 $articleContentEditor = new ArticleContentEditor($articleContent);
203
204 $versionData[] = $articleContent;
205 $hasChanges = true;
206 }
207
208 // save tags
209 if (!$isRevert && !empty($content['tags'])) {
210 TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
211 }
212
213 // update search index
214 SearchIndexManager::getInstance()->set(
215 'com.woltlab.wcf.article',
216 $articleContent->articleContentID,
217 $articleContent->content,
218 $articleContent->title,
219 $article->time,
220 $article->userID,
221 $article->username,
222 $languageID ?: null,
223 $articleContent->teaser
224 );
225
226 // save embedded objects
227 if (!empty($content['htmlInputProcessor'])) {
228 /** @noinspection PhpUndefinedMethodInspection */
229 $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
230 if ($articleContent->hasEmbeddedObjects != MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
231 $articleContentEditor->update(['hasEmbeddedObjects' => $articleContent->hasEmbeddedObjects ? 0 : 1]);
232 }
233 }
234 }
235
236 if ($hasChanges) {
237 $articleObj = new ArticleVersionTracker($article->getDecoratedObject());
238 $articleObj->setContent($versionData);
239 VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj);
240 }
241 }
242 }
243
244 // reset storage
245 if (ARTICLE_ENABLE_VISIT_TRACKING) {
246 UserStorageHandler::getInstance()->resetAll('unreadArticles');
247 }
248
249 $publicationStatus = (isset($this->parameters['data']['publicationStatus'])) ? $this->parameters['data']['publicationStatus'] : null;
250 if ($publicationStatus !== null) {
251 $usersToArticles = [];
252 /** @var ArticleEditor $articleEditor */
253 foreach ($this->objects as $articleEditor) {
254 if ($publicationStatus != $articleEditor->publicationStatus) {
255 // The article was published before or was now published.
256 if ($publicationStatus == Article::PUBLISHED || $articleEditor->publicationStatus == Article::PUBLISHED) {
257 if (!isset($usersToArticles[$articleEditor->userID])) {
258 $usersToArticles[$articleEditor->userID] = 0;
259 }
260
261 $usersToArticles[$articleEditor->userID] += ($publicationStatus == Article::PUBLISHED) ? 1 : -1;
262 }
263 }
264 }
265
266 if (!empty($usersToArticles)) {
267 ArticleEditor::updateArticleCounter($usersToArticles);
268 }
269 }
270 }
271
272 /**
273 * Validates parameters to delete articles.
274 *
275 * @throws UserInputException
276 */
277 public function validateDelete() {
278 if (empty($this->objects)) {
279 $this->readObjects();
280
281 if (empty($this->objects)) {
282 throw new UserInputException('objectIDs');
283 }
284 }
285
286 foreach ($this->getObjects() as $article) {
287 if (!$article->canDelete()) {
288 throw new PermissionDeniedException();
289 }
290
291 if (!$article->isDeleted) {
292 throw new UserInputException('objectIDs');
293 }
294 }
295 }
296
297 /**
298 * @inheritDoc
299 */
300 public function delete() {
301 $articleIDs = $articleContentIDs = [];
302 foreach ($this->getObjects() as $article) {
303 $articleIDs[] = $article->articleID;
304 foreach ($article->getArticleContents() as $articleContent) {
305 $articleContentIDs[] = $articleContent->articleContentID;
306 }
307 }
308
309 // delete articles
310 parent::delete();
311
312 if (!empty($articleIDs)) {
313 // delete like data
314 LikeHandler::getInstance()->removeLikes('com.woltlab.wcf.likeableArticle', $articleIDs);
315 // delete comments
316 CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.articleComment', $articleContentIDs);
317 // delete tag to object entries
318 TagEngine::getInstance()->deleteObjects('com.woltlab.wcf.article', $articleContentIDs);
319 // delete entry from search index
320 SearchIndexManager::getInstance()->delete('com.woltlab.wcf.article', $articleContentIDs);
321 }
322
323 $this->unmarkItems();
324
325 return [
326 'objectIDs' => $this->objectIDs,
327 'redirectURL' => LinkHandler::getInstance()->getLink('ArticleList', ['isACP' => true])
328 ];
329 }
330
331 /**
332 * Validates parameters to move articles to the trash bin.
333 *
334 * @throws UserInputException
335 */
336 public function validateTrash() {
337 if (empty($this->objects)) {
338 $this->readObjects();
339
340 if (empty($this->objects)) {
341 throw new UserInputException('objectIDs');
342 }
343 }
344
345 foreach ($this->getObjects() as $article) {
346 if (!$article->canDelete()) {
347 throw new PermissionDeniedException();
348 }
349
350 if ($article->isDeleted) {
351 throw new UserInputException('objectIDs');
352 }
353 }
354 }
355
356 /**
357 * Moves articles to the trash bin.
358 */
359 public function trash() {
360 foreach ($this->getObjects() as $articleEditor) {
361 $articleEditor->update(['isDeleted' => 1]);
362 }
363
364 $this->unmarkItems();
365
366 // reset storage
367 if (ARTICLE_ENABLE_VISIT_TRACKING) {
368 UserStorageHandler::getInstance()->resetAll('unreadArticles');
369 }
370
371 return ['objectIDs' => $this->objectIDs];
372 }
373
374 /**
375 * Validates parameters to restore articles.
376 *
377 * @throws UserInputException
378 */
379 public function validateRestore() {
380 $this->validateDelete();
381 }
382
383 /**
384 * Restores articles.
385 */
386 public function restore() {
387 foreach ($this->getObjects() as $articleEditor) {
388 $articleEditor->update(['isDeleted' => 0]);
389 }
390
391 $this->unmarkItems();
392
393 // reset storage
394 if (ARTICLE_ENABLE_VISIT_TRACKING) {
395 UserStorageHandler::getInstance()->resetAll('unreadArticles');
396 }
397
398 return ['objectIDs' => $this->objectIDs];
399 }
400
401 /**
402 * Validates parameters to toggle between i18n and monolingual mode.
403 *
404 * @throws UserInputException
405 */
406 public function validateToggleI18n() {
407 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
408
409 $this->articleEditor = $this->getSingleObject();
410 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
411 $this->readInteger('languageID');
412 $this->language = LanguageFactory::getInstance()->getLanguage($this->parameters['languageID']);
413 if ($this->language === null) {
414 throw new UserInputException('languageID');
415 }
416
417 $contents = $this->articleEditor->getArticleContents();
418 if (!isset($contents[$this->language->languageID])) {
419 // there is no content
420 throw new UserInputException('languageID');
421 }
422 }
423 }
424
425 /**
426 * Toggles between i18n and monolingual mode.
427 */
428 public function toggleI18n() {
429 $removeContent = [];
430
431 // i18n -> monolingual
432 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
433 foreach ($this->articleEditor->getArticleContents() as $articleContent) {
434 if ($articleContent->languageID == $this->language->languageID) {
435 $articleContentEditor = new ArticleContentEditor($articleContent);
436 $articleContentEditor->update(['languageID' => null]);
437 }
438 else {
439 $removeContent[] = $articleContent;
440 }
441 }
442 }
443 else {
444 // monolingual -> i18n
445 $articleContent = $this->articleEditor->getArticleContent();
446 $data = [];
447 foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
448 $data[$language->languageID] = [
449 'title' => $articleContent->title,
450 'teaser' => $articleContent->teaser,
451 'content' => $articleContent->content,
452 'imageID' => $articleContent->imageID ?: null,
453 'teaserImageID' => $articleContent->teaserImageID ?: null
454 ];
455 }
456
457 $action = new ArticleAction([$this->articleEditor], 'update', ['content' => $data]);
458 $action->executeAction();
459
460 $removeContent[] = $articleContent;
461 }
462
463 if (!empty($removeContent)) {
464 $action = new ArticleContentAction($removeContent, 'delete');
465 $action->executeAction();
466 }
467
468 // flush edit history
469 VersionTracker::getInstance()->reset('com.woltlab.wcf.article', $this->articleEditor->getDecoratedObject()->articleID);
470
471 // update article's i18n state
472 $this->articleEditor->update([
473 'isMultilingual' => ($this->articleEditor->getDecoratedObject()->isMultilingual) ? 0 : 1
474 ]);
475 }
476
477 /**
478 * Marks articles as read.
479 */
480 public function markAsRead() {
481 if (empty($this->parameters['visitTime'])) {
482 $this->parameters['visitTime'] = TIME_NOW;
483 }
484
485 if (empty($this->objects)) {
486 $this->readObjects();
487 }
488
489 foreach ($this->getObjects() as $article) {
490 VisitTracker::getInstance()->trackObjectVisit('com.woltlab.wcf.article', $article->articleID, $this->parameters['visitTime']);
491 }
492
493 // reset storage
494 if (WCF::getUser()->userID) {
495 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
496 }
497 }
498
499 /**
500 * Marks all articles as read.
501 */
502 public function markAllAsRead() {
503 VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
504
505 // reset storage
506 if (WCF::getUser()->userID) {
507 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
508 }
509 }
510
511 /**
512 * Validates the mark all as read action.
513 */
514 public function validateMarkAllAsRead() {
515 // does nothing
516 }
517
518 /**
519 * Validates the `setCategory` action.
520 *
521 * @throws UserInputException
522 */
523 public function validateSetCategory() {
524 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
525
526 $this->readBoolean('useMarkedArticles', true);
527
528 // if no object ids are given, use clipboard handler
529 if (empty($this->objectIDs) && $this->parameters['useMarkedArticles']) {
530 $this->objectIDs = array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
531 }
532
533 if (empty($this->objects)) {
534 $this->readObjects();
535
536 if (empty($this->objects)) {
537 throw new UserInputException('objectIDs');
538 }
539 }
540
541 $this->readInteger('categoryID');
542 if (ArticleCategory::getCategory($this->parameters['categoryID']) === null) {
543 throw new UserInputException('categoryID');
544 }
545 }
546
547 /**
548 * Sets the category of articles.
549 */
550 public function setCategory() {
551 foreach ($this->getObjects() as $articleEditor) {
552 $articleEditor->update(['categoryID' => $this->parameters['categoryID']]);
553 }
554
555 $this->unmarkItems();
556 }
557
558 /**
559 * Validates the `publish` action.
560 *
561 * @throws UserInputException
562 */
563 public function validatePublish() {
564 if (empty($this->objects)) {
565 $this->readObjects();
566
567 if (empty($this->objects)) {
568 throw new UserInputException('objectIDs');
569 }
570 }
571
572 foreach ($this->getObjects() as $article) {
573 if (!$article->canPublish()) {
574 throw new PermissionDeniedException();
575 }
576
577 if ($article->publicationStatus == Article::PUBLISHED) {
578 throw new UserInputException('objectIDs');
579 }
580 }
581 }
582
583 /**
584 * Publishes articles.
585 */
586 public function publish() {
587 $usersToArticles = [];
588 foreach ($this->getObjects() as $articleEditor) {
589 $articleEditor->update([
590 'time' => TIME_NOW,
591 'publicationStatus' => Article::PUBLISHED,
592 'publicationDate' => 0
593 ]);
594
595 if (!isset($usersToArticles[$articleEditor->userID])) {
596 $usersToArticles[$articleEditor->userID] = 0;
597 }
598
599 $usersToArticles[$articleEditor->userID]++;
600 }
601
602 ArticleEditor::updateArticleCounter($usersToArticles);
603
604 $this->unmarkItems();
605 }
606
607 /**
608 * Validates the `unpublish` action.
609 *
610 * @throws UserInputException
611 */
612 public function validateUnpublish() {
613 if (empty($this->objects)) {
614 $this->readObjects();
615
616 if (empty($this->objects)) {
617 throw new UserInputException('objectIDs');
618 }
619 }
620
621 foreach ($this->getObjects() as $article) {
622 if (!$article->canPublish()) {
623 throw new PermissionDeniedException();
624 }
625
626 if ($article->publicationStatus != Article::PUBLISHED) {
627 throw new UserInputException('objectIDs');
628 }
629 }
630 }
631
632 /**
633 * Unpublishes articles.
634 */
635 public function unpublish() {
636 $usersToArticles = [];
637 foreach ($this->getObjects() as $articleEditor) {
638 $articleEditor->update(['publicationStatus' => Article::UNPUBLISHED]);
639
640 if (!isset($usersToArticles[$articleEditor->userID])) {
641 $usersToArticles[$articleEditor->userID] = 0;
642 }
643
644 $usersToArticles[$articleEditor->userID]--;
645 }
646
647 ArticleEditor::updateArticleCounter($usersToArticles);
648
649 $this->unmarkItems();
650 }
651
652 /**
653 * Unmarks articles.
654 *
655 * @param integer[] $articleIDs
656 */
657 protected function unmarkItems(array $articleIDs = []) {
658 if (empty($articleIDs)) {
659 foreach ($this->getObjects() as $article) {
660 $articleIDs[] = $article->articleID;
661 }
662 }
663
664 if (!empty($articleIDs)) {
665 ClipboardHandler::getInstance()->unmark($articleIDs, ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article'));
666 }
667 }
668 }