Session log — s2l.online register-then-course bug fix
Session log — s2l.online register-then-course bug fix
What happened, in order
- How the bug surfaced
- Tracing the flow with curl
- Root cause: invisible login state
- Designing the fix: a single shortcode
- Patching the plugin and the theme instances
- Bonus fix: missing braces on the profile page
- End-to-end verification
- Later that day: hiding two language pages
- Later that evening: the new logo in the header
- Still open
1. How the bug surfaced
The day after the private content reviewer went live at reviewer.s2l.online/hasmukh/, Hasmukh used it for the first time. Instead of pasting marketing copy, he pasted a real complaint about his own site:
The reviewer treated it as a content review, flagged the wrong-genre problem, and reached the right conclusion (rethink the angle). At the bottom Claude added a separate operational note: if this is a real bug, point me at it and I will look into the site itself. Hasmukh said yes, look into it now.
2. Tracing the flow with curl
The first instinct was to assume registration was broken. The first attempt did look broken — the test posted to /register/ with the wrong field names (name instead of full_name, csrf_token instead of ep_csrf_token) so no user was created and no cookie set. Resist the temptation to declare victory on a bad test.
The second attempt read the form first and pulled out the real field names plus the dynamic honeypot field ep_fe400309. With those right, registration worked perfectly:
- POST to
/register/returned302to/ - Server sent
Set-Cookie: PM_USER=…; secure; HttpOnly; path=/; Max-Age=604800 - The user record was created in
pm_users - A subsequent GET on
/watch/?course=sars-efiling&lesson=1with that same cookie did not show the “Please log in” page. It showed “You need to enrol in this course first.” That is the auth-passed branch.
3. Root cause: invisible login state
So why was Hasmukh seeing a login loop? The answer was on the home page itself. Inspecting the served HTML for a logged-in session showed it identical to the guest version:
- Top-right CTA:
<a href="/register" class="nav-cta">Start Learning Free</a> - Mobile nav red CTA: same target,
/register - Footer:
<li><a href="/register">Register Free</a></li><li><a href="/login">Login</a></li> - Anywhere there was an account/profile/logout link: zero matches
The user’s mental model was therefore correct. They had registered, the home page showed only “Register” and “Login” to them, so they assumed registration had failed and clicked Login again. The site never told them they were logged in. The complaint “gives login again” was an exact description of what the home page nav was offering them.
4. Designing the fix: a single shortcode
EP Membership already exposes Please log in to view this content.
,
, Log out,
, , and . None of those swap a guest CTA for a member CTA in a nav, which is what was needed in three places: desktop nav, mobile nav, footer. PageMotor’s shortcode parser only passes inline attributes to handlers, so a self-closing [member-nav mode="cta|mobile|footer|inline"] was the cleanest fit.
The shortcode reads $motor->user and emits the right list-item HTML for each mode. Defaults for member-side: link to /profile/, label “My account”. Defaults for guest-side: link to /register, label “Start Learning Free”. Footer mode swaps the two-link guest pair (Register Free + Login) for a two-link member pair (My account + Log out, with the logout URL going through ?ep_membership_logout=1). All three modes accept overrides via member_text, guest_text, member_href, guest_href, and class.
5. Patching the plugin and the theme instances
The plugin patch was applied via a small idempotent PHP script. It backed up plugin.php with a timestamped suffix, registered 'member-nav' => 'render_member_nav' in the shortcodes() array, and inserted the render_member_nav method just before render_login_gate. After writing, the script ran php -l on the result and would have rolled back automatically on a syntax error. The lint passed cleanly.
The theme patch worked on the JSON value of the S2L_Theme_instances option in pm_options. A second PHP script decoded the JSON, walked every string node in the tree, replaced three exact substrings, re-encoded with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, and wrote it back inside one statement. Three replacements, one row affected:
1 × <li><a href="/register" class="nav-cta">Start Learning Free</a></li>
→ [member-nav mode=cta]
1 × <li><a href="/register" style="color: var(--red); …">Start Learning Free →</a></li>
→ [member-nav mode=mobile]
1 × <li><a href="/register">Register Free</a></li><li><a href="/login">Login</a></li>
→ [member-nav mode=footer]
No nginx reload, no CSS recompile. PageMotor reads plugin code and theme instances on every request.
6. Bonus fix: missing braces on the profile page
While reading render_enrolled_courses for context, an unrelated bug was visible: an if with no curly braces, controlling only the first line of what should have been its body.
if (empty($enrolments))
$redirect = $this->get_setting('registration_redirect', '/');
return '<div class="profile-section">…You have not enrolled…</div>';
The return was unconditional. Every visit to /profile/, regardless of how many courses the user was enrolled in, returned the “You have not enrolled in any courses yet” message. The code further down that loops through enrolments and renders progress bars was unreachable. PHP also logged hundreds of Undefined variable $redirect warnings on every profile view, which is what first surfaced the line in the error log.
The patch script wrapped the if body in braces in the same pass. Lint OK.
7. End-to-end verification
Two curl runs against the live site, one as guest and one with a freshly-registered cookie:
- Guest top CTA →
Start Learning Free→/register✓ - Guest mobile CTA → same ✓
- Guest footer →
Register Free+Login✓ - Logged-in top CTA →
My account→/profile/✓ - Logged-in mobile CTA →
My account →→/profile/✓ - Logged-in footer →
My account+Log out(logout URLhttps://s2l.online/?ep_membership_logout=1) ✓ - Logged-in
/sars-efiling/→ renders,Start Coursebutton targets/watch/?course=sars-efiling&lesson=1✓ - Logged-in
/watch/?course=sars-efiling&lesson=1→ “You need to enrol in this course first” with thedata-course="9"Enrol Free button (correct branch for the auth-passed, not-enrolled state) ✓ /profile/→ renders “Your account” and the “Your Courses” section header (which would never have appeared before the braces fix) ✓- No new entries in
/var/www/s2l/php-errors.logafter the patch ✓
9. Hiding two language pages until their courses are ready
Two of the eleven language pages on s2l.online — isiNdebele and Tshivenda — needed to come off public view because the underlying course content was not yet ready. The decision was “hide”, not “delete” — both pages had to be restorable in seconds.
A grep across pm_options showed that the two language names lived in four distinct places, all inside the S2L_Theme_instances JSON blob: the rotating welcome banner at the top of the home page (Hero_Content), the row of language tabs above the courses grid (Courses_Content), the “Courses in your language” cards (Languages_Content), and the Languages column of the footer (Footer_Content). Plus the two landing pages themselves at /ndebele and /tshivenda in pm_content.
The edit was done in Python end-to-end: pull the option value, parse JSON, target each of the four HTML_Box entries by key, do a structural removal in each, then push the whole option back via a parameterised PHP UPDATE. Trailing-comma cleanup on the rotating-banner array. The intro copy on the languages section also dropped from “all 10 South African languages” to “8 South African languages, with more South African languages on the way” so it matched the visible cards. Both landing pages were switched to status='draft'.
A first pass on the Languages section left a stray </div> after the last remaining card — the wrapper-close pattern needed to swallow the card’s own closing div as well. A targeted string replace cleaned that up before the value was pushed back to the database. Final structural counts after the edit, sanity-checked locally before the write:
language-cards in Languages_Content: 9
lang-tabs in Courses_Content: 9
language links in Footer_Content: 9
references to ndebele/tshivenda: 0
Verification on the live origin: GET / returned 200 with zero references to either hidden language name in the served HTML; GET /ndebele and GET /tshivenda both returned 302, redirecting away from the now-draft pages. The remaining nine languages render normally in the rotating banner, the courses tabs, the language cards, and the footer.
/tmp/lang_pages_backup_*.sql (the two affected page rows), /tmp/theme_before_lang_hide.json (full pre-change theme option), and /tmp/theme_after_lang_hide.json (the new value).10. Adding the new S2L logo to the header
The S2L brand logo — a portrait composition of a 3D linked-block icon, a rising blue-and-red arrow, a green DNA helix wrapping a stylised “S2L” numeral, the wordmark S2L.online, and a tagline The Digital Growth Helix — arrived as a single PNG. The job was to drop it into the navigation at the top of every page without breaking the existing nav layout, while keeping the change instantly reversible.
The header at that point was rendering a placeholder: three coloured letter spans (S, 2, L) inside a dark rounded square (.logo-badge) with the wording Something to Learn / Online stacked beside it. The placeholder lived inside the Header_Content HTML Box of the S2L_Theme_instances JSON option in pm_options.
The full pre-change S2L_Theme_instances row was dumped to Audit Trail/header-logo-2026-04-26/instances-backup.sql first — one file, one row, restoreable in a single mysql import. The PNG was uploaded to /var/www/s2l/user-content/images/s2l-logo.png (owner s2l:s2l, mode 644). A quick request against https://s2l.online/user-content/images/s2l-logo.png returned 200, confirming nginx was serving the static file directly without hitting the PageMotor rewrite.
The HTML edit was a Python pass over the JSON option: pull the value, parse, replace the .logo-badge span block with a single <img class="nav-logo-img" src="/user-content/images/s2l-logo.png" alt="S2L.online — Something to Learn Online">, dump back, and push via a parameterised UPDATE using _utf8mb4 0x… to keep the bytes intact. The “Something to Learn / Online” wording was deliberately kept beside the logo — the logo’s own internal text is too small to read at header height, and the visible wordmark provides a readable fallback.
For the styling, a read of /var/www/s2l/lib/theme.php showed that _write_css() compiles three sources into the live css.css file: ${class}_css (the seed-compiled theme CSS), ${class}_css_editor (WYSIWYG editor styles), and ${class}_css_custom (free-form custom additions). The S2L_Theme_css_custom row did not yet exist; an INSERT … ON DUPLICATE KEY UPDATE created it with the new rules:
.nav-logo-img {
display: block;
height: 50px;
width: auto;
}
@media (max-width: 768px) {
.nav-logo-img {
height: 42px;
}
}
The recompile-css.php endpoint at http://localhost/recompile-css.php?token=… returned 404: the nginx vhosts are bound to server_name documentation.mobilearn.africa and s2l.online rather than localhost, so the public hostname had to be used. Hitting https://s2l.online/recompile-css.php?token=… returned CSS recompiled successfully, the ?v= cache buster on the stylesheet link bumped, and the served HTML showed the img tag in place of the badge.
S2L_Theme_instances from the SQL backup and re-run the recompile — the logo file itself can stay in place, harmless if unreferenced.8. Still open
One known frictional step remains: the Enrol Free button on /watch/ for free courses is a JavaScript-driven AJAX call, not a hard link. A user with a slow connection or a JS hiccup can click it and see no visible response. The cleaner path is auto-enrolment for free courses on the first visit to /watch/?course=… — turn the explicit Enrol click into an implicit server-side step. That is one click and one mental model fewer between “I want to learn” and “the lesson is playing.” Tagged for a future session.
Backups left in place for rollback:
/var/www/s2l/user-content/plugins/ep-membership/plugin.php.bak.20260426-133725/tmp/S2L_Theme_instances.bak.20260426-133755.json