485 lines
19 KiB
PHP
485 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkVersion;
|
|
use App\Models\ArtworkVersionEvent;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
|
|
|
/**
|
|
* ArtworkVersioningService
|
|
*
|
|
* Manages non-destructive file replacement for artworks.
|
|
*
|
|
* Guarantees:
|
|
* - All replacements create a new version row; originals are never deleted.
|
|
* - Engagement (views, favourites, downloads) is never reset.
|
|
* - Ranking scores receive a small protective decay on each replacement.
|
|
* - Abusive rapid replacement is blocked by rate limits.
|
|
* - Major visual changes trigger requires_reapproval flag.
|
|
*/
|
|
final class ArtworkVersioningService
|
|
{
|
|
// ── Rate-limit thresholds ─────────────────────────────────────────────
|
|
private const MAX_PER_HOUR = 3;
|
|
private const MAX_PER_DAY = 10;
|
|
|
|
// ── Reapproval: flag when dimension changes beyond this fraction ──────
|
|
private const DIMENSION_CHANGE_THRESHOLD = 0.5; // 50 % change triggers re-approval
|
|
|
|
// ── Ranking decay applied per replacement ─────────────────────────────
|
|
private const RANKING_DECAY_FACTOR = 0.93; // 7 % decay
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Capture the current artwork media state as a revision snapshot.
|
|
*
|
|
* @return array{artwork: array<string, mixed>, files: array<int, array<string, mixed>>}
|
|
*/
|
|
public function captureArtworkSnapshot(Artwork $artwork): array
|
|
{
|
|
return [
|
|
'artwork' => [
|
|
'file_name' => (string) ($artwork->file_name ?? ''),
|
|
'file_path' => (string) ($artwork->file_path ?? ''),
|
|
'hash' => (string) ($artwork->hash ?? ''),
|
|
'file_ext' => (string) ($artwork->file_ext ?? ''),
|
|
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
|
|
'file_size' => (int) ($artwork->file_size ?? 0),
|
|
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
|
|
'width' => (int) ($artwork->width ?? 0),
|
|
'height' => (int) ($artwork->height ?? 0),
|
|
],
|
|
'files' => DB::table('artwork_files')
|
|
->where('artwork_id', $artwork->id)
|
|
->orderBy('variant')
|
|
->get(['variant', 'path', 'mime', 'size'])
|
|
->map(fn ($row): array => [
|
|
'variant' => (string) ($row->variant ?? ''),
|
|
'path' => (string) ($row->path ?? ''),
|
|
'mime' => (string) ($row->mime ?? 'application/octet-stream'),
|
|
'size' => (int) ($row->size ?? 0),
|
|
])
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Create a new immutable revision from a fully materialized artwork snapshot.
|
|
*
|
|
* @param array<string, mixed> $snapshot
|
|
* @param array<string, mixed>|null $previousSnapshot
|
|
*/
|
|
public function createVersionFromSnapshot(
|
|
Artwork $artwork,
|
|
array $snapshot,
|
|
int $userId,
|
|
?string $changeNote = null,
|
|
?array $previousSnapshot = null,
|
|
): ArtworkVersion {
|
|
$normalizedSnapshot = $this->normalizeSnapshot($snapshot);
|
|
$previous = $this->normalizeSnapshot($previousSnapshot ?? $this->captureArtworkSnapshot($artwork));
|
|
|
|
$this->rateLimitCheck($userId, $artwork->id);
|
|
|
|
return DB::transaction(function () use ($artwork, $normalizedSnapshot, $previous, $userId, $changeNote): ArtworkVersion {
|
|
$this->ensureBaselineVersion($artwork, $previous, $userId);
|
|
|
|
$nextNumber = max((int) $artwork->versions()->max('version_number'), 0) + 1;
|
|
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
|
|
|
|
$artwork->versions()->update(['is_current' => false]);
|
|
|
|
$version = ArtworkVersion::create([
|
|
'artwork_id' => $artwork->id,
|
|
'version_number' => $nextNumber,
|
|
'file_path' => $meta['file_path'],
|
|
'file_hash' => $meta['hash'],
|
|
'width' => $meta['width'],
|
|
'height' => $meta['height'],
|
|
'file_size' => $meta['file_size'],
|
|
'change_note' => $changeNote,
|
|
'snapshot_json' => $normalizedSnapshot,
|
|
'is_current' => true,
|
|
]);
|
|
|
|
$needsReapproval = $this->shouldRequireReapprovalFromSnapshots($previous, $normalizedSnapshot);
|
|
|
|
$artwork->update([
|
|
'current_version_id' => $version->id,
|
|
'version_count' => $nextNumber,
|
|
'version_updated_at' => now(),
|
|
'requires_reapproval' => $needsReapproval,
|
|
]);
|
|
|
|
$this->applyRankingProtection($artwork);
|
|
|
|
ArtworkVersionEvent::create([
|
|
'artwork_id' => $artwork->id,
|
|
'user_id' => $userId,
|
|
'action' => 'create_version',
|
|
'version_id' => $version->id,
|
|
]);
|
|
|
|
$this->incrementRateLimitCounters($userId, $artwork->id);
|
|
|
|
return $version;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply a stored snapshot back onto the artwork row and artwork_files table.
|
|
*
|
|
* @param array<string, mixed> $snapshot
|
|
*/
|
|
public function applySnapshot(Artwork $artwork, array $snapshot): void
|
|
{
|
|
$normalizedSnapshot = $this->normalizeSnapshot($snapshot);
|
|
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
|
|
|
|
DB::transaction(function () use ($artwork, $normalizedSnapshot, $meta): void {
|
|
DB::table('artwork_files')
|
|
->where('artwork_id', $artwork->id)
|
|
->delete();
|
|
|
|
$rows = collect($normalizedSnapshot['files'] ?? [])
|
|
->filter(fn (array $row): bool => ($row['variant'] ?? '') !== '' && ($row['path'] ?? '') !== '')
|
|
->map(fn (array $row): array => [
|
|
'artwork_id' => $artwork->id,
|
|
'variant' => (string) $row['variant'],
|
|
'path' => (string) $row['path'],
|
|
'mime' => (string) ($row['mime'] ?? 'application/octet-stream'),
|
|
'size' => (int) ($row['size'] ?? 0),
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
if ($rows !== []) {
|
|
DB::table('artwork_files')->insert($rows);
|
|
}
|
|
|
|
$artwork->update([
|
|
'file_name' => $meta['file_name'],
|
|
'file_path' => $meta['file_path'],
|
|
'hash' => $meta['hash'],
|
|
'file_ext' => $meta['file_ext'],
|
|
'thumb_ext' => $meta['thumb_ext'],
|
|
'file_size' => $meta['file_size'],
|
|
'mime_type' => $meta['mime_type'],
|
|
'width' => $meta['width'],
|
|
'height' => $meta['height'],
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new version for an artwork after a file replacement.
|
|
*
|
|
* This is the primary entry-point called by the controller.
|
|
*
|
|
* @param Artwork $artwork The artwork being updated.
|
|
* @param string $filePath Relative path stored for this version.
|
|
* @param string $fileHash SHA-256 hex hash of the new file.
|
|
* @param int $width New file width in pixels.
|
|
* @param int $height New file height in pixels.
|
|
* @param int $fileSize New file size in bytes.
|
|
* @param int $userId ID of the acting user (for audit log).
|
|
* @param string|null $changeNote Optional user-supplied change note.
|
|
*
|
|
* @throws TooManyRequestsHttpException When rate limit is exceeded.
|
|
* @throws \RuntimeException When the hash is identical to the current file.
|
|
*/
|
|
public function createNewVersion(
|
|
Artwork $artwork,
|
|
string $filePath,
|
|
string $fileHash,
|
|
int $width,
|
|
int $height,
|
|
int $fileSize,
|
|
int $userId,
|
|
?string $changeNote = null,
|
|
): ArtworkVersion {
|
|
$snapshot = [
|
|
'artwork' => [
|
|
'file_name' => (string) ($artwork->file_name ?? 'artwork'),
|
|
'file_path' => $filePath,
|
|
'hash' => $fileHash,
|
|
'file_ext' => (string) ($artwork->file_ext ?? ''),
|
|
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
|
|
'file_size' => $fileSize,
|
|
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
|
|
'width' => $width,
|
|
'height' => $height,
|
|
],
|
|
'files' => [],
|
|
];
|
|
|
|
return $this->createVersionFromSnapshot($artwork, $snapshot, $userId, $changeNote);
|
|
}
|
|
|
|
/**
|
|
* Restore a previous version by cloning it as a new (current) version.
|
|
*
|
|
* The restored file is treated as a brand-new version so the history
|
|
* remains strictly append-only and the version counter always increases.
|
|
*
|
|
* @throws TooManyRequestsHttpException When rate limit is exceeded.
|
|
*/
|
|
public function restoreVersion(
|
|
ArtworkVersion $version,
|
|
Artwork $artwork,
|
|
int $userId,
|
|
): ArtworkVersion {
|
|
$previousSnapshot = $this->captureArtworkSnapshot($artwork);
|
|
$snapshot = is_array($version->snapshot_json)
|
|
? $this->normalizeSnapshot($version->snapshot_json)
|
|
: $this->normalizeSnapshot([
|
|
'artwork' => array_merge($previousSnapshot['artwork'] ?? [], [
|
|
'file_name' => (string) ($artwork->file_name ?? 'artwork'),
|
|
'file_path' => $version->file_path,
|
|
'hash' => $version->file_hash,
|
|
'file_ext' => (string) ($artwork->file_ext ?? ''),
|
|
'thumb_ext' => (string) ($artwork->thumb_ext ?? ''),
|
|
'file_size' => (int) $version->file_size,
|
|
'mime_type' => (string) ($artwork->mime_type ?? 'application/octet-stream'),
|
|
'width' => (int) $version->width,
|
|
'height' => (int) $version->height,
|
|
]),
|
|
'files' => $previousSnapshot['files'] ?? [],
|
|
]);
|
|
|
|
$this->applySnapshot($artwork, $snapshot);
|
|
$artwork->refresh();
|
|
|
|
return $this->createVersionFromSnapshot(
|
|
$artwork,
|
|
$snapshot,
|
|
$userId,
|
|
"Restored from version {$version->version_number}",
|
|
$previousSnapshot,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decide whether the new file warrants a moderation re-check.
|
|
*
|
|
* Triggers when either dimension changes by more than the threshold.
|
|
*/
|
|
public function shouldRequireReapproval(Artwork $artwork, int $newWidth, int $newHeight): bool
|
|
{
|
|
// First version upload — no existing dimensions to compare
|
|
if (!$artwork->width || !$artwork->height) {
|
|
return false;
|
|
}
|
|
|
|
$widthChange = abs($newWidth - $artwork->width) / max($artwork->width, 1);
|
|
$heightChange = abs($newHeight - $artwork->height) / max($artwork->height, 1);
|
|
|
|
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|
|
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $previousSnapshot
|
|
* @param array<string, mixed> $newSnapshot
|
|
*/
|
|
public function shouldRequireReapprovalFromSnapshots(array $previousSnapshot, array $newSnapshot): bool
|
|
{
|
|
$previous = $this->snapshotArtworkMeta($previousSnapshot);
|
|
$next = $this->snapshotArtworkMeta($newSnapshot);
|
|
|
|
if (($previous['width'] ?? 0) <= 0 || ($previous['height'] ?? 0) <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$widthChange = abs($next['width'] - $previous['width']) / max($previous['width'], 1);
|
|
$heightChange = abs($next['height'] - $previous['height']) / max($previous['height'], 1);
|
|
|
|
return $widthChange > self::DIMENSION_CHANGE_THRESHOLD
|
|
|| $heightChange > self::DIMENSION_CHANGE_THRESHOLD;
|
|
}
|
|
|
|
/**
|
|
* Apply a small protective decay (7 %) to ranking and heat scores.
|
|
*
|
|
* This prevents creators from gaming the ranking algorithm by rapidly
|
|
* cycling file versions to refresh discovery signals.
|
|
* Engagement totals (views, favourites, downloads) are NOT touched.
|
|
*/
|
|
public function applyRankingProtection(Artwork $artwork): void
|
|
{
|
|
try {
|
|
DB::table('artwork_stats')
|
|
->where('artwork_id', $artwork->id)
|
|
->update([
|
|
'ranking_score' => DB::raw('ranking_score * ' . self::RANKING_DECAY_FACTOR),
|
|
'heat_score' => DB::raw('heat_score * ' . self::RANKING_DECAY_FACTOR),
|
|
'engagement_velocity' => DB::raw('engagement_velocity * ' . self::RANKING_DECAY_FACTOR),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
// Non-fatal — log and continue so the version is still saved.
|
|
Log::warning('ArtworkVersioningService: ranking protection failed', [
|
|
'artwork_id' => $artwork->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throw TooManyRequestsHttpException when the user has exceeded either:
|
|
* • 3 replacements per hour for this artwork
|
|
* • 10 replacements per day for this user (across all artworks)
|
|
*/
|
|
public function rateLimitCheck(int $userId, int $artworkId): void
|
|
{
|
|
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
|
|
$dayKey = "artwork_version:day:{$userId}";
|
|
|
|
$hourCount = (int) Cache::get($hourKey, 0);
|
|
$dayCount = (int) Cache::get($dayKey, 0);
|
|
|
|
if ($hourCount >= self::MAX_PER_HOUR) {
|
|
throw new TooManyRequestsHttpException(
|
|
3600,
|
|
'You have replaced this artwork too many times in the last hour. Please wait before trying again.'
|
|
);
|
|
}
|
|
|
|
if ($dayCount >= self::MAX_PER_DAY) {
|
|
throw new TooManyRequestsHttpException(
|
|
86400,
|
|
'You have reached the daily replacement limit. Please wait until tomorrow.'
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Private helpers ────────────────────────────────────────────────────
|
|
|
|
private function incrementRateLimitCounters(int $userId, int $artworkId): void
|
|
{
|
|
$hourKey = "artwork_version:hour:{$userId}:{$artworkId}";
|
|
$dayKey = "artwork_version:day:{$userId}";
|
|
|
|
// Hourly counter — expires in 1 hour
|
|
if (Cache::has($hourKey)) {
|
|
Cache::increment($hourKey);
|
|
} else {
|
|
Cache::put($hourKey, 1, 3600);
|
|
}
|
|
|
|
// Daily counter — expires at midnight (or 24 hours from first hit)
|
|
if (Cache::has($dayKey)) {
|
|
Cache::increment($dayKey);
|
|
} else {
|
|
Cache::put($dayKey, 1, 86400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $snapshot
|
|
*/
|
|
private function ensureBaselineVersion(Artwork $artwork, ?array $snapshot, int $userId): ?ArtworkVersion
|
|
{
|
|
if ($artwork->versions()->exists()) {
|
|
return null;
|
|
}
|
|
|
|
$normalizedSnapshot = $this->normalizeSnapshot($snapshot ?? $this->captureArtworkSnapshot($artwork));
|
|
$meta = $this->snapshotArtworkMeta($normalizedSnapshot);
|
|
$baselineNumber = max(1, (int) ($artwork->version_count ?? 1));
|
|
|
|
$version = ArtworkVersion::create([
|
|
'artwork_id' => $artwork->id,
|
|
'version_number' => $baselineNumber,
|
|
'file_path' => $meta['file_path'],
|
|
'file_hash' => $meta['hash'],
|
|
'width' => $meta['width'],
|
|
'height' => $meta['height'],
|
|
'file_size' => $meta['file_size'],
|
|
'change_note' => 'Baseline snapshot',
|
|
'snapshot_json' => $normalizedSnapshot,
|
|
'is_current' => true,
|
|
]);
|
|
|
|
$artwork->update([
|
|
'current_version_id' => $version->id,
|
|
'version_count' => $baselineNumber,
|
|
'version_updated_at' => now(),
|
|
]);
|
|
|
|
ArtworkVersionEvent::create([
|
|
'artwork_id' => $artwork->id,
|
|
'user_id' => $userId,
|
|
'action' => 'baseline_snapshot',
|
|
'version_id' => $version->id,
|
|
]);
|
|
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $snapshot
|
|
* @return array{file_name: string, file_path: string, hash: string, file_ext: string, thumb_ext: string, file_size: int, mime_type: string, width: int, height: int}
|
|
*/
|
|
private function snapshotArtworkMeta(array $snapshot): array
|
|
{
|
|
$normalized = $this->normalizeSnapshot($snapshot);
|
|
$artwork = $normalized['artwork'];
|
|
|
|
return [
|
|
'file_name' => (string) ($artwork['file_name'] ?? 'artwork'),
|
|
'file_path' => (string) ($artwork['file_path'] ?? ''),
|
|
'hash' => (string) ($artwork['hash'] ?? ''),
|
|
'file_ext' => (string) ($artwork['file_ext'] ?? ''),
|
|
'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''),
|
|
'file_size' => (int) ($artwork['file_size'] ?? 0),
|
|
'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'),
|
|
'width' => max(0, (int) ($artwork['width'] ?? 0)),
|
|
'height' => max(0, (int) ($artwork['height'] ?? 0)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $snapshot
|
|
* @return array{artwork: array<string, mixed>, files: array<int, array<string, mixed>>}
|
|
*/
|
|
private function normalizeSnapshot(?array $snapshot): array
|
|
{
|
|
$artwork = is_array($snapshot['artwork'] ?? null) ? $snapshot['artwork'] : [];
|
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
|
|
|
return [
|
|
'artwork' => [
|
|
'file_name' => (string) ($artwork['file_name'] ?? 'artwork'),
|
|
'file_path' => (string) ($artwork['file_path'] ?? ''),
|
|
'hash' => (string) ($artwork['hash'] ?? ''),
|
|
'file_ext' => (string) ($artwork['file_ext'] ?? ''),
|
|
'thumb_ext' => (string) ($artwork['thumb_ext'] ?? ''),
|
|
'file_size' => (int) ($artwork['file_size'] ?? 0),
|
|
'mime_type' => (string) ($artwork['mime_type'] ?? 'application/octet-stream'),
|
|
'width' => max(0, (int) ($artwork['width'] ?? 0)),
|
|
'height' => max(0, (int) ($artwork['height'] ?? 0)),
|
|
],
|
|
'files' => collect($files)
|
|
->filter(fn ($file): bool => is_array($file))
|
|
->map(fn (array $file): array => [
|
|
'variant' => (string) ($file['variant'] ?? ''),
|
|
'path' => (string) ($file['path'] ?? ''),
|
|
'mime' => (string) ($file['mime'] ?? 'application/octet-stream'),
|
|
'size' => (int) ($file['size'] ?? 0),
|
|
])
|
|
->filter(fn (array $file): bool => $file['variant'] !== '' && $file['path'] !== '')
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
}
|