Allow artworks to have one primary category and extra secondaries.

Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
This commit is contained in:
test
2026-09-20 14:50:12 +02:00
parent 4d60a876af
commit 232880e01f
43 changed files with 2896 additions and 565 deletions
+150 -89
View File
@@ -5,52 +5,58 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadChunkRequest;
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\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\IndexArtworkJob;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\Tag;
use App\Notifications\NewArtworkReviewNotification;
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\UploadQueueService;
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\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\GroupArtworkReviewService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Moderation\ArtworkUploadPolicy;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadChunkService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use App\Support\ArtworkDescriptionContentValidator;
use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Validation\ValidationException;
use App\Uploads\Services\PublishService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
final class UploadController extends Controller
{
@@ -59,8 +65,7 @@ final class UploadController extends Controller
UploadPipelineService $pipeline,
UploadQuotaService $quota,
UploadAuditService $audit
)
{
) {
$user = $request->user();
try {
@@ -251,6 +256,7 @@ final class UploadController extends Controller
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
}
return UploadSessionStatus::PROCESSED;
});
@@ -305,7 +311,7 @@ final class UploadController extends Controller
'realpath' => $chunkFile->getRealPath(),
]);
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
}
@@ -333,7 +339,7 @@ final class UploadController extends Controller
'total_bytes' => $result->totalBytes,
'progress' => $result->progress,
], Response::HTTP_OK);
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Upload chunk failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
@@ -383,7 +389,7 @@ final class UploadController extends Controller
'session_id' => $result['session_id'],
'status' => $result['status'],
], Response::HTTP_OK);
} catch (\Throwable $e) {
} catch (Throwable $e) {
logger()->warning('Upload cancel failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
@@ -512,6 +518,7 @@ final class UploadController extends Controller
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);
}
}
@@ -538,6 +545,8 @@ final class UploadController extends Controller
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'description' => ['nullable', 'string'],
'tags' => ['nullable', 'array'],
'license' => ['nullable', 'string'],
@@ -547,7 +556,7 @@ final class UploadController extends Controller
$this->ensureValidArtworkDescription($validated);
$updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
foreach (['title', 'category_id', 'secondary_category_ids', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) {
$updates[$field] = $validated[$field];
}
@@ -557,7 +566,7 @@ final class UploadController extends Controller
foreach ($updates as $field => $value) {
$current = $upload->{$field} ?? null;
if ($field === 'tags') {
if ($field === 'tags' || $field === 'secondary_category_ids') {
$current = $current ? json_decode((string) $current, true) : null;
}
@@ -575,6 +584,10 @@ final class UploadController extends Controller
$dirty['tags'] = json_encode($dirty['tags']);
}
if (array_key_exists('secondary_category_ids', $dirty)) {
$dirty['secondary_category_ids'] = json_encode($dirty['secondary_category_ids']);
}
if (! empty($dirty)) {
$dirty['updated_at'] = now();
DB::table('uploads')->where('id', $id)->update($dirty);
@@ -616,23 +629,26 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['nullable', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
@@ -649,21 +665,21 @@ final class UploadController extends Controller
$this->ensureValidArtworkDescription($validated);
$mode = $validated['mode'] ?? 'now';
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public';
// Resolve the UTC publish_at datetime for schedule mode
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
$publishAt = Carbon::parse($validated['publish_at'])->utc();
// Must be at least 1 minute in the future (server-side guard)
if ($publishAt->lte(now()->addMinute())) {
return response()->json([
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
} catch (\Throwable) {
} catch (Throwable) {
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
@@ -696,22 +712,25 @@ final class UploadController extends Controller
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
}
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
// Sync category if provided
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
$categoryId = isset($validated['primary_category_id'])
? (int) $validated['primary_category_id']
: (isset($validated['category']) ? (int) $validated['category'] : null);
$secondaryCategoryIds = array_values(array_map('intval', $validated['secondary_category_ids'] ?? []));
if ($categoryId) {
app(ArtworkCategoryService::class)
->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
}
// Sync tags if provided
if (!empty($validated['tags']) && is_array($validated['tags'])) {
if (! empty($validated['tags']) && is_array($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
$tag = Tag::firstOrCreate(
['slug' => Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
@@ -727,56 +746,95 @@ final class UploadController extends Controller
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->artwork_status = 'scheduled';
$artwork->published_at = null;
$artwork->published_at = null;
$artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id);
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'published_at' => null,
], Response::HTTP_OK);
}
// Publish immediately
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->published_at = now();
// New and suspicious accounts are quarantined. Never let the client
// decide that an artwork is approved.
$policy = $uploadPolicy->assess($user, $artwork);
if ($policy['requires_review']) {
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = false;
$artwork->approval_source = null;
$artwork->artwork_status = 'review';
$artwork->published_at = null;
$artwork->publish_at = null;
$artwork->moderated_at = null;
$artwork->moderated_by = null;
$artwork->moderation_note = implode(', ', $policy['reasons']);
$artwork->save();
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
if ($notifyEmail !== '') {
try {
Notification::route('mail', $notifyEmail)->notify(
new NewArtworkReviewNotification($artwork, $user, $policy['reasons'])
);
} catch (Throwable) {
// Email failure must not expose the artwork.
}
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'submitted_for_review',
'message' => 'Upload received and queued for moderation.',
'review_reasons' => $policy['reasons'],
], Response::HTTP_OK);
}
// Publish immediately for trusted users only.
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->approval_source = 'trusted_auto';
$artwork->published_at = now();
$artwork->artwork_status = 'published';
$artwork->publish_at = null;
$artwork->publish_at = null;
$artwork->save();
IndexArtworkJob::dispatch((int) $artwork->id);
// Record upload activity event
try {
\App\Models\ActivityEvent::record(
actorId: (int) $user->id,
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
ActivityEvent::record(
actorId: (int) $user->id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
} catch (Throwable) {
}
try {
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
} catch (\Throwable) {}
} catch (Throwable) {
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK);
}
@@ -785,11 +843,11 @@ final class UploadController extends Controller
$upload = $publishService->publish($id, $user);
return response()->json([
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
@@ -808,7 +866,10 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],