Merge branch 'feature/NovaDesignImplement' into develop

This commit is contained in:
2026-02-15 09:25:23 +01:00
1091 changed files with 25935 additions and 3363 deletions

View File

@@ -0,0 +1,49 @@
Current DB & Models Analysis — 2026-02-10
Summary
- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members.
- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`.
Observed DB columns (categories)
- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at
Models verified
- `ContentType` (app/Models/ContentType.php)
- hasMany `categories()` and `rootCategories()`
- uses `slug` for route binding
- Status: OK and aligns with DB
- `Category` (app/Models/Category.php)
- belongsTo `contentType()`
- self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`)
- `descendants()` recursive helper
- `seo()` relation, `artworks()` pivot
- scopes: `active()`, `roots()`
- accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs`
- slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes)
- Status: OK and consistent with screenshots
Key behaviors and checks
- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}`
- Slug policy: generation with `Str::slug()` + model validation. Do not bypass.
- Ordering: use `children()` (sort_order then name) for deterministic menus.
- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories.
- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths.
Copilot / Dev rules (short checklist)
- Always look up content types by `slug`, not by numeric ID.
- Use `->roots()->active()->with('children')` for public category lists.
- Use `$category->url` and `$category->canonical_url` for links and canonical tags.
- Maintain slug rules: lowercase, only `a-z0-9-`.
- When reparenting categories, consider invalidating any cached derived paths for descendants.
- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data.
Suggested next steps
- Add a PHPUnit test asserting slug validation and `url` generation for nested categories.
- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`.
Files referenced
- [app/Models/ContentType.php](app/Models/ContentType.php)
- [app/Models/Category.php](app/Models/Category.php)
If you want, I can now add the PHPUnit test or generate the ER diagram.

View File

@@ -45,6 +45,150 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false
# Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
SKINBASE_DUPLICATE_HASH_POLICY=block
# Vision / AI auto-tagging (local defaults)
VISION_ENABLED=true
VISION_QUEUE=default
VISION_IMAGE_VARIANT=md
# CLIP service (set base URL to enable CLIP calls)
CLIP_BASE_URL=
CLIP_ANALYZE_ENDPOINT=/analyze
CLIP_TIMEOUT_SECONDS=8
CLIP_CONNECT_TIMEOUT_SECONDS=2
CLIP_HTTP_RETRIES=1
CLIP_HTTP_RETRY_DELAY_MS=200
CLIP_EMBED_ENDPOINT=/embed
CLIP_EMBED_TIMEOUT_SECONDS=8
CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=2
CLIP_EMBED_HTTP_RETRIES=1
CLIP_EMBED_HTTP_RETRY_DELAY_MS=200
# Similar artworks / embedding pipeline
RECOMMENDATIONS_QUEUE=${VISION_QUEUE}
RECOMMENDATIONS_EMBEDDING_ENABLED=true
RECOMMENDATIONS_EMBEDDING_MODEL=clip
RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
RECOMMENDATIONS_MIN_DIM=64
RECOMMENDATIONS_MAX_DIM=4096
RECOMMENDATIONS_BACKFILL_BATCH=200
# Personalized discovery foundation (Phase 8)
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
DISCOVERY_PROFILE_VERSION=profile-v1
DISCOVERY_EVENT_VERSION=event-v1
DISCOVERY_ALGO_VERSION=${RECOMMENDATIONS_ALGO_VERSION}
DISCOVERY_CACHE_VERSION=cache-v1
DISCOVERY_DECAY_HALF_LIFE_HOURS=72
DISCOVERY_WEIGHT_VIEW=1
DISCOVERY_WEIGHT_CLICK=2
DISCOVERY_WEIGHT_FAVORITE=4
DISCOVERY_WEIGHT_DOWNLOAD=3
DISCOVERY_CACHE_TTL_MINUTES=60
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
DISCOVERY_RANKING_W1=0.65
DISCOVERY_RANKING_W2=0.20
DISCOVERY_RANKING_W3=0.10
DISCOVERY_RANKING_W4=0.05
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1=rank-w-v1
DISCOVERY_RANKING_W1_CLIP_COSINE_V1=0.65
DISCOVERY_RANKING_W2_CLIP_COSINE_V1=0.20
DISCOVERY_RANKING_W3_CLIP_COSINE_V1=0.10
DISCOVERY_RANKING_W4_CLIP_COSINE_V1=0.05
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
DISCOVERY_ROLLOUT_ENABLED=false
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
DISCOVERY_FORCE_ALGO_VERSION=
DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT=3
DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT=5
DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT=4
DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT=8
DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT=10
DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT=15
DISCOVERY_EVAL_WEIGHT_CTR=0.45
DISCOVERY_EVAL_WEIGHT_SAVE_RATE=0.35
DISCOVERY_EVAL_WEIGHT_LONG_DWELL=0.25
DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY=0.15
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
# YOLO service (optional)
YOLO_ENABLED=true
YOLO_BASE_URL=
YOLO_ANALYZE_ENDPOINT=/analyze
YOLO_TIMEOUT_SECONDS=8
YOLO_CONNECT_TIMEOUT_SECONDS=2
YOLO_HTTP_RETRIES=1
YOLO_HTTP_RETRY_DELAY_MS=200
YOLO_PHOTOGRAPHY_ONLY=true
# -----------------------------------------------------------------------------
# Production examples (uncomment and adjust)
# -----------------------------------------------------------------------------
# VISION_ENABLED=true
# VISION_QUEUE=vision
# VISION_IMAGE_VARIANT=md
#
# CLIP_BASE_URL=https://clip.internal
# CLIP_ANALYZE_ENDPOINT=/analyze
# CLIP_TIMEOUT_SECONDS=5
# CLIP_CONNECT_TIMEOUT_SECONDS=1
# CLIP_HTTP_RETRIES=1
# CLIP_HTTP_RETRY_DELAY_MS=150
# CLIP_EMBED_ENDPOINT=/embed
# CLIP_EMBED_TIMEOUT_SECONDS=5
# CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=1
# CLIP_EMBED_HTTP_RETRIES=1
# CLIP_EMBED_HTTP_RETRY_DELAY_MS=150
# RECOMMENDATIONS_QUEUE=vision
# RECOMMENDATIONS_EMBEDDING_ENABLED=true
# RECOMMENDATIONS_EMBEDDING_MODEL=clip
# RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
# RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
# RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1,clip-cosine-v2
# RECOMMENDATIONS_BACKFILL_BATCH=250
# DISCOVERY_QUEUE=vision
# DISCOVERY_PROFILE_VERSION=profile-v1
# DISCOVERY_EVENT_VERSION=event-v1
# DISCOVERY_ALGO_VERSION=clip-cosine-v1
# DISCOVERY_CACHE_VERSION=cache-v1
# DISCOVERY_DECAY_HALF_LIFE_HOURS=72
# DISCOVERY_WEIGHT_VIEW=1
# DISCOVERY_WEIGHT_CLICK=2
# DISCOVERY_WEIGHT_FAVORITE=4
# DISCOVERY_WEIGHT_DOWNLOAD=3
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
# DISCOVERY_RANKING_W1=0.65
# DISCOVERY_RANKING_W2=0.20
# DISCOVERY_RANKING_W3=0.10
# DISCOVERY_RANKING_W4=0.05
#
# YOLO_ENABLED=true
# YOLO_BASE_URL=https://yolo.internal
# YOLO_ANALYZE_ENDPOINT=/analyze
# YOLO_TIMEOUT_SECONDS=5
# YOLO_CONNECT_TIMEOUT_SECONDS=1
# YOLO_HTTP_RETRIES=1
# YOLO_HTTP_RETRY_DELAY_MS=150
# YOLO_PHOTOGRAPHY_ONLY=true
CACHE_STORE=database CACHE_STORE=database
# CACHE_PREFIX= # CACHE_PREFIX=

366
README.md
View File

@@ -54,6 +54,372 @@ In order to ensure that the Laravel community is welcoming to all, please review
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## Vision & AI Auto-Tagging Integration
## Upload UI Feature Flag (`uploads.v2`)
The new React upload wizard is behind a feature flag and is **disabled by default**.
- Flag env var: `SKINBASE_UPLOADS_V2`
- Config key: `features.uploads_v2`
- Client flags source: `window.SKINBASE_FLAGS`
### Default behavior
- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered.
- `SKINBASE_UPLOADS_V2=true``UploadWizard` is rendered.
### Setup
In `.env` (or `.env.example` for project defaults):
```dotenv
SKINBASE_UPLOADS_V2=false
```
Enable explicitly when ready:
```dotenv
SKINBASE_UPLOADS_V2=true
```
After changing env values, clear/reload config as usual:
```bash
php artisan config:clear
```
The system intentionally keeps legacy upload as the default until the flag is explicitly turned on.
## Upload Moderation UI Flow
Admin moderation for draft uploads is available through a dedicated queue page.
- Page route: `/admin/uploads/moderation`
- Access: authenticated users with `role=admin` or `role=moderator`
- Data source: `GET /api/admin/uploads/pending`
### Queue behavior
1. The page loads pending draft uploads (`moderation_status=pending`).
2. Moderators can enter an optional note per upload.
3. Approve action calls:
- `POST /api/admin/uploads/{id}/approve`
- Sets moderation to approved and records moderator + timestamp.
4. Reject action calls:
- `POST /api/admin/uploads/{id}/reject`
- Sets upload status/processing state to rejected and stores note.
### Publish gate
- Normal users can publish only when `moderation_status=approved`.
- Admin users can publish with override behavior.
## Similar Artworks Analytics (A/B Evaluation)
The artwork page similar-items block emits two event types:
- `impression` (block rendered)
- `click` (item clicked)
Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`.
- Ingest endpoint: `POST /api/analytics/similar-artworks`
- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD`
- Scheduler: runs daily at `03:10`
## Personalized Discovery Foundation (Phase 8)
This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery.
- Tables:
- `user_interest_profiles`
- `user_discovery_events`
- `user_recommendation_cache`
- Ingest endpoint: `POST /api/discovery/events` (auth required)
- Supported event types: `view`, `click`, `favorite`, `download`
- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`)
- Normalization: recency-decay + score normalization in `UserInterestProfileService`
No feed ranking/UI behavior is introduced in this foundation step.
### Feed Endpoint Skeleton
The backend now exposes a personalized feed API skeleton:
- Endpoint: `GET /api/v1/feed` (auth required)
- Query params:
- `limit` (1-50, default 24)
- `cursor` (opaque cursor token for pagination)
- `algo_version` (optional override)
- Response includes `data` items and `meta.next_cursor` for cursor pagination.
Behavior:
- Reads `user_recommendation_cache` by `user_id + algo_version`.
- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job.
- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache.
- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates.
## Feed Analytics Instrumentation
Feed analytics now track:
- `feed_impression`
- `feed_click`
Payload dimensions:
- `user_id` (derived from auth session)
- `artwork_id`
- `position`
- `algo_version`
- `source` (`personalized`, `cold_start`, `fallback`)
Optional:
- `dwell_seconds` (for click dwell bucket metrics)
Endpoints:
- Ingest: `POST /api/analytics/feed` (auth required)
- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
- Admin report: `GET /api/admin/reports/feed-performance`
Daily metrics include CTR, save-rate, and dwell buckets.
For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback.
Reference helper: `resources/js/lib/feedAnalytics.js`.
## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven)
Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`.
- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty
- Per-algo sets: `discovery.ranking.algo_weight_sets`
- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`)
- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
Offline evaluator and A/B helper:
- Evaluate objective across one/all algos:
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD`
- Optional: `--algo=clip-cosine-v1`
- Baseline vs candidate comparison:
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD`
Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`.
Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified.
Operational runbook: `docs/feed-rollout-runbook.md`.
## Operations / Runbooks
- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md`
- Feed rollout and rollback: `docs/feed-rollout-runbook.md`
No automatic tuning is enabled in this phase.
Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`.
The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values.
### Critical Safety Rule
⚠️ **Publish must never depend on vision services.**
- Upload/publish flow dispatches AI tagging to queue after publish work.
- Vision failures, timeouts, or service outages must not block artwork publish.
- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch).
### Environment Variables (Vision)
Set these in `.env` (all are optional; defaults are in `config/vision.php`):
#### Global
- `VISION_ENABLED` (default: `true`)
- Master switch for all AI auto-tagging.
- `VISION_QUEUE` (default: `default`)
- Queue name used by `AutoTagArtworkJob`.
- `VISION_IMAGE_VARIANT` (default: `md`)
- Derivative variant sent to vision services (e.g. `md`, `lg`).
#### CLIP
- `CLIP_BASE_URL` (default: empty)
- Base URL for CLIP service (example: `https://clip.internal`).
- If empty, CLIP call is skipped.
- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `CLIP_BASE_URL`.
- `CLIP_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for CLIP calls.
- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for CLIP calls.
- `CLIP_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for CLIP requests.
- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between CLIP retries.
#### YOLO (optional)
- `YOLO_ENABLED` (default: `true`)
- Enables YOLO integration.
- `YOLO_BASE_URL` (default: empty)
- Base URL for YOLO service. If empty, YOLO call is skipped.
- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `YOLO_BASE_URL`.
- `YOLO_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for YOLO calls.
- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for YOLO calls.
- `YOLO_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for YOLO requests.
- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between YOLO retries.
- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`)
- When `true`, YOLO is called only for artworks in photography content type.
### Expected CLIP Response Format
CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`:
```json
[
{ "tag": "cyberpunk", "confidence": 0.42 },
{ "tag": "city", "confidence": 0.31 }
]
```
Also accepted:
```json
{
"tags": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
or
```json
{
"data": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
### Expected YOLO Response Format
YOLO may return the same tag list format as CLIP, or object detections:
```json
{
"objects": [
{ "label": "person", "confidence": 0.91 },
{ "label": "camera", "confidence": 0.67 }
]
}
```
`label` values are converted to tags, confidence is preserved when present.
### AutoTagArtworkJob Behavior
- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set.
- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`.
- Merges CLIP + YOLO tags and keeps highest confidence for duplicates.
- Normalizes tags before attach (lowercase, cleanup, slug-safe format).
- Uses `TagService::attachAiTags()` to store pivot data:
- `source = ai`
- `confidence = <float|null>`
- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`).
- Logs failures with reference/context for troubleshooting.
- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish.
### Queue / Worker Requirements (`VISION_QUEUE`)
- Ensure a worker is running for the configured queue.
- Example worker command:
```bash
php artisan queue:work --queue=default
```
- If `VISION_QUEUE=vision`, run worker for that queue:
```bash
php artisan queue:work --queue=vision
```
- In production, use Supervisor/systemd/Horizon to keep workers alive.
- Without an active worker, auto-tagging jobs remain queued and will not execute.
### Local vs Production Notes
#### Local development
- For fully offline local work, set `VISION_ENABLED=false`.
- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally.
- Prefer short timeouts to avoid slow dev feedback loops.
#### Production
- Use internal/private service endpoints for CLIP/YOLO when possible.
- Keep conservative timeouts and low retry counts to prevent queue congestion.
- Monitor failed jobs and logs for vision service reliability.
- Scale queue workers based on upload volume and service latency.
### Verify Setup (Health + Test Call)
After configuring env vars and restarting workers, verify in this order:
Quick helper (PowerShell):
```powershell
pwsh -File ./scripts/vision-smoke.ps1
```
Optional flags:
```powershell
pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp"
pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze
```
1. Confirm queue worker is consuming `VISION_QUEUE`.
```bash
php artisan queue:work --queue=default
```
1. Check CLIP/YOLO health endpoints (replace host/port as needed):
```bash
curl -fsS "$CLIP_BASE_URL/health"
curl -fsS "$YOLO_BASE_URL/health"
```
1. Make a direct analyze test call (CLIP example):
```bash
curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}'
```
1. Trigger an upload/publish and confirm:
- Publish response succeeds even if CLIP/YOLO is down.
- `AutoTagArtworkJob` is queued/executed asynchronously.
- AI tags appear on the artwork when services are healthy.
- Failures are logged, but publish is unaffected.
## License ## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -6,11 +6,11 @@ class Banner
{ {
public static function ShowResponsiveAd() public static function ShowResponsiveAd()
{ {
echo '<div class="responsive_ad">'; #echo '<div class="responsive_ad">';
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>'; #echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>'; #echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>'; #echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
echo '</div>'; #echo '</div>';
} }
public static function ShowBanner300x250() public static function ShowBanner300x250()

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class AggregateFeedAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-feed {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate feed analytics into daily metrics by algo version and source';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$rows = DB::table('feed_events')
->selectRaw('algo_version, source')
->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120")
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus")
->whereDate('event_date', $date)
->groupBy('algo_version', 'source')
->get();
foreach ($rows as $row) {
$algoVersion = (string) $row->algo_version;
$source = (string) $row->source;
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = $this->countSavesForGroup($date, $algoVersion, $source);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
DB::table('feed_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => $algoVersion,
'source' => $source,
],
[
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => $ctr,
'save_rate' => $saveRate,
'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0),
'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0),
'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0),
'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0),
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated feed analytics for {$date}.");
return self::SUCCESS;
}
private function countSavesForGroup(string $date, string $algoVersion, string $source): int
{
/** @var Collection<int, object{user_id:int,artwork_id:int}> $clickedPairs */
$clickedPairs = DB::table('feed_events')
->select('user_id', 'artwork_id')
->whereDate('event_date', $date)
->where('event_type', 'feed_click')
->where('algo_version', $algoVersion)
->where('source', $source)
->groupBy('user_id', 'artwork_id')
->get();
if ($clickedPairs->isEmpty()) {
return 0;
}
$saves = 0;
foreach ($clickedPairs as $pair) {
$hasSave = DB::table('user_discovery_events')
->whereDate('event_date', $date)
->where('user_id', (int) $pair->user_id)
->where('artwork_id', (int) $pair->artwork_id)
->where('algo_version', $algoVersion)
->whereIn('event_type', ['favorite', 'download'])
->exists();
if ($hasSave) {
$saves++;
}
}
return $saves;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateSimilarArtworkAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-similar-artworks {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate similar artwork analytics into daily counts by algo version';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$rows = DB::table('similar_artwork_events')
->selectRaw('algo_version')
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereDate('event_date', $date)
->groupBy('algo_version')
->get();
foreach ($rows as $row) {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
DB::table('similar_artwork_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) $row->algo_version,
],
[
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => $ctr,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated similar artwork analytics for {$date}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\AvatarService;
class AvatarsMigrate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate {--force}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars to new WebP avatar storage';
protected $service;
public function __construct(AvatarService $service)
{
parent::__construct();
$this->service = $service;
}
public function handle()
{
$this->info('Starting avatar migration...');
// Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon
$rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get();
if ($rows->isEmpty()) {
// fallback to users table
$rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get();
}
$count = 0;
foreach ($rows as $row) {
$userId = $row->user_id;
$legacy = $row->avatar_legacy ?? null;
if (!$legacy) {
continue;
}
// Try common legacy paths
$candidates = [
public_path('user-picture/' . $legacy),
public_path('avatar/' . $userId . '/' . $legacy),
storage_path('app/public/user-picture/' . $legacy),
storage_path('app/public/avatar/' . $userId . '/' . $legacy),
];
$found = false;
foreach ($candidates as $p) {
if (file_exists($p) && is_readable($p)) {
$this->info("Processing user {$userId} from {$p}");
$hash = $this->service->storeFromLegacyFile($userId, $p);
if ($hash) {
$this->info(" -> migrated, hash={$hash}");
$count++;
$found = true;
break;
}
}
}
if (!$found) {
$this->warn("Legacy file not found for user {$userId}, filename={$legacy}");
}
}
$this->info("Migration complete. Processed: {$count}");
return 0;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkEmbeddingsJob;
use Illuminate\Console\Command;
final class BackfillArtworkEmbeddingsCommand extends Command
{
protected $signature = 'artworks:embeddings-backfill {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--force : Regenerate even when source hash matches}';
protected $description = 'Queue resumable CLIP embedding backfill for artworks';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$force = (bool) $this->option('force');
BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force);
$this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Recommendations\FeedOfflineEvaluationService;
use Illuminate\Console\Command;
final class CompareFeedAbCommand extends Command
{
protected $signature = 'analytics:compare-feed-ab
{baseline : Baseline algo_version}
{candidate : Candidate algo_version}
{--from= : Start date (Y-m-d), defaults to last 30 days}
{--to= : End date (Y-m-d), defaults to today}
{--json : Output as JSON}';
protected $description = 'A/B helper for baseline vs candidate feed algo comparison';
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
{
parent::__construct();
}
public function handle(): int
{
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
$to = (string) ($this->option('to') ?: now()->toDateString());
if ($from > $to) {
$this->error('Invalid range: --from must be <= --to');
return self::FAILURE;
}
$baseline = (string) $this->argument('baseline');
$candidate = (string) $this->argument('candidate');
$comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
[[
(string) $comparison['baseline']['algo_version'],
(float) $comparison['baseline']['ctr'],
(float) $comparison['baseline']['save_rate'],
(float) $comparison['baseline']['long_dwell_share'],
(float) $comparison['baseline']['bounce_rate'],
(float) $comparison['baseline']['objective_score'],
], [
(string) $comparison['candidate']['algo_version'],
(float) $comparison['candidate']['ctr'],
(float) $comparison['candidate']['save_rate'],
(float) $comparison['candidate']['long_dwell_share'],
(float) $comparison['candidate']['bounce_rate'],
(float) $comparison['candidate']['objective_score'],
]]
);
$delta = (array) $comparison['delta'];
$this->line('Δ objective_score: ' . (string) $delta['objective_score']);
$this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a'));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Recommendations\FeedOfflineEvaluationService;
use Illuminate\Console\Command;
final class EvaluateFeedWeightsCommand extends Command
{
protected $signature = 'analytics:evaluate-feed-weights
{--algo= : Optional algo_version to evaluate}
{--from= : Start date (Y-m-d), defaults to last 30 days}
{--to= : End date (Y-m-d), defaults to today}
{--json : Output as JSON}';
protected $description = 'Offline feed weight evaluation using feed_daily_metrics';
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
{
parent::__construct();
}
public function handle(): int
{
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
$to = (string) ($this->option('to') ?: now()->toDateString());
$algo = $this->option('algo') ? (string) $this->option('algo') : null;
if ($from > $to) {
$this->error('Invalid range: --from must be <= --to');
return self::FAILURE;
}
if ($algo !== null && $algo !== '') {
$result = $this->evaluator->evaluateAlgo($algo, $from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
[[
(string) $result['algo_version'],
(float) $result['ctr'],
(float) $result['save_rate'],
(float) $result['long_dwell_share'],
(float) $result['bounce_rate'],
(float) $result['objective_score'],
]]
);
}
return self::SUCCESS;
}
$results = $this->evaluator->evaluateAll($from, $to);
if ((bool) $this->option('json')) {
$this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$rows = array_map(static fn (array $row): array => [
(string) $row['algo_version'],
(float) $row['ctr'],
(float) $row['save_rate'],
(float) $row['long_dwell_share'],
(float) $row['bounce_rate'],
(float) $row['objective_score'],
], $results);
$this->table(
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
$rows
);
return self::SUCCESS;
}
}

View File

@@ -102,7 +102,7 @@ class ImportLegacyUsers extends Command
DB::table('user_profiles')->insert([ DB::table('user_profiles')->insert([
'user_id' => $legacyId, 'user_id' => $legacyId,
'bio' => $row->about_me ?: $row->description ?: null, 'about' => $row->about_me ?: $row->description ?: null,
'avatar' => $row->picture ?: null, 'avatar' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null, 'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null, 'country' => $row->country ?: null,
@@ -115,15 +115,7 @@ class ImportLegacyUsers extends Command
'updated_at' => $now, 'updated_at' => $now,
]); ]);
if (!empty($row->web)) { // Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
DB::table('user_social_links')->insert([
'user_id' => $legacyId,
'platform' => 'website',
'url' => $row->web,
'created_at' => $now,
'updated_at' => $now,
]);
}
DB::table('user_statistics')->insert([ DB::table('user_statistics')->insert([
'user_id' => $legacyId, 'user_id' => $legacyId,

View File

@@ -6,6 +6,12 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\ImportLegacyUsers; use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories; use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks; use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Uploads\Commands\CleanupUploadsCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@@ -18,7 +24,14 @@ class Kernel extends ConsoleKernel
ImportLegacyUsers::class, ImportLegacyUsers::class,
ImportCategories::class, ImportCategories::class,
MigrateFeaturedWorks::class, MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,
\App\Console\Commands\ResetAllUserPasswords::class, \App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
BackfillArtworkEmbeddingsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class,
]; ];
/** /**
@@ -26,7 +39,9 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{ {
// $schedule->command('inspire')->hourly(); $schedule->command('uploads:cleanup')->dailyAt('03:00');
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
} }
/** /**

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Artworks;
final class ArtworkDraftResult
{
public function __construct(
public readonly int $artworkId,
public readonly string $status
) {
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadChunkResult
{
public function __construct(
public readonly string $sessionId,
public readonly string $status,
public readonly int $receivedBytes,
public readonly int $totalBytes,
public readonly int $progress
) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadInitResult
{
public function __construct(
public readonly string $sessionId,
public readonly string $token,
public readonly string $status
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadScanResult
{
public function __construct(
public readonly bool $ok,
public readonly string $reason
) {
}
public static function clean(): self
{
return new self(true, '');
}
public static function infected(string $reason): self
{
return new self(false, $reason);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
use Carbon\CarbonImmutable;
final class UploadSessionData
{
public function __construct(
public readonly string $id,
public readonly int $userId,
public readonly string $tempPath,
public readonly string $status,
public readonly string $ip,
public readonly CarbonImmutable $createdAt,
public readonly int $progress,
public readonly ?string $failureReason
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadStoredFile
{
public function __construct(
public readonly string $path,
public readonly int $size,
public readonly string $extension
) {
}
public static function fromPath(string $path): self
{
$size = is_file($path) ? (int) filesize($path) : 0;
$extension = (string) pathinfo($path, PATHINFO_EXTENSION);
return new self($path, $size, $extension);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadValidatedFile
{
public function __construct(
public readonly UploadValidationResult $validation,
public readonly ?string $hash
) {
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\DTOs\Uploads;
final class UploadValidationResult
{
public function __construct(
public readonly bool $ok,
public readonly string $reason,
public readonly ?int $width,
public readonly ?int $height,
public readonly ?string $mime,
public readonly ?int $size
) {
}
public static function ok(int $width, int $height, string $mime, int $size): self
{
return new self(true, '', $width, $height, $mime, $size);
}
public static function fail(string $reason, ?int $width = null, ?int $height = null, ?string $mime = null, ?int $size = null): self
{
return new self(false, $reason, $width, $height, $mime, $size);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class FeedPerformanceReportController extends Controller
{
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 100);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$rows = DB::table('feed_daily_metrics')
->selectRaw('algo_version, source')
->selectRaw('SUM(impressions) as impressions')
->selectRaw('SUM(clicks) as clicks')
->selectRaw('SUM(saves) as saves')
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
->whereBetween('metric_date', [$from, $to])
->groupBy('algo_version', 'source')
->orderBy('algo_version')
->orderBy('source')
->get();
$byAlgoSource = $rows->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = (int) ($row->saves ?? 0);
return [
'algo_version' => (string) $row->algo_version,
'source' => (string) $row->source,
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6),
'dwell_buckets' => [
'0_5' => (int) ($row->dwell_0_5 ?? 0),
'5_30' => (int) ($row->dwell_5_30 ?? 0),
'30_120' => (int) ($row->dwell_30_120 ?? 0),
'120_plus' => (int) ($row->dwell_120_plus ?? 0),
],
];
})->values();
$topClickedArtworks = DB::table('feed_events as e')
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
->selectRaw('e.algo_version')
->selectRaw('e.source')
->selectRaw('e.artwork_id')
->selectRaw('a.title as artwork_title')
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('e.event_date', [$from, $to])
->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title')
->get()
->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
return [
'algo_version' => (string) $row->algo_version,
'source' => (string) $row->source,
'artwork_id' => (int) $row->artwork_id,
'artwork_title' => (string) ($row->artwork_title ?? ''),
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
];
})
->sort(static function (array $a, array $b): int {
$clickCompare = $b['clicks'] <=> $a['clicks'];
if ($clickCompare !== 0) {
return $clickCompare;
}
return $b['ctr'] <=> $a['ctr'];
})
->take($limit)
->values();
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'generated_at' => now()->toISOString(),
'limit' => $limit,
],
'by_algo_source' => $byAlgoSource,
'top_clicked_artworks' => $topClickedArtworks,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class SimilarArtworkReportController extends Controller
{
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 100);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$byAlgoRows = DB::table('similar_artwork_events')
->selectRaw('algo_version')
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('event_date', [$from, $to])
->groupBy('algo_version')
->orderBy('algo_version')
->get();
$byAlgo = $byAlgoRows->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
return [
'algo_version' => (string) $row->algo_version,
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($ctr, 6),
];
})->values();
$pairRows = DB::table('similar_artwork_events as e')
->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id')
->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id')
->selectRaw('e.algo_version')
->selectRaw('e.source_artwork_id')
->selectRaw('e.similar_artwork_id')
->selectRaw('source.title as source_title')
->selectRaw('similar.title as similar_title')
->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('e.event_date', [$from, $to])
->whereNotNull('e.similar_artwork_id')
->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title')
->get();
$topSimilarities = $pairRows
->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
return [
'algo_version' => (string) $row->algo_version,
'source_artwork_id' => (int) $row->source_artwork_id,
'source_title' => (string) ($row->source_title ?? ''),
'similar_artwork_id' => (int) $row->similar_artwork_id,
'similar_title' => (string) ($row->similar_title ?? ''),
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($ctr, 6),
];
})
->sort(function (array $a, array $b): int {
$ctrCompare = $b['ctr'] <=> $a['ctr'];
if ($ctrCompare !== 0) {
return $ctrCompare;
}
$clickCompare = $b['clicks'] <=> $a['clicks'];
if ($clickCompare !== 0) {
return $clickCompare;
}
return $b['impressions'] <=> $a['impressions'];
})
->take($limit)
->values();
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'generated_at' => now()->toISOString(),
'limit' => $limit,
],
'by_algo_version' => $byAlgo,
'top_similarities' => $topSimilarities,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Upload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class UploadModerationController extends Controller
{
public function pending(): JsonResponse
{
$uploads = Upload::query()
->where('status', 'draft')
->where('moderation_status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'type',
'status',
'processing_state',
'title',
'preview_path',
'created_at',
'moderation_status',
]);
return response()->json([
'data' => $uploads,
], Response::HTTP_OK);
}
public function approve(string $id, Request $request): JsonResponse
{
$upload = Upload::query()->find($id);
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
$upload->moderation_status = 'approved';
$upload->moderated_at = now();
$upload->moderated_by = (int) $request->user()->id;
$upload->moderation_note = $request->input('note');
$upload->save();
return response()->json([
'success' => true,
'id' => (string) $upload->id,
'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK);
}
public function reject(string $id, Request $request): JsonResponse
{
$upload = Upload::query()->find($id);
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
$upload->moderation_status = 'rejected';
$upload->status = 'rejected';
$upload->processing_state = 'rejected';
$upload->moderated_at = now();
$upload->moderated_by = (int) $request->user()->id;
$upload->moderation_note = (string) $request->input('note', '');
$upload->save();
return response()->json([
'success' => true,
'id' => (string) $upload->id,
'status' => (string) $upload->status,
'processing_state' => (string) $upload->processing_state,
'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK);
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkCreateRequest;
use App\Http\Resources\ArtworkListResource; use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\Artworks\ArtworkDraftService;
use App\Models\Category; use App\Models\Category;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ArtworkController extends Controller class ArtworkController extends Controller
{ {
@@ -17,6 +20,27 @@ class ArtworkController extends Controller
$this->service = $service; $this->service = $service;
} }
/**
* POST /api/artworks
* Creates a draft artwork placeholder for the upload pipeline.
*/
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
{
$user = $request->user();
$data = $request->validated();
$result = $drafts->createDraft(
(int) $user->id,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null
);
return response()->json([
'artwork_id' => $result->artworkId,
'status' => $result->status,
], Response::HTTP_CREATED);
}
/** /**
* GET /api/v1/artworks/{slug} * GET /api/v1/artworks/{slug}
* Returns a single public artwork resource by slug. * Returns a single public artwork resource by slug.

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkTagsStoreRequest;
use App\Http\Requests\Artworks\ArtworkTagsUpdateRequest;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Jobs\AutoTagArtworkJob;
final class ArtworkTagController extends Controller
{
public function __construct(
private readonly TagService $tags,
) {
}
public function index(int $id): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound(request()->user(), $artwork);
$queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true);
$queuedCount = 0;
$failedCount = 0;
if (in_array($queueConnection, ['database', 'redis'], true)) {
try {
$queuedCount = (int) DB::table('jobs')
->where('payload', 'like', '%AutoTagArtworkJob%')
->where('payload', 'like', '%' . $artwork->id . '%')
->count();
} catch (\Throwable) {
$queuedCount = 0;
}
try {
$failedCount = (int) DB::table('failed_jobs')
->where('payload', 'like', '%AutoTagArtworkJob%')
->where('payload', 'like', '%' . $artwork->id . '%')
->count();
} catch (\Throwable) {
$failedCount = 0;
}
}
$triggered = false;
$shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true;
$queuedCount = max(1, $queuedCount);
}
$tags = $artwork->tags()
->select('tags.id', 'tags.name', 'tags.slug')
->withPivot(['source', 'confidence'])
->orderByDesc('artwork_tag.confidence')
->get()
->map(static function ($tag): array {
$source = (string) ($tag->pivot->source ?? 'manual');
return [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'source' => $source,
'confidence' => (float) ($tag->pivot->confidence ?? 0),
'is_ai' => $source === 'ai',
];
})
->values();
return response()->json([
'vision_enabled' => $visionEnabled,
'tags' => $tags,
'ai_tags' => $tags->where('is_ai', true)->values(),
'debug' => [
'queue_connection' => $queueConnection,
'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount,
'triggered' => $triggered,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(),
],
]);
}
public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
try {
$payload = $request->validated();
$this->tags->attachUserTags($artwork, $payload['tags']);
return response()->json(['ok' => true], Response::HTTP_CREATED);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
try {
$payload = $request->validated();
$this->tags->syncTags($artwork, $payload['tags']);
return response()->json(['ok' => true]);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function destroy(int $id, Tag $tag): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound(request()->user(), $artwork);
try {
$this->tags->detachTags($artwork, [$tag->id]);
return response()->json(['ok' => true]);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
private function authorizeOrNotFound($user, Artwork $artwork): void
{
if (! $user) {
abort(404);
}
if (! $user->can('updateTags', $artwork)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\IngestUserDiscoveryEventJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryEventController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_id' => ['nullable', 'uuid'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'occurred_at' => ['nullable', 'date'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$eventId = (string) ($payload['event_id'] ?? (string) Str::uuid());
$algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1'));
$occurredAt = isset($payload['occurred_at'])
? (string) $payload['occurred_at']
: now()->toIso8601String();
IngestUserDiscoveryEventJob::dispatch(
eventId: $eventId,
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: (string) $payload['event_type'],
algoVersion: $algoVersion,
occurredAt: $occurredAt,
meta: (array) ($payload['meta'] ?? [])
)->onQueue((string) config('discovery.queue', 'default'));
return response()->json([
'queued' => true,
'event_id' => $eventId,
'algo_version' => $algoVersion,
], Response::HTTP_ACCEPTED);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class FeedAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'in:feed_impression,feed_click'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'position' => ['nullable', 'integer', 'min:1', 'max:500'],
'algo_version' => ['required', 'string', 'max:64'],
'source' => ['required', 'string', 'in:personalized,cold_start,fallback'],
'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
'occurred_at' => ['nullable', 'date'],
]);
$occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now();
DB::table('feed_events')->insert([
'event_date' => $occurredAt->toDateString(),
'event_type' => (string) $payload['event_type'],
'user_id' => (int) $request->user()->id,
'artwork_id' => (int) $payload['artwork_id'],
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
'algo_version' => (string) $payload['algo_version'],
'source' => (string) $payload['source'],
'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null,
'occurred_at' => $occurredAt,
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json(['success' => true], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\PersonalizedFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class FeedController extends Controller
{
public function __construct(private readonly PersonalizedFeedService $feedService)
{
}
public function index(Request $request): JsonResponse
{
$payload = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
'cursor' => ['nullable', 'string', 'max:512'],
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$result = $this->feedService->getFeed(
userId: (int) $request->user()->id,
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null
);
return response()->json($result);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class SimilarArtworkAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'in:impression,click'],
'algo_version' => ['required', 'string', 'max:64'],
'source_artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'similar_artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'position' => ['nullable', 'integer', 'min:1', 'max:100'],
'items_count' => ['nullable', 'integer', 'min:0', 'max:100'],
]);
DB::table('similar_artwork_events')->insert([
'event_date' => now()->toDateString(),
'event_type' => (string) $payload['event_type'],
'algo_version' => (string) $payload['algo_version'],
'source_artwork_id' => (int) $payload['source_artwork_id'],
'similar_artwork_id' => isset($payload['similar_artwork_id']) ? (int) $payload['similar_artwork_id'] : null,
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
'items_count' => isset($payload['items_count']) ? (int) $payload['items_count'] : null,
'occurred_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json(['success' => true], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tags\PopularTagsRequest;
use App\Http\Requests\Tags\TagSearchRequest;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
final class TagController extends Controller
{
public function search(TagSearchRequest $request): JsonResponse
{
$q = (string) ($request->validated()['q'] ?? '');
$q = trim($q);
$query = Tag::query()->where('is_active', true);
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('name', 'like', $q . '%')
->orWhere('slug', 'like', $q . '%');
});
}
$tags = $query
->orderByDesc('usage_count')
->limit(20)
->get(['id', 'name', 'slug', 'usage_count']);
return response()->json([
'data' => $tags,
]);
}
public function popular(PopularTagsRequest $request): JsonResponse
{
$limit = (int) ($request->validated()['limit'] ?? 20);
$tags = Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count']);
return response()->json([
'data' => $tags,
]);
}
}

View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Uploads\UploadFinishRequest;
use App\Http\Requests\Uploads\UploadInitRequest;
use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService;
use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use Carbon\Carbon;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use Illuminate\Support\Str;
final class UploadController extends Controller
{
public function init(
UploadInitRequest $request,
UploadPipelineService $pipeline,
UploadQuotaService $quota,
UploadAuditService $audit
)
{
$user = $request->user();
try {
$quota->enforce($user->id);
} catch (Throwable $e) {
return response()->json([
'message' => $e->getMessage(),
], Response::HTTP_TOO_MANY_REQUESTS);
}
$result = $pipeline->initSession($user->id, (string) $request->ip());
$audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [
'session_id' => $result->sessionId,
]);
return response()->json([
'session_id' => $result->sessionId,
'upload_token' => $result->token,
'status' => $result->status,
], Response::HTTP_OK);
}
public function finish(
UploadFinishRequest $request,
UploadPipelineService $pipeline,
UploadSessionRepository $sessions,
UploadAuditService $audit
) {
$user = $request->user();
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$session = $sessions->getOrFail($sessionId);
$request->artwork();
$validated = $pipeline->validateAndHash($sessionId);
if (! $validated->validation->ok || ! $validated->hash) {
return response()->json([
'message' => 'Upload validation failed.',
'reason' => $validated->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$scan = $pipeline->scan($sessionId);
if (! $scan->ok) {
return response()->json([
'message' => 'Upload scan failed.',
'reason' => $scan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
return 'queued';
}
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
return UploadSessionStatus::PROCESSED;
});
$audit->log($user->id, 'upload_finished', $session->ip, [
'session_id' => $sessionId,
'hash' => $validated->hash,
'artwork_id' => $artworkId,
'status' => $status,
]);
return response()->json([
'artwork_id' => $artworkId,
'status' => $status,
], Response::HTTP_OK);
} catch (Throwable $e) {
Log::error('Upload finish failed', [
'session_id' => $sessionId,
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'Upload finish failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function chunk(UploadChunkRequest $request, UploadChunkService $chunks)
{
$user = $request->user();
$chunkFile = $request->file('chunk');
// Debug: log uploaded file object details to help diagnose missing chunk
try {
if (! $chunkFile) {
logger()->warning('Chunk upload: no file present on request', [
'session_id' => (string) $request->input('session_id'),
'headers' => $request->headers->all(),
]);
} else {
logger()->warning('Chunk upload file details', [
'session_id' => (string) $request->input('session_id'),
'client_name' => $chunkFile->getClientOriginalName() ?? null,
'client_size' => $chunkFile->getSize() ?? null,
'error' => $chunkFile->getError(),
'realpath' => $chunkFile->getRealPath(),
]);
}
} catch (\Throwable $e) {
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
}
try {
// Use getPathname() — this returns the PHP temp filename even when
// getRealPath() may be false (platform/stream wrappers can cause
// getRealPath() to return false). getPathname() is safe for reading
// the uploaded chunk file.
$chunkPath = $chunkFile ? $chunkFile->getPathname() : '';
$result = $chunks->appendChunk(
(string) $request->input('session_id'),
(string) $chunkPath,
(int) $request->input('offset'),
(int) $request->input('chunk_size'),
(int) $request->input('total_size'),
(int) $user->id,
(string) $request->ip()
);
return response()->json([
'session_id' => $result->sessionId,
'status' => $result->status,
'received_bytes' => $result->receivedBytes,
'total_bytes' => $result->totalBytes,
'progress' => $result->progress,
], Response::HTTP_OK);
} catch (\Throwable $e) {
logger()->warning('Upload chunk failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
]);
// Include the underlying error message in the response during debugging
// so the frontend can show a useful description. Remove or hide this
// in production if you prefer more generic errors.
return response()->json([
'message' => 'Upload chunk failed.',
'error' => $e->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit)
{
$user = $request->user();
$payload = $statusService->get($id);
$audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [
'session_id' => $id,
'status' => $payload['status'],
]);
return response()->json([
'session_id' => $payload['session_id'],
'status' => $payload['status'],
'progress' => $payload['progress'],
'failure_reason' => $payload['failure_reason'],
'received_bytes' => $payload['received_bytes'] ?? 0,
], Response::HTTP_OK);
}
public function cancel(UploadCancelRequest $request, UploadCancelService $cancel)
{
$user = $request->user();
try {
$result = $cancel->cancel(
(string) $request->input('session_id'),
(int) $user->id,
(string) $request->ip()
);
return response()->json([
'session_id' => $result['session_id'],
'status' => $result['status'],
], Response::HTTP_OK);
} catch (\Throwable $e) {
logger()->warning('Upload cancel failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'Upload cancel failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Preload an upload draft: validate main file, create draft and store files.
*
* Returns JSON: { upload_id, status, expires_at }
*/
public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService)
{
$user = $request->user();
$request->validate([
'main' => ['required', 'file'],
'screenshots' => ['sometimes', 'array'],
'screenshots.*' => ['file', 'image', 'max:5120'],
]);
$main = $request->file('main');
// Detect type from mime
$mime = (string) $main->getClientMimeType();
$type = null;
if (str_starts_with($mime, 'image/')) {
$type = 'image';
} elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) {
$type = 'archive';
}
if ($type === null) {
return response()->json([
'message' => 'Invalid main file type.',
'errors' => [
'main' => ['The main file must be an image or archive.'],
],
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($type === 'archive') {
$validator = Validator::make($request->all(), [
'screenshots' => ['required', 'array', 'min:1'],
'screenshots.*' => ['file', 'image', 'max:5120'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $validator->errors(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$inspection = $archiveInspector->inspect((string) $main->getPathname());
if (! $inspection->valid) {
return response()->json([
'message' => 'Archive inspection failed.',
'reason' => $inspection->reason,
'stats' => $inspection->stats,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$incomingFiles = [$main];
if ($type === 'archive' && $request->hasFile('screenshots')) {
foreach ($request->file('screenshots') as $screenshot) {
$incomingFiles[] = $screenshot;
}
}
$mainHash = $draftService->calculateHash((string) $main->getPathname());
try {
$warnings = $draftQuotaService->assertCanCreateDraft($user, [
'files' => $incomingFiles,
'main_hash' => $mainHash,
]);
} catch (DraftQuotaException $e) {
return response()->json([
'message' => $e->machineCode(),
'code' => $e->machineCode(),
], $e->httpStatus());
}
// Create draft record (meta-only) and store main file via service
$draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]);
try {
$mainInfo = $draftService->storeMainFile($draft['id'], $main);
// If archive, allow optional screenshots to be uploaded in the same request
if ($type === 'archive' && $request->hasFile('screenshots')) {
foreach ($request->file('screenshots') as $ss) {
try {
$draftService->storeScreenshot($draft['id'], $ss);
} catch (Throwable $e) {
// Keep controller thin: log and continue
logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]);
}
}
}
// Set expiration (default 7 days) and return info
$ttlDays = (int) config('uploads.draft_ttl_days', 7);
$expiresAt = Carbon::now()->addDays($ttlDays);
$draftService->setExpiration($draft['id'], $expiresAt);
VirusScanJob::dispatch($draft['id']);
$response = [
'upload_id' => $draft['id'],
'status' => 'draft',
'expires_at' => $expiresAt->toISOString(),
];
if (! empty($warnings)) {
$response['warnings'] = array_values($warnings);
}
return response()->json($response, Response::HTTP_OK);
} catch (Throwable $e) {
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function autosave(string $id, Request $request)
{
$user = $request->user();
$upload = DB::table('uploads')->where('id', $id)->first();
if (! $upload) {
return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $upload->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
if ((string) $upload->status !== 'draft') {
return response()->json([
'message' => 'Only draft uploads can be autosaved.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'exists:categories,id'],
'description' => ['nullable', 'string'],
'tags' => ['nullable', 'array'],
'license' => ['nullable', 'string'],
'nsfw' => ['nullable', 'boolean'],
]);
$updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) {
$updates[$field] = $validated[$field];
}
}
$dirty = [];
foreach ($updates as $field => $value) {
$current = $upload->{$field} ?? null;
if ($field === 'tags') {
$current = $current ? json_decode((string) $current, true) : null;
}
if ($field === 'nsfw') {
$current = is_null($current) ? null : (bool) $current;
$value = is_null($value) ? null : (bool) $value;
}
if ($current !== $value) {
$dirty[$field] = $value;
}
}
if (array_key_exists('tags', $dirty)) {
$dirty['tags'] = json_encode($dirty['tags']);
}
if (! empty($dirty)) {
$dirty['updated_at'] = now();
DB::table('uploads')->where('id', $id)->update($dirty);
$upload = DB::table('uploads')->where('id', $id)->first();
}
return response()->json([
'success' => true,
'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()),
], Response::HTTP_OK);
}
public function processingStatus(string $id, Request $request)
{
$user = $request->user();
$upload = DB::table('uploads')->where('id', $id)->first();
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $upload->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$status = (string) ($upload->status ?? 'draft');
$isScanned = (bool) ($upload->is_scanned ?? false);
$previewReady = ! empty($upload->preview_path);
$hasTags = (bool) ($upload->has_tags ?? false);
$processingState = (string) ($upload->processing_state ?? 'pending_scan');
return response()->json([
'id' => (string) $upload->id,
'status' => $status,
'is_scanned' => $isScanned,
'preview_ready' => $previewReady,
'has_tags' => $hasTags,
'processing_state' => $processingState,
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
]);
if (ctype_digit($id)) {
$artworkId = (int) $id;
$artwork = Artwork::query()->find($artworkId);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $artwork->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$title = trim((string) ($validated['title'] ?? $artwork->title ?? ''));
if ($title === '') {
$title = 'Untitled artwork';
}
$slugBase = Str::slug($title);
if ($slugBase === '') {
$slugBase = 'artwork';
}
$slug = $slugBase;
$suffix = 2;
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
$slug = $slugBase . '-' . $suffix;
$suffix++;
}
$artwork->title = $title;
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
}
$artwork->slug = $slug;
$artwork->is_public = true;
$artwork->is_approved = true;
$artwork->published_at = now();
$artwork->save();
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK);
}
try {
$upload = $publishService->publish($id, $user);
return response()->json([
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
} catch (UploadNotFoundException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
} catch (UploadPublishValidationException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
}

View File

@@ -1,9 +1,12 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Controllers\CategoryPageController;
use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest; use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Category; use App\Models\Category;
use App\Services\Recommendations\SimilarArtworksService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -51,14 +54,116 @@ class ArtworkController extends Controller
} }
/** /**
* Show a single artwork by slug. Ensure it's public, approved and not deleted. * Show a single artwork by slug. Resolve the slug manually to avoid implicit
* route-model binding exceptions when the slug does not correspond to an artwork.
*/ */
public function show(Artwork $artwork): View public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
{ {
if (! $artwork->is_public || ! $artwork->is_approved || $artwork->trashed()) { // Manually resolve artwork by slug when provided. The route may bind
// the 'artwork' parameter to an Artwork model or pass the slug string.
$foundArtwork = null;
$artworkSlug = null;
if ($artwork instanceof Artwork) {
$foundArtwork = $artwork;
$artworkSlug = $artwork->slug;
} elseif ($artwork) {
$artworkSlug = (string) $artwork;
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
}
// If no artwork was found, treat the request as a category path.
// The route places the artwork slug in the last segment, so include it
// when forwarding to CategoryPageController to support arbitrary-depth paths
if (! $foundArtwork) {
$combinedPath = $categoryPath;
if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
}
return app(CategoryPageController::class)->show(request(), $contentTypeSlug, $combinedPath);
}
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
abort(404); abort(404);
} }
return view('artworks.show', ['artwork' => $artwork]); $foundArtwork->loadMissing(['categories.contentType', 'user']);
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
$similarService = app(SimilarArtworksService::class);
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
$selectedAlgoVersion = $defaultAlgoVersion;
}
$similarArtworks->each(static function (Artwork $item): void {
$item->loadMissing(['categories.contentType', 'user']);
});
$similarItems = $similarArtworks
->map(function (Artwork $item): ?array {
$category = $item->categories->first();
$contentType = $category?->contentType;
if (! $category || ! $contentType || empty($item->slug)) {
return null;
}
return [
'id' => (int) $item->id,
'title' => (string) $item->title,
'author' => (string) optional($item->user)->name,
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
'url' => route('artworks.show', [
'contentTypeSlug' => (string) $contentType->slug,
'categoryPath' => (string) $category->slug,
'artwork' => (string) $item->slug,
]),
];
})
->filter()
->values();
return view('artworks.show', [
'artwork' => $foundArtwork,
'similarItems' => $similarItems,
'similarAlgoVersion' => $selectedAlgoVersion,
]);
}
private function selectAlgoVersionForRequest(Request $request, string $default): string
{
$configured = (array) config('recommendations.ab.algo_versions', []);
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
if ($versions === []) {
return $default;
}
if (! in_array($default, $versions, true)) {
array_unshift($versions, $default);
$versions = array_values(array_unique($versions));
}
$forced = trim((string) $request->query('algo_version', ''));
if ($forced !== '' && in_array($forced, $versions, true)) {
return $forced;
}
if (count($versions) === 1) {
return $versions[0];
}
$visitorKey = $request->user()?->id
? 'u:' . (string) $request->user()->id
: 's:' . (string) $request->session()->getId();
$bucket = abs(crc32($visitorKey)) % count($versions);
return $versions[$bucket] ?? $default;
} }
} }

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Services\AvatarService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AvatarController
{
protected $service;
public function __construct(AvatarService $service)
{
$this->service = $service;
}
/**
* Handle avatar upload request.
*/
public function upload(Request $request)
{
$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$rules = [
'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp',
];
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
return response()->json(['success' => true, 'hash' => $hash], 200);
} catch (\Exception $e) {
return response()->json(['error' => 'Processing failed', 'message' => $e->getMessage()], 500);
}
}
}

View File

@@ -5,12 +5,13 @@ namespace App\Http\Controllers;
use App\Models\Category; use App\Models\Category;
use App\Models\ContentType; use App\Models\ContentType;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
class CategoryPageController extends Controller class CategoryPageController extends Controller
{ {
public function show(Request $request, string $contentTypeSlug, string $categoryPath = null) public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
{ {
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first(); $contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
if (! $contentType) { if (! $contentType) {
@@ -24,38 +25,51 @@ class CategoryPageController extends Controller
$page_title = $contentType->name; $page_title = $contentType->name;
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase'); $page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
// Load artworks for this content type (show gallery on the root page)
$perPage = 40;
$artworks = Artwork::whereHas('categories', function ($q) use ($contentType) {
$q->where('categories.content_type_id', $contentType->id);
})
->published()->public()
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderBy('published_at', 'desc')
->paginate($perPage)
->withQueryString();
return view('legacy.content-type', compact( return view('legacy.content-type', compact(
'contentType', 'contentType',
'rootCategories', 'rootCategories',
'artworks',
'page_title', 'page_title',
'page_meta_description' 'page_meta_description'
)); ));
} }
$segments = array_filter(explode('/', $categoryPath)); $segments = array_filter(explode('/', $categoryPath));
if (empty($segments)) { $slugs = array_values(array_map('strtolower', $segments));
if (empty($slugs)) {
return redirect('/browse-categories'); return redirect('/browse-categories');
} }
// Traverse categories by slug path within the content type // If the first slug exists but under a different content type, redirect to its canonical URL
$current = Category::where('content_type_id', $contentType->id) $firstSlug = $slugs[0];
->whereNull('parent_id') $globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first();
->where('slug', strtolower(array_shift($segments))) if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) {
->first(); $redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs);
return redirect($redirectPath, 301);
}
// Resolve category by path using the helper that validates parent chain and content type
if (! $current) { $category = Category::findByPath($contentType->slug, $slugs);
if (! $category) {
abort(404); abort(404);
} }
foreach ($segments as $slug) {
$current = $current->children()->where('slug', strtolower($slug))->first();
if (! $current) {
abort(404);
}
}
$category = $current;
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get(); $rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
@@ -71,21 +85,23 @@ class CategoryPageController extends Controller
$category->load('children'); $category->load('children');
$gather($category); $gather($category);
// Load artworks that are attached to any of these categories // Load artworks via ArtworkService to support arbitrary-depth category paths
$query = Artwork::whereHas('categories', function ($q) use ($collected) {
$q->whereIn('categories.id', $collected);
})->published()->public();
// Paginate results
$perPage = 40; $perPage = 40;
$artworks = $query->orderBy('published_at', 'desc') try {
->paginate($perPage) $service = app(ArtworkService::class);
->withQueryString(); // service expects an array with contentType slug first, then category slugs
$pathSlugs = array_merge([strtolower($contentTypeSlug)], $slugs);
$artworks = $service->getArtworksByCategoryPath($pathSlugs, $perPage);
} catch (\Throwable $e) {
abort(404);
}
$page_title = $category->name; $page_title = $category->name;
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase'); $page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography'; $page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
// resolved category and breadcrumbs are used by the view
return view('legacy.category-slug', compact( return view('legacy.category-slug', compact(
'contentType', 'contentType',
'category', 'category',

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\ArtworkController;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ContentRouterController extends Controller
{
/**
* Universal router for content-type roots, nested category paths, and artwork slugs.
* Delegates to existing controllers to keep business logic centralized.
*/
public function handle(Request $request, string $contentTypeSlug, ?string $categoryPath = null, $artwork = null)
{
if (! empty($artwork)) {
$normalizedCategoryPath = trim((string) $categoryPath, '/');
return app(ArtworkController::class)->show($request, $contentTypeSlug, $normalizedCategoryPath, $artwork);
}
$path = $categoryPath;
// If no path provided, render the content-type landing (root) page
if (empty($path)) {
// Special-case photography root to use legacy controller
if (strtolower($contentTypeSlug) === 'photography') {
return app(\App\Http\Controllers\Legacy\PhotographyController::class)->index($request);
}
return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null);
}
$segments = array_values(array_filter(explode('/', $path)));
if (empty($segments)) {
return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null);
}
// Treat the last segment as an artwork slug candidate and delegate to ArtworkController::show
$artworkSlug = array_pop($segments);
$categoryPath = implode('/', $segments);
return app(ArtworkController::class)->show($request, $contentTypeSlug, $categoryPath, $artworkSlug);
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Http\Controllers\Dashboard; namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Dashboard\ArtworkEditRequest;
use App\Http\Requests\Dashboard\ArtworkDestroyRequest;
use App\Http\Requests\Dashboard\UpdateArtworkRequest; use App\Http\Requests\Dashboard\UpdateArtworkRequest;
use App\Models\Artwork;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -25,10 +26,9 @@ class ArtworkController extends Controller
]); ]);
} }
public function edit(Request $request, int $id): View public function edit(ArtworkEditRequest $request, int $id): View
{ {
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); $artwork = $request->artwork();
$this->authorize('update', $artwork);
return view('artworks.edit', [ return view('artworks.edit', [
'artwork' => $artwork, 'artwork' => $artwork,
@@ -38,8 +38,7 @@ class ArtworkController extends Controller
public function update(UpdateArtworkRequest $request, int $id): RedirectResponse public function update(UpdateArtworkRequest $request, int $id): RedirectResponse
{ {
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); $artwork = $request->artwork();
$this->authorize('update', $artwork);
$data = $request->validated(); $data = $request->validated();
@@ -83,10 +82,9 @@ class ArtworkController extends Controller
->with('status', 'Artwork updated.'); ->with('status', 'Artwork updated.');
} }
public function destroy(Request $request, int $id): RedirectResponse public function destroy(ArtworkDestroyRequest $request, int $id): RedirectResponse
{ {
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail(); $artwork = $request->artwork();
$this->authorize('delete', $artwork);
// Best-effort remove stored file. // Best-effort remove stored file.
if (! empty($artwork->file_path) && Storage::disk('public')->exists($artwork->file_path)) { if (! empty($artwork->file_path) && Storage::disk('public')->exists($artwork->file_path)) {

View File

@@ -21,61 +21,105 @@ class PhotographyController extends Controller
public function index(Request $request) public function index(Request $request)
{ {
// Legacy group mapping: Photography => id 3 // Legacy group mapping: Photography => id 3
$group = 'Photography'; // Determine the requested content type from the first URL segment (photography|wallpapers|skins)
$id = 3; $segment = strtolower($request->segment(1) ?? 'photography');
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
// Fetch legacy category info if available // Human-friendly group name (used by legacy templates)
$category = null; $group = ucfirst($contentSlug);
try {
if (Schema::hasTable('artworks_categories')) { // Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType
$category = DB::table('artworks_categories') $id = null;
->select('category_name', 'rootid', 'section_id', 'description', 'category_id') if ($contentSlug === 'photography') {
->where('category_id', $id) $id = 3; // legacy root id for photography in oldSite (kept for backward compatibility)
->first();
} }
} catch (\Throwable $e) {
// Fetch legacy category info if available (only when we have an id)
$category = null; $category = null;
} try {
if ($id !== null && Schema::hasTable('artworks_categories')) {
$page_title = $category->category_name ?? 'Photography'; $category = DB::table('artworks_categories')
$tidy = $category->description ?? null; ->select('category_name', 'rootid', 'section_id', 'description', 'category_id')
->where('category_id', $id)
$perPage = 40; ->first();
// Use ArtworkService to get artworks for the content type 'photography'
try {
$artworks = $this->artworks->getArtworksByContentType('photography', $perPage);
} catch (\Throwable $e) {
$artworks = collect();
}
// Load subcategories (legacy) if available
$subcategories = collect();
try {
if (Schema::hasTable('artworks_categories')) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get();
if ($subcategories->count() == 0 && !empty($category->rootid)) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get();
} }
} catch (\Throwable $e) {
$category = null;
} }
} catch (\Throwable $e) {
$subcategories = collect();
}
// Fallback to authoritative categories table when legacy table is missing/empty // Page title and description: prefer legacy category when present, otherwise use ContentType data
if (! $subcategories || $subcategories->count() === 0) { $ct = ContentType::where('slug', $contentSlug)->first();
$ct = ContentType::where('slug', 'photography')->first(); $page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
if ($ct) { $tidy = $category->description ?? ($ct->description ?? null);
$subcategories = $ct->rootCategories()
->orderBy('sort_order') $perPage = 40;
->orderBy('name')
->get() // Load artworks for the requested content type using standard pagination
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name]); try {
} else { $artQuery = \App\Models\Artwork::public()
->published()
->whereHas('categories', function ($q) use ($ct) {
$q->where('categories.content_type_id', $ct->id);
})
->with([
'user:id,name',
'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->orderByDesc('published_at');
$artworks = $artQuery->paginate($perPage)->withQueryString();
} catch (\Throwable $e) {
// Return an empty paginator so views using ->links() / ->firstItem() work
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
'path' => url()->current(),
]);
}
// Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories
$subcategories = collect();
try {
if ($id !== null && Schema::hasTable('artworks_categories')) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get();
if ($subcategories->count() == 0 && !empty($category->rootid)) {
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get();
}
}
} catch (\Throwable $e) {
$subcategories = collect(); $subcategories = collect();
} }
}
return view('legacy.photography', compact('page_title','tidy','group','artworks','subcategories','id')); if (! $subcategories || $subcategories->count() === 0) {
if ($ct) {
$subcategories = $ct->rootCategories()
->orderBy('sort_order')
->orderBy('name')
->get()
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
} else {
$subcategories = collect();
}
}
// Coerce collections to a paginator so the view's pagination helpers work
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
$page = (int) ($request->query('page', 1));
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
'path' => url()->current(),
'query' => request()->query(),
]);
}
// Prepare variables for the modern content-type view
$contentType = ContentType::where('slug', $contentSlug)->first();
$rootCategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
$page_meta_description = $tidy;
return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
} }
} }

View File

@@ -37,29 +37,46 @@ class UserController extends Controller
$request->session()->flash('error', 'Password not changed.'); $request->session()->flash('error', 'Password not changed.');
} }
} else { } else {
$data = $request->only(['real_name','web','country_code','signature','description','about_me']); // Map legacy form fields into the modern schema.
$user->real_name = $data['real_name'] ?? $user->real_name; $data = $request->only(['name','web','country_code','signature','description','about_me']);
$user->web = $data['web'] ?? $user->web;
$user->country_code = $data['country_code'] ?? $user->country_code; // Core user column: `name`
$user->signature = $data['signature'] ?? $user->signature; if (isset($data['name'])) {
$user->description = $data['description'] ?? $user->description; $user->name = $data['name'] ?? $user->name;
$user->about_me = $data['about_me'] ?? $user->about_me; }
// Collect other profile updates to persist into `user_profiles` when available
$profileUpdates = [];
if (!empty($data['web'])) $profileUpdates['website'] = $data['web'];
if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature'];
if (!empty($data['description'])) $profileUpdates['description'] = $data['description'];
if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me'];
if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code'];
$d1 = $request->input('date1'); $d1 = $request->input('date1');
$d2 = $request->input('date2'); $d2 = $request->input('date2');
$d3 = $request->input('date3'); $d3 = $request->input('date3');
if ($d1 && $d2 && $d3) { if ($d1 && $d2 && $d3) {
$user->birth = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1); $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1);
} }
$user->gender = $request->input('gender', $user->gender); $userGender = $request->input('gender', $user->gender);
$user->mlist = $request->has('newsletter') ? 1 : 0; if (!empty($userGender)) {
$user->friend_upload_notice = $request->has('friend_upload_notice') ? 1 : 0; $g = strtolower($userGender);
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
$profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender);
}
$profileUpdates['mlist'] = $request->has('newsletter') ? 1 : 0;
$profileUpdates['friend_upload_notice'] = $request->has('friend_upload_notice') ? 1 : 0;
// Files: avatar/photo/emoticon
if ($request->hasFile('avatar')) { if ($request->hasFile('avatar')) {
$f = $request->file('avatar'); $f = $request->file('avatar');
$name = $user->id . '.' . $f->getClientOriginalExtension(); $name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('avatar'), $name); $f->move(public_path('avatar'), $name);
// store filename in profile avatar (legacy field) — modern avatar pipeline will later migrate
$profileUpdates['avatar'] = $name;
$user->icon = $name; $user->icon = $name;
} }
@@ -67,6 +84,7 @@ class UserController extends Controller
$f = $request->file('personal_picture'); $f = $request->file('personal_picture');
$name = $user->id . '.' . $f->getClientOriginalExtension(); $name = $user->id . '.' . $f->getClientOriginalExtension();
$f->move(public_path('user-picture'), $name); $f->move(public_path('user-picture'), $name);
$profileUpdates['cover_image'] = $name;
$user->picture = $name; $user->picture = $name;
} }
@@ -77,25 +95,28 @@ class UserController extends Controller
$user->eicon = $name; $user->eicon = $name;
} }
// Save core user fields
$user->save(); $user->save();
// Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table
try {
if (!empty($profileUpdates) && Schema::hasTable('user_profiles')) {
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates + ['updated_at' => now(), 'created_at' => now()]);
} elseif (!empty($profileUpdates)) {
DB::table('users')->where('id', $user->id)->update($profileUpdates);
}
} catch (\Throwable $e) {
// ignore persistence errors for legacy path
}
$request->session()->flash('status', 'Profile updated.'); $request->session()->flash('status', 'Profile updated.');
} }
} }
// Prepare birth date parts for the legacy form // Prepare birth date parts for the legacy form (initialized — parsed after merging profiles)
$birthDay = null; $birthDay = null;
$birthMonth = null; $birthMonth = null;
$birthYear = null; $birthYear = null;
if (! empty($user->birth)) {
try {
$dt = Carbon::parse($user->birth);
$birthDay = $dt->format('d');
$birthMonth = $dt->format('m');
$birthYear = $dt->format('Y');
} catch (\Throwable $e) {
// ignore parse errors
}
}
// Load country list if available (legacy table names) // Load country list if available (legacy table names)
$countries = collect(); $countries = collect();
@@ -109,6 +130,50 @@ class UserController extends Controller
$countries = collect(); $countries = collect();
} }
// Merge modern `user_profiles` and `user_social_links` into the user object for the view
try {
if (Schema::hasTable('user_profiles')) {
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
if ($profile) {
// map modern profile fields onto the legacy user properties/helpers used by the view
if (isset($profile->website)) $user->homepage = $profile->website;
if (isset($profile->about)) $user->about_me = $profile->about;
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
if (isset($profile->gender)) $user->gender = $profile->gender;
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
if (isset($profile->avatar)) $user->icon = $profile->avatar;
if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
if (isset($profile->signature)) $user->signature = $profile->signature;
if (isset($profile->description)) $user->description = $profile->description;
}
}
} catch (\Throwable $e) {
// ignore profile merge errors
}
try {
if (Schema::hasTable('user_social_links')) {
$social = DB::table('user_social_links')->where('user_id', $user->id)->first();
if ($social) {
$user->social = $social;
}
}
} catch (\Throwable $e) {
// ignore social links errors
}
// Parse birth date parts after merging `user_profiles` so profile birthdate is used
if (! empty($user->birth)) {
try {
$dt = Carbon::parse($user->birth);
$birthDay = $dt->format('d');
$birthMonth = $dt->format('m');
$birthYear = $dt->format('Y');
} catch (\Throwable $e) {
// ignore parse errors
}
}
return view('legacy.user', [ return view('legacy.user', [
'user' => $user, 'user' => $user,
'birthDay' => $birthDay, 'birthDay' => $birthDay,

View File

@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Artwork; use App\Http\Requests\Manage\ManageArtworkEditRequest;
use App\Models\ArtworkCategory; use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -38,13 +39,9 @@ class ManageController extends Controller
]); ]);
} }
public function edit(Request $request, $id) public function edit(ManageArtworkEditRequest $request, $id)
{ {
$userId = $request->user()->id; $artwork = $request->artwork();
$artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
// If artworks no longer have a single `category` column, fetch pivot selection // If artworks no longer have a single `category` column, fetch pivot selection
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id'); $selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
@@ -63,22 +60,10 @@ class ManageController extends Controller
]); ]);
} }
public function update(Request $request, $id) public function update(ManageArtworkUpdateRequest $request, $id)
{ {
$userId = $request->user()->id; $existing = $request->artwork();
$existing = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first(); $data = $request->validated();
if (! $existing) {
abort(404);
}
$data = $request->validate([
'name' => 'required|string|max:255',
'section' => 'nullable|integer',
'description' => 'nullable|string',
'artwork' => 'nullable|file|image',
'attachment' => 'nullable|file',
]);
$update = [ $update = [
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? $existing->description, 'description' => $data['description'] ?? $existing->description,
@@ -100,7 +85,7 @@ class ManageController extends Controller
$update['fname'] = basename($attPath); $update['fname'] = basename($attPath);
} }
DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update); DB::table('artworks')->where('id', (int)$id)->update($update);
// Update pivot: set single category selection for this artwork // Update pivot: set single category selection for this artwork
if (isset($data['section'])) { if (isset($data['section'])) {
@@ -114,13 +99,9 @@ class ManageController extends Controller
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.'); return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
} }
public function destroy(Request $request, $id) public function destroy(ManageArtworkDestroyRequest $request, $id)
{ {
$userId = $request->user()->id; $artwork = $request->artwork();
$artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
if (! $artwork) {
abort(404);
}
// delete files if present (stored in new storage location) // delete files if present (stored in new storage location)
if (!empty($artwork->fname)) { if (!empty($artwork->fname)) {
@@ -130,7 +111,7 @@ class ManageController extends Controller
Storage::delete('public/uploads/artworks/' . $artwork->picture); Storage::delete('public/uploads/artworks/' . $artwork->picture);
} }
DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete(); DB::table('artworks')->where('id', (int)$id)->delete();
return redirect()->route('manage')->with('status', 'Artwork deleted.'); return redirect()->route('manage')->with('status', 'Artwork deleted.');
} }

View File

@@ -8,6 +8,8 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password as PasswordRule;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -24,17 +26,124 @@ class ProfileController extends Controller
/** /**
* Update the user's profile information. * Update the user's profile information.
*/ */
public function update(ProfileUpdateRequest $request): RedirectResponse public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
{ {
$request->user()->fill($request->validated()); $user = $request->user();
if ($request->user()->isDirty('email')) { // Core fields
$request->user()->email_verified_at = null; $validated = $request->validated();
logger()->debug('Profile update validated data', $validated);
// Username is read-only and must not be changed here.
// Use `name` for the real/display name field.
if (isset($validated['name'])) {
$user->name = $validated['name'];
} }
$request->user()->save(); // Only allow setting email when we don't have one yet (legacy users)
if (!empty($validated['email']) && empty($user->email)) {
$user->email = $validated['email'];
$user->email_verified_at = null;
}
return Redirect::route('profile.edit')->with('status', 'profile-updated'); $user->save();
// Profile fields - target columns in `user_profiles` per spec
$profileUpdates = [];
if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about'];
// website / legacy homepage
if (!empty($validated['web'])) {
$profileUpdates['website'] = $validated['web'];
} elseif (!empty($validated['homepage'])) {
$profileUpdates['website'] = $validated['homepage'];
}
// Birthday -> store as birthdate
$day = $validated['day'] ?? null;
$month = $validated['month'] ?? null;
$year = $validated['year'] ?? null;
if ($year && $month && $day) {
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day);
}
// Gender normalization -> store as provided normalized value
if (!empty($validated['gender'])) {
$g = strtolower($validated['gender']);
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
}
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
// Mailing and notify flags: normalize true/false when saving
if (array_key_exists('mailing', $validated)) {
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
}
if (array_key_exists('notify', $validated)) {
$profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
}
// signature/description should be stored in their own columns
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
// 'about' direct field (ensure explicit about wins when provided)
if (isset($validated['about'])) $profileUpdates['about'] = $validated['about'];
// Files: avatar -> use AvatarService, emoticon and photo -> store to public disk
if ($request->hasFile('avatar')) {
try {
$hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
// store returned hash into profile avatar column
if (!empty($hash)) {
$profileUpdates['avatar'] = $hash;
}
} catch (\Exception $e) {
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
}
}
if ($request->hasFile('emoticon')) {
$file = $request->file('emoticon');
$fname = $file->getClientOriginalName();
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
} catch (\Exception $e) {}
}
if ($request->hasFile('photo')) {
$file = $request->file('photo');
$fname = $file->getClientOriginalName();
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
// store cover image filename in user_profiles.cover_image (fallback to users.picture)
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
$profileUpdates['cover_image'] = $fname;
} else {
try {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
} catch (\Exception $e) {}
}
}
// Persist profile updates now that files (avatar/cover) have been handled
try {
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
if (!empty($profileUpdates)) {
\Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
}
} else {
if (!empty($profileUpdates)) {
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates);
}
}
} catch (\Exception $e) {
logger()->error('Profile update error: '.$e->getMessage());
}
return Redirect::to('/user')->with('status', 'profile-updated');
} }
/** /**
@@ -58,4 +167,21 @@ class ProfileController extends Controller
return Redirect::to('/'); return Redirect::to('/');
} }
/**
* Update the user's password.
*/
public function password(Request $request): RedirectResponse
{
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', PasswordRule::min(8)],
]);
$user = $request->user();
$user->password = Hash::make($request->input('password'));
$user->save();
return Redirect::to('/user')->with('status', 'password-updated');
}
} }

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use Illuminate\View\View;
final class TagController extends Controller
{
public function show(Tag $tag): View
{
$artworks = $tag->artworks()
->public()
->published()
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.views')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->paginate(24);
return view('tags.show', [
'tag' => $tag,
'artworks' => $artworks,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureAdminOrModerator
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$role = strtolower((string) ($user->role ?? ''));
if (! in_array($role, ['admin', 'moderator'], true)) {
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
final class HandleInertiaRequests extends Middleware
{
protected $rootView = 'upload';
public function version(Request $request): ?string
{
return parent::version($request);
}
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
] : null,
],
'cdn' => [
'files_url' => config('cdn.files_url'),
],
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Artworks;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkCreateRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:150',
'description' => 'nullable|string',
'category' => 'nullable|string|max:120',
'tags' => 'nullable|string|max:200',
'license' => 'nullable|boolean',
];
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Artwork create unauthorized access', [
'reason' => $reason,
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Artworks;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkTagsStoreRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()) {
throw new NotFoundHttpException();
}
return true;
}
public function rules(): array
{
return [
'tags' => 'required|array|max:15',
'tags.*' => 'required|string|max:64',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Artworks;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkTagsUpdateRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()) {
throw new NotFoundHttpException();
}
return true;
}
public function rules(): array
{
return [
'tags' => 'required|array|max:15',
'tags.*' => 'required|string|max:64',
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Dashboard;
use App\Models\Artwork;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkDestroyRequest extends FormRequest
{
private ?Artwork $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = Artwork::query()->whereKey($id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [];
}
public function artwork(): Artwork
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Dashboard artwork delete unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Dashboard;
use App\Models\Artwork;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ArtworkEditRequest extends FormRequest
{
private ?Artwork $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = Artwork::query()->whereKey($id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [];
}
public function artwork(): Artwork
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Dashboard artwork edit unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -2,13 +2,36 @@
namespace App\Http\Requests\Dashboard; namespace App\Http\Requests\Dashboard;
use App\Models\Artwork;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class UpdateArtworkRequest extends FormRequest class UpdateArtworkRequest extends FormRequest
{ {
private ?Artwork $artwork = null;
public function authorize(): bool public function authorize(): bool
{ {
// Authorization is enforced in the controller via ArtworkPolicy. $user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = Artwork::query()->whereKey($id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true; return true;
} }
@@ -21,4 +44,28 @@ class UpdateArtworkRequest extends FormRequest
'file' => 'nullable|image|max:102400', 'file' => 'nullable|image|max:102400',
]; ];
} }
public function artwork(): Artwork
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Dashboard artwork update unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
} }

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkDestroyRequest extends FormRequest
{
private ?object $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = DB::table('artworks')->where('id', $id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [];
}
public function artwork(): object
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Manage artwork delete unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkEditRequest extends FormRequest
{
private ?object $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = DB::table('artworks')->where('id', $id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [];
}
public function artwork(): object
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Manage artwork edit unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ManageArtworkUpdateRequest extends FormRequest
{
private ?object $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$id = (int) $this->route('id');
if ($id <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = DB::table('artworks')->where('id', $id)->first();
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'section' => 'nullable|integer',
'description' => 'nullable|string',
'artwork' => 'nullable|file|image',
'attachment' => 'nullable|file',
];
}
public function artwork(): object
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Manage artwork update unauthorized access', [
'reason' => $reason,
'artwork_id' => $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -16,7 +16,7 @@ class ProfileUpdateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'name' => ['required', 'string', 'max:255'], 'username' => ['sometimes', 'string', 'max:255'],
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
@@ -25,6 +25,21 @@ class ProfileUpdateRequest extends FormRequest
'max:255', 'max:255',
Rule::unique(User::class)->ignore($this->user()->id), Rule::unique(User::class)->ignore($this->user()->id),
], ],
'name' => ['nullable', 'string', 'max:255'],
'web' => ['nullable', 'url', 'max:255'],
'day' => ['nullable', 'numeric', 'between:1,31'],
'month' => ['nullable', 'numeric', 'between:1,12'],
'year' => ['nullable', 'numeric', 'digits:4'],
'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'],
'country' => ['nullable', 'string', 'max:10'],
'mailing' => ['nullable', 'boolean'],
'notify' => ['nullable', 'boolean'],
'about' => ['nullable', 'string'],
'signature' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
]; ];
} }
} }

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Tags;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class PopularTagsRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()) {
throw new NotFoundHttpException();
}
return true;
}
public function rules(): array
{
return [
'limit' => 'nullable|integer|min:1|max:50',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Tags;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class TagSearchRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()) {
throw new NotFoundHttpException();
}
return true;
}
public function rules(): array
{
return [
'q' => 'nullable|string|max:64',
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadCancelRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$sessionId = (string) $this->input('session_id');
if ($sessionId === '') {
$this->logUnauthorized('missing_session_id');
$this->denyAsNotFound();
}
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
if (! $token) {
$this->logUnauthorized('missing_token');
$this->denyAsNotFound();
}
$sessions = $this->container->make(UploadSessionRepository::class);
$session = $sessions->get($sessionId);
if (! $session || $session->userId !== $user->id) {
$this->logUnauthorized('not_owned_or_missing');
$this->denyAsNotFound();
}
$tokens = $this->container->make(UploadTokenService::class);
$payload = $tokens->get((string) $token);
if (! $payload) {
$this->logUnauthorized('invalid_token');
$this->denyAsNotFound();
}
if (($payload['session_id'] ?? null) !== $sessionId) {
$this->logUnauthorized('token_session_mismatch');
$this->denyAsNotFound();
}
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
$this->logUnauthorized('token_user_mismatch');
$this->denyAsNotFound();
}
return true;
}
public function rules(): array
{
return [
'session_id' => 'required|uuid',
'upload_token' => 'nullable|string|min:40|max:200',
];
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload cancel unauthorized access', [
'reason' => $reason,
'session_id' => (string) $this->input('session_id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadChunkRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$sessionId = (string) $this->input('session_id');
if ($sessionId === '') {
$this->logUnauthorized('missing_session_id');
$this->denyAsNotFound();
}
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
if (! $token) {
$this->logUnauthorized('missing_token');
$this->denyAsNotFound();
}
$sessions = $this->container->make(UploadSessionRepository::class);
$session = $sessions->get($sessionId);
if (! $session || $session->userId !== $user->id) {
$this->logUnauthorized('not_owned_or_missing');
$this->denyAsNotFound();
}
$tokens = $this->container->make(UploadTokenService::class);
$payload = $tokens->get((string) $token);
if (! $payload) {
$this->logUnauthorized('invalid_token');
$this->denyAsNotFound();
}
if (($payload['session_id'] ?? null) !== $sessionId) {
$this->logUnauthorized('token_session_mismatch');
$this->denyAsNotFound();
}
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
$this->logUnauthorized('token_user_mismatch');
$this->denyAsNotFound();
}
return true;
}
public function rules(): array
{
$maxBytes = (int) config('uploads.chunk.max_bytes', 0);
$maxKb = $maxBytes > 0 ? (int) ceil($maxBytes / 1024) : 5120;
$chunkSizeRule = $maxBytes > 0 ? 'required|integer|min:1|max:' . $maxBytes : 'required|integer|min:1';
return [
'session_id' => 'required|uuid',
'offset' => 'required|integer|min:0',
'total_size' => 'required|integer|min:1',
'chunk_size' => $chunkSizeRule,
'chunk' => 'required|file|max:' . $maxKb,
'upload_token' => 'nullable|string|min:40|max:200',
];
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload chunk unauthorized access', [
'reason' => $reason,
'session_id' => (string) $this->input('session_id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Models\Artwork;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadFinishRequest extends FormRequest
{
private ?Artwork $artwork = null;
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$sessionId = (string) $this->input('session_id');
if ($sessionId === '') {
$this->logUnauthorized('missing_session_id');
$this->denyAsNotFound();
}
$sessions = $this->container->make(UploadSessionRepository::class);
$session = $sessions->get($sessionId);
if (! $session || $session->userId !== $user->id) {
$this->logUnauthorized('not_owned_or_missing');
$this->denyAsNotFound();
}
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
if ($token) {
$tokens = $this->container->make(UploadTokenService::class);
$payload = $tokens->get((string) $token);
if (! $payload) {
$this->logUnauthorized('invalid_token');
$this->denyAsNotFound();
}
if (($payload['session_id'] ?? null) !== $sessionId) {
$this->logUnauthorized('token_session_mismatch');
$this->denyAsNotFound();
}
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
$this->logUnauthorized('token_user_mismatch');
$this->denyAsNotFound();
}
}
$artworkId = (int) $this->input('artwork_id');
if ($artworkId <= 0) {
$this->logUnauthorized('missing_artwork_id');
$this->denyAsNotFound();
}
$artwork = Artwork::query()->find($artworkId);
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
$this->logUnauthorized('artwork_not_owned_or_missing');
$this->denyAsNotFound();
}
$this->artwork = $artwork;
return true;
}
public function rules(): array
{
return [
'session_id' => 'required|uuid',
'artwork_id' => 'required|integer',
'upload_token' => 'nullable|string|min:40|max:200',
];
}
public function artwork(): Artwork
{
if (! $this->artwork) {
$this->denyAsNotFound();
}
return $this->artwork;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload finish unauthorized access', [
'reason' => $reason,
'session_id' => (string) $this->input('session_id'),
'artwork_id' => $this->input('artwork_id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use Illuminate\Foundation\Http\FormRequest;
final class UploadInitRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user();
}
public function rules(): array
{
return [
'client' => 'nullable|string|max:64',
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadStatusRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
$this->logUnauthorized('missing_user');
$this->denyAsNotFound();
}
$sessionId = (string) $this->route('id');
if ($sessionId === '') {
$this->logUnauthorized('missing_session_id');
$this->denyAsNotFound();
}
$sessions = $this->container->make(UploadSessionRepository::class);
$session = $sessions->get($sessionId);
if (! $session || $session->userId !== $user->id) {
$this->logUnauthorized('not_owned_or_missing');
$this->denyAsNotFound();
}
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
if ($token) {
$tokens = $this->container->make(UploadTokenService::class);
$payload = $tokens->get((string) $token);
if (! $payload) {
$this->logUnauthorized('invalid_token');
$this->denyAsNotFound();
}
if (($payload['session_id'] ?? null) !== $sessionId) {
$this->logUnauthorized('token_session_mismatch');
$this->denyAsNotFound();
}
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
$this->logUnauthorized('token_user_mismatch');
$this->denyAsNotFound();
}
}
return true;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload status unauthorized access', [
'reason' => $reason,
'session_id' => (string) $this->route('id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
]);
}
public function rules(): array
{
return [
'id' => 'required|uuid',
'upload_token' => 'nullable|string|min:40|max:200',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'id' => $this->route('id'),
]);
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
use App\Services\ThumbnailService;
class ArtworkListResource extends JsonResource class ArtworkListResource extends JsonResource
{ {
@@ -48,6 +48,8 @@ class ArtworkListResource extends JsonResource
$categoryPath = $primaryCategory->full_slug_path ?? null; $categoryPath = $primaryCategory->full_slug_path ?? null;
} }
$slugVal = $get('slug'); $slugVal = $get('slug');
$hash = (string) ($get('hash') ?? '');
$thumbExt = (string) ($get('thumb_ext') ?? '');
$webUrl = $contentTypeSlug && $categoryPath && $slugVal $webUrl = $contentTypeSlug && $categoryPath && $slugVal
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal ? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
: null; : null;
@@ -60,7 +62,7 @@ class ArtworkListResource extends JsonResource
'width' => $get('width'), 'width' => $get('width'),
'height' => $get('height'), 'height' => $get('height'),
], ],
'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))), 'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
'author' => $this->whenLoaded('user', function () { 'author' => $this->whenLoaded('user', function () {
return [ return [
'name' => $this->user->name ?? null, 'name' => $this->user->name ?? null,

View File

@@ -2,7 +2,6 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
class ArtworkResource extends JsonResource class ArtworkResource extends JsonResource
@@ -32,6 +31,21 @@ class ArtworkResource extends JsonResource
} }
return null; return null;
}; };
$hash = (string) ($get('hash') ?? '');
$fileExt = (string) ($get('file_ext') ?? '');
$filesBase = rtrim((string) config('cdn.files_url', ''), '/');
$buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string {
$normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue));
$normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue));
if ($normalizedHash === '' || $normalizedExt === '') return null;
$h1 = substr($normalizedHash, 0, 2);
$h2 = substr($normalizedHash, 2, 2);
if ($h1 === '' || $h2 === '' || $filesBase === '') return null;
return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt);
};
return [ return [
'slug' => $get('slug'), 'slug' => $get('slug'),
'title' => $get('title'), 'title' => $get('title'),
@@ -39,10 +53,10 @@ class ArtworkResource extends JsonResource
'width' => $get('width'), 'width' => $get('width'),
'height' => $get('height'), 'height' => $get('height'),
// File URLs: produce public URLs without exposing internal file_path // File URLs are derived from hash/ext (no DB path dependency)
'file' => [ 'file' => [
'name' => $get('file_name') ?? null, 'name' => $get('file_name') ?? null,
'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))), 'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)),
'size' => $get('file_size') ?? null, 'size' => $get('file_size') ?? null,
'mime_type' => $get('mime_type') ?? null, 'mime_type' => $get('mime_type') ?? null,
], ],

View File

@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use App\Services\TagService;
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\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
final class AutoTagArtworkJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Keep retries low; tagging must never block publish.
*/
public int $tries = 3;
/**
* Hard timeout safety for queue workers.
*/
public int $timeout = 20;
/**
* @param int $artworkId
* @param string $hash File hash used to build public derivative URLs.
*/
public function __construct(
private readonly int $artworkId,
private readonly string $hash,
) {
$queue = (string) config('vision.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function backoff(): array
{
return [2, 10, 30];
}
public function handle(TagService $tagService, TagNormalizer $normalizer): void
{
if (! (bool) config('vision.enabled', true)) {
return;
}
$artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
if (! $artwork) {
return;
}
$imageUrl = $this->buildImageUrl($this->hash);
if ($imageUrl === null) {
return;
}
$processingKey = $this->processingKey($this->artworkId, $this->hash);
if (! $this->acquireProcessingLock($processingKey)) {
return;
}
$ref = (string) Str::uuid();
try {
$clipTags = $this->callClip($imageUrl, $ref);
$yoloTags = [];
if ($this->shouldRunYolo($artwork)) {
$yoloTags = $this->callYolo($imageUrl, $ref);
}
$merged = $this->mergeTags($clipTags, $yoloTags);
if ($merged === []) {
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
return;
}
// Normalize explicitly (requirement), then attach via TagService (source=ai + confidence).
$payload = [];
foreach ($merged as $row) {
$tag = $normalizer->normalize((string) ($row['tag'] ?? ''));
if ($tag === '') {
continue;
}
$payload[] = [
'tag' => $tag,
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null,
];
}
$tagService->attachAiTags($artwork, $payload);
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
} catch (\Throwable $e) {
Log::error('AutoTagArtworkJob failed', [
'ref' => $ref,
'artwork_id' => $this->artworkId,
'hash' => $this->hash,
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
]);
// Retry-safe: allow queue retry on transient failures.
throw $e;
} finally {
$this->releaseProcessingLock($processingKey);
}
}
private function buildImageUrl(string $hash): ?string
{
$base = (string) config('cdn.files_url');
$base = rtrim($base, '/');
if ($base === '') {
return null;
}
$variant = (string) config('vision.image_variant', 'md');
$variant = $variant !== '' ? $variant : 'md';
// Matches the upload public path layout used for derivatives (img/aa/bb/cc/variant.webp).
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
$clean = str_pad($clean, 6, '0');
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
$path = 'img/' . implode('/', $segments) . '/' . $variant . '.webp';
return $base . '/' . $path;
}
/**
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function callClip(string $imageUrl, string $ref): array
{
$base = trim((string) config('vision.clip.base_url', ''));
if ($base === '') {
return [];
}
$endpoint = (string) config('vision.clip.endpoint', '/analyze');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.clip.timeout_seconds', 8);
$connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2);
$retries = (int) config('vision.clip.retries', 1);
$delay = (int) config('vision.clip.retry_delay_ms', 200);
try {
$response = Http::acceptJson()
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, [
'url' => $imageUrl,
'image_url' => $imageUrl,
'limit' => 8,
'artwork_id' => $this->artworkId,
'hash' => $this->hash,
]);
} catch (\Throwable $e) {
Log::warning('CLIP analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
throw $e;
}
if ($response->serverError()) {
Log::warning('CLIP analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
throw new \RuntimeException('CLIP server error: ' . $response->status());
}
if (! $response->ok()) {
Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
// Fallback: try uploading the local derivative file to the gateway's file upload
// endpoint (`/analyze/all/file`) if the gateway cannot fetch the public URL.
try {
$variant = (string) config('vision.image_variant', 'md');
$row = DB::table('artwork_files')
->where('artwork_id', $this->artworkId)
->where('variant', $variant)
->first();
if ($row && ! empty($row->path)) {
$storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR);
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path);
if (is_file($absolute) && is_readable($absolute)) {
$uploadUrl = rtrim($base, '/') . '/analyze/all/file';
try {
$attach = file_get_contents($absolute);
if ($attach !== false) {
$uploadResp = Http::attach('file', $attach, basename($absolute))
->post($uploadUrl, ['limit' => 5]);
if ($uploadResp->ok()) {
return $this->extractTagList($uploadResp->json());
}
Log::warning('CLIP upload fallback non-ok', ['ref' => $ref, 'status' => $uploadResp->status(), 'body' => $this->safeBody($uploadResp->body())]);
}
} catch (\Throwable $e) {
Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]);
}
}
}
} catch (\Throwable $e) {
Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]);
}
return [];
}
return $this->extractTagList($response->json());
}
/**
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function callYolo(string $imageUrl, string $ref): array
{
if (! (bool) config('vision.yolo.enabled', true)) {
return [];
}
$base = trim((string) config('vision.yolo.base_url', ''));
if ($base === '') {
return [];
}
$endpoint = (string) config('vision.yolo.endpoint', '/analyze');
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
$timeout = (int) config('vision.yolo.timeout_seconds', 8);
$connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2);
$retries = (int) config('vision.yolo.retries', 1);
$delay = (int) config('vision.yolo.retry_delay_ms', 200);
try {
$response = Http::acceptJson()
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, [
'url' => $imageUrl,
'image_url' => $imageUrl,
'conf' => 0.25,
'artwork_id' => $this->artworkId,
'hash' => $this->hash,
]);
} catch (\Throwable $e) {
Log::warning('YOLO analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
throw $e;
}
if ($response->serverError()) {
Log::warning('YOLO analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
throw new \RuntimeException('YOLO server error: ' . $response->status());
}
if (! $response->ok()) {
Log::warning('YOLO analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
return [];
}
return $this->extractTagList($response->json());
}
private function shouldRunYolo(Artwork $artwork): bool
{
if (! (bool) config('vision.yolo.enabled', true)) {
return false;
}
if (! (bool) config('vision.yolo.photography_only', true)) {
return true;
}
foreach ($artwork->categories as $category) {
$slug = strtolower((string) ($category->contentType?->slug ?? ''));
if ($slug === 'photography') {
return true;
}
}
return false;
}
/**
* @param mixed $json
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function extractTagList(mixed $json): array
{
if (is_array($json) && $this->isListOfTags($json)) {
return $json;
}
if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) {
return $json['tags'];
}
if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) {
return $json['data'];
}
// Common YOLO-style response: objects: [{label, confidence}]
if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) {
$out = [];
foreach ($json['objects'] as $obj) {
if (! is_array($obj)) {
continue;
}
$label = (string) ($obj['label'] ?? $obj['tag'] ?? '');
if ($label === '') {
continue;
}
$out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null];
}
return $out;
}
return [];
}
/**
* @param array<mixed> $arr
*/
private function isListOfTags(array $arr): bool
{
if ($arr === []) {
return true;
}
foreach ($arr as $row) {
if (! is_array($row)) {
return false;
}
if (! array_key_exists('tag', $row)) {
return false;
}
}
return true;
}
/**
* @param array<int, array{tag: string, confidence?: float|int|null}> $a
* @param array<int, array{tag: string, confidence?: float|int|null}> $b
* @return array<int, array{tag: string, confidence?: float|int|null}>
*/
private function mergeTags(array $a, array $b): array
{
$byTag = [];
foreach (array_merge($a, $b) as $row) {
$tag = (string) ($row['tag'] ?? '');
if ($tag === '') {
continue;
}
$conf = $row['confidence'] ?? null;
$conf = is_numeric($conf) ? (float) $conf : null;
// Keep highest confidence for duplicates.
if (! isset($byTag[$tag])) {
$byTag[$tag] = ['tag' => $tag, 'confidence' => $conf];
continue;
}
$existing = $byTag[$tag]['confidence'];
if ($existing === null || ($conf !== null && $conf > (float) $existing)) {
$byTag[$tag]['confidence'] = $conf;
}
}
return array_values($byTag);
}
private function processingKey(int $artworkId, string $hash): string
{
return 'autotag:processing:' . $artworkId . ':' . $hash;
}
private function processedKey(int $artworkId, string $hash): string
{
return 'autotag:processed:' . $artworkId . ':' . $hash;
}
private function acquireProcessingLock(string $key): bool
{
try {
$didSet = Redis::setnx($key, 1);
if ($didSet) {
Redis::expire($key, 1800);
}
return (bool) $didSet;
} catch (\Throwable $e) {
// If Redis is unavailable, proceed without dedupe.
return true;
}
}
private function releaseProcessingLock(string $key): void
{
try {
Redis::del($key);
} catch (\Throwable $e) {
// ignore
}
}
private function markProcessed(string $key): void
{
try {
Redis::setex($key, 604800, 1); // 7 days
} catch (\Throwable $e) {
// ignore
}
}
private function safeBody(string $body): string
{
$body = trim($body);
if ($body === '') {
return '';
}
return Str::limit($body, 800);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Jobs\GenerateArtworkEmbeddingJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class BackfillArtworkEmbeddingsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 120;
public function __construct(
private readonly int $afterId = 0,
private readonly int $batchSize = 200,
private readonly bool $force = false,
) {
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(): void
{
$batch = max(1, min($this->batchSize, 1000));
$artworks = Artwork::query()
->where('id', '>', $this->afterId)
->whereNotNull('hash')
->orderBy('id')
->limit($batch)
->get(['id', 'hash']);
if ($artworks->isEmpty()) {
return;
}
foreach ($artworks as $artwork) {
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash, $this->force);
}
if ($artworks->count() === $batch) {
$lastId = (int) $artworks->last()->id;
self::dispatch($lastId, $batch, $this->force);
}
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Services\Vision\ArtworkEmbeddingClient;
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\Redis;
final class GenerateArtworkEmbeddingJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 20;
public function __construct(
private readonly int $artworkId,
private readonly ?string $sourceHash = null,
private readonly bool $force = false,
) {
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function backoff(): array
{
return [2, 10, 30];
}
public function handle(ArtworkEmbeddingClient $client): void
{
if (! (bool) config('recommendations.embedding.enabled', true)) {
return;
}
$artwork = Artwork::query()->find($this->artworkId);
if (! $artwork) {
return;
}
$sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? '')));
if ($sourceHash === '') {
return;
}
$model = (string) config('recommendations.embedding.model', 'clip');
$modelVersion = (string) config('recommendations.embedding.model_version', 'v1');
$algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
if (! $this->force) {
$existing = ArtworkEmbedding::query()
->where('artwork_id', $artwork->id)
->where('model', $model)
->where('model_version', $modelVersion)
->first();
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
return;
}
}
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
if (! $this->acquireLock($lockKey)) {
return;
}
try {
$imageUrl = $this->buildImageUrl($sourceHash);
if ($imageUrl === null) {
return;
}
$vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash);
if ($vector === []) {
return;
}
$normalized = $this->normalize($vector);
ArtworkEmbedding::query()->updateOrCreate(
[
'artwork_id' => (int) $artwork->id,
'model' => $model,
'model_version' => $modelVersion,
],
[
'algo_version' => $algoVersion,
'dim' => count($normalized),
'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR),
'source_hash' => $sourceHash,
'is_normalized' => true,
'generated_at' => now(),
'meta' => [
'source' => 'clip',
'image_variant' => (string) config('vision.image_variant', 'md'),
],
]
);
} finally {
$this->releaseLock($lockKey);
}
}
/**
* @param array<int, float> $vector
* @return array<int, float>
*/
private function normalize(array $vector): array
{
$sumSquares = 0.0;
foreach ($vector as $value) {
$sumSquares += ($value * $value);
}
if ($sumSquares <= 0.0) {
return $vector;
}
$norm = sqrt($sumSquares);
return array_map(static fn (float $value): float => $value / $norm, $vector);
}
private function buildImageUrl(string $hash): ?string
{
$base = rtrim((string) config('cdn.files_url', ''), '/');
if ($base === '') {
return null;
}
$variant = (string) config('vision.image_variant', 'md');
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
$clean = str_pad($clean, 6, '0');
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
}
private function lockKey(int $artworkId, string $model, string $version): string
{
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
}
private function acquireLock(string $key): bool
{
try {
$didSet = Redis::setnx($key, 1);
if ($didSet) {
Redis::expire($key, 1800);
}
return (bool) $didSet;
} catch (\Throwable) {
return true;
}
}
private function releaseLock(string $key): void
{
try {
Redis::del($key);
} catch (\Throwable) {
// ignore
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\Uploads\UploadPipelineService;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class GenerateDerivativesJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
private readonly string $sessionId,
private readonly string $hash,
private readonly int $artworkId
) {
}
public function handle(UploadPipelineService $pipeline): void
{
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId);
// Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\Recommendations\UserInterestProfileService;
use Carbon\CarbonImmutable;
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\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Schema;
final class IngestUserDiscoveryEventJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [5, 30, 120];
/**
* @param array<string, mixed> $meta
*/
public function __construct(
public readonly string $eventId,
public readonly int $userId,
public readonly int $artworkId,
public readonly string $eventType,
public readonly string $algoVersion,
public readonly string $occurredAt,
public readonly array $meta = []
) {
}
public function handle(UserInterestProfileService $profileService): void
{
$idempotencyKey = sprintf('discovery:event:processed:%s', $this->eventId);
try {
$didSet = false;
try {
$didSet = (bool) Redis::setnx($idempotencyKey, 1);
if ($didSet) {
Redis::expire($idempotencyKey, 86400 * 2);
}
} catch (\Throwable $e) {
Log::warning('Redis unavailable for discovery ingestion; proceeding without redis dedupe', [
'event_id' => $this->eventId,
'error' => $e->getMessage(),
]);
$didSet = true;
}
if (! $didSet) {
return;
}
$occurredAt = CarbonImmutable::parse($this->occurredAt);
$eventVersion = (string) config('discovery.event_version', 'event-v1');
$eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0;
$categoryId = DB::table('artwork_category')
->where('artwork_id', $this->artworkId)
->orderBy('category_id')
->value('category_id');
$insertPayload = [
'event_id' => $this->eventId,
'user_id' => $this->userId,
'artwork_id' => $this->artworkId,
'category_id' => $categoryId !== null ? (int) $categoryId : null,
'event_type' => $this->eventType,
'event_version' => $eventVersion,
'algo_version' => $this->algoVersion,
'weight' => $eventWeight,
'event_date' => $occurredAt->toDateString(),
'occurred_at' => $occurredAt->toDateTimeString(),
'created_at' => now(),
'updated_at' => now(),
];
if (Schema::hasColumn('user_discovery_events', 'meta')) {
$insertPayload['meta'] = $this->meta;
} elseif (Schema::hasColumn('user_discovery_events', 'metadata')) {
$insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES);
}
DB::table('user_discovery_events')->insertOrIgnore($insertPayload);
$profileService->applyEvent(
userId: $this->userId,
eventType: $this->eventType,
artworkId: $this->artworkId,
categoryId: $categoryId !== null ? (int) $categoryId : null,
occurredAt: $occurredAt,
eventId: $this->eventId,
algoVersion: $this->algoVersion,
eventMeta: $this->meta
);
} catch (\Throwable $e) {
Log::error('IngestUserDiscoveryEventJob failed', [
'event_id' => $this->eventId,
'user_id' => $this->userId,
'artwork_id' => $this->artworkId,
'event_type' => $this->eventType,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\Recommendations\PersonalizedFeedService;
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;
final class RegenerateUserRecommendationCacheJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [10, 60, 180];
public function __construct(
public readonly int $userId,
public readonly string $algoVersion
) {
}
public function handle(PersonalizedFeedService $feedService): void
{
try {
$feedService->regenerateCacheForUser($this->userId, $this->algoVersion);
} catch (\Throwable $e) {
Log::error('RegenerateUserRecommendationCacheJob failed', [
'user_id' => $this->userId,
'algo_version' => $this->algoVersion,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
/** /**
* App\Models\Artwork * App\Models\Artwork
@@ -74,13 +76,8 @@ class Artwork extends Model
return null; return null;
} }
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; $sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
$h = $this->hash; return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey);
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$ext = $this->thumb_ext;
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
} }
/** /**
@@ -99,6 +96,19 @@ class Artwork extends Model
return $this->thumbUrl('md'); return $this->thumbUrl('md');
} }
/**
* Backwards-compatible alias used by legacy views: `$art->thumbnail_url`.
* Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder.
*/
public function getThumbnailUrlAttribute(): ?string
{
$url = $this->getThumbUrlAttribute();
if (!empty($url)) return $url;
$thumb = $this->getThumbAttribute();
if (!empty($thumb)) return $thumb;
return '/images/placeholder.jpg';
}
/** /**
* Provide a responsive `srcset` for legacy views. * Provide a responsive `srcset` for legacy views.
*/ */
@@ -132,6 +142,12 @@ class Artwork extends Model
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
} }
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id')
->withPivot(['source', 'confidence']);
}
public function comments(): HasMany public function comments(): HasMany
{ {
return $this->hasMany(ArtworkComment::class); return $this->hasMany(ArtworkComment::class);
@@ -142,6 +158,16 @@ class Artwork extends Model
return $this->hasMany(ArtworkDownload::class); return $this->hasMany(ArtworkDownload::class);
} }
public function embeddings(): HasMany
{
return $this->hasMany(ArtworkEmbedding::class, 'artwork_id');
}
public function similarities(): HasMany
{
return $this->hasMany(ArtworkSimilarity::class, 'artwork_id');
}
public function features(): HasMany public function features(): HasMany
{ {
return $this->hasMany(ArtworkFeature::class, 'artwork_id'); return $this->hasMany(ArtworkFeature::class, 'artwork_id');
@@ -175,4 +201,24 @@ class Artwork extends Model
{ {
return 'slug'; return 'slug';
} }
protected static function booted(): void
{
static::deleting(function (Artwork $artwork): void {
if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) {
return;
}
// Cleanup pivot rows and decrement usage counts on force delete.
$tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all();
if ($tagIds === []) {
return;
}
DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete();
DB::table('tags')
->whereIn('id', $tagIds)
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
});
}
} }

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkEmbedding extends Model
{
protected $table = 'artwork_embeddings';
protected $fillable = [
'artwork_id',
'model',
'model_version',
'algo_version',
'dim',
'embedding_json',
'source_hash',
'is_normalized',
'generated_at',
'meta',
];
protected $casts = [
'is_normalized' => 'boolean',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkSimilarity extends Model
{
protected $table = 'artwork_similarities';
protected $fillable = [
'artwork_id',
'similar_artwork_id',
'model',
'model_version',
'algo_version',
'rank',
'score',
'generated_at',
'meta',
];
protected $casts = [
'rank' => 'integer',
'score' => 'float',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
public function similarArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'similar_artwork_id');
}
}

View File

@@ -113,4 +113,50 @@ class Category extends Model
{ {
return 'slug'; return 'slug';
} }
/**
* Resolve a category by a content-type slug and a category path (e.g. "audio/winamp").
* This will locate the category with the final slug and verify its parent chain
* matches the provided path and that the category belongs to the given content type.
*
* @param string $contentTypeSlug
* @param string|array $categoryPath
* @return Category|null
*/
public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category
{
$parts = is_array($categoryPath)
? array_values(array_map('strtolower', array_filter($categoryPath)))
: array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath))));
if (empty($parts)) {
return null;
}
$last = end($parts);
$category = static::where('slug', $last)
->whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})
->first();
if (! $category) {
return null;
}
// Verify parent chain matches the preceding parts in the path
$idx = count($parts) - 2;
$current = $category;
while ($idx >= 0) {
$parent = $current->parent;
if (! $parent || $parent->slug !== $parts[$idx]) {
return null;
}
$current = $parent;
$idx--;
}
return $category;
}
} }

View File

@@ -4,6 +4,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use App\Models\Artwork;
class ContentType extends Model class ContentType extends Model
{ {
@@ -19,6 +22,18 @@ class ContentType extends Model
return $this->categories()->whereNull('parent_id'); return $this->categories()->whereNull('parent_id');
} }
/**
* Return an Eloquent builder for Artworks that belong to this content type.
* This traverses the pivot `artwork_category` via the `categories` relation.
* Note: not a direct Eloquent relation (uses whereHas) so it can be queried/eager-loaded manually.
*/
public function artworks(): EloquentBuilder
{
return Artwork::whereHas('categories', function ($q) {
$q->where('content_type_id', $this->id);
});
}
public function getRouteKeyName(): string public function getRouteKeyName(): string
{ {
return 'slug'; return 'slug';

39
app/Models/Tag.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
final class Tag extends Model
{
use HasFactory;
protected $table = 'tags';
protected $fillable = [
'name',
'slug',
'usage_count',
'is_active',
];
protected $casts = [
'usage_count' => 'integer',
'is_active' => 'boolean',
];
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_tag', 'tag_id', 'artwork_id')
->withPivot(['source', 'confidence']);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

51
app/Models/Upload.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Upload extends Model
{
protected $table = 'uploads';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'user_id',
'type',
'status',
'processing_state',
'title',
'slug',
'category_id',
'description',
'tags',
'license',
'nsfw',
'is_scanned',
'has_tags',
'preview_path',
'published_at',
'final_path',
'expires_at',
'moderation_status',
'moderated_at',
'moderated_by',
'moderation_note',
];
protected $casts = [
'tags' => 'array',
'nsfw' => 'boolean',
'is_scanned' => 'boolean',
'has_tags' => 'boolean',
'published_at' => 'datetime',
'expires_at' => 'datetime',
'moderated_at' => 'datetime',
];
}

View File

@@ -23,6 +23,7 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'role',
]; ];
/** /**
@@ -53,4 +54,19 @@ class User extends Authenticatable
{ {
return $this->hasMany(Artwork::class); return $this->hasMany(Artwork::class);
} }
public function hasRole(string $role): bool
{
return strtolower((string) ($this->role ?? '')) === strtolower($role);
}
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function isModerator(): bool
{
return $this->hasRole('moderator');
}
} }

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserDiscoveryEvent extends Model
{
use HasFactory;
protected $table = 'user_discovery_events';
protected $guarded = [];
protected $casts = [
'occurred_at' => 'datetime',
'meta' => 'array',
'weight' => 'float',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserInterestProfile extends Model
{
use HasFactory;
protected $table = 'user_interest_profiles';
protected $guarded = [];
protected $casts = [
'raw_scores_json' => 'array',
'normalized_scores_json' => 'array',
'last_event_at' => 'datetime',
'half_life_hours' => 'float',
'total_weight' => 'float',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class UserProfile extends Model
{
protected $table = 'user_profiles';
protected $primaryKey = 'user_id';
public $incrementing = false;
protected $keyType = 'int';
protected $fillable = [
'user_id',
'about',
'signature',
'description',
'avatar',
'avatar_hash',
'avatar_mime',
'avatar_updated_at',
'cover_image',
'country',
'country_code',
'language',
'birthdate',
'gender',
'website',
];
protected $casts = [
'birthdate' => 'date',
'avatar_updated_at' => 'datetime',
];
public $timestamps = true;
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Return a public URL for the avatar when stored on the `public` disk under `avatars/`.
*/
public function getAvatarUrlAttribute(): ?string
{
if (empty($this->avatar)) {
return null;
}
// If the stored value already looks like a full URL, return it.
if (preg_match('#^https?://#i', $this->avatar)) {
return $this->avatar;
}
// Prefer `public` disk and avatars folder.
$path = 'avatars/' . ltrim($this->avatar, '/');
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
// Fallback: return null if not found
return null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserRecommendationCache extends Model
{
use HasFactory;
protected $table = 'user_recommendation_cache';
protected $guarded = [];
protected $casts = [
'recommendations_json' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -40,6 +40,25 @@ class ArtworkPolicy
return false; return false;
} }
protected function isModerator(User $user): bool
{
foreach (['is_moderator', 'is_mod', 'moderator'] as $prop) {
if (isset($user->{$prop})) {
return (bool) $user->{$prop};
}
}
if (method_exists($user, 'hasRole')) {
return (bool) ($user->hasRole('moderator') || $user->hasRole('mod'));
}
if (method_exists($user, 'isModerator')) {
return (bool) $user->isModerator();
}
return false;
}
/** /**
* Public view: only approved + public + not-deleted artworks. * Public view: only approved + public + not-deleted artworks.
*/ */
@@ -64,6 +83,14 @@ class ArtworkPolicy
return $user->id === $artwork->user_id; return $user->id === $artwork->user_id;
} }
/**
* Tag edits: owner or moderator or admin (admin handled by before()).
*/
public function updateTags(User $user, Artwork $artwork): bool
{
return $user->id === $artwork->user_id || $this->isModerator($user);
}
/** /**
* Owner can delete their own artwork (soft delete). * Owner can delete their own artwork (soft delete).
*/ */

View File

@@ -2,7 +2,15 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Upload\UploadDraftService;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -11,7 +19,10 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// // Bind UploadDraftService interface to implementation
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
return new UploadDraftService($app->make('filesystem'));
});
} }
/** /**
@@ -19,6 +30,93 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// $this->configureUploadRateLimiters();
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
$avatar = null;
$displayName = null;
$userId = null;
if (Auth::check()) {
$userId = Auth::id();
try {
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
$uploadCount = 0;
}
try {
// legacy table name fallback handled elsewhere; here we look for user_favorites or favourites
$favCount = DB::table('user_favorites')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
try {
$favCount = DB::table('favourites')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
$favCount = 0;
}
}
try {
$msgCount = DB::table('messages')->where('reciever_id', $userId)->whereNull('read_at')->count();
} catch (\Throwable $e) {
$msgCount = 0;
}
try {
$noticeCount = DB::table('notification')->where('user_id', $userId)->where('new', 1)->count();
} catch (\Throwable $e) {
$noticeCount = 0;
}
try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
$avatar = $profile->avatar ?? null;
} catch (\Throwable $e) {
$avatar = null;
}
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
}
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName'));
});
}
private function configureUploadRateLimiters(): void
{
RateLimiter::for('uploads-init', function (Request $request): array {
return $this->buildUploadLimits($request, 'init');
});
RateLimiter::for('uploads-finish', function (Request $request): array {
return $this->buildUploadLimits($request, 'finish');
});
RateLimiter::for('uploads-status', function (Request $request): array {
return $this->buildUploadLimits($request, 'status');
});
}
private function buildUploadLimits(Request $request, string $key): array
{
$config = (array) config('uploads.rate_limits.' . $key, []);
$decay = (int) config('uploads.rate_limits.decay_minutes', 1);
$perUser = (int) ($config['per_user'] ?? 0);
$perIp = (int) ($config['per_ip'] ?? 0);
$limits = [];
if ($perUser > 0) {
$userId = $request->user()?->id ?? 'guest';
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId);
}
if ($perIp > 0) {
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip());
}
return $limits;
} }
} }

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Repositories\Uploads;
use Illuminate\Support\Facades\DB;
final class ArtworkFileRepository
{
public function upsert(int $artworkId, string $variant, string $path, string $mime, int $size): void
{
DB::table('artwork_files')->updateOrInsert(
['artwork_id' => $artworkId, 'variant' => $variant],
['path' => $path, 'mime' => $mime, 'size' => $size]
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Repositories\Uploads;
use Illuminate\Support\Facades\DB;
final class AuditLogRepository
{
public function log(?int $userId, string $action, string $ip, array $meta = []): void
{
DB::table('audit_logs')->insert([
'user_id' => $userId,
'action' => $action,
'ip' => $ip,
'meta' => empty($meta) ? null : json_encode($meta, JSON_THROW_ON_ERROR),
'created_at' => now()->toDateTimeString(),
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Repositories\Uploads;
use App\DTOs\Uploads\UploadSessionData;
use App\Services\Uploads\UploadSessionStatus;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use RuntimeException;
final class UploadSessionRepository
{
public function create(string $id, int $userId, string $tempPath, string $status, string $ip): UploadSessionData
{
$createdAt = CarbonImmutable::now();
DB::table('uploads_sessions')->insert([
'id' => $id,
'user_id' => $userId,
'temp_path' => $tempPath,
'status' => $status,
'ip' => $ip,
'created_at' => $createdAt->toDateTimeString(),
'progress' => 0,
'failure_reason' => null,
]);
return new UploadSessionData($id, $userId, $tempPath, $status, $ip, $createdAt, 0, null);
}
public function get(string $id): ?UploadSessionData
{
$row = DB::table('uploads_sessions')->where('id', $id)->first();
if (! $row) {
return null;
}
return new UploadSessionData(
(string) $row->id,
(int) $row->user_id,
(string) $row->temp_path,
(string) $row->status,
(string) $row->ip,
CarbonImmutable::parse($row->created_at),
(int) ($row->progress ?? 0),
$row->failure_reason ? (string) $row->failure_reason : null
);
}
public function getOrFail(string $id): UploadSessionData
{
$session = $this->get($id);
if (! $session) {
throw new RuntimeException('Upload session not found.');
}
return $session;
}
public function updateStatus(string $id, string $status): void
{
DB::table('uploads_sessions')->where('id', $id)->update([
'status' => $status,
]);
}
public function updateProgress(string $id, int $progress): void
{
DB::table('uploads_sessions')->where('id', $id)->update([
'progress' => max(0, min(100, $progress)),
]);
}
public function updateFailureReason(string $id, ?string $reason): void
{
DB::table('uploads_sessions')->where('id', $id)->update([
'failure_reason' => $reason,
]);
}
public function updateTempPath(string $id, string $tempPath): void
{
DB::table('uploads_sessions')->where('id', $id)->update([
'temp_path' => $tempPath,
]);
}
public function countActiveForUser(int $userId): int
{
$terminal = [
UploadSessionStatus::PROCESSED,
UploadSessionStatus::QUARANTINED,
UploadSessionStatus::CANCELLED,
];
return (int) DB::table('uploads_sessions')
->where('user_id', $userId)
->whereNotIn('status', $terminal)
->count();
}
public function countForUserSince(int $userId, CarbonImmutable $since): int
{
return (int) DB::table('uploads_sessions')
->where('user_id', $userId)
->where('created_at', '>=', $since->toDateTimeString())
->count();
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\Artworks;
use App\DTOs\Artworks\ArtworkDraftResult;
use App\Models\Artwork;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class ArtworkDraftService
{
public function createDraft(int $userId, string $title, ?string $description): ArtworkDraftResult
{
return DB::transaction(function () use ($userId, $title, $description) {
$slug = $this->uniqueSlug($title);
$artwork = Artwork::create([
'user_id' => $userId,
'title' => $title,
'slug' => $slug,
'description' => $description,
'file_name' => 'pending',
'file_path' => '',
'file_size' => 0,
'mime_type' => 'application/octet-stream',
'width' => 1,
'height' => 1,
'is_public' => false,
'is_approved' => false,
'published_at' => null,
]);
return new ArtworkDraftResult((int) $artwork->id, 'draft');
});
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$base = $base !== '' ? $base : 'artwork';
for ($i = 0; $i < 5; $i++) {
$suffix = Str::lower(Str::random(6));
$slug = Str::limit($base . '-' . $suffix, 160, '');
if (! Artwork::where('slug', $slug)->exists()) {
return $slug;
}
}
return Str::limit($base . '-' . Str::uuid()->toString(), 160, '');
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Intervention\Image\ImageManagerStatic as Image;
use RuntimeException;
use Carbon\Carbon;
use Illuminate\Http\UploadedFile;
class AvatarService
{
protected $sizes = [
'xs' => 32,
'sm' => 64,
'md' => 128,
'lg' => 256,
'xl' => 512,
];
protected $quality = 85;
public function __construct()
{
// Guard: if Intervention Image is not installed, defer error until actual use
if (class_exists(\Intervention\Image\ImageManagerStatic::class)) {
try {
Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']);
$this->imageAvailable = true;
} catch (\Throwable $e) {
// If configuration fails, treat as unavailable and log for diagnostics
logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
$this->imageAvailable = false;
}
} else {
$this->imageAvailable = false;
}
}
/**
* Process an uploaded file for a user and store webp sizes.
* Returns the computed sha1 hash.
*
* @param int $userId
* @param UploadedFile $file
* @return string sha1 hash
*/
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
{
if (! $this->imageAvailable) {
throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.');
}
// Load image and re-encode to webp after validating
try {
$img = Image::make($file->getRealPath());
} catch (\Throwable $e) {
throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
}
// Ensure square center crop per spec
$max = max($img->width(), $img->height());
$img->fit($max, $max);
$basePath = "avatars/{$userId}";
Storage::disk('public')->makeDirectory($basePath);
// Save original as webp
$originalData = (string) $img->encode('webp', $this->quality);
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
// Generate sizes
foreach ($this->sizes as $name => $size) {
$resized = $img->resize($size, $size, function ($constraint) {
$constraint->upsize();
})->encode('webp', $this->quality);
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
}
$hash = sha1($originalData);
$mime = 'image/webp';
// Persist metadata to user_profiles if exists, otherwise users table fallbacks
if (SchemaHasTable('user_profiles')) {
DB::table('user_profiles')->where('user_id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
} else {
DB::table('users')->where('id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
}
return $hash;
}
/**
* Process a legacy file path for a user (path-to-file).
* Returns sha1 or null when missing.
*
* @param int $userId
* @param string $path Absolute filesystem path
* @return string|null
*/
public function storeFromLegacyFile(int $userId, string $path): ?string
{
if (!file_exists($path) || !is_readable($path)) {
return null;
}
try {
$img = Image::make($path);
} catch (\Exception $e) {
return null;
}
$max = max($img->width(), $img->height());
$img->fit($max, $max);
$basePath = "avatars/{$userId}";
Storage::disk('public')->makeDirectory($basePath);
$originalData = (string) $img->encode('webp', $this->quality);
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
foreach ($this->sizes as $name => $size) {
$resized = $img->resize($size, $size, function ($constraint) {
$constraint->upsize();
})->encode('webp', $this->quality);
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
}
$hash = sha1($originalData);
$mime = 'image/webp';
if (SchemaHasTable('user_profiles')) {
DB::table('user_profiles')->where('user_id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
} else {
DB::table('users')->where('id', $userId)->update([
'avatar_hash' => $hash,
'avatar_updated_at' => Carbon::now(),
'avatar_mime' => $mime,
]);
}
return $hash;
}
}
/**
* Helper: check for table existence without importing Schema facade repeatedly
*/
function SchemaHasTable(string $name): bool
{
try {
return \Illuminate\Support\Facades\Schema::hasTable($name);
} catch (\Throwable $e) {
return false;
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use Illuminate\Support\Facades\DB;
final class FeedOfflineEvaluationService
{
/**
* @return array<string, mixed>
*/
public function evaluateAlgo(string $algoVersion, string $from, string $to): array
{
$row = DB::table('feed_daily_metrics')
->selectRaw('SUM(impressions) as impressions')
->selectRaw('SUM(clicks) as clicks')
->selectRaw('SUM(saves) as saves')
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
->where('algo_version', $algoVersion)
->whereBetween('metric_date', [$from, $to])
->first();
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = (int) ($row->saves ?? 0);
$dwell05 = (int) ($row->dwell_0_5 ?? 0);
$dwell530 = (int) ($row->dwell_5_30 ?? 0);
$dwell30120 = (int) ($row->dwell_30_120 ?? 0);
$dwell120Plus = (int) ($row->dwell_120_plus ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
$longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0;
$bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0;
$objectiveWeights = (array) config('discovery.evaluation.objective_weights', []);
$wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45);
$wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35);
$wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25);
$wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15);
$saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true);
if ($saveRateInformational) {
$wSave = 0.0;
}
$normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty;
if ($normalizationSum > 0.0) {
$wCtr /= $normalizationSum;
$wSave /= $normalizationSum;
$wLong /= $normalizationSum;
$wBouncePenalty /= $normalizationSum;
}
$objectiveScore = ($wCtr * $ctr)
+ ($wSave * $saveRate)
+ ($wLong * $longDwellShare)
- ($wBouncePenalty * $bounceRate);
return [
'algo_version' => $algoVersion,
'from' => $from,
'to' => $to,
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => round($ctr, 6),
'save_rate' => round($saveRate, 6),
'long_dwell_share' => round($longDwellShare, 6),
'bounce_rate' => round($bounceRate, 6),
'dwell_buckets' => [
'0_5' => $dwell05,
'5_30' => $dwell530,
'30_120' => $dwell30120,
'120_plus' => $dwell120Plus,
],
'objective_score' => round($objectiveScore, 6),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function evaluateAll(string $from, string $to): array
{
$algoVersions = DB::table('feed_daily_metrics')
->select('algo_version')
->whereBetween('metric_date', [$from, $to])
->distinct()
->orderBy('algo_version')
->pluck('algo_version')
->map(static fn (mixed $v): string => (string) $v)
->all();
$out = [];
foreach ($algoVersions as $algoVersion) {
$out[] = $this->evaluateAlgo($algoVersion, $from, $to);
}
usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']);
return $out;
}
/**
* @return array<string, mixed>
*/
public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array
{
$baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to);
$candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to);
$deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score'];
$objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0
? ($deltaObjective / (float) $baseline['objective_score']) * 100.0
: null;
return [
'from' => $from,
'to' => $to,
'baseline' => $baseline,
'candidate' => $candidate,
'delta' => [
'objective_score' => round($deltaObjective, 6),
'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null,
'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6),
'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6),
'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6),
'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6),
],
];
}
}

View File

@@ -0,0 +1,567 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\UserInterestProfile;
use App\Models\UserRecommendationCache;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class PersonalizedFeedService
{
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
{
$safeLimit = max(1, min(50, $limit));
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
$weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
$offset = $this->decodeCursorToOffset($cursor);
$cache = UserRecommendationCache::query()
->where('user_id', $userId)
->where('algo_version', $resolvedAlgoVersion)
->first();
$cacheItems = $this->extractCacheItems($cache);
$isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture();
$cacheStatus = 'hit';
if ($cache === null) {
$cacheStatus = 'miss';
} elseif (! $isFresh) {
$cacheStatus = 'stale';
}
if ($cache === null || ! $isFresh) {
RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
->onQueue((string) config('discovery.queue', 'default'));
}
$items = $cacheItems;
if ($items === []) {
$items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
$cacheStatus = $cacheStatus . '-fallback';
}
return $this->buildFeedPageResponse(
items: $items,
offset: $offset,
limit: $safeLimit,
algoVersion: $resolvedAlgoVersion,
weightVersion: (string) $weightSet['version'],
cacheStatus: $cacheStatus,
generatedAt: $cache?->generated_at?->toIso8601String()
);
}
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
{
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
$cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
$ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));
$items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
$generatedAt = now();
$expiresAt = now()->addMinutes($ttlMinutes);
UserRecommendationCache::query()->updateOrCreate(
[
'user_id' => $userId,
'algo_version' => $resolvedAlgoVersion,
],
[
'cache_version' => $cacheVersion,
'recommendations_json' => [
'items' => $items,
'algo_version' => $resolvedAlgoVersion,
'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
'generated_at' => $generatedAt->toIso8601String(),
],
'generated_at' => $generatedAt,
'expires_at' => $expiresAt,
]
);
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array
{
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->first();
$normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
$personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);
if ($personalized === []) {
return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
}
$fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');
$combined = [];
foreach (array_merge($personalized, $fallback) as $item) {
$artworkId = (int) ($item['artwork_id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
if (! isset($combined[$artworkId])) {
$combined[$artworkId] = [
'artwork_id' => $artworkId,
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
];
continue;
}
if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
$combined[$artworkId]['score'] = (float) $item['score'];
$combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
}
}
$candidates = array_values($combined);
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @param array<string, mixed> $normalizedScores
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
{
$weightSet = $this->resolveRankingWeights($algoVersion);
$w1 = (float) $weightSet['w1'];
$w2 = (float) $weightSet['w2'];
$w3 = (float) $weightSet['w3'];
$w4 = (float) $weightSet['w4'];
$categoryAffinities = [];
foreach ($normalizedScores as $key => $score) {
if (! is_numeric($score)) {
continue;
}
if (! str_starts_with((string) $key, 'category:')) {
continue;
}
$categoryId = (int) str_replace('category:', '', (string) $key);
if ($categoryId <= 0) {
continue;
}
$categoryAffinities[$categoryId] = (float) $score;
}
if ($categoryAffinities === []) {
return [];
}
$rows = DB::table('artworks')
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereIn('artwork_category.category_id', array_keys($categoryAffinities))
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->orderByDesc('artworks.published_at')
->limit(max(200, $maxItems * 8))
->get([
'artworks.id',
'artworks.published_at',
'artwork_category.category_id',
DB::raw('COALESCE(artwork_stats.views, 0) as views'),
]);
$scored = [];
foreach ($rows as $row) {
$artworkId = (int) $row->id;
$categoryId = (int) $row->category_id;
$affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
if ($affinity <= 0.0) {
continue;
}
$publishedAt = CarbonImmutable::parse((string) $row->published_at);
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
$recency = exp(-$ageDays / 30.0);
$popularity = log(1 + max(0, (int) $row->views)) / 10.0;
$novelty = max(0.0, 1.0 - min(1.0, $popularity));
// Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
$score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);
if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
$scored[$artworkId] = [
'artwork_id' => $artworkId,
'score' => $score,
'source' => 'personalized',
];
}
}
$candidates = array_values($scored);
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
{
$popularIds = DB::table('artworks')
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->orderByDesc('artwork_stats.views')
->orderByDesc('artwork_stats.downloads')
->orderByDesc('artworks.published_at')
->limit(max(40, $maxItems))
->pluck('artworks.id')
->map(static fn (mixed $id): int => (int) $id)
->all();
$seedIds = array_slice($popularIds, 0, 12);
$similarIds = [];
if ($seedIds !== []) {
$similarIds = DB::table('artwork_similarities')
->where('algo_version', $algoVersion)
->whereIn('artwork_id', $seedIds)
->orderBy('rank')
->orderByDesc('score')
->limit(max(80, $maxItems * 2))
->pluck('similar_artwork_id')
->map(static fn (mixed $id): int => (int) $id)
->all();
}
$candidates = [];
foreach ($popularIds as $index => $artworkId) {
$candidates[] = [
'artwork_id' => $artworkId,
'score' => max(0.0, 1.0 - ($index * 0.003)),
'source' => $sourceLabel,
];
}
foreach ($similarIds as $index => $artworkId) {
$candidates[] = [
'artwork_id' => $artworkId,
'score' => max(0.0, 0.75 - ($index * 0.002)),
'source' => $sourceLabel,
];
}
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
}
/**
* @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
{
if ($candidates === []) {
return [];
}
$uniqueCandidates = [];
foreach ($candidates as $candidate) {
$artworkId = (int) ($candidate['artwork_id'] ?? 0);
if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
continue;
}
$uniqueCandidates[$artworkId] = [
'artwork_id' => $artworkId,
'score' => (float) ($candidate['score'] ?? 0.0),
'source' => (string) ($candidate['source'] ?? 'mixed'),
];
}
$flattened = array_values($uniqueCandidates);
$candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);
$nearDuplicatePairs = DB::table('artwork_similarities')
->where('algo_version', $algoVersion)
->where('score', '>=', 0.97)
->whereIn('artwork_id', $candidateIds)
->whereIn('similar_artwork_id', $candidateIds)
->get(['artwork_id', 'similar_artwork_id']);
$adjacency = [];
foreach ($nearDuplicatePairs as $pair) {
$left = (int) $pair->artwork_id;
$right = (int) $pair->similar_artwork_id;
if ($left === $right) {
continue;
}
$adjacency[$left][$right] = true;
$adjacency[$right][$left] = true;
}
$selected = [];
$selectedSet = [];
foreach ($flattened as $candidate) {
$id = (int) $candidate['artwork_id'];
$isNearDuplicate = false;
foreach ($selectedSet as $selectedId => $value) {
if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
$isNearDuplicate = true;
break;
}
}
if ($isNearDuplicate) {
continue;
}
$selected[] = [
'artwork_id' => $id,
'score' => round((float) $candidate['score'], 6),
'source' => (string) $candidate['source'],
];
$selectedSet[$id] = true;
if (count($selected) >= $maxItems) {
break;
}
}
return $selected;
}
/**
* @param array<int, array{artwork_id:int,score:float,source:string}> $items
*/
private function buildFeedPageResponse(
array $items,
int $offset,
int $limit,
string $algoVersion,
string $weightVersion,
string $cacheStatus,
?string $generatedAt
): array {
$safeOffset = max(0, $offset);
$pageItems = array_slice($items, $safeOffset, $limit);
$ids = array_values(array_unique(array_map(
static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
$pageItems
)));
/** @var Collection<int, Artwork> $artworks */
$artworks = Artwork::query()
->with(['user:id,name'])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$responseItems = [];
foreach ($pageItems as $item) {
$artworkId = (int) ($item['artwork_id'] ?? 0);
$artwork = $artworks->get($artworkId);
if ($artwork === null) {
continue;
}
$responseItems[] = [
'id' => $artwork->id,
'slug' => $artwork->slug,
'title' => $artwork->title,
'thumbnail_url' => $artwork->thumb_url,
'author' => $artwork->user?->name,
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
];
}
$nextOffset = $safeOffset + $limit;
$hasNext = $nextOffset < count($items);
return [
'data' => $responseItems,
'meta' => [
'algo_version' => $algoVersion,
'weight_version' => $weightVersion,
'cursor' => $this->encodeOffsetToCursor($safeOffset),
'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
'limit' => $limit,
'cache_status' => $cacheStatus,
'generated_at' => $generatedAt,
'total_candidates' => count($items),
],
];
}
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
{
if ($algoVersion !== null && $algoVersion !== '') {
return $algoVersion;
}
$forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
if ($forcedAlgoVersion !== '') {
return $forcedAlgoVersion;
}
$defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
$rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
if (! $rolloutEnabled || $userId === null || $userId <= 0) {
return $defaultAlgoVersion;
}
$baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
$candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
return $baselineAlgoVersion;
}
$activeGate = (string) config('discovery.rollout.active_gate', 'g10');
$gates = (array) config('discovery.rollout.gates', []);
$gate = (array) ($gates[$activeGate] ?? []);
$rolloutPercentage = (int) ($gate['percentage'] ?? 0);
$rolloutPercentage = max(0, min(100, $rolloutPercentage));
if ($rolloutPercentage <= 0) {
return $baselineAlgoVersion;
}
if ($rolloutPercentage >= 100) {
return $candidateAlgoVersion;
}
$bucket = abs((int) crc32((string) $userId)) % 100;
return $bucket < $rolloutPercentage
? $candidateAlgoVersion
: $baselineAlgoVersion;
}
/**
* @return array{version:string,w1:float,w2:float,w3:float,w4:float}
*/
public function resolveRankingWeights(string $algoVersion): array
{
$defaults = (array) config('discovery.ranking.default_weights', []);
$byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
$override = (array) ($byAlgo[$algoVersion] ?? []);
$resolved = array_merge($defaults, $override);
$weights = [
'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
];
$sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
if ($sum > 0.0) {
$weights['w1'] /= $sum;
$weights['w2'] /= $sum;
$weights['w3'] /= $sum;
$weights['w4'] /= $sum;
}
return $weights;
}
private function decodeCursorToOffset(?string $cursor): int
{
if ($cursor === null || $cursor === '') {
return 0;
}
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($decoded === false) {
return 0;
}
$json = json_decode($decoded, true);
if (! is_array($json)) {
return 0;
}
return max(0, (int) Arr::get($json, 'offset', 0));
}
private function encodeOffsetToCursor(int $offset): string
{
$payload = json_encode(['offset' => max(0, $offset)]);
if (! is_string($payload)) {
return '';
}
return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
}
/**
* @return array<int, array{artwork_id:int,score:float,source:string}>
*/
private function extractCacheItems(?UserRecommendationCache $cache): array
{
if ($cache === null) {
return [];
}
$raw = (array) ($cache->recommendations_json ?? []);
$items = $raw['items'] ?? null;
if (! is_array($items)) {
return [];
}
$typed = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$artworkId = (int) ($item['artwork_id'] ?? 0);
if ($artworkId <= 0) {
continue;
}
$typed[] = [
'artwork_id' => $artworkId,
'score' => (float) ($item['score'] ?? 0.0),
'source' => (string) ($item['source'] ?? 'mixed'),
];
}
return $typed;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\Artwork;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SimilarArtworksService
{
/**
* @return Collection<int, Artwork>
*/
public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection
{
$effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
$ids = DB::table('artwork_similarities')
->where('artwork_id', $artworkId)
->where('algo_version', $effectiveAlgo)
->orderBy('rank')
->limit(max(1, min($limit, 50)))
->pluck('similar_artwork_id')
->map(static fn ($id) => (int) $id)
->all();
if ($ids === []) {
return collect();
}
$artworks = Artwork::query()
->whereIn('id', $ids)
->public()
->published()
->get();
$byId = $artworks->keyBy('id');
return collect($ids)
->map(static fn (int $id) => $byId->get($id))
->filter();
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\UserInterestProfile;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
final class UserInterestProfileService
{
/**
* @param array<string, mixed> $eventMeta
*/
public function applyEvent(
int $userId,
string $eventType,
int $artworkId,
?int $categoryId,
CarbonInterface $occurredAt,
string $eventId,
string $algoVersion,
array $eventMeta = []
): void {
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
$halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
$weightMap = (array) config('discovery.weights', []);
$eventWeight = (float) ($weightMap[$eventType] ?? 1.0);
DB::transaction(function () use (
$userId,
$categoryId,
$artworkId,
$occurredAt,
$eventId,
$algoVersion,
$profileVersion,
$halfLifeHours,
$eventWeight,
$eventMeta
): void {
$profile = UserInterestProfile::query()
->where('user_id', $userId)
->where('profile_version', $profileVersion)
->where('algo_version', $algoVersion)
->lockForUpdate()
->first();
$rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
$lastEventAt = $profile?->last_event_at;
if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
$hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
$rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
}
$interestKey = $categoryId !== null
? sprintf('category:%d', $categoryId)
: sprintf('artwork:%d', $artworkId);
$rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;
$rawScores = array_filter(
$rawScores,
static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
);
$normalizedScores = $this->normalizeScores($rawScores);
$totalWeight = array_sum($rawScores);
$payload = [
'user_id' => $userId,
'profile_version' => $profileVersion,
'algo_version' => $algoVersion,
'raw_scores_json' => $rawScores,
'normalized_scores_json' => $normalizedScores,
'total_weight' => $totalWeight,
'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
? $occurredAt
: $lastEventAt,
'half_life_hours' => $halfLifeHours,
'updated_from_event_id' => $eventId,
'updated_at' => now(),
];
if ($profile === null) {
$payload['created_at'] = now();
UserInterestProfile::query()->create($payload);
return;
}
$profile->fill($payload);
$profile->save();
}, 3);
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
{
if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
return $this->castToFloatScores($scores);
}
$decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
$output = [];
foreach ($scores as $key => $score) {
if (! is_numeric($score)) {
continue;
}
$decayed = (float) $score * $decayFactor;
if ($decayed > 0.000001) {
$output[(string) $key] = $decayed;
}
}
return $output;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
public function normalizeScores(array $scores): array
{
$typedScores = $this->castToFloatScores($scores);
$sum = array_sum($typedScores);
if ($sum <= 0.0) {
return [];
}
$normalized = [];
foreach ($typedScores as $key => $score) {
$normalized[$key] = $score / $sum;
}
return $normalized;
}
/**
* @param array<string, mixed> $scores
* @return array<string, float>
*/
private function castToFloatScores(array $scores): array
{
$output = [];
foreach ($scores as $key => $score) {
if (is_numeric($score) && (float) $score > 0.0) {
$output[(string) $key] = (float) $score;
}
}
return $output;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Services;
final class TagNormalizer
{
public function normalize(string $tag): string
{
$value = trim($tag);
if ($value === '') {
return '';
}
$value = mb_strtolower($value, 'UTF-8');
// Remove emoji / symbols and keep only letters, numbers, whitespace and hyphens.
// (Unicode safe: \p{L} letters, \p{N} numbers)
$value = (string) preg_replace('/[^\p{L}\p{N}\s\-]+/u', '', $value);
// Normalize whitespace.
$value = (string) preg_replace('/\s+/u', ' ', $value);
$value = trim($value);
// Spaces -> hyphens and collapse repeats.
$value = str_replace(' ', '-', $value);
$value = (string) preg_replace('/\-+/u', '-', $value);
$value = trim($value, "-\t\n\r\0\x0B");
$maxLength = (int) config('tags.max_length', 32);
if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) {
$value = mb_substr($value, 0, $maxLength, 'UTF-8');
$value = rtrim($value, '-');
}
return $value;
}
}

329
app/Services/TagService.php Normal file
View File

@@ -0,0 +1,329 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagNormalizer;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class TagService
{
public function __construct(
private readonly TagNormalizer $normalizer,
) {
}
public function createOrFindTag(string $rawTag): Tag
{
$normalized = $this->normalizer->normalize($rawTag);
$this->validateNormalizedTag($normalized);
// Keep tags normalized in both name and slug (spec: normalize all tags).
// Unique(slug) + Unique(name) prevents duplicates.
return Tag::query()->firstOrCreate(
['slug' => $normalized],
['name' => $normalized, 'usage_count' => 0, 'is_active' => true]
);
}
/**
* @param array<int, string> $tags
*/
public function attachUserTags(Artwork $artwork, array $tags): void
{
$normalized = $this->normalizeUserTags($tags);
if ($normalized === []) {
return;
}
DB::transaction(function () use ($artwork, $normalized): void {
$tagIdsBySlug = [];
foreach ($normalized as $tag) {
$model = $this->createOrFindTag($tag);
$tagIdsBySlug[$model->slug] = $model->id;
}
$tagIds = array_values($tagIdsBySlug);
$existing = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->whereIn('tag_id', $tagIds)
->pluck('source', 'tag_id')
->all();
$toAttach = [];
$toUpdate = [];
$newlyAttachedTagIds = [];
foreach ($tagIds as $tagId) {
$source = $existing[$tagId] ?? null;
if ($source === null) {
$toAttach[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
$newlyAttachedTagIds[] = $tagId;
continue;
}
if ($source !== 'user') {
// User tags take precedence over AI/system.
$toUpdate[$tagId] = ['source' => 'user', 'confidence' => null];
}
}
if ($toAttach !== []) {
$artwork->tags()->syncWithoutDetaching($toAttach);
$this->incrementUsageCounts($newlyAttachedTagIds);
}
foreach ($toUpdate as $tagId => $payload) {
$artwork->tags()->updateExistingPivot($tagId, $payload);
}
});
}
/**
* @param array<int, array{tag: string, confidence?: float|int|null}> $aiTags
*/
public function attachAiTags(Artwork $artwork, array $aiTags): void
{
if ($aiTags === []) {
return;
}
DB::transaction(function () use ($artwork, $aiTags): void {
$payloads = [];
$newlyAttachedTagIds = [];
foreach ($aiTags as $row) {
$raw = (string) ($row['tag'] ?? '');
$confidence = $row['confidence'] ?? null;
$normalized = $this->normalizer->normalize($raw);
if ($normalized === '') {
continue;
}
// AI tagging must be optional: invalid/banned tags are skipped (not fatal).
try {
$this->validateNormalizedTag($normalized);
} catch (ValidationException $e) {
continue;
}
$tag = $this->createOrFindTag($normalized);
$existingSource = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->where('tag_id', $tag->id)
->value('source');
if ($existingSource === 'user') {
continue;
}
if ($existingSource === null) {
$payloads[$tag->id] = [
'source' => 'ai',
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
'created_at' => now(),
];
$newlyAttachedTagIds[] = $tag->id;
continue;
}
if ($existingSource === 'ai') {
$artwork->tags()->updateExistingPivot($tag->id, [
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
]);
}
}
if ($payloads !== []) {
$artwork->tags()->syncWithoutDetaching($payloads);
$this->incrementUsageCounts($newlyAttachedTagIds);
}
});
}
public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
{
if ($tagSlugsOrIds === []) {
return;
}
$tagIds = Tag::query()
->whereIn('id', array_filter($tagSlugsOrIds, 'is_numeric'))
->orWhereIn('slug', array_filter($tagSlugsOrIds, fn ($v) => is_string($v) && $v !== ''))
->pluck('id')
->all();
if ($tagIds === []) {
return;
}
DB::transaction(function () use ($artwork, $tagIds): void {
$existing = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->whereIn('tag_id', $tagIds)
->pluck('tag_id')
->all();
if ($existing === []) {
return;
}
$artwork->tags()->detach($existing);
$this->decrementUsageCounts($existing);
});
}
/**
* Sync user tags (PUT semantics): replaces the set of user-origin tags.
*
* @param array<int, string> $tags
*/
public function syncTags(Artwork $artwork, array $tags): void
{
$normalized = $this->normalizeUserTags($tags);
DB::transaction(function () use ($artwork, $normalized): void {
$desiredTagIds = [];
foreach ($normalized as $tag) {
$model = $this->createOrFindTag($tag);
$desiredTagIds[] = $model->id;
}
$desiredTagIds = array_values(array_unique($desiredTagIds));
$currentUserTagIds = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->where('source', 'user')
->pluck('tag_id')
->all();
$toDetach = array_values(array_diff($currentUserTagIds, $desiredTagIds));
$toAttach = array_values(array_diff($desiredTagIds, $currentUserTagIds));
if ($toDetach !== []) {
$artwork->tags()->detach($toDetach);
$this->decrementUsageCounts($toDetach);
}
if ($toAttach !== []) {
$payload = [];
foreach ($toAttach as $tagId) {
$payload[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
}
$artwork->tags()->syncWithoutDetaching($payload);
$this->incrementUsageCounts($toAttach);
}
// Ensure desired tags are marked as user (user precedence).
if ($desiredTagIds !== []) {
$existingNonUser = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->whereIn('tag_id', $desiredTagIds)
->where('source', '!=', 'user')
->pluck('tag_id')
->all();
foreach ($existingNonUser as $tagId) {
$artwork->tags()->updateExistingPivot($tagId, ['source' => 'user', 'confidence' => null]);
}
}
});
}
public function updateUsageCount(Tag $tag): void
{
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
$tag->forceFill(['usage_count' => $count])->save();
}
/**
* @param array<int, string> $tags
* @return array<int, string>
*/
private function normalizeUserTags(array $tags): array
{
$max = (int) config('tags.max_user_tags', 15);
if (count($tags) > $max) {
throw ValidationException::withMessages([
'tags' => ["Too many tags (max {$max})."],
]);
}
$normalized = [];
foreach ($tags as $tag) {
$value = $this->normalizer->normalize((string) $tag);
if ($value === '') {
continue;
}
$this->validateNormalizedTag($value);
$normalized[] = $value;
}
$normalized = array_values(array_unique($normalized));
return $normalized;
}
private function validateNormalizedTag(string $normalized): void
{
if ($normalized === '') {
throw ValidationException::withMessages([
'tags' => ['Invalid tag.'],
]);
}
$banned = array_map('strval', (array) config('tags.banned', []));
if ($banned !== [] && in_array($normalized, $banned, true)) {
throw ValidationException::withMessages([
'tags' => ['Tag is not allowed.'],
]);
}
$patterns = (array) config('tags.banned_regex', []);
foreach ($patterns as $pattern) {
$pattern = (string) $pattern;
if ($pattern === '') {
continue;
}
if (@preg_match($pattern, $normalized) === 1) {
throw ValidationException::withMessages([
'tags' => ['Tag is not allowed.'],
]);
}
}
}
/**
* @param array<int, int> $tagIds
*/
private function incrementUsageCounts(array $tagIds): void
{
if ($tagIds === []) {
return;
}
Tag::query()->whereIn('id', $tagIds)->increment('usage_count');
}
/**
* @param array<int, int> $tagIds
*/
private function decrementUsageCounts(array $tagIds): void
{
if ($tagIds === []) {
return;
}
// Never allow negative counts.
DB::table('tags')
->whereIn('id', $tagIds)
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
}
}

View File

@@ -5,7 +5,8 @@ use Illuminate\Support\Facades\Storage;
class ThumbnailService class ThumbnailService
{ {
protected const CDN_HOST = 'http://files.skinbase.org'; // Use the thumbnails CDN host (HTTPS)
protected const CDN_HOST = 'https://files.skinbase.org';
protected const VALID_SIZES = ['sm','md','lg','xl']; protected const VALID_SIZES = ['sm','md','lg','xl'];

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Upload\Contracts;
use Illuminate\Http\UploadedFile;
interface UploadDraftServiceInterface
{
/**
* Create a new draft and return identifying info.
*
* @param array $attributes
* @return array ['id' => string, 'path' => string, 'meta' => array]
*/
public function createDraft(array $attributes = []): array;
/**
* Store the main uploaded file for the draft.
*
* @param string $draftId
* @param UploadedFile $file
* @return array Metadata about stored file (path, size, mime, hash)
*/
public function storeMainFile(string $draftId, UploadedFile $file): array;
/**
* Store a screenshot/preview image for the draft.
*
* @param string $draftId
* @param UploadedFile $file
* @return array Metadata about stored screenshot
*/
public function storeScreenshot(string $draftId, UploadedFile $file): array;
/**
* Calculate a content hash for a local file path or storage path.
*
* @param string $filePath
* @return string
*/
public function calculateHash(string $filePath): string;
/**
* Set an expiration timestamp for the draft.
*
* @param string $draftId
* @param \Carbon\Carbon|null $expiresAt
* @return bool
*/
public function setExpiration(string $draftId, ?\Carbon\Carbon $expiresAt = null): bool;
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Upload;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use RuntimeException;
final class PreviewService
{
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
} catch (\Throwable $e) {
$this->manager = null;
}
}
public function generateFromImage(string $uploadId, string $sourcePath): array
{
if ($this->manager === null) {
throw new RuntimeException('PreviewService requires Intervention Image.');
}
$disk = Storage::disk('local');
if (! $disk->exists($sourcePath)) {
return $this->generatePlaceholder($uploadId);
}
$absolute = $disk->path($sourcePath);
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
$preview = $this->manager->read($absolute)->scaleDown(1280, 1280);
$thumb = $this->manager->read($absolute)->cover(320, 320);
$previewEncoded = (string) $preview->encode(new \Intervention\Image\Encoders\WebpEncoder(85));
$thumbEncoded = (string) $thumb->encode(new \Intervention\Image\Encoders\WebpEncoder(82));
$disk->put($previewPath, $previewEncoded);
$disk->put($thumbPath, $thumbEncoded);
return [
'preview_path' => $previewPath,
'thumb_path' => $thumbPath,
];
}
public function generateFromArchive(string $uploadId, ?string $screenshotPath = null): array
{
if ($screenshotPath !== null && Storage::disk('local')->exists($screenshotPath)) {
return $this->generateFromImage($uploadId, $screenshotPath);
}
return $this->generatePlaceholder($uploadId);
}
public function generatePlaceholder(string $uploadId): array
{
$disk = Storage::disk('local');
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
// 1x1 transparent webp
$tinyWebp = base64_decode('UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAAAfQ//73v/+BiOh/AAA=');
$disk->put($previewPath, $tinyWebp ?: '');
$disk->put($thumbPath, $tinyWebp ?: '');
return [
'preview_path' => $previewPath,
'thumb_path' => $thumbPath,
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\Upload;
use App\Services\TagNormalizer;
final class TagAnalysisService
{
public function __construct(private readonly TagNormalizer $normalizer)
{
}
/**
* @return array<int, array{tag:string,confidence:float,source:string}>
*/
public function analyze(string $filename, ?string $previewPath, ?string $categoryContext): array
{
$results = [];
foreach ($this->extractFilenameTags($filename) as $tag) {
$results[] = [
'tag' => $tag,
'confidence' => 0.72,
'source' => 'filename',
];
}
if ($previewPath !== null && $previewPath !== '') {
// Stub AI output for now (real model integration can replace this later)
$results[] = [
'tag' => 'ai-detected',
'confidence' => 0.66,
'source' => 'ai',
];
$results[] = [
'tag' => 'visual-content',
'confidence' => 0.61,
'source' => 'ai',
];
}
if ($categoryContext !== null && $categoryContext !== '') {
$normalized = $this->normalizer->normalize($categoryContext);
if ($normalized !== '') {
$results[] = [
'tag' => $normalized,
'confidence' => 0.60,
'source' => 'manual',
];
}
}
return $this->dedupe($results);
}
/**
* @return array<int, string>
*/
private function extractFilenameTags(string $filename): array
{
$base = pathinfo($filename, PATHINFO_FILENAME) ?: $filename;
$parts = preg_split('/[\s._\-]+/', mb_strtolower($base, 'UTF-8')) ?: [];
$tags = [];
foreach ($parts as $part) {
$normalized = $this->normalizer->normalize((string) $part);
if ($normalized !== '' && mb_strlen($normalized, 'UTF-8') >= 3) {
$tags[] = $normalized;
}
}
return array_values(array_unique($tags));
}
/**
* @param array<int, array{tag:string,confidence:float,source:string}> $rows
* @return array<int, array{tag:string,confidence:float,source:string}>
*/
private function dedupe(array $rows): array
{
$best = [];
foreach ($rows as $row) {
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
if ($tag === '') {
continue;
}
$confidence = (float) ($row['confidence'] ?? 0.0);
$source = (string) ($row['source'] ?? 'manual');
if (! isset($best[$tag]) || $best[$tag]['confidence'] < $confidence) {
$best[$tag] = [
'tag' => $tag,
'confidence' => max(0.0, min(1.0, $confidence)),
'source' => in_array($source, ['ai', 'filename', 'manual'], true) ? $source : 'manual',
];
}
}
return array_values($best);
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Services\Upload;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class UploadDraftService implements UploadDraftServiceInterface
{
protected FilesystemManager $filesystem;
protected FilesystemContract $disk;
protected string $diskName;
protected string $basePath = 'tmp/drafts';
public function __construct(FilesystemManager $filesystem, string $diskName = 'local')
{
$this->filesystem = $filesystem;
$this->diskName = $diskName;
$this->disk = $this->filesystem->disk($this->diskName);
}
public function createDraft(array $attributes = []): array
{
$id = (string) Str::uuid();
$path = trim($this->basePath, '/') . '/' . $id;
if (! $this->disk->exists($path)) {
$this->disk->makeDirectory($path);
}
$meta = array_merge(['id' => $id, 'created_at' => Carbon::now()->toISOString()], $attributes);
DB::table('uploads')->insert([
'id' => $id,
'user_id' => (int) ($attributes['user_id'] ?? 0),
'type' => (string) ($attributes['type'] ?? 'image'),
'status' => 'draft',
'moderation_status' => 'pending',
'processing_state' => 'pending_scan',
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->writeMeta($id, $meta);
return ['id' => $id, 'path' => $path, 'meta' => $meta];
}
public function storeMainFile(string $draftId, UploadedFile $file): array
{
$dir = trim($this->basePath, '/') . '/' . $draftId . '/main';
if (! $this->disk->exists($dir)) {
$this->disk->makeDirectory($dir);
}
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
$size = $this->safeSize($storedPath, $file);
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
$meta = $this->readMeta($draftId);
$meta['main_file'] = $info;
$this->writeMeta($draftId, $meta);
DB::table('upload_files')->insert([
'upload_id' => $draftId,
'path' => $storedPath,
'type' => 'main',
'hash' => $hash,
'size' => $size,
'mime' => $mime,
'created_at' => now(),
]);
return $info;
}
public function storeScreenshot(string $draftId, UploadedFile $file): array
{
$dir = trim($this->basePath, '/') . '/' . $draftId . '/screenshots';
if (! $this->disk->exists($dir)) {
$this->disk->makeDirectory($dir);
}
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
$size = $this->safeSize($storedPath, $file);
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
$meta = $this->readMeta($draftId);
$meta['screenshots'][] = $info;
$this->writeMeta($draftId, $meta);
DB::table('upload_files')->insert([
'upload_id' => $draftId,
'path' => $storedPath,
'type' => 'screenshot',
'hash' => $hash,
'size' => $size,
'mime' => $mime,
'created_at' => now(),
]);
return $info;
}
public function calculateHash(string $filePath): string
{
// If path points to a local filesystem file
if (is_file($filePath)) {
return hash_file('sha256', $filePath);
}
// If path is a storage-relative path
if ($this->disk->exists($filePath)) {
$contents = $this->disk->get($filePath);
return hash('sha256', $contents);
}
throw new \RuntimeException('File not found for hashing: ' . $filePath);
}
public function setExpiration(string $draftId, ?Carbon $expiresAt = null): bool
{
$meta = $this->readMeta($draftId);
$meta['expires_at'] = $expiresAt?->toISOString();
$this->writeMeta($draftId, $meta);
DB::table('uploads')->where('id', $draftId)->update([
'expires_at' => $expiresAt,
'updated_at' => now(),
]);
return true;
}
protected function metaPath(string $draftId): string
{
return trim($this->basePath, '/') . '/' . $draftId . '/meta.json';
}
protected function readMeta(string $draftId): array
{
$path = $this->metaPath($draftId);
if (! $this->disk->exists($path)) {
return [];
}
$raw = $this->disk->get($path);
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
protected function writeMeta(string $draftId, array $meta): void
{
$path = $this->metaPath($draftId);
$this->disk->put($path, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
protected function safeSize(string $storedPath, UploadedFile $file): int
{
try {
return $this->disk->size($storedPath);
} catch (\Throwable $e) {
return (int) $file->getSize();
}
}
protected function safeMimeType(string $storedPath): ?string
{
try {
return $this->disk->mimeType($storedPath);
} catch (\Throwable $e) {
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\AuditLogRepository;
final class UploadAuditService
{
public function __construct(private readonly AuditLogRepository $repository)
{
}
public function log(?int $userId, string $action, string $ip, array $meta = []): void
{
$this->repository->log($userId, $action, $ip, $meta);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class UploadCancelService
{
public function __construct(
private readonly UploadSessionRepository $sessions,
private readonly UploadStorageService $storage,
private readonly UploadAuditService $audit
) {
}
public function cancel(string $sessionId, int $userId, string $ip): array
{
$session = $this->sessions->getOrFail($sessionId);
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
$lock = Cache::lock('uploads:cancel:' . $sessionId, $lockSeconds);
try {
$lock->block($lockWait);
} catch (\Throwable $e) {
$this->audit->log($userId, 'upload_cancel_locked', $ip, [
'session_id' => $sessionId,
]);
throw new RuntimeException('Upload is busy. Please retry.');
}
try {
if (in_array($session->status, [UploadSessionStatus::CANCELLED, UploadSessionStatus::PROCESSED, UploadSessionStatus::QUARANTINED], true)) {
$this->audit->log($userId, 'upload_cancel_noop', $ip, [
'session_id' => $sessionId,
'status' => $session->status,
]);
return [
'session_id' => $sessionId,
'status' => $session->status,
];
}
$this->safeDeleteTmp($session->tempPath);
$this->sessions->updateStatus($sessionId, UploadSessionStatus::CANCELLED);
$this->sessions->updateProgress($sessionId, 0);
$this->sessions->updateFailureReason($sessionId, 'cancelled');
$this->audit->log($userId, 'upload_cancelled', $ip, [
'session_id' => $sessionId,
]);
return [
'session_id' => $sessionId,
'status' => UploadSessionStatus::CANCELLED,
];
} finally {
optional($lock)->release();
}
}
private function safeDeleteTmp(string $path): void
{
$tmpRoot = $this->storage->sectionPath('tmp');
$realRoot = realpath($tmpRoot);
$realPath = realpath($path);
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
return;
}
if (File::exists($realPath)) {
File::delete($realPath);
}
}
}

Some files were not shown because too many files have changed in this diff Show More