DX audit and fix (PHP) #9
66 changed files with 546 additions and 169 deletions
66
.forgejo/workflows/ci.yml
Normal file
66
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: lthn/build:php-${{ matrix.php }}
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: ["8.3", "8.4"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Clone sister packages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for pkg in php-framework php-tenant; do
|
||||
echo "Cloning $pkg into ../$pkg"
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/${pkg}.git" \
|
||||
../$pkg
|
||||
done
|
||||
ls -la ../php-framework/composer.json ../php-tenant/composer.json
|
||||
|
||||
- name: Configure path repositories
|
||||
run: |
|
||||
composer config repositories.core path ../php-framework --no-interaction
|
||||
composer config repositories.tenant path ../php-tenant --no-interaction
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Run Pint
|
||||
run: |
|
||||
if [ -f vendor/bin/pint ]; then
|
||||
vendor/bin/pint --test
|
||||
else
|
||||
echo "Pint not installed, skipping"
|
||||
fi
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
if [ -f vendor/bin/pest ]; then
|
||||
if [ -d tests/Unit ] || [ -d tests/unit ]; then
|
||||
vendor/bin/pest tests/Unit --ci
|
||||
elif [ -d src/Tests/Unit ]; then
|
||||
vendor/bin/pest src/Tests/Unit --ci
|
||||
else
|
||||
echo "No unit test directory found, skipping"
|
||||
fi
|
||||
elif [ -f vendor/bin/phpunit ]; then
|
||||
vendor/bin/phpunit --testsuite=Unit
|
||||
else
|
||||
echo "No test runner found, skipping"
|
||||
fi
|
||||
38
.forgejo/workflows/release.yml
Normal file
38
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Publish Composer Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create package archive
|
||||
run: |
|
||||
apt-get update && apt-get install -y zip
|
||||
zip -r package.zip . \
|
||||
-x ".forgejo/*" \
|
||||
-x ".git/*" \
|
||||
-x "tests/*" \
|
||||
-x "docker/*" \
|
||||
-x "*.yaml" \
|
||||
-x "infection.json5" \
|
||||
-x "phpstan.neon" \
|
||||
-x "phpunit.xml" \
|
||||
-x "psalm.xml" \
|
||||
-x "rector.php" \
|
||||
-x "TODO.md" \
|
||||
-x "ROADMAP.md" \
|
||||
-x "CONTRIBUTING.md" \
|
||||
-x "package.json" \
|
||||
-x "package-lock.json"
|
||||
|
||||
- name: Publish to Forgejo Composer registry
|
||||
run: |
|
||||
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
|
||||
--upload-file package.zip \
|
||||
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ yarn-error.log
|
|||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
.core/
|
||||
.idea/
|
||||
|
|
|
|||
10
Boot.php
10
Boot.php
|
|
@ -8,11 +8,11 @@ use Core\Events\ApiRoutesRegistering;
|
|||
use Core\Events\ConsoleBooting;
|
||||
use Core\Events\McpToolsRegistering;
|
||||
use Core\Events\WebRoutesRegistering;
|
||||
use Core\Mod\Content\Services\HtmlSanitiser;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Core\Mod\Content\Services\HtmlSanitiser;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
|
|
@ -149,8 +149,8 @@ class Boot extends ServiceProvider
|
|||
$event->livewire('content.admin.webhook-manager', View\Modal\Admin\WebhookManager::class);
|
||||
$event->livewire('content.admin.content-search', View\Modal\Admin\ContentSearch::class);
|
||||
|
||||
if (file_exists(__DIR__.'/Routes/web.php')) {
|
||||
$event->routes(fn () => require __DIR__.'/Routes/web.php');
|
||||
if (file_exists(__DIR__.'/routes/web.php')) {
|
||||
$event->routes(fn () => require __DIR__.'/routes/web.php');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,8 +159,8 @@ class Boot extends ServiceProvider
|
|||
*/
|
||||
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||
{
|
||||
if (file_exists(__DIR__.'/Routes/api.php')) {
|
||||
$event->routes(fn () => require __DIR__.'/Routes/api.php');
|
||||
if (file_exists(__DIR__.'/routes/api.php')) {
|
||||
$event->routes(fn () => require __DIR__.'/routes/api.php');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
102
CLAUDE.md
102
CLAUDE.md
|
|
@ -4,9 +4,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Package Overview
|
||||
|
||||
This is `host-uk/core-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search.
|
||||
This is `lthn/php-content`, a Laravel package providing headless CMS functionality for the Core PHP Framework. It handles content management, AI generation, revisions, webhooks, and search.
|
||||
|
||||
**Namespace:** `Core\Mod\Content\`
|
||||
**Entry point:** `Boot.php` (extends `ServiceProvider`, uses event-driven registration)
|
||||
**Dependencies:** `lthn/php` (core framework), `ezyang/htmlpurifier` (required, security-critical)
|
||||
**Optional:** `core-tenant` (workspaces/users), `core-agentic` (AI services), `core-mcp` (MCP tool registration)
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -19,9 +22,9 @@ composer run test # Pest tests
|
|||
|
||||
## Architecture
|
||||
|
||||
### Boot.php (Service Provider)
|
||||
### Event-Driven Boot
|
||||
|
||||
The entry point extending `ServiceProvider` with event-driven registration:
|
||||
`Boot.php` registers lazily via event listeners — routes, commands, views, and MCP tools are only loaded when their events fire:
|
||||
|
||||
```php
|
||||
public static array $listens = [
|
||||
|
|
@ -32,66 +35,73 @@ public static array $listens = [
|
|||
];
|
||||
```
|
||||
|
||||
### Package Structure
|
||||
### AI Generation Pipeline (Two-Stage)
|
||||
|
||||
```
|
||||
Boot.php # Service provider + event listeners
|
||||
config.php # Package configuration
|
||||
Models/ # Eloquent: ContentItem, ContentBrief, ContentRevision, etc.
|
||||
Services/ # Business logic: ContentSearchService, ContentRender, etc.
|
||||
Controllers/Api/ # REST API controllers
|
||||
Mcp/Handlers/ # MCP tools for AI agent integration
|
||||
Jobs/ # Queue jobs: GenerateContentJob, ProcessContentWebhook
|
||||
View/Modal/ # Livewire components (Web/ and Admin/)
|
||||
View/Blade/ # Blade templates
|
||||
Migrations/ # Database migrations
|
||||
routes/ # web.php, api.php, console.php
|
||||
```
|
||||
`AIGatewayService` orchestrates a two-stage content generation pipeline:
|
||||
1. **Stage 1 (Gemini):** Fast, cost-effective draft generation
|
||||
2. **Stage 2 (Claude):** Quality refinement and brand voice alignment
|
||||
|
||||
### Key Models
|
||||
Brief workflow: `pending` → `queued` → `generating` → `review` → `published`
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `ContentItem` | Published content with revisions |
|
||||
| `ContentBrief` | Content generation requests/queue |
|
||||
| `ContentRevision` | Version history for content items |
|
||||
| `ContentMedia` | Attached media files |
|
||||
| `ContentTaxonomy` | Categories and tags |
|
||||
| `ContentWebhookEndpoint` | External webhook configurations |
|
||||
### Dual API Authentication
|
||||
|
||||
### API Routes (routes/api.php)
|
||||
All `/api/content/*` endpoints are registered twice in `routes/api.php`:
|
||||
- Session auth (`middleware('auth')`)
|
||||
- API key auth (`middleware(['api.auth', 'api.scope.enforce'])`) — uses `Authorization: Bearer hk_xxx`
|
||||
|
||||
- `/api/content/briefs` - Content brief CRUD
|
||||
- `/api/content/generate/*` - AI generation (rate limited)
|
||||
- `/api/content/media` - Media management
|
||||
- `/api/content/search` - Full-text search
|
||||
- `/api/content/webhooks/{endpoint}` - External webhooks (no auth, signature verified)
|
||||
Webhook endpoints are public (no auth, signature-verified via HMAC).
|
||||
|
||||
### Livewire Component Paths
|
||||
|
||||
Livewire components live in `View/Modal/` (not the typical `Livewire/` directory):
|
||||
- `View/Modal/Web/` — public-facing (Blog, Post, Help, HelpArticle, Preview)
|
||||
- `View/Modal/Admin/` — admin (WebhookManager, ContentSearch)
|
||||
|
||||
Blade templates are in `View/Blade/`, registered with the `content` view namespace.
|
||||
|
||||
### Search Backends
|
||||
|
||||
`ContentSearchService` supports three backends via `CONTENT_SEARCH_BACKEND`:
|
||||
- `database` (default) — LIKE queries with relevance scoring (title > slug > excerpt > content)
|
||||
- `scout_database` — Laravel Scout with database driver
|
||||
- `meilisearch` — Laravel Scout with Meilisearch
|
||||
|
||||
## Conventions
|
||||
|
||||
- UK English (colour, organisation, centre)
|
||||
- UK English (colour, organisation, centre, behaviour)
|
||||
- `declare(strict_types=1);` in all PHP files
|
||||
- Type hints on all parameters and return types
|
||||
- Final classes by default unless inheritance is intended
|
||||
- Pest for testing (not PHPUnit syntax)
|
||||
- Livewire + Flux Pro for UI components
|
||||
- Livewire + Flux Pro for UI components (not vanilla Alpine)
|
||||
- Font Awesome Pro for icons (not Heroicons)
|
||||
|
||||
## Rate Limiters
|
||||
### Naming
|
||||
|
||||
Defined in `Boot::configureRateLimiting()`:
|
||||
- `content-generate` - AI generation (10/min authenticated)
|
||||
- `content-briefs` - Brief creation (30/min)
|
||||
- `content-webhooks` - Incoming webhooks (60/min per endpoint)
|
||||
- `content-search` - Search queries (configurable, default 60/min)
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Model | Singular PascalCase | `ContentItem` |
|
||||
| Table | Plural snake_case | `content_items` |
|
||||
| Controller | `{Model}Controller` | `ContentBriefController` |
|
||||
| Livewire Page | `{Feature}Page` | `ProductListPage` |
|
||||
| Livewire Modal | `{Feature}Modal` | `EditProductModal` |
|
||||
|
||||
### Don'ts
|
||||
|
||||
- Don't create controllers for Livewire pages
|
||||
- Don't use Heroicons (use Font Awesome Pro)
|
||||
- Don't use vanilla Alpine components (use Flux Pro)
|
||||
- Don't use American English spellings
|
||||
|
||||
## Configuration (config.php)
|
||||
|
||||
Key settings exposed via environment:
|
||||
- `CONTENT_GENERATION_TIMEOUT` - AI generation timeout
|
||||
- `CONTENT_MAX_REVISIONS` - Revision limit per item
|
||||
- `CONTENT_SEARCH_BACKEND` - Search driver (database, scout_database, meilisearch)
|
||||
- `CONTENT_CACHE_TTL` - Content cache duration
|
||||
Key environment variables:
|
||||
- `CONTENT_GENERATION_TIMEOUT` — AI generation timeout (default 300s)
|
||||
- `CONTENT_MAX_REVISIONS` — Revision limit per item (default 50)
|
||||
- `CONTENT_SEARCH_BACKEND` — Search driver (database, scout_database, meilisearch)
|
||||
- `CONTENT_CACHE_TTL` — Content cache duration (default 3600s)
|
||||
- `CONTENT_SEARCH_RATE_LIMIT` — Search rate limit per minute (default 60)
|
||||
|
||||
## License
|
||||
|
||||
EUPL-1.2 (European Union Public Licence)
|
||||
EUPL-1.2 (European Union Public Licence)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentAuthor;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Console\Commands;
|
||||
|
||||
use Core\Mod\Content\Services\WebhookRetryService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Services\WebhookRetryService;
|
||||
|
||||
/**
|
||||
* ProcessPendingWebhooks
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Prune old content revisions based on retention policy.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Console\Commands;
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
|
||||
/**
|
||||
* PublishScheduledContent
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Concerns\ResolvesWorkspace;
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Models\ContentBrief;
|
||||
use Core\Mod\Content\Resources\ContentBriefResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Content Brief API Controller
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Concerns\ResolvesWorkspace;
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
|
||||
/**
|
||||
* Content Media API Controller
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Concerns\ResolvesWorkspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
use Core\Mod\Content\Resources\ContentRevisionResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Content Revision API Controller
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Concerns\ResolvesWorkspace;
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
|
||||
/**
|
||||
* Content Search API Controller
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Mod\Content\Jobs\ProcessContentWebhook;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Services\WebhookDeliveryLogger;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Jobs\ProcessContentWebhook;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Core\Mod\Content\Services\WebhookDeliveryLogger;
|
||||
|
||||
/**
|
||||
* Controller for receiving external content webhooks.
|
||||
|
|
@ -29,8 +28,7 @@ class ContentWebhookController extends Controller
|
|||
{
|
||||
public function __construct(
|
||||
protected WebhookDeliveryLogger $deliveryLogger
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Receive a webhook from an external source.
|
||||
|
|
@ -295,5 +293,4 @@ class ContentWebhookController extends Controller
|
|||
|
||||
return 'wordpress.post_updated';
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Api\Concerns\HasApiResponses;
|
||||
use Core\Api\Concerns\ResolvesWorkspace;
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Jobs\GenerateContentJob;
|
||||
use Core\Mod\Content\Models\AIUsage;
|
||||
use Core\Mod\Content\Models\ContentBrief;
|
||||
use Core\Mod\Content\Resources\ContentBriefResource;
|
||||
use Core\Mod\Content\Services\AIGatewayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Content Generation API Controller
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Controllers;
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
|
||||
/**
|
||||
* ContentPreviewController - Preview draft content before publishing.
|
||||
|
|
|
|||
24
Database/Factories/ContentBriefFactory.php
Normal file
24
Database/Factories/ContentBriefFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Content\Database\Factories;
|
||||
|
||||
use Core\Mod\Content\Models\ContentBrief;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ContentBriefFactory extends Factory
|
||||
{
|
||||
protected $model = ContentBrief::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'title' => $this->faker->sentence(),
|
||||
'description' => $this->faker->paragraph(),
|
||||
'status' => ContentBrief::STATUS_PENDING,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
Database/Factories/ContentItemFactory.php
Normal file
29
Database/Factories/ContentItemFactory.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Content\Database\Factories;
|
||||
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ContentItemFactory extends Factory
|
||||
{
|
||||
protected $model = ContentItem::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'content_type' => ContentType::NATIVE->value,
|
||||
'type' => 'post',
|
||||
'status' => 'publish',
|
||||
'slug' => $this->faker->slug(),
|
||||
'title' => $this->faker->sentence(),
|
||||
'excerpt' => $this->faker->paragraph(),
|
||||
'content_html' => '<p>'.$this->faker->paragraphs(3, true).'</p>',
|
||||
];
|
||||
}
|
||||
}
|
||||
25
Database/Factories/ContentTaxonomyFactory.php
Normal file
25
Database/Factories/ContentTaxonomyFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Content\Database\Factories;
|
||||
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ContentTaxonomyFactory extends Factory
|
||||
{
|
||||
protected $model = ContentTaxonomy::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'type' => $this->faker->randomElement(['category', 'tag']),
|
||||
'name' => $this->faker->word(),
|
||||
'slug' => $this->faker->slug(),
|
||||
'count' => $this->faker->numberBetween(0, 100),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
Database/Factories/ContentWebhookEndpointFactory.php
Normal file
42
Database/Factories/ContentWebhookEndpointFactory.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Content\Database\Factories;
|
||||
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ContentWebhookEndpointFactory extends Factory
|
||||
{
|
||||
protected $model = ContentWebhookEndpoint::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => $this->faker->company(),
|
||||
'secret' => $this->faker->sha256(),
|
||||
'require_signature' => true,
|
||||
'allowed_types' => [],
|
||||
'is_enabled' => true,
|
||||
'failure_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function circuitBroken(): static
|
||||
{
|
||||
return $this->state([
|
||||
'failure_count' => ContentWebhookEndpoint::MAX_FAILURES,
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state([
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
31
Database/Factories/ContentWebhookLogFactory.php
Normal file
31
Database/Factories/ContentWebhookLogFactory.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Content\Database\Factories;
|
||||
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ContentWebhookLogFactory extends Factory
|
||||
{
|
||||
protected $model = ContentWebhookLog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'endpoint_id' => ContentWebhookEndpoint::factory(),
|
||||
'event_type' => $this->faker->randomElement([
|
||||
'wordpress.post_created',
|
||||
'wordpress.post_updated',
|
||||
'cms.content_created',
|
||||
]),
|
||||
'payload' => ['title' => $this->faker->sentence()],
|
||||
'status' => 'pending',
|
||||
'source_ip' => $this->faker->ipv4(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Jobs;
|
||||
|
||||
use Core\Mod\Content\Models\ContentBrief;
|
||||
use Core\Mod\Content\Services\AIGatewayService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentBrief;
|
||||
use Core\Mod\Content\Services\AIGatewayService;
|
||||
|
||||
/**
|
||||
* GenerateContentJob
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Process incoming content webhooks.
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
use Carbon\Carbon;
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* MCP tool handler for creating content items.
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* MCP tool handler for deleting content items.
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
|
||||
/**
|
||||
* MCP tool handler for listing content items.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* MCP tool handler for reading content items.
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
|
||||
/**
|
||||
* MCP tool handler for searching content.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* MCP tool handler for listing content taxonomies.
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ namespace Core\Mod\Content\Mcp\Handlers;
|
|||
use Carbon\Carbon;
|
||||
use Core\Front\Mcp\Contracts\McpToolHandler;
|
||||
use Core\Front\Mcp\McpContext;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* MCP tool handler for updating content items.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Content\Services\ContentRender;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Models;
|
||||
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* AIUsage Model
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Models;
|
||||
|
||||
use Core\Mod\Content\Enums\BriefContentType;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Core\Mod\Content\Enums\BriefContentType;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* ContentBrief Model
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Models;
|
||||
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Observers\ContentItemObserver;
|
||||
use Core\Mod\Content\Services\HtmlSanitiser;
|
||||
use Core\Seo\HasSeoMetadata;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Seo\HasSeoMetadata;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -14,9 +17,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Observers\ContentItemObserver;
|
||||
use Core\Mod\Content\Services\HtmlSanitiser;
|
||||
|
||||
#[ObservedBy([ContentItemObserver::class])]
|
||||
class ContentItem extends Model
|
||||
|
|
|
|||
|
|
@ -377,14 +377,14 @@ class ContentWebhookEndpoint extends Model
|
|||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
if (! $this->is_enabled) {
|
||||
return 'zinc';
|
||||
}
|
||||
|
||||
if ($this->isCircuitBroken()) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (! $this->is_enabled) {
|
||||
return 'zinc';
|
||||
}
|
||||
|
||||
if ($this->failure_count > 0) {
|
||||
return 'yellow';
|
||||
}
|
||||
|
|
@ -397,14 +397,14 @@ class ContentWebhookEndpoint extends Model
|
|||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
if (! $this->is_enabled) {
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
if ($this->isCircuitBroken()) {
|
||||
return 'Circuit Open';
|
||||
}
|
||||
|
||||
if (! $this->is_enabled) {
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
if ($this->failure_count > 0) {
|
||||
return "Active ({$this->failure_count} failures)";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Observers;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Services\CdnPurgeService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Content Item Observer - handles CDN cache purging on content changes.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plug\Cdn\CdnManager;
|
||||
use Plug\Response;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Content\Services;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* ContentRender - Public workspace frontend renderer.
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Content\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
|
||||
/**
|
||||
* Content Search Service
|
||||
|
|
|
|||
|
|
@ -97,13 +97,8 @@ class HtmlSanitiser
|
|||
$config->set('HTML.Nofollow', true);
|
||||
$config->set('HTML.TargetNoopener', true);
|
||||
|
||||
// Disable cache in development, enable via config in production
|
||||
$cacheDir = config('content.purifier_cache_dir');
|
||||
if ($cacheDir && is_dir($cacheDir) && is_writable($cacheDir)) {
|
||||
$config->set('Cache.SerializerPath', $cacheDir);
|
||||
} else {
|
||||
$config->set('Cache.DefinitionImpl', null);
|
||||
}
|
||||
// Allow id attributes (disabled by default in HTMLPurifier)
|
||||
$config->set('Attr.EnableID', true);
|
||||
|
||||
// Safe URI schemes only
|
||||
$config->set('URI.AllowedSchemes', [
|
||||
|
|
@ -117,6 +112,22 @@ class HtmlSanitiser
|
|||
$config->set('URI.DisableExternalResources', false);
|
||||
$config->set('URI.DisableResources', false);
|
||||
|
||||
// Disable cache and set definition ID for custom HTML definitions
|
||||
$config->set('Cache.DefinitionImpl', null);
|
||||
$config->set('HTML.DefinitionID', 'core-content-html5');
|
||||
$config->set('HTML.DefinitionRev', 1);
|
||||
|
||||
// Register HTML5 elements that HTMLPurifier doesn't know about
|
||||
// NOTE: maybeGetRawHTMLDefinition() finalizes the config — all
|
||||
// $config->set() calls must come before this point.
|
||||
if ($def = $config->maybeGetRawHTMLDefinition()) {
|
||||
$def->addElement('section', 'Block', 'Flow', 'Common');
|
||||
$def->addElement('article', 'Block', 'Flow', 'Common');
|
||||
$def->addElement('figure', 'Block', 'Flow', 'Common');
|
||||
$def->addElement('figcaption', 'Inline', 'Flow', 'Common');
|
||||
$def->addElement('mark', 'Inline', 'Inline', 'Common');
|
||||
}
|
||||
|
||||
$this->purifier = new HTMLPurifier($config);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* WebhookDeliveryLogger
|
||||
|
|
@ -43,7 +43,6 @@ class WebhookDeliveryLogger
|
|||
* @param int $durationMs Processing duration in milliseconds
|
||||
* @param int|null $responseCode HTTP response code if applicable
|
||||
* @param string|null $responseBody Response body if applicable
|
||||
* @return void
|
||||
*/
|
||||
public function logSuccess(
|
||||
ContentWebhookLog $webhookLog,
|
||||
|
|
@ -74,7 +73,6 @@ class WebhookDeliveryLogger
|
|||
* @param int $durationMs Processing duration in milliseconds
|
||||
* @param int|null $responseCode HTTP response code if applicable
|
||||
* @param string|null $responseBody Response body if applicable
|
||||
* @return void
|
||||
*/
|
||||
public function logFailure(
|
||||
ContentWebhookLog $webhookLog,
|
||||
|
|
@ -128,7 +126,7 @@ class WebhookDeliveryLogger
|
|||
'source_ip' => $request->ip(),
|
||||
'signature_verified' => false,
|
||||
'signature_failure_reason' => $failureReason,
|
||||
'error_message' => 'Signature verification failed: ' . $failureReason,
|
||||
'error_message' => 'Signature verification failed: '.$failureReason,
|
||||
'processed_at' => now(),
|
||||
]);
|
||||
|
||||
|
|
@ -149,7 +147,6 @@ class WebhookDeliveryLogger
|
|||
*
|
||||
* @param ContentWebhookLog $webhookLog The webhook log entry
|
||||
* @param string $verificationMethod How the signature was verified (e.g., 'current_secret', 'grace_period')
|
||||
* @return void
|
||||
*/
|
||||
public function logSignatureSuccess(
|
||||
ContentWebhookLog $webhookLog,
|
||||
|
|
@ -172,7 +169,6 @@ class WebhookDeliveryLogger
|
|||
*
|
||||
* @param Request $request The incoming request
|
||||
* @param ContentWebhookEndpoint $endpoint The webhook endpoint
|
||||
* @return void
|
||||
*/
|
||||
public function logSignatureNotRequired(
|
||||
Request $request,
|
||||
|
|
@ -195,7 +191,6 @@ class WebhookDeliveryLogger
|
|||
* @param array $payload The parsed payload
|
||||
* @param string $eventType The determined event type
|
||||
* @param array $verificationResult The signature verification result
|
||||
* @return ContentWebhookLog
|
||||
*/
|
||||
public function createDeliveryLog(
|
||||
Request $request,
|
||||
|
|
@ -225,7 +220,6 @@ class WebhookDeliveryLogger
|
|||
* @param ContentWebhookLog $webhookLog The webhook log entry
|
||||
* @param int $durationMs Processing duration in milliseconds
|
||||
* @param array|null $result Processing result details
|
||||
* @return void
|
||||
*/
|
||||
public function recordProcessingMetrics(
|
||||
ContentWebhookLog $webhookLog,
|
||||
|
|
@ -341,7 +335,6 @@ class WebhookDeliveryLogger
|
|||
* Extract content ID from payload.
|
||||
*
|
||||
* @param array $data The webhook payload
|
||||
* @return int|null
|
||||
*/
|
||||
protected function extractContentId(array $data): ?int
|
||||
{
|
||||
|
|
@ -364,7 +357,6 @@ class WebhookDeliveryLogger
|
|||
* Extract content type from payload.
|
||||
*
|
||||
* @param array $data The webhook payload
|
||||
* @return string|null
|
||||
*/
|
||||
protected function extractContentType(array $data): ?string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Services;
|
||||
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
|
||||
/**
|
||||
* WebhookRetryService
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
|
||||
/**
|
||||
* Content Search Livewire Component
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
|
||||
/**
|
||||
* Livewire component for managing content webhook endpoints.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Web;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Content\Services\ContentRender;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class Blog extends Component
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Web;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class Help extends Component
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Web;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Content\Services\ContentRender;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class HelpArticle extends Component
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Web;
|
||||
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Content\Services\ContentRender;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Tenant\Services\WorkspaceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class Post extends Component
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\View\Modal\Web;
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Livewire\Component;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
|
||||
/**
|
||||
* Preview - Render draft/unpublished content with preview token.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
{
|
||||
"name": "host-uk/core-content",
|
||||
"name": "lthn/php-content",
|
||||
"description": "Content management and headless CMS for Laravel",
|
||||
"keywords": ["laravel", "content", "cms", "headless"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"content",
|
||||
"cms",
|
||||
"headless"
|
||||
],
|
||||
"license": "EUPL-1.2",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"host-uk/core": "dev-main",
|
||||
"lthn/php": "*",
|
||||
"ezyang/htmlpurifier": "^4.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.18",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0"
|
||||
"pestphp/pest": "^3.0",
|
||||
"core/php-tenant": "@dev"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -20,7 +26,8 @@
|
|||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Core\\Mod\\Content\\Tests\\": "Tests/"
|
||||
"Core\\Mod\\Content\\Tests\\": "Tests/",
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
|
|
@ -41,5 +48,15 @@
|
|||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"repositories": [
|
||||
{
|
||||
"name": "core",
|
||||
"type": "path",
|
||||
"url": "../php-framework"
|
||||
}
|
||||
],
|
||||
"replace": {
|
||||
"core/php-content": "self.version"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
phpunit.xml
Normal file
39
phpunit.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="random"
|
||||
requireCoverageMetadata="false"
|
||||
beStrictAboutCoverageMetadata="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_DEBUG" value="true"/>
|
||||
<env name="APP_KEY" value="base64:Kx0qLJZJAQcDSFE2gMpuOlwrJcC6kXHM0j0KJdMGqzQ="/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -9,7 +9,6 @@ declare(strict_types=1);
|
|||
* Supports both session auth and API key auth.
|
||||
*/
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Core\Mod\Content\Controllers\Api\ContentBriefController;
|
||||
use Core\Mod\Content\Controllers\Api\ContentMediaController;
|
||||
use Core\Mod\Content\Controllers\Api\ContentRevisionController;
|
||||
|
|
@ -17,6 +16,7 @@ use Core\Mod\Content\Controllers\Api\ContentSearchController;
|
|||
use Core\Mod\Content\Controllers\Api\ContentWebhookController;
|
||||
use Core\Mod\Content\Controllers\Api\GenerationController;
|
||||
use Core\Mod\Content\Controllers\ContentPreviewController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Console commands are registered via Core modules
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Core\Mod\Content\View\Modal\Web\Blog;
|
||||
use Core\Mod\Content\View\Modal\Web\Help;
|
||||
use Core\Mod\Content\View\Modal\Web\HelpArticle;
|
||||
use Core\Mod\Content\View\Modal\Web\Post;
|
||||
use Core\Mod\Content\View\Modal\Web\Preview;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Content\Models\ContentAuthor;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Tests\Feature;
|
||||
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Tenant\Models\User;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Content\Services\ContentRender;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Content\Models\ContentAuthor;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentMedia;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentRevision;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
|||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -74,7 +73,7 @@ class WebhookSignatureVerificationTest extends TestCase
|
|||
public function it_accepts_github_style_signature(): void
|
||||
{
|
||||
$payload = json_encode(['ID' => 456, 'post_title' => 'GitHub Style']);
|
||||
$signature = 'sha256=' . hash_hmac('sha256', $payload, 'test-webhook-secret-key');
|
||||
$signature = 'sha256='.hash_hmac('sha256', $payload, 'test-webhook-secret-key');
|
||||
|
||||
$response = $this->postJson(
|
||||
"/api/content/webhooks/{$this->endpoint->uuid}",
|
||||
|
|
@ -525,7 +524,7 @@ class WebhookSignatureVerificationTest extends TestCase
|
|||
$response2 = $this->postJson(
|
||||
"/api/content/webhooks/{$this->endpoint->uuid}",
|
||||
json_decode($payload, true),
|
||||
['X-Signature' => 'a' . substr($validSignature, 1), 'X-Event-Type' => 'post.updated']
|
||||
['X-Signature' => 'a'.substr($validSignature, 1), 'X-Event-Type' => 'post.updated']
|
||||
);
|
||||
$response2->assertStatus(401);
|
||||
|
||||
|
|
|
|||
5
tests/Pest.php
Normal file
5
tests/Pest.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
uses(Tests\TestCase::class)->in('Feature', 'Unit');
|
||||
|
|
@ -1,10 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [
|
||||
\Core\Tenant\Boot::class,
|
||||
\Core\Mod\Content\Boot::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getEnvironmentSetUp($app): void
|
||||
{
|
||||
$app['config']->set('database.default', 'testing');
|
||||
$app['config']->set('database.connections.testing', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
|
||||
// Stub external dependencies not available in test environment
|
||||
if (! class_exists(\Plug\Cdn\CdnManager::class)) {
|
||||
$app->bind(\Core\Mod\Content\Services\CdnPurgeService::class, fn () => \Mockery::mock(\Core\Mod\Content\Services\CdnPurgeService::class)->shouldIgnoreMissing());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Tests\Unit;
|
||||
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentTaxonomy;
|
||||
use Core\Mod\Content\Services\ContentSearchService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ use Tests\TestCase;
|
|||
|
||||
class ContentWebhookEndpointTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Register the webhook route if not already defined (routes may not
|
||||
// load in Orchestra Testbench without the full Core event pipeline)
|
||||
if (! \Illuminate\Support\Facades\Route::has('api.content.webhooks.receive')) {
|
||||
\Illuminate\Support\Facades\Route::get('/api/content/webhooks/{endpoint}', fn () => null)
|
||||
->name('api.content.webhooks.receive');
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_generates_uuid_on_creation(): void
|
||||
{
|
||||
|
|
@ -73,7 +85,8 @@ class ContentWebhookEndpointTest extends TestCase
|
|||
#[Test]
|
||||
public function it_rejects_webhook_without_secret_when_signature_required(): void
|
||||
{
|
||||
$endpoint = ContentWebhookEndpoint::factory()->create([
|
||||
// Use make() to bypass the creating event which auto-generates secrets
|
||||
$endpoint = ContentWebhookEndpoint::factory()->make([
|
||||
'secret' => null,
|
||||
'require_signature' => true,
|
||||
]);
|
||||
|
|
@ -88,7 +101,8 @@ class ContentWebhookEndpointTest extends TestCase
|
|||
#[Test]
|
||||
public function it_allows_webhook_without_secret_when_signature_not_required(): void
|
||||
{
|
||||
$endpoint = ContentWebhookEndpoint::factory()->create([
|
||||
// Use make() to bypass the creating event which auto-generates secrets
|
||||
$endpoint = ContentWebhookEndpoint::factory()->make([
|
||||
'secret' => null,
|
||||
'require_signature' => false,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Content\Tests\Unit;
|
||||
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Core\Mod\Content\Enums\ContentType;
|
||||
use Core\Mod\Content\Jobs\ProcessContentWebhook;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Mod\Content\Models\ContentWebhookLog;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class WebhookDeliveryLoggerTest extends TestCase
|
|||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->logger = new WebhookDeliveryLogger();
|
||||
$this->logger = new WebhookDeliveryLogger;
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->endpoint = ContentWebhookEndpoint::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue