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/IpAddresses extension in Invision Community 5

An administrator types an IP address into AdminCP → Members → IP Address Tools and gets a grid of tiles: "Posts 14", "Registrations 1", "Device Logins 3", "Transactions 0". Clicking a tile opens a table of the matching records. The same screen exists for moderators in the ModCP under "IP Tools", and the reverse view — every IP a given member has ever used, with a first-seen and last-seen date — appears on a member's ACP profile, in the ban form, and in the GDPR personal-information export. Every one of those tiles, tables and rows comes from a core/IpAddresses extension. If your application logs an IP address anywhere and does not ship one of these, that data is invisible to both tools and is never cleared by the IP pruning task, no matter what the site's retention setting says.

Content items, comments and reviews are handled for you. Core ships a single generated extension, applications/core/extensions/core/IpAddresses/Content.php, which extends IPS\Content\ExtensionGenerator and produces one instance per routed content class. You only need to write an extension by hand for IPs you store outside the content system — logs, transactions, download records, votes, and so on. That is exactly what the ACP developer help text says: it is "Required if your classes store IP addresses outside of Content Items/Comments/Reviews" (applications/core/dev/lang.php:1291).

The contract

system/Extensions/IpAddressesAbstract.php declares five methods. Two are abstract and you must implement both. Three have working defaults and are optional.

namespace IPS\Extensions;

abstract class IpAddressesAbstract
{
    /* Optional. Show this area in the AdminCP lookup tool. Default TRUE. */
    public function supportedInAcp(): bool
    {
        return TRUE;
    }

    /* Optional. Show this area in the ModCP lookup tool. Default TRUE. */
    public function supportedInModCp(): bool
    {
        return TRUE;
    }

    /* Optional. Blank the IP addresses you store for records older than
       $time. $time is a UNIX TIMESTAMP, not a number of days. */
    public function pruneIpAddresses( int $time ) : void
    {
        //
    }

    /* REQUIRED. */
    abstract public function findByIp( string $ip, ?Url $baseUrl = NULL ): string|int|null;

    /* REQUIRED. */
    abstract public function findByMember( Member $member ): array|Select;
}

Url is IPS\Http\Url, Member is IPS\Member and Select is IPS\Db\Select. The docblocks on supportedInAcp() and supportedInModCp() still say "If the method does not exist in an extension, the result is presumed to be TRUE". That note is legacy: in IC5 the abstract supplies the method, so it always exists.

findByIp( string $ip, ?Url $baseUrl = NULL )

This method is called twice for two different purposes, and the second argument is what distinguishes them.

With $baseUrl as NULL, return a count as an integer. That number is the figure on the tile. Returning NULL instead removes your area from the screen entirely — the caller tests if ( $count !== NULL ) before recording it (applications/core/modules/admin/members/ip.php:117). Returning 0 keeps the tile but renders it greyed out and unclickable, per applications/core/dev/html/global/members/ipLookup.phtml:25-37. Core uses the NULL return for "this does not apply here": Content::findByIp() returns NULL when the content class has no ip_address entry in its $databaseColumnMap, or when the owning application is disabled (Content.php:79-87).

With a $baseUrl, return the rendered table for that URL. Every hand-written core implementation builds an IPS\Helpers\Table\Db — sometimes a subclass, as Dnames and MemberHistory do with IPS\Member\History (system/Member/History.php:37), sometimes via a factory, as nexus does with Transaction::table() and Payout::table() — and returns (string) $table. The return type is string|int|null and there is no declare(strict_types=1) anywhere in IPS's own code, so returning the table object itself also works — PHP coerces it through IPS\Helpers\Table\Table::__toString() (system/Helpers/Table/Table.php:506). Content::findByIp() relies on that and returns the bare object at Content.php:224. Casting explicitly is clearer and is what the other eighteen do.

The $ip you receive may contain SQL wildcards. Both callers pass str_replace( '*', '%', $ip ), so a search for 192.168.1.* arrives as 192.168.1.%. Every core implementation therefore uses LIKE ?, never =.

findByMember( Member $member )

Return one row per distinct IP address that member has used in your data, keyed by the IP address itself. Each row must be an array with the keys ip, count, first and last, where first and last are UNIX timestamps. You may return either a plain array or an unconsumed IPS\Db\SelectMember::ipAddresses() simply iterates whatever you give it.

The idiomatic implementation is a single grouped query with setKeyField( 'ip' ):

return Db::i()->select(
    "ip_address AS ip, count(*) AS count, MIN(vote_date) AS first, MAX(vote_date) AS last",
    'core_voters',
    array( 'member_id=?', $member->member_id ),
    NULL, NULL,
    'ip_address'
)->setKeyField( 'ip' );

Both the AS ip alias and the setKeyField( 'ip' ) are load-bearing; see the failure modes below. If your area has no per-member concept, return an empty array — SpamLogs and AdminLoginLogs both do.

pruneIpAddresses( int $time )

$time is an absolute UNIX timestamp computed by the task as time() - ( 86400 * Settings::i()->ip_address_prune ). Blank out the IP column on rows older than it. Votes.php:46 is the whole of a typical implementation:

Db::i()->update( 'core_voters', [ 'ip_address' => '' ], [ "ip_address != '' AND vote_date <?", $time ] );

Note the asymmetry with content classes. In Member::pruneAllLoggedIpAddresses() the static Content::pruneIpAddresses() is called with Settings::i()->ip_address_prune, a number of days (system/Member/Member.php:6982, and system/Content/Content.php:1210-1216 converts it), whereas extensions are called with the already-computed timestamp (Member.php:6992). Two conventions, one method name.

Registration and the language string

Extensions are discovered only from applications/<app>/data/extensions.json. Nothing scans the extensions/ directory.

{
    "core": {
        "IpAddresses": {
            "Votes": "IPS\\myapp\\extensions\\core\\IpAddresses\\Votes"
        }
    }
}

Application::allExtensions() re-keys every entry as <app directory>_<key> (system/Application/Application.php:419), and that composite key is used for three things: the array key in the counts grid, the area query-string parameter on the tile link, and the language key. The language key is ipAddresses__{app}_{key} — the mapping is hard-coded in the developer centre's missing-strings scan at applications/core/modules/admin/developer/details.php:574. So the extension above needs:

'ipAddresses__myapp_Votes' => "Poll Votes",

Core's own strings sit at applications/core/dev/lang.php:1401-1414 and applications/downloads/dev/lang.php:556. The generated content extensions do not need one; ExtensionGenerator::generate() writes ipAddresses__core_Content_<class> into the language stack at runtime from the class's $archiveTitle if it has one and otherwise its $title, suffixed _pl (system/Content/ExtensionGenerator.php:65). Application::constructExtensionClass() repeats the same line for extensions rebuilt from the cached classname map (Application.php:492).

Who calls it

Five call sites, all found by grepping for 'core', 'IpAddresses'.

CallerWhat it calls
applications/core/modules/admin/members/ip.php:107-132The ACP lookup grid. allExtensions( 'core', 'IpAddresses' ), then supportedInAcp() and findByIp( $ip ) on each. Requires the membertools_ip ACP restriction.
applications/core/modules/admin/members/ip.php:83-85A clicked tile. Loads only the owning app's extensions and calls findByIp( $ip, $url ).
applications/core/extensions/core/ModCp/IPTools.php:94-154The same two things on the front end, gated on the can_use_ip_tools moderator permission, using supportedInModCp().
system/Member/Member.php:2613-2615Member::ipAddresses() calls findByMember() on every extension and merges the results, summing count and taking the minimum first and maximum last.
system/Member/Member.php:6990-6993Member::pruneAllLoggedIpAddresses() calls pruneIpAddresses( $time ). Reached only from the pruneipaddresses task (applications/core/tasks/pruneipaddresses.php).

Member::ipAddresses() in turn feeds the ACP member IP tab (applications/core/modules/admin/members/members.php:2487), the IP checkbox list on the ban form (members.php:3172), the ModCP per-member view (IPTools.php:169) and the known_ip_addresses key of the GDPR export (applications/core/extensions/core/MemberExportPersonalInformation/Main.php:81).

Two details about how the objects are built. The lookup screens use the default $checkAccess=TRUE, which means Application::canAccess() is consulted for the owning application and your extension is constructed with Member::loggedIn() passed to the constructor (Application::constructExtensionClass(), Application.php:479). The prune path uses allExtensions( 'core', 'IpAddresses', FALSE, 'core' ) — no access check, constructor gets NULL, and core's own extensions are sorted first. The abstract declares no constructor, so you can ignore the argument; PHP does not object to the extra parameter.

A minimal example

This is applications/core/extensions/core/IpAddresses/Votes.php reduced to its structure — a complete extension over a flat log table with a member column, a date column and an IP column. Imports and the usual guard are included because they are part of what makes the file work.

<?php

namespace IPS\myapp\extensions\core\IpAddresses;

use IPS\DateTime;
use IPS\Db;
use IPS\Db\Select;
use IPS\Extensions\IpAddressesAbstract;
use IPS\Helpers\Table\Db as TableDb;
use IPS\Http\Url;
use IPS\Member;
use IPS\Theme;
use function defined;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
    header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
    exit;
}

class Votes extends IpAddressesAbstract
{
    /**
     * $time is a UNIX timestamp.
     */
    public function pruneIpAddresses( int $time ) : void
    {
        Db::i()->update( 'myapp_votes', [ 'ip_address' => '' ], [ "ip_address != '' AND vote_date <?", $time ] );
    }

    /**
     * @param   string      $ip         May contain % wildcards - use LIKE
     * @param   Url|null    $baseUrl    NULL = return a count
     */
    public function findByIp( string $ip, ?Url $baseUrl = NULL ): string|int|null
    {
        /* Return count */
        if ( $baseUrl === NULL )
        {
            return Db::i()->select( 'COUNT(*)', 'myapp_votes', array( "ip_address LIKE ?", $ip ) )->first();
        }

        /* Init Table */
        $table = new TableDb( 'myapp_votes', $baseUrl, array( "ip_address LIKE ?", $ip ) );

        $table->include    = array( 'member_id', 'vote_date', 'ip_address' );
        $table->mainColumn = 'vote_date';
        $table->langPrefix = 'myapp_votes_';

        $table->tableTemplate = array( Theme::i()->getTemplate( 'tables', 'core', 'admin' ), 'table' );
        $table->rowsTemplate  = array( Theme::i()->getTemplate( 'tables', 'core', 'admin' ), 'rows' );

        $table->sortBy        = $table->sortBy ?: 'vote_date';
        $table->sortDirection = $table->sortDirection ?: 'desc';

        $table->parsers = array(
            'member_id' => function( $val, $row )
            {
                $member = Member::load( $val );
                return Theme::i()->getTemplate( 'global', 'core' )->userPhoto( $member, 'tiny' ) . ' ' . $member->link();
            },
            'vote_date' => function( $val, $row )
            {
                return DateTime::ts( $val );
            },
        );

        return (string) $table;
    }

    /**
     * Keys are IPs; each row needs ip, count, first, last.
     */
    public function findByMember( Member $member ) : array|Select
    {
        return Db::i()->select(
            "ip_address AS ip, count(*) AS count, MIN(vote_date) AS first, MAX(vote_date) AS last",
            'myapp_votes',
            array( 'member_id=?', $member->member_id ),
            NULL, NULL,
            'ip_address'
        )->setKeyField( 'ip' );
    }
}

Note the tableTemplate and rowsTemplate lines, and note what they are actually for. Table::__construct() already defaults both to Theme::i()->getTemplate( 'tables', 'core' ) with no location argument (system/Helpers/Table/Table.php:330-331), and getTemplate() fills a missing location from Dispatcher::i()->controllerLocation (system/Theme/Theme.php:880-883). So in the AdminCP the defaults are already the admin templates; the explicit assignments matter in the ModCP, where the identical extension is rendered from a front-end dispatcher and would otherwise pick up the front-end table markup. Fourteen of the nineteen shipped implementations set both. Logins and Validations do not, and the two nexus extensions leave the defaults alone because they delegate to Transaction::table() / Payout::table(). Registration goes the other way, swapping in a front-end modcp rows template when the dispatcher location is front (Registration.php:72-77).

Fatal error: Class ... contains 2 abstract methods

The most common first failure. Because findByIp() and findByMember() are abstract, omitting either produces a compile-time fatal of the form:

Fatal error: Class IPS\myapp\extensions\core\IpAddresses\Votes contains 2 abstract
methods and must therefore be declared abstract or implement the remaining methods
(IPS\Extensions\IpAddressesAbstract::findByIp, IPS\Extensions\IpAddressesAbstract::findByMember)

The blast radius is narrower than with some other extension points. The class is only loaded when something calls class_exists() on it, which happens in Application::extensions() and Application::constructExtensionClass() — so the fatal fires on the ACP and ModCP IP tools, the member IP tab, the ban form, the PII export and the prune task, but not on ordinary front-end pages. A site can look perfectly healthy while the IP tools are dead.

The signatures must match character for character, including the nullable ?Url and the union return types. Widening a return type is not permitted in PHP, so declaring findByMember(): ?array is itself a fatal; narrowing is fine, and downloads does exactly that with public function findByMember( Member $member ) : array (applications/downloads/extensions/core/IpAddresses/DownloadLog.php:118).

The file exists but no tile ever appears

Silence, no log entry. Work through these in order.

  • The class is not listed in data/extensions.json under core → IpAddresses. Application::extensions() reads that file and nothing else (Application.php:917-950).
  • The class name in the JSON does not resolve. extensions() skips any entry failing class_exists() with a continue and no message.
  • The classname map is cached. allExtensions() stores it in Store::i()->extensions keyed by extension type (Application.php:359, 442). Saving or deleting an application clears it — Application::$caches includes 'extensions' (Application.php:117) — but after hand-editing extensions.json you may need to clear the system cache.
  • findByIp( $ip ) returned NULL. That is the documented way to opt out, and it is easy to hit by accident if your guard clauses return NULL rather than 0.
  • supportedInAcp() or supportedInModCp() returned FALSE. Note this is per-tool: Dnames is ModCP-only, while Logins, SpamLogs, AdminLogs, AdminLoginLogs, ErrorLogs, MemberHistory, ModeratorLogs, Validations and both of nexus's extensions (Transactions and Payouts) are ACP-only.
  • The application is disabled, or the viewer's group cannot access it. allExtensions() skips disabled apps and, with the default $checkAccess=TRUE, apps failing canAccess() (Application.php:401-412). In the ACP an administrator holding the core/applications/app_manage restriction bypasses the group check (Application.php:5308); in the ModCP nobody does, so an app restricted to certain groups is invisible in the moderator tool to moderators outside them.
  • RECOVERY_MODE is on, which skips all non-IPS applications (Application.php:396).

Wildcard searches return nothing for my area

Exact IPs work, 192.168.1.* returns zero. Both callers convert * to % before handing you the string, so the query must use LIKE ?. An = comparison matches nothing and reports a perfectly plausible 0. Nothing warns you.

The IP column is blank on the member's IP address list

Or the "see uses" button links to &ip= with nothing after it. The rows returned by findByMember() are handed straight to an IPS\Helpers\Table\Custom whose include list is array( 'ip', 'location', 'count', 'first', 'last' ) and whose mainColumn and quickSearch are both 'ip' (members.php:2509-2540). The geolocation parser and the row button both read $row['ip']. So the IP must appear inside the row as well as being the array key — hence the AS ip alias in every core query. Omit it and the table renders with an empty first column and dead buttons, no error.

My member IP rows are numbered 1, 2, 3 instead of showing IP addresses

You returned an IPS\Db\Select without calling setKeyField( 'ip' ). Select::key() falls back to the integer row counter when no key field is set (system/Db/Select.php:531-554), so Member::ipAddresses() merges your rows under numeric keys. Row 0 is then dropped outright by the if ( $ip ) test at Member.php:2638, and the rest are rendered as though the member's IP address were "1". The same wrong keys are what the GDPR export writes to known_ip_addresses, because it uses array_keys().

The equivalent mistake with a plain array is returning a list rather than a map. iterator_to_array() preserves whatever keys the Select produced, so it does not rescue a missing setKeyField().

"Undefined array key" warnings on the member IP tab

Member::ipAddresses() reads $data['count'], $data['first'] and $data['last'] without any isset() guard (Member.php:2626-2633). Alias your columns to exactly those names. Under PHP 8 a missing key is a warning plus a NULL, so the merge silently produces nonsense totals rather than failing outright.

first and last must be integer timestamps. The docblock on Member::ipAddresses() claims they are \IPS\DateTime objects; they are not. The consuming tables call DateTime::ts( $val ) themselves (members.php:2517-2525, IPTools.php:181-190), and the merge compares them with < and >. The docblock on the abstract's findByMember() is the accurate one.

TypeError: findByMember() must not return null

Member::ipAddresses() contains a if ( $results === NULL ) { continue; } guard at Member.php:2617-2620. It is unreachable in IC5: the declared return type is array|Select, and a subclass cannot widen it, so returning NULL raises a TypeError before the guard is ever consulted. Return array() for "nothing", as SpamLogs, AdminLoginLogs and Content do.

My tile is labelled "ipAddresses__myapp_Votes"

The language string is missing. The template prints {lang="ipAddresses__{$key}"} with no fallback, so the raw key is what an administrator sees. The key must be ipAddresses__ plus the application directory, an underscore, and the exact key you used in extensions.json — not the class name if the two differ. The developer centre flags this: run the "Missing language strings" scan in the ACP developer tools and look under the "IpAddresses Extensions" heading (details.php:563-599, string devscan__strings_ipaddresses).

Clicking a tile throws an error in the AdminCP

The area parameter is parsed with explode( '_', Request::i()->area ), taking element 0 as the application directory and everything after mb_strlen( $exploded[0] ) + 1 as the extension key (ip.php:82-84). Underscores in the extension key are fine — that is how Content_forums_Topic works (ExtensionGenerator strips the leading IPS\ with mb_substr( $_class, 4 ) and replaces the remaining backslashes, so IPS\forums\Topic becomes the key Content_forums_Topic and the area core_Content_forums_Topic). An underscore in the application directory is not: Application::load( 'my' ) throws OutOfRangeException, and the ACP controller has no try/catch around it. The ModCP version is guarded with Application::appIsEnabled() and degrades to a 404 with code 2C250/2 instead (IPTools.php:94-107). No validation forbidding an underscore in an application directory was found anywhere in system/Application/Application.php. What is there is an assumption: Application.php:6039 matches application language keys with /^__app_([a-z]*)$/, which only works for a lower-case alphabetic directory. The safe course is simply not to use an underscore.

The prune task wiped every IP address in my table

$time is a timestamp, not a day count. Treating it as days — comparing vote_date < 30, say — matches every row you have ever written. The confusion is understandable, because the sibling static Content::pruneIpAddresses() genuinely does take days. Check which one you are looking at.

The reverse symptom, nothing being pruned at all, has three usual causes. The ip_address_prune setting is 0, which the task treats as "never" and returns immediately (pruneipaddresses.php:45-48, with $time computed at line 50). Or your extension does not override pruneIpAddresses(), whose default body is empty — Logins deliberately leaves it empty with the comment "Main cleanup task takes care of this". Or the pruneipaddresses task is not running. Note also that content classes are pruned twice on each run, once through Content::routedClasses() and again through the generated Content extension; both do the same UPDATE.

What core does not do for you

There is no permission filtering on the counts. findByIp() is called with nothing but the IP string, and the tile shows a raw COUNT(*). Access to the tool as a whole is controlled upstream by the membertools_ip ACP restriction and the can_use_ip_tools moderator permission, but within the tool, whatever you count is shown. Core's Content extension counts everything too and only applies canView() when rendering individual row links, substituting the ipaddress_no_permission string for records the viewer cannot see (Content.php:132-138, 178-184). If your data is sensitive, either filter it in your own query or return FALSE from supportedInModCp().

The counts grid also splits your extension into one of two buckets based on whether it exposes a public class property. Extensions with one are treated as content classes and are only shown if $class::$databaseColumnMap['ip_address'] is set; everything else lands in the "other" bucket, which is rendered first (ip.php:119-130). That property is set by ExtensionGenerator, so unless you are subclassing it, do not declare a property named class on your extension.

Verified against

Read from Invision Community 5.0.19 source: system/Extensions/IpAddressesAbstract.php, system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), canAccess()), system/Member/Member.php (ipAddresses(), pruneAllLoggedIpAddresses()), system/Content/ExtensionGenerator.php, system/Content/Content.php (pruneIpAddresses()), system/Db/Select.php (key(), setKeyField()), system/Helpers/Table/Table.php (__construct(), __toString()), system/Theme/Theme.php (getTemplate()), system/Member/History.php, applications/core/modules/admin/members/ip.php, applications/core/modules/admin/members/members.php, applications/core/extensions/core/ModCp/IPTools.php, applications/core/extensions/core/MemberExportPersonalInformation/Main.php, applications/core/modules/admin/developer/details.php, applications/core/tasks/pruneipaddresses.php, applications/core/dev/html/global/members/ipLookup.phtml, and all nineteen shipped implementations under applications/{core,nexus,downloads}/extensions/core/IpAddresses/. No implementations exist in forums, cms, gallery, blog or calendar — their IP data is covered by the generated core/Content extension.

Two points are stated from PHP language behaviour rather than from IPS source, and are worth confirming against your own PHP version: the exact wording of the "contains 2 abstract methods" fatal, and the fact that returning a Table object from findByIp() is coerced to a string because no declare(strict_types=1) is in force (the only files carrying that declaration ship under system/3rd_party/, and the directive is per-file). Whether IPS deliberately permits or forbids underscores in an application directory could not be determined from the source; only the parsing consequence in ip.php is verified.


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.