[read_col, write_raw_col, write_rendered_col|null] * * For artwork_comments we write two columns; for the others we only sanitize in-place. */ private const TARGETS = [ 'artwork_comments' => [ 'read' => 'content', 'write_raw' => 'raw_content', 'write_rendered' => 'rendered_content', ], 'artworks' => [ 'read' => 'description', 'write_raw' => 'description', 'write_rendered' => null, ], 'forum_posts' => [ 'read' => 'content', 'write_raw' => 'content', 'write_rendered' => null, ], ]; public function handle(): int { $dryRun = (bool) $this->option('dry-run'); $chunk = max(1, (int) $this->option('chunk')); $tableOpt = $this->option('table'); $artworkId = $this->option('artwork-id'); if ($artworkId !== null) { if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) { $this->error("--artwork-id must be a positive integer. Got: {$artworkId}"); return self::FAILURE; } $artworkId = (int) $artworkId; } $targets = self::TARGETS; if ($tableOpt) { if (! isset($targets[$tableOpt])) { $this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets))); return self::FAILURE; } $targets = [$tableOpt => $targets[$tableOpt]]; } // --artwork-id removes forum_posts (no artwork FK) and informs the user. if ($artworkId !== null) { unset($targets['forum_posts']); $this->line("Filtering to artwork #{$artworkId} (forum_posts skipped)."); } if ($dryRun) { $this->warn('DRY-RUN mode — no changes will be written.'); } $totalModified = 0; $totalRows = 0; foreach ($targets as $table => $def) { $this->line("Processing {$table}…"); [$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId); $totalModified += $modified; $totalRows += $rows; $this->line(" → {$rows} rows scanned, {$modified} modified."); } $this->newLine(); $this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.'); return self::SUCCESS; } private function processTable( string $table, array $def, int $chunk, bool $dryRun, ?int $artworkId = null ): array { $totalModified = 0; $totalRows = 0; $readCol = $def['read']; $writeRawCol = $def['write_raw']; $writeRenderedCol = $def['write_rendered']; DB::table($table) ->whereNotNull($readCol) ->when($artworkId !== null, function ($q) use ($table, $artworkId) { // artwork_comments has artwork_id; artworks is filtered by its own PK. $filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id'; $q->where($filterCol, $artworkId); }) ->orderBy('id') ->chunk($chunk, function ($rows) use ( $table, $readCol, $writeRawCol, $writeRenderedCol, $dryRun, &$totalModified, &$totalRows ) { foreach ($rows as $row) { $original = $row->$readCol ?? ''; $stripped = ContentSanitizer::stripToPlain($original); $totalRows++; // Detect if content had HTML that we need to clean $hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original); if ($writeRawCol === $readCol && ! $hadHtml) { // Same column, no HTML, skip continue; } $rendered = ContentSanitizer::render($stripped); $totalModified++; if ($hadHtml) { $this->line(" [{$table}#{$row->id}] Stripped HTML from content."); Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}"); } if ($dryRun) { continue; } $update = [$writeRawCol => $stripped]; if ($writeRenderedCol) { $update[$writeRenderedCol] = $rendered; } DB::table($table)->where('id', $row->id)->update($update); } // Also populate rendered_content for rows that have raw_content but no rendered_content if ($writeRenderedCol && ! $dryRun) { DB::table($table) ->whereNotNull($writeRawCol) ->whereNull($writeRenderedCol) ->orderBy('id') ->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) { foreach ($missing as $row) { $rendered = ContentSanitizer::render($row->$writeRawCol ?? ''); DB::table($table)->where('id', $row->id)->update([ $writeRenderedCol => $rendered, ]); } }); } }); return [$totalModified, $totalRows]; } }