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\Select — Member::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'.
| Caller | What it calls |
|---|---|
applications/core/modules/admin/members/ip.php:107-132 | The 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-85 | A clicked tile. Loads only the owning app's extensions and calls findByIp( $ip, $url ). |
applications/core/extensions/core/ModCp/IPTools.php:94-154 | The same two things on the front end, gated on the can_use_ip_tools moderator permission, using supportedInModCp(). |
system/Member/Member.php:2613-2615 | Member::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-6993 | Member::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.jsonundercore → 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 failingclass_exists()with acontinueand no message. - The classname map is cached.
allExtensions()stores it inStore::i()->extensionskeyed by extension type (Application.php:359, 442). Saving or deleting an application clears it —Application::$cachesincludes'extensions'(Application.php:117) — but after hand-editingextensions.jsonyou may need to clear the system cache. findByIp( $ip )returnedNULL. That is the documented way to opt out, and it is easy to hit by accident if your guard clauses returnNULLrather than0.supportedInAcp()orsupportedInModCp()returnedFALSE. Note this is per-tool:Dnamesis ModCP-only, whileLogins,SpamLogs,AdminLogs,AdminLoginLogs,ErrorLogs,MemberHistory,ModeratorLogs,Validationsand both ofnexus's extensions (TransactionsandPayouts) 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 failingcanAccess()(Application.php:401-412). In the ACP an administrator holding thecore/applications/app_managerestriction 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_MODEis 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.
Recommended Comments