Wire new routes and environment keys for the recent modules.

Register AdSense, artwork review, comment captcha, lazy collections, and upload review props, and document the matching env vars and CLI commands.
This commit is contained in:
test
2026-09-20 14:51:04 +02:00
parent 39819a85cb
commit 9c537e08d0
4 changed files with 75 additions and 1 deletions
+37
View File
@@ -4,6 +4,9 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost 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. # Artwork original downloads: PHP fallback unless nginx X-Accel is configured.
# Leave false until the internal location exists and has been verified. # Leave false until the internal location exists and has been verified.
DOWNLOAD_ACCEL_ENABLED=false DOWNLOAD_ACCEL_ENABLED=false
@@ -71,6 +74,26 @@ SKINBASE_SESSION_DEBUG_HEADER=false
BROADCAST_CONNECTION=reverb BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis 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 # Must exceed Horizon supervisor-default timeout (960s). Production used 90s
# and nightly RecComputeSimilar* jobs failed with MaxAttemptsExceeded. # and nightly RecComputeSimilar* jobs failed with MaxAttemptsExceeded.
REDIS_QUEUE_RETRY_AFTER=1080 REDIS_QUEUE_RETRY_AFTER=1080
@@ -133,6 +156,13 @@ UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
UPLOAD_RATE_CHUNK_USER=180 UPLOAD_RATE_CHUNK_USER=180
UPLOAD_RATE_CHUNK_IP=360 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 # Draft abuse prevention controls
SKINBASE_MAX_DRAFTS=10 SKINBASE_MAX_DRAFTS=10
SKINBASE_MAX_DRAFT_STORAGE_MB=1024 SKINBASE_MAX_DRAFT_STORAGE_MB=1024
@@ -445,6 +475,13 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback 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 # Optional light theme feature
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle. # 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. # Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
+2
View File
@@ -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: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: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` | | `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 ### 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-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: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: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: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: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` | | `artworks:normalize-slugs` | Normalize stored artwork slugs from titles without enforcing uniqueness | `php artisan artworks:normalize-slugs --only-mismatched --dry-run` |
+14 -1
View File
@@ -486,6 +486,11 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')-
Route::get('pending', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'pending']) Route::get('pending', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'pending'])
->name('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']) Route::post('{id}/approve', [\App\Http\Controllers\Api\Admin\UploadModerationController::class, 'approve'])
->whereUuid('id') ->whereUuid('id')
->name('approve'); ->name('approve');
@@ -495,6 +500,12 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/uploads')-
->name('reject'); ->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::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']) Route::get('queue', [\App\Http\Controllers\Api\Admin\ModerationReportQueueController::class, 'index'])
->name('queue'); ->name('queue');
@@ -713,7 +724,7 @@ Route::middleware(['web', 'throttle:60,1'])
->whereNumber('id') ->whereNumber('id')
->name('api.artworks.comments.index'); ->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']) Route::post('artworks/{id}/comments', [\App\Http\Controllers\Api\ArtworkCommentController::class, 'store'])
->whereNumber('id') ->whereNumber('id')
->name('api.artworks.comments.store'); ->name('api.artworks.comments.store');
@@ -901,6 +912,7 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
// ── Profile API (public, throttled) ───────────────────────────────────────── // ── Profile API (public, throttled) ─────────────────────────────────────────
// GET /api/profile/{username}/artworks?sort=latest|trending|rising|views|favs&cursor=... // GET /api/profile/{username}/artworks?sort=latest|trending|rising|views|favs&cursor=...
// GET /api/profile/{username}/favourites?cursor=... // GET /api/profile/{username}/favourites?cursor=...
// GET /api/profile/{username}/collections
// GET /api/profile/{username}/stats // GET /api/profile/{username}/stats
Route::middleware(['web', 'throttle:60,1']) Route::middleware(['web', 'throttle:60,1'])
->prefix('profile/{username}') ->prefix('profile/{username}')
@@ -909,6 +921,7 @@ Route::middleware(['web', 'throttle:60,1'])
->group(function () { ->group(function () {
Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks'); 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('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('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('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'); Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history');
+22
View File
@@ -40,6 +40,7 @@ use App\Http\Controllers\RSS\TagFeedController;
use App\Http\Controllers\RSS\CreatorFeedController; use App\Http\Controllers\RSS\CreatorFeedController;
use App\Http\Controllers\RSS\BlogFeedController; use App\Http\Controllers\RSS\BlogFeedController;
use App\Http\Controllers\Admin\AdminController; use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\AdsenseAnalyticsController;
use App\Http\Controllers\Moderation\StaffApplicationsController; use App\Http\Controllers\Moderation\StaffApplicationsController;
use App\Http\Controllers\Moderation\StoriesController as ModerationStoriesController; use App\Http\Controllers\Moderation\StoriesController as ModerationStoriesController;
use App\Http\Controllers\Moderation\UsernameQueueController; 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\CollectionProgrammingController;
use App\Http\Controllers\Settings\CollectionSurfaceController; use App\Http\Controllers\Settings\CollectionSurfaceController;
use App\Services\GroupMembershipService; use App\Services\GroupMembershipService;
use App\Services\Moderation\ArtworkUploadPolicy;
use Inertia\Inertia; use Inertia\Inertia;
Route::get('/build-info.json', BuildInfoController::class)->name('build-info'); 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'), 'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000), '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' => [ 'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false), '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'), 'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880), 'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000), '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' => [ 'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false), '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::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']) Route::middleware(['auth'])
->prefix('admin') ->prefix('admin')
->group(function () { ->group(function () {