diff --git a/.env.example b/.env.example index bc8108fd..2768b02a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Maximum secondary artwork categories in addition to the primary category. +CATEGORIES_MAX_SECONDARY=5 + # Artwork original downloads: PHP fallback unless nginx X-Accel is configured. # Leave false until the internal location exists and has been verified. DOWNLOAD_ACCEL_ENABLED=false @@ -71,6 +74,26 @@ SKINBASE_SESSION_DEBUG_HEADER=false BROADCAST_CONNECTION=reverb FILESYSTEM_DISK=local QUEUE_CONNECTION=redis + +# Comment spam protection: observe records scores without changing visibility. +COMMENT_SPAM_ENABLED=true +COMMENT_SPAM_MODE=enforce +COMMENT_SPAM_AI_ENABLED=true +COMMENT_SPAM_AI_PROVIDER=together +# Supported: Prism-ML/Ternary-Bonsai-27B or meta-llama/Llama-3.2-3B-Instruct-Turbo +COMMENT_SPAM_AI_MODEL=meta-llama/Llama-3.2-3B-Instruct-Turbo +COMMENT_SPAM_AI_TIMEOUT=5 +TOGETHER_API_KEY= +TOGETHER_API_BASE_URL=https://api.together.xyz/v1 +COMMENT_SPAM_LOCAL_SAFE_MAX=29 +COMMENT_SPAM_LOCAL_SPAM_MIN=70 +COMMENT_SPAM_AI_SPAM_MIN=70 +COMMENT_SPAM_SIGNATURE_CACHE_MINUTES=1440 +COMMENT_TURNSTILE_ENABLED=false +COMMENT_TURNSTILE_SITE_KEY= +COMMENT_TURNSTILE_SECRET_KEY= +COMMENT_TURNSTILE_RISK_THRESHOLD=40 +COMMENT_TURNSTILE_FAIL_OPEN=false # Must exceed Horizon supervisor-default timeout (960s). Production used 90s # and nightly RecComputeSimilar* jobs failed with MaxAttemptsExceeded. REDIS_QUEUE_RETRY_AFTER=1080 @@ -133,6 +156,13 @@ UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000 UPLOAD_RATE_CHUNK_USER=180 UPLOAD_RATE_CHUNK_IP=360 +# New accounts without this many moderator/legacy published artworks go to review. +# Queued uploads and trusted_auto publishes do not count, so a new user's 6th upload stays hidden. +UPLOAD_MIN_APPROVED_UPLOADS=5 +UPLOAD_MIN_ACCOUNT_AGE_DAYS=7 +UPLOAD_MAX_UNTRUSTED_LEVEL=1 +UPLOAD_BOT_RISK_REVIEW_THRESHOLD=40 + # Draft abuse prevention controls SKINBASE_MAX_DRAFTS=10 SKINBASE_MAX_DRAFT_STORAGE_MB=1024 @@ -445,6 +475,13 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=/auth/google/callback +# Google AdSense Management API (admin analytics, read-only) +# Redirect URI must match the Google Cloud OAuth client exactly. +ADSENSE_CLIENT_ID= +ADSENSE_CLIENT_SECRET= +ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback +ADSENSE_SYNC_ENABLED=true + # Optional light theme feature # Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle. # Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 314acac4..17a63ebf 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -48,6 +48,7 @@ Examples below are representative. For the full option list of any Artisan comma | `analytics:compare-feed-ab` | Compare baseline versus candidate feed algorithms over a date window | `php artisan analytics:compare-feed-ab baseline_v1 candidate_v2 --from=2026-04-01 --to=2026-04-16` | | `analytics:evaluate-feed-weights` | Run offline feed-weight evaluation against stored daily metrics | `php artisan analytics:evaluate-feed-weights --algo=v2 --from=2026-04-01 --to=2026-04-16` | | `analytics:seed-tag-interaction-demo` | Seed demo analytics data for local dashboards and ranking experiments | `php artisan analytics:seed-tag-interaction-demo --days=7 --per-day=100` | +| `adsense:sync` | Synchronize Google AdSense entities and daily reporting into the admin analytics tables | `php artisan adsense:sync --days=3` | ### AI Biography @@ -79,6 +80,7 @@ Examples below are representative. For the full option list of any Artisan comma | `artworks:check-user-ids` | Verify that every `artworks.user_id` exists in `users` | `php artisan artworks:check-user-ids --show-missing=50` | | `artworks:check-user-refs` | Full artwork-user reference audit with optional legacy-user recovery helpers | `php artisan artworks:check-user-refs --show-missing=50 --json` | | `artworks:embeddings-backfill` | Queue resumable CLIP embedding backfill jobs for artworks | `php artisan artworks:embeddings-backfill --after-id=0 --batch=200` | +| `artworks:backfill-primary-category` | Report and backfill `artworks.primary_category_id` from unique pivot memberships | `php artisan artworks:backfill-primary-category --report --apply` | | `artworks:generate-missing-sq-thumbs` | Generate missing smart square thumbnails | `php artisan artworks:generate-missing-sq-thumbs --limit=200 --dry-run` | | `artworks:import-hashes` | Import artwork hash and extension metadata from a CSV file | `php artisan artworks:import-hashes artworks_hash_skinbase.csv --start=0 --limit=1000` | | `artworks:normalize-slugs` | Normalize stored artwork slugs from titles without enforcing uniqueness | `php artisan artworks:normalize-slugs --only-mismatched --dry-run` | diff --git a/routes/api.php b/routes/api.php index 1b7c257a..2b4c241b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -486,6 +486,11 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')- Route::get('pending', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'pending']) ->name('pending'); + Route::get('{id}/preview', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'preview']) + ->middleware('signed') + ->whereUuid('id') + ->name('preview'); + Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'approve']) ->whereUuid('id') ->name('approve'); @@ -495,6 +500,12 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')- ->name('reject'); }); +Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/artwork-review')->name('api.admin.artwork-review.')->group(function () { + Route::get('pending', [\App\Http\Controllers\Api\Admin\ArtworkModerationController::class, 'pending'])->name('pending'); + Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\ArtworkModerationController::class, 'approve'])->whereNumber('id')->name('approve'); + Route::post('{id}/reject', [\App\Http\Controllers\Api\Admin\ArtworkModerationController::class, 'reject'])->whereNumber('id')->name('reject'); +}); + Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')->name('api.admin.reports.')->group(function () { Route::get('queue', [\App\Http\Controllers\Api\Admin\ModerationReportQueueController::class, 'index']) ->name('queue'); @@ -713,7 +724,7 @@ Route::middleware(['web', 'throttle:60,1']) ->whereNumber('id') ->name('api.artworks.comments.index'); -Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:20,1'])->group(function () { +Route::middleware(['web', 'auth', 'verified', 'normalize.username', 'throttle:20,1', 'comment.captcha', 'forum.spam.detection:artwork_comment'])->group(function () { Route::post('artworks/{id}/comments', [\App\Http\Controllers\Api\ArtworkCommentController::class, 'store']) ->whereNumber('id') ->name('api.artworks.comments.store'); @@ -901,6 +912,7 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1']) // ── Profile API (public, throttled) ───────────────────────────────────────── // GET /api/profile/{username}/artworks?sort=latest|trending|rising|views|favs&cursor=... // GET /api/profile/{username}/favourites?cursor=... +// GET /api/profile/{username}/collections // GET /api/profile/{username}/stats Route::middleware(['web', 'throttle:60,1']) ->prefix('profile/{username}') @@ -909,6 +921,7 @@ Route::middleware(['web', 'throttle:60,1']) ->group(function () { Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks'); Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites'); + Route::get('collections', [\App\Http\Controllers\Api\ProfileApiController::class, 'collections'])->name('collections'); Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats'); Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks'); Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history'); diff --git a/routes/web.php b/routes/web.php index 4d5899d4..b2eaed41 100644 --- a/routes/web.php +++ b/routes/web.php @@ -40,6 +40,7 @@ use App\Http\Controllers\RSS\TagFeedController; use App\Http\Controllers\RSS\CreatorFeedController; use App\Http\Controllers\RSS\BlogFeedController; use App\Http\Controllers\Admin\AdminController; +use App\Http\Controllers\Admin\AdsenseAnalyticsController; use App\Http\Controllers\Moderation\StaffApplicationsController; use App\Http\Controllers\Moderation\StoriesController as ModerationStoriesController; use App\Http\Controllers\Moderation\UsernameQueueController; @@ -78,6 +79,7 @@ use App\Http\Controllers\Settings\CollectionManageController; use App\Http\Controllers\Settings\CollectionProgrammingController; use App\Http\Controllers\Settings\CollectionSurfaceController; use App\Services\GroupMembershipService; +use App\Services\Moderation\ArtworkUploadPolicy; use Inertia\Inertia; Route::get('/build-info.json', BuildInfoController::class)->name('build-info'); @@ -976,6 +978,10 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () { 'filesCdnUrl' => config('cdn.files_url'), 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), 'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000), + 'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5), + 'requires_upload_review' => $user + ? (bool) app(ArtworkUploadPolicy::class)->assess($user)['requires_review'] + : false, 'feature_flags' => [ 'uploads_v2' => (bool) config('features.uploads_v2', false), ], @@ -1036,6 +1042,10 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () { 'filesCdnUrl' => config('cdn.files_url'), 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), 'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000), + 'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5), + 'requires_upload_review' => $user + ? (bool) app(ArtworkUploadPolicy::class)->assess($user)['requires_review'] + : false, 'feature_flags' => [ 'uploads_v2' => (bool) config('features.uploads_v2', false), ], @@ -1069,6 +1079,18 @@ Route::middleware(['auth', 'verified', 'ensure.onboarding.complete'])->prefix('m Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show'); }); +Route::middleware(['auth', 'admin.role']) + ->prefix('admin/adsense') + ->name('admin.adsense.') + ->group(function (): void { + Route::get('/', [AdsenseAnalyticsController::class, 'index'])->name('index'); + Route::get('/connect', [AdsenseAnalyticsController::class, 'connect'])->name('connect'); + Route::get('/oauth/callback', [AdsenseAnalyticsController::class, 'callback'])->name('callback'); + Route::post('/account', [AdsenseAnalyticsController::class, 'selectAccount'])->name('account'); + Route::post('/sync', [AdsenseAnalyticsController::class, 'sync'])->name('sync'); + Route::delete('/disconnect', [AdsenseAnalyticsController::class, 'disconnect'])->name('disconnect'); + }); + Route::middleware(['auth']) ->prefix('admin') ->group(function () {