Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
- Phase 1: data model. 13 IPF objects with handlers (category, forum, moderator, topic, post, attachment, report, suspension, readmark, online, digest, archive, permtemplate), group permissions on categories and forums, counters and cascading deletes through handler events, indexes in `imforum_db_upgrade_1()`, labels in English and Dutch, integration tests against MySQL 5.5, MySQL 5.7 (MyISAM) and MariaDB 10.11.
- Phase 2: administration. Admin screens on `icms_ipf_view_Table` and `getSecureForm()` for categories and forums (moderators and group permissions on the forum form), a permission template that new forums start with and that can be applied to existing forums, reports, suspensions, digests, the archive, prune (with a preview and a confirmation) and a full recount. Every operation that writes runs only on POST with a valid security token, deletion confirmations included. Works around three core issues: a missing admin menu on the first request after the menu cache is rebuilt, `count(null)` when a permission select is left empty, and the date picker failing when a form is built before `icms_cp_header()`.
- Phase 3: reading. `index.php`, `forum.php`, `topic.php` (flat and threaded), `viewall.php` (latest, unread, featured), `archive.php`, `rss.php`, search, who is online, signatures, ranks and the poster bar, read marks per topic and per forum ("mark all as read" is a POST with a token), karma and required-reply locks that hide the text until unlocked, three blocks (recent, hot, top posters) and six preferences. Permissions are checked in one `PermissionService`: category access, forum access and view, switched-off forums for moderators and webmasters only. Our own pager replaces the core's, which prints `PHP_SELF` unescaped. Integration tests cover the permission checks, read marks, thread order, karma, online tracking, the archive and search.
- Phase 4: writing. `post.php` starts topics, replies, edits and deletes through one `PostService` that holds all rules: the permissions forum_post, forum_reply, forum_edit, forum_delete, forum_attach and forum_noapprove, suspensions, a flood limit, a time limit for members, locked topics, what the forum allows (HTML, signature, topic prefix), approval and the edit history. Members delete only their own last post of a topic. The form is built on the IPF secure form, with a preview, a disclaimer and a captcha for guests. Attachments go through `Uploader` (extension list per forum, a list of extensions that is never accepted, size limit, a random file name without extension in a folder the web server cannot reach) and are served by `attachment.php`, which checks the read permission and the karma lock and always sends a download. Subscriptions use the core notification system for topics, forums and all forums; the mail is only sent to members who may read the forum and never holds the text of a post. Post text is now stored as typed (`XOBJ_DTYPE_OTHER`): the IPF textarea type ran every text through HTMLPurifier when saving, which turned BBCode into HTML for good and made the HTML, smiley and line break switches of a post meaningless. The text is made safe when it is shown. The welcome post is left for a later release: the core has no event for a new registration to hook it on.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Community forum module for ImpressCMS 2.0, built on the ImpressCMS Persistable Framework (IPF). imforum is the successor of iForum: same features, new code, and an importer that takes over the data of a running iForum installation.

Status: phase 3 (reading). Visitors can read the forum index, forums, topics (flat or threaded), the latest, unread and featured topics, the archive and an RSS feed, and search the forums. Everything is filtered by the group permissions, read marks, karma and required replies are applied, and three blocks are available. Posting, editing and moderating from the front end come in later phases. Categories, forums, moderators, permissions, reports, suspensions, digests, the archive and pruning are managed from the admin area.
Status: phase 4 (writing). Members start topics, reply (with a quick reply under the topic), quote, edit and delete their own posts, attach files and follow topics and forums by notification; guests can post where the group permissions allow it, behind a captcha. Posts wait for a moderator when the group lacks forum_noapprove. Moderation from the front end (move, merge, split, lock, approve, reports) comes in phase 5. Categories, forums, moderators, permissions, reports, suspensions, digests, the archive and pruning are managed from the admin area.

## Requirements

Expand Down
4 changes: 4 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ parameters:
# many core docblocks use "str" and "unknown_type", which are not types
message: '#^Parameter \#\d \$\w+ of (method|class) icms_\w+(::\w+\(\)| constructor) expects (str|unknown_type), #'
identifier: argument.type
-
# icms_ipf_form_Secure::__construct() documents the IPF object as a string
message: '#^Parameter \#1 \$target of class icms_ipf_form_Secure constructor expects string, mod_imforum_\w+ given\.$#'
identifier: argument.type
-
# icms_ipf_Tree::getFirstChild() documents string; the tree is keyed by integer ids
message: '#^Parameter \#1 \$key of method icms_ipf_Tree::getFirstChild\(\) expects string, int given\.$#'
Expand Down
13 changes: 13 additions & 0 deletions src/assets/css/imforum.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,16 @@
.imforum-depth-2, .imforum-depth-3, .imforum-depth-4, .imforum-depth-5,
.imforum-depth-6, .imforum-depth-7, .imforum-depth-8 { margin-left: 1.5em; }
}

/* writing */
.imforum .imforum-actions { margin: 0.75em 0; }
.imforum .imforum-button { display: inline-block; padding: 0.3em 0.9em; border: 1px solid currentColor; border-radius: 3px; text-decoration: none; font-weight: bold; }
.imforum .imforum-prefix { font-weight: bold; }
.imforum .imforum-post-actions { margin-top: 0.75em; text-align: right; font-size: 0.9em; }
.imforum .imforum-post-actions a { margin-left: 0.9em; }
.imforum .imforum-attachments ul { margin: 0.25em 0 0 1.25em; padding: 0; }
.imforum .imforum-quickreply { margin-top: 1.5em; }
.imforum .imforum-quickreply textarea { width: 100%; box-sizing: border-box; }
.imforum .imforum-errors p { margin: 0.25em 0; }
.imforum .imforum-disclaimer { margin: 0.75em 0; padding: 0.5em 0.75em; border-left: 3px solid currentColor; opacity: 0.85; }
.imforum .imforum-confirm { margin: 1em 0; }
56 changes: 56 additions & 0 deletions src/attachment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
/**
* Download an attachment
*
* The files sit in uploads/imforum under random names and are not reachable
* directly. This script checks that the reader may read the post, that the
* karma lock is open, counts the download and sends the file. Everything but
* a few picture types is sent as a download, never rendered by the browser.
*
* @copyright The iForum project
* @license MIT, see LICENSE
* @package imforum
* @since 1.0
*/

include_once dirname(__DIR__, 2) . '/mainfile.php';
include_once __DIR__ . '/include/common.php';

$permissions = mod_imforum_PermissionService::forCurrentUser();
$attachment = icms_getModuleHandler('attachment', IMFORUM_DIRNAME)->get(mod_imforum_View::intParam('id'));
$post = $attachment->isNew() ? null : icms_getModuleHandler('post', IMFORUM_DIRNAME)->get((int) $attachment->getVar('post_id', 'e'));
$forum = ($post !== null && !$post->isNew()) ? $permissions->getForum((int) $post->getVar('forum_id', 'e')) : null;

$allowed = $forum !== null
&& $permissions->canReadForum($forum)
&& (int) $attachment->getVar('online', 'e') === 1
&& ((int) $post->getVar('approved', 'e') === 1 || $permissions->isModerator((int) $forum->id()));
if ($allowed) {
$karma = new mod_imforum_KarmaService($permissions->getUid());
$allowed = $karma->getLock($post, $permissions->isModerator((int) $forum->id())) === mod_imforum_KarmaService::LOCK_NONE;
}
$path = $allowed ? $attachment->getFilePath() : '';
if (!$allowed || !is_file($path)) {
http_response_code($allowed ? 404 : 403);
header('Content-Type: text/plain; charset=utf-8');
echo $allowed ? 'Not found' : _NOPERM;
exit;
}

icms_getModuleHandler('attachment', IMFORUM_DIRNAME)->incrementDownloads((int) $attachment->id());

$name = (string) $attachment->getVar('name_display', 'n');
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?: 'attachment';
$mime = (string) $attachment->getVar('mimetype', 'e');
$inline = in_array($mime, ['image/gif', 'image/jpeg', 'image/png', 'image/webp'], true) && isset($_GET['inline']);

while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: ' . ($inline ? $mime : 'application/octet-stream'));
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $safeName . '"; filename*=UTF-8\'\'' . rawurlencode($name));
header('Content-Length: ' . (int) filesize($path));
header('X-Content-Type-Options: nosniff');
header("Content-Security-Policy: default-src 'none'; sandbox");
readfile($path);
exit;
30 changes: 30 additions & 0 deletions src/class/AttachmentHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ public function __construct(icms_db_legacy_Database $db) {
parent::__construct($db, 'attachment', 'attach_id', 'name_display', '', IMFORUM_DIRNAME);
}

/**
* The attachments of these posts that are online
*
* @param list<int> $postIds
* @return array<int, list<mod_imforum_Attachment>> post id => attachments, oldest first
*/
public function getByPosts(array $postIds): array {
$postIds = array_values(array_filter(array_map('intval', $postIds)));
if (!$postIds) {
return [];
}
$criteria = new icms_db_criteria_Compo(new icms_db_criteria_Item('post_id', '(' . implode(',', $postIds) . ')', 'IN'));
$criteria->add(new icms_db_criteria_Item('online', 1));
$criteria->setSort('attach_id');
$criteria->setOrder('ASC');
$byPost = [];
foreach ($this->getObjects($criteria) as $attachment) {
$byPost[(int) $attachment->getVar('post_id', 'e')][] = $attachment;
}
return $byPost;
}

public function incrementDownloads(int $attachId): bool {
return (bool) $this->db->queryF("UPDATE {$this->table} SET downloads = downloads + 1 WHERE attach_id = $attachId");
}

public function countForPost(int $postId): int {
return $this->getCount(new icms_db_criteria_Item('post_id', $postId));
}

protected function beforeInsert(mod_imforum_Attachment $attachment): bool {
if ((int) $attachment->getVar('attach_time', 'e') === 0) {
$attachment->setVar('attach_time', time());
Expand Down
110 changes: 110 additions & 0 deletions src/class/Notifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php
/**
* Tells the subscribers of a forum or topic about a new post
*
* Only members who may read the forum are told, and the mail holds the title
* and the address but never the text, so a karma lock or a required reply
* cannot be bypassed through a mailbox.
*
* @copyright The iForum project
* @license MIT, see LICENSE
* @package imforum
* @since 1.0
*/

defined('ICMS_ROOT_PATH') || die('ImpressCMS root path not defined');

include_once dirname(__DIR__) . '/include/common.php';

class mod_imforum_Notifier {

/**
* Notify for an approved post
*
* @param bool $newTopic whether the post starts the topic
*/
public function newPost(mod_imforum_Post $post, mod_imforum_Topic $topic, mod_imforum_Forum $forum, bool $newTopic): void {
$notifications = icms::handler('icms_data_notification');
$moduleId = (int) icms_getModuleHandler('forum', IMFORUM_DIRNAME)->getModuleInfo()->getVar('mid');
$forumId = (int) $forum->id();
$topicId = (int) $topic->id();

$events = [];
if ($newTopic) {
$events[] = ['forum', $forumId, 'new_thread'];
$events[] = ['global', 0, 'new_thread'];
} else {
$events[] = ['thread', $topicId, 'new_post'];
}
$events[] = ['forum', $forumId, 'new_post'];
$events[] = ['global', 0, 'new_post'];

$tags = [
'X_POST_URL' => mod_imforum_View::url('topic', ['topic' => $topicId, 'post' => (int) $post->id()]) . '#post-' . (int) $post->id(),
'X_POST_SUBJECT' => (string) $post->getVar('subject', 'n'),
'X_TOPIC_TITLE' => (string) $topic->getVar('topic_title', 'n'),
'X_FORUM_NAME' => (string) $forum->getVar('forum_name', 'n'),
'X_POSTER' => $this->posterName($post),
];
$author = (int) $post->getVar('uid', 'e');
$told = [];
foreach ($events as [$category, $itemId, $event]) {
$subscribers = $this->getSubscribers($moduleId, $category, $itemId, $event);
// a member who gets the mail for one event is not told again for another
$readers = array_values(array_diff($this->filterReaders($subscribers, $forum), $told, [$author]));
if (!$readers) {
continue;
}
$told = array_merge($told, $readers);
$notifications->triggerEvent($category, $itemId, $event, $tags, $readers, $moduleId, $author);
}
}

/**
* Of these members, those who may read the forum
*
* @param list<int> $uids
* @param (callable(int): list<int>)|null $groupsOf the groups of a member; the core member handler by default
* @return list<int>
*/
public function filterReaders(array $uids, mod_imforum_Forum $forum, ?callable $groupsOf = null): array {
$groupsOf ??= static fn (int $uid): array => array_map('intval', (array) icms::handler('icms_member')->getGroupsByUser($uid));
$readers = [];
foreach (array_unique($uids) as $uid) {
$groups = $groupsOf($uid);
$permissions = new mod_imforum_PermissionService($groups, $uid, in_array((int) ICMS_GROUP_ADMIN, $groups, true));
if ($permissions->canReadForum($forum)) {
$readers[] = $uid;
}
}
return $readers;
}

/**
* The category and event are names from icms_version.php, never request input
*
* @return list<int>
*/
private function getSubscribers(int $moduleId, string $category, int $itemId, string $event): array {
$criteria = new icms_db_criteria_Compo(new icms_db_criteria_Item('not_modid', $moduleId));
$criteria->add(new icms_db_criteria_Item('not_category', $category));
$criteria->add(new icms_db_criteria_Item('not_itemid', $itemId));
$criteria->add(new icms_db_criteria_Item('not_event', $event));
$uids = [];
foreach (icms::handler('icms_data_notification')->getObjects($criteria) as $notification) {
$uids[] = (int) $notification->getVar('not_uid');
}
return $uids;
}

private function posterName(mod_imforum_Post $post): string {
$uid = (int) $post->getVar('uid', 'e');
if ($uid > 0) {
$name = icms_member_user_Handler::getUserLink($uid);
return trim(strip_tags((string) $name));
}
global $icmsConfig;
$name = trim((string) $post->getVar('poster_name', 'n'));
return $name !== '' ? $name : (string) ($icmsConfig['anonymous'] ?? '');
}
}
85 changes: 85 additions & 0 deletions src/class/PermissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,91 @@ public function canReadForum(mod_imforum_Forum $forum): bool {
return $this->webmaster || ($this->canSeeForum($forum) && $this->has('forum_view', (int) $forum->id()));
}

/**
* Start a topic in the forum
*/
public function canStartTopic(mod_imforum_Forum $forum): bool {
return $this->canReadForum($forum) && $this->has('forum_post', (int) $forum->id()) && !$this->isSuspended((int) $forum->id());
}

/**
* Reply in a topic; a locked topic takes replies from moderators only
*/
public function canReply(mod_imforum_Forum $forum, mod_imforum_Topic $topic): bool {
$forumId = (int) $forum->id();
if (!$this->canReadForum($forum) || !$this->has('forum_reply', $forumId) || $this->isSuspended($forumId)) {
return false;
}
return !$topic->isLocked() || $this->isModerator($forumId);
}

/**
* Members change their own posts for a while; moderators change any post
*/
public function canEdit(mod_imforum_Forum $forum, mod_imforum_Topic $topic, mod_imforum_Post $post): bool {
$forumId = (int) $forum->id();
if (!$this->canReadForum($forum)) {
return false;
}
if ($this->isModerator($forumId)) {
return true;
}
return $this->isAuthor($post) && $this->has('forum_edit', $forumId) && !$topic->isLocked()
&& !$this->isSuspended($forumId) && $this->isWithinTimeLimit($post);
}

/**
* Members delete their own last post of a topic for a while, so no reply loses what it answered
*/
public function canDelete(mod_imforum_Forum $forum, mod_imforum_Topic $topic, mod_imforum_Post $post): bool {
$forumId = (int) $forum->id();
if (!$this->canReadForum($forum)) {
return false;
}
if ($this->isModerator($forumId)) {
return true;
}
return $this->isAuthor($post) && $this->has('forum_delete', $forumId) && !$topic->isLocked()
&& !$this->isSuspended($forumId) && $this->isWithinTimeLimit($post)
&& (int) $topic->getVar('topic_last_post_id', 'e') === (int) $post->id();
}

public function canAttach(mod_imforum_Forum $forum): bool {
return $this->has('forum_attach', (int) $forum->id());
}

/**
* Posts of these members appear at once instead of waiting for a moderator
*/
public function skipsApproval(mod_imforum_Forum $forum): bool {
return $this->isModerator((int) $forum->id()) || $this->has('forum_noapprove', (int) $forum->id());
}

/**
* A suspension on the member or the address, for this forum or every forum; moderators are exempt
*/
public function isSuspended(int $forumId): bool {
if ($this->webmaster || $this->isModerator($forumId)) {
return false;
}
return icms_getModuleHandler('suspension', IMFORUM_DIRNAME)->isSuspended($this->uid, mod_imforum_View::clientIp(), $forumId);
}

/**
* Guests never own a post: their name is only text
*/
private function isAuthor(mod_imforum_Post $post): bool {
return $this->uid > 0 && (int) $post->getVar('uid', 'e') === $this->uid;
}

/**
* 0 minutes means no limit
*/
private function isWithinTimeLimit(mod_imforum_Post $post): bool {
$minutes = (int) mod_imforum_Settings::get('edit_timelimit');
return $minutes <= 0 || time() - (int) $post->getVar('post_time', 'e') <= $minutes * 60;
}

public function isModerator(int $forumId): bool {
if ($this->webmaster) {
return true;
Expand Down
6 changes: 5 additions & 1 deletion src/class/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ public function __construct(mod_imforum_PostHandler $handler) {
$this->quickInitVar('poster_ip', XOBJ_DTYPE_TXTBOX);
$this->quickInitVar('post_time', XOBJ_DTYPE_LTIME, false, '', '', 0);
$this->quickInitVar('subject', XOBJ_DTYPE_TXTBOX, true);
$this->quickInitVar('post_text', XOBJ_DTYPE_TXTAREA, true);
// Stored as typed. XOBJ_DTYPE_TXTAREA would run the text through HTMLPurifier
// when it is saved, which turns BBCode into HTML for good and makes the
// switches of the post (HTML, smilies, line breaks) meaningless. The text
// is made safe when it is shown, in getFormattedText().
$this->quickInitVar('post_text', XOBJ_DTYPE_OTHER, true);
// JSON list of edits; not a PHP array var, so nothing is ever unserialised
$this->quickInitVar('post_edit', XOBJ_DTYPE_OTHER);
$this->quickInitVar('icon', XOBJ_DTYPE_TXTBOX);
Expand Down
Loading
Loading