diff --git a/ansible/roles/speedBB/tasks/main.yaml b/ansible/roles/speedBB/tasks/main.yaml index ea3e5b0..46d34e0 100644 --- a/ansible/roles/speedBB/tasks/main.yaml +++ b/ansible/roles/speedBB/tasks/main.yaml @@ -33,6 +33,21 @@ state: directory mode: "0775" +- name: Migrate existing public/storage directory content before symlink + shell: | + set -e + cd "{{ prod_base_dir }}" + if [ -d public/storage ] && [ ! -L public/storage ]; then + if command -v rsync >/dev/null 2>&1; then + rsync -a public/storage/ storage/app/public/ + else + cp -a public/storage/. storage/app/public/ + fi + rm -rf public/storage + fi + args: + executable: /bin/bash + - name: Ensure public storage symlink exists file: src: "{{ prod_base_dir }}/storage/app/public" diff --git a/app/Http/Controllers/SystemStatusController.php b/app/Http/Controllers/SystemStatusController.php index 0b82377..6e9d677 100644 --- a/app/Http/Controllers/SystemStatusController.php +++ b/app/Http/Controllers/SystemStatusController.php @@ -63,10 +63,31 @@ class SystemStatusController extends Controller 'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']), 'proc_functions' => $procFunctionStatus, 'storage_writable' => is_writable(storage_path()), + 'storage_public_linked' => $this->isPublicStorageLinked(), 'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true), ]); } + private function isPublicStorageLinked(): bool + { + $publicStorage = public_path('storage'); + $storagePublic = storage_path('app/public'); + + if (!is_link($publicStorage)) { + return false; + } + + $target = readlink($publicStorage); + if ($target === false) { + return false; + } + + $resolvedTarget = realpath(dirname($publicStorage) . DIRECTORY_SEPARATOR . $target); + $expectedTarget = realpath($storagePublic); + + return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget; + } + private function resolveBinary(string $name): ?string { $process = new Process(['sh', '-lc', "command -v {$name}"]); diff --git a/app/Http/Controllers/SystemUpdateController.php b/app/Http/Controllers/SystemUpdateController.php index 0e8317d..2c81c8d 100644 --- a/app/Http/Controllers/SystemUpdateController.php +++ b/app/Http/Controllers/SystemUpdateController.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Process\Process; class SystemUpdateController extends Controller @@ -113,7 +114,7 @@ class SystemUpdateController extends Controller $append('Syncing files...'); $usedRsync = false; $rsyncPath = trim((string) shell_exec('command -v rsync')); - $protectedPaths = ['custom', 'public/custom']; + $protectedPaths = ['storage', 'public/storage', 'custom', 'public/custom']; if ($rsyncPath !== '') { $usedRsync = true; $rsync = new Process([ @@ -149,6 +150,8 @@ class SystemUpdateController extends Controller File::copyDirectory($sourceDir, base_path()); } + $this->ensurePublicStorageLink(); + $append('Installing composer dependencies...'); $composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path()); $composer->setTimeout(600); @@ -212,4 +215,39 @@ class SystemUpdateController extends Controller ], 500); } } + + private function ensurePublicStorageLink(): void + { + $storagePublic = storage_path('app/public'); + $publicStorage = public_path('storage'); + + if (file_exists($storagePublic) && !is_dir($storagePublic)) { + @rename($storagePublic, $storagePublic.'.bak.'.date('Ymd_His')); + } + if (!is_dir($storagePublic) && !@mkdir($storagePublic, 0775, true) && !is_dir($storagePublic)) { + throw new RuntimeException('Failed to prepare storage/app/public directory.'); + } + + if (is_link($publicStorage)) { + $target = readlink($publicStorage); + $resolved = $target !== false ? realpath(dirname($publicStorage).DIRECTORY_SEPARATOR.$target) : false; + $expected = realpath($storagePublic); + if ($resolved !== $expected) { + @unlink($publicStorage); + } + } elseif (is_dir($publicStorage)) { + File::copyDirectory($publicStorage, $storagePublic); + File::deleteDirectory($publicStorage); + } elseif (file_exists($publicStorage)) { + @rename($publicStorage, $publicStorage.'.bak.'.date('Ymd_His')); + } + + if (!is_link($publicStorage) && !@symlink($storagePublic, $publicStorage)) { + throw new RuntimeException('Failed to recreate public/storage symlink.'); + } + + foreach (['avatars', 'logos', 'favicons', 'rank-badges'] as $dir) { + File::ensureDirectoryExists($storagePublic.DIRECTORY_SEPARATOR.$dir); + } + } } diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index c5dd693..b68237f 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use RuntimeException; class UploadController extends Controller { @@ -14,6 +16,7 @@ class UploadController extends Controller if (!$user) { return response()->json(['message' => 'Unauthorized'], 401); } + $this->ensurePublicStorageReady(); $data = $request->validate([ 'file' => [ @@ -45,6 +48,7 @@ class UploadController extends Controller if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { return response()->json(['message' => 'Forbidden'], 403); } + $this->ensurePublicStorageReady(); $data = $request->validate([ 'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'], @@ -64,6 +68,7 @@ class UploadController extends Controller if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { return response()->json(['message' => 'Forbidden'], 403); } + $this->ensurePublicStorageReady(); $data = $request->validate([ 'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'], @@ -76,4 +81,49 @@ class UploadController extends Controller 'url' => Storage::url($path), ]); } + + private function ensurePublicStorageReady(): void + { + $storagePublic = storage_path('app/public'); + $publicStorage = public_path('storage'); + + if (file_exists($storagePublic) && !is_dir($storagePublic)) { + @rename($storagePublic, $storagePublic.'.bak.'.date('Ymd_His')); + } + if (!is_dir($storagePublic) && !@mkdir($storagePublic, 0775, true) && !is_dir($storagePublic)) { + throw new RuntimeException('Failed to create storage/app/public directory.'); + } + + if (is_link($publicStorage)) { + $target = readlink($publicStorage); + $resolved = $target !== false ? realpath(dirname($publicStorage).DIRECTORY_SEPARATOR.$target) : false; + $expected = realpath($storagePublic); + if ($resolved === $expected) { + $this->ensureUploadSubdirs($storagePublic); + return; + } + @unlink($publicStorage); + } elseif (is_dir($publicStorage)) { + File::copyDirectory($publicStorage, $storagePublic); + File::deleteDirectory($publicStorage); + } elseif (file_exists($publicStorage)) { + @rename($publicStorage, $publicStorage.'.bak.'.date('Ymd_His')); + } + + if (!@symlink($storagePublic, $publicStorage) && !is_link($publicStorage)) { + throw new RuntimeException('Failed to create public/storage symlink.'); + } + + $this->ensureUploadSubdirs($storagePublic); + } + + private function ensureUploadSubdirs(string $storagePublic): void + { + foreach (['avatars', 'favicons', 'logos', 'rank-badges'] as $dir) { + $path = $storagePublic.DIRECTORY_SEPARATOR.$dir; + if (!is_dir($path)) { + @mkdir($path, 0775, true); + } + } + } } diff --git a/composer.json b/composer.json index 0bfe506..4ddd248 100644 --- a/composer.json +++ b/composer.json @@ -98,5 +98,5 @@ "minimum-stability": "stable", "prefer-stable": true, "version": "26.0.3", - "build": "103" + "build": "104" } diff --git a/git_update.sh b/git_update.sh index d8eef96..66094ac 100755 --- a/git_update.sh +++ b/git_update.sh @@ -20,6 +20,39 @@ resolve_php_bin() { echo "php" } +ensure_storage_link() { + local storage_public="storage/app/public" + local public_storage="public/storage" + + echo "Ensuring public storage link..." + + if [[ -e "$storage_public" && ! -d "$storage_public" ]]; then + local backup_path="${storage_public}.bak.$(date +%Y%m%d_%H%M%S)" + echo "Found invalid $storage_public (not a directory). Backing up to $backup_path" + mv "$storage_public" "$backup_path" + fi + + mkdir -p "$storage_public" + + # If public/storage is a real directory, migrate files before converting to symlink. + if [[ -d "$public_storage" && ! -L "$public_storage" ]]; then + echo "Migrating existing files from $public_storage to $storage_public" + if command -v rsync >/dev/null 2>&1; then + rsync -a "$public_storage"/ "$storage_public"/ + else + cp -a "$public_storage"/. "$storage_public"/ + fi + rm -rf "$public_storage" + elif [[ -e "$public_storage" && ! -L "$public_storage" ]]; then + local public_backup="${public_storage}.bak.$(date +%Y%m%d_%H%M%S)" + echo "Found invalid $public_storage (not a directory/symlink). Backing up to $public_backup" + mv "$public_storage" "$public_backup" + fi + + ln -sfn ../storage/app/public "$public_storage" + mkdir -p "$storage_public/logos" "$storage_public/favicons" "$storage_public/rank-badges" +} + resolve_configured_php_bin() { local configured="${1:-}" local current="${2:-php}" @@ -248,6 +281,8 @@ main() { echo "Running with PHP binary: $PHP_BIN artisan migrate --force" "$PHP_BIN" artisan migrate --force + ensure_storage_link + echo "Syncing version/build to settings..." echo "Running with PHP binary: $PHP_BIN -r " VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')" diff --git a/resources/js/api/client.js b/resources/js/api/client.js index d3f89c1..b984c71 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -130,12 +130,7 @@ export async function fetchVersion() { } export async function fetchPing() { - const response = await fetch('/ping', { - headers: { - Accept: 'application/json', - }, - }) - return parseResponse(response) + return apiFetch('/ping') } export async function fetchVersionCheck() { diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index 1e8a8ab..ba7fc9e 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -563,6 +563,14 @@ function Acp({ isAdmin }) { current: '—', status: systemStatus.storage_writable ? 'ok' : 'bad', }, + { + id: 'storage_link', + label: t('system.storage_linked'), + path: 'public/storage -> storage/app/public', + min: '—', + current: '—', + status: systemStatus.storage_public_linked ? 'ok' : 'bad', + }, { id: 'updates', label: t('system.updates_writable'), @@ -576,9 +584,9 @@ function Acp({ isAdmin }) { const visibleSystemChecks = useMemo(() => { const visibilityBySection = { - insite: ['php', 'proc', 'storage', 'updates'], - cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'updates'], - ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'updates'], + insite: ['php', 'proc', 'storage', 'storage_link', 'updates'], + cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'storage_link'], + ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'storage_link', 'updates'], info: [], } const allowed = new Set(visibilityBySection[systemSection] || []) @@ -3861,14 +3869,15 @@ function Acp({ isAdmin }) {

CLI default php: {systemStatus?.php_default || '—'} ( {systemStatus?.php_default_version || 'unknown'}){' '} - {cliDefaultPhpIsSufficient ? ( + {phpSelectedIsSufficient ? (