The Client Area Bridge

5 Aufrufe Markdown

Call a method on your addon module from the customer's browser. The operation already exists: no controller, no route, no endpoint of your own.

Overview

An addon page in the client area is HTML your module produced. Then it needs to do something: save a preference, fetch a status, start a job. One operation is already wired on the website, and it calls any method whose name starts with use_.

The admin panel has the same bridge behind the tools controller. What differs is who is allowed through, and that difference is the security part of this article.

Prerequisites

  • An Addon module with status true in its configuration; a disabled addon is invisible to the bridge.
  • A web face: the client area opt-in (show_on_clientArea plus clientArea()) or a public main() page. Without one the bridge refuses every call.
  • coremio/modules/Addons/SampleAddon, a working demonstration of both faces.

Structure

controllers/website/addon.php The website face: resolves /addon/{Name}, produces the page and dispatches the bridge operation.
operations/ClientAddon.php The trait holding use_addon_method, plus the gate that decides whether your addon has a web face.
operations/AdminTools.php The admin twin: same operation name and prefix rule, behind an administrator session.
AddonModule Your base class. Supplies $area_link, $error, $config, $lang, $dir, $url and view().
one call, end to end
browser  POST /addon/MyAddon   operation=use_addon_method & method=save-preference
   |
   +-- addon controller  ->  addon_ctx()     enabled config + module instance + web face?
   +-- ClientAddon       ->  member session required when the face is the client area
   +-- name normalised   ->  'save-preference'  becomes  use_save_preference
   +-- method_exists     ->  refuse when absent
   +-- $module->use_save_preference()          called with NO arguments
   +-- falsy return      ->  Exception($module->error)
   '-- array or string   ->  JSON body

Walkthrough

Give the Addon a Web Face

  1. Set show_on_clientArea to true in config.php and add a clientArea() method returning ['page_title' => …, 'breadcrumbs' => …, 'content' => …]. That makes /addon/{Name} the customer page and requires a signed-in member.
  2. Or add a main() method for a public page, which carries no session guarantee at all.
  3. Set meta.slug for a pretty address; the controller rewrites $area_link to it.

Write the use_ Method

  1. Name it use_{something}; nothing else is callable, and that prefix is the whole boundary at the dispatch layer.
  2. Take no parameters. Read your input yourself with Filter::init("POST/…"), as an operation does.
  3. Return a non-empty array or string; a falsy return counts as failure and raises an exception carrying $this->error.
  4. For a real failure, throw: the caller converts it into the standard error envelope.

Call It from the Page

  1. Post to $this->area_link, which the controller already points at the correct address. Never hand-write the path.
  2. Send operation=use_addon_method and method={name without the prefix}, plus whatever else your method reads.
  3. On the website use plain fetch with the X-Requested-With header; in the admin panel use WcpRequest.

Reference

The Two Endpoints

FaceAddressWho gets through
client area/addon/{Name} or the configured sluga signed-in member, if the addon opted in
public page/addon/{Name}anyone, with no session
legacy alias/addon/{Name}/clientas the client area; 404 without the opt-in
admin panelthe tools addons addressan administrator with the tools privilege
admin-only addon called from the websiteany of the abovenobody: the operation throws Addon not found.
The web face check is a real security boundary, added after a real hole

Before it existed, an admin-only addon's use_* methods were reachable from the website with no session. Settings were overwritten, ticket data read, paid API credit burned. Treat it as the outer wall, not the only one.

The Operation and the Name Rule

signature
public function use_addon_method(Operation $operation): bool;
name normalisation
$method = (string) Filter::init("REQUEST/method", "route");        // keeps a-zA-Z0-9 - _ .
$method = "use_" . str_replace([' ', '-', '.'], '_', $method);     // spaces, hyphens, dots -> underscore

if ($method === "use_" || !method_exists($module, $method))
    throw new \Exception("Undefined addon method.");

$result = $module->{$method}();                                     // NO arguments
if (!$result) throw new \Exception((string) (($module->error ?? '') ?: 'An error occurred'));

return $operation->output($result);

So method=save-preference, save.preference and save_preference all reach use_save_preference(). The route filter runs first, keeping only a-zA-Z0-9, hyphen, underscore and dot, so a name cannot escape into another class.

What Your Method Must Return

signature
public function use_sample_method(): array|string;
You returnThe customer receivesUse it for
a non-empty arraythat array, JSON encodedthe normal case; keep the standard keys
a non-empty stringthe string, written as isa ready HTML fragment for the DOM
[], '', false or null{"status":"error","message":"…"} built from $this->errornothing: a legacy path, do not design for it
a thrown exception{"status":"error","message":"your message"}every real failure

The envelope to aim for matches the rest of the panel:

the shape to aim for
return [
    'status'  => 'successful',
    'message' => $this->lang['saved'] ?? 'Saved.',
    'data'    => ['preference' => $value, 'updated_at' => time()],
];

What the Base Class Hands You

exact signatures
class AddonModule
{
    public string|bool $error = '';        // read by the bridge when your method returns falsy
    public array  $config    = [];         // config.php merged with the saved settings
    public array  $lang      = [];         // lang/{selected}.php
    public string $area_link = '';         // the address to post to; REWRITTEN per face
    public string $_name     = '';         // the directory name
    public array  $user      = [];         // the signed-in member, when there is one
    public array  $admin     = [];         // the signed-in administrator, when there is one
    public string $url       = CORE_FOLDER . DS . MODULES_FOLDER . DS . 'Addons' . DS;
    public string $dir;                    // filesystem path of the module directory
    // The constructor appends {Name} to $url and resolves it into a public URL, fills $dir,
    // $config, $lang, $user and $admin, and points $area_link at the current face.

    public function __construct();
    protected function view($file = '', $variables = []): string;
    public function privileges();
    public function save_settings($pFields, $accessPs): bool;
    public function change_addon_status($arg = '');
    public function save_config($data = []): bool;
    public function use_default_settings($formElements = null);
    public function isEnabled();
}
area_link is not one fixed value

In the panel it points at the tools addons address. On the website the addon controller overwrites it with the public address. Print it rather than building a path.

Reading Core Data

signature
// $groupAction is "Group/Action" from the route registry; prefix it with "client:" for the
// customer registry, in which case $input must also carry owner_id.
public static function internal(string $groupAction, array $input = [], array $query = []): array;
in a use_ method
use WISECP\Api\Kernel;

// In process: no HTTP, no authentication, no rate limit, but the SAME envelope the
// external API returns. $input fills the path parameters first, then the body.
$resp    = Kernel::internal('Tickets/GetTicketMessages', ['id' => $ticketId], ['limit' => 50]);
$replies = $resp['data'] ?? [];

Prefer this over calling a helper directly: the resource layer hands back decrypted, allow-listed and normalised rows.

Example

A client page that stores one customer preference.

MyAddon.php
<?php
namespace WISECP\Modules\Addons\MyAddon;

use Exception;
use Filter;
use Language;
use User;
use UserManager;

class MyAddon extends \AddonModule
{
    /** Rendered at /addon/MyAddon once show_on_clientArea is true in config.php. */
    public function clientArea(): array
    {
        $member = UserManager::LoginData('member');

        return [
            'page_title'  => $this->lang['meta']['name'] ?? 'My Addon',
            'breadcrumbs' => [['link' => '', 'title' => $this->lang['meta']['name'] ?? 'My Addon']],
            'content'     => $this->view('client.php', [
                'link'       => $this->area_link,                                  // post here
                'preference' => (string) (User::getInfo((int) $member['id'], ['my_addon_pref'])['my_addon_pref'] ?? ''),
            ]),
        ];
    }

    /**
     * Reached as method=save-preference. No parameters: it reads its own input, exactly
     * like an operation, and it re-reads the account from the session rather than trusting
     * anything the request sent.
     *
     * @throws Exception
     */
    public function use_save_preference(): array
    {
        $member = UserManager::LoginData('member');
        if (!$member) throw new Exception(Language::gc('website/index/addon-login-required') ?: 'Login required.');

        $value = Filter::init('POST/preference', 'route');
        if ($value === '' || !in_array($value, ['daily', 'weekly', 'never'], true))
            throw new Exception($this->lang['err-bad-preference'] ?? 'Choose one of the offered options.');

        User::AddInfo((int) $member['id'], ['my_addon_pref' => $value]);

        return [
            'status'  => 'successful',
            'message' => $this->lang['saved'] ?? 'Saved.',
            'data'    => ['preference' => $value],
        ];
    }
}
views/client.php
<select id="ma-pref" class="form-select">
    <option value="daily">Daily</option>
    <option value="weekly">Weekly</option>
    <option value="never">Never</option>
</select>
<button type="button" id="ma-save" class="btn btn-primary mt-2">Save</button>

<script>
(function () {
    var link = ;
    var btn  = document.getElementById('ma-save');
    var sel  = document.getElementById('ma-pref');
    if (!btn || !sel) return;

    btn.addEventListener('click', function () {
        btn.disabled = true;

        var body = new URLSearchParams({
            operation:  'use_addon_method',
            method:     'save-preference',   // hyphen is fine, it becomes use_save_preference
            preference: sel.value
        });

        fetch(link, {
            method:  'POST',
            headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
            body:    body
        })
        .then(function (r) { return r.json(); })
        .then(function (r) {
            if (r.status === 'successful') window.WCPTheme && window.WCPTheme.toast(r.message, 'success');
            else window.WCPTheme && window.WCPTheme.toast(r.message, 'error');
        })
        .finally(function () { btn.disabled = false; });
    });
})();
</script>

The same call from an admin page:

the admin twin
WcpRequest(AREA_LINK, {
    method: 'POST',
    data:   { operation: 'use_addon_method', method: 'save-preference', preference: 'weekly' },
    button: runBtn,
    buttonLoader: window.saving_loader,
    done: function (response) {           // omit `done` and the standard handling applies
        output.textContent = JSON.stringify(response, null, 2);
    }
});

Pitfalls

A public page makes every use_ method public too

An addon whose web face is main() is reachable with no session by design, for compatibility with older modules. Ask, for each method, who should be able to call it. Write that check in the method.

Do not trust an account id that arrives in the request

The bridge authenticates the visitor, not the record. A method that reads a customer id from the body serves another customer's data to whoever asks. Read the account from the member session.

An empty successful result reads as a failure

Returning [] after a legitimately empty query produces an error envelope carrying whatever is in the error property, often an empty message. Return an array whose data field is the empty list.

Saving through the settings operation overwrites status and access privileges

The standard settings save writes the status flag and access privilege list along with your fields, so a custom save that reuses it can disable your addon. Give the custom save its own use_ method.

A disabled addon answers nothing

The context resolver checks the enabled flag before it builds the instance, so a call against a disabled addon fails with Addon not found., not a method error.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.