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

A core/EditorMedia extension adds a tab to the Insert existing attachment dialog — the folder icon in the editor's attachment bar. A member composing a post clicks it, and gets a grid of files they already own on the site, with a search box and pagination, and a button to insert one or several into the post they are writing. Core ships one source (their previous post attachments), Gallery adds their gallery images, and Downloads adds the files they have uploaded to Downloads. Your extension adds another such tab. Core's own summary of the extension point, in applications/core/dev/lang.php line 1285, is "Add additional media sources to the 'Insert existing attachment' button on the editor."

The contract

IPS\Extensions\EditorMediaAbstract has exactly two methods and both are abstract. There are no optional methods, no properties, and no constructor.

namespace IPS\Extensions;

abstract class EditorMediaAbstract
{
    /* How many items this member has. Used as a boolean gate AND as the
       pagination total. */
    abstract public function count( Member $member, string $postKey, ?string $search=NULL ): array|int;

    /* One page of items, keyed by URL. */
    abstract public function get( Member $member, ?string $search, string $postKey, int $page, int $limit ): array;
}

Note the argument order is not the same in the two methods. count() takes ( $member, $postKey, $search ); get() takes ( $member, $search, $postKey, $page, $limit ). PHP matches by position, not by name, so carrying the names across — declaring count( Member $member, ?string $search, string $postKey ) and then querying on $search — is a silent bug: you are filtering on the post key. Carrying the declared types across is not silent; it is a compile-time Declaration must be compatible fatal, because count()'s third parameter is optional and get()'s third parameter is required.

The three shipped implementations declare count() as string $search=NULL rather than ?string $search=NULL — the implicit nullable that PHP 8.4 deprecates. Copying that is legal today but will warn; write ?string.

count() must return a number. The declared return type is array|int, but see the failure mode below — nothing in core can handle an array. All three shipped implementations return Db::i()->select( 'COUNT(*)', ... )->first(). The value is used twice: as a truthiness test that decides whether your tab exists at all, and as the numerator of ceil( $count / $perPage ) for the pager.

get() must return a flat array whose keys are URLs and whose values are IPS\File objects. The docblock on EditorMediaAbstract line 45 describes a two-level array grouped by title — array( 'Title' => array( 'http://…/file1.txt' => \IPS\File, … ), … ) — and that docblock is stale. No shipped implementation returns it and the template cannot render it. The template that consumes the return value is applications/core/dev/html/global/editor/myMediaResults.phtml, lines 8 and 9:

{{foreach $files as $url => $file}}
    {template="uploadFile" group="forms" location="global" params="preg_replace( … ), $file, NULL, TRUE, TRUE, $url"}
{{endforeach}}

Two things follow from that line. The preg_replace matches the whole key against {base_url}applications/core/interface/file/attachment.php?id=(\d+)(&key=[a-z0-9]+)? and replaces it with just the captured digits — so for a real attachment the key collapses to the numeric attachment ID, security key and all, and any key that does not match that pattern passes through unchanged. Whatever comes out becomes data-fileid on the inserted element. The unmodified key is also passed as the sixth argument, $link, which becomes data-filelink. Your keys must be unique — the array is a map, so two items sharing a URL silently collapse into one.

Whether the key is also the URL the reader lands on depends on the item's mediaType(), and only one of the four cases uses it. _buildInsertData() in ips.editor.insertable.js reads data-filelink only in its default (file) branch, where it becomes the href of the inserted <a>. For image it reads data-thumbnailurl and data-fullsizeurl instead — both derived from the IPS\File object, not from your key — and for video and audio it ignores the link entirely and rebuilds the href as {base_url}applications/core/interface/file/attachment.php?id={data-fileid}. So Gallery's images insert pointing at the gallery_Images storage URL, not at the image page URL Gallery uses as its key; the key survives only in data-fileid. Plan your keys accordingly: for plain files the key is the destination, for images and video it is not, and for audio a key that is not an attachment ID produces a broken src.

get() is responsible for its own paging. Core passes $page (1-based) and $limit and does not slice the result. $limit is hard-coded to 12 in applications/core/modules/front/system/editor.php line 379.

The rendering template applications/core/dev/html/global/forms/uploadFile.phtml is always called with $showAsImages = TRUE from this path, so only its first branch matters. It reads these properties off each IPS\File you return:

  • mediaType()image, video, audio or file, derived purely from the extension on originalFilename (system/File/File.php line 1481). This decides which kind of element the JavaScript builds on insert.
  • originalFilename — the caption, and the basis of every type test. File::get() sets it from the stored filename; override it if the stored name is obfuscated, as Attachment.php line 99 and Files.php line 82 both do.
  • contextInfo — declared public ?string with no default at system/File/File.php line 54, so it is an uninitialised typed property until you set it. The template guards with isset(). When set, it becomes the title and the filename moves to the sub-line. Gallery puts the image caption here, Downloads puts the parent file's name.
  • screenshotpublic mixed, line 59. If set to an IPS\File, that file is used for the preview thumbnail instead of the file itself. Downloads uses it for its primary screenshot.
  • attachmentThumbnailUrlpublic ?string, line 1350. Emitted as data-thumbnailurl and used as the src of the inserted <img>, with the full-size URL becoming a wrapping link.
  • filesize() — rendered under the title. Read the performance trap below before you ignore this one.

Who calls it

Only two places in the whole suite, both found with grep -rn "'core', 'EditorMedia'".

applications/core/modules/front/system/editor.php, method myMedia() (line 376), is the AJAX dialog itself. It calls count() once per extension with no search term to build the tab list (lines 384–390), keeping only the extensions that return something truthy. It then validates Request::i()->tab against that list, resolves the class with Application::getExtensionClass(), instantiates it with new $classname and no arguments (line 407), calls count() again with the search term for the pager (line 411), and calls get() for the visible page (lines 426, 440 or 455 depending on whether this is the initial load, a search or a page change).

system/Helpers/Form/Editor.php, method canUseMediaExtension() (line 566), calls count( Member::loggedIn(), '' ) on every extension and returns TRUE at the first truthy answer. This only decides whether the "Other Media" dropdown is drawn in the placeholder attachment bar — the one shown to members who cannot upload at all. In the ordinary case, where the uploader is present, applications/core/dev/html/global/forms/editorAttachments.phtml line 53 renders the folder icon unconditionally, and the dialog opens whether or not any source has anything to show.

In both call paths the member passed to your methods is always Member::loggedIn(). There is no path that asks one member for another member's media. Use the argument anyway — it is what makes the query correct.

A minimal example

Registration first. An extension file that is not listed in data/extensions.json does not exist as far as IC5 is concerned: Application::extensions() reads only that file and never scans the directory.

/* applications/myapp/data/extensions.json */
{
    "core": {
        "EditorMedia": {
            "Attachments": "IPS\\myapp\\extensions\\core\\EditorMedia\\Attachments"
        }
    }
}
/* applications/myapp/dev/lang.php - the tab label in the dialog sidebar */
'editorMedia_myapp_Attachments' => "My App Uploads",

The class below follows applications/downloads/extensions/core/EditorMedia/Files.php, which is the best of the three shipped implementations because it is the only one that pre-caches the file size.

<?php
/* applications/myapp/extensions/core/EditorMedia/Attachments.php */

namespace IPS\myapp\extensions\core\EditorMedia;

use IPS\Db;
use IPS\Extensions\EditorMediaAbstract;
use IPS\File;
use IPS\Member;
use function defined;

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

class Attachments extends EditorMediaAbstract
{
    /* Both methods must apply the SAME conditions or the pager lies. */
    protected function where( Member $member, ?string $search ) : array
    {
        $where = array(
            array( 'upload_member=?', $member->member_id )
        );

        if ( $search )
        {
            $where[] = array( "upload_name LIKE ( CONCAT( '%', ?, '%' ) )", $search );
        }

        return $where;
    }

    /* Return type is array|int in the abstract. Return an int. */
    public function count( Member $member, string $postKey, ?string $search=NULL ): array|int
    {
        if ( !$member->member_id )
        {
            return 0;
        }

        return Db::i()->select( 'COUNT(*)', 'myapp_uploads', $this->where( $member, $search ) )->first();
    }

    public function get( Member $member, ?string $search, string $postKey, int $page, int $limit ): array
    {
        if ( !$member->member_id )
        {
            return array();
        }

        $return = array();

        /* You do your own paging. Core does not slice this. */
        foreach ( Db::i()->select(
            '*',
            'myapp_uploads',
            $this->where( $member, $search ),
            'upload_date DESC',
            array( ( $page - 1 ) * $limit, $limit )
        ) as $row )
        {
            /* Third argument is the cached filesize - without it the dialog
               downloads the whole file just to print "1.2 MB". */
            $obj = File::get( 'myapp_Uploads', $row['upload_location'], $row['upload_size'] );

            /* Drives the caption and every isImage()/isVideo() test. */
            $obj->originalFilename = $row['upload_name'];

            /* Optional: shown instead of the filename, with the filename below. */
            $obj->contextInfo = $row['upload_title'];

            /* Key = the URL the inserted element points at. Must be unique. */
            $return[ (string) $obj->url ] = $obj;
        }

        return $return;
    }
}

Fatal error: Return value must be of type array, int returned

This is the first thing that happens to almost everyone, and the cause is core's own scaffolding. The Developer Centre generates new extensions from applications/core/data/defaults/extensions/EditorMedia.txt, and that template declares:

public function count( MemberClass $member, string $postKey, ?string $search=NULL ): array
{
    return array();
}

Return types are covariant, so narrowing array|int to array is legal and the file loads. But the moment you fill the body in with the obvious Db::i()->select( 'COUNT(*)', … )->first(), PHP raises a TypeError inside the AJAX dialog — and because it is a fatal, what the member sees is a blank or generic-error modal, not a message that names your app. Widen the declaration back to array|int, or just declare : int.

The unmodified skeleton is also inert: it returns an empty array, which is falsy, so the extension never appears anywhere and never errors.

Returning an array from count() breaks the dialog

The type says array|int and the docblocks in Gallery and Downloads still say array( 'Title' => 0 ), but there is no code path in IC5 that consumes an array here. editor.php lines 429, 443 and 458 all do ceil( $count / $perPage ). Dividing an array in PHP 8 is a fatal TypeError: Unsupported operand types: array / int, thrown from editor.php, not from your file.

The blast radius is narrower than it first looks, and it is worth being precise about it. The truthiness gate at line 386 accepts a non-empty array happily, so your tab is listed. But $count at line 411 is re-fetched from the selected extension only, so the fatal fires only on the request that renders your tab: the moment a member clicks it, or on the initial dialog load if yours happens to be the first source with a truthy count — which is the order allExtensions() returns, application order, with core's Attachment ahead of yours unless the member has no attachments. Other tabs keep working. It is still a broken dialog for anyone who reaches yours, and it will not be attributed to your app.

Return an integer. If you have nothing to offer, return 0.

My tab never appears in the dialog

Four independent causes, all silent.

count() returned something falsy. This is the gate, and it is evaluated with no search term. 0, '0', NULL and array() all remove your tab completely — and if every extension returns falsy, the dialog renders "You do not have any existing attachments." (editor_no_media) with no sidebar at all. There is no way to force a tab to be shown when your count is zero.

The class is not in extensions.json. Application::extensions() (system/Application/Application.php lines 911–974) reads only applications/<app>/data/extensions.json. If the class is absent from it, or listed but class_exists() returns false, the loop does continue with no message. Creating the extension through ACP → Developer Centre writes the JSON for you; hand-created files need it adding by hand.

The datastore is stale. Application::allExtensions() caches the resolved class list in Store::i()->extensions and only rebuilds it when the EditorMedia key is missing. Adding an extension to a running site does nothing until the cache is cleared (ACP → System → Support → Clear cache, or unset( Store::i()->extensions )).

The member cannot access your application. Both callers use the default $checkAccess=TRUE, so allExtensions() skips any app whose canAccess() fails for the logged-in member. Non-IPS applications are also skipped entirely when \IPS\RECOVERY_MODE is on.

Call to a member function mediaType() on array

You followed the docblock on EditorMediaAbstract line 45 and returned array( 'My Files' => array( $url => $file ) ). The template iterates one level only and hands each value straight to uploadFile.phtml, which calls $file->mediaType() on line 4. The result is a fatal inside the dialog. There is no grouping feature in IC5's My Media dialog — the grouping the docblock describes is not implemented anywhere. Return a flat url => IPS\File map.

Members can see each other's files

Nothing in core filters your rows. myMedia() performs no permission check beyond the application-access check that allExtensions() already did, and it never inspects what you return. If your WHERE clause does not contain the member ID, every member sees every member's private uploads, with working URLs, and there will be no error to notice. All three shipped implementations constrain by member — core on attach_member_id=? and Gallery on image_member_id=? directly, Downloads indirectly, because downloads_files_records has no submitter column, so it filters record_file_id IN( SELECT file_id FROM downloads_files WHERE file_submitter=? ). Gallery additionally requires image_approved=1.

Guard against guests too. Member::loggedIn() for a guest has member_id of NULL, and member_id=NULL in a prepared statement matches nothing under normal MySQL semantics, but returning early is clearer and cheaper.

Opening the dialog takes many seconds and hammers the file store

This is the most common performance complaint and it is easy to cause. uploadFile.phtml prints {filesize="$file->filesize()"} for every item. IPS\File::filesize() (system/File/File.php line 969) returns $this->_cachedFilesize if it is set, and otherwise does strlen( $this->contents() ). File::load() never populates _cachedFilesize. So unless you pass the size yourself, rendering one page of twelve items reads twelve entire files — file_get_contents() on a local store, or twelve full HTTP GETs against S3, R2 or Backblaze on a remote one.

The fix is the third argument to File::get( string $storageExtension, string|Url $url, ?int $cachedFilesize=NULL ). Downloads does this (Files.php line 81); core's Attachment and Gallery's Images do not, which is why the "Post Attachments" tab is slow on remote storage. If you do not store a byte count, store one.

A related detail: when the underlying file is missing, contents() throws IPS\File\Exception, which extends RuntimeException, and filesize() catches it and returns FALSE rather than propagating. A deleted file shows up as a size of nothing, not as an error.

The sidebar label reads "editorMedia_myapp_Attachments"

The tab label comes from applications/core/dev/html/global/editor/myMedia.phtml line 16, {lang="editorMedia_{$k}"}, where $k is {application directory}_{extension key from extensions.json}. addToStack() on a missing key returns the key itself, so a missing string shows as raw text rather than erroring. Core ships editorMedia_core_Attachment ("Post Attachments"), editorMedia_gallery_Images ("Gallery Images") and editorMedia_downloads_Files ("File Downloads") for exactly this reason.

The Developer Centre's language scan knows about this key: applications/core/modules/admin/developer/details.php line 571 maps 'EditorMedia' => 'editorMedia_{app}_{key}' and flags it as a missing string if you have not defined it. Note that the sidebar is only rendered when more than one source has content (myMedia.phtml line 5), so on a bare install with no Gallery or Downloads a missing label is invisible until a second source appears.

ArgumentCountError or TypeError when the dialog opens

Your extension is constructed by two different mechanisms with two different argument lists. Application::constructExtensionClass() does new $classToUse( Member::loggedIn() ) — one argument. editor.php line 407 does new $classname — no arguments. EditorMediaAbstract declares no constructor, and PHP tolerates surplus arguments to a class that has none, so writing no constructor is safest. If you must have one, declare it __construct( ?Member $member = null ). A constructor with a required parameter passes the tab-list stage — allExtensions() supplies one argument — and then throws ArgumentCountError at line 407 on the request that renders your tab. ArgumentCountError extends TypeError extends Error, and nothing on that path catches it.

There is a second, quieter half to this. constructExtensionClass() swallows RuntimeException and OutOfRangeException from your constructor and returns null, logging nothing. Your extension simply stops existing.

The item count in the pager does not match what is listed

count() and get() are separate queries and core assumes without checking that they agree. Two specific ways they drift apart:

Ignoring $search in count(). It is optional in the signature and easy to skip. The result is a pager built from the unfiltered total: empty pages 2 onwards, and a page count that never changes as the member types.

Ignoring $postKey. $postKey identifies the post currently being composed — it is md5( autoSaveKey . ':' . session_id() ), computed in system/Helpers/Form/Editor.php line 306. Core's Attachment extension uses it to exclude the attachments already on this post, so the member is not offered files the uploader is already showing them: array( 'attach_post_key<>?', $postKey ), in both methods, guarded by if ( $postKey ) because it arrives as '' from canUseMediaExtension(). Gallery and Downloads ignore it entirely, which is also fine — their items are not attachments. If you use it, use it in both methods.

My media is inserted but is not tracked as an attachment

That is by design, and worth understanding before you choose your array keys. When the post is saved, IPS\Text\Parser::_getAttachment() (system/Text/Parser.php line 2199) tries to resolve each link and image back to a core_attachments row, by ID, by an applications/core/interface/file/attachment.php?id= URL, or by matching the core_Attachment storage URL. Anything that does not resolve is left as an ordinary link or image: no core_attachments_map row, no attachment count, no entry on the member's Attachments page, and no cleanup when the post is deleted.

So if you want your items tracked as attachments, they must genuinely be attachments and you must key them by their attachment.php URL, exactly as Attachment.php lines 93–96 build it. If they are your own content — as with Gallery's images and Downloads' files — key them by the item's own page URL and accept that they insert as plain links and images.

Unverified: whatever the key is also ends up in the data-fileid attribute of the inserted element (see _buildInsert() in dev/js/framework/controllers/editor/ips.editor.insertable.js lines 312–392), and HTMLPurifier is configured to permit a URL there — system/Text/HtmlPurifierIntOrInternalLink.php exists specifically to allow "internal URLs or Integers" for data-fileid. But Parser::_getAttachment() types its second parameter int $fileId = NULL and is called with that attribute at Parser.php lines 1533 and 1646. On paper a non-numeric data-fileid reaching that call is a TypeError. This could not be exercised at runtime here, so treat it as unconfirmed — but it is a reason to key your results with a well-formed internal URL and nothing more exotic.

Sorting, grouping and filtering that do not exist

Worth stating plainly, because the abstract's docblock implies otherwise. There is no grouping. There is no sort control — ordering is whatever get() returns, and PHP preserves insertion order, so ORDER BY in your query is the only sorting there is. There is no type filter; if you want to offer only images, filter in your own WHERE. And the search box is a single free-text field passed straight through as $search; the shipped implementations all turn it into a LIKE ( CONCAT( '%', ?, '%' ) ) — core against attach_file, Downloads against record_realname, Gallery against image_caption plus a second lookup that also matches the member's album names.

Verified against

Read from Invision Community 5.0.19 source. system/Extensions/EditorMediaAbstract.php; all three shipped implementations, applications/core/extensions/core/EditorMedia/Attachment.php, applications/gallery/extensions/core/EditorMedia/Images.php and applications/downloads/extensions/core/EditorMedia/Files.php; the two callers, applications/core/modules/front/system/editor.php (myMedia()) and system/Helpers/Form/Editor.php (canUseMediaExtension(), html()); system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), getExtensionClass()); system/File/File.php (get(), filesize(), contents(), mediaType()) and system/File/FileSystem.php; the templates applications/core/dev/html/global/editor/myMedia.phtml, myMediaContent.phtml, myMediaResults.phtml, applications/core/dev/html/global/forms/uploadFile.phtml, editorAttachments.phtml and editorAttachmentsPlaceholder.phtml; the client controller dev/js/framework/controllers/editor/ips.editor.insertable.js; the Developer Centre scaffolding in applications/core/data/defaults/extensions/EditorMedia.txt and applications/core/modules/admin/developer/extensions.php, plus the language scan in applications/core/modules/admin/developer/details.php; and system/Text/Parser.php with system/Text/HtmlPurifierIntOrInternalLink.php for what happens to inserted media on save. Language strings from applications/core/dev/lang/editor.php, applications/core/dev/lang.php, applications/gallery/dev/lang.php and applications/downloads/dev/lang.php.


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.