Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / Diskuze

Diskuze#

Nejmenší modul systému — 12 entit, žádné dynamické parametry, žádné hlídače, žádná expirace. Kód: app/UI/Api/Discussion/, front app/UI/Front/Discussion/.

Z čeho se modul skládá#

Skupina Entity
obsah Message (nested set), MessageAssetRel
taxonomie Category, CategoryText, CategoryPrice (ceník kategorie)
interakce MessageVote, MessageReport
placení Tariff, TariffText, TariffPrice, TariffQuickPrice, PaymentItem

Zakázaná slova modul vlastní nemá. Čte jeden seznam pro celý web, systémovou tabulku cms_system_blacklisted_words (Systém → Zakázaná slova), přes Api System\BlacklistedWordManager. MessageManager::beforeSave() odmítne předmět nebo text návštěvníka se zakázaným slovem jazyka stránky (textRefusal()), a totéž dělá CommentManager u komentářů vláken typu, který to vyžaduje (ThreadType::checksBlacklistedWords(), dnes jen DiscussionMessage). Administrátor, cron a interní úlohy se nekontrolují. Odmítnutí nese errorCode blacklistedWord (BlacklistedWordRefusal) a front ukáže hlášku front.general.blacklistedWord.

Modulové tabulky zakázaných slov už neexistují

Dřívější cms_mod_discussion_blacklisted_words (a kopie v inzerci a katalogu firem) migrace odstranila spolu s entitami, managery a repozitáři. Nový kód sahá jen na systémový BlacklistedWordManager (findActiveValues(?languageId), textRefusal()).

Vlákno a příspěvek jsou jedna entita#

Message je nested set (lft, rgt, level). Vlákno je kořenový uzel, odpověď je jeho potomek — mezi „téma“ a „příspěvek“ tedy není žádný typový rozdíl.

Přesun příspěvku není nijak omezený

Administrátor umí přetáhnout kterýkoli uzel kamkoli a systém nekontroluje, jestli to dává smysl. Přetažením odpovědi pod jiné téma se diskuze rozejde a nedá se to vrátit tlačítkem zpět.

Smazání uzlu smaže celý podstrom

Smazáním úvodního příspěvku zmizí celé vlákno včetně všech odpovědí. V seznamu to vypadá jako smazání jednoho řádku.

Text příspěvku se čistí na serveru#

Pisatel píše v osekaném editoru (addSimpleEditor() v MessageFormComponent) a detail příspěvku i výpis ve stylu 3 vypisují text přes |noescape. Proto ho MessageManager::beforeSave() při každém uložení prožene přes EditorHtmlSanitizer::purify() — na webu, v administraci i z externího API. Povolené značky jsou tytéž jako u parametrů inzerátů a v klientském editoru (EditorHtmlSanitizer::ALLOWED_HTML).

subject se nečistí: je to prostý text a šablony ho vždy escapují. Purifikace by jen pokazila legitimní text se znakem <.

Sanitizace patří do manageru, ne do formuláře

Whitelist editoru v rich-editor.js drží jen prohlížeč. Požadavek mimo formulář ho obejde a bez kontroly v beforeSave() by vznikl uložený XSS. Podrobně Formuláře.

Co modul nemá#

Užitečnější než výčet funkcí je vědět, co tu není — ušetří to hledání neexistujícího nastavení:

Není Čím se to obchází
uzamčení vlákna vypnout „povolit komentáře“, nebo příspěvek deaktivovat
placený přístup ke čtení kategorie platí se jen založení tématu v placené kategorii a tarify příspěvku
práva podle kategorie jen globální přepínače „jen pro přihlášené“

Kategorie má příznak active. Vypnutá kategorie, nebo kategorie pod vypnutým předkem, vrátí na webu 404 (CategoryTrait::isCategoryBranchActive()).

Hodnocení palcem#

Hlasy jsou MessageVote s hodnotou 1 = palec nahoru, 0 = palec dolů. Počítadla na příspěvku (votesUp, votesDown) přepočítává Api\MessageVoteManager z řádků hlasů (cms_mod_discussion_message_votes) jedním UPDATE v transakci uložení hlasu — po uložení, smazání, hromadném deleteAll() i po updateColumns na vote/messageId. Stejně jako u hodnocení (RatingVoteManager) píše počítadlo cesta hlasu sama; tabulka hlasů je zdroj pravdy.

Počítadla hlasů nepatří do registerEvent

Dřív front po uložení hlasu volal zvlášť :Api:Discussion:Message:registerEvent s votesUp/votesDown. Ten endpoint mají povolený i hosté a obchází MessageVoteManager::beforeSave(), takže skript mohl počítadlo zvedat bez hlasu, kolikrát chtěl, a smazání hlasu ho nesnížilo. Allowlist registerEvent má teď jen čítače zobrazení.

Rozjeté počítadlo se srovná prvním hlasem

Přepočet je absolutní, takže první nový hlas srovná počítadlo se skutečnými hlasy (na DEV měl příspěvek 21 tři hlasy dolů a počítadlo 0).

Na jednu zprávu hlasuje každý jednou. Hlídá to MessageVoteManager::beforeSave() (jen při vložení, přes hasVoted()):

Hlasující Podle čeho se pozná Co to drží v databázi
přihlášený účet (userId), IP se ignoruje unikátní index uq_message_user (zpráva × uživatel)
nepřihlášený jen IP (insertIp) nic — kontrola je jen v manageru

Po hlasu zvedni i zobrazenou hodnotu

votesUp a votesDown jsou v MessageManager::$nonInvalidatingColumns, takže zápis query cache nezneplatní (viz Cache). Entita, ze které se řádek překreslí, má proto hodnotu o jedna nižší a hlasující by svůj hlas neviděl. VoteTrait::bumpDisplayedVotes() po úspěšném zápisu zvedne číslo v paměti, bez dalšího čtení z DB, a redrawControl() ho pošle do snippetu. Při DB_DUPLICATE_ENTRY se nevolá.

Druhý hlas vrátí DB_DUPLICATE_ENTRY; front podle kódu počítadlo nezvýší a ukáže hlášku, že už hlasoval. Na externí cestě dosadí server hlasujícího z JWT a IP z požadavku, jinak by deduplikace neměla podle čeho porovnávat.

Kontrolu v manageru nenahrazuj indexem

Index nepřihlášené nechytí (řádek s NULL uživatelem nikdy nekoliduje) a index nad IP by srazil dva různé lidi za jednou firemní sítí. Přihlášeného, kterého zachytí až index, ohlásí uložení porušením unikátnosti, a to zavře EntityManager do konce požadavku.

Hlášení příspěvků#

Návštěvník příspěvek nahlásí (MessageInfoLine::handleReport()); vznikne MessageReport (uživatel, IP, čas, solved = false). Jeden nahlašovatel má u příspěvku nejvýš jedno nevyřešené nahlášení (přihlášený podle účtu, host podle IP); další vrátí DB_DUPLICATE_ENTRY a front ho ukáže jako úspěch. Po vyřešení smí nahlásit znovu.

Pravidlo Provedení
zápis jen z AJAXu #[Requires(ajax: true)] — holý GET (robot) je 403
ID z adresy se porovná hlášení se založí jen pro zprávu této komponenty; podvržené ID nic nezaloží
příznak Message::$reported je odvozený příspěvek je „nahlášený", dokud má aspoň jedno nevyřešené nahlášení
externí :Api:Discussion:MessageReport:save cílový příspěvek musí existovat (jinak ITEM_NOT_FOUND) a přihlášený čerpá kvótu zakládání; front kontrolu ID tahle cesta nevidí

Příznak dorovnává sdílená traita ReportedFlagSyncTrait (syncReportedFlags()), kterou používá Api\MessageReportManager i AdvertReportManager inzerce a katalogu firem. Manager jen řekne, co je cíl a jak se příznak čte a zapisuje (reportTargetField(), reportTargetIdOf(), reportTargetIdsOf(), targetHasUnsolvedReports(), writeTargetReported()). Přepočet běží po každé změně: uložení, updateColumns na solved (vyřešit / znovu otevřít) nebo na cílový sloupec messageId (přesun hlášení; dorovná starý i nový příspěvek), smazání i hromadné deleteAll() (to háčky mazání obchází, proto si cíle zjistí předem). Diskuze počítá nevyřešená hlášení přímo DQL dotazem, ne přes findAll, aby výsledek nebyl ze zastaralé query cache. Chyba přepočtu jde do logu, ne uživateli. Administrace na příznak nesahá.

Příznak reported se ručně nenastavuje

updateColumns($id, ['reported' => …]) na příspěvku je jen pro MessageReportManager. Ruční zápis se rozejde s přehledem nahlášení.

Administrace nahlášení: Admin\Discussion\Presenters\MessageReportPresenter (/admin/diskuze-nahlasene-prispevky/prehled), SQL sql/2026-10-02_07_admin_discussion_message_reports.sql (admin stránka, klíče :Admin:Discussion:MessageReport:{show,edit,del}, položka v menu pod Diskuzí). Výpis, vyřešení a znovuotevření dává sdílená ReportModerationPresenterTrait (stejná pro AdvertReportPresenter inzerce a katalogu firem); presenter jen pojmenuje cíl a oprávnění. Signály solve! a reopen! (handleSolve(), handleReopen(), jen POST) mají serverovou bránu checkPrivilege(…:edit) — skryté tlačítko není ochrana. Výchozí pohled je filtr solved = 0; řádky dekoruje decorateReportRows() dvěma dotazy (cíle a nahlašovatelé), bez N+1.

Provozní návod: Nahlášené příspěvky.

Platby#

Platí se dvě věci: založení tématu v placené kategorii a tarify příspěvku. Callback platby je DISCUSSION_PAYMENT (PaymentCallbackRegistry), SMS služby DISK (DiscussionExpressServiceProvider).

Placené kategorie#

Kategorie diskuze má ceník jako kategorie inzerce: CategoryPrice podle platebního regionu, brány a měny, druh plnění, příznak „Zdarma“ (for_free) a volbu „Nastavit ceny i podkategoriím“. Entita implementuje PricedCategoryInterface a propsání ceníku do podstromu dělá sdílený CategoryPriceSyncTrait (stejný jako v inzerci a katalogu firem). Externí Api for_free nepřepíše (#[ExternalReadonly]).

Krok Co se stane
vložení tématu MessageManager zapíše vazby na kategorie s vlastním paid (zdarma = 1, placená = 0) a téma dostane messages.paid: zaplacené je, když je aspoň jedna kategorie zdarma; jinak se uloží nezaplacené
odpověď, téma bez kategorie vždy zaplacené
úprava tématu ponechaná kategorie si paid drží, nová dostane paid podle for_free
platba platební stránka nabídne položku (PaymentItem s categoryId) za každou placenou, dosud nezaplacenou kategorii tématu
vypořádání DISCUSSION_PAYMENT PaymentItemManager po aktivaci položek zavolá MessageManager::markCategoriesPaid(): označí zaplacené vazby i téma, připíše kredity za obsah a přeindexuje

Nezaplacené téma je mimo výpisy, TOP, sitemapu i kredity za obsah (paid = 1 patří do podmínky „zveřejněno“ vedle approved_at a confirmed_at). Autor ho vidí v detailu s upozorněním a tlačítkem Zaplatit a v „Mých příspěvcích“ s odznakem vedoucím na platbu.

Tarify příspěvku#

Tarif Účinek Odkud se ID bere
topování plní toped_until; výpis řadí topedUntil > NOW() dopředu a odznak TOP čte Message::isToped() discussionToppingTariffId (výchozí 1) v config/Shared/bazaar.neon → AdvertToppingConfig
zvýraznění aktivní platná položka dá kartě ve výpisu třídu highlighted discussionHighlightTariffId (výchozí 2) → AdvertHighlightConfig
Topbox příspěvek s aktivní platnou položkou tarifu se ukáže v bočním widgetu „TOP“ (TopMessageList, v aside-left.latte všech šablon) id tarifu předává šablona: {control frontDiscussionTopMessageList [3], 'Style_2', 10}
zobrazení odkazů webové adresy v textu jsou na detailu klikací odkazy (TextLinkifier, filtr \|linkify, rel="ugc nofollow"); bez tarifu zůstanou prostým textem discussionLinksTariffId (výchozí 4) → AdvertLinksConfig

Odznak TOP čte toped_until, ne platební položky

Výpis načítá jen položky tarifu zvýraznění, takže dřívější test na tarif 1 v šablonách nebyl nikdy pravdivý a odznak se neukázal. Teď ho řídí Message::isToped() — stejná hodnota jako řazení.

Topbox a odkazy mají i inzerce a katalog firem

Topbox je tam TopAdvertList s tarifem 3, odkazy tarif 4 (bazaarLinksTariffId, comcatLinksTariffId). V inzerci a katalogu adresa bez tarifu navíc zmizí za [URL] (filtr parametru v režimu „při zobrazení“), viz Bazaar.

Položka platby v administraci musí mít příspěvek (form.messages.missingMessage); sloupec je v DB nullable, takže stráž je ve formuláři. Hromadné deleteAll() položek přepočítá toped_until stejně jako delete().

Která cesta přepočítá toped_until jen nahoru a která absolutně (záložka Platby v editaci příspěvku posílá meta['recalcTopedUntil']), ukazuje tabulka v Inzerci; diskuze ji má stejnou, jen nad příspěvky.

Smazání aktivní platební položky zvýraznění zkrátí

Prodloužení z platby jde jen nahoru (je monotonní), ruční zásah v administraci umí obojí — a smazání položky se chová jako ruční zkrácení.

Nastavení#

Modul má v tabulce nastavení index 8. Formulář dnes zapisuje:

Klíč Typ
message.perPage, comment.perPage, maxImageSize číslo
message.approval.auto, message.confirmation.required.logged, message.confirmation.required.guest, message.onlyForLoggedIn, comment.onlyForLoggedIn, vote.onlyForLoggedIn přepínač

Schválení a potvrzení příspěvku#

Příspěvek má dvě nezávislé podmínky zveřejnění, obě se vyhodnocují při vložení (vlákno i odpověď, z webu, z API i z administrace):

Podmínka Sloupec Co ji řídí
schválení správcem approved_at message.approval.auto; správce schvaluje přepínačem Schváleno
potvrzení autorem odkazem z e-mailu confirmed_at (+ otisk klíče v activation_key) message.confirmation.required.logged a .guest — podle toho, jestli příspěvek má vlastníka (userId)

Nevyžaduje-li se potvrzení, příspěvek dostane confirmed_at hned. Vyžaduje-li se, dostane klíč a autorovi odejde e-mail s odkazem; chybí-li e-mail, potvrdí se hned (odkaz by neměl kam jít). Veřejné čtecí cesty (výpis, hledání, oblíbené, sitemap, expresní SMS platba) vždy vyžadují approved_at i confirmed_at; detail čekajícího příspěvku vidí jen přihlášený autor, s hláškou, bez komentářů a doporučení. „Moje příspěvky“ čekající nefiltrují a označují je štítkem. Přepnutí nastavení už vložené příspěvky nemění.

Rozměry obrázků a vodoznak v nastavení nejsou — nevracejte je

Varianty se čtou z DB (použití discussion-messages a discussion-categories, viz Varianty obrázků), vodoznak je globální watermarkPath; přiřazení se edituje v části Obrázky pod formulářem nastavení. Staré klíče se smazaly 2026-08-13; kdyby je někdo do formuláře vrátil, první uložení je založí zpátky, protože saveSettingForm() zapisuje všechny vyjmenované klíče.

Obrázky u příspěvku#

Uložení nahradí celý seznam obrázků

Nenaběhne-li nahrávací pole, přílohy zmizí — tatáž mechanika jako u každé jiné kolekce. Podrobně Formuláře, oddíl o nahrávání souborů.

Smazání vlákna (deleteNode(), i hromadné deleteAll(), které maže zprávu po zprávě přes deleteNode()) smaže s ním vlákna komentářů a hodnocení, obrázky i jejich soubory a dokument v ES.

Hlavní obrázek (Message::$asset) se odvozuje z vazeb obrázků. Na externím :Api:Discussion:Message:save ho vlastník nenastaví (#[FieldAccess] s Privileged na Api entitě, stejně jako Advert::$asset), jinak by mohl příspěvku podstrčit cizí obrázek.

Správa příspěvku hostem#

Stejný mechanismus jako u inzerátů (sdílený ManageKeyTrait nad ManageKeyAwareInterface):

  • Příspěvek bez účtu dostane při vložení náhodné tajemství do sloupce password (hodnota z dat se ignoruje); nikdo ho nezná, váže se na něj odkaz i editační oprávnění.
  • E-maily po vložení (DS_MESSAGE_INSERT, DS_MESSAGE_ACTIVATION_REQUEST) nesou odkaz diskuze-editace/<id>?manageKey=…. V DB je jen otisk (manage_key_hash) a platnost (manage_key_expires, 7 dní); použitím se klíč nespotřebuje.
  • :Api:Discussion:Message:redeemManageKey klíč ověří a vydá oprávnění s rozsahem discussion.message.edit (CapabilityTokenIssuer) — editační relace na 2 hodiny, přesměrování na editaci bez klíče v adrese.
  • Vlastníka nese dál jen userId; Api MessagePresenter přijme vlastníka podle identity, nebo podle oprávnění s otiskem tajemství z DB. Změna tajemství odkaz i relaci zneplatní.
  • Migrace 2026-10-27_09_discussion_message_manage_link.sql (sloupce, RBAC klíč, odstavec {if $manageLink} v šablonách e-mailů).

Naplánované úlohy#

Endpoint Co dělá Doporučený interval
/cron/discussion/elastic/sync průběžná synchronizace indexu à 10 minut
/cron/discussion/elastic/reindex úplný reindex denně v noci
/cron/discussion/sitemap/{message,category} sitemapy denně v noci

Kam sáhnout#

Chci Kde
logiku příspěvků a stromu app/UI/Api/Discussion/Models/Managers/MessageManager.php
hlasování app/UI/Front/Discussion/Components/CommentListRowComponent/Traits/VoteTrait.php
přírůstkový zápis počítadel app/Core/Traits/Api/Models/Managers/RegisterEventTrait.php
nastavení modulu app/UI/Admin/Discussion/Presenters/SettingPresenter.php
cron úlohy app/UI/Cron/Discussion/Presenters/

Navazující kapitoly: Blog · Katalog firem · Elasticsearch · Formuláře

Nastavení cookies