16 Commits

Author SHA1 Message Date
df3b8a7ec2 feat: Add Prometheus metrics integration
All checks were successful
Create Release Package / build-release (push) Successful in 1m0s
- Add includes/Prometheus/Integration.php with metrics collection
- Expose content, engagement, user, WooCommerce, and ActivityPub metrics
- Add settings toggle in Integrations tab
- Requires WP Prometheus plugin to be active
- Bump version to 0.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:46:22 +01:00
d1597aa854 docs: Update CLAUDE.md with memory leak investigation session
- Documented v0.4.1 through v0.4.9 fix attempts
- Listed all protection mechanisms in place
- Recorded key learnings about WordPress hooks and recursion
- Status: unresolved, documented as known incompatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:10:09 +01:00
12f7d8f7b3 docs: Add known plugin incompatibilities section
- Added Known Issues section documenting WP Prometheus incompatibility
- Updated version badge to 0.4.9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:07:50 +01:00
bdc11d8769 revert: Restore conditional the_content filter usage
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
- Reverted nuclear option from v0.4.8
- get_post_data() now uses the_content filter conditionally
- All other protections remain in place
- Memory leak investigation to be continued later

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:05:59 +01:00
35ad390aeb fix: Nuclear option - never apply the_content filter
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
- get_post_data() now ALWAYS strips shortcodes and uses raw content
- Never calls apply_filters('the_content') or get_the_excerpt()
- FediStream posts don't need shortcode processing in content
- This guarantees no recursion through WordPress hook system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:59:04 +01:00
b592e45d58 fix: Hard main template rendering lock
Some checks failed
Create Release Package / build-release (push) Failing after 53s
- Added $rendering_main_template flag that blocks all other renders
- Reduced MAX_RENDER_DEPTH from 5 to 2
- template-wrapper.php passes is_main_template=true to enable hard lock
- Any render attempt during main template rendering is blocked

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:55:23 +01:00
a41eddbc49 fix: Block shortcode rendering during page template loading
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
- Added $loading_page_template flag in TemplateLoader
- template-wrapper.php sets flag before loading theme header/footer
- Shortcodes::render_template() returns early if flag is set
- Prevents recursion from theme components, widgets, or other plugins

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:09:16 +01:00
eb85870909 fix: Multi-layer protection against Twig rendering recursion
All checks were successful
Create Release Package / build-release (push) Successful in 57s
- Added render depth tracking in Plugin::render() with max depth of 5
- Strip shortcodes from content when in shortcode context
- Prevents any later do_shortcode() calls from triggering recursion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:04:38 +01:00
6988e49287 fix: Prevent get_the_excerpt() from triggering the_content filter
All checks were successful
Create Release Package / build-release (push) Successful in 58s
- get_the_excerpt() internally calls the_content filter when generating auto-excerpts
- When in shortcode context, now uses raw post_excerpt or wp_trim_words() instead
- This was the remaining recursion path causing memory exhaustion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:56:52 +01:00
166a5e6f7c fix: Complete memory leak fix for shortcode context handling
All checks were successful
Create Release Package / build-release (push) Successful in 58s
- Changed shortcode context from boolean to depth counter for nested shortcodes
- Added shortcode context protection to template-wrapper.php for single page views
- Fixes remaining recursion path in single FediStream post views

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:52:13 +01:00
fedab21c2a fix: Complete memory leak fix with shortcode context tracking
All checks were successful
Create Release Package / build-release (push) Successful in 57s
The v0.4.1 fix was incomplete - shortcodes called get_*_data() methods
directly, bypassing the recursion tracking in get_post_data().

Changes:
- Added $in_shortcode_context flag to TemplateLoader
- Added enter/exit_shortcode_context() methods
- All shortcode render methods now enter context before data loading
- When in shortcode context, the_content filter is always skipped

This fully prevents infinite recursion when post content contains
FediStream shortcodes that would otherwise recursively render.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:44:51 +01:00
eaefcff9c9 fix: Critical memory leak in TemplateLoader causing OOM errors
All checks were successful
Create Release Package / build-release (push) Successful in 57s
- Added recursion depth tracking to prevent infinite loops from shortcodes in content
- Nested items now skip the_content filter, using wp_kses_post() instead
- Made get_artist_data(), get_album_data(), get_track_data(), get_playlist_data() public
- Methods now accept both int post IDs and WP_Post objects
- Added $load_nested parameter to control nested item loading

Fixes memory exhaustion in Twig's StagingExtension when post content
contains FediStream shortcodes that trigger recursive template rendering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:37:43 +01:00
04201a66f8 docs: Update session history with CI/CD fixes
Document all CI/CD pipeline fixes:
- Gitea API for releases (not GitHub action)
- Git submodule with relative URL
- Composer path repository
- gettext installation
- SIGPIPE fix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:46:06 +01:00
8ae703787c chore: Reorder exclusions in release workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:38:37 +01:00
c540cde0a4 docs: Update README for v0.4.0
- Update version badge to 0.4.0
- Add CI/CD badge
- Add release package installation instructions
- Add license key section
- Add releases section
- Update from-source instructions for submodules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:35:06 +01:00
d96e3e3a4d chore: Exclude .gitea and .gitmodules from release package
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:54 +01:00
12 changed files with 1056 additions and 82 deletions

View File

@@ -78,6 +78,7 @@ jobs:
-x "${PLUGIN_NAME}/.github/*" \ -x "${PLUGIN_NAME}/.github/*" \
-x "${PLUGIN_NAME}/.vscode/*" \ -x "${PLUGIN_NAME}/.vscode/*" \
-x "${PLUGIN_NAME}/.claude/*" \ -x "${PLUGIN_NAME}/.claude/*" \
-x "${PLUGIN_NAME}/.gitea/*" \
-x "${PLUGIN_NAME}/CLAUDE.md" \ -x "${PLUGIN_NAME}/CLAUDE.md" \
-x "${PLUGIN_NAME}/wp-core" \ -x "${PLUGIN_NAME}/wp-core" \
-x "${PLUGIN_NAME}/wp-core/*" \ -x "${PLUGIN_NAME}/wp-core/*" \
@@ -87,6 +88,7 @@ jobs:
-x "${PLUGIN_NAME}/composer.lock" \ -x "${PLUGIN_NAME}/composer.lock" \
-x "${PLUGIN_NAME}/*.log" \ -x "${PLUGIN_NAME}/*.log" \
-x "${PLUGIN_NAME}/.gitignore" \ -x "${PLUGIN_NAME}/.gitignore" \
-x "${PLUGIN_NAME}/.gitmodules" \
-x "${PLUGIN_NAME}/.editorconfig" \ -x "${PLUGIN_NAME}/.editorconfig" \
-x "${PLUGIN_NAME}/phpcs.xml*" \ -x "${PLUGIN_NAME}/phpcs.xml*" \
-x "${PLUGIN_NAME}/phpunit.xml*" \ -x "${PLUGIN_NAME}/phpunit.xml*" \

View File

@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2026-02-02
### Added
- **Prometheus Metrics Integration** - Expose FediStream metrics for monitoring
- Content metrics: `fedistream_content_total` (by type/status), `fedistream_genres_total`, `fedistream_moods_total`
- Engagement metrics: `fedistream_plays_total`, `fedistream_plays_today`, `fedistream_favorites_total`, `fedistream_local_follows_total`, `fedistream_listening_history_entries`
- User metrics: `fedistream_users_with_library`, `fedistream_users_following_artists`, `fedistream_notifications_total`, `fedistream_notifications_pending`
- WooCommerce metrics (conditional): `fedistream_purchases_total`, `fedistream_customers_total`, `fedistream_products_total`
- ActivityPub metrics (conditional): `fedistream_activitypub_followers_total`, `fedistream_activitypub_followers_by_artist`, `fedistream_activitypub_reactions_total`
- New setting in Integrations tab to enable/disable Prometheus metrics
- Requires WP Prometheus plugin to be active
## [0.4.9] - 2026-02-02
### Changed
- **Reverted nuclear option** - Restored conditional the_content filter usage
- `get_post_data()` now uses the_content filter only when NOT in shortcode context, NOT at depth > 1, and NOT loading page template
- All other protections remain in place (render depth, page template loading flag, main template lock, shortcode context)
- Memory leak investigation to be continued later
## [0.4.8] - 2026-02-02
### Fixed
- **Nuclear option: NEVER apply the_content filter** - Completely removed the_content filter usage (reverted in 0.4.9)
- `get_post_data()` now ALWAYS strips shortcodes and uses raw content
- NEVER calls `apply_filters('the_content', ...)` or `get_the_excerpt()`
- FediStream posts don't need shortcode processing in their content anyway
- This guarantees no recursion through WordPress hook system
## [0.4.7] - 2026-02-02
### Fixed
- **Hard main template rendering lock** - Added additional protection at Plugin::render() level
- Added `$rendering_main_template` flag that completely blocks any other render calls while main template is rendering
- Reduced MAX_RENDER_DEPTH from 5 to 2 (allows one level of {% include %} but prevents deeper recursion)
- template-wrapper.php now passes `is_main_template = true` to enable the hard lock
- Any render attempt during main template rendering is immediately blocked
## [0.4.6] - 2026-02-02
### Fixed
- **Page template loading lock** - Block ALL shortcode rendering during page template loading
- Added `$loading_page_template` flag in TemplateLoader
- template-wrapper.php now sets this flag before loading theme header/footer
- Shortcodes::render_template() checks this flag and returns early if set
- This prevents any recursion triggered by theme components, widgets, or other plugins during page template loading
- Main template rendering still works (uses Plugin::render() directly, not through Shortcodes)
## [0.4.5] - 2026-02-02
### Fixed
- **Multi-layer recursion protection** - Added additional safeguards against infinite Twig rendering
- Added render depth tracking in `Plugin::render()` with max depth of 5
- Strip shortcodes from content when in shortcode context (prevents any later `do_shortcode()` calls from triggering recursion)
- This addresses the Twig StagingExtension.php recursion error
## [0.4.4] - 2026-02-02
### Fixed
- **Fix excerpt-triggered recursion** - `get_the_excerpt()` internally calls `the_content` filter when generating auto-excerpts
- When in shortcode context, now uses raw `$post->post_excerpt` or generates simple excerpt with `wp_trim_words()` instead
- This was the remaining recursion path causing memory exhaustion in `class-wp-hook.php`
## [0.4.3] - 2026-02-02
### Fixed
- **Further memory leak fix** - v0.4.2 fix was still incomplete
- Changed `$in_shortcode_context` boolean to `$shortcode_context_depth` counter to properly handle nested shortcodes
- Added shortcode context protection to `template-wrapper.php` for single page views
- This fixes the remaining recursion path where `the_content` filter was still being applied when viewing single FediStream posts (artists, albums, tracks, playlists)
## [0.4.2] - 2026-02-02
### Fixed
- **Complete fix for memory leak** - v0.4.1 fix was incomplete
- Added `$in_shortcode_context` flag to TemplateLoader to track when we're rendering shortcodes
- All shortcode render methods now call `enter_shortcode_context()` before loading data
- When in shortcode context, `the_content` filter is always skipped to prevent recursive shortcode processing
- This prevents infinite recursion when post content contains FediStream shortcodes
## [0.4.1] - 2026-02-02
### Fixed
- **Critical memory leak** causing "Allowed memory size exhausted" errors in Twig's StagingExtension
- Root cause: `apply_filters('the_content')` in `get_post_data()` triggered shortcode processing, causing infinite recursion when post content contained FediStream shortcodes
- Added recursion depth tracking with `MAX_RECURSION_DEPTH = 3` to prevent runaway nesting
- Nested items now skip `the_content` filter, using `wp_kses_post()` instead
- Nested data loading (albums within artists, tracks within albums) is now properly bounded
### Changed
- Made `get_artist_data()`, `get_album_data()`, `get_track_data()`, and `get_playlist_data()` public methods in TemplateLoader (previously private but called externally)
- These methods now accept both `int` post IDs and `WP_Post` objects for flexibility
- Added `$load_nested` parameter to control whether nested items are fully loaded or just counted
## [0.4.0] - 2026-01-29 ## [0.4.0] - 2026-01-29
### Added ### Added
@@ -191,7 +296,16 @@ Initial release of WP FediStream - a WordPress plugin for streaming music over A
--- ---
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.0...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.9...HEAD
[0.4.9]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.8...v0.4.9
[0.4.8]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.7...v0.4.8
[0.4.7]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.6...v0.4.7
[0.4.6]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.5...v0.4.6
[0.4.5]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.4...v0.4.5
[0.4.4]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.3...v0.4.4
[0.4.3]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.2...v0.4.3
[0.4.2]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.1...v0.4.2
[0.4.1]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.0...v0.4.1
[0.4.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.3.0...v0.4.0 [0.4.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.3.0...v0.4.0
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.2.0...v0.3.0 [0.3.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.2.0...v0.3.0
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.1...v0.2.0 [0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.1...v0.2.0

View File

@@ -507,7 +507,7 @@ wp-fedistream/
### 2026-01-29 - CI/CD Pipeline v0.4.0 ### 2026-01-29 - CI/CD Pipeline v0.4.0
**Summary:** Added Gitea Actions workflow for automated release package creation. **Summary:** Added Gitea Actions workflow for automated release package creation with multiple iterations to fix CI issues.
**Features:** **Features:**
@@ -520,23 +520,90 @@ wp-fedistream/
- SHA256 checksum generation - SHA256 checksum generation
- Package structure verification - Package structure verification
- Changelog extraction for release notes - Changelog extraction for release notes
- Automatic Gitea release creation with attachments - Automatic Gitea release creation via API
- Pre-release detection for tags containing `-` - Pre-release detection for tags containing `-`
**Files Created:** **Files Created:**
- `.gitea/workflows/release.yml` - CI/CD release pipeline - `.gitea/workflows/release.yml` - CI/CD release pipeline
- `.gitmodules` - Git submodule configuration
- `lib/wc-licensed-product-client/` - Submodule for private dependency
**Files Modified:** **Files Modified:**
- `CLAUDE.md` - Added CI/CD documentation and updated directory structure - `CLAUDE.md` - Added CI/CD documentation and updated directory structure
- `CHANGELOG.md` - Added v0.4.0 entry - `CHANGELOG.md` - Added v0.4.0 entry
- `wp-fedistream.php` - Version bump to 0.4.0 - `wp-fedistream.php` - Version bump to 0.4.0
- `composer.json` - Changed to path repository for submodule
- `README.md` - Updated for v0.4.0, added release/installation docs
**CI/CD Fixes Applied:**
1. `actions/gitea-release-action@v1` doesn't exist - use Gitea API directly with curl
2. Private repo network issue - use git submodule with relative URL (`../wc-licensed-product-client.git`)
3. Composer path repository for submodule dependency
4. `msgfmt` not found - install gettext package
5. SIGPIPE error (exit 141) - use `set +o pipefail` and `|| true`
**Notes:** **Notes:**
- Requires `GITEA_TOKEN` secret configured in repository settings - Requires `SRC_GITEA_TOKEN` secret configured in repository settings
- Uses `shivammathur/setup-php@v2` for PHP setup - Uses `shivammathur/setup-php@v2` for PHP setup
- Uses `actions/gitea-release-action@v1` for release creation - Uses Gitea API directly for release creation (not GitHub Actions)
- Compatible with GitHub Actions syntax - Submodule uses relative URL for CI compatibility
- User simplified checksums to SHA256 only (removed MD5) - Composer symlinks from `lib/wc-licensed-product-client` to vendor
### 2026-02-02 - Memory Leak Investigation v0.4.1 through v0.4.9
**Summary:** Investigated memory exhaustion issue when WP FediStream is used with WP Prometheus plugin. Multiple fix attempts were made but the root cause remains unresolved.
**Problem:**
- PHP Fatal error: Allowed memory size exhausted (1GB)
- Error locations varied: Twig StagingExtension.php, Environment.php, ExtensionSet.php, WordPress class-wp-hook.php
- Only occurs when WP Prometheus plugin is also active
- Suspected infinite recursion through WordPress hook system
**Fix Attempts (v0.4.1 - v0.4.8):**
1. **v0.4.1** - Added recursion depth tracking in `get_post_data()`, skip `the_content` filter at depth > 1
2. **v0.4.2** - Added `$in_shortcode_context` flag, all shortcode render methods enter context before data loading
3. **v0.4.3** - Changed boolean to counter (`$shortcode_context_depth`), added context to `template-wrapper.php`
4. **v0.4.4** - Fixed `get_the_excerpt()` which internally triggers `the_content` filter
5. **v0.4.5** - Added render depth tracking in `Plugin::render()`, strip shortcodes from content
6. **v0.4.6** - Added `$loading_page_template` flag to block shortcode rendering during page template loading
7. **v0.4.7** - Added `$rendering_main_template` hard lock in `Plugin::render()`, reduced MAX_RENDER_DEPTH to 2
8. **v0.4.8** - Nuclear option: ALWAYS skip `the_content` filter (didn't work, reverted)
**v0.4.9 - Current State:**
- Reverted nuclear option
- Kept all other protections in place
- Issue documented in README.md as known incompatibility
**Files Modified:**
- `includes/Frontend/TemplateLoader.php` - Multiple recursion protection mechanisms
- `includes/Frontend/Shortcodes.php` - Shortcode context entry, page template loading check
- `includes/Frontend/template-wrapper.php` - Page template loading flag, main template render flag
- `includes/Plugin.php` - Render depth tracking, main template rendering lock
- `README.md` - Added Known Issues section
**Protection Mechanisms in Place:**
1. `$recursion_depth` counter in `get_post_data()` (max 3)
2. `$shortcode_context_depth` counter for nested shortcodes
3. `$loading_page_template` flag blocks shortcode rendering during page load
4. `$rendering_main_template` flag in `Plugin::render()` blocks parallel renders
5. `MAX_RENDER_DEPTH = 2` in `Plugin::render()`
6. Skip `the_content` and `get_the_excerpt()` when in protected context
7. Strip shortcodes from content when skipping content filter
**Key Learnings:**
- `get_the_excerpt()` internally calls `apply_filters('the_content', ...)` when generating auto-excerpts
- `the_content` filter triggers `do_shortcode()` which can cause recursive shortcode processing
- WordPress hook system (class-wp-hook.php) can itself be the recursion point
- The interaction between FediStream and WP Prometheus appears to be at a fundamental WordPress level
**Status:** UNRESOLVED - Documented as known incompatibility, investigation to continue later

View File

@@ -2,10 +2,11 @@
Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels. Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels.
[![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](CHANGELOG.md)
[![PHP](https://img.shields.io/badge/PHP-%3E%3D8.3-purple.svg)](https://php.net) [![PHP](https://img.shields.io/badge/PHP-%3E%3D8.3-purple.svg)](https://php.net)
[![WordPress](https://img.shields.io/badge/WordPress-%3E%3D6.4-blue.svg)](https://wordpress.org) [![WordPress](https://img.shields.io/badge/WordPress-%3E%3D6.4-blue.svg)](https://wordpress.org)
[![License](https://img.shields.io/badge/license-GPL--2.0%2B-green.svg)](https://www.gnu.org/licenses/gpl-2.0.html) [![License](https://img.shields.io/badge/license-GPL--2.0%2B-green.svg)](https://www.gnu.org/licenses/gpl-2.0.html)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea%20Actions-green.svg)](https://src.bundespruefstelle.ch/magdev/wp-fedistream/actions)
## Description ## Description
@@ -31,29 +32,47 @@ WP FediStream is a WordPress plugin that enables musicians, bands, and labels to
- PHP 8.3 or higher - PHP 8.3 or higher
- WordPress 6.4 or higher - WordPress 6.4 or higher
- Composer (for development/installation) - Valid license key (required for frontend features)
### Optional ### Optional
- [ActivityPub Plugin](https://wordpress.org/plugins/activitypub/) - For Fediverse integration - [ActivityPub Plugin](https://wordpress.org/plugins/activitypub/) - For Fediverse integration
- [WooCommerce](https://woocommerce.com/) 10.0+ - For selling music - [WooCommerce](https://woocommerce.com/) 10.0+ - For selling music
## License Key
WP FediStream requires a valid license key for frontend functionality (player, shortcodes, ActivityPub). The admin dashboard works without a license, allowing you to configure the plugin before activation.
To obtain a license key, contact the author or purchase from the official website.
## Installation ## Installation
### From Source ### From Release Package (Recommended)
1. Clone or download the repository to your WordPress plugins directory: 1. Download the latest release from the [Releases page](https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases)
2. Upload the ZIP file via **Plugins > Add New > Upload Plugin** in WordPress admin
3. Activate the plugin under **Plugins > Installed Plugins**
4. Navigate to **FediStream > Settings** and enter your license key
5. Start using the plugin via the **FediStream** admin menu
### From Source (Development)
1. Clone the repository to your WordPress plugins directory:
```bash ```bash
cd wp-content/plugins/ cd wp-content/plugins/
git clone https://src.bundespruefstelle.ch/magdev/wp-fedistream.git git clone --recurse-submodules https://src.bundespruefstelle.ch/magdev/wp-fedistream.git
``` ```
2. Install Composer dependencies: 2. Install Composer dependencies:
```bash ```bash
cd wp-fedistream cd wp-fedistream
composer install --no-dev composer install
``` ```
3. Activate the plugin in WordPress admin under **Plugins > Installed Plugins** 3. Activate the plugin in WordPress admin under **Plugins > Installed Plugins**
@@ -133,6 +152,16 @@ wp-fedistream/
└── wp-fedistream.php # Plugin entry point └── wp-fedistream.php # Plugin entry point
``` ```
## Releases
Release packages are automatically built via Gitea Actions when a version tag is pushed. Each release includes:
- Production-ready ZIP package with all dependencies
- SHA256 checksum for verification
- Changelog notes extracted from CHANGELOG.md
Download releases from: <https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases>
## Contributing ## Contributing
This project is in early development. Contributions, bug reports, and feature requests are welcome. This project is in early development. Contributions, bug reports, and feature requests are welcome.

View File

@@ -81,6 +81,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_artist( array $atts ): string { public function render_artist( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -95,6 +98,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_artist' ); $post = $this->get_post( $atts, 'fedistream_artist' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -119,6 +123,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_album( array $atts ): string { public function render_album( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -132,6 +139,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_album' ); $post = $this->get_post( $atts, 'fedistream_album' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -155,6 +163,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_track( array $atts ): string { public function render_track( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -168,6 +179,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_track' ); $post = $this->get_post( $atts, 'fedistream_track' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -191,6 +203,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_playlist( array $atts ): string { public function render_playlist( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -204,6 +219,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_playlist' ); $post = $this->get_post( $atts, 'fedistream_playlist' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -227,6 +243,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_latest_releases( array $atts ): string { public function render_latest_releases( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 6, 'count' => 6,
@@ -292,6 +311,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_popular_tracks( array $atts ): string { public function render_popular_tracks( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 10, 'count' => 10,
@@ -359,6 +381,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_artists_grid( array $atts ): string { public function render_artists_grid( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 12, 'count' => 12,
@@ -426,6 +451,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_player( array $atts ): string { public function render_player( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'track' => 0, 'track' => 0,
@@ -471,6 +499,7 @@ class Shortcodes {
} }
if ( empty( $tracks ) ) { if ( empty( $tracks ) ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -523,18 +552,31 @@ class Shortcodes {
* @return string * @return string
*/ */
private function render_template( string $template, array $context ): string { private function render_template( string $template, array $context ): string {
// Block shortcode rendering while loading page template to prevent recursion.
// This catches any shortcodes triggered by theme header/footer, widgets, etc.
if ( TemplateLoader::is_loading_page_template() ) {
return '<!-- FediStream: shortcode blocked during page template loading -->';
}
// Check for unlicensed mode. // Check for unlicensed mode.
if ( $this->unlicensed_mode ) { if ( $this->unlicensed_mode ) {
return $this->get_unlicensed_message(); return $this->get_unlicensed_message();
} }
// Enter shortcode context to prevent recursive shortcode processing.
TemplateLoader::enter_shortcode_context();
try { try {
return $this->plugin->render( $template, $context ); $result = $this->plugin->render( $template, $context );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
TemplateLoader::exit_shortcode_context();
if ( WP_DEBUG ) { if ( WP_DEBUG ) {
return '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>'; return '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
} }
return ''; return '';
} }
TemplateLoader::exit_shortcode_context();
return $result;
} }
} }

View File

@@ -21,6 +21,96 @@ if ( ! defined( 'ABSPATH' ) ) {
*/ */
class TemplateLoader { class TemplateLoader {
/**
* Recursion depth for get_post_data calls.
*
* @var int
*/
private static int $recursion_depth = 0;
/**
* Maximum allowed recursion depth.
*
* @var int
*/
private const MAX_RECURSION_DEPTH = 3;
/**
* Shortcode rendering context depth counter.
* When > 0, the_content filter is skipped to prevent recursive shortcode processing.
* Using a counter instead of boolean to handle nested shortcodes properly.
*
* @var int
*/
private static int $shortcode_context_depth = 0;
/**
* Flag indicating we're currently loading a FediStream page template.
* This completely blocks any nested FediStream shortcode rendering.
*
* @var bool
*/
private static bool $loading_page_template = false;
/**
* Enter page template loading mode.
* This blocks ALL shortcode rendering during page template loading.
*
* @return void
*/
public static function enter_page_template_loading(): void {
self::$loading_page_template = true;
}
/**
* Exit page template loading mode.
*
* @return void
*/
public static function exit_page_template_loading(): void {
self::$loading_page_template = false;
}
/**
* Check if we're loading a page template.
*
* @return bool
*/
public static function is_loading_page_template(): bool {
return self::$loading_page_template;
}
/**
* Enter shortcode rendering context.
* Call this before rendering shortcode content to prevent recursive shortcode processing.
*
* @return void
*/
public static function enter_shortcode_context(): void {
++self::$shortcode_context_depth;
}
/**
* Exit shortcode rendering context.
* Call this after shortcode rendering is complete.
*
* @return void
*/
public static function exit_shortcode_context(): void {
if ( self::$shortcode_context_depth > 0 ) {
--self::$shortcode_context_depth;
}
}
/**
* Check if we're in a shortcode rendering context.
*
* @return bool
*/
public static function is_in_shortcode_context(): bool {
return self::$shortcode_context_depth > 0;
}
/** /**
* Constructor. * Constructor.
*/ */
@@ -192,33 +282,68 @@ class TemplateLoader {
* Get post data for template. * Get post data for template.
* *
* @param \WP_Post $post Post object. * @param \WP_Post $post Post object.
* @param bool $skip_nested Whether to skip loading nested items (albums, tracks, etc.).
* @return array Post data. * @return array Post data.
*/ */
public static function get_post_data( \WP_Post $post ): array { public static function get_post_data( \WP_Post $post, bool $skip_nested = false ): array {
// Track recursion to prevent infinite loops from shortcodes in content.
++self::$recursion_depth;
// Skip the_content filter if:
// 1. We're in a shortcode context (prevents recursive shortcode processing)
// 2. We're at depth > 1 (nested data loading)
// 3. We're loading a page template
$skip_content_filter = self::$shortcode_context_depth > 0
|| self::$recursion_depth > 1
|| self::$loading_page_template;
// When skipping content filter, use raw excerpt to avoid get_the_excerpt()
// triggering the_content filter internally when generating auto-excerpts.
if ( $skip_content_filter ) {
$excerpt = $post->post_excerpt;
if ( empty( $excerpt ) ) {
// Generate a simple excerpt without triggering the_content filter.
$excerpt = wp_trim_words( wp_strip_all_tags( $post->post_content ), 55, '&hellip;' );
}
} else {
$excerpt = get_the_excerpt( $post );
}
// When skipping content filter, strip shortcodes to prevent them from
// being processed by anything else that might call do_shortcode on the output.
if ( $skip_content_filter ) {
$content = strip_shortcodes( $post->post_content );
$content = wp_kses_post( $content );
} else {
$content = apply_filters( 'the_content', $post->post_content );
}
$data = array( $data = array(
'id' => $post->ID, 'id' => $post->ID,
'title' => get_the_title( $post ), 'title' => get_the_title( $post ),
'content' => apply_filters( 'the_content', $post->post_content ), 'content' => $content,
'excerpt' => get_the_excerpt( $post ), 'excerpt' => $excerpt,
'permalink' => get_permalink( $post ), 'permalink' => get_permalink( $post ),
'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ), 'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ),
'date' => get_the_date( '', $post ), 'date' => get_the_date( '', $post ),
'author' => get_the_author_meta( 'display_name', $post->post_author ), 'author' => get_the_author_meta( 'display_name', $post->post_author ),
); );
// Add post type specific data. // Add post type specific data (skip nested items if at max depth).
$load_nested = ! $skip_nested && self::$recursion_depth < self::MAX_RECURSION_DEPTH;
switch ( $post->post_type ) { switch ( $post->post_type ) {
case 'fedistream_artist': case 'fedistream_artist':
$data = array_merge( $data, self::get_artist_data( $post->ID ) ); $data = array_merge( $data, self::get_artist_data( $post->ID, $load_nested ) );
break; break;
case 'fedistream_album': case 'fedistream_album':
$data = array_merge( $data, self::get_album_data( $post->ID ) ); $data = array_merge( $data, self::get_album_data( $post->ID, $load_nested ) );
break; break;
case 'fedistream_track': case 'fedistream_track':
$data = array_merge( $data, self::get_track_data( $post->ID ) ); $data = array_merge( $data, self::get_track_data( $post->ID ) );
break; break;
case 'fedistream_playlist': case 'fedistream_playlist':
$data = array_merge( $data, self::get_playlist_data( $post->ID ) ); $data = array_merge( $data, self::get_playlist_data( $post->ID, $load_nested ) );
break; break;
} }
@@ -226,16 +351,23 @@ class TemplateLoader {
$data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' ); $data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' );
$data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' ); $data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' );
--self::$recursion_depth;
return $data; return $data;
} }
/** /**
* Get artist-specific data. * Get artist-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested albums.
* @return array Artist data. * @return array Artist data.
*/ */
private static function get_artist_data( int $post_id ): array { public static function get_artist_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo'; $type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo';
$types = array( $types = array(
'solo' => __( 'Solo Artist', 'wp-fedistream' ), 'solo' => __( 'Solo Artist', 'wp-fedistream' ),
@@ -244,7 +376,11 @@ class TemplateLoader {
'collective' => __( 'Collective', 'wp-fedistream' ), 'collective' => __( 'Collective', 'wp-fedistream' ),
); );
$albums = get_posts( $albums = array();
$album_count = 0;
if ( $load_nested ) {
$album_posts = get_posts(
array( array(
'post_type' => 'fedistream_album', 'post_type' => 'fedistream_album',
'posts_per_page' => -1, 'posts_per_page' => -1,
@@ -261,6 +397,27 @@ class TemplateLoader {
'order' => 'DESC', 'order' => 'DESC',
) )
); );
$album_count = count( $album_posts );
$albums = array_map(
function ( $album ) {
return self::get_post_data( $album, true ); // Skip further nesting.
},
$album_posts
);
} else {
// Just get the count without loading full data.
$album_count = (int) get_posts(
array(
'post_type' => 'fedistream_album',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_album_artist',
'meta_value' => $post_id,
'fields' => 'ids',
)
);
$album_count = is_array( $album_count ) ? count( $album_count ) : 0;
}
return array( return array(
'artist_type' => $type, 'artist_type' => $type,
@@ -270,18 +427,23 @@ class TemplateLoader {
'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ), 'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ),
'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(), 'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(),
'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(), 'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(),
'albums' => array_map( array( __CLASS__, 'get_post_data' ), $albums ), 'albums' => $albums,
'album_count' => count( $albums ), 'album_count' => $album_count,
); );
} }
/** /**
* Get album-specific data. * Get album-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested tracks.
* @return array Album data. * @return array Album data.
*/ */
private static function get_album_data( int $post_id ): array { public static function get_album_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album'; $type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album';
$types = array( $types = array(
'album' => __( 'Album', 'wp-fedistream' ), 'album' => __( 'Album', 'wp-fedistream' ),
@@ -293,7 +455,11 @@ class TemplateLoader {
); );
$artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true ); $artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true );
$tracks = get_posts( $tracks = array();
$total_tracks = 0;
if ( $load_nested ) {
$track_posts = get_posts(
array( array(
'post_type' => 'fedistream_track', 'post_type' => 'fedistream_track',
'posts_per_page' => -1, 'posts_per_page' => -1,
@@ -311,6 +477,27 @@ class TemplateLoader {
'order' => 'ASC', 'order' => 'ASC',
) )
); );
$total_tracks = count( $track_posts );
$tracks = array_map(
function ( $track ) {
return self::get_post_data( $track, true ); // Skip further nesting.
},
$track_posts
);
} else {
// Just get the count without loading full data.
$track_ids = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_track_album',
'meta_value' => $post_id,
'fields' => 'ids',
)
);
$total_tracks = is_array( $track_ids ) ? count( $track_ids ) : 0;
}
return array( return array(
'album_type' => $type, 'album_type' => $type,
@@ -322,19 +509,23 @@ class TemplateLoader {
'artist_url' => $artist_id ? get_permalink( $artist_id ) : '', 'artist_url' => $artist_id ? get_permalink( $artist_id ) : '',
'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ), 'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ),
'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ), 'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ),
'total_tracks' => count( $tracks ), 'total_tracks' => $total_tracks,
'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ), 'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ),
'tracks' => array_map( array( __CLASS__, 'get_post_data' ), $tracks ), 'tracks' => $tracks,
); );
} }
/** /**
* Get track-specific data. * Get track-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @return array Track data. * @return array Track data.
*/ */
private static function get_track_data( int $post_id ): array { public static function get_track_data( int|\WP_Post $post_id ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$album_id = get_post_meta( $post_id, '_fedistream_track_album', true ); $album_id = get_post_meta( $post_id, '_fedistream_track_album', true );
$audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true ); $audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true );
$artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array(); $artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array();
@@ -374,16 +565,21 @@ class TemplateLoader {
/** /**
* Get playlist-specific data. * Get playlist-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested tracks.
* @return array Playlist data. * @return array Playlist data.
*/ */
private static function get_playlist_data( int $post_id ): array { public static function get_playlist_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks'; $table = $wpdb->prefix . 'fedistream_playlist_tracks';
$duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true ); $duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true );
// Get tracks. // Get track IDs.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$track_ids = $wpdb->get_col( $track_ids = $wpdb->get_col(
$wpdb->prepare( $wpdb->prepare(
@@ -393,10 +589,14 @@ class TemplateLoader {
); );
$tracks = array(); $tracks = array();
$track_count = count( $track_ids );
if ( $load_nested && ! empty( $track_ids ) ) {
foreach ( $track_ids as $track_id ) { foreach ( $track_ids as $track_id ) {
$track = get_post( $track_id ); $track = get_post( $track_id );
if ( $track && 'publish' === $track->post_status ) { if ( $track && 'publish' === $track->post_status ) {
$tracks[] = self::get_post_data( $track ); $tracks[] = self::get_post_data( $track, true ); // Skip further nesting.
}
} }
} }
@@ -404,7 +604,7 @@ class TemplateLoader {
'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public', 'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public',
'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ), 'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ),
'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ), 'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ),
'track_count' => count( $tracks ), 'track_count' => $load_nested ? count( $tracks ) : $track_count,
'total_duration' => $duration, 'total_duration' => $duration,
'duration_formatted' => $duration >= 3600 'duration_formatted' => $duration >= 3600
? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 ) ? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 )

View File

@@ -13,6 +13,12 @@ if ( ! defined( 'ABSPATH' ) ) {
use WP_FediStream\Plugin; use WP_FediStream\Plugin;
use WP_FediStream\Frontend\TemplateLoader; use WP_FediStream\Frontend\TemplateLoader;
// Enter page template loading mode - this completely blocks nested FediStream rendering.
TemplateLoader::enter_page_template_loading();
// Also enter shortcode context to prevent recursive shortcode processing in post content.
TemplateLoader::enter_shortcode_context();
// Get template context. // Get template context.
$context = TemplateLoader::get_context(); $context = TemplateLoader::get_context();
@@ -54,7 +60,8 @@ get_header();
if ( $template_name ) { if ( $template_name ) {
try { try {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render( $template_name, $context ); // Pass true for is_main_template to set the hard rendering lock.
echo $plugin->render( $template_name, $context, true );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
if ( WP_DEBUG ) { if ( WP_DEBUG ) {
echo '<div class="fedistream-error">'; echo '<div class="fedistream-error">';
@@ -75,4 +82,8 @@ get_header();
</main> </main>
<?php <?php
// Exit shortcode context and page template loading mode.
TemplateLoader::exit_shortcode_context();
TemplateLoader::exit_page_template_loading();
get_footer(); get_footer();

View File

@@ -347,6 +347,7 @@ class Installer {
$defaults = array( $defaults = array(
'wp_fedistream_enable_activitypub' => 1, 'wp_fedistream_enable_activitypub' => 1,
'wp_fedistream_enable_woocommerce' => 0, 'wp_fedistream_enable_woocommerce' => 0,
'wp_fedistream_enable_prometheus' => 0,
'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ), 'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ),
'wp_fedistream_max_upload_size' => 50, // MB 'wp_fedistream_max_upload_size' => 50, // MB
'wp_fedistream_default_license' => 'all-rights-reserved', 'wp_fedistream_default_license' => 'all-rights-reserved',

View File

@@ -16,6 +16,7 @@ use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Frontend\Widgets; use WP_FediStream\Frontend\Widgets;
use WP_FediStream\PostTypes\Artist; use WP_FediStream\PostTypes\Artist;
use WP_FediStream\WooCommerce\Integration as WooCommerceIntegration; use WP_FediStream\WooCommerce\Integration as WooCommerceIntegration;
use WP_FediStream\Prometheus\Integration as PrometheusIntegration;
use WP_FediStream\WooCommerce\DigitalDelivery; use WP_FediStream\WooCommerce\DigitalDelivery;
use WP_FediStream\WooCommerce\StreamingAccess; use WP_FediStream\WooCommerce\StreamingAccess;
use WP_FediStream\PostTypes\Album; use WP_FediStream\PostTypes\Album;
@@ -55,6 +56,29 @@ final class Plugin {
*/ */
private ?\Twig\Environment $twig = null; private ?\Twig\Environment $twig = null;
/**
* Current Twig render depth to prevent infinite recursion.
*
* @var int
*/
private static int $render_depth = 0;
/**
* Maximum allowed Twig render depth.
* Set to 2 to allow one level of nested includes but prevent deeper recursion.
*
* @var int
*/
private const MAX_RENDER_DEPTH = 2;
/**
* Flag to track if we're currently rendering the main page template.
* This is a hard lock that prevents ANY other rendering.
*
* @var bool
*/
private static bool $rendering_main_template = false;
/** /**
* Post type instances. * Post type instances.
* *
@@ -184,6 +208,11 @@ final class Plugin {
new StreamingAccess(); new StreamingAccess();
} }
// Initialize Prometheus integration.
if ( get_option( 'wp_fedistream_enable_prometheus', 0 ) && $this->is_prometheus_active() ) {
new PrometheusIntegration();
}
// Initialize user library and notifications. // Initialize user library and notifications.
new UserLibrary(); new UserLibrary();
new LibraryPage(); new LibraryPage();
@@ -425,6 +454,7 @@ final class Plugin {
// Get current settings. // Get current settings.
$enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 ); $enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 );
$enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 ); $enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 );
$enable_prometheus = get_option( 'wp_fedistream_enable_prometheus', 0 );
$max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 ); $max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 );
$default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' ); $default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' );
@@ -463,7 +493,7 @@ final class Plugin {
$this->render_settings_tab( $max_upload_size, $default_license ); $this->render_settings_tab( $max_upload_size, $default_license );
break; break;
case 'integrations': case 'integrations':
$this->render_integrations_tab( $enable_activitypub, $enable_woocommerce ); $this->render_integrations_tab( $enable_activitypub, $enable_woocommerce, $enable_prometheus );
break; break;
} }
?> ?>
@@ -502,6 +532,7 @@ final class Plugin {
case 'integrations': case 'integrations':
update_option( 'wp_fedistream_enable_activitypub', isset( $_POST['enable_activitypub'] ) ? 1 : 0 ); update_option( 'wp_fedistream_enable_activitypub', isset( $_POST['enable_activitypub'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_woocommerce', isset( $_POST['enable_woocommerce'] ) ? 1 : 0 ); update_option( 'wp_fedistream_enable_woocommerce', isset( $_POST['enable_woocommerce'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_prometheus', isset( $_POST['enable_prometheus'] ) ? 1 : 0 );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Integration settings saved.', 'wp-fedistream' ) . '</p></div>'; echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Integration settings saved.', 'wp-fedistream' ) . '</p></div>';
break; break;
} }
@@ -696,9 +727,10 @@ final class Plugin {
* *
* @param int $enable_activitypub Whether ActivityPub is enabled. * @param int $enable_activitypub Whether ActivityPub is enabled.
* @param int $enable_woocommerce Whether WooCommerce integration is enabled. * @param int $enable_woocommerce Whether WooCommerce integration is enabled.
* @param int $enable_prometheus Whether Prometheus metrics are enabled.
* @return void * @return void
*/ */
private function render_integrations_tab( int $enable_activitypub, int $enable_woocommerce ): void { private function render_integrations_tab( int $enable_activitypub, int $enable_woocommerce, int $enable_prometheus ): void {
?> ?>
<form method="post" action=""> <form method="post" action="">
<?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?> <?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?>
@@ -737,6 +769,23 @@ final class Plugin {
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<tr>
<th scope="row"><?php esc_html_e( 'Prometheus Metrics', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_prometheus" value="1" <?php checked( $enable_prometheus, 1 ); ?> <?php disabled( ! $this->is_prometheus_active() ); ?>>
<?php esc_html_e( 'Enable Prometheus metrics', 'wp-fedistream' ); ?>
</label>
<?php if ( ! $this->is_prometheus_active() ) : ?>
<p class="description" style="color: #d63638;">
<span class="dashicons dashicons-dismiss" style="font-size: 16px; width: 16px; height: 16px; vertical-align: text-bottom;"></span>
<?php esc_html_e( 'WP Prometheus plugin is not installed or active.', 'wp-fedistream' ); ?>
</p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Expose FediStream metrics for Prometheus monitoring.', 'wp-fedistream' ); ?></p>
<?php endif; ?>
</td>
</tr>
</table> </table>
<?php submit_button(); ?> <?php submit_button(); ?>
@@ -842,8 +891,35 @@ final class Plugin {
* @param array $context Template context variables. * @param array $context Template context variables.
* @return string Rendered template. * @return string Rendered template.
*/ */
public function render( string $template, array $context = array() ): string { public function render( string $template, array $context = array(), bool $is_main_template = false ): string {
return $this->twig->render( $template . '.twig', $context ); // If we're already rendering the main template, block any other renders.
if ( self::$rendering_main_template && ! $is_main_template ) {
return '<!-- FediStream: blocked during main template render -->';
}
// Prevent infinite recursion in Twig rendering.
if ( self::$render_depth >= self::MAX_RENDER_DEPTH ) {
return '<!-- FediStream: render depth exceeded -->';
}
// Set main template lock if this is the main template.
$was_main = self::$rendering_main_template;
if ( $is_main_template ) {
self::$rendering_main_template = true;
}
++self::$render_depth;
try {
$result = $this->twig->render( $template . '.twig', $context );
} finally {
--self::$render_depth;
if ( $is_main_template ) {
self::$rendering_main_template = $was_main;
}
}
return $result;
} }
/** /**
@@ -883,4 +959,13 @@ final class Plugin {
public function is_activitypub_active(): bool { public function is_activitypub_active(): bool {
return class_exists( 'Activitypub\Activitypub' ); return class_exists( 'Activitypub\Activitypub' );
} }
/**
* Check if WP Prometheus plugin is active.
*
* @return bool
*/
public function is_prometheus_active(): bool {
return defined( 'WP_PROMETHEUS_VERSION' ) || class_exists( 'WP_Prometheus\Plugin' );
}
} }

View File

@@ -0,0 +1,422 @@
<?php
/**
* Prometheus Integration.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Prometheus;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main Prometheus integration class.
*
* Exposes FediStream metrics for Prometheus monitoring via the
* wp_prometheus_collect_metrics action hook.
*/
class Integration {
/**
* Whether WP Prometheus is active.
*
* @var bool
*/
private bool $prometheus_active = false;
/**
* Constructor.
*/
public function __construct() {
// Check WP Prometheus immediately since we're instantiated during plugins_loaded.
$this->check_prometheus();
// If plugins_loaded hasn't fully completed, hook init at priority 20.
// Otherwise, run init directly.
if ( ! did_action( 'plugins_loaded' ) || doing_action( 'plugins_loaded' ) ) {
add_action( 'plugins_loaded', array( $this, 'init' ), 20 );
} else {
$this->init();
}
}
/**
* Check if WP Prometheus is active.
*
* @return void
*/
public function check_prometheus(): void {
$this->prometheus_active = defined( 'WP_PROMETHEUS_VERSION' )
|| class_exists( 'WP_Prometheus\Plugin' );
}
/**
* Initialize Prometheus integration.
*
* @return void
*/
public function init(): void {
if ( ! $this->prometheus_active ) {
return;
}
// Register metrics collector.
add_action( 'wp_prometheus_collect_metrics', array( $this, 'collect_metrics' ) );
}
/**
* Check if Prometheus is active.
*
* @return bool
*/
public function is_active(): bool {
return $this->prometheus_active;
}
/**
* Collect FediStream metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
public function collect_metrics( $collector ): void {
// Register and collect all metric categories.
$this->collect_content_metrics( $collector );
$this->collect_engagement_metrics( $collector );
$this->collect_user_metrics( $collector );
if ( $this->is_woocommerce_enabled() ) {
$this->collect_woocommerce_metrics( $collector );
}
if ( $this->is_activitypub_enabled() ) {
$this->collect_activitypub_metrics( $collector );
}
}
/**
* Collect content metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_content_metrics( $collector ): void {
// Content count by type and status.
$content_gauge = $collector->register_gauge(
'fedistream_content_total',
'Total FediStream content count',
array( 'type', 'status' )
);
$post_types = array(
'fedistream_artist',
'fedistream_album',
'fedistream_track',
'fedistream_playlist',
);
foreach ( $post_types as $post_type ) {
$type_label = str_replace( 'fedistream_', '', $post_type );
$counts = wp_count_posts( $post_type );
foreach ( array( 'publish', 'draft', 'pending' ) as $status ) {
$count = isset( $counts->$status ) ? (int) $counts->$status : 0;
$content_gauge->set( $count, array( $type_label, $status ) );
}
}
// Genre count.
$genres_gauge = $collector->register_gauge(
'fedistream_genres_total',
'Total number of genres',
array()
);
$genre_count = wp_count_terms( array( 'taxonomy' => 'fedistream_genre' ) );
$genres_gauge->set( is_wp_error( $genre_count ) ? 0 : (int) $genre_count, array() );
// Mood count.
$moods_gauge = $collector->register_gauge(
'fedistream_moods_total',
'Total number of moods',
array()
);
$mood_count = wp_count_terms( array( 'taxonomy' => 'fedistream_mood' ) );
$moods_gauge->set( is_wp_error( $mood_count ) ? 0 : (int) $mood_count, array() );
}
/**
* Collect engagement metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_engagement_metrics( $collector ): void {
global $wpdb;
$plays_table = $wpdb->prefix . 'fedistream_plays';
$favorites_table = $wpdb->prefix . 'fedistream_favorites';
$follows_table = $wpdb->prefix . 'fedistream_user_follows';
$history_table = $wpdb->prefix . 'fedistream_listening_history';
// Total plays.
$plays_gauge = $collector->register_gauge(
'fedistream_plays_total',
'Total track plays',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_plays = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$plays_table}" );
$plays_gauge->set( $total_plays, array() );
// Plays today.
$plays_today_gauge = $collector->register_gauge(
'fedistream_plays_today',
'Track plays today',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$plays_today = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$plays_table} WHERE DATE(played_at) = CURDATE()"
);
$plays_today_gauge->set( $plays_today, array() );
// Favorites by type.
$favorites_gauge = $collector->register_gauge(
'fedistream_favorites_total',
'Total favorites by content type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$favorites_by_type = $wpdb->get_results(
"SELECT content_type, COUNT(*) as count FROM {$favorites_table} GROUP BY content_type"
);
foreach ( $favorites_by_type as $row ) {
$favorites_gauge->set( (int) $row->count, array( $row->content_type ) );
}
// Local follows.
$follows_gauge = $collector->register_gauge(
'fedistream_local_follows_total',
'Total local artist follows',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_follows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$follows_table}" );
$follows_gauge->set( $total_follows, array() );
// Listening history entries.
$history_gauge = $collector->register_gauge(
'fedistream_listening_history_entries',
'Total listening history entries',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$history_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$history_table}" );
$history_gauge->set( $history_count, array() );
}
/**
* Collect user metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_user_metrics( $collector ): void {
global $wpdb;
$favorites_table = $wpdb->prefix . 'fedistream_favorites';
$follows_table = $wpdb->prefix . 'fedistream_user_follows';
$notifications_table = $wpdb->prefix . 'fedistream_notifications';
// Users with library (favorites).
$users_library_gauge = $collector->register_gauge(
'fedistream_users_with_library',
'Users who have favorites',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$users_with_library = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$favorites_table}"
);
$users_library_gauge->set( $users_with_library, array() );
// Users following artists.
$users_following_gauge = $collector->register_gauge(
'fedistream_users_following_artists',
'Users following at least one artist',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$users_following = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$follows_table}"
);
$users_following_gauge->set( $users_following, array() );
// Notifications by type and status.
$notifications_gauge = $collector->register_gauge(
'fedistream_notifications_total',
'Notifications by type and status',
array( 'type', 'status' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$notifications = $wpdb->get_results(
"SELECT type, is_read, COUNT(*) as count FROM {$notifications_table} GROUP BY type, is_read"
);
foreach ( $notifications as $row ) {
$status = $row->is_read ? 'read' : 'unread';
$notifications_gauge->set( (int) $row->count, array( $row->type, $status ) );
}
// Pending notifications (simple gauge).
$pending_gauge = $collector->register_gauge(
'fedistream_notifications_pending',
'Total unread notifications',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$pending = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$notifications_table} WHERE is_read = 0"
);
$pending_gauge->set( $pending, array() );
}
/**
* Collect WooCommerce metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_woocommerce_metrics( $collector ): void {
global $wpdb;
$purchases_table = $wpdb->prefix . 'fedistream_purchases';
// Purchases by type.
$purchases_gauge = $collector->register_gauge(
'fedistream_purchases_total',
'Total purchases by content type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$purchases = $wpdb->get_results(
"SELECT content_type, COUNT(*) as count FROM {$purchases_table} GROUP BY content_type"
);
foreach ( $purchases as $row ) {
$purchases_gauge->set( (int) $row->count, array( $row->content_type ) );
}
// Unique customers.
$customers_gauge = $collector->register_gauge(
'fedistream_customers_total',
'Unique customers with purchases',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$customers = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$purchases_table}"
);
$customers_gauge->set( $customers, array() );
// WooCommerce products count (FediStream types).
if ( function_exists( 'wc_get_products' ) ) {
$products_gauge = $collector->register_gauge(
'fedistream_products_total',
'FediStream products in WooCommerce',
array( 'type' )
);
foreach ( array( 'fedistream_album', 'fedistream_track' ) as $type ) {
$products = wc_get_products(
array(
'type' => $type,
'limit' => -1,
'return' => 'ids',
)
);
$products_gauge->set( count( $products ), array( $type ) );
}
}
}
/**
* Collect ActivityPub metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_activitypub_metrics( $collector ): void {
global $wpdb;
$followers_table = $wpdb->prefix . 'fedistream_followers';
$reactions_table = $wpdb->prefix . 'fedistream_reactions';
// Total ActivityPub followers.
$followers_gauge = $collector->register_gauge(
'fedistream_activitypub_followers_total',
'Total ActivityPub followers across all artists',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_followers = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$followers_table}" );
$followers_gauge->set( $total_followers, array() );
// Followers by artist (top 10 to avoid cardinality explosion).
$followers_by_artist_gauge = $collector->register_gauge(
'fedistream_activitypub_followers_by_artist',
'Followers per artist',
array( 'artist_id', 'artist_name' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$followers_by_artist = $wpdb->get_results(
"SELECT artist_id, COUNT(*) as count FROM {$followers_table} GROUP BY artist_id ORDER BY count DESC LIMIT 10"
);
foreach ( $followers_by_artist as $row ) {
$artist = get_post( $row->artist_id );
$artist_name = $artist ? $artist->post_title : 'Unknown';
$followers_by_artist_gauge->set( (int) $row->count, array( $row->artist_id, $artist_name ) );
}
// Reactions by type (if table exists).
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$table_exists = $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $reactions_table )
);
if ( $table_exists ) {
$reactions_gauge = $collector->register_gauge(
'fedistream_activitypub_reactions_total',
'Fediverse reactions by type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$reactions = $wpdb->get_results(
"SELECT reaction_type, COUNT(*) as count FROM {$reactions_table} GROUP BY reaction_type"
);
foreach ( $reactions as $row ) {
$reactions_gauge->set( (int) $row->count, array( $row->reaction_type ) );
}
}
}
/**
* Check if WooCommerce integration is enabled.
*
* @return bool
*/
private function is_woocommerce_enabled(): bool {
return get_option( 'wp_fedistream_enable_woocommerce', 0 )
&& class_exists( 'WooCommerce' );
}
/**
* Check if ActivityPub integration is enabled.
*
* @return bool
*/
private function is_activitypub_enabled(): bool {
return (bool) get_option( 'wp_fedistream_enable_activitypub', 1 );
}
}

View File

@@ -0,0 +1 @@
<?php // Silence is golden.

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP FediStream * Plugin Name: WP FediStream
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-fedistream * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-fedistream
* Description: Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels. * Description: Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels.
* Version: 0.4.0 * Version: 0.5.0
* Requires at least: 6.4 * Requires at least: 6.4
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* *
* @var string * @var string
*/ */
define( 'WP_FEDISTREAM_VERSION', '0.4.0' ); define( 'WP_FEDISTREAM_VERSION', '0.5.0' );
/** /**
* Plugin file path. * Plugin file path.