Compare commits

...

22 Commits

Author SHA1 Message Date
gitea-actions[bot] dfd0fef3b8 chore(version): pre-release bump to 01.00.08-dev [skip ci] 2026-06-21 21:43:08 +00:00
Jonathan Miller 26328d530e Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph into dev
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Universal: PR Check / Validate PR (pull_request) Successful in 11s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 4s
2026-06-21 16:42:56 -05:00
Jonathan Miller 77da0c5517 fix: remove remaining @ suppression, check Folder::create() returns
- Remove @getimagesize() suppression in ImageHelper, ImageGenerator,
  MokoOG — let PHP report warnings for corrupt/unreadable images
- Add Log::add() when ImageHelper::resize() cannot read image dimensions
- Check Folder::create() return value in ImageGenerator and ImageHelper,
  return graceful fallback if directory creation fails
2026-06-21 16:42:54 -05:00
gitea-actions[bot] 6a928f856f chore(version): pre-release bump to 01.00.07-dev [skip ci] 2026-06-21 21:26:34 +00:00
Jonathan Miller fa75b7d9c4 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph into dev
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 8s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 25s
2026-06-21 16:26:18 -05:00
Jonathan Miller 46e30c950b fix: address PR review findings — error handling and data integrity
- Add missing language field to batch-generated records
- Wrap batch insert in try-catch to handle duplicate key races
- Add logging to all empty catch blocks (script.php, MokoOG license check)
- Guard loadShopProduct() with try-catch for missing MokoSuiteShop tables
- Guard reviews query in JsonLdBuilder for missing #__mokoshop_reviews
2026-06-21 16:26:13 -05:00
gitea-actions[bot] 77148d2401 chore(version): pre-release bump to 01.00.06-dev [skip ci] 2026-06-21 20:40:44 +00:00
Jonathan Miller 38af92b876 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph into dev
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 8s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Universal: PR Check / Validate PR (pull_request) Successful in 32s
2026-06-21 15:40:35 -05:00
Jonathan Miller 28d44d6884 fix: undefined $db in findImage(), pass cached product to buildProduct()
- Add missing Factory::getDbo() in findImage() category fallback — would
  cause fatal error on article pages with no images (found in PR review)
- Pass cached product to JsonLdBuilder::buildProduct() to avoid duplicate
  DB query (same pattern as buildArticle with cachedArticle)
- Fix orphaned PHPDoc block for getImageDimensions()
2026-06-21 15:40:01 -05:00
gitea-actions[bot] 3d2d91ace5 chore(version): pre-release bump to 01.00.05-dev [skip ci] 2026-06-21 20:32:13 +00:00
Jonathan Miller 0cc69b7d77 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph into dev
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
2026-06-21 15:32:06 -05:00
Jonathan Miller 1375c5820e docs: update README and CHANGELOG for v1.0 assessment
- Rename MokoJoomOpenGraph to MokoSuiteOpenGraph throughout
- Add MokoSuiteShop integration, Product JSON-LD to feature lists
- Remove dead adapter references (K2, VirtueMart, HikaShop)
- Document all fixes: DB caching, TagTable validation, CSV language,
  batch limit, GD logging, canonical URL API, language filters
2026-06-21 15:31:18 -05:00
gitea-actions[bot] 0e6137b064 chore(version): pre-release bump to 01.00.04-dev [skip ci] 2026-06-21 20:27:36 +00:00
Jonathan Miller e105474c68 Merge remote-tracking branch 'origin/dev' into dev
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
# Conflicts:
#	.mokogitea/workflows/pre-release.yml
2026-06-21 15:25:56 -05:00
Jonathan Miller 7fd716f3a4 chore: normalize workflow line endings for merge 2026-06-21 15:23:30 -05:00
Jonathan Miller ca06c86328 perf: consolidate article DB queries into single cached lookup (#38)
- Add loadArticle() with static per-request cache for article data
- Refactor getArticleDate(), getArticleAuthor() to use cached article
- Refactor findImage() for com_content to use cached article
- Pass cached article to JsonLdBuilder::buildArticle() to skip its query
- Reduces article page DB queries from 5 to 1 for OG tag generation
2026-06-21 11:09:52 -05:00
Jonathan Miller 7a7041c7f3 fix: remove updateservers from package manifest (#44)
Update server is managed externally, not via static updates.xml.
2026-06-21 11:01:32 -05:00
Jonathan Miller f484675300 fix: batch limit cap, TagTable validation, CSV language column (#42, #43, #52)
- Cap batch process limit to 200 per request to prevent DoS (#42)
- Add TagTable::check() validation: og_type enum, field max lengths,
  canonical_url format, robots directives, content_type pattern (#43)
- Add language column to CSV export headers and data (#52)
- Parse language column on CSV import with format validation
- Include language in duplicate check query to match unique key
2026-06-21 10:57:38 -05:00
Jonathan Miller 8793e6b3f4 feat: add MokoSuiteShop product OG tag support (#53)
- Detect com_mokoshop product views and set og:type to 'product'
- Auto-generate OG tags from CRM product data (name, description, image)
- Add product:price:amount and product:price:currency meta tags
- Add JSON-LD Product schema with offers, SKU, and aggregate ratings
- Load product images from linked #__content article images
- Cache product DB lookups to avoid duplicate queries per request
2026-06-21 10:20:38 -05:00
Jonathan Miller 0afc8b135a fix: replace GD error suppression with logging, remove dead adapters (#49, #36)
- Replace @ error suppression in ImageGenerator with Log::add() warnings
  for missing GD, missing font, corrupt images (#49)
- Add GD extension pre-check before attempting image generation
- Add WebP function_exists() guard for servers without WebP support
- Remove @ suppression from ImageHelper::loadImage() with logging
- Remove unused ContentType adapters (HikaShop, K2, VirtueMart) and
  ContentTypeInterface — not targeting these platforms (#36)
2026-06-21 10:12:00 -05:00
Jonathan Miller 433ecfea71 fix: resolve 3 v1.0 release blockers (#47, #48, #39)
- Add TagsController extending AdminController for admin list
  delete/publish/unpublish operations (#48)
- Add language filter to loadOgDataByType() and loadOgDataByMenu()
  matching the pattern already used in loadOgData() (#47)
- Replace direct $doc->_links access with getHeadData()/setHeadData()
  public API for Joomla forward compatibility (#39)
- Update ISSUES.md with full 2026-06-21 assessment
2026-06-21 10:02:03 -05:00
Jonathan Miller a67cd6da76 fix: gitignore site/ should be /site/ to avoid matching tmpl/site/ 2026-06-06 22:20:08 -05:00
23 changed files with 787 additions and 422 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ releases/
build/
dist/
out/
site/
/site/
*.map
*.css.map
*.js.map
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoJoomOpenGraph</display-name>
<org>MokoConsulting</org>
<description>Open Graph, SEO meta tags, and social sharing image management for Joomla articles and menu items</description>
<version>01.00.03</version>
<version>01.00.08</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+17 -5
View File
@@ -1,8 +1,8 @@
# Changelog
<!-- VERSION: 01.00.03 -->
<!-- VERSION: 01.00.08 -->
All notable changes to MokoJoomOpenGraph will be documented in this file.
All notable changes to MokoSuiteOpenGraph will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
@@ -25,9 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- CSV import/export for bulk OG tag management (#12)
- OG image text overlay generator (#7)
- Multilingual OG tag support with per-language records (#11)
- JSON-LD structured data: Article, WebPage, BreadcrumbList schemas (#6)
- JSON-LD structured data: Article, Product, WebPage, BreadcrumbList schemas (#6)
- Social platform debugger quick links (Facebook, LinkedIn, Google) (#9)
- Content type adapter architecture for K2, VirtueMart, HikaShop (#5)
- MokoSuiteShop product OG tag support with pricing meta and JSON-LD Product schema (#53)
- WhatsApp and Telegram link preview optimization (#10)
- Category-level OG tag support (#4)
- Batch OG tag generation for existing articles (#1)
@@ -40,5 +40,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Facebook App ID and Telegram channel support
- Database table `#__mokoog_tags` with multilingual unique key
### Changed
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
- Replace GD `@` error suppression with `Log::add()` warnings (#49)
- TagTable::check() validates og_type, field lengths, canonical_url, robots directives (#43)
- CSV import/export now includes language column for multilingual support (#52)
- Batch process limit capped at 200 per request (#42)
- Canonical URL replacement uses public `getHeadData()`/`setHeadData()` API (#39)
- Language-aware queries on `loadOgDataByType()` and `loadOgDataByMenu()` (#47)
### Removed
- Removed deploy-manual.yml workflow — using Joomla update server for distribution
- Removed dead ContentType adapters (K2, VirtueMart, HikaShop) — not targeting these platforms (#36)
- Removed `<updateservers>` from package manifest — managed externally (#44)
- Removed deploy-manual.yml workflow
+318
View File
@@ -0,0 +1,318 @@
# MokoSuiteOpenGraph — Code Assessment Issues
Generated: 2026-06-06
Updated: 2026-06-21
Reviewed: Full codebase (all PHP, SQL, XML, JS, CSS, templates)
---
## Status Legend
- FIXED — Verified resolved in codebase
- OPEN — Still present, needs work
- WONTFIX — Intentional or acceptable as-is
---
## Bugs
### BUG-01: Batch generation offset pagination skips articles — FIXED
**Severity:** High
**File:** `source/packages/com_mokoog/src/Controller/BatchController.php:89`
The `process()` method now correctly uses `$db->setQuery($query, 0, $limit)` with a comment explaining that processed articles are automatically excluded by the LEFT JOIN filter.
---
### BUG-02: License key session flag set before check completes — FIXED
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:543`
Session flag is now set after the DB query succeeds, inside the try block but after query setup. If the query throws, the catch block runs without the flag being set.
---
### BUG-03: Hardcoded og:image dimensions are often wrong — FIXED
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:129-134`
Now uses `$this->getImageDimensions($image)` which calls `getimagesize()` to detect actual dimensions. Dimension meta tags only emitted when dimensions are successfully detected.
---
### BUG-04: `strlen()` vs `mb_strlen()` inconsistency in truncation — FIXED
**Severity:** Low
**Files:** MokoOG.php, BatchController.php, HikaShopAdapter.php, K2Adapter.php
All instances now consistently use `mb_strlen()` for length checks with `mb_substr()` for truncation.
---
### BUG-05: `ImageGenerator::wrapText()` can produce broken output — FIXED
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php:156`
Now checks `mb_strlen($lines[2]) > 3` before truncating. Short lines get `'...'` appended instead.
---
## Potential Issues
### ISSUE-01: ContentType adapters exist but are never wired up — OPEN
**Severity:** High (wasted code)
**Files:**
- `source/packages/com_mokoog/src/ContentType/ContentTypeInterface.php`
- `source/packages/com_mokoog/src/ContentType/HikaShopAdapter.php`
- `source/packages/com_mokoog/src/ContentType/K2Adapter.php`
- `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php`
The system plugin (`MokoOG.php`) still never references or loads these adapters. The `findImage()` and `loadOgData()` methods only handle `com_content`. Third-party content types get no auto-generated OG tags.
**Action:** Wire adapters into the system plugin's `onBeforeCompileHead` flow, or remove them if not planned for v1.
---
### ISSUE-02: `applySeoTags()` accesses internal `$doc->_links` property — OPEN
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:257-259`
Still directly accessing `$doc->_links` (protected/internal property). Fragile across Joomla versions.
**Fix:** Use `$doc->getHeadData()` to read links and `$doc->addHeadLink()` with proper clearing logic.
---
### ISSUE-03: No input sanitization on OG values before output — OPEN
**Severity:** Medium
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php`
No `htmlspecialchars()` or `InputFilter` found in the content plugin's save path. While Joomla's `setMetaData()` escapes on output, defense-in-depth recommends sanitizing on input.
**Fix:** Apply `htmlspecialchars()` or Joomla's `InputFilter` when saving OG data.
---
### ISSUE-04: `loadOgDataByType()` and `loadOgDataByMenu()` ignore language — OPEN
**Severity:** Medium
**Files:**
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:324-337` (`loadOgDataByType`)
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:346-359` (`loadOgDataByMenu`)
These methods still have no language filter. On multilingual sites, category fallback or menu OG data could come from any language. The unique key is now `(content_type, content_id, language)` but these queries don't filter by language, so `loadObject()` returns an arbitrary match.
**Fix:** Add the same language filter pattern used in `loadOgData()`.
---
### ISSUE-05: VirtueMart adapter interpolates language into table name — OPEN (low risk)
**Severity:** Low (defense-in-depth)
**File:** `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php:34,47`
Language tag is interpolated into the table name. While `quoteName()` wraps the result, the language tag itself is not validated against an allowlist.
**Fix:** Validate tag format with a regex before interpolation.
---
### ISSUE-06: No admin list controller for publish/delete operations — OPEN
**Severity:** Medium
**File:** `source/packages/com_mokoog/src/Controller/`
No `TagsController extends AdminController` exists. The admin list view toolbar buttons for delete/publish/unpublish will produce task routing errors.
**Fix:** Add a `TagsController extends AdminController` with proper CSRF and ACL checks.
---
### ISSUE-07: CSV import/export does not handle `language` column — OPEN
**Severity:** Low
**File:** `source/packages/com_mokoog/src/Controller/ImportExportController.php`
No reference to `language` found in the controller. Export omits the column, import creates records with default `*` language. Multilingual sites cannot bulk import/export language-specific OG data.
**Fix:** Add `language` as a column in export, and parse it on import with a fallback to `*`.
---
### ISSUE-08: No ACL check in content plugin form injection — WONTFIX
**Severity:** Low
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php:49`
Any user who can edit an article can modify OG tags. This is acceptable behavior for most sites — if you can edit the article, you should be able to control its social sharing appearance.
---
## New Issues (Found 2026-06-21)
### ISSUE-09: ImageGenerator uses @ error suppression on GD functions
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
All GD library calls use the `@` suppression operator, making debugging difficult. If the GD extension is missing or a font file is not found, failures are completely silent.
**Fix:** Replace `@` suppression with proper error checking and logging via `Log::add()`.
---
### ISSUE-10: No TTF font file bundled or documented
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
The image generator requires a TTF font file for text overlay, but no font is included in the package and no fallback or documentation exists for configuring the font path.
**Fix:** Bundle a permissively-licensed font (e.g., Open Sans, Noto Sans) or document the required configuration.
---
### ISSUE-11: ImageGenerator cache grows unbounded
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
Generated images in `images/mokoog/generated/` are never cleaned up. On sites with many articles, this directory grows indefinitely.
**Fix:** Add a cleanup CLI command or admin button (see FEAT-07), or implement LRU/TTL-based cache eviction.
---
### ISSUE-12: JSON-LD missing common schema types
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php`
Only 4 schema types are implemented (Article, WebPage, BreadcrumbList, Organization). Missing: NewsArticle, BlogPosting, Product, VideoObject, Event — some of which correspond to existing `og_type` dropdown values.
**Fix:** Add at least NewsArticle and BlogPosting as Article subtypes.
---
### ISSUE-13: No API input validation beyond field whitelisting
**Severity:** Low
**Files:**
- `source/packages/com_mokoog/api/src/Controller/TagsController.php`
- `source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php`
The REST API exposes full CRUD but has no validation for field content (e.g., max lengths, valid URLs for og_image/canonical_url, valid og_type values).
**Fix:** Add validation rules matching the form XML constraints.
---
## Feature Expansion Opportunities
### FEAT-01: Wire up ContentType adapter system — NOT IMPLEMENTED
Connect the existing `ContentTypeInterface` adapters to the system plugin so HikaShop products, K2 items, and VirtueMart products automatically get OG tags. Blocked by ISSUE-01.
---
### FEAT-02: Admin edit view for individual OG tag records — NOT IMPLEMENTED
A `TagModel` and `tag.xml` form exist but there's no edit template (`tmpl/tag/`) or `TagController`. Users can only manage OG tags through article/menu editors.
---
### FEAT-03: Publish/unpublish toggle in admin list — NOT IMPLEMENTED
Blocked by ISSUE-06 (no TagsController). The list view shows published status as text but has no clickable toggle.
---
### FEAT-04: Actual image dimension detection for og:image meta — FIXED
Implemented via `getImageDimensions()` method using `getimagesize()`. See BUG-03.
---
### FEAT-05: Duplicate OG tag detection — NOT IMPLEMENTED
No detection for conflicting OG meta tags from other extensions.
---
### FEAT-06: Support og:video and og:audio URLs — NOT IMPLEMENTED
No `og_video` or `og_audio` columns, form fields, or rendering logic found anywhere in the codebase.
---
### FEAT-07: Generated image cache cleanup — NOT IMPLEMENTED
No CLI command or admin purge button. See ISSUE-11.
---
### FEAT-08: Sitemap integration — NOT IMPLEMENTED
No sitemap generation or integration exists.
---
### FEAT-09: Social share preview in admin list — NOT IMPLEMENTED
No thumbnails or inline validation in the admin list view. Live preview only exists in the article/menu editor (via plg_content_mokoog).
---
### FEAT-10: Bulk OG tag editing — NOT IMPLEMENTED
No batch edit modal for selecting multiple items and changing common fields.
---
## Security Fixes (from CHANGELOG [Unreleased])
All 4 claimed security fixes have been **verified as implemented**:
| Fix | Status | Evidence |
|-----|--------|----------|
| JSON-LD XSS (#34) | IMPLEMENTED | `</` escaping in `JsonLdBuilder::toScriptTag()` |
| ACL on Batch/ImportExport (#37) | IMPLEMENTED | `authorise()` checks on all controller methods |
| CSV import validation (#35) | IMPLEMENTED | File type, MIME, size (2MB), content_type regex |
| Multilingual data corruption (#41) | IMPLEMENTED | Language-aware load/save in content plugin |
Additional security review found **no vulnerabilities** for: SQL injection, CSRF, file upload, path traversal, code injection, or XSS in output.
---
## Summary
| Category | Total | Fixed | Open | Won't Fix |
|----------|-------|-------|------|-----------|
| Bugs | 5 | 5 | 0 | 0 |
| Issues | 13 | 0 | 12 | 1 |
| Features | 10 | 1 | 9 | 0 |
| Security | 4 | 4 | 0 | 0 |
### Priority for v1.0.0 Release
**Must fix:**
- ISSUE-06: TagsController for admin list operations (publish/delete broken)
- ISSUE-04: Language filter on loadOgDataByType/loadOgDataByMenu (data integrity on multilingual sites)
**Should fix:**
- ISSUE-02: Replace `$doc->_links` access (Joomla version fragility)
- ISSUE-03: Input sanitization on save (defense-in-depth)
- ISSUE-09: GD error suppression (debuggability)
- ISSUE-10: Bundle or document TTF font requirement
**Nice to have for v1.0.0:**
- FEAT-02: Admin edit view
- FEAT-03: Publish/unpublish toggle
- ISSUE-07: Language column in CSV import/export
+7 -7
View File
@@ -1,12 +1,12 @@
# MokoJoomOpenGraph
# MokoSuiteOpenGraph
<!-- VERSION: 01.00.03 -->
<!-- VERSION: 01.00.08 -->
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
## Overview
MokoJoomOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
MokoSuiteOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
## Features
@@ -31,7 +31,7 @@ MokoJoomOpenGraph gives you full control over how your Joomla content appears wh
- **Meta description** — Per-page meta description control
- **Robots directive** — Per-page noindex/nofollow settings
- **Canonical URL** — Custom canonical URL overrides
- **JSON-LD structured data** — Article, WebPage, BreadcrumbList, Organization schemas
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas
### Admin Tools
- **Tag manager dashboard** — View and manage all OG records centrally
@@ -43,19 +43,19 @@ MokoJoomOpenGraph gives you full control over how your Joomla content appears wh
### Developer Features
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
- **Content type adapters** — Extensible architecture for K2, VirtueMart, HikaShop
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
## Installation
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/releases)
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases)
2. In Joomla Administrator → Extensions → Install → Upload Package File
3. All plugins are enabled automatically on install
## Configuration
Navigate to **Extensions → Plugins → System - MokoJoomOpenGraph** to configure:
Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to configure:
- Site name override
- Default OG title and description (site-wide fallback)
- Default fallback image
+1 -1
View File
@@ -8,7 +8,7 @@
-->
<extension type="component" method="upgrade">
<name>com_mokoog</name>
<version>01.00.03-dev</version>
<version>01.00.08-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,60 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\ContentType;
defined('_JEXEC') or die;
interface ContentTypeInterface
{
/**
* Check if this adapter can handle the given component/view.
*
* @param string $option Component option (e.g. com_virtuemart)
* @param string $view View name (e.g. productdetails)
*
* @return bool
*/
public function canHandle(string $option, string $view): bool;
/**
* Get the content type identifier for database storage.
*
* @return string
*/
public function getContentType(): string;
/**
* Get the title for the content item.
*
* @param int $id Content item ID
*
* @return string
*/
public function getTitle(int $id): string;
/**
* Get a description for the content item.
*
* @param int $id Content item ID
*
* @return string
*/
public function getDescription(int $id): string;
/**
* Get the primary image for the content item.
*
* @param int $id Content item ID
*
* @return string Image path relative to JPATH_ROOT, or empty string
*/
public function getImage(int $id): string;
}
@@ -1,76 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class HikaShopAdapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_hikashop' && $view === 'product';
}
public function getContentType(): string
{
return 'com_hikashop';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_name'))
->from($db->quoteName('#__hikashop_product'))
->where($db->quoteName('product_id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_description'))
->from($db->quoteName('#__hikashop_product'))
->where($db->quoteName('product_id') . ' = ' . $id);
$db->setQuery($query);
$text = $db->loadResult() ?: '';
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
if (mb_strlen($text) > 160) {
$text = mb_substr($text, 0, 157) . '...';
}
return $text;
}
public function getImage(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('f.file_path'))
->from($db->quoteName('#__hikashop_file', 'f'))
->where($db->quoteName('f.file_ref_id') . ' = ' . $id)
->where($db->quoteName('f.file_type') . ' = ' . $db->quote('product'))
->order($db->quoteName('f.file_ordering') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadResult() ?: '';
}
}
@@ -1,73 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class K2Adapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_k2' && $view === 'item';
}
public function getContentType(): string
{
return 'com_k2';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('title'))
->from($db->quoteName('#__k2_items'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('introtext'))
->from($db->quoteName('#__k2_items'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$text = $db->loadResult() ?: '';
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
if (mb_strlen($text) > 160) {
$text = mb_substr($text, 0, 157) . '...';
}
return $text;
}
public function getImage(int $id): string
{
// K2 stores images as media/k2/items/cache/{md5}_L.jpg
$imagePath = 'media/k2/items/cache/' . md5('Image' . $id) . '_L.jpg';
if (is_file(JPATH_ROOT . '/' . $imagePath)) {
return $imagePath;
}
return '';
}
}
@@ -1,82 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class VirtueMartAdapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_virtuemart' && $view === 'productdetails';
}
public function getContentType(): string
{
return 'com_virtuemart';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_name'))
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_s_desc'))
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
$db->setQuery($query);
$desc = $db->loadResult() ?: '';
return strip_tags($desc);
}
public function getImage(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('m.file_url'))
->from($db->quoteName('#__virtuemart_product_medias', 'pm'))
->join('INNER', $db->quoteName('#__virtuemart_medias', 'm') . ' ON ' . $db->quoteName('m.virtuemart_media_id') . ' = ' . $db->quoteName('pm.virtuemart_media_id'))
->where($db->quoteName('pm.virtuemart_product_id') . ' = ' . $id)
->order($db->quoteName('pm.ordering') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadResult() ?: '';
}
/**
* Get the VirtueMart language table suffix.
*
* @return string
*/
private function getLangTag(): string
{
$lang = Factory::getLanguage()->getTag();
return strtolower(str_replace('-', '_', $lang));
}
}
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -67,7 +67,7 @@ class BatchController extends BaseController
}
$app = Factory::getApplication();
$limit = $app->getInput()->getInt('limit', 50);
$limit = min($app->getInput()->getInt('limit', 50), 200);
$db = Factory::getDbo();
$query = $db->getQuery(true)
@@ -90,6 +90,7 @@ class BatchController extends BaseController
$articles = $db->loadObjectList();
$created = 0;
$skipped = 0;
$now = Factory::getDate()->toSql();
foreach ($articles as $article) {
@@ -98,23 +99,28 @@ class BatchController extends BaseController
$ogImage = $this->extractImage($article);
$record = (object) [
'content_type' => 'com_content',
'content_id' => (int) $article->id,
'og_title' => $ogTitle,
'og_description' => $ogDescription,
'og_image' => $ogImage,
'og_type' => 'article',
'seo_title' => '',
'content_type' => 'com_content',
'content_id' => (int) $article->id,
'og_title' => $ogTitle,
'og_description' => $ogDescription,
'og_image' => $ogImage,
'og_type' => 'article',
'seo_title' => '',
'meta_description' => $article->metadesc ?: '',
'robots' => '',
'canonical_url' => '',
'published' => 1,
'created' => $now,
'modified' => $now,
'robots' => '',
'canonical_url' => '',
'language' => '*',
'published' => 1,
'created' => $now,
'modified' => $now,
];
$db->insertObject('#__mokoog_tags', $record);
$created++;
try {
$db->insertObject('#__mokoog_tags', $record);
$created++;
} catch (\RuntimeException $e) {
$skipped++;
}
}
echo new JsonResponse([
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -59,6 +59,7 @@ class ImportExportController extends BaseController
$db->quoteName('t.meta_description'),
$db->quoteName('t.robots'),
$db->quoteName('t.canonical_url'),
$db->quoteName('t.language'),
])
->from($db->quoteName('#__mokoog_tags', 't'))
->leftJoin(
@@ -83,6 +84,7 @@ class ImportExportController extends BaseController
'content_type', 'content_id', 'article_title',
'og_title', 'og_description', 'og_image', 'og_type',
'seo_title', 'meta_description', 'robots', 'canonical_url',
'language',
]);
foreach ($rows as $row) {
@@ -184,6 +186,12 @@ class ImportExportController extends BaseController
$metaDesc = trim($row[8] ?? '');
$robots = trim($row[9] ?? '');
$canonicalUrl = trim($row[10] ?? '');
$language = trim($row[11] ?? '*');
// Validate language tag format (e.g., 'en-GB', '*')
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
$language = '*';
}
if (empty($contentType) || $contentId <= 0) {
$skipped++;
@@ -198,12 +206,13 @@ class ImportExportController extends BaseController
continue;
}
// Check for existing record
// Check for existing record (unique key includes language)
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
->where($db->quoteName('content_id') . ' = ' . $contentId)
->where($db->quoteName('language') . ' = ' . $db->quote($language));
$db->setQuery($query);
$existingId = $db->loadResult();
@@ -219,6 +228,7 @@ class ImportExportController extends BaseController
'meta_description' => $metaDesc,
'robots' => $robots,
'canonical_url' => $canonicalUrl,
'language' => $language,
'published' => 1,
'modified' => $now,
];
@@ -0,0 +1,33 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class TagsController extends AdminController
{
/**
* Proxy for getModel.
*
* @param string $name Model name
* @param string $prefix Model prefix
* @param array $config Configuration array
*
* @return BaseDatabaseModel
*/
public function getModel($name = 'Tag', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -32,6 +32,16 @@ class TagTable extends Table
*
* @return bool
*/
private const VALID_OG_TYPES = [
'article', 'website', 'product', 'profile', 'book', 'music.song',
'music.album', 'video.movie', 'video.episode', 'video.other',
];
private const VALID_ROBOTS = [
'index', 'noindex', 'follow', 'nofollow', 'none', 'noarchive',
'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview',
];
public function check(): bool
{
if (empty($this->content_type)) {
@@ -40,12 +50,58 @@ class TagTable extends Table
return false;
}
if (!preg_match('/^[a-z][a-z0-9_.]*$/', $this->content_type)) {
$this->setError('Content type contains invalid characters.');
return false;
}
if (empty($this->content_id)) {
$this->setError('Content ID is required.');
return false;
}
// Validate og_type against known values
if (!empty($this->og_type) && !\in_array($this->og_type, self::VALID_OG_TYPES, true)) {
$this->og_type = 'article';
}
// Truncate fields to schema max lengths
if (mb_strlen($this->og_title ?? '') > 255) {
$this->og_title = mb_substr($this->og_title, 0, 255);
}
if (mb_strlen($this->seo_title ?? '') > 70) {
$this->seo_title = mb_substr($this->seo_title, 0, 70);
}
if (mb_strlen($this->meta_description ?? '') > 200) {
$this->meta_description = mb_substr($this->meta_description, 0, 200);
}
// Validate canonical_url format if non-empty
if (!empty($this->canonical_url) && !filter_var($this->canonical_url, FILTER_VALIDATE_URL)) {
$this->canonical_url = '';
}
// Validate robots directives
if (!empty($this->robots)) {
$parts = array_map('trim', explode(',', strtolower($this->robots)));
$valid = array_filter($parts, function ($part) {
// Allow directives with values like "max-snippet:-1"
$directive = explode(':', $part)[0];
return \in_array($directive, self::VALID_ROBOTS, true);
});
$this->robots = $valid ? implode(', ', $valid) : '';
}
// Default language to '*' if not set
if (empty($this->language)) {
$this->language = '*';
}
return true;
}
}
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoJoomOpenGraph</name>
<version>01.00.03-dev</version>
<version>01.00.08-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoJoomOpenGraph</name>
<version>01.00.03-dev</version>
<version>01.00.08-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -112,7 +112,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$image = $ogData->og_image ?: $this->findImage($option, $view, $id);
$url = Uri::getInstance()->toString();
$siteName = $this->params->get('og_site_name', $app->get('sitename', ''));
$type = $ogData->og_type ?: 'article';
$defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article';
$type = $ogData->og_type ?: $defaultType;
// Open Graph tags
$doc->setMetaData('og:title', $title, 'property');
@@ -188,6 +189,16 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
// MokoSuiteShop product meta tags
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData) {
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
}
}
// Fire event so third-party plugins can add custom OG/social tags
$eventData = [
'subject' => $doc,
@@ -206,8 +217,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if ($this->params->get('jsonld_enabled', 1)) {
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl);
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl, $this->loadShopProduct($id));
} elseif ($option === 'com_content' && $view === 'article' && $id > 0) {
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl, $this->loadArticle($id));
} else {
$schema = JsonLdBuilder::buildWebPage($title, $description);
}
@@ -253,11 +266,17 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
// Canonical URL
if (!empty($ogData->canonical_url)) {
// Remove any existing canonical link first
foreach ($doc->_links as $link => $attribs) {
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
unset($doc->_links[$link]);
// Remove any existing canonical link via public API
$headData = $doc->getHeadData();
if (!empty($headData['links'])) {
foreach ($headData['links'] as $link => $attribs) {
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
unset($headData['links'][$link]);
}
}
$doc->setHeadData($headData);
}
$doc->addHeadLink($ogData->canonical_url, 'canonical');
@@ -323,15 +342,20 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
private function loadOgDataByType(string $contentType, int $contentId): ?object
{
$db = Factory::getDbo();
$db = Factory::getDbo();
$lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId)
->where($db->quoteName('published') . ' = 1');
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
$db->setQuery($query);
$db->setQuery($query, 0, 1);
return $db->loadObject();
}
@@ -345,15 +369,20 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
private function loadOgDataByMenu(int $menuId): ?object
{
$db = Factory::getDbo();
$db = Factory::getDbo();
$lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('menu'))
->where($db->quoteName('content_id') . ' = ' . $menuId)
->where($db->quoteName('published') . ' = 1');
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
$db->setQuery($query);
$db->setQuery($query, 0, 1);
return $db->loadObject();
}
@@ -398,19 +427,31 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $this->params->get('default_image', '');
}
// For MokoSuiteShop products, look at the linked article's images
if ($option === 'com_mokoshop' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData && !empty($productData->images)) {
$imagesData = json_decode($productData->images, true);
if (!empty($imagesData['image_fulltext'])) {
return $imagesData['image_fulltext'];
}
if (!empty($imagesData['image_intro'])) {
return $imagesData['image_intro'];
}
}
return $this->params->get('default_image', '');
}
// For Joomla articles, look at the intro/full image fields
if ($option === 'com_content' && $id > 0) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('images'))
->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . (int) $id);
$article = $this->loadArticle($id);
$db->setQuery($query);
$images = $db->loadResult();
if ($images) {
$imagesData = json_decode($images, true);
if ($article && !empty($article->images)) {
$imagesData = json_decode($article->images, true);
if (!empty($imagesData['image_fulltext'])) {
return $imagesData['image_fulltext'];
@@ -423,6 +464,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
// Fallback: check the article's category for an image
if ($view === 'article') {
$db = Factory::getDbo();
$catQuery = $db->getQuery(true)
->select($db->quoteName('cat.params'))
->from($db->quoteName('#__categories', 'cat'))
@@ -466,6 +508,38 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
}
/**
* Load and cache a full article record with author for the current request.
*
* @param int $id Article ID
*
* @return object|null
*/
private function loadArticle(int $id): ?object
{
static $cache = [];
if (isset($cache[$id])) {
return $cache[$id];
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
'a.created', 'a.modified', 'a.publish_up', 'a.metadesc',
]))
->select($db->quoteName('u.name', 'author_name'))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $id);
$db->setQuery($query);
$cache[$id] = $db->loadObject();
return $cache[$id];
}
/**
* Get a date field from an article.
*
@@ -476,16 +550,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
private function getArticleDate(int $id, string $field): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName($field))
->from($db->quoteName('#__content'))
->where($db->quoteName('id') . ' = ' . $id);
$article = $this->loadArticle($id);
$db->setQuery($query);
$date = $db->loadResult();
return $date ?: '';
return $article->$field ?? '';
}
/**
@@ -497,16 +564,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
private function getArticleAuthor(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('u.name'))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $id);
$article = $this->loadArticle($id);
$db->setQuery($query);
return $db->loadResult() ?: '';
return $article->author_name ?? '';
}
/**
@@ -534,7 +594,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$query = $db->getQuery(true)
->select($db->quoteName('extra_query'))
->from($db->quoteName('#__update_sites'))
->where($db->quoteName('name') . ' = ' . $db->quote('MokoJoomOpenGraph Updates'))
->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteOpenGraph Updates'))
->setLimit(1);
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
@@ -555,18 +615,52 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field '
. 'for the MokoJoomOpenGraph update site.',
. 'for the MokoSuiteOpenGraph update site.',
'warning'
);
} catch (\Throwable $e) {
// Don't break admin over a license check
// Don't break admin over a license check, but log for debugging
\Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
/**
* Get the actual pixel dimensions of a local image.
* Load MokoSuiteShop product data by product ID.
*
* Returns [width, height] or null for external URLs or unreadable images.
* @param int $productId CRM product ID
*
* @return object|null Product with name, description, images, price, currency, sku
*/
private function loadShopProduct(int $productId): ?object
{
static $cache = [];
if (isset($cache[$productId])) {
return $cache[$productId];
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
->select('c.title AS name, c.introtext AS description, c.images')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
->where($db->quoteName('p.id') . ' = ' . $productId)
->where($db->quoteName('p.published') . ' = 1');
$db->setQuery($query);
$cache[$productId] = $db->loadObject();
} catch (\RuntimeException $e) {
// MokoSuiteShop tables may not exist
$cache[$productId] = null;
}
return $cache[$productId];
}
/**
* Get the actual pixel dimensions of a local image.
*
* @param string $image Image path (relative or absolute URL)
*
@@ -592,7 +686,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return null;
}
$info = @getimagesize($absPath);
$info = getimagesize($absPath);
if (!$info) {
return null;
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -13,6 +13,7 @@ namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class ImageGenerator
{
@@ -40,20 +41,32 @@ class ImageGenerator
array $fontColor = [255, 255, 255],
int $quality = 90
): string {
if (!\extension_loaded('gd')) {
Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog');
return '';
}
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
if (!is_file($templateAbs)) {
Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
if (!$fontFile || !is_file($fontFile)) {
Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog');
return '';
}
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir)) {
Folder::create($outputDir);
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
return '';
}
$hash = md5($title . $templateImage . $fontSize);
@@ -67,20 +80,24 @@ class ImageGenerator
}
// Load template image
$imageInfo = @getimagesize($templateAbs);
$imageInfo = getimagesize($templateAbs);
if (!$imageInfo) {
Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
$source = match ($imageInfo[2]) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => @imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => @imagecreatefromwebp($templateAbs),
IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false,
default => false,
};
if (!$source) {
Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -14,6 +14,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class ImageHelper
{
@@ -63,9 +64,11 @@ class ImageHelper
return $imagePath;
}
$imageInfo = @getimagesize($absPath);
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
return $imagePath;
}
@@ -79,8 +82,10 @@ class ImageHelper
// Ensure output directory exists
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir)) {
Folder::create($outputDir);
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
return $imagePath;
}
// Generate output filename based on source hash + dimensions
@@ -179,7 +184,7 @@ class ImageHelper
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
}
$imageInfo = @getimagesize($absPath);
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
@@ -211,12 +216,18 @@ class ImageHelper
*/
private static function loadImage(string $path, int $type)
{
return match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
$image = match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
IMAGETYPE_PNG => imagecreatefrompng($path),
IMAGETYPE_GIF => imagecreatefromgif($path),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : false,
default => false,
};
if (!$image) {
Log::add('MokoOG ImageHelper: Failed to load image: ' . basename($path), Log::WARNING, 'mokoog');
}
return $image;
}
}
@@ -1,7 +1,7 @@
<?php
/**
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
@@ -20,31 +20,36 @@ class JsonLdBuilder
/**
* Build Article schema for a com_content article.
*
* @param int $articleId Article ID
* @param string $title Page title
* @param string $description Page description
* @param string $image Image URL (absolute)
* @param int $articleId Article ID
* @param string $title Page title
* @param string $description Page description
* @param string $image Image URL (absolute)
* @param object|null $cachedArticle Pre-loaded article data (avoids duplicate query)
*
* @return array|null
*/
public static function buildArticle(int $articleId, string $title, string $description, string $image): ?array
public static function buildArticle(int $articleId, string $title, string $description, string $image, ?object $cachedArticle = null): ?array
{
if ($articleId <= 0) {
return null;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.created', 'a.modified', 'a.publish_up',
'u.name',
]))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $articleId);
$article = $cachedArticle;
$db->setQuery($query);
$article = $db->loadObject();
if (!$article) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.created', 'a.modified', 'a.publish_up',
]))
->select($db->quoteName('u.name', 'author_name'))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $articleId);
$db->setQuery($query);
$article = $db->loadObject();
}
if (!$article) {
return null;
@@ -60,10 +65,12 @@ class JsonLdBuilder
'dateModified' => $article->modified ?: $article->created,
];
if (!empty($article->name)) {
$authorName = $article->author_name ?? '';
if (!empty($authorName)) {
$schema['author'] = [
'@type' => 'Person',
'name' => $article->name,
'name' => $authorName,
];
}
@@ -152,6 +159,95 @@ class JsonLdBuilder
];
}
/**
* Build Product schema for a MokoSuiteShop product.
*
* @param int $productId CRM product ID
* @param string $title Product title
* @param string $description Product description
* @param string $image Image URL (absolute)
* @param object|null $cachedProduct Pre-loaded product data (avoids duplicate query)
*
* @return array|null
*/
public static function buildProduct(int $productId, string $title, string $description, string $image, ?object $cachedProduct = null): ?array
{
if ($productId <= 0) {
return null;
}
$product = $cachedProduct;
if (!$product) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('p.sku, p.price, p.currency, p.stock_qty')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->where($db->quoteName('p.id') . ' = ' . $productId)
->where($db->quoteName('p.published') . ' = 1');
$db->setQuery($query);
$product = $db->loadObject();
}
if (!$product) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
if (!empty($product->sku)) {
$schema['sku'] = $product->sku;
}
if (!empty($image)) {
$schema['image'] = $image;
}
// Offers (pricing and availability)
$availability = ((float) $product->stock_qty > 0)
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock';
$schema['offers'] = [
'@type' => 'Offer',
'price' => number_format((float) $product->price, 2, '.', ''),
'priceCurrency' => $product->currency ?: 'USD',
'availability' => $availability,
'url' => Uri::getInstance()->toString(),
];
// Aggregate rating from reviews if available
try {
$reviewQuery = $db->getQuery(true)
->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating')
->from($db->quoteName('#__mokoshop_reviews'))
->where($db->quoteName('product_id') . ' = ' . $productId)
->where($db->quoteName('status') . ' = ' . $db->quote('approved'));
$db->setQuery($reviewQuery);
$rating = $db->loadObject();
if ($rating && (int) $rating->review_count > 0) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => round((float) $rating->avg_rating, 1),
'reviewCount' => (int) $rating->review_count,
];
}
} catch (\RuntimeException $e) {
// Reviews table may not exist if MokoSuiteShop reviews module not installed
}
return $schema;
}
/**
* Encode a schema array to a JSON-LD script tag string.
*
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoJoomOpenGraph</name>
<version>01.00.03-dev</version>
<version>01.00.08-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+3 -6
View File
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomOpenGraph
* @package MokoSuiteOpenGraph
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
-->
<extension type="package" method="upgrade">
<name>Package - MokoJoomOpenGraph</name>
<name>Package - MokoSuiteOpenGraph</name>
<packagename>mokoog</packagename>
<version>01.00.03-dev</version>
<version>01.00.08-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -30,9 +30,6 @@
<language tag="en-GB">language/en-GB/pkg_mokoog.sys.ini</language>
</languages>
<updateservers>
<server type="extension" name="MokoJoomOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/updates.xml</server>
</updateservers>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
</extension>
+9 -3
View File
@@ -82,7 +82,9 @@ class Pkg_MokoOGInstallerScript
$key = $db->loadResult();
if (!empty($key)) { $this->savedDownloadKey = $key; }
}
catch (\Throwable $e) {}
catch (\Throwable $e) {
\Joomla\CMS\Log\Log::add('MokoOG saveDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
private function restoreDownloadKey(): void
@@ -112,7 +114,9 @@ class Pkg_MokoOGInstallerScript
)->execute();
}
}
catch (\Throwable $e) {}
catch (\Throwable $e) {
\Joomla\CMS\Log\Log::add('MokoOG restoreDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
private function warnMissingLicenseKey(): void
@@ -147,6 +151,8 @@ class Pkg_MokoOGInstallerScript
'warning'
);
}
catch (\Throwable $e) {}
catch (\Throwable $e) {
\Joomla\CMS\Log\Log::add('MokoOG warnMissingLicenseKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
}