Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/MemberHistory extension in Invision Community 5

Invision Community keeps an audit trail of things that happen to a member's account — group changes, display name changes, password changes, warnings, purchases, and so on. Each entry is one row in core_member_history, and the row itself is nothing but an application key, a type string and a blob of JSON. A core/MemberHistory extension is what turns that JSON back into a readable sentence. Without one, an administrator looking at ACP → Members → (a member) → History sees a blank cell where the description should be. The ACP's own description of the extension, in applications/core/dev/lang.php:1316, is simply "Add data parsers for Member History display".

Only three implementations ship with 5.0.19: applications/core/extensions/core/MemberHistory/Core.php, applications/core/extensions/core/MemberHistory/Mailchimp.php and applications/nexus/extensions/core/MemberHistory/Nexus.php. Forums, Pages, Downloads, Gallery, Calendar and Blog do not implement it at all.

The contract

The abstract is IPS\Extensions\MemberHistoryAbstract, in system/Extensions/MemberHistoryAbstract.php. It has three abstract methods and one concrete method with a usable default. There are no properties and no constructor.

namespace IPS\Extensions;

abstract class MemberHistoryAbstract
{
    /* Every log_type string this extension is willing to parse.
       Flat array of strings. Must not be empty in practice - see below. */
    abstract public function getTypes(): array;

    /* Render the "type" cell. Core's implementations return an icon. */
    abstract public function parseLogType( string $value, array $row ): string;

    /* Render the "information" cell. This is the one that matters. */
    abstract public function parseLogData( string $value, array $row ): string;

    /* Render the "member" cell. Already implemented; override only if
       you want something other than photo + profile link. */
    public function parseLogMember( string $value, array $row ): string
    {
        $member = Member::load( $value );
        return Theme::i()->getTemplate( 'global', 'core' )->userPhoto( $member, 'tiny' ) . ' ' . $member->link();
    }
}

In all three parse methods, $value is the raw value of the column being rendered and $row is the whole core_member_history row as an associative array. The columns you can rely on are those in applications/core/data/schema.json: log_id, log_app, log_member, log_by (nullable), log_type, log_data (TEXT, nullable), log_date (DECIMAL 12,2 — a microtime float, not an integer) and log_ip_address.

For parseLogData(), $value is the JSON string that was stored, so you decode it yourself. Every shipped implementation begins with json_decode( $value, TRUE ). The return value is written straight into the table cell as HTML.

All three parse methods are declared : string. Returning NULL or an array from your own implementation is a TypeError, and the consequences differ by method — see the failure modes below.

How rows get created

Nothing in the extension writes to the table. Rows come from IPS\Member::logHistory(), at system/Member/Member.php:5757:

public function logHistory( mixed $app, string $type, mixed $extra=NULL, mixed $by=NULL ) : void

$app is written to log_app, $type to log_type, and $extra is passed through json_encode() into log_data. $by is the member who performed the action. Passing NULL substitutes Session::i()->member — deliberately not Member::loggedIn(), so that an admin logged in as a member is recorded as the admin — but only when a Dispatcher instance exists and is not in its destructor phase (system/Member/Member.php:5762). From a task or a CLI script that condition is false, $by stays NULL, and log_by is written as NULL. Passing FALSE also records nobody, because the insert writes $by ? $by->member_id : NULL. The call is a no-op if the member has no member_id.

The value you pass as $app is the binding between the row and your extension. This is the single most common thing to get wrong, and it is covered in detail below.

Who calls it

Exactly one class consumes the extension: IPS\Member\History, in system/Member/History.php, which is a IPS\Helpers\Table\Db subclass over core_member_history. Its constructor loads every extension once per request into a static cache (lines 60–68):

$apps = Application::appsWithExtension( 'core', 'MemberHistory' );

foreach( $apps as $application )
{
    static::$extensions[ $application->directory ] = $application->extensions( 'core', 'MemberHistory' );
}

Note the array is keyed by application directory, not by extension key. It then registers five closures in $this->parsers (lines 125–182), one each for log_type, log_date, log_ip_address, log_member and log_data. The log_date and log_ip_address ones are self-contained; the other three consult your extension, and all three resolve it the same way:

foreach( $extensions[ $row['log_app'] ] as $extension )
{
    if( in_array( $row['log_type'], $extension->getTypes() ) )
    {
        return $extension->parseLogData( $val, $row );
    }
}

So dispatch is: look up the bucket for the row's log_app, then walk that app's extensions in extensions.json order and take the first one whose getTypes() contains the row's log_type.

IPS\Member\History is instantiated in four places in the shipped code:

  • applications/core/modules/admin/members/members.php:1394 — the History block on the ACP member profile, gated on the core/members/member_history ACP restriction. Constructed as new History( $url, $where, TRUE, FALSE, TRUE ), so $showMember=FALSE and $showType=FALSE.
  • applications/core/modules/admin/members/members.php:4866 — the full-page do=history view, behind Dispatcher::i()->checkAcpPermission( 'member_history' ) at line 4845. Only two arguments are passed, so every optional flag takes its default.
  • applications/core/extensions/core/IpAddresses/MemberHistory.php:73 — the "Member History" tab of the ACP IP address lookup. This one overrides $table->include at line 76 to array( 'log_member', 'log_data', 'log_date', 'log_ip_address' ).
  • applications/core/extensions/core/IpAddresses/Dnames.php:73 — the "Display Names" tab of the IP address lookup, hard-scoped to log_app='core' AND log_type='display_name'. It sets the same include list at line 76.

Three of those four are AdminCP only. The fourth is not. Dnames does not override supportedInModCp(), and IPS\Extensions\IpAddressesAbstract::supportedInModCp() returns TRUE (system/Extensions/IpAddressesAbstract.php:46–49), so the front-end ModCP IP tools page renders it: applications/core/extensions/core/ModCp/IPTools.php:97 gates on supportedInModCp() and line 102 calls findByIp() with the 'front' URL built at line 88. The MemberHistory IP extension really does return FALSE (applications/core/extensions/core/IpAddresses/MemberHistory.php:52–55), but that only suppresses that one tab, not Dnames.

In practice this front-end path cannot reach a third-party extension, because Dnames' WHERE clause pins the rows to log_app='core' AND log_type='display_name' and dispatch therefore always lands in core's bucket. But it does mean Core::parseLogData() and MemberHistoryAbstract::parseLogMember() run with Dispatcher::i()->controllerLocation === 'front', and it means the blanket statement "this extension only ever runs in the AdminCP" is not true of the class as shipped. Write your own methods so they do not depend on being in the AdminCP: pass an explicit location to Theme::i()->getTemplate() rather than letting it default to the current controllerLocation.

Also relevant: the core/cleanup task prunes the table. applications/core/tasks/cleanup.php:197 deletes rows older than the prune_member_history setting where log_app != 'nexus'; Commerce rows get their own nexus_prune_history setting at line 203. Both are skipped entirely if the setting is empty, and both are also skipped while a PruneLargeTable background task is already working on core_member_history (lines 62–70) — an interlock, not an opt-out. Third-party rows are governed by the core setting, and there is no way to exempt them from the extension.

Registering it

Extension files are not discovered by scanning. IPS\Application::extensions() reads applications/<app>/data/extensions.json and nothing else (system/Application/Application.php:917–950). An unlisted file is invisible, with no error anywhere.

/* applications/acme/data/extensions.json */
{
    "core": {
        "MemberHistory": {
            "Acme": "IPS\\acme\\extensions\\core\\MemberHistory\\Acme"
        }
    }
}

You also need one language string per type in the __admin__ pack, named log_type_title_<type>. History::__construct() builds the "Type" advanced-search filter from those keys at line 111:

$options[ $type ] = 'log_type_title_' . $type;

If the key does not exist, IPS\Lang falls back to printing the key itself, escaped (system/Lang/Lang.php:2131–2135), so the filter dropdown shows the literal text log_type_title_yourtype.

A minimal example

The Mailchimp extension is the smallest complete implementation in the suite. This is applications/core/extensions/core/MemberHistory/Mailchimp.php, verbatim apart from the copyright header and the class docblock:

<?php

namespace IPS\core\extensions\core\MemberHistory;

use IPS\Extensions\MemberHistoryAbstract;
use IPS\Member;
use IPS\Theme;
use function defined;

/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Mailchimp extends MemberHistoryAbstract
{
    /**
     * Return the valid member history log types
     *
     * @return array
     */
    public function getTypes(): array
    {
        return array(
            'mailchimp_subscribed'
        );
    }

    /**
     * Parse LogType column
     *
     * @param   string      $value      column value
     * @param   array       $row        entire log row
     * @return  string
     */
    public function parseLogType( string $value, array $row ): string
    {
        return Theme::i()->getTemplate( 'members', 'core', 'admin' )->logType( 'email_change' );
    }

    /**
     * Parse LogData column
     *
     * @param   string      $value      column value
     * @param   array       $row        entire log row
     * @return  string
     */
    public function parseLogData( string $value, array $row ): string
    {
        $jsonValue = json_decode( $value, TRUE );

        if( isset( $jsonValue['error_code'] ) )
        {
            return Member::loggedIn()->language()->addToStack( 'history__mailchimp_error', true, [ 'sprintf' => [ $jsonValue['error_code'], $jsonValue['error_message'] ] ] );
        }

        return Member::loggedIn()->language()->addToStack( 'history__mailchimp_' . $jsonValue['action'], true, [ 'sprintf' => [ $jsonValue['list'] ] ] );
    }
}

The rows it parses are written in four places in applications/core/sources/Mailchimp/Mailchimp.php, all with the single log type mailchimp_subscribed. Line 177 is the success case:

$member->logHistory( 'core', 'mailchimp_subscribed', [ 'list' => $this->getListName(), 'action' => ( $subscribe ? 'subscribed' : 'unsubscribed' ) ] );

Line 227 is the same call with 'action' => 'email_update', and lines 167 and 217 write the error shape instead — [ 'error_code' => ..., 'error_message' => ... ], which is the branch parseLogData() tests for first. Note the pattern: one log type, several payload shapes, discriminated inside parseLogData().

The matching language strings are in applications/core/dev/lang.php:2060–2064:

'history__mailchimp_subscribed' => "Member subscribed to Mailchimp list %s",
'history__mailchimp_unsubscribed' => "Member unsubscribed from Mailchimp list %s",
'history__mailchimp_email_update' => "Email address updated for Mailchimp list %s",
'history__mailchimp_error' => "Mailchimp subscription failed with error %s: %s",
'log_type_title_mailchimp_subscribed' => "Mailchimp Subscriptions",

The first three are reached by string concatenation — 'history__mailchimp_' . $jsonValue['action'] — so they will not show up if you grep for the whole key. The last one is the advanced-search filter label, and there is exactly one of them because getTypes() returns exactly one type.

Two things in that example are worth copying. First, the description is always produced through Language::addToStack() so it can be translated. Second, the dynamic values go in sprintf, not htmlsprintfsystem/Lang/Lang.php:2087–2098 runs every sprintf value through htmlspecialchars(), whereas htmlsprintf values are inserted raw.

Core::parseLogData() is the model for anything larger. It is a switch on $row['log_type'], and it ends with return $value; at applications/core/extensions/core/MemberHistory/Core.php:834 — if no case matched, it hands back the raw JSON rather than an empty string, so the administrator at least sees something. Copy that habit.

Nothing is parsed and no error appears: log_app does not match your app directory

The dispatch key is $extensions[ $row['log_app'] ], and that array is keyed by the directory of the application that owns the extension. So if your app lives in applications/acme, every row your extension is meant to parse must have been written with $member->logHistory( 'acme', ... ). Writing logHistory( 'core', ... ) from an application called acme sends the row to core's bucket, where Core::getTypes() and Mailchimp::getTypes() will not recognise the type, and your extension is never consulted — it is not even in the array being searched.

What the administrator sees is an entry with a correct date and IP address and a completely empty Information column. There is no exception and no log entry. The reason is that the log_data closure at system/Member/History.php:164–181 has no fallback: when the foreach finishes without a match, the closure simply ends, returning NULL, and Table\Db writes that NULL into the cell.

If log_app names an application that has no MemberHistory extension at all — a typo, or an app that was uninstalled after the rows were written — $extensions[ $row['log_app'] ] is an undefined key and the foreach runs over NULL. Both are E_WARNING, and IPS\IPS::errorHandler() returns early for E_WARNING in production (init.php:796–800), so again: blank cell, nothing logged. With IN_DEV and DEV_USE_WHOOPS enabled the Whoops handler is registered instead of IPS\IPS::errorHandler() (init.php:609–621) and these warnings become visible, which is the practical way to catch this.

Fatal error: contains abstract methods and must therefore be declared abstract

Three of the four methods are abstract. Omitting any of getTypes(), parseLogType() or parseLogData() is a PHP compile-time fatal, raised the moment the autoloader compiles your class inside class_exists( $classname ) at system/Application/Application.php:926. That happens on every page that builds a member history table, and it kills the whole page, not just your extension.

It is easy to omit parseLogType() because core's own implementations of it are trivial one-liners and, as the include-list section below explains, it is not reached at all on the pages you are most likely to test against. It is still mandatory.

Copy the signatures verbatim. The return types are the part PHP will not forgive: declaring public function getTypes() without : array is Fatal error: Declaration of ... must be compatible with ..., not a warning, because return types may only be narrowed. The parameter types are more forgiving than that — PHP permits widening, so parseLogData( $value, $row ): string with the type hints dropped does compile — but narrowing one (say int $value) is the same fatal. There is nothing to gain from deviating; copy them exactly.

An exception in parseLogData is caught; one in parseLogMember is not

This asymmetry is in system/Member/History.php and it is worth knowing before you debug a white page.

The log_type and log_data closures both wrap the dispatch in try { } catch( Throwable $e ). They call Log::log( $e, 'member_history' ) and then return the raw column value — the raw type string for log_type (line 143), and the raw JSON for log_data (line 179, with the comment "Return the value so the admin may have some clue as to what the log entry was for"). So a broken parseLogData() shows the administrator a cell full of JSON, and the real error is only visible in ACP → Support → System Logs under the member_history category. Nothing on screen tells you to look there.

The log_member closure at lines 153–163 has no try/catch. Any exception thrown by your parseLogMember() — including the TypeError you get from returning NULL, or an OutOfRangeException from loading a deleted member — escapes to IPS\IPS::exceptionHandler() and takes the page down with a generic error. That closure does at least end with return ''; when no extension matches, so an unmatched row is blank rather than fatal.

Note where that can bite you, though: per the include-list rule below, parseLogMember() is only invoked by callers that put log_member in include — in shipped code, the two IP address lookup tabs. It is never called on the ACP member profile. So a broken parseLogMember() breaks the IP lookup, not the member's History block, which is a confusing symptom if you are not expecting it.

Which of your methods actually run depends on the caller's include list

This is the opposite of what the layout of Table\Db suggests, so it is worth being exact. system/Helpers/Table/Db.php:455–462 keeps the full database row in $_row and then, when $this->include is not NULL, rebuilds $row from the include list alone. The parser loop at line 474 iterates $row, not $_row. History::__construct() always assigns a non-NULL include (line 59 onwards), so a parser only runs for a column that is in the include list. Active advanced-search columns are added back at lines 464–470, so filtering by Type can pull log_type into the loop even when $showType was FALSE.

The comment at system/Helpers/Table/Db.php:476 — "deliberately do this before removing the row in case we need to do some processing, but don't want the column to actually show" — refers to the include/exclude test further down at line 488, which for History only ever bites those advanced-search columns. It is not a promise that every parser runs.

The practical consequences:

  • On the ACP member profile and the do=history page, include is log_date, log_data, log_ip_address. parseLogMember() and parseLogType() are not called at all. Only parseLogData() does any work.
  • On the two IP address lookup tabs, include is log_member, log_data, log_date, log_ip_address. There parseLogMember() is called and parseLogType() is not.
  • No shipped caller passes $showType=TRUE, so parseLogType() runs only when an administrator uses the Type filter. Test it deliberately; it will not be exercised by simply loading the page.

The second argument your methods receive is still the complete row. The query has no explicit $selects, so it is a SELECT *, and line 480 passes $_row — all eight columns — as $row regardless of which columns are being displayed. Whatever you need from the row is there.

Do not use these methods for side effects: which ones fire, and how often, is decided by the caller and not by you. Keep them cheap; they run once per row per page load.

Your output is inserted as raw HTML

Table\Db escapes column values with htmlspecialchars() only in the else branch, when no parser is registered (system/Helpers/Table/Db.php:482–485). A parsed column is inserted verbatim — the row templates emit {$v|raw} (for example applications/core/dev/html/admin/tables/rows.phtml:35). Since log_data frequently contains member-supplied text — old and new display names, email addresses — concatenating it into your return string is a stored XSS hole in the AdminCP, and via the ModCP IP tools path described earlier it is not strictly confined to the AdminCP either. Pass it through addToStack() with sprintf, or escape it yourself. Reserve htmlsprintf for values you generated, such as a $member->link() or a template call.

Two extensions claiming the same type

There is no registry and no collision check. If two extensions in the same application both return 'foo' from getTypes(), the first one in extensions.json order wins, silently, and it wins for all three columns independently. Across applications there is no conflict at all, because the log_app bucket is consulted first — two different apps may both use the type string 'login' without interfering.

Note also that getTypes() is called inside the per-row loop, once per extension per row, for every dispatching column the caller actually included — on the ACP member profile that is log_data alone, on the IP lookup tabs it is log_data and log_member. It is also called once per extension in History::__construct() (line 100) to build the filter options. Return a literal array; do not run queries in it.

Your extension is skipped for some administrators

History::__construct() calls Application::appsWithExtension( 'core', 'MemberHistory' ) without a third argument, and that parameter defaults to TRUE (system/Application/Application.php:510), meaning "check that the currently logged-in member can access this application". Application::canAccess() short-circuits to TRUE in the AdminCP for anyone holding the core/applications/app_manage ACP restriction (system/Application/Application.php:5308), which covers most administrators. An administrator without that restriction falls through to the group test, and will see the blank-cell behaviour described above while a full administrator sees the correct text. This is a real behavioural difference between two admins looking at the same page, and it looks like a caching bug if you do not know about it.

Read the group test carefully before you reason about it, because it does not do what the column name implies. In 5.0.19 (system/Application/Application.php:5313–5343), canAccess() returns TRUE when disabled_groups is NULL, FALSE when it is '*', and otherwise returns TRUE only if the member's groups intersect the disabled_groups list — and FALSE if they do not. In other words, once an application is taken offline for some groups, the listed groups are the ones that keep access. Whether that inversion is intentional or a core bug is unverified; what matters here is that any non-NULL disabled_groups on your application will hide your extension from some administrators, and you should not predict which ones from the column's name.

Separately, static::$extensions on IPS\Member\History is a static populated on first construction and never invalidated, so all history tables in a single request share one set of extension objects. Application::constructExtensionClass() returns NULL if the class does not exist or its constructor throws RuntimeException or OutOfRangeException (system/Application/Application.php:469–500); such entries are dropped, again silently.

Things this article does not verify

  • The extension classes are constructed with one argument, NULL. History calls $application->extensions( 'core', 'MemberHistory' ) with no further arguments, and extensions() defaults $checkAccess to FALSE (system/Application/Application.php:911), which constructExtensionClass() turns into new $class( NULL ) at line 479. MemberHistoryAbstract declares no constructor and none of the shipped implementations do either, so whether a custom constructor is supported here is unverified — the safe course is not to declare one.
  • Core::parseLogType() calls Theme::i()->getTemplate( 'members', 'core' ) with no location argument, so it resolves against the current controllerLocation (system/Theme/Theme.php:880–883), whereas Mailchimp::parseLogType() passes 'admin' explicitly. No shipped caller puts log_type in its include list, so parseLogType() is only reached through the AdminCP Type filter and the two forms behave identically today. There is no members template group under applications/core/dev/html/front/, so the unqualified form would not resolve on the front-end ModCP path described above — but that path never includes log_type, so this is a latent mismatch rather than an observed failure, and it is unverified in the sense that it was not exercised.
  • Mailchimp::parseLogType() passes the hard-coded string 'email_change' to the template rather than $value, which renders an envelope icon. Whether that is deliberate or a copy-and-paste slip is unverified; it is reproduced above because the example is quoted as it ships.
  • The $showApp constructor parameter of IPS\Member\History (line 55) is accepted and then never referenced again — the identifier appears only in the docblock at line 51 and in the signature. log_app is therefore never added to include by the constructor and has no code path that displays it as a column. A caller could still set $table->include by hand, as the IP address extensions do, so calling the parameter entirely dead is unverified beyond this one file; only its uselessness inside the constructor is confirmed.
  • History::__construct() contains a special case at lines 102–109 that replaces the filter label for the type 'oauth' with an OAuth client's title when exactly one client exists. Whether a third-party extension can rely on or reproduce that behaviour is unverified; it is hard-coded to the string 'oauth'.

Verified against

Read from Invision Community 5.0.19 source: system/Extensions/MemberHistoryAbstract.php, system/Member/History.php, system/Member/Member.php (logHistory()), system/Application/Application.php (extensions(), appsWithExtension(), constructExtensionClass(), canAccess()), system/Helpers/Table/Db.php, system/Lang/Lang.php, system/Theme/Theme.php, init.php (errorHandler()), all three shipped implementations under applications/{core,nexus}/extensions/core/MemberHistory/, applications/core/extensions/core/IpAddresses/MemberHistory.php and Dnames.php, system/Extensions/IpAddressesAbstract.php, applications/core/extensions/core/ModCp/IPTools.php, applications/core/data/extensions.json, applications/core/modules/admin/members/members.php, applications/core/sources/Mailchimp/Mailchimp.php, applications/core/tasks/cleanup.php, the logType templates in applications/core/dev/html/admin/members/ and applications/nexus/dev/html/admin/customers/, and the core_member_history definition in applications/core/data/schema.json.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.