prepared and gallery fixes

This commit is contained in:
2026-02-19 08:36:32 +01:00
parent 8935065af1
commit c30fa5a392
36 changed files with 1437 additions and 104 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ForumPost;
use App\Services\BbcodeConverter;
class ForumConvertPosts extends Command
{
protected $signature = 'forum:convert-posts {--dry-run} {--chunk=500} {--limit=} {--report}';
protected $description = 'Convert migrated forum posts content from legacy BBCode to HTML in-place';
public function handle(): int
{
$dry = $this->option('dry-run');
$chunk = (int)$this->option('chunk');
$limit = $this->option('limit') ? (int)$this->option('limit') : null;
$query = ForumPost::query()->orderBy('id');
$total = $limit ? min($query->count(), $limit) : $query->count();
$this->info('Converting forum posts (dry-run='.($dry ? 'yes' : 'no').')');
$this->info("Total posts to consider: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$converter = new BbcodeConverter();
$processed = 0;
$changed = 0;
try {
$query->chunkById($chunk, function ($posts) use (&$bar, &$processed, &$changed, $dry, $limit, $converter) {
foreach ($posts as $post) {
if ($limit !== null && $processed >= $limit) {
throw new \RuntimeException('limit_reached');
}
$bar->advance();
$processed++;
$old = $post->content ?? '';
$new = $converter->convert($old);
if ($old === $new) {
continue;
}
$changed++;
if ($dry) {
$this->line('[dry] would update post ' . $post->id);
continue;
}
$post->content = $new;
$post->save();
}
});
} catch (\RuntimeException $e) {
if ($e->getMessage() !== 'limit_reached') {
throw $e;
}
// intentionally stop chunking when limit reached
}
$bar->finish();
$this->line('');
$this->info("Processed: {$processed} posts. Changed: {$changed} posts.");
if ($this->option('report')) {
$this->info('Conversion complete');
}
return 0;
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\ForumCategory;
use App\Models\ForumThread;
use App\Models\ForumPost;
use Exception;
use App\Services\BbcodeConverter;
class ForumMigrateOld extends Command
{
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}';
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
protected string $logPath;
public function __construct()
{
parent::__construct();
$this->logPath = storage_path('logs/forum_migration.log');
}
public function handle(): int
{
$this->info('Starting forum migration');
$this->log('Starting forum migration');
$dry = $this->option('dry-run');
$only = $this->option('only');
$chunk = (int)$this->option('chunk');
try {
if (!$only || $only === 'categories') {
$this->migrateCategories($dry);
}
if (!$only || $only === 'threads') {
$this->migrateThreads($dry, $chunk);
}
if (!$only || $only === 'posts') {
$this->migratePosts($dry, $chunk);
}
if (!$only || $only === 'gallery') {
$this->migrateGallery($dry, $chunk);
}
if ($this->option('report')) {
$this->generateReport();
}
$this->info('Forum migration finished');
$this->log('Forum migration finished');
return 0;
} catch (Exception $e) {
$this->error('Migration failed: ' . $e->getMessage());
$this->log('Migration failed: ' . $e->getMessage());
return 1;
}
}
protected function migrateCategories(bool $dry)
{
$this->info('Migrating categories');
$legacy = DB::connection('legacy');
$roots = $legacy->table('forum_topics')
->select('root_id')
->distinct()
->where('root_id', '>', 0)
->pluck('root_id');
$this->info('Found ' . $roots->count() . ' legacy root ids');
foreach ($roots as $rootId) {
$row = $legacy->table('forum_topics')->where('topic_id', $rootId)->first();
$name = $row->topic ?? 'Category ' . $rootId;
$slug = Str::slug(substr($name, 0, 150));
$this->line("-> root {$rootId}: {$name}");
if ($dry) {
$this->log("[dry] create category {$name} ({$slug})");
continue;
}
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
}
$this->info('Categories migrated');
}
protected function migrateThreads(bool $dry, int $chunk)
{
$this->info('Migrating threads');
$legacy = DB::connection('legacy');
$query = $legacy->table('forum_topics')->orderBy('topic_id');
$total = $query->count();
$this->info("Total threads to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
// chunk by legacy primary key `topic_id`
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
foreach ($rows as $r) {
$bar->advance();
$data = [
'id' => $r->topic_id,
'category_id' => $this->resolveCategoryId($r->root_id ?? null, $r->topic_id),
// resolve user id or assign to system user (1) when missing or not found
'user_id' => $this->resolveUserId($r->user_id ?? null),
'title' => $r->topic,
'slug' => $this->uniqueSlug(Str::slug(substr($r->topic,0,200)) ?: 'thread-'.$r->topic_id, $r->topic_id),
'content' => $r->preview ?? '',
'views' => $r->views ?? 0,
'is_locked' => isset($r->open) ? !((bool)$r->open) : false,
'is_pinned' => false,
'visibility' => $this->mapPrivilegeToVisibility($r->privilege ?? 0),
'last_post_at' => $this->normalizeDate($r->last_update ?? null),
];
if ($dry) {
$this->log('[dry] thread: ' . $r->topic_id . ' - ' . $r->topic);
continue;
}
ForumThread::updateOrCreate(['id' => $data['id']], $data);
}
}, 'topic_id');
$bar->finish();
$this->line('');
$this->info('Threads migrated');
}
protected function migratePosts(bool $dry, int $chunk)
{
$this->info('Migrating posts');
$legacy = DB::connection('legacy');
$query = $legacy->table('forum_posts')->orderBy('post_id');
$total = $query->count();
$this->info("Total posts to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
// legacy forum_posts uses `post_id` as primary key
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
foreach ($rows as $r) {
$bar->advance();
$data = [
'id' => $r->post_id,
'thread_id' => $r->topic_id,
'user_id' => $r->user_id ?? null,
'content' => $this->convertLegacyMessage($r->message ?? ''),
'is_edited' => isset($r->isupdated) ? (bool)$r->isupdated : false,
'edited_at' => $r->updated ?? null,
];
if ($dry) {
$this->log('[dry] post: ' . $r->post_id);
continue;
}
ForumPost::updateOrCreate(['id' => $data['id']], $data);
}
}, 'post_id');
$bar->finish();
$this->line('');
$this->info('Posts migrated');
}
protected function mapPrivilegeToVisibility($priv)
{
// legacy privilege: 0 public, 1 members, 4 staff? adjust mapping conservatively
if ($priv >= 4) return 'staff';
if ($priv >= 1) return 'members';
return 'public';
}
protected function normalizeDate($val)
{
if (empty($val)) return null;
$s = trim((string)$val);
// legacy sometimes contains sentinel invalid dates like -0001-11-30 or zero dates
if (strpos($s, '-0001') !== false) return null;
if (strpos($s, '0000-00-00') !== false) return null;
if (strtotime($s) === false) return null;
return date('Y-m-d H:i:s', strtotime($s));
}
protected function uniqueSlug(string $base, int $id)
{
$slug = $base;
$i = 0;
while (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
$i++;
$slug = $base . '-' . $id;
// if somehow still exists, append counter
if (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
$slug = $base . '-' . $id . '-' . $i;
}
}
return $slug;
}
protected function resolveCategoryId($rootId, $topicId)
{
// prefer explicit rootId
if (!empty($rootId)) {
// ensure category exists
if (ForumCategory::where('id', $rootId)->exists()) return $rootId;
}
// if this topic itself is a category
if (ForumCategory::where('id', $topicId)->exists()) return $topicId;
// fallback: use first available category
$first = ForumCategory::first();
if ($first) return $first->id;
// as last resort, create Uncategorized
$cat = ForumCategory::create(['name' => 'Uncategorized', 'slug' => 'uncategorized']);
return $cat->id;
}
protected function resolveUserId($userId)
{
if (empty($userId)) {
return 1;
}
// check users table in default connection
if (\DB::table('users')->where('id', $userId)->exists()) {
return $userId;
}
return 1; // fallback system user
}
protected function convertLegacyMessage($msg)
{
$converter = new BbcodeConverter();
return $converter->convert($msg);
}
protected function generateReport()
{
$this->info('Generating migration report');
$legacy = DB::connection('legacy');
$legacyCounts = [
'categories' => $legacy->table('forum_topics')->where('root_id','>',0)->distinct('root_id')->count('root_id'),
'threads' => $legacy->table('forum_topics')->count(),
'posts' => $legacy->table('forum_posts')->count(),
];
$newCounts = [
'categories' => ForumCategory::count(),
'threads' => ForumThread::count(),
'posts' => ForumPost::count(),
'attachments' => \DB::table('forum_attachments')->count(),
];
$this->info('Legacy counts: ' . json_encode($legacyCounts));
$this->info('New counts: ' . json_encode($newCounts));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts));
}
protected function log(string $msg)
{
$line = '[' . date('c') . '] ' . $msg . "\n";
file_put_contents($this->logPath, $line, FILE_APPEND | LOCK_EX);
}
protected function migrateGallery(bool $dry, int $chunk)
{
$this->info('Migrating gallery (forum_topics_gallery → forum_attachments)');
$legacy = DB::connection('legacy');
if (!$legacy->getSchemaBuilder()->hasTable('forum_topics_gallery')) {
$this->info('No legacy forum_topics_gallery table found, skipping');
return;
}
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
$total = $query->count();
$this->info("Total gallery items to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
foreach ($rows as $r) {
$bar->advance();
// expected legacy fields: id, name, category (topic id), folder, datum, description
$topicId = $r->category ?? ($r->topic_id ?? null);
$fileName = $r->name ?? null;
if (empty($topicId) || empty($fileName)) {
$this->log('Skipping gallery row with missing topic or name: ' . json_encode($r));
continue;
}
$nid = floor($topicId / 100);
$relativePath = "files/news/{$nid}/{$topicId}/{$fileName}";
$publicPath = public_path($relativePath);
$fileSize = null;
$mimeType = null;
$width = null;
$height = null;
if (file_exists($publicPath)) {
$fileSize = filesize($publicPath);
$img = @getimagesize($publicPath);
if ($img !== false) {
$width = $img[0];
$height = $img[1];
$mimeType = $img['mime'] ?? null;
} else {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $publicPath);
finfo_close($finfo);
}
}
// find legacy first post id for this topic
$legacy = DB::connection('legacy');
$firstPostId = $legacy->table('forum_posts')
->where('topic_id', $topicId)
->orderBy('post_date')
->value('post_id');
// map to new forum_posts id (we preserved ids when migrating)
$postId = null;
if ($firstPostId && \App\Models\ForumPost::where('id', $firstPostId)->exists()) {
$postId = $firstPostId;
} else {
// fallback: find any post in new DB for thread
$post = \App\Models\ForumPost::where('thread_id', $topicId)->orderBy('created_at')->first();
if ($post) $postId = $post->id;
}
if (empty($postId)) {
$this->log('No target post found for gallery item: topic ' . $topicId . ' file ' . $fileName);
continue;
}
if ($dry) {
$this->log("[dry] attach {$relativePath} -> post {$postId}");
continue;
}
\App\Models\ForumAttachment::create([
'post_id' => $postId,
'file_path' => $relativePath,
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'created_at' => now(),
'updated_at' => now(),
]);
}
}, 'id');
$bar->finish();
$this->line('');
$this->info('Gallery migrated');
}
}

View File

@@ -1,13 +1,12 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\CategoryPageController;
use App\Http\Controllers\Web\BrowseGalleryController;
use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Recommendations\SimilarArtworksService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -71,15 +70,27 @@ class ArtworkController extends Controller
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
}
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
// prefer category rendering over artwork slug collisions so same-level groups
// behave consistently.
if (! empty($artworkSlug)) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
$resolvedCategory = Category::findByPath($contentTypeSlug, $combinedPath);
if ($resolvedCategory) {
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
}
}
// If no artwork was found, treat the request as a category path.
// The route places the artwork slug in the last segment, so include it
// when forwarding to CategoryPageController to support arbitrary-depth paths
// The route places the artwork slug in the last segment, so include it.
// Delegate to BrowseGalleryController to render the same modern gallery
// layout used by routes like /skins/audio.
if (! $foundArtwork) {
$combinedPath = $categoryPath;
if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
}
return app(CategoryPageController::class)->show(request(), $contentTypeSlug, $combinedPath);
return app(BrowseGalleryController::class)->content(request(), $contentTypeSlug, $combinedPath);
}
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {

View File

@@ -41,4 +41,40 @@ class ChatController extends Controller
return view('community.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
}
/**
* Handle legacy AJAX chat posts from old JS.
*/
public function post(Request $request)
{
$message = $request->input('message') ?? $request->input('chat_txt') ?? null;
if (empty($message)) {
return response()->json(['ok' => false, 'error' => 'empty_message'], 400);
}
// Ensure legacy $_SESSION keys exist for Chat class (best-effort sync from Laravel session/auth)
if (empty($_SESSION['web_login']['user_id'])) {
$webLogin = session('web_login');
if ($webLogin && isset($webLogin['user_id'])) {
$_SESSION['web_login'] = $webLogin;
} elseif (auth()->check()) {
$user = auth()->user();
$_SESSION['web_login'] = [
'user_id' => $user->id,
'username' => $user->username ?? $user->name ?? null,
'status' => true,
];
}
}
$chat = new \App\Chat();
try {
$chat->StoreMessage($message);
$chat->UpdateChatFile('cron/chat_log.txt', 50);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => 'store_failed', 'message' => $e->getMessage()], 500);
}
return response()->json(['ok' => true]);
}
}

View File

@@ -18,13 +18,76 @@ class ForumController extends Controller
public function index()
{
$data = $this->legacy->forumIndex();
if (empty($data['topics']) || count($data['topics']) === 0) {
try {
$categories = \App\Models\ForumCategory::query()
->withCount(['threads as num_subtopics'])
->orderBy('position')
->orderBy('id')
->get();
$topics = $categories->map(function ($category) {
$threadIds = \App\Models\ForumThread::where('category_id', $category->id)->pluck('id');
return (object) [
'topic_id' => $category->id,
'topic' => $category->name,
'discuss' => null,
'last_update' => \App\Models\ForumThread::where('category_id', $category->id)->max('last_post_at'),
'num_posts' => $threadIds->isEmpty() ? 0 : \App\Models\ForumPost::whereIn('thread_id', $threadIds)->count(),
'num_subtopics' => (int) ($category->num_subtopics ?? 0),
];
});
$data['topics'] = $topics;
} catch (\Throwable $e) {
// keep legacy response
}
}
return view('community.forum.index', $data);
}
public function topic(Request $request, $topic_id)
public function topic(Request $request, $topic_id, $slug = null)
{
// Redirect to canonical slug when possible
try {
$thread = \App\Models\ForumThread::find((int) $topic_id);
if ($thread && !empty($thread->slug)) {
$correct = $thread->slug;
if ($slug !== $correct) {
$qs = $request->getQueryString();
$url = route('legacy.forum.topic', ['topic_id' => $topic_id, 'slug' => $correct]);
if ($qs) $url .= '?' . $qs;
return redirect($url, 301);
}
}
} catch (\Throwable $e) {
// ignore
}
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
// fallback to new forum tables if migration already ran
try {
$thread = \App\Models\ForumThread::with(['posts.user'])->find((int) $topic_id);
if ($thread) {
$posts = \App\Models\ForumPost::where('thread_id', $thread->id)->orderBy('created_at')->get();
$data = [
'type' => 'posts',
'thread' => $thread,
'posts' => $posts,
'page_title' => $thread->title ?? 'Forum',
];
}
} catch (\Throwable $e) {
// ignore and fall through to placeholder
}
}
if (! $data) {
return view('shared.placeholder');
}

View File

@@ -26,6 +26,19 @@ class NewsController extends Controller
return redirect('/');
}
// redirect to canonical slug for SEO if available
try {
$correct = \Illuminate\Support\Str::slug($news->headline ?? 'news-' . $id);
if ($slug !== $correct) {
$qs = $request->getQueryString();
$url = route('legacy.news.show', ['id' => $id, 'slug' => $correct]);
if ($qs) $url .= '?' . $qs;
return redirect($url, 301);
}
} catch (\Throwable $e) {
// ignore
}
try {
$comments = DB::table('news_comment as c')
->leftJoin('users as u', 'c.user_id', '=', 'u.user_id')

View File

@@ -18,6 +18,30 @@ class ArtController extends Controller
public function show(Request $request, $id, $slug = null)
{
// canonicalize to new artwork route when possible
try {
$art = \App\Models\Artwork::find((int)$id);
if ($art && !empty($art->slug)) {
if ($slug !== $art->slug) {
// attempt to derive contentType and category for route
$category = $art->categories()->with('contentType')->first();
if ($category && $category->contentType) {
$contentTypeSlug = $category->contentType->slug ?? 'other';
$categoryPath = $category->slug ?? $category->category_name ?? 'other';
return redirect(route('artworks.show', [
'contentTypeSlug' => $contentTypeSlug,
'categoryPath' => $categoryPath,
'artwork' => $art->slug,
]), 301);
} elseif (!empty($art->slug)) {
// fallback: redirect to artwork slug only (may be handled by router)
return redirect('/' . $art->slug, 301);
}
}
}
} catch (\Throwable $e) {
// ignore and continue rendering legacy view
}
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
if (auth()->check()) {
try {

View File

@@ -4,10 +4,10 @@ namespace App\Http\Controllers\Web;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use App\Http\Controllers\ArtworkController as ArtworkControllerAlias;
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
@@ -120,13 +120,32 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
]);
}
public function showArtwork(Request $request, string $contentTypeSlug, string $categoryPath, string $artwork)
public function showArtwork(...$params)
{
return app(\App\Http\Controllers\ArtController::class)->show(
$request,
strtolower($contentTypeSlug),
trim($categoryPath, '/'),
$artwork
$req = request();
$pathSegments = array_values(array_filter(explode('/', trim($req->path(), '/'))));
$contentTypeSlug = $params[0] ?? ($pathSegments[0] ?? null);
$categoryPath = $params[1] ?? null;
$artwork = $params[2] ?? null;
// If artwork wasn't provided (some route invocations supply fewer args),
// derive it from the request path's last segment.
if ($artwork === null) {
$artwork = end($pathSegments) ?: null;
}
$contentTypeSlug = strtolower((string) $contentTypeSlug);
$categoryPath = $categoryPath !== null ? trim((string) $categoryPath, '/') : (isset($pathSegments[1]) ? implode('/', array_slice($pathSegments, 1, max(0, count($pathSegments) - 2))) : '');
// Normalize artwork param if route-model binding returned an Artwork model
$artworkSlug = $artwork instanceof Artwork ? (string) $artwork->slug : (string) $artwork;
return app(\App\Http\Controllers\ArtworkController::class)->show(
$req,
$contentTypeSlug,
$categoryPath,
$artworkSlug
);
}

View File

@@ -17,6 +17,19 @@ class GalleryController extends Controller
abort(404);
}
// canonicalize username in URL when possible
try {
$correctName = $user->name ?? $user->uname ?? null;
if ($username && $correctName && $username !== $correctName) {
$qs = $request->getQueryString();
$url = route('legacy.gallery', ['id' => $user->id, 'username' => $correctName]);
if ($qs) $url .= '?' . $qs;
return redirect($url, 301);
}
} catch (\Throwable $e) {
// ignore
}
$page = max(1, (int) $request->query('page', 1));
$hits = 20;

View File

@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
class HomeController extends Controller
{
@@ -36,34 +38,49 @@ class HomeController extends Controller
$latestUploads = $this->artworks->getLatestArtworks(20);
// Forum news (root forum section id 2876)
$forumNews = DB::table('forum_topics as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id')
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.post_date', 'desc')
->limit(8)
->get();
try {
$forumNews = DB::table('forum_topics as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id')
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.post_date', 'desc')
->limit(8)
->get();
} catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
$forumNews = collect();
}
// Our news (latest site news)
$ourNews = DB::table('news as t1')
->join('news_categories as c', 't1.category_id', '=', 'c.category_id')
->join('users as u', 't1.user_id', '=', 'u.user_id')
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
->orderBy('t1.create_date', 'desc')
->limit(5)
->get();
try {
$ourNews = DB::table('news as t1')
->join('news_categories as c', 't1.category_id', '=', 'c.category_id')
->join('users as u', 't1.user_id', '=', 'u.user_id')
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
->orderBy('t1.create_date', 'desc')
->limit(5)
->get();
} catch (QueryException $e) {
Log::warning('News table missing or DB error when loading our news', ['exception' => $e->getMessage()]);
$ourNews = collect();
}
// Latest forum activity (exclude rootless and news root)
$latestForumActivity = DB::table('forum_topics as t1')
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.last_update', 'desc')
->orderBy('t1.post_date', 'desc')
->limit(10)
->get();
try {
$latestForumActivity = DB::table('forum_topics as t1')
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.last_update', 'desc')
->orderBy('t1.post_date', 'desc')
->limit(10)
->get();
} catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
$latestForumActivity = collect();
}
return view('web.home', compact(
'page_title',

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
* Add legacy endpoints that post from old JS which don't include tokens.
*
* @var array<int, string>
*/
protected $except = [
'chat_post',
'chat_post/*',
];
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ForumAttachment extends Model
{
protected $table = 'forum_attachments';
protected $fillable = [
'id','post_id','file_path','file_size','mime_type','width','height'
];
public $incrementing = true;
public function post()
{
return $this->belongsTo(ForumPost::class, 'post_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ForumCategory extends Model
{
protected $table = 'forum_categories';
protected $fillable = [
'id', 'name', 'slug', 'parent_id', 'position'
];
public $incrementing = true;
public function parent()
{
return $this->belongsTo(ForumCategory::class, 'parent_id');
}
public function threads()
{
return $this->hasMany(ForumThread::class, 'category_id');
}
}

33
app/Models/ForumPost.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumPost extends Model
{
use SoftDeletes;
protected $table = 'forum_posts';
protected $fillable = [
'id','thread_id','user_id','content','is_edited','edited_at'
];
public $incrementing = true;
protected $casts = [
'edited_at' => 'datetime',
];
public function thread()
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function attachments()
{
return $this->hasMany(ForumAttachment::class, 'post_id');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumThread extends Model
{
use SoftDeletes;
protected $table = 'forum_threads';
protected $fillable = [
'id','category_id','user_id','title','slug','content','views','is_locked','is_pinned','visibility','last_post_at'
];
public $incrementing = true;
protected $casts = [
'last_post_at' => 'datetime',
];
public function category()
{
return $this->belongsTo(ForumCategory::class, 'category_id');
}
public function posts()
{
return $this->hasMany(ForumPost::class, 'thread_id');
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services;
class BbcodeConverter
{
/**
* Convert simple BBCode to HTML. Safe-escapes content and supports basic tags.
*/
public function convert(?string $text): string
{
if ($text === null) return '';
// Normalize line endings
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Protect code blocks first
$codeBlocks = [];
$text = preg_replace_callback('/\[code\](.*?)\[\/code\]/is', function ($m) use (&$codeBlocks) {
$idx = count($codeBlocks);
$codeBlocks[$idx] = '<pre><code>' . htmlspecialchars($m[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
return "__CODEBLOCK_{$idx}__";
}, $text);
// Escape remaining text to avoid XSS
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Basic tags
$simple = [
'/\[b\](.*?)\[\/b\]/is' => '<strong>$1</strong>',
'/\[i\](.*?)\[\/i\]/is' => '<em>$1</em>',
'/\[u\](.*?)\[\/u\]/is' => '<span style="text-decoration:underline;">$1</span>',
'/\[s\](.*?)\[\/s\]/is' => '<del>$1</del>',
];
foreach ($simple as $pat => $rep) {
$text = preg_replace($pat, $rep, $text);
}
// [url=link]text[/url] and [url]link[/url]
$text = preg_replace_callback('/\[url=(.*?)\](.*?)\[\/url\]/is', function ($m) {
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
$label = $m[2];
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $label . '</a>';
}, $text);
$text = preg_replace_callback('/\[url\](.*?)\[\/url\]/is', function ($m) {
$url = $this->sanitizeUrl(html_entity_decode($m[1]));
return '<a href="' . $url . '" rel="noopener noreferrer" target="_blank">' . $url . '</a>';
}, $text);
// [img]url[/img]
$text = preg_replace_callback('/\[img\](.*?)\[\/img\]/is', function ($m) {
$src = $this->sanitizeUrl(html_entity_decode($m[1]));
return '<img src="' . $src . '" alt="" />';
}, $text);
// [quote]...[/quote]
$text = preg_replace('/\[quote\](.*?)\[\/quote\]/is', '<blockquote>$1</blockquote>', $text);
// [list] and [*]
// Convert [list]...[*]item[*]...[/list] to <ul><li>...</li></ul>
$text = preg_replace_callback('/\[list\](.*?)\[\/list\]/is', function ($m) {
$items = preg_split('/\[\*\]/', $m[1]);
$out = '';
foreach ($items as $it) {
$it = trim($it);
if ($it === '') continue;
$out .= '<li>' . $it . '</li>';
}
return '<ul>' . $out . '</ul>';
}, $text);
// sizes and colors: simple inline styles
$text = preg_replace('/\[size=(\d+)\](.*?)\[\/size\]/is', '<span style="font-size:$1px;">$2</span>', $text);
$text = preg_replace('/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\](.*?)\[\/color\]/is', '<span style="color:$1;">$2</span>', $text);
// Preserve line breaks
$text = nl2br($text);
// Restore code blocks
if (!empty($codeBlocks)) {
foreach ($codeBlocks as $i => $html) {
$text = str_replace('__CODEBLOCK_' . $i . '__', $html, $text);
}
}
return $text;
}
protected function sanitizeUrl($url)
{
$url = trim($url);
// allow relative paths
if (strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0 || strpos($url, '/') === 0) {
return htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// fallback: prefix with http:// if looks like domain
if (preg_match('/^[A-Za-z0-9\-\.]+(\:[0-9]+)?(\/.*)?$/', $url)) {
return 'http://' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
return '#';
}
}