Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

### Added
- **Site Identity — an admin screen for the site's brand.** A new first-party `identity` module adds a
**Site Identity** screen (under Settings) that finally surfaces the site **name** + **tagline** for
editing, plus a **logo** and **favicon** (chosen from the Media Library or uploaded, via the platform
media picker) and the **social profile URLs**. Everything writes to the config tier (live-override,
no deploy) — and it's wired to what already consumes it: the **favicon** is emitted into the head
(`rel="icon"` + `apple-touch-icon`, a single high-res square the browser scales — no derivative
soup), the **logo** feeds `Organization.logo` and the **socials** feed `Organization.sameAs` in the
JSON-LD. The screen has its **own ACL resource**, so access is grantable on its own — the seam for
letting each org's admin manage its own identity in a multi-tenant install. Config is written at
GLOBAL scope for now (the single site, guest-visible), isolated in one `_scope()` method so the
future multi-site module can flip it to per-org.

## [0.16.0-beta] — 2026-07-17

### Added
Expand Down
37 changes: 37 additions & 0 deletions modules/identity/Bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity module bootstrap.
*
* First-party "Site Identity" surface — the admin screen that sets the site name, tagline, logo,
* favicon, and social links, all stored in the config tier (live-override, no deploy). It is a
* module (not core) so it carries its own ACL resource and Settings entry; the values it writes
* are plain core config, consumed by the layout (favicon), TigerSEO (Organization logo + sameAs),
* and anywhere `tiger.site.*` is read.
*
* Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader, so
* Identity_Service_* / Identity_Form_* / Identity_Plugin_* load by convention; controllers load via
* the registered module dir; configs/acl.ini + languages/ are picked up by the core globs.
*/
class Identity_Bootstrap extends Zend_Application_Module_Bootstrap
{
/** Contribute the favicon to the head (config-driven; fail-open). High stackIndex = after routing. */
protected function _initFavicon()
{
Zend_Controller_Front::getInstance()->registerPlugin(new Identity_Plugin_Favicon(), 91);
}

/** List Site Identity under the admin Settings tree (ACL-gated to Identity_AdminController). */
protected function _initAdminSettings()
{
Tiger_Admin_Settings::register([
'key' => 'identity',
'label' => 'Site Identity',
'icon' => 'fa-fingerprint',
'href' => '/identity/admin',
'resource' => 'Identity_AdminController',
'order' => 10,
]);
}
}
21 changes: 21 additions & 0 deletions modules/identity/configs/acl.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
; Site Identity ACL — a dedicated resource for the screen + its /api service, so access is
; grantable on its own (the seam for letting an org's admin manage that org's site identity in
; a multi-tenant install). Deny-by-default: without these allows the screen and save are refused.
[production]

acl.resources.identity_admin_ctrl.resource = "Identity_AdminController"
acl.resources.identity_svc.resource = "Identity_Service_Identity"

acl.rules.identity_admin_ctrl.role = "admin"
acl.rules.identity_admin_ctrl.resource = "Identity_AdminController"
acl.rules.identity_admin_ctrl.permission = "allow"

acl.rules.identity_svc.role = "admin"
acl.rules.identity_svc.resource = "Identity_Service_Identity"
acl.rules.identity_svc.permission = "allow"

[staging : production]

[testing : production]

[development : production]
56 changes: 56 additions & 0 deletions modules/identity/controllers/AdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity_AdminController — the Site Identity screen (site name, tagline, logo, favicon, social
* links). Its own controller (and ACL resource), so access is grantable independently of the rest
* of the admin — the seam that lets a multi-tenant install hand each org's admin the keys to its
* own site identity. Thin per ADMIN.md: it renders the form pre-filled from the live config; the
* save is an /api call (Identity_Service_Identity).
*/
class Identity_AdminController extends Tiger_Controller_Admin_Action
{
/**
* Admin shell (layout) comes from the base; keep the explicit init cascade.
*
* @return void
*/
public function init()
{
parent::init();
}

/**
* Render the Site Identity form, pre-filled from the live config. The two media references
* (logo, favicon) are passed to the view separately — the picker field renders their inputs.
*
* @return void
*/
public function indexAction()
{
$tiger = Zend_Registry::get('Zend_Config')->get('tiger');
$site = $tiger ? $tiger->get('site') : null;
$social = ($tiger && $tiger->get('seo')) ? $tiger->get('seo')->get('social') : null;

$g = static function ($node, $key, $default = '') {
return ($node && (string) $node->get($key) !== '') ? (string) $node->get($key) : $default;
};

$form = new Identity_Form_Identity();
$form->populate([
'site_name' => $g($site, 'name', 'Tiger'),
'tagline' => $g($site, 'tagline'),
'social_twitter' => $g($social, 'twitter'),
'social_facebook' => $g($social, 'facebook'),
'social_instagram' => $g($social, 'instagram'),
'social_linkedin' => $g($social, 'linkedin'),
'social_youtube' => $g($social, 'youtube'),
'social_github' => $g($social, 'github'),
]);

$this->view->title = 'Site Identity — Tiger Admin';
$this->view->form = $form;
$this->view->logoId = $g($site, 'logo');
$this->view->faviconId = $g($site, 'favicon');
}
}
55 changes: 55 additions & 0 deletions modules/identity/forms/Identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity_Form_Identity — the Site Identity form: the site's name + tagline and its social
* profile URLs (which feed Organization.sameAs in the JSON-LD). The logo + favicon are NOT
* declared here — they're media references rendered in the view via the media-picker field
* helper (their hidden inputs post with the form and the service reads them from $params).
* Social URLs are optional and lightly validated (a URL when present); nothing is required
* but the name.
*
* @api
*/
class Identity_Form_Identity extends Tiger_Form
{
/**
* Declare the form's elements.
*
* @return array the element schema
*/
protected function elements(): array
{
// Optional social URLs: ZF1 ships no URI validator, so a lenient http(s) regex — and because
// the field isn't required, Zend_Form only runs it when a value is actually present.
$url = static function () {
return [
'required' => false,
'filters' => ['StringTrim'],
'validators' => [['Regex', false, ['pattern' => '#^https?://.+#i']]],
'attribs' => ['class' => 'form-control', 'placeholder' => 'https://…'],
];
};

return [
['text', 'site_name', [
'required' => true,
'filters' => ['StringTrim'],
'validators' => [['StringLength', false, [1, 191]]],
'attribs' => ['class' => 'form-control', 'placeholder' => $this->_t('identity.field.site_name')],
]],
['text', 'tagline', [
'required' => false,
'filters' => ['StringTrim'],
'validators' => [['StringLength', false, [0, 191]]],
'attribs' => ['class' => 'form-control', 'placeholder' => $this->_t('identity.field.tagline')],
]],
['text', 'social_twitter', $url()],
['text', 'social_facebook', $url()],
['text', 'social_instagram', $url()],
['text', 'social_linkedin', $url()],
['text', 'social_youtube', $url()],
['text', 'social_github', $url()],
];
}
}
12 changes: 12 additions & 0 deletions modules/identity/languages/en/identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity module — English strings. Semantic, owner-prefixed keys (identity.*). Loaded on top of
* core strings by the translate cascade; API response messages resolve these in the caller's locale.
*/
return [
'identity.saved' => 'Site identity saved.',
'identity.field.site_name' => 'e.g. Acme, Inc.',
'identity.field.tagline' => 'A short line under the name',
];
89 changes: 89 additions & 0 deletions modules/identity/plugins/Favicon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity_Plugin_Favicon — contributes the site favicon to the head registry. The favicon is core
* site identity (config `tiger.site.favicon`, a media id), not SEO, but like the SEO head tags it
* rides TigerZF's headLink registry so the layout renders it with no theme edit. A single high-res
* square source is emitted as both `rel="icon"` (browsers downscale it for every tab size) and
* `rel="apple-touch-icon"` (iOS) — the modern, derivative-free approach. Fail-open: a missing or
* unresolvable favicon simply emits nothing (the browser falls back to /favicon.ico if present).
*/
class Identity_Plugin_Favicon extends Zend_Controller_Plugin_Abstract
{
/** Emit-once latch — the favicon is the same on every dispatch (incl. forwards). */
private static $_done = false;

/**
* @param Zend_Controller_Request_Abstract $request
* @return void
*/
public function preDispatch(Zend_Controller_Request_Abstract $request)
{
if (self::$_done) {
return;
}
self::$_done = true;

try {
$id = self::_config('site.favicon');
if ($id === '') {
return;
}
$url = self::_mediaUrl($id, $request);
if ($url === '') {
return;
}
$view = self::_view();
$view->headLink(['rel' => 'icon', 'href' => $url]);
$view->headLink(['rel' => 'apple-touch-icon', 'href' => $url]);
} catch (Throwable $e) {
// fail-open — the favicon must never break a request
}
}

/** Resolve a media id to an absolute (or root-relative) URL, or '' when unresolvable. */
private static function _mediaUrl($id, $request)
{
if (!class_exists('Tiger_Model_Media')) {
return '';
}
$model = new Tiger_Model_Media();
$row = $model->findById($id);
if (!$row) {
return '';
}
$url = (string) $model->url($row->toArray());
if ($url !== '' && !preg_match('#^https?://#i', $url) && strpos($url, '/') !== 0) {
$url = '/' . ltrim($url, '/'); // keep it root-relative if the adapter returned a bare path
}
return $url;
}

/** A Zend_View to reach the head helpers (shares the process-wide placeholder registry). */
private static function _view()
{
if (Zend_Registry::isRegistered('Zend_View')) {
$v = Zend_Registry::get('Zend_View');
if ($v instanceof Zend_View_Interface) {
return $v;
}
}
return new Zend_View();
}

/** Read a `tiger.<dotKey>` config value with a default. */
private static function _config($dotKey, $default = '')
{
if (!Zend_Registry::isRegistered('Zend_Config')) {
return $default;
}
$node = Zend_Registry::get('Zend_Config')->get('tiger');
foreach (explode('.', $dotKey) as $seg) {
if (!($node instanceof Zend_Config)) { return $default; }
$node = $node->get($seg);
if ($node === null) { return $default; }
}
return is_scalar($node) ? (string) $node : $default;
}
}
90 changes: 90 additions & 0 deletions modules/identity/services/Identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Identity_Service_Identity — the /api service behind the Site Identity screen. Validates the
* form, then writes the identity to the config tier (the live-override store, no deploy):
* the site name/tagline, the logo + favicon media references, and the social profile URLs
* that feed Organization.sameAs in the JSON-LD.
*
* Scope: writes at GLOBAL scope for now — the single site's identity, visible to guests on
* public pages (anonymous requests only receive GLOBAL config, by design; core stays
* multi-site-unaware). The scope lives in one place (`_scope()`) so the future multi-site
* module, which resolves the acting org per domain and loads that org's config for guests,
* can flip identity to per-org scoping without touching the rest of this service.
*
* @api
*/
class Identity_Service_Identity extends Tiger_Service_Service
{
/** The config keys this screen owns — form field name => dot-notation config key. */
const KEYS = [
'site_name' => 'tiger.site.name',
'tagline' => 'tiger.site.tagline',
'social_twitter' => 'tiger.seo.social.twitter',
'social_facebook' => 'tiger.seo.social.facebook',
'social_instagram' => 'tiger.seo.social.instagram',
'social_linkedin' => 'tiger.seo.social.linkedin',
'social_youtube' => 'tiger.seo.social.youtube',
'social_github' => 'tiger.seo.social.github',
];

/**
* Save the site identity: validate, then persist every field to the config store.
*
* @param array $params the posted form values (+ logo_media_id / favicon_media_id)
* @return void
*/
public function save(array $params): void
{
if (!$this->_isAdmin()) {
$this->_error('core.api.error.not_allowed');
return;
}

$form = new Identity_Form_Identity();
if (!$form->isValid($params)) {
$this->_formErrors($form);
return;
}
$v = $form->getValues();

// Media references ride outside the form (the picker owns their hidden inputs); accept a
// media_id (36-char UUID) or empty to clear. Anything else is ignored — never trusted raw.
$logo = self::_mediaId($params['logo_media_id'] ?? '');
$favicon = self::_mediaId($params['favicon_media_id'] ?? '');

try {
$this->_transaction(function () use ($v, $logo, $favicon) {
$cfg = new Tiger_Model_Config();
[$scope, $scopeId] = $this->_scope();
foreach (self::KEYS as $field => $key) {
$cfg->set($scope, $scopeId, $key, trim((string) ($v[$field] ?? '')));
}
$cfg->set($scope, $scopeId, 'tiger.site.logo', $logo);
$cfg->set($scope, $scopeId, 'tiger.site.favicon', $favicon);
});
$this->_success([], 'identity.saved');
} catch (Throwable $e) {
$this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general');
}
}

/**
* The config scope for identity writes. GLOBAL today (the single site, guest-visible). The
* multi-site module overrides this to (SCOPE_ORG, <domain's org>).
*
* @return array [scope, scopeId]
*/
protected function _scope()
{
return [Tiger_Model_Config::SCOPE_GLOBAL, ''];
}

/** A value that looks like a media UUID, else '' (clears the reference). */
private static function _mediaId($v)
{
$v = trim((string) $v);
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) ? $v : '';
}
}
Loading
Loading