diff --git a/CHANGELOG.md b/CHANGELOG.md index e998b14..73fa512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/modules/identity/Bootstrap.php b/modules/identity/Bootstrap.php new file mode 100644 index 0000000..5e5a395 --- /dev/null +++ b/modules/identity/Bootstrap.php @@ -0,0 +1,37 @@ +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, + ]); + } +} diff --git a/modules/identity/configs/acl.ini b/modules/identity/configs/acl.ini new file mode 100644 index 0000000..c8d699b --- /dev/null +++ b/modules/identity/configs/acl.ini @@ -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] diff --git a/modules/identity/controllers/AdminController.php b/modules/identity/controllers/AdminController.php new file mode 100644 index 0000000..a7d2f49 --- /dev/null +++ b/modules/identity/controllers/AdminController.php @@ -0,0 +1,56 @@ +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'); + } +} diff --git a/modules/identity/forms/Identity.php b/modules/identity/forms/Identity.php new file mode 100644 index 0000000..1f33cab --- /dev/null +++ b/modules/identity/forms/Identity.php @@ -0,0 +1,55 @@ + 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()], + ]; + } +} diff --git a/modules/identity/languages/en/identity.php b/modules/identity/languages/en/identity.php new file mode 100644 index 0000000..5c978f7 --- /dev/null +++ b/modules/identity/languages/en/identity.php @@ -0,0 +1,12 @@ + 'Site identity saved.', + 'identity.field.site_name' => 'e.g. Acme, Inc.', + 'identity.field.tagline' => 'A short line under the name', +]; diff --git a/modules/identity/plugins/Favicon.php b/modules/identity/plugins/Favicon.php new file mode 100644 index 0000000..fb34747 --- /dev/null +++ b/modules/identity/plugins/Favicon.php @@ -0,0 +1,89 @@ +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.` 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; + } +} diff --git a/modules/identity/services/Identity.php b/modules/identity/services/Identity.php new file mode 100644 index 0000000..ccb7243 --- /dev/null +++ b/modules/identity/services/Identity.php @@ -0,0 +1,90 @@ + 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, ). + * + * @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 : ''; + } +} diff --git a/modules/identity/views/scripts/admin/index.phtml b/modules/identity/views/scripts/admin/index.phtml new file mode 100644 index 0000000..1bf3b40 --- /dev/null +++ b/modules/identity/views/scripts/admin/index.phtml @@ -0,0 +1,124 @@ +form; +$el = function ($name) use ($form) { return $form->getElement($name); }; +$va = $this->themeAssets; +?> +
+
+

Site Identity

+

Your site's name, logo, favicon, and social profiles — the brand that appears in browser tabs, search results, and social shares.

+
+
+ +
+
+ +
+ +
+ +
+ +
+
+
Identity
+
+
+ + +
Shown in the site header and browser tab, and used as the default page title and brand name in search results.
+
+
+ + +
A short line describing the site (optional).
+
+
+
+
+ +
+
+
Logo & favicon
+
+
+ mediaField('logo_media_id', $this->logoId, ['kind' => 'image', 'label' => 'Logo']) ?> +
Used for your brand in search results (Organization schema) and available to themes.
+
+
+ mediaField('favicon_media_id', $this->faviconId, ['kind' => 'image', 'label' => 'Favicon']) ?> +
The little icon in the browser tab. Use a square image — 512×512 or larger is ideal; the browser scales it down to every size it needs.
+
+
+
+
+ +
+
+
Social profiles
+
+

Full URLs to your official profiles. These are published as your brand's verified links (schema.org sameAs) — leave any blank.

+
+ ['X / Twitter', 'fa-x-twitter'], + 'social_facebook' => ['Facebook', 'fa-facebook'], + 'social_instagram' => ['Instagram', 'fa-instagram'], + 'social_linkedin' => ['LinkedIn', 'fa-linkedin'], + 'social_youtube' => ['YouTube', 'fa-youtube'], + 'social_github' => ['GitHub', 'fa-github'], + ]; + foreach ($socials as $name => $meta): ?> +
+ + +
+ +
+
+
+
+ +
+
+ + +