Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / Discussion

Discussion#

The smallest module in the system — 12 entities, no dynamic parameters, no saved-search alerts, no expiry. The code lives in app/UI/Api/Discussion/, the front end in app/UI/Front/Discussion/.

What the module is made of#

Group Entities
content Message (a nested set), MessageAssetRel
taxonomy Category, CategoryText, CategoryPrice (the category price list)
interaction MessageVote, MessageReport
payment Tariff, TariffText, TariffPrice, TariffQuickPrice, PaymentItem

The module has no blacklisted words of its own. It reads one list for the whole website, the system table cms_system_blacklisted_words (System → Blacklisted words), through the Api System\BlacklistedWordManager. MessageManager::beforeSave() refuses a visitor's subject or text containing a blacklisted word of the page language (textRefusal()), and CommentManager does the same for comments in threads of a type that asks for it (ThreadType::checksBlacklistedWords(), today only DiscussionMessage). The administrator, cron and internal jobs are not checked. The refusal carries the errorCode blacklistedWord (BlacklistedWordRefusal) and the front shows the message front.general.blacklistedWord.

The module tables of blacklisted words no longer exist

The former cms_mod_discussion_blacklisted_words (and the copies in classifieds and the company catalogue) were dropped by a migration together with their entities, managers and repositories. New code uses only the system BlacklistedWordManager (findActiveValues(?languageId), textRefusal()).

A thread and a post are one entity#

Message is a nested set (lft, rgt, level). A thread is a root node, a reply is its descendant — there is no type difference between "topic" and "post".

Moving a post is not constrained in any way

An administrator can drag any node anywhere and the system does not check whether it makes sense. Dragging a reply under a different topic makes the discussion incoherent, and there is no undo button.

Deleting a node deletes the whole subtree

Deleting the opening post removes the entire thread including every reply. In the listing it looks like deleting a single row.

The post text is sanitized on the server#

The author writes in the reduced editor (addSimpleEditor() in MessageFormComponent), and both the post detail and the style 3 list print text with |noescape. That is why MessageManager::beforeSave() runs it through EditorHtmlSanitizer::purify() on every save — from the website, the administration and the external API alike. The allowed tags are the same as for advert parameters and in the client editor (EditorHtmlSanitizer::ALLOWED_HTML).

subject is not sanitized: it is plain text and the templates always escape it. Purifying it would only mangle legitimate text containing <.

Sanitization belongs in the manager, not in the form

The editor whitelist in rich-editor.js lives only in the browser. A request outside the form bypasses it, and without the check in beforeSave() the result would be stored XSS. In detail: Forms.

What the module does not have#

More useful than a feature list is knowing what is not here — it saves you hunting for a setting that does not exist:

Missing How it is worked around
locking a thread turn off "allow comments", or deactivate the post
paid read access to a category only founding a topic in a paid category and post tariffs are paid for
per-category permissions only the global "signed-in users only" switches

A category has an active flag. A disabled category, or a category under a disabled parent, returns 404 on the website (CategoryTrait::isCategoryBranchActive()).

Thumb voting#

Votes are MessageVote rows with the value 1 = thumbs up, 0 = thumbs down. The counters on the post (votesUp, votesDown) are recounted by Api\MessageVoteManager from the vote rows (cms_mod_discussion_message_votes) in one UPDATE inside the vote's save transaction — after a save, a delete, a bulk deleteAll() and an updateColumns of vote/messageId. As with ratings (RatingVoteManager), the vote path writes the counter itself; the vote table is the source of truth.

Vote counters do not belong in registerEvent

The front used to call :Api:Discussion:Message:registerEvent with votesUp/votesDown separately after saving the vote. That endpoint is granted to guests and bypasses MessageVoteManager::beforeSave(), so a script could raise a counter without a vote as often as it liked, and deleting a vote never lowered it. The registerEvent allowlist now holds only the view counters.

A drifted counter is corrected by the first vote

The recount is absolute, so the first new vote aligns the counter with the real votes (on DEV post 21 had three thumbs-down votes and a counter of 0).

Everyone votes once per message. MessageVoteManager::beforeSave() enforces it (on insert only, through hasVoted()):

Voter Identified by What holds it in the database
logged in the account (userId), the IP is ignored the unique index uq_message_user (message × user)
anonymous the IP only (insertIp) nothing — the check lives only in the manager

After a vote, raise the displayed value too

votesUp and votesDown are in MessageManager::$nonInvalidatingColumns, so the write does not invalidate the query cache (see Cache). The entity the row is redrawn from therefore holds a value one lower and the voter would not see their vote. VoteTrait::bumpDisplayedVotes() raises the number in memory after a successful write, without another DB read, and redrawControl() sends it into the snippet. It is not called on DB_DUPLICATE_ENTRY.

A second vote returns DB_DUPLICATE_ENTRY; for that code the front end does not raise the counter and says the visitor has already voted. On the external path the server fills in the voter from the JWT and the IP from the request; otherwise de-duplication would have nothing to compare.

Do not replace the manager check with an index

The index does not catch anonymous voters (a row with a NULL user never collides), and an index on the IP would block two different people behind one company network. A logged-in voter caught only by the index makes the save fail with a unique violation, which closes the EntityManager for the rest of the request.

Post reports#

A visitor reports a post (MessageInfoLine::handleReport()); a MessageReport is created (user, IP, time, solved = false). One reporter has at most one unresolved report per post (a logged-in user by account, a guest by IP); another one returns DB_DUPLICATE_ENTRY and the front shows it as a success. After resolution the reporter may report again.

Rule Implementation
a write only from AJAX #[Requires(ajax: true)] — a bare GET (a robot) is 403
the ID from the address is compared a report is created only for the message of this component; a planted ID creates nothing
the Message::$reported flag is derived a post is "reported" while it has at least one unresolved report
the external :Api:Discussion:MessageReport:save the target post must exist (otherwise ITEM_NOT_FOUND) and a logged-in user draws on the creation quota; this path never sees the front-end ID check

The flag is recalculated by the shared trait ReportedFlagSyncTrait (syncReportedFlags()), used by Api\MessageReportManager and by the AdvertReportManager of classifieds and the company catalogue. The manager only tells what the target is and how the flag is read and written (reportTargetField(), reportTargetIdOf(), reportTargetIdsOf(), targetHasUnsolvedReports(), writeTargetReported()). The recalculation runs after every change: a save, updateColumns on solved (resolve / reopen) or on the target column messageId (moving a report; both the old and the new post are recalculated), a delete and a bulk deleteAll() (it bypasses the delete hooks, so the targets are collected beforehand). Discussion counts the unresolved reports by a direct DQL query, not through findAll, so the result is not from a stale query cache. A failed recalculation goes to the log, not to the user. The administration does not touch the flag.

The reported flag is not set by hand

updateColumns($id, ['reported' => …]) on a post is only for MessageReportManager. A manual write drifts away from the report overview.

The report administration: Admin\Discussion\Presenters\MessageReportPresenter (/admin/diskuze-nahlasene-prispevky/prehled), SQL sql/2026-10-02_07_admin_discussion_message_reports.sql (an admin page, keys :Admin:Discussion:MessageReport:{show,edit,del}, a menu item under Discussion). The signals The list, solving and reopening come from the shared ReportModerationPresenterTrait (the same for the AdvertReportPresenter of classifieds and the company catalogue); the presenter only names the target and the privileges. The signals solve! and reopen! (handleSolve(), handleReopen(), POST only) have a server-side gate checkPrivilege(…:edit) — a hidden button is not protection. The default view is the filter solved = 0; rows are decorated by decorateReportRows() with two queries (targets and reporters), without N+1.

Operator guide: Reported posts.

Payments#

Two things are paid for: founding a topic in a paid category and post tariffs. The payment callback is DISCUSSION_PAYMENT (PaymentCallbackRegistry), SMS services DISK (DiscussionExpressServiceProvider).

A discussion category has a price list like a classifieds category: CategoryPrice per payment region, gateway and currency, a supply kind, the "Free" flag (for_free) and the option "Set prices for subcategories too". The entity implements PricedCategoryInterface, and copying the price list down the subtree is done by the shared CategoryPriceSyncTrait (the same as in classifieds and the company catalogue). The external Api cannot overwrite for_free (#[ExternalReadonly]).

Step What happens
inserting a topic MessageManager writes the category relations with their own paid (free = 1, paid = 0) and the topic gets messages.paid: it is paid when at least one category is free; otherwise it is saved unpaid
a reply, a topic without a category always paid
editing a topic a kept category keeps its paid, a new one gets paid from for_free
payment the payment page offers an item (PaymentItem with categoryId) for every paid, not yet paid category of the topic
settling DISCUSSION_PAYMENT after activating the items, PaymentItemManager calls MessageManager::markCategoriesPaid(): it marks the paid relations and the topic, credits the content rewards and reindexes

An unpaid topic is left out of lists, TOP, the sitemap and content credits (paid = 1 belongs to the "published" condition next to approved_at and confirmed_at). The author sees it on the detail with a notice and a Pay button, and in "My posts" with a badge leading to the payment.

Post tariffs#

Tariff Effect Where the ID comes from
topping fills toped_until; the list sorts topedUntil > NOW() first and the TOP badge reads Message::isToped() discussionToppingTariffId (default 1) in config/Shared/bazaar.neon → AdvertToppingConfig
highlighting an active valid item gives the list card the highlighted class discussionHighlightTariffId (default 2) → AdvertHighlightConfig
Topbox a post with an active, valid item of the tariff shows in the "TOP" side widget (TopMessageList, in aside-left.latte of all themes) the template passes the tariff id: {control frontDiscussionTopMessageList [3], 'Style_2', 10}
show links web addresses in the text are clickable links on the detail (TextLinkifier, filter \|linkify, rel="ugc nofollow"); without the tariff they stay plain text discussionLinksTariffId (default 4) → AdvertLinksConfig

The TOP badge reads toped_until, not payment items

The list loads only items of the highlighting tariff, so the former template test on tariff 1 was never true and the badge never showed. It is now driven by Message::isToped() — the same value as the sort order.

Classifieds and the company catalog have Topbox and links too

There Topbox is TopAdvertList with tariff 3 and links are tariff 4 (bazaarLinksTariffId, comcatLinksTariffId). Without the tariff an address there is also hidden behind [URL] (the parameter filter in the "on render" mode), see Bazaar.

A payment item in the administration must have a post (form.messages.missingMessage); the column is nullable in the DB, so the guard is in the form. A bulk deleteAll() of items recalculates toped_until the same way as delete().

Which path recalculates toped_until only upwards and which absolutely (the Payments tab of the message edit sends meta['recalcTopedUntil']) is shown in the table in Classifieds; discussion has the same, only over messages.

Deleting an active payment item shortens the highlight

An extension from a payment only moves upwards (it is monotonic); a manual change in the administration can go both ways — and deleting the item behaves as a manual shortening.

Settings#

The module has index 8 in the settings table. The form today writes:

Key Type
message.perPage, comment.perPage, maxImageSize number
message.approval.auto, message.confirmation.required.logged, message.confirmation.required.guest, message.onlyForLoggedIn, comment.onlyForLoggedIn, vote.onlyForLoggedIn switch

Approval and confirmation of a post#

A post has two independent conditions for publication, both evaluated at the moment of posting (thread or reply, from the web, the API or the administration):

Condition Column What controls it
approval by an administrator approved_at message.approval.auto; an administrator approves with the Schváleno (approved) switch
confirmation by the author via an email link confirmed_at (+ key fingerprint in activation_key) message.confirmation.required.logged and .guest — depending on whether the post has an owner (userId)

When confirmation is not required, the post gets confirmed_at at once. When it is, the post gets a key and an email with the link goes to the author; if there is no email address, it is confirmed at once (the link would have nowhere to go). The public read paths (listing, search, favourites, sitemap, express SMS payment) always require both approved_at and confirmed_at; the detail of a waiting post is seen only by the logged-in author, with a message, without comments and recommending. "My posts" does not filter out waiting posts and labels them. Flipping a setting leaves posts already posted unchanged.

Image dimensions and the watermark are not in the settings — do not bring them back

The variants are read from the DB (usages discussion-messages and discussion-categories, see Image variants), the watermark is the global watermarkPath; the assignment is edited in the Obrázky part below the settings form. The old keys were deleted on 2026-08-13; if somebody put them back into the form, the first save would recreate them, because saveSettingForm() writes every listed key.

Images on a post#

Saving replaces the whole list of images

If the upload field does not come up, the attachments disappear — the same mechanism as with any other collection. In detail: Forms, the section on file uploads.

Deleting a thread (deleteNode(), and also the bulk deleteAll(), which deletes message by message through deleteNode()) deletes its comment and rating threads, the images with their files and the ES document.

The main image (Message::$asset) is derived from the image relations. On the external :Api:Discussion:Message:save the owner cannot set it (#[FieldAccess] with Privileged on the Api entity, the same as Advert::$asset); otherwise they could point the post at someone else's image.

Guest post management#

The same mechanism as for adverts (shared ManageKeyTrait over ManageKeyAwareInterface):

  • A post without an account gets a random secret in the password column on insert (a value from the data is ignored); nobody knows it, the link and the edit capability are bound to it.
  • The emails after posting (DS_MESSAGE_INSERT, DS_MESSAGE_ACTIVATION_REQUEST) carry the link diskuze-editace/<id>?manageKey=…. The DB holds only the hash (manage_key_hash) and the validity (manage_key_expires, 7 days); using the key does not consume it.
  • :Api:Discussion:Message:redeemManageKey verifies the key and issues a capability with the scope discussion.message.edit (CapabilityTokenIssuer) — a 2-hour edit session and a redirect to the edit page without the key in the URL.
  • Only userId carries the owner; the Api MessagePresenter accepts the owner by identity, or by a capability carrying the hash of the secret from the DB. Changing the secret invalidates the link and the session.
  • Migration 2026-10-27_09_discussion_message_manage_link.sql (columns, RBAC key, the {if $manageLink} paragraph in the email templates).

Scheduled tasks#

Endpoint What it does Recommended interval
/cron/discussion/elastic/sync incremental index synchronisation every 10 min
/cron/discussion/elastic/reindex a full reindex nightly
/cron/discussion/sitemap/{message,category} sitemaps nightly

Where to look#

I want Where
the post and tree logic app/UI/Api/Discussion/Models/Managers/MessageManager.php
voting app/UI/Front/Discussion/Components/CommentListRowComponent/Traits/VoteTrait.php
the incremental counter write app/Core/Traits/Api/Models/Managers/RegisterEventTrait.php
the module settings app/UI/Admin/Discussion/Presenters/SettingPresenter.php
the cron tasks app/UI/Cron/Discussion/Presenters/

Follow-up chapters: Blog · Company catalogue · Elasticsearch · Forms

Cookie settings