Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.36% covered (warning)
54.36%
81 / 149
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArticlesController
54.36% covered (warning)
54.36%
81 / 149
28.57% covered (danger)
28.57%
2 / 7
102.52
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 beforeFilter
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
9.66
 pageIndex
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 viewBySlug
97.18% covered (success)
97.18%
69 / 71
0.00% covered (danger)
0.00%
0 / 1
7
 addComment
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 recordPageView
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Controller;
5
6use App\Model\Table\PageViewsTable;
7use App\Model\Table\SlugsTable;
8use App\Utility\SettingsManager;
9use Cake\Cache\Cache;
10use Cake\Event\EventInterface;
11use Cake\Http\Exception\NotFoundException;
12use Cake\Http\Response;
13use Cake\Routing\Router;
14
15/**
16 * Articles Controller
17 *
18 * Manages article-related operations including viewing, listing, and commenting.
19 *
20 * @property \App\Model\Table\ArticlesTable $Articles
21 * @property \App\Model\Table\PageViewsTable $PageViews
22 * @property \App\Model\Table\SlugsTable $Slugs
23 */
24class ArticlesController extends AppController
25{
26    /**
27     * Default pagination configuration.
28     *
29     * Defines the default settings for paginating article records.
30     * The limit determines how many articles are displayed per page.
31     *
32     * @var array<string, mixed> $paginate Configuration array for pagination
33     */
34    protected array $paginate = [
35        'limit' => 6,
36    ];
37
38    /**
39     * PageViews Table
40     *
41     * @var \App\Model\Table\PageViewsTable $PageViews
42     *
43     * This property holds an instance of the PageViewsTable class.
44     * It is used to interact with the page_views table in the database.
45     * The PageViewsTable provides methods for querying and manipulating
46     * page view data, such as tracking article views and retrieving view statistics.
47     */
48    protected PageViewsTable $PageViews;
49
50    /**
51     * Slugs Table
52     *
53     * @var \App\Model\Table\SlugsTable $Slugs
54     *
55     * This property holds an instance of the SlugsTable class.
56     * It is used to interact with the slugs table in the database.
57     * The SlugsTable provides methods for querying and manipulating
58     * slug data, such as creating new slugs, finding the latest slug
59     * for an article, and managing slug history.
60     */
61    protected SlugsTable $Slugs;
62
63    /**
64     * Initializes the controller.
65     *
66     * Sets up the Slugs and PageViews table instances.
67     *
68     * @return void
69     */
70    public function initialize(): void
71    {
72        parent::initialize();
73        $this->Slugs = $this->fetchTable('Slugs');
74        $this->PageViews = $this->fetchTable('PageViews');
75    }
76
77    /**
78     * Configures authentication for specific actions.
79     *
80     * @param \Cake\Event\EventInterface $event The event instance.
81     * @return void
82     */
83    public function beforeFilter(EventInterface $event): void
84    {
85        parent::beforeFilter($event);
86
87        $this->Authentication->addUnauthenticatedActions(['view', 'index', 'viewBySlug', 'pageIndex']);
88
89        if ($this->request->getParam('action') === 'addComment' && $this->request->is('post')) {
90            $result = $this->Authentication->getResult();
91            if (!$result || !$result->isValid()) {
92                $session = $this->request->getSession();
93                $session->write('Comment.formData', $this->request->getData());
94            }
95        }
96    }
97
98    /**
99     * Displays the page index.
100     *
101     * Retrieves and sets the root page article and a threaded list of all page articles.
102     *
103     * @return void
104     */
105    public function pageIndex(): void
106    {
107        $article = $this->Articles->find()
108            ->orderBy(['lft' => 'ASC'])
109            ->where([
110                'Articles.kind' => 'page',
111                'Articles.is_published' => 1,
112            ])
113            ->first();
114
115        $articles = $this->Articles->getTree();
116
117        $this->set(compact('article', 'articles'));
118    }
119
120    /**
121     * Displays a paginated list of published articles.
122     *
123     * Retrieves published articles with optional tag filtering.
124     *
125     * @return void
126     */
127    public function index(): void
128    {
129        $cacheKey = $this->cacheKey;
130        $articles = Cache::read($cacheKey, 'content');
131        $selectedTagId = $this->request->getQuery('tag');
132
133        if (!$articles) {
134            $query = $this->Articles->find()
135                ->where([
136                    'Articles.kind' => 'article',
137                    'Articles.is_published' => 1,
138                ])
139                ->contain(['Users', 'Tags'])
140                ->orderBy(['Articles.published' => 'DESC']);
141
142            if ($selectedTagId) {
143                $query->matching('Tags', function ($q) use ($selectedTagId) {
144                    return $q->where(['Tags.id' => $selectedTagId]);
145                });
146            }
147
148            $year = $this->request->getQuery('year');
149            $month = $this->request->getQuery('month');
150
151            if ($year) {
152                $conditions = ['YEAR(Articles.published)' => $year];
153                if ($month) {
154                    $conditions['MONTH(Articles.published)'] = $month;
155                }
156                $query->where($conditions);
157            }
158
159            $articles = $this->paginate($query);
160            Cache::write($cacheKey, $articles, 'content');
161        }
162
163        $this->set(compact(
164            'articles',
165            'selectedTagId',
166        ));
167
168        $this->viewBuilder()->setLayout('article_index');
169    }
170
171    /**
172     * Displays an article by its slug.
173     *
174     * Retrieves an article using the provided slug, handling caching and redirects.
175     *
176     * @param string $slug The slug of the article to view.
177     * @return \Cake\Http\Response|null
178     * @throws \Cake\Http\Exception\NotFoundException If the article is not found.
179     */
180    public function viewBySlug(string $slug): ?Response
181    {
182        $cacheKey = $slug . $this->cacheKey;
183        $article = Cache::read($cacheKey, 'content');
184
185        if (empty($article)) {
186            // If not in cache, we need to check if this is the latest slug
187            $slugEntity = $this->Slugs->find()
188                ->where([
189                    'slug' => $slug,
190                    'model' => 'Articles',
191                    ])
192                ->orderBy(['created' => 'DESC'])
193                ->select(['foreign_key'])
194                ->first();
195
196            if (!$slugEntity) {
197                // If no slug found, try to find the article directly (fallback)
198                $article = $this->Articles->find()
199                    ->where(['slug' => $slug, 'is_published' => 1])
200                    ->first();
201
202                if (!$article) {
203                    throw new NotFoundException(__('Article not found'));
204                }
205
206                $articleId = $article->id;
207            } else {
208                $articleId = $slugEntity->foreign_key;
209            }
210
211            // Check if it's the latest slug for the article
212            $latestSlug = $this->Slugs->find()
213                ->where(['foreign_key' => $articleId])
214                ->orderBy(['created' => 'DESC'])
215                ->select(['slug'])
216                ->first();
217
218            // If $slug is not the same as the latestSlug, do a 301 redirect
219            if ($latestSlug && $latestSlug->slug !== $slug) {
220                return $this->redirect(
221                    [
222                        'controller' => 'Articles',
223                        'action' => 'view-by-slug',
224                        'slug' => $latestSlug->slug,
225                        '_full' => true,
226                    ],
227                    301,
228                );
229            }
230
231            // Fetch the full article with its associations
232            $article = $this->Articles->find()
233                ->where([
234                    'Articles.id' => $articleId,
235                    'Articles.is_published' => 1,
236                ])
237                ->contain([
238                    'Users',
239                    'Tags',
240                    'Comments' => function ($q) {
241                        return $q->where(['Comments.display' => 1])
242                                ->orderBy(['Comments.created' => 'DESC'])
243                                ->contain(['Users']);
244                    },
245                    'Images',
246                ])
247                ->first();
248
249            if (!$article) {
250                throw new NotFoundException(__('Article not found'));
251            }
252
253            Cache::write($cacheKey, $article, 'content');
254        }
255
256        $this->viewBuilder()->setLayout($article->kind);
257
258        $selectedTagId = false;
259
260        // Get the child pages and breadcrumbs for the current article
261        $childPages = $this->Articles->find('children', for: $article->id)
262            ->orderBy(['lft' => 'ASC'])
263            ->cache($cacheKey . '_children', 'content')
264            ->toArray();
265
266        // Breadcrumbs
267        $crumbs = $this->Articles->find('path', for: $article->id)
268            ->cache($cacheKey . '_crumbs', 'content')
269            ->select(['slug', 'title', 'id'])
270            ->all();
271
272        $this->recordPageView($article->id);
273
274        $this->set(compact(
275            'article',
276            'childPages',
277            'selectedTagId',
278            'crumbs',
279        ));
280
281        return $this->render($article->kind);
282    }
283
284    /**
285     * Adds a new comment to an article.
286     *
287     * @param string $articleId The ID of the article to which the comment will be added.
288     * @return \Cake\Http\Response|null
289     */
290    public function addComment(string $articleId): ?Response
291    {
292        if (!$this->request->getSession()->read('Auth.id')) {
293            $this->Flash->error(__('You must be logged in to add a comment.'));
294
295            return $this->redirect($this->referer());
296        }
297
298        $article = $this->Articles
299            ->find()
300            ->where(['id' => $articleId])
301            ->contain([])
302            ->first();
303
304        if (!$article) {
305            $this->Flash->error(__('Article not found.'));
306
307            return $this->redirect($this->referer());
308        }
309
310        if (
311            (!SettingsManager::read('Comments.articlesEnabled') && $article->kind == 'article')
312            || (!SettingsManager::read('Comments.pagesEnabled') && $article->kind == 'page')
313        ) {
314            $this->Flash->error(__('Comments are not enabled'));
315
316            return $this->redirect($this->referer());
317        }
318
319        $userId = $this->request->getSession()->read('Auth.id');
320        $content = $this->request->getData('content');
321
322        if ($this->Articles->addComment($articleId, $userId, $content)) {
323            $this->Flash->success(__('Your comment has been added.'));
324        } else {
325            $this->Flash->error(__('Unable to add your comment.'));
326        }
327
328        return $this->redirect(Router::url([
329            '_name' => 'article-by-slug',
330            'slug' => $article->slug,
331        ], true));
332    }
333
334    /**
335     * Records a page view for a given article.
336     *
337     * @param string $articleId The ID of the article being viewed
338     * @return void
339     */
340    private function recordPageView(string $articleId): void
341    {
342        $pageView = $this->PageViews->newEmptyEntity();
343        $pageView->article_id = $articleId;
344        $pageView->ip_address = $this->request->clientIp();
345        $pageView->user_agent = $this->request->getHeaderLine('User-Agent');
346        $pageView->referer = $this->request->referer();
347        $this->PageViews->save($pageView);
348    }
349}