10 Commits

Author SHA1 Message Date
a93381dce6 Bump version to 0.3.8
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:34:38 +01:00
a522455a0a Fix duplicate translation string causing sprintf error
Removed duplicated German translation text that had two %s placeholders
causing ArgumentCountError in settings page. Updated composer.lock with
latest client library (64d215c).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:34:06 +01:00
2de6abe133 Update CLAUDE.md with v0.3.7 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:21:49 +01:00
8d60758f23 Add release package v0.3.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:19:53 +01:00
82bec621c6 Bump version to 0.3.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:18:33 +01:00
034593f896 Dashboard widget improvements and download counter feature (v0.3.7)
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug
- Fixed: Download links in customer account resulted in 404 errors
- Removed: Redundant "Status Breakdown" section from dashboard widget
- Changed: License Types section now uses card style layout
- Added: Download counter for licensed product versions
- Added: Download Statistics admin dashboard widget
- Updated translations (356 strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:17:46 +01:00
202f8a6dc0 Update composer.lock with latest client library
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:23:27 +01:00
36b51c9fc8 Update CLAUDE.md with v0.3.6 session history
- Document security hardening changes (CSRF, IP spoofing, XSS)
- Add recursive key sorting fix for response signing
- Document trusted proxy configuration
- Add release information (SHA256, package size)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:22:26 +01:00
d0aaf3180f Merge branch 'main' into dev 2026-01-23 21:21:25 +01:00
4e683e2ff4 Update CLAUDE.md roadmap after v0.3.5 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:24:37 +01:00
16 changed files with 1898 additions and 1640 deletions

View File

@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.8] - 2026-01-24
### Fixed
- Fixed duplicate German translation string causing `ArgumentCountError` in settings page
- The notification settings description had duplicated text with two `%s` placeholders
### Changed
- Updated `magdev/wc-licensed-product-client` to latest version (64d215c)
## [0.3.7] - 2026-01-24
### Added
- Download counter for licensed product versions (tracked per version)
- Download Statistics admin dashboard widget showing total downloads, top products, and top versions
- New `DownloadWidgetController` class for download statistics widget
- New `incrementDownloadCount()`, `getTotalDownloadCount()`, and `getDownloadStatistics()` methods in `VersionManager`
- New `download_count` column in product versions database table
### Fixed
- Dashboard widget "View All Licenses" link now uses correct page slug (`wc-licenses`)
- Download links in customer account page no longer result in 404 errors (added query var registration)
- Added `license-download` endpoint registration during plugin activation
### Changed
- Removed redundant "Status Breakdown" section from dashboard widget (info already shown in stat cards)
- License Types section in dashboard widget now uses card style matching the stats row above
- Improved dashboard widget visual consistency
### Technical Details
- Added `addDownloadQueryVar()` method to `DownloadController` for proper endpoint registration
- Updated `Installer::activate()` to register `license-download` endpoint before flushing rewrite rules
- Updated translations (356 strings)
## [0.3.6] - 2026-01-23 ## [0.3.6] - 2026-01-23
### Security ### Security

119
CLAUDE.md
View File

@@ -36,9 +36,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
No known bugs at the moment. No known bugs at the moment.
### Version 0.3.8
No changes at the moment.
### Version 0.4.0 ### Version 0.4.0
- On first plugin activation, get the checksums of all security related files (at least in `src/`) as hashes, store them encrypted on the server and add a mechanism to check the integrity of the files and the license validity periodically, control via wp-cron. No changes at the moment.
## Technical Stack ## Technical Stack
@@ -974,3 +978,116 @@ Added admin dashboard widget for license statistics and automatic license expira
- `autoExpireLicense()` updates status to expired and returns true if changed - `autoExpireLicense()` updates status to expired and returns true if changed
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired) - LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
- Expired notification tracked via user meta to prevent duplicate emails - Expired notification tracked via user meta to prevent duplicate emails
### 2026-01-23 - Version 0.3.6 - Security Hardening
**Overview:**
Security audit and implementation alignment with client/server documentation. Fixed response signing compatibility, rate limiting security, and XSS prevention.
**Security Fixes:**
- Added CSRF protection (nonce verification) to CSV export functionality
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
- Enabled explicit Twig autoescape (`'html'`) for XSS protection
- Fixed unescaped status values in CSS class names in Twig templates
**Implementation Fixes:**
- Fixed response signing to use recursive key sorting for client library compatibility
- ResponseSigner now recursively sorts nested array keys alphabetically as required by `magdev/wc-licensed-product-client`
**Modified files:**
- `src/Api/ResponseSigner.php` - Added `recursiveKeySort()` method for proper signature generation
- `src/Api/RestApiController.php` - Added trusted proxy support with `isTrustedProxy()`, `isCloudflareIp()`, `ipMatchesCidr()` methods
- `src/Plugin.php` - Added explicit `autoescape => 'html'` to Twig environment
- `src/Admin/AdminController.php` - Added nonce verification to `handleCsvExport()`, added `export_csv_url()` Twig function
- `templates/frontend/licenses.html.twig` - Added `esc_attr()` for CSS class status
- `templates/admin/licenses.html.twig` - Added `esc_attr()` for CSS class status, updated export link to use `export_csv_url()`
**Configuration:**
To enable trusted proxy support for rate limiting, add to `wp-config.php`:
```php
// For Cloudflare
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
// Or for specific IPs/CIDR ranges
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
```
**Technical notes:**
- Rate limiting now only trusts proxy headers (`HTTP_CF_CONNECTING_IP`, `HTTP_X_FORWARDED_FOR`, `HTTP_X_REAL_IP`) when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
- Without trusted proxy configuration, rate limiting uses `REMOTE_ADDR` only (prevents IP spoofing)
- Cloudflare IP ranges are hardcoded for convenience (as of 2024)
- CIDR notation supported for custom proxy ranges
- Recursive key sorting ensures signature compatibility with SecureLicenseClient
- References: <https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/raw/branch/main/docs/server-implementation.md>
**Release v0.3.6:**
- Created release package: `releases/wc-licensed-product-0.3.6.zip` (818 KB)
- SHA256: `b0063f0312759f090e12faba83de730baf4114139d763e46fad2b781d4b38270`
- Tagged as `v0.3.6` and pushed to `main` branch
### 2026-01-24 - Version 0.3.7 - Dashboard Improvements & Download Counter
**Overview:**
Fixed dashboard widget bugs, improved UI consistency, and added download tracking functionality with a new statistics widget.
**Bug Fixes:**
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug (`wc-licensed-product-licenses` instead of `wc-licenses`)
- Fixed: Download links in customer account resulted in 404 errors due to missing query var registration
- Added `license-download` endpoint registration during plugin activation in `Installer::activate()`
- Added `addDownloadQueryVar()` method to `DownloadController` for proper WordPress endpoint recognition
**UI Improvements:**
- Removed redundant "Status Breakdown" section from license statistics widget (info already shown in stat cards above)
- Changed License Types section to use card-style layout matching the stats row above
- Cleaned up unused CSS for status badges
**New Features:**
- Download counter for licensed product versions (tracked per version in database)
- New Download Statistics admin dashboard widget showing:
- Total downloads count
- Top 5 products by downloads
- Top 5 versions by downloads
**New files:**
- `src/Admin/DownloadWidgetController.php` - Dashboard widget for download statistics
**New methods in VersionManager:**
- `incrementDownloadCount()` - Atomically increment download count for a version
- `getTotalDownloadCount()` - Get total downloads across all versions
- `getDownloadStatistics()` - Get download stats grouped by product and version
**Modified files:**
- `src/Installer.php` - Added `download_count` column to versions table, added `license-download` endpoint registration
- `src/Product/ProductVersion.php` - Added `downloadCount` property and `getDownloadCount()` method
- `src/Product/VersionManager.php` - Added download counting methods
- `src/Frontend/DownloadController.php` - Added query var registration, increment download count on file serve
- `src/Admin/DashboardWidgetController.php` - Fixed URL, removed Status Breakdown, changed License Types to cards
- `src/Plugin.php` - Added DownloadWidgetController instantiation
**Technical notes:**
- Download count is incremented atomically using SQL `download_count = download_count + 1`
- Statistics queries use SQL aggregation with product name enrichment via `wc_get_product()`
- WordPress endpoints require both `add_rewrite_endpoint()` AND `query_vars` filter registration
- Existing installations need to flush rewrite rules (Settings > Permalinks > Save) or reactivate plugin
**Release v0.3.7:**
- Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB)
- SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6`
- Tagged as `v0.3.7` and pushed to `main` branch

16
composer.lock generated
View File

@@ -12,7 +12,7 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git", "url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
"reference": "a3a957914fd6ef74cb479e213d1d3bc0606f496b" "reference": "64d215cb265a64ff318cfbb954dd128b0076dc1d"
}, },
"require": { "require": {
"php": "^8.3", "php": "^8.3",
@@ -52,7 +52,7 @@
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues", "issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client" "source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
}, },
"time": "2026-01-22T20:05:48+00:00" "time": "2026-01-24T13:32:11+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -894,16 +894,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.22.2", "version": "v3.23.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -957,7 +957,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.22.2" "source": "https://github.com/twigphp/Twig/tree/v3.23.0"
}, },
"funding": [ "funding": [
{ {
@@ -969,7 +969,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-14T11:28:47+00:00" "time": "2026-01-23T21:00:41+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1 @@
e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6 wc-licensed-product-0.3.7.zip

View File

@@ -55,7 +55,7 @@ final class DashboardWidgetController
public function renderWidget(): void public function renderWidget(): void
{ {
$stats = $this->licenseManager->getStatistics(); $stats = $this->licenseManager->getStatistics();
$licensesUrl = admin_url('admin.php?page=wc-licensed-product-licenses'); $licensesUrl = admin_url('admin.php?page=wc-licenses');
?> ?>
<style> <style>
.wclp-widget-stats { .wclp-widget-stats {
@@ -96,40 +96,6 @@ final class DashboardWidgetController
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-top: 4px; margin-top: 4px;
} }
.wclp-widget-divider {
border-top: 1px solid #e2e4e7;
margin: 16px 0;
}
.wclp-status-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wclp-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.wclp-status-badge.active {
background: #d4edda;
color: #155724;
}
.wclp-status-badge.inactive {
background: #e2e3e5;
color: #383d41;
}
.wclp-status-badge.expired {
background: #f8d7da;
color: #721c24;
}
.wclp-status-badge.revoked {
background: #d6d8db;
color: #1b1e21;
}
.wclp-widget-footer { .wclp-widget-footer {
margin-top: 16px; margin-top: 16px;
padding-top: 12px; padding-top: 12px;
@@ -160,60 +126,16 @@ final class DashboardWidgetController
</div> </div>
</div> </div>
<div class="wclp-widget-divider"></div> <div class="wclp-widget-stats">
<div class="wclp-stat-card">
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;"> <div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring'])); ?></div>
<?php esc_html_e('Status Breakdown', 'wc-licensed-product'); ?> <div class="wclp-stat-label"><?php esc_html_e('Time-limited', 'wc-licensed-product'); ?></div>
</h4> </div>
<div class="wclp-status-list"> <div class="wclp-stat-card">
<span class="wclp-status-badge active"> <div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['lifetime'])); ?></div>
<span class="dashicons dashicons-yes-alt" style="font-size: 14px; width: 14px; height: 14px;"></span> <div class="wclp-stat-label"><?php esc_html_e('Lifetime', 'wc-licensed-product'); ?></div>
<?php printf( </div>
esc_html__('Active: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_ACTIVE]
); ?>
</span>
<span class="wclp-status-badge inactive">
<span class="dashicons dashicons-marker" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Inactive: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_INACTIVE]
); ?>
</span>
<span class="wclp-status-badge expired">
<span class="dashicons dashicons-clock" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Expired: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_EXPIRED]
); ?>
</span>
<span class="wclp-status-badge revoked">
<span class="dashicons dashicons-dismiss" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Revoked: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_REVOKED]
); ?>
</span>
</div> </div>
<div class="wclp-widget-divider"></div>
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
<?php esc_html_e('License Types', 'wc-licensed-product'); ?>
</h4>
<p style="margin: 0; font-size: 13px; color: #646970;">
<span class="dashicons dashicons-calendar-alt" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
<?php printf(
esc_html__('Time-limited: %d', 'wc-licensed-product'),
$stats['expiring']
); ?>
&nbsp;&nbsp;|&nbsp;&nbsp;
<span class="dashicons dashicons-infinity" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
<?php printf(
esc_html__('Lifetime: %d', 'wc-licensed-product'),
$stats['lifetime']
); ?>
</p>
<div class="wclp-widget-footer"> <div class="wclp-widget-footer">
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary"> <a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">

View File

@@ -0,0 +1,184 @@
<?php
/**
* Download Statistics Widget Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\Product\VersionManager;
/**
* Handles the WordPress admin dashboard widget for download statistics
*/
final class DownloadWidgetController
{
private VersionManager $versionManager;
public function __construct(VersionManager $versionManager)
{
$this->versionManager = $versionManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
}
/**
* Register the dashboard widget
*/
public function registerDashboardWidget(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
wp_add_dashboard_widget(
'wclp_download_statistics',
__('Download Statistics', 'wc-licensed-product'),
[$this, 'renderWidget']
);
}
/**
* Render the dashboard widget content
*/
public function renderWidget(): void
{
$stats = $this->versionManager->getDownloadStatistics();
?>
<style>
.wclp-download-widget-stats {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: 16px;
}
.wclp-download-stat-card {
background: #f8f9fa;
border: 1px solid #e2e4e7;
border-radius: 4px;
padding: 12px;
text-align: center;
border-left: 3px solid #2271b1;
}
.wclp-download-stat-number {
font-size: 32px;
font-weight: 600;
color: #1d2327;
line-height: 1.2;
}
.wclp-download-stat-label {
font-size: 12px;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.wclp-download-list {
margin: 0;
padding: 0;
list-style: none;
}
.wclp-download-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e2e4e7;
}
.wclp-download-list li:last-child {
border-bottom: none;
}
.wclp-download-list .product-name {
font-weight: 500;
color: #1d2327;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 12px;
}
.wclp-download-list .version-info {
font-size: 12px;
color: #646970;
}
.wclp-download-list .download-count {
background: #e7f5ff;
color: #0a4b78;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.wclp-download-section-title {
margin: 16px 0 8px 0;
font-size: 13px;
color: #1d2327;
font-weight: 600;
}
.wclp-no-downloads {
color: #646970;
font-style: italic;
text-align: center;
padding: 12px 0;
}
</style>
<div class="wclp-download-widget-stats">
<div class="wclp-download-stat-card">
<div class="wclp-download-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
<div class="wclp-download-stat-label"><?php esc_html_e('Total Downloads', 'wc-licensed-product'); ?></div>
</div>
</div>
<h4 class="wclp-download-section-title">
<?php esc_html_e('Top Products', 'wc-licensed-product'); ?>
</h4>
<?php if (!empty($stats['by_product'])): ?>
<ul class="wclp-download-list">
<?php foreach (array_slice($stats['by_product'], 0, 5) as $product): ?>
<li>
<span class="product-name"><?php echo esc_html($product['product_name']); ?></span>
<span class="download-count">
<?php echo esc_html(number_format_i18n($product['downloads'])); ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
<?php endif; ?>
<h4 class="wclp-download-section-title">
<?php esc_html_e('Top Versions', 'wc-licensed-product'); ?>
</h4>
<?php if (!empty($stats['by_version'])): ?>
<ul class="wclp-download-list">
<?php foreach (array_slice($stats['by_version'], 0, 5) as $version): ?>
<li>
<span class="product-name">
<?php echo esc_html($version['product_name']); ?>
<span class="version-info">v<?php echo esc_html($version['version']); ?></span>
</span>
<span class="download-count">
<?php echo esc_html(number_format_i18n($version['downloads'])); ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
<?php endif; ?>
<?php
}
}

View File

@@ -35,6 +35,9 @@ final class DownloadController
// Add download endpoint // Add download endpoint
add_action('init', [$this, 'addDownloadEndpoint']); add_action('init', [$this, 'addDownloadEndpoint']);
// Register query var for the endpoint
add_filter('query_vars', [$this, 'addDownloadQueryVar']);
// Handle download requests // Handle download requests
add_action('template_redirect', [$this, 'handleDownloadRequest']); add_action('template_redirect', [$this, 'handleDownloadRequest']);
} }
@@ -47,6 +50,15 @@ final class DownloadController
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES); add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
} }
/**
* Register the download query var
*/
public function addDownloadQueryVar(array $vars): array
{
$vars[] = 'license-download';
return $vars;
}
/** /**
* Handle download request * Handle download request
*/ */
@@ -160,8 +172,12 @@ final class DownloadController
$downloadUrl = $version->getDownloadUrl(); $downloadUrl = $version->getDownloadUrl();
if ($attachmentId) { if ($attachmentId) {
// Increment download count before serving
$this->versionManager->incrementDownloadCount($versionId);
$this->serveAttachment($attachmentId, $version->getVersion()); $this->serveAttachment($attachmentId, $version->getVersion());
} elseif ($downloadUrl) { } elseif ($downloadUrl) {
// Increment download count before redirect
$this->versionManager->incrementDownloadCount($versionId);
// Redirect to external URL // Redirect to external URL
wp_redirect($downloadUrl); wp_redirect($downloadUrl);
exit; exit;

View File

@@ -35,8 +35,9 @@ final class Installer
// Set version in options // Set version in options
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION); update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
// Register the licenses endpoint before flushing rewrite rules // Register endpoints before flushing rewrite rules
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES); add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
// Flush rewrite rules for REST API and My Account endpoints // Flush rewrite rules for REST API and My Account endpoints
flush_rewrite_rules(); flush_rewrite_rules();
@@ -103,6 +104,7 @@ final class Installer
download_url VARCHAR(512) DEFAULT NULL, download_url VARCHAR(512) DEFAULT NULL,
attachment_id BIGINT UNSIGNED DEFAULT NULL, attachment_id BIGINT UNSIGNED DEFAULT NULL,
file_hash VARCHAR(64) DEFAULT NULL, file_hash VARCHAR(64) DEFAULT NULL,
download_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@@ -11,6 +11,7 @@ namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController; use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
use Jeremias\WcLicensedProduct\Admin\DownloadWidgetController;
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
@@ -154,6 +155,7 @@ final class Plugin
new OrderLicenseController($this->licenseManager); new OrderLicenseController($this->licenseManager);
new SettingsController(); new SettingsController();
new DashboardWidgetController($this->licenseManager); new DashboardWidgetController($this->licenseManager);
new DownloadWidgetController($this->versionManager);
// Show admin notice if unlicensed and not on localhost // Show admin notice if unlicensed and not on localhost
if (!$isLicensed && !$licenseChecker->isLocalhost()) { if (!$isLicensed && !$licenseChecker->isLocalhost()) {

View File

@@ -24,6 +24,7 @@ class ProductVersion
private ?string $downloadUrl; private ?string $downloadUrl;
private ?int $attachmentId; private ?int $attachmentId;
private ?string $fileHash; private ?string $fileHash;
private int $downloadCount;
private bool $isActive; private bool $isActive;
private \DateTimeInterface $releasedAt; private \DateTimeInterface $releasedAt;
private \DateTimeInterface $createdAt; private \DateTimeInterface $createdAt;
@@ -44,6 +45,7 @@ class ProductVersion
$version->downloadUrl = $data['download_url'] ?: null; $version->downloadUrl = $data['download_url'] ?: null;
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null; $version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
$version->fileHash = $data['file_hash'] ?? null; $version->fileHash = $data['file_hash'] ?? null;
$version->downloadCount = (int) ($data['download_count'] ?? 0);
$version->isActive = (bool) $data['is_active']; $version->isActive = (bool) $data['is_active'];
$version->releasedAt = new \DateTimeImmutable($data['released_at']); $version->releasedAt = new \DateTimeImmutable($data['released_at']);
$version->createdAt = new \DateTimeImmutable($data['created_at']); $version->createdAt = new \DateTimeImmutable($data['created_at']);
@@ -144,6 +146,11 @@ class ProductVersion
return $this->fileHash; return $this->fileHash;
} }
public function getDownloadCount(): int
{
return $this->downloadCount;
}
/** /**
* Get the download URL from attachment * Get the download URL from attachment
*/ */
@@ -197,6 +204,7 @@ class ProductVersion
'download_url' => $this->downloadUrl, 'download_url' => $this->downloadUrl,
'attachment_id' => $this->attachmentId, 'attachment_id' => $this->attachmentId,
'file_hash' => $this->fileHash, 'file_hash' => $this->fileHash,
'download_count' => $this->downloadCount,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'), 'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'), 'created_at' => $this->createdAt->format('Y-m-d H:i:s'),

View File

@@ -276,4 +276,98 @@ class VersionManager
return (int) $count > 0; return (int) $count > 0;
} }
/**
* Increment download count for a version
*/
public function incrementDownloadCount(int $versionId): bool
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET download_count = download_count + 1 WHERE id = %d",
$versionId
)
);
return $result !== false;
}
/**
* Get total download count across all versions
*/
public function getTotalDownloadCount(): int
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$count = $wpdb->get_var("SELECT COALESCE(SUM(download_count), 0) FROM {$tableName}");
return (int) $count;
}
/**
* Get download statistics per product
*/
public function getDownloadStatistics(): array
{
global $wpdb;
$tableName = Installer::getVersionsTable();
// Get total downloads
$totalDownloads = $this->getTotalDownloadCount();
// Get downloads per product (top 10)
$byProduct = $wpdb->get_results(
"SELECT product_id, SUM(download_count) as downloads
FROM {$tableName}
GROUP BY product_id
ORDER BY downloads DESC
LIMIT 10",
ARRAY_A
);
// Get downloads per version (top 10)
$byVersion = $wpdb->get_results(
"SELECT id, product_id, version, download_count
FROM {$tableName}
WHERE download_count > 0
ORDER BY download_count DESC
LIMIT 10",
ARRAY_A
);
// Enrich product data with names
$productsWithNames = [];
foreach ($byProduct ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$productsWithNames[] = [
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'downloads' => (int) $row['downloads'],
];
}
// Enrich version data with product names
$versionsWithNames = [];
foreach ($byVersion ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$versionsWithNames[] = [
'version_id' => (int) $row['id'],
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'version' => $row['version'],
'downloads' => (int) $row['download_count'],
];
}
return [
'total' => $totalDownloads,
'by_product' => $productsWithNames,
'by_version' => $versionsWithNames,
];
}
} }

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.3.6 * Version: 0.3.8
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.3.6'); define('WC_LICENSED_PRODUCT_VERSION', '0.3.8');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));