1<?php
2/**
3 * Title: Changelog
4 * Icon: fa-list-ul
5 * Group: Content
6 * Order: 20
7 * Description: Write and manage the entries shown on the public changelog page.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15/**
16 * Rebuild the cache the public page reads.
17 * changelog.php serves from engine/cache/changelog and only refreshes it after
18 * a write, so every change here has to refresh it too or the site shows stale
19 * entries until something else happens to save it.
20 */
21function acp_changelog_rebuild_cache(): void {
22 $cache = new Cache('engine/cache/changelog');
23 $cache->useMemory(false);
24 $cache->setContent(db()->fetchAll("
25 SELECT `id`, `text`, `time`, `report_id`, `status`
26 FROM `znote_changelog`
27 ORDER BY `id` DESC;
28 ") ?: []);
29 $cache->save();
30}
31
32// The public page writes 35 for entries created by hand; reports.php writes the
33// report status. Kept as-is so both keep meaning the same thing.
34const ACP_CHANGELOG_MANUAL_STATUS = 35;
35
36// ---------------------------------------------------------------------------
37// Mutations
38// ---------------------------------------------------------------------------
39if ($_SERVER['REQUEST_METHOD'] === 'POST') {
40
41 $do = (string)($_POST['do'] ?? '');
42 $id = intv($_POST['id'] ?? 0);
43
44 if ($do === 'delete' && $id > 0) {
45 db()->execute("DELETE FROM `znote_changelog` WHERE `id` = ? LIMIT 1;", [$id]);
46 acp_changelog_rebuild_cache();
47 acp_log('changelog.delete', '#' . $id);
48 acp_flash_success(t('acp.chg.deleted'));
49 acp_redirect('changelog');
50 }
51
52 $text = trim((string)($_POST['text'] ?? ''));
53
54 if ($text === '') {
55 acp_flash_error(t('acp.chg.empty'));
56 acp_redirect('changelog', $id > 0 ? ['action' => 'edit', 'id' => $id] : []);
57 }
58
59 // The column is varchar(255). Cutting is no longer safe now that the text can
60 // carry BBCode - substr() would happily slice [b]word[/b] into [b]word[/ and
61 // leave the tag dangling on the public page. Refuse instead and say why.
62 if (strlen($text) > 254) {
63 acp_flash_error(t('acp.chg.too_long', ['n' => strlen($text)]));
64 acp_redirect('changelog', $id > 0 ? ['action' => 'edit', 'id' => $id] : []);
65 }
66
67 if ($do === 'update' && $id > 0) {
68 db()->execute("
69 UPDATE `znote_changelog`
70 SET `text` = ?
71 WHERE `id` = ?
72 LIMIT 1;
73 ", [$text, $id]);
74 acp_changelog_rebuild_cache();
75 acp_log('changelog.update', '#' . $id, ['text' => substr($text, 0, 60)]);
76 acp_flash_success(t('acp.chg.updated'));
77 acp_redirect('changelog');
78 }
79
80 if ($do === 'create') {
81 $when = intv($_POST['time'] ?? 0);
82 if ($when <= 0) {
83 $when = time();
84 }
85
86 db()->execute("
87 INSERT INTO `znote_changelog` (`text`, `time`, `report_id`, `status`)
88 VALUES (?, ?, 0, ?);
89 ", [$text, $when, ACP_CHANGELOG_MANUAL_STATUS]);
90 acp_changelog_rebuild_cache();
91 acp_log('changelog.create', '', ['text' => substr($text, 0, 60)]);
92 acp_flash_success(t('acp.chg.published'));
93 acp_redirect('changelog');
94 }
95
96 acp_flash_error(t('acp.chg.unknown_action'));
97 acp_redirect('changelog');
98}
99
100// ---------------------------------------------------------------------------
101// View state
102// ---------------------------------------------------------------------------
103$entries = db()->fetchAll("
104 SELECT `id`, `text`, `time`, `report_id`, `status`
105 FROM `znote_changelog`
106 ORDER BY `id` DESC;
107");
108$entries = is_array($entries) ? $entries : [];
109
110$editing = null;
111if (($_GET['action'] ?? '') === 'edit') {
112 $editId = intv($_GET['id'] ?? 0);
113 foreach ($entries as $entry) {
114 if ((int)$entry['id'] === $editId) {
115 $editing = $entry;
116 break;
117 }
118 }
119 if ($editing === null) {
120 acp_flash_error(t('acp.chg.not_found'));
121 acp_redirect('changelog');
122 }
123}
124
125$fromReports = 0;
126foreach ($entries as $entry) {
127 if ((int)$entry['report_id'] > 0) {
128 $fromReports++;
129 }
130}
131?>
132
133<div class="acp-stats">
134 <?php
135 acp_stat(t('acp.chg.stat_entries'), count($entries), 'fa-list-ul', null, 'blue');
136 acp_stat(t('acp.chg.stat_from_reports'), $fromReports, 'fa-bug', acp_url('reports'), 'purple');
137 ?>
138</div>
139
140<div class="acp-grid acp-grid--2">
141
142 <section class="acp-card">
143 <header class="acp-card-head">
144 <h2><?= $editing !== null ? t('acp.chg.edit_entry', ['id' => (int)$editing['id']]) : t('acp.chg.new_entry') ?></h2>
145 <p><?= t('acp.chg.appears_top') ?></p>
146 </header>
147 <div class="acp-card-body">
148 <form method="post">
149 <?= acp_csrf_field() ?>
150 <input type="hidden" name="do" value="<?= $editing !== null ? 'update' : 'create' ?>">
151 <?php if ($editing !== null): ?>
152 <input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
153 <?php endif; ?>
154
155 <div class="acp-field">
156 <label class="acp-label" for="text"><?= t('acp.chg.text_label') ?></label>
157 <?php acp_editor('text', $editing !== null ? (string)$editing['text'] : '', [
158 'height' => 150,
159 'maxlength' => 254,
160 // A changelog line is one sentence in a varchar(255), so the
161 // toolbar stays small - lists and images would not fit.
162 'toolbar' => 'bold,italic,underline,strike|color,removeformat|link,unlink|undo,redo,source',
163 ]); ?>
164 </div>
165
166 <div class="acp-actions">
167 <button class="acp-btn acp-btn--green" type="submit">
168 <i class="fa fa-check"></i> <?= $editing !== null ? t('acp.chg.save_changes') : t('acp.chg.publish_entry') ?>
169 </button>
170 <?php if ($editing !== null): ?>
171 <a class="acp-btn acp-btn--ghost" href="<?= h(acp_url('changelog')) ?>"><?= t('acp.chg.cancel') ?></a>
172 <?php endif; ?>
173 </div>
174 </form>
175 </div>
176 </section>
177
178 <section class="acp-card">
179 <header class="acp-card-head">
180 <h2><?= t('acp.chg.how_title') ?></h2>
181 </header>
182 <div class="acp-card-body">
183 <p>
184 <?= t('acp.chg.how_text1', [
185 'link' => '<a href="' . h(acp_url('reports')) . '">' . t('acp.chg.reports_link') . '</a>',
186 ]) ?>
187 </p>
188 <p>
189 <?= t('acp.chg.how_text2') ?>
190 </p>
191 <p>
192 <a href="<?= h(acp_site('changelog.php')) ?>" target="_blank" rel="noopener">
193 <i class="fa fa-external-link"></i> <?= t('acp.chg.view_public') ?>
194 </a>
195 </p>
196 </div>
197 </section>
198</div>
199
200<section class="acp-card">
201 <header class="acp-card-head">
202 <h2><?= t('acp.chg.published_entries') ?></h2>
203 <p><?= t('acp.chg.newest_first') ?></p>
204 </header>
205 <div class="acp-card-body is-flush">
206 <?php if ($entries): ?>
207 <div class="acp-table-wrap">
208 <table class="acp-table">
209 <thead>
210 <tr>
211 <th>#</th>
212 <th><?= t('acp.chg.col_date') ?></th>
213 <th><?= t('acp.chg.col_entry') ?></th>
214 <th><?= t('acp.chg.col_source') ?></th>
215 <th class="is-num"><?= t('acp.chg.col_actions') ?></th>
216 </tr>
217 </thead>
218 <tbody>
219 <?php foreach ($entries as $entry):
220 $id = (int)$entry['id'];
221 $reportId = (int)$entry['report_id'];
222 ?>
223 <tr>
224 <td class="is-muted"><?= $id ?></td>
225 <td class="is-nowrap is-muted"><?= h(getClock((int)$entry['time'], true, true)) ?></td>
226 <td><?= h((string)$entry['text']) ?></td>
227 <td class="is-nowrap">
228 <?php if ($reportId > 0): ?>
229 <a class="acp-pill acp-pill--purple" href="<?= h(acp_url('reports', ['action' => 'edit', 'id' => $reportId])) ?>">
230 <?= t('acp.chg.report_hash', ['id' => $reportId]) ?>
231 </a>
232 <?php else: ?>
233 <span class="acp-pill acp-pill--grey"><?= t('acp.chg.manual') ?></span>
234 <?php endif; ?>
235 </td>
236 <td class="is-num is-nowrap">
237 <a class="acp-btn acp-btn--ghost acp-btn--sm" href="<?= h(acp_url('changelog', ['action' => 'edit', 'id' => $id])) ?>">
238 <i class="fa fa-pencil"></i> <?= t('acp.chg.edit') ?>
239 </a>
240 <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.chg.confirm_delete')) ?>">
241 <?= acp_csrf_field() ?>
242 <input type="hidden" name="do" value="delete">
243 <input type="hidden" name="id" value="<?= $id ?>">
244 <button class="acp-btn acp-btn--red acp-btn--sm" type="submit"><i class="fa fa-trash"></i> <?= t('acp.chg.delete') ?></button>
245 </form>
246 </td>
247 </tr>
248 <?php endforeach; ?>
249 </tbody>
250 </table>
251 </div>
252 <?php else: ?>
253 <?php acp_empty(t('acp.chg.no_entries'), 'fa-list-ul'); ?>
254 <?php endif; ?>
255 </div>
256</section>
257