Skip to content

fix(forum): close the authorisation holes, and give the plugin its first tests (v2.3.x) - #5862

Merged
Deltik merged 13 commits into
release/v2.3.xfrom
e107help/forum-fixes-v2.3.x
Aug 1, 2026
Merged

fix(forum): close the authorisation holes, and give the plugin its first tests (v2.3.x)#5862
Deltik merged 13 commits into
release/v2.3.xfrom
e107help/forum-fixes-v2.3.x

Conversation

@e107help

@e107help e107help Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The forum sweep from #5861, on the stable line. Sixteen defects, six of them authorisation failures, one reachable with no account at all. The plugin had no automated test of any kind, which is why these have sat there for years.

Every fix arrives with a test that fails without it. Reverting e107_plugins/forum on this branch fails 33 acceptance tests and 3 errors.

Authorisation

Where What was wrong
forum_class.php:455, forum_post.php:1608 "Your own post" was USERID == post_user with no USER check. USERID is 0 for a visitor with no account, and an anonymous post stores post_user = 0, so where anonymous posting is enabled a passing stranger could delete or edit anonymous posts. Needing no cookie, such a request is not challenged for a token either. Needing no configuration at all: any member could delete any post they had ever written, anywhere, including one that opened a thread, leaving the thread with no first post. Neither path enforced "last post", "not the opening post" or thread_active, all three of which the docblock and the UI claim.
forum_class.php:1384, forum_post.php:1743 Arbitrary file deletion. post_attachments is written straight out of $_POST['post_attachments_json'] with no validation of names or paths, and the delete concatenated each stored entry onto the poster's attachment directory and unlinked the result, with no basename and no containment check. Post with a relative path, delete your own post, and e107 removes any file the web user can reach.
forum_class.php:1663, :558, forum_mod.php:14, forum_post.php:1331 A moderator of one forum moderated the whole site, by four separate routes. forumGetMods() memoised one list on the object whatever userclass it was asked about, and the page primes that memo before any write is authorised. ajaxModerate() derived permission from whichever of thread/post arrived last and then acted on the thread regardless. forum_thread_moderate() took its target out of the POST field name and checked nothing. moveThread() was gated only on a MODERATOR computed for whichever forum the mover named. Authorisation now happens at the sink, against the object each action changes.
forum_class.php:295 Quick reply asked checkPerm() about $_POST['post'], a forum, then wrote $_POST['thread'] into post_thread, with nothing tying the two together. Naming any forum you may post in bought a reply in any thread on the site, including one in a forum you are redirected away from. thread_active went unread as well, so this route took replies into closed threads that the ordinary form refuses.
forum_class.php:396 Subscribing asked nothing beyond "are you signed in". trackEmail() then posts the full body of every later reply to everyone in forum_track with no per-recipient check, so a subscription to a thread you cannot open was a standing feed of its contents.

Public routes

forum.php?new was a PHP 8 fatal for every visitor without an account and every crawler, because threadGetNew() dereferenced USERLV bare and class2.php defines it only for a signed-in visitor. The same query carried no forum_class predicate, alone among the listings this plugin ships, and compared thread ids against thread_forum_id, so reading one thread hid every thread in whichever forum carried that id. The page then rendered those thread rows through the template built for forum rows, so under a v2 theme the whole section came out as an empty table.

Reporting a post inserted a row and fired an event that mails the moderators, with no throttle of any kind, on a form anyone who may post could submit as fast as they could script it. It also read USERNAME bare, twice, so on a forum open to guests a report was a fatal rather than a report. It answers to the site's own flood setting now, scoped per reporter and skipped for admins, exactly as posting does.

Client side

Four defects in js/forum.js:

  • Cancel meant delete. Core binds a confirm() to a[data-confirm] and returns the answer, which gets jQuery as far as preventDefault() plus stopPropagation(). That only stops the event reaching ancestors; another handler on the same element runs whatever the visitor answered, and forum.js binds one, first. Clicking Cancel on "delete this thread?" deleted the thread.
  • One click sent more than one request. The elements were filtered with jQuery's one(), a one-time event binder, where jQuery-Once's once() was meant. Given a name and no handler, one() returns the set untouched, so nothing was marked and nothing filtered, and attachBehaviors() runs again after every successful track and quick reply. forum_track has no unique key to stop the duplicate rows or the duplicate notification mail.
  • TinyMCE anywhere on the page broke everything. The quick-reply text was read by calling getContent() on whatever tinymce.get() returned, for every action including moderator links on pages with no quick-reply box. tinymce.get() answers null for a field it is not attached to.
  • A failed request said nothing, anywhere, which looks exactly like a click that never fired.

Reviewers, note one change outside the plugin. e107_web/js/core/all.jquery.js gains stopImmediatePropagation() on the a[data-confirm] and .e-confirm handlers. That closes the same hole for every confirmation dialog in e107, not just the forum's, and it is the only file here that is not forum code.

Correctness

forum_lastpost_info means "when the last post happened, and in which thread", and the recalculation read thread_datestamp, which is when the thread was started, and ordered by it. Deleting one spam post could point a busy forum at a thread nobody had touched in years. threadUpdateCounts() stored the raw row count in a column that excludes the opening post everywhere it is read, so splitting a topic left both halves one reply heavy. "Mark all forums read" carries no forum id and the branch meaning "all of them" tested against 0 rather than emptiness, so it matched nothing and redirected having done nothing.

These three are on the stable line rather than master only, because each is visible to an ordinary visitor: a forum's "last post" points at the wrong thread after any deletion, a split topic reports a page that is not there, and "mark all forums read" silently does nothing.

What differs from #5861

Same defects, same fixes, same tests. This line's database and test APIs are different enough to be worth calling out:

  • There is no query builder here, so the nine new queries are written against $sql->select(), ->gen() and ->count() in the style of the code around them, with every interpolated value cast. Same rows read, same decisions taken.
  • db_verify here has no engine-preference map: getFixQuery() puts the engine straight out of a plugin's SQL into the CREATE TABLE it builds, so a MyISAM schema really does install as MyISAM. The acceptance helper mirrors that. On master the same schemas resolve to InnoDB and the helper mirrors that, with a unit test pinning the two copies together; there is no map here to pin to, so those assertions are replaced by ones describing what this line actually does.
  • This line has no webdriver suite (it ships acceptance, functional and unit), so the four client-side defects ship here with no browser coverage. The source fix is identical to master's and is proven there by ForumActionsCest. Better to say that plainly than to imply parity.

Testing

suite result
acceptance 129 tests, 553 assertions
unit 866 tests, 16830 assertions

Run on PHP 8.2, the newest this branch's CI covers. Every Cest carries positive controls, so a fix that simply refused everything would not pass either. The moderation, self-service, attachment and quick-reply exploits were each demonstrated against a running install before the tests that assert them were written.

Getting the forum into the suite also turned up a broken shared helper: havePluginTables() matched only ) ENGINE=<word>;, so for a plugin whose tables carry AUTO_INCREMENT after the engine it silently created one table out of four and reported success. That is fixed with its own unit tests.

e107help added 13 commits August 1, 2026 14:58
…ould pick

havePluginTables() matched CREATE TABLE with a pattern that required the
statement to end immediately after the engine name. Most of the bundled
plugins that use it declare table options after that point, so the pattern
did not match them.

It did not merely skip them. The body group is lazy, so when a statement
failed at its tail the body ran on until it reached one that did end at its
engine. forum collapsed from four tables into a single match named `forum`
whose body spanned all four, and hero, linkwords and pm matched nothing at
all, which made the helper throw the "no CREATE TABLE statements" error for
plugins whose SQL is perfectly well formed.

The engine was hardcoded to MyISAM on the way back out, and neither taking
that literally nor reading it from the file is right. db_verify treats a
declared engine as a request rather than an instruction and satisfies it
from storageEnginePreferenceMap, so the bundled MyISAM schemas are installed
as InnoDB on any server that has InnoDB, and hero's InnoDB could legitimately
become XtraDB. A test given MyISAM was therefore working against a table with
no transactions and different FULLTEXT behaviour from the one the plugin
manager builds.

Let the tail run to the semicolon, then resolve the declared engine the same
way the handler does.

The helper keeps its own copy of the alias table because the acceptance suite
runs outside the application and cannot boot db_verify. A test compares the
two by reflection, so the copy cannot drift silently.

The parse and the engine resolution move into static methods so they can be
tested without a module container, and the tests read the shipped SQL rather
than a fixture, so they fail if the helper and e107 ever disagree.
The forum plugin has never had a test. Standing one up is not a matter of
creating its tables, and the shape of the data decides whether the tests that
follow mean anything.

Every front-end page redirects on the plug_installed preference, so the plugin
has to be installed rather than merely present; postDelete() also touches
user_extended columns that come from the plugin's extendedFields rather than
from forum_sql.php. The fixture therefore installs it properly, once per suite.

The trap worth naming: _getForumPermList() only grants permission on a forum
whose forum_parent is not 0 AND whose parent row passes the same class test.
A forum seeded at the top level grants nobody anything, so a suite built on one
would see every "X cannot do Y" assertion pass without exercising a line of the
code it names. forum.forum_moderators is also a tinyint holding a single
userclass id, and a stored 0 is userclass 0 (Everyone) rather than "admins",
which forumGetMods() only infers from e_UC_ADMIN or an empty string.

So the Cest here asserts the fixture from both sides before anything relies on
it: a public forum is readable, a restricted one is not, a seeded member can
sign in and reach a members-only page a guest is bounced from, the two forums
carry different moderator classes, and neither member is an admin. That last
one matters because getperms('0') short-circuits every MODERATOR test in the
plugin, so an admin would pass the very checks the later tests exist to probe.

Anything needing the application itself goes through a probe file dropped into
the docroot for the run, the same shape 0020, 0022 and 0023 use. Rows go in
through haveInDatabase so the Db module takes them out again after each test.

csrf_enforce is pinned per test rather than inherited. 0023 removes the
preference in its teardown and runs immediately before this file, and an unset
value resolves to a mode that reads no token and expects a Sec-Fetch-Site
header PhpBrowser never sends.
A moderator of any one forum could delete, lock, unlock, stick and unstick
threads and posts in every other forum on the site, by three independent
routes. Each was reproduced against a running install before this change.

forumGetMods() memoises one moderator list on the forum object regardless of
which userclass it is asked about, and the three getModeratorUserIdsBy*()
helpers all went through it. Both forum_viewtopic.php and forum_viewforum.php
prime that memo from the id in the URL while working out MODERATOR, so by the
time any write was authorised the answer had already been fixed to the forum
being viewed, whatever thread or post the request actually named.

That memo cannot simply be keyed by userclass: templates read it to render a
forum's moderator list and to badge post authors, so changing what it holds
changes what they display. Authorisation now uses a separate lookup that is
keyed by class and never touches it.

ajaxModerate() then took whichever of thread or post arrived last, derived the
permission from that, and acted on the thread anyway. Its own comment warned
about precisely this and the code did the opposite. Each action is now
authorised against the object it changes, and an action that names no id is
refused rather than falling through to a handler with an unset variable.

forum_thread_moderate() took its target straight out of the POST field name
and checked nothing whatsoever, relying on a MODERATOR constant its caller had
computed for a different forum. It now authorises every id it is handed.

moveThread() checked MODERATOR alone, which answers for neither end of a move:
the destination was never authorised at all, so a moderator could push a thread
into a forum they have no rights over. Both ends are checked now.

The tests exercise all three routes, and each carries a positive control that
the same action still succeeds where it was granted, because a fix that refused
everything would otherwise turn the file green. Reverting this change makes six
of them fail.

One gap worth stating: the no-id case asserts only that nothing is destroyed.
Its real symptom was a PHP warning printed ahead of the JSON, which the test
environment does not render, so that part is not pinned by a test.
The self-service delete authorised on `USERID == $row['post_user']` and
nothing more. USERID is 0 for a caller with no account, and an anonymous post
stores post_user 0, so every unauthenticated visitor owned every anonymous
post on the site. Neither that handler nor postDelete() looks at which forum
the post belongs to, so the reach was site-wide, including forums the caller
could not read. A request with no cookie also carries no ambient authority, so
the CSRF rule lets it through by design and nothing else stood in the way.

Reproduced against a running install: one POST, no account, no session, no
token, and the row was gone.

The same pseudo-identity sat in isAuthor(), which guards editing. There a
guest could rewrite another guest's post, and that is reproduced by a test
here too.

Three more rules the endpoint's name, its docblock and the control that offers
it all promise, and which the server never checked: the post must be the last
in its thread, must not be the one that opened it, and the thread must still
be open. forum_post.php's own checkPerms() has always honoured the last of
those. Restoring them narrows what a member may do, but to what e107 already
told them they could do; the shortcode has only ever offered the control under
exactly these conditions.

Deleting a thread's opening post through this route left the thread row behind
with nothing to open it, which is the case worth calling out separately.

Reverting this change fails five of the tests added with it. The two that
still pass are deliberate guards rather than regression proofs: refusing
somebody else's post already worked when both ids were non-zero, and the
member's own last post must still delete, which is the control that stops the
rest of the file going green by breaking the feature.
…directory

post_attachments is written straight out of $_POST['post_attachments_json']
with no validation whatsoever, and postDeleteAttachments() concatenated each
stored entry onto the poster's attachment directory and unlinked the result.
A member could submit an ordinary reply carrying a relative path, delete their
own post, and have e107 remove any file the web user could reach.

Demonstrated end to end against a running install through the real forms: a
reply through the reply form, a delete through the delete control, and a file
at the docroot root was gone. e107_config.php is the obvious target, and a
site missing it believes it is uninstalled.

The entry is now refused unless it is a bare filename, and the same check runs
on the way in so a value that could never describe a real upload is not stored
at all.

The identical loop was also broken for legitimate attachments. Uploads store
array('file' => ..., 'name' => ..., 'size' => ...) per entry and only
sendFile() ever carried the shim for that shape, so here the array was
concatenated onto the path: every real attachment was "deleted" as a file
named Array, the actual upload was orphaned, and post_attachments was then
cleared, leaving nothing to find it by. Both list keys were also read without
checking they were set.

One precondition is worth recording because it cost a false negative first
time round. The traversal only resolves when the poster's own attachment
directory exists, since the operating system walks user_000002/../.. one
component at a time. It exists as soon as that member has ever attached a
file, so the fixture creates it the way an upload does. Skipping that made the
unlink fail for an unrelated reason and the defect read as absent.

Fixture threads and posts are now backdated an hour. e107's flood check
compares the newest thread_datestamp against now, so a fixture stamped "just
now" made every reply a test tried to post look like flooding and it was
refused with nothing written, which turned an assertion about what got stored
into an assertion about an empty table.

Reverting this change fails all four tests added with it.
ajaxQuickReply() took two ids out of the request and never related them.
checkPerm() was asked about $_POST['post'], a forum, and $_POST['thread']
was then written into post_thread, so naming any forum you may post in
bought a reply in any thread on the site, including one in a forum you
are redirected away from. thread_active went unread as well, while the
ordinary posting form has refused a closed thread since forever.

The thread now decides the forum: it is read back from forum_thread,
that forum answers the permission question, and it is what the row is
filed under. A closed thread takes replies only from a moderator of it,
which is the licence checkPerms() grants in forum_post.php.

ajaxTrack() asked nothing beyond "are you signed in". trackEmail() posts
the full body of every later reply out to whoever sits in forum_track
with no per-recipient check, so a subscription to a thread you cannot
open is a standing feed of its contents. Subscribing is a way of
reading, and now answers to the same view permission the forum's own
pages do, on both routes that dispatch it.

The cross-forum reply was demonstrated against a running install before
the tests were written. Reverting this commit fails five of the nine
tests in 0028_ForumPostingCest; the other four are controls, so a fix
that simply refused everything would not pass either.
…ssions

forum.php?new is a public route and the query behind it was wrong three
ways.

USERLV is defined only in the authenticated branch of class2.php and was
dereferenced bare, so on PHP 8 the page was a fatal for every visitor
without an account, and for every crawler. It now falls back to zero,
which is what login_menu does, and the read-state lookup no longer
assumes a user row is there to read.

There was no forum_class predicate, alone among the listings this plugin
ships: e_search, e_rss and e_list have all filtered on it for years. The
query now offers only forums the caller may open. What reaches the page
today is not a disclosure, because the page cannot print it: forum.php
hands the thread rows to the template built for forum rows, so a v2 theme
renders the section empty. That is a fourth defect and it is not fixed
here, but the query should not be the thing standing between a member and
a closed forum once it is.

The already-read filter compared thread ids against thread_forum_id, so
reading one thread hid every thread in whichever forum happened to carry
that id. forumGetUnreadForums() has always compared the right column.

Because the page prints no thread names, the two filters are asserted
against the query rather than the rendered output; the fatal is asserted
against the page, which is where it lands. Reverting this commit fails
four of the five tests in 0029_ForumNewListingCest, one of them by the
guest fatal itself.
Three ways the forum's AJAX replies were untrue.

A duplicate reply reported success. postAdd() answers a duplicate with
-1 and that went straight into post_id, so the reply slid into the page
looking accepted and was gone on the next refresh. forum_post.php has
reported it properly for years. The check ignores the thread on purpose,
so the way to reach it is the same text in two threads of one forum,
which is what the test does; the double-click route is confounded by
forum.js binding its click handler more than once.

An empty reply reported nothing. The response array was only built on
the way through the success path, so the page received neither status
nor msg and said nothing at all. It is now always an array, always with
a status, and the failure paths each name their own reason.

Moderation swallowed every other AJAX request on the page. The forum
called ajaxModerate() whenever the viewer was a moderator, whatever the
action, and ajaxModerate() always ends by printing JSON and exiting. A
moderator's poll vote, rating or plugin widget on a forum page was
answered with a forum error while the same click by an ordinary member
went through, which reads as a permissions fault and is not one. Both
dispatch sites now ask first, through one predicate rather than a list
repeated per page.
forum_lastpost_info means "when the last post happened, and in which
thread": postAdd() writes post_datestamp into it. The recalculation read
thread_datestamp instead, which is when the thread was started, and
ordered by it, so a forum's last post became its newest thread, dated to
that thread's birth and credited to whoever had posted in it most
recently. postDelete() and threadDelete() both trigger the
recalculation, so removing one spam post could point a busy forum at a
thread nobody had touched in years.

thread_total_replies excludes the opening post everywhere it is read;
postAdd() increments it once per reply and every reader adds one back.
threadUpdateCounts() stored the raw row count, so splitting a topic left
both halves one reply heavy, which the reader turns into a page that is
not there.

"Mark all forums read" carries no forum id, and the branch meaning "all
of them" tested against 0 rather than emptiness, so the request took the
per-forum path with a list containing null, matched nothing and
redirected having done nothing. Every other link carries an id, which is
why only the board-wide one was affected.

Reverting this commit fails all five tests in 0030_ForumCountsCest.
The rows on forum.php?new are threads. Under a v2 theme forum.php handed
them to $FORUM_TEMPLATE['main']['forum'], the template written for forum
rows, whose shortcodes ask for {FORUMNAME}, {REPLIESX} and {THREADSX}.
None of those resolves against a thread, so the section rendered as an
empty table and the page was decoration. Nobody had noticed because the
same page fatally errored for guests and answered members with an empty
result, both fixed alongside this.

The legacy template has carried the right shortcodes since v1
({NEWIMAGE}, {NEWSPOSTNAME}, {STARTERTITLE}, all implemented in
forum_shortcodes.php); the v2 setup simply never gained a section of its
own, and the comments beside those three assignments show the keys that
were meant to exist. This adds them, in the v2 style, and reads them with
merging on and keyed to the section, so a theme whose template predates
this falls back to the plugin's and one that overrides part of it keeps
the rest.

No breadcrumb in the new section: it renders before forum.php builds
$breadarray, and the forum listing directly underneath carries one.
Cancel meant delete. Core binds a confirm() to a[data-confirm] and
returns the answer, which gets jQuery as far as preventDefault() plus
stopPropagation(); stopPropagation() only stops the event reaching
ancestors, so another handler on the same element runs whatever the
visitor answered. forum.js binds one, and binds it first, because
attachBehaviors() is registered ahead of that ready block. A moderator
who clicked Cancel on "delete this thread?" deleted the thread. forum.js
now asks the question itself and stops the event dead on both answers,
so nothing acts on a refusal and nobody is asked twice; core's handler
gains stopImmediatePropagation() as well, which closes the same hole for
every other a[data-confirm] in e107.

One click sent more than one request. The elements were filtered with
jQuery's one(), a one-time event binder, where jQuery-Once's once() was
meant: given a name and no handler, one() returns the set untouched, so
nothing was marked and nothing filtered. attachBehaviors() runs again
after every successful track and quick reply, so handlers piled up, and
forum_track has no unique key to stop the duplicate rows or the
duplicate notification mail.

Any action on a page carrying TinyMCE for something else threw before
the request was made: the quick-reply text was read by calling
getContent() on whatever tinymce.get() returned, including on moderator
links on pages with no quick-reply box at all. tinymce.get() answers
null for a field it is not attached to.

And a request that failed said nothing anywhere, which looks exactly
like a click that never fired. There is an error callback now.

All four are proven on master by ForumActionsCest, which this line has
no webdriver suite to run: v2.3.x ships acceptance, functional and unit
only. The source fix is identical; the coverage is not, and the pull
request says so rather than implying parity.
Reporting a post inserts a row and fires an event that mails the
moderators, and it had no throttle of any kind. Anyone who may post
could submit that form as fast as they could script it, with the site's
own mail server doing the sending, and on a forum open to guests they
did not even need an account. Posting has answered to the site's flood
setting for as long as the plugin has existed; reporting answers to the
same one now, scoped to the reporter so one person cannot silence
everybody else's reports, and skipped for admins exactly as posting
skips it. Guests share a bucket, which is the conservative direction for
an unauthenticated way to send mail.

The same handler read USERNAME bare, twice, and class2.php defines it
only for a signed-in visitor, so on a site that allows anonymous posting
a guest reporting a post got a PHP 8 fatal rather than a report. That
turned out to be live: reverting this commit, the guest test fails on
the response code.

Reverting fails two of the three tests in 0031_ForumReportCest; the
third is the control that an ordinary report still lands.
Everything in the twelve commits above was written against master, which
has a query builder and an engine-preference map in db_verify. This line
has neither, so the parts that touched them are rewritten rather than
carried over.

The queries added by the authorisation and bookkeeping fixes now use
$sql->select(), ->gen() and ->count() the way the rest of this file
does, with every interpolated value cast. Behaviour is unchanged: the
same rows are read and the same decisions taken.

havePluginTables() creates each table with the engine its schema
declares, because that is what db_verify::getFixQuery() does here: it
puts the declared engine straight into the CREATE TABLE with no
substitution, so a MyISAM schema really does install as MyISAM on this
line. Master resolves the same schemas to InnoDB through a preference
map and its copy of the helper mirrors that, with a unit test pinning
the two together. There is no map here to pin to, so that test is
replaced by ones asserting what this line actually does.

The fixture also empties the forum tables once per run, before the
first test seeds anything. Rows the probe inserts and rows the
application creates are tracked by neither the Db module nor the tests,
which clean up after themselves only when they pass, so a run that
failed part way through left rows behind that the next run's assertions
counted. One real failure was turning into a second, unrelated-looking
one on the next run.
@Deltik
Deltik merged commit c88e399 into release/v2.3.x Aug 1, 2026
74 of 75 checks passed
@Deltik
Deltik deleted the e107help/forum-fixes-v2.3.x branch August 1, 2026 15:20
@rica-carv

Copy link
Copy Markdown
Member

Just wondering, what abou my old forum pr's???
#5425
#5423
#5415
#5414
#5413
#5402
#5346
#5325
#5320
#5309
Should i discard them?

@Deltik

Deltik commented Aug 2, 2026

Copy link
Copy Markdown
Member

@rica-carv, I'm having @e107help go through your old open pull requests about the forum plugin. We should have actionable decisions for each of them soon-ish.

@e107help

e107help Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@rica-carv Boas! Done, and the short answer to "Should i discard them?" is no. Not one of the ten.

I have read all of them against master at c7cc268, checked each for conflicts, and left a review on every thread with the reasoning. Seven of the ten still merge cleanly after last month's forum work. Where the fix was mechanical I pushed it to your branch myself rather than sending you another round of notes, since maintainer edits are enabled; each review says exactly what I changed.

Ready as far as I can tell

  • Forum postoptions bug #5423 is a real bug and it still reproduces today. A moderator reading their own last post gets the delete entry twice, once from the user block and once from the moderator block. Your guard is correct.
  • Added support for v2.3.3 wrapper in newforumposts #5346 is one line and I verified the compatibility worry properly: $SC_WRAPPER travels through sc_style, not through the array the handler clears, so existing templates are untouched. Your description was right.
  • Forum quickreply templatization #5414 I have finished. @CaMer0n told you it was two tweaks from a commit, you did the tweaks and asked for a re-review on 7 February 2025, and then we left you waiting eighteen months. Sorry. Four small behavioural fixes went in on top of your work, including a strpos() that treated a match at position 0 as a miss.

Fixed, and worth explaining

  • Reopening pull request #2065 forum userlist wrapping #5309 was stalled on an objection @CaMer0n raised in 2016 and @Jimmi08 repeated in 2024. They were right, and about something narrow: the wrapper sat inside the if (!isset($FORUM_MAIN_END)) guard, so a theme supplying its own $FORUM_MAIN_END skipped it and lost the label and the online.php link. That is why bootstrap3 looked fine when you tested. Moving the assignment above the guard settles it, and the markers in core asking for this change have been yours since 2016.
  • Forum breadcrumb shortcode update #5413: the direction is right, the true was not. e_form::breadcrumb() returns null on THEME_VERSION 2.3 themes on purpose, so their own {---BREADCRUMB---} is the only trail on the page; forcing it gave them two.
  • Forum post content shortcode parms management #5415: the parm never reached the truncation, because the menu always fills in the preference it was hiding behind. Rewired so the template parm wins.

Need a decision from you

  • Forumjump enhancement #5402's parent-name jump list is a genuine usability win, but forumGetAllowed() moved to the query builder for v2.4 and the branch now conflicts. The review has the replacement query written out.
  • Some forum code cleanup #5320 bundles three changes wanting three different answers. The dead-variable removals could land tomorrow; retiring the {THREADTITLE} family would blank the table headings on themes carrying their own forum template; and the new redirect fires after HEADERF so it cannot send its header. Splitting the safe part out gets a third of it merged quickly.
  • Forum icon sc template cleanup #5425 I would hold, but the design question underneath it deserved an answer years ago and never got one, so I have answered Should templates have access to core functions? #5424 on the thread. Short version: {GLYPH} cannot carry those constants because the parser is a single pass and never re-scans what a shortcode returns, so the tokens would print literally everywhere except the one call site you patched.
  • Templatization of forum stats page #5325 you already flagged as needing cleanup, and forum_stats.php has since been migrated to the query builder, so the branch conflicts hard. The two new files are the valuable part and are worth rebuilding on their own.

On the ones marked as needing your call: if the appetite has gone after this long a wait, that is entirely fair. Say so on the thread and we will carry them with credit to you. :-)

Approving and merging is @Deltik's, not mine, so nothing here is a merge decision. But none of these deserved to sit for two years, and none of them should be discarded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants