diff --git a/.env.example b/.env.example index ea0665b..0596cfa 100644 --- a/.env.example +++ b/.env.example @@ -57,3 +57,19 @@ VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + +# Next.js site revalidation +NEXTJS_REVALIDATE_URL= +NEXTJS_REVALIDATE_TOKEN= + +# Optional: sync uploaded images into Next.js public/ for static serving +# Example (local): /Users/gbanyan/Project/usher-site/public +NEXTJS_PUBLIC_PATH= +NEXTJS_PUBLIC_UPLOAD_PREFIX=uploads + +# Optional: auto-commit+push assets in the Next.js repo on upload/import +NEXTJS_AUTOPUSH_ASSETS=false +NEXTJS_REPO_PATH= +NEXTJS_REPO_REMOTE=origin +NEXTJS_REPO_BRANCH=main +NEXTJS_REPO_ASSETS_COMMIT_MESSAGE="chore(assets): sync public assets" diff --git a/.gitignore b/.gitignore index 8f75473..3876450 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ Homestead.yaml auth.json npm-debug.log yarn-error.log +.DS_Store +**/.DS_Store /.fleet /.idea /.vscode diff --git a/app/Console/Commands/ImportHugoContent.php b/app/Console/Commands/ImportHugoContent.php index 569110d..574f264 100644 --- a/app/Console/Commands/ImportHugoContent.php +++ b/app/Console/Commands/ImportHugoContent.php @@ -30,7 +30,7 @@ class ImportHugoContent extends Command private int $tagsCreated = 0; - private int $imagescopied = 0; + private int $imagesCopied = 0; private int $skipped = 0; @@ -140,7 +140,7 @@ class ImportHugoContent extends Command ['Pages created', $this->pagesCreated], ['Categories created', $this->categoriesCreated], ['Tags created', $this->tagsCreated], - ['Images copied', $this->imagescopied], + ['Images copied', $this->imagesCopied], ['Skipped (_index.md etc.)', $this->skipped], ] ); @@ -360,7 +360,15 @@ class ImportHugoContent extends Command { $this->info("Copying images from: {$imagesPath}"); - $destPath = storage_path('app/public/migrated-images'); + $nextPublicRoot = config('services.nextjs.public_path'); + if (! is_string($nextPublicRoot) || $nextPublicRoot === '' || ! is_dir($nextPublicRoot)) { + $nextPublicRoot = base_path('../usher-site/public'); + } + + $nextPublicImages = rtrim($nextPublicRoot, '/').'/images'; + $destPath = is_dir($nextPublicImages) + ? $nextPublicImages + : storage_path('app/public/migrated-images'); if (! $isDryRun && ! is_dir($destPath)) { File::makeDirectory($destPath, 0755, true); @@ -382,7 +390,12 @@ class ImportHugoContent extends Command File::copy($file->getPathname(), $destFile); } - $this->imagescopied++; + $this->imagesCopied++; + } + + // If we copied into the Next.js repo, optionally auto-push the new assets. + if (str_ends_with($destPath, '/images') && is_dir(dirname($destPath).'/.git')) { + \App\Services\NextjsRepoSyncService::scheduleAssetsPush(); } } @@ -511,7 +524,7 @@ class ImportHugoContent extends Command /** * Convert Hugo image paths to migrated storage paths. - * Hugo: "images/blog/Update.jpg" → "migrated-images/blog/Update.jpg" + * Hugo: "images/blog/Update.jpg" → "images/blog/Update.jpg" */ private function resolveImagePath(?string $hugoPath): ?string { @@ -519,9 +532,13 @@ class ImportHugoContent extends Command return null; } - // Remove leading "images/" prefix - $relativePath = preg_replace('#^images/#', '', $hugoPath); + $path = ltrim($hugoPath, '/'); - return "migrated-images/{$relativePath}"; + // Normalize to "images/..." so it can be served from Next.js public/images/. + if (! str_starts_with($path, 'images/')) { + $path = 'images/'.ltrim($path, '/'); + } + + return $path; } } diff --git a/app/Console/Commands/NextjsPushAssets.php b/app/Console/Commands/NextjsPushAssets.php new file mode 100644 index 0000000..bb83fee --- /dev/null +++ b/app/Console/Commands/NextjsPushAssets.php @@ -0,0 +1,22 @@ +info('Done (best-effort). Check logs if nothing happened.'); + + return self::SUCCESS; + } +} + diff --git a/app/Http/Controllers/Admin/ArticleController.php b/app/Http/Controllers/Admin/ArticleController.php index 06681bb..72a544e 100644 --- a/app/Http/Controllers/Admin/ArticleController.php +++ b/app/Http/Controllers/Admin/ArticleController.php @@ -8,6 +8,8 @@ use App\Models\ArticleAttachment; use App\Models\ArticleCategory; use App\Models\ArticleTag; use App\Models\AuditLog; +use App\Services\SiteAssetSyncService; +use App\Services\SiteRevalidationService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -117,7 +119,15 @@ class ArticleController extends Controller ]; if ($request->hasFile('featured_image')) { - $articleData['featured_image_path'] = $request->file('featured_image')->store('articles/images', 'public'); + $storagePath = $request->file('featured_image')->store('articles/images', 'public'); + $nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath); + + if ($nextPublicPath) { + $articleData['featured_image_path'] = $nextPublicPath; + $articleData['featured_image_storage_path'] = $storagePath; + } else { + $articleData['featured_image_path'] = $storagePath; + } } $article = Article::create($articleData); @@ -148,6 +158,10 @@ class ArticleController extends Controller 'ip_address' => request()->ip(), ]); + if ($validated['save_action'] === 'publish') { + SiteRevalidationService::revalidateArticle($article->slug); + } + $message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿'; return redirect() @@ -227,13 +241,35 @@ class ArticleController extends Controller } if ($request->hasFile('featured_image')) { - if ($article->featured_image_path) { + // Remove old copies (best-effort). If we synced to Next.js, the DB path is relative (uploads/...). + if ($article->featured_image_storage_path) { + Storage::disk('public')->delete($article->featured_image_storage_path); + } elseif ($article->featured_image_path && str_starts_with($article->featured_image_path, 'articles/')) { Storage::disk('public')->delete($article->featured_image_path); } - $updateData['featured_image_path'] = $request->file('featured_image')->store('articles/images', 'public'); + + SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path); + + $storagePath = $request->file('featured_image')->store('articles/images', 'public'); + $nextPublicPath = SiteAssetSyncService::syncPublicDiskFileToNext($storagePath); + + if ($nextPublicPath) { + $updateData['featured_image_path'] = $nextPublicPath; + $updateData['featured_image_storage_path'] = $storagePath; + } else { + $updateData['featured_image_path'] = $storagePath; + $updateData['featured_image_storage_path'] = null; + } } elseif ($request->boolean('remove_featured_image') && $article->featured_image_path) { - Storage::disk('public')->delete($article->featured_image_path); + if ($article->featured_image_storage_path) { + Storage::disk('public')->delete($article->featured_image_storage_path); + } elseif (str_starts_with($article->featured_image_path, 'articles/')) { + Storage::disk('public')->delete($article->featured_image_path); + } + + SiteAssetSyncService::deleteNextPublicFile($article->featured_image_path); $updateData['featured_image_path'] = null; + $updateData['featured_image_storage_path'] = null; } $article->update($updateData); @@ -264,6 +300,10 @@ class ArticleController extends Controller 'ip_address' => request()->ip(), ]); + if ($article->isPublished()) { + SiteRevalidationService::revalidateArticle($article->slug); + } + return redirect() ->route('admin.articles.show', $article) ->with('status', '文章已成功更新'); @@ -276,6 +316,8 @@ class ArticleController extends Controller } $title = $article->title; + $slug = $article->slug; + $wasPublished = $article->isPublished(); $article->delete(); AuditLog::create([ @@ -285,6 +327,10 @@ class ArticleController extends Controller 'ip_address' => request()->ip(), ]); + if ($wasPublished) { + SiteRevalidationService::revalidateArticle($slug); + } + return redirect() ->route('admin.articles.index') ->with('status', '文章已成功刪除'); @@ -313,6 +359,8 @@ class ArticleController extends Controller 'ip_address' => request()->ip(), ]); + SiteRevalidationService::revalidateArticle($article->slug); + return back()->with('status', '文章已成功發布'); } @@ -339,6 +387,8 @@ class ArticleController extends Controller 'ip_address' => request()->ip(), ]); + SiteRevalidationService::revalidateArticle($article->slug); + return back()->with('status', '文章已成功歸檔'); } diff --git a/app/Http/Controllers/Admin/DocumentController.php b/app/Http/Controllers/Admin/DocumentController.php index 741d40c..9c43b2a 100644 --- a/app/Http/Controllers/Admin/DocumentController.php +++ b/app/Http/Controllers/Admin/DocumentController.php @@ -7,6 +7,7 @@ use App\Models\AuditLog; use App\Models\Document; use App\Models\DocumentCategory; use App\Models\DocumentVersion; +use App\Support\DownloadFile; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -238,9 +239,10 @@ class DocumentController extends Controller // Log access $document->logAccess('download', auth()->user()); - return Storage::disk('private')->download( - $version->file_path, - $version->original_filename + return DownloadFile::fromDisk( + disk: 'private', + path: $version->file_path, + downloadName: $version->original_filename ); } diff --git a/app/Http/Controllers/Admin/PageController.php b/app/Http/Controllers/Admin/PageController.php index 4f7ab1f..07aaf1d 100644 --- a/app/Http/Controllers/Admin/PageController.php +++ b/app/Http/Controllers/Admin/PageController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\AuditLog; use App\Models\Page; +use App\Services\SiteRevalidationService; use Illuminate\Http\Request; class PageController extends Controller @@ -72,6 +73,10 @@ class PageController extends Controller 'ip_address' => request()->ip(), ]); + if ($validated['status'] === 'published') { + SiteRevalidationService::revalidatePage($page->slug); + } + return redirect() ->route('admin.pages.show', $page) ->with('status', '頁面已成功建立'); @@ -135,6 +140,10 @@ class PageController extends Controller 'ip_address' => request()->ip(), ]); + if ($page->isPublished()) { + SiteRevalidationService::revalidatePage($page->slug); + } + return redirect() ->route('admin.pages.show', $page) ->with('status', '頁面已成功更新'); @@ -147,6 +156,8 @@ class PageController extends Controller } $title = $page->title; + $slug = $page->slug; + $wasPublished = $page->isPublished(); $page->delete(); AuditLog::create([ @@ -156,6 +167,10 @@ class PageController extends Controller 'ip_address' => request()->ip(), ]); + if ($wasPublished) { + SiteRevalidationService::revalidatePage($slug); + } + return redirect() ->route('admin.pages.index') ->with('status', '頁面已成功刪除'); @@ -176,6 +191,8 @@ class PageController extends Controller 'ip_address' => request()->ip(), ]); + SiteRevalidationService::revalidatePage($page->slug); + return back()->with('status', '頁面已成功發布'); } } diff --git a/app/Http/Controllers/Api/ArticleController.php b/app/Http/Controllers/Api/ArticleController.php index eb30485..2e91636 100644 --- a/app/Http/Controllers/Api/ArticleController.php +++ b/app/Http/Controllers/Api/ArticleController.php @@ -7,8 +7,8 @@ use App\Http\Resources\ArticleCollectionResource; use App\Http\Resources\ArticleResource; use App\Models\Article; use App\Models\ArticleAttachment; +use App\Support\DownloadFile; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; class ArticleController extends Controller { @@ -87,9 +87,10 @@ class ArticleController extends Controller $attachment->incrementDownloadCount(); - return Storage::disk('public')->download( - $attachment->file_path, - $attachment->original_filename + return DownloadFile::fromDisk( + disk: 'public', + path: $attachment->file_path, + downloadName: $attachment->original_filename ); } } diff --git a/app/Http/Controllers/PublicDocumentController.php b/app/Http/Controllers/PublicDocumentController.php index d3d515e..1f6831b 100644 --- a/app/Http/Controllers/PublicDocumentController.php +++ b/app/Http/Controllers/PublicDocumentController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Document; use App\Models\DocumentCategory; +use App\Support\DownloadFile; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -109,9 +110,10 @@ class PublicDocumentController extends Controller // Log access $document->logAccess('download', $user); - return Storage::disk('private')->download( - $currentVersion->file_path, - $currentVersion->original_filename + return DownloadFile::fromDisk( + disk: 'private', + path: $currentVersion->file_path, + downloadName: $currentVersion->original_filename ); } @@ -139,9 +141,10 @@ class PublicDocumentController extends Controller // Log access $document->logAccess('download', $user); - return Storage::disk('private')->download( - $version->file_path, - $version->original_filename + return DownloadFile::fromDisk( + disk: 'private', + path: $version->file_path, + downloadName: $version->original_filename ); } diff --git a/app/Jobs/PushNextjsPublicAssetsJob.php b/app/Jobs/PushNextjsPublicAssetsJob.php new file mode 100644 index 0000000..98d626d --- /dev/null +++ b/app/Jobs/PushNextjsPublicAssetsJob.php @@ -0,0 +1,32 @@ +featured_image_path, 'articles/')) { + return Storage::disk('public')->url($this->featured_image_path); + } + + // Migrated images — relative path (served from Next.js public/) return '/'.$this->featured_image_path; } } diff --git a/app/Services/NextjsRepoSyncService.php b/app/Services/NextjsRepoSyncService.php new file mode 100644 index 0000000..cf89356 --- /dev/null +++ b/app/Services/NextjsRepoSyncService.php @@ -0,0 +1,111 @@ +get()) { + return; + } + + try { + if (! is_dir($repoPath.'/.git')) { + Log::warning("Next.js repo push skipped (no .git): {$repoPath}"); + + return; + } + + $remote = (string) config('services.nextjs.repo_remote', 'origin'); + $branch = (string) config('services.nextjs.repo_branch', 'main'); + + // Keep the repo up to date to reduce push failures. + static::run($repoPath, ['git', 'fetch', $remote]); + static::run($repoPath, ['git', 'checkout', $branch]); + static::run($repoPath, ['git', 'pull', '--rebase', $remote, $branch]); + + // Only stage asset directories we manage. + static::run($repoPath, ['git', 'add', '-A', '--', 'public/uploads', 'public/images']); + + $status = static::run($repoPath, ['git', 'status', '--porcelain']); + if (trim($status) === '') { + return; + } + + $msg = (string) config('services.nextjs.repo_assets_commit_message', 'chore(assets): sync public assets'); + $msg .= ' ('.now()->format('Y-m-d H:i:s').')'; + + static::run($repoPath, ['git', 'commit', '-m', $msg]); + static::run($repoPath, ['git', 'push', $remote, $branch]); + } catch (\Throwable $e) { + Log::warning('Next.js repo push failed: '.$e->getMessage()); + } finally { + $lock->release(); + } + } + + public static function scheduleAssetsPush(): void + { + if (! config('services.nextjs.autopush_assets')) { + return; + } + + // If queue is sync, this runs inline; otherwise requires a worker. + \App\Jobs\PushNextjsPublicAssetsJob::dispatch()->delay(now()->addSeconds(5)); + } + + private static function resolveRepoRoot(): ?string + { + $configured = config('services.nextjs.repo_path'); + if (is_string($configured) && $configured !== '' && is_dir($configured)) { + return $configured; + } + + $public = config('services.nextjs.public_path'); + if (is_string($public) && $public !== '') { + $root = dirname($public); + if (is_dir($root)) { + return $root; + } + } + + $guess = base_path('../usher-site'); + if (is_dir($guess)) { + return $guess; + } + + return null; + } + + private static function run(string $cwd, array $cmd): string + { + $p = new Process($cmd, $cwd); + $p->setTimeout(60); + $p->run(); + + $out = (string) $p->getOutput(); + $err = (string) $p->getErrorOutput(); + if (! $p->isSuccessful()) { + throw new \RuntimeException('Command failed: '.implode(' ', $cmd)."\n".$err.$out); + } + + return $out.$err; + } +} + diff --git a/app/Services/SiteAssetSyncService.php b/app/Services/SiteAssetSyncService.php new file mode 100644 index 0000000..a0c9989 --- /dev/null +++ b/app/Services/SiteAssetSyncService.php @@ -0,0 +1,101 @@ +path($publicDiskPath); + if (! is_file($source)) { + Log::warning("Site asset sync skipped (source missing): {$publicDiskPath}"); + + return null; + } + + $prefix = config('services.nextjs.public_upload_prefix', 'uploads'); + $prefix = trim((string) $prefix, '/'); + if ($prefix === '') { + $prefix = 'uploads'; + } + + $relativeDest = $prefix.'/'.$publicDiskPath; + $dest = rtrim($nextPublicRoot, '/').'/'.$relativeDest; + + $destDir = dirname($dest); + if (! is_dir($destDir) && ! @mkdir($destDir, 0755, true) && ! is_dir($destDir)) { + Log::warning("Site asset sync failed (mkdir): {$destDir}"); + + return null; + } + + if (! @copy($source, $dest)) { + Log::warning("Site asset sync failed (copy): {$publicDiskPath} -> {$dest}"); + + return null; + } + + // Optionally commit+push the updated assets into the usher-site repo. + NextjsRepoSyncService::scheduleAssetsPush(); + + return $relativeDest; + } + + /** + * Delete a previously synced Next.js public asset (best-effort). + */ + public static function deleteNextPublicFile(?string $nextPublicRelativePath): void + { + if (! $nextPublicRelativePath) { + return; + } + + $nextPublicRoot = static::resolveNextPublicRoot(); + if (! $nextPublicRoot) { + return; + } + + $rel = ltrim($nextPublicRelativePath, '/'); + $path = rtrim($nextPublicRoot, '/').'/'.$rel; + + if (is_file($path)) { + @unlink($path); + } + + // Stage deletion via scheduled push (best-effort). + NextjsRepoSyncService::scheduleAssetsPush(); + } + + private static function resolveNextPublicRoot(): ?string + { + $configured = config('services.nextjs.public_path'); + if (is_string($configured) && $configured !== '' && is_dir($configured)) { + return $configured; + } + + // Local-dev default: sibling repo next to this Laravel repo. + $guess = base_path('../usher-site/public'); + if (is_dir($guess)) { + return $guess; + } + + return null; + } +} diff --git a/app/Services/SiteRevalidationService.php b/app/Services/SiteRevalidationService.php new file mode 100644 index 0000000..b9d6cef --- /dev/null +++ b/app/Services/SiteRevalidationService.php @@ -0,0 +1,48 @@ + $type]; + if ($slug) { + $payload['slug'] = $slug; + } + + Http::timeout(5) + ->withHeaders(['x-revalidate-token' => $token]) + ->post($url, $payload); + } catch (\Throwable $e) { + Log::warning("Site revalidation failed for {$type}/{$slug}: {$e->getMessage()}"); + } + } +} diff --git a/app/Support/DownloadFile.php b/app/Support/DownloadFile.php new file mode 100644 index 0000000..0e4f2cf --- /dev/null +++ b/app/Support/DownloadFile.php @@ -0,0 +1,71 @@ +download($path, $safeName, $headers); + $response->headers->set('Content-Disposition', static::contentDisposition($downloadName)); + + return $response; + } + + /** + * Build RFC 5987 compatible Content-Disposition value. + */ + public static function contentDisposition( + string $downloadName, + string $type = 'attachment' + ): string { + $safeOriginalName = static::sanitizeHeaderFilename($downloadName); + $safeFallback = addcslashes(static::asciiFallbackFilename($safeOriginalName), "\"\\"); + $encoded = rawurlencode($safeOriginalName); + + return "{$type}; filename=\"{$safeFallback}\"; filename*=UTF-8''{$encoded}"; + } + + /** + * Generate ASCII fallback filename for old clients. + */ + public static function asciiFallbackFilename(string $filename): string + { + $filename = static::sanitizeHeaderFilename($filename); + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $basename = pathinfo($filename, PATHINFO_FILENAME); + + $asciiBase = Str::ascii($basename); + $asciiBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) $asciiBase); + $asciiBase = trim((string) $asciiBase, '._-'); + if ($asciiBase === '') { + $asciiBase = 'download'; + } + + $asciiExtension = Str::ascii((string) $extension); + $asciiExtension = preg_replace('/[^A-Za-z0-9]+/', '', (string) $asciiExtension); + + return $asciiExtension === '' + ? $asciiBase + : "{$asciiBase}.{$asciiExtension}"; + } + + private static function sanitizeHeaderFilename(string $filename): string + { + $filename = str_replace(["\r", "\n"], '', trim($filename)); + + return $filename !== '' ? $filename : 'download'; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index e9d9dbd..d477ae2 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -36,6 +36,13 @@ return [ 'throw' => false, ], + // Private files remain under storage/app and are served via controller responses. + 'private' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + 'throw' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/config/services.php b/config/services.php index 0ace530..612244b 100644 --- a/config/services.php +++ b/config/services.php @@ -31,4 +31,21 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'nextjs' => [ + 'revalidate_url' => env('NEXTJS_REVALIDATE_URL'), + 'revalidate_token' => env('NEXTJS_REVALIDATE_TOKEN'), + // Optional: sync assets into a local Next.js repo for static serving. + // Example: /Users/gbanyan/Project/usher-site/public + 'public_path' => env('NEXTJS_PUBLIC_PATH'), + // Folder under public/ where Laravel uploads are copied (no leading/trailing slash). + 'public_upload_prefix' => env('NEXTJS_PUBLIC_UPLOAD_PREFIX', 'uploads'), + // Optional: automatically git commit+push when public/ assets change. + // Requires the Next.js repo to be checked out and authenticated on this machine. + 'autopush_assets' => env('NEXTJS_AUTOPUSH_ASSETS', false), + 'repo_path' => env('NEXTJS_REPO_PATH'), + 'repo_remote' => env('NEXTJS_REPO_REMOTE', 'origin'), + 'repo_branch' => env('NEXTJS_REPO_BRANCH', 'main'), + 'repo_assets_commit_message' => env('NEXTJS_REPO_ASSETS_COMMIT_MESSAGE', 'chore(assets): sync public assets'), + ], + ]; diff --git a/database/factories/ArticleCategoryFactory.php b/database/factories/ArticleCategoryFactory.php new file mode 100644 index 0000000..b0f9087 --- /dev/null +++ b/database/factories/ArticleCategoryFactory.php @@ -0,0 +1,28 @@ + + */ +class ArticleCategoryFactory extends Factory +{ + protected $model = ArticleCategory::class; + + public function definition(): array + { + $name = $this->faker->unique()->words(2, true); + + return [ + 'name' => $name, + 'slug' => Str::slug($name) ?: 'category-'.Str::random(8), + 'description' => $this->faker->optional()->sentence(), + 'sort_order' => $this->faker->numberBetween(0, 50), + ]; + } +} + diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php new file mode 100644 index 0000000..0c2c60d --- /dev/null +++ b/database/factories/ArticleFactory.php @@ -0,0 +1,79 @@ + + */ +class ArticleFactory extends Factory +{ + protected $model = Article::class; + + public function definition(): array + { + $title = $this->faker->sentence(6); + $slug = Str::slug($title) ?: 'article-'.Str::random(10); + + return [ + 'title' => $title, + 'slug' => $slug, + 'summary' => $this->faker->optional()->paragraph(), + 'content' => $this->faker->paragraphs(5, true), + 'content_type' => Article::CONTENT_TYPE_BLOG, + 'status' => Article::STATUS_PUBLISHED, + 'access_level' => Article::ACCESS_LEVEL_PUBLIC, + 'featured_image_path' => null, + 'featured_image_alt' => null, + 'author_name' => $this->faker->optional()->name(), + 'author_user_id' => null, + 'meta_description' => $this->faker->optional()->sentence(), + 'meta_keywords' => null, + 'is_pinned' => false, + 'display_order' => 0, + 'published_at' => now()->subDay(), + 'expires_at' => null, + 'archived_at' => null, + 'view_count' => 0, + 'created_by_user_id' => User::factory(), + 'last_updated_by_user_id' => User::factory(), + ]; + } + + public function draft(): static + { + return $this->state(fn () => [ + 'status' => Article::STATUS_DRAFT, + 'published_at' => null, + ]); + } + + public function pinned(int $order = 0): static + { + return $this->state(fn () => [ + 'is_pinned' => true, + 'display_order' => $order, + ]); + } + + public function expired(): static + { + return $this->state(fn () => [ + 'status' => Article::STATUS_PUBLISHED, + 'expires_at' => now()->subDay(), + ]); + } + + public function scheduled(): static + { + return $this->state(fn () => [ + 'status' => Article::STATUS_PUBLISHED, + 'published_at' => now()->addDay(), + ]); + } +} + diff --git a/database/factories/ArticleTagFactory.php b/database/factories/ArticleTagFactory.php new file mode 100644 index 0000000..59a4405 --- /dev/null +++ b/database/factories/ArticleTagFactory.php @@ -0,0 +1,26 @@ + + */ +class ArticleTagFactory extends Factory +{ + protected $model = ArticleTag::class; + + public function definition(): array + { + $name = $this->faker->unique()->words(2, true); + + return [ + 'name' => $name, + 'slug' => Str::slug($name) ?: 'tag-'.Str::random(8), + ]; + } +} + diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 0000000..7138c38 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,47 @@ + + */ +class PageFactory extends Factory +{ + protected $model = Page::class; + + public function definition(): array + { + $title = $this->faker->sentence(4); + $slug = Str::slug($title) ?: 'page-'.Str::random(10); + + return [ + 'title' => $title, + 'slug' => $slug, + 'content' => $this->faker->paragraphs(6, true), + 'template' => null, + 'custom_fields' => null, + 'status' => Page::STATUS_PUBLISHED, + 'meta_description' => $this->faker->optional()->sentence(), + 'meta_keywords' => null, + 'parent_id' => null, + 'sort_order' => 0, + 'published_at' => now()->subDay(), + 'created_by_user_id' => User::factory(), + 'last_updated_by_user_id' => User::factory(), + ]; + } + + public function draft(): static + { + return $this->state(fn () => [ + 'status' => Page::STATUS_DRAFT, + 'published_at' => null, + ]); + } +} + diff --git a/database/migrations/2026_02_09_170000_add_featured_image_storage_path_to_articles_table.php b/database/migrations/2026_02_09_170000_add_featured_image_storage_path_to_articles_table.php new file mode 100644 index 0000000..01ac971 --- /dev/null +++ b/database/migrations/2026_02_09_170000_add_featured_image_storage_path_to_articles_table.php @@ -0,0 +1,24 @@ +string('featured_image_storage_path')->nullable()->after('featured_image_path'); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn('featured_image_storage_path'); + }); + } +}; + diff --git a/tests/Feature/Cms/AdminCmsAccessTest.php b/tests/Feature/Cms/AdminCmsAccessTest.php new file mode 100644 index 0000000..bb4327d --- /dev/null +++ b/tests/Feature/Cms/AdminCmsAccessTest.php @@ -0,0 +1,55 @@ +artisan('db:seed', ['--class' => FinancialWorkflowPermissionsSeeder::class]); + } + + public function test_user_without_permissions_cannot_access_admin_articles(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/admin/articles') + ->assertForbidden(); + } + + public function test_secretary_general_can_access_admin_articles_and_create_draft(): void + { + $role = Role::where('name', 'secretary_general')->firstOrFail(); + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get('/admin/articles') + ->assertOk(); + + $res = $this->actingAs($user)->post('/admin/articles', [ + 'title' => 'Test Article', + 'content' => 'Hello world', + 'content_type' => 'blog', + 'access_level' => 'public', + 'save_action' => 'draft', + ]); + + $res->assertRedirect(); + $this->assertDatabaseHas('articles', [ + 'title' => 'Test Article', + 'status' => 'draft', + ]); + } +} + diff --git a/tests/Feature/Cms/ArticleApiTest.php b/tests/Feature/Cms/ArticleApiTest.php new file mode 100644 index 0000000..ba8cee2 --- /dev/null +++ b/tests/Feature/Cms/ArticleApiTest.php @@ -0,0 +1,91 @@ +create(['title' => 'Visible']); + Article::factory()->draft()->create(['title' => 'Draft']); + Article::factory()->expired()->create(['title' => 'Expired']); + Article::factory()->scheduled()->create(['title' => 'Scheduled']); + + $res = $this->getJson('/api/v1/articles?per_page=50'); + + $res->assertOk(); + $res->assertJsonCount(1, 'data'); + $res->assertJsonFragment(['title' => 'Visible']); + $res->assertJsonMissing(['title' => 'Draft']); + $res->assertJsonMissing(['title' => 'Expired']); + $res->assertJsonMissing(['title' => 'Scheduled']); + } + + public function test_articles_index_filters_by_type_category_tag_and_search(): void + { + $cat = ArticleCategory::factory()->create(['slug' => 'news']); + $tag = ArticleTag::factory()->create(['slug' => 'health']); + + $a1 = Article::factory()->create([ + 'content_type' => Article::CONTENT_TYPE_NOTICE, + 'title' => 'Needle Drop', + 'content' => 'alpha beta', + ]); + $a1->categories()->attach($cat); + $a1->tags()->attach($tag); + + Article::factory()->create([ + 'content_type' => Article::CONTENT_TYPE_BLOG, + 'title' => 'Other', + 'content' => 'gamma delta', + ]); + + $this->getJson('/api/v1/articles?type=notice&per_page=50') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['slug' => $a1->slug]); + + $this->getJson('/api/v1/articles?category=news&per_page=50') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['slug' => $a1->slug]); + + $this->getJson('/api/v1/articles?tag=health&per_page=50') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['slug' => $a1->slug]); + + $this->getJson('/api/v1/articles?search=needle&per_page=50') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['slug' => $a1->slug]); + } + + public function test_articles_show_returns_wrapped_data_and_increments_view_count(): void + { + $article = Article::factory()->create([ + 'view_count' => 0, + 'title' => 'My Article', + ]); + + $this->getJson('/api/v1/articles/'.$article->slug) + ->assertOk() + ->assertJsonPath('data.slug', $article->slug) + ->assertJsonStructure([ + 'data' => ['id', 'title', 'slug', 'content'], + 'related' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + ]); + + $this->assertSame(1, $article->refresh()->view_count); + } +} diff --git a/tests/Feature/Cms/HomepageApiTest.php b/tests/Feature/Cms/HomepageApiTest.php new file mode 100644 index 0000000..135d733 --- /dev/null +++ b/tests/Feature/Cms/HomepageApiTest.php @@ -0,0 +1,51 @@ +pinned(0)->create(['content_type' => Article::CONTENT_TYPE_BLOG]); + Article::factory()->pinned(1)->create(['content_type' => Article::CONTENT_TYPE_NOTICE]); + + Article::factory()->create(['content_type' => Article::CONTENT_TYPE_BLOG]); + Article::factory()->create(['content_type' => Article::CONTENT_TYPE_NOTICE]); + Article::factory()->create(['content_type' => Article::CONTENT_TYPE_DOCUMENT]); + Article::factory()->create(['content_type' => Article::CONTENT_TYPE_RELATED_NEWS]); + + $about = Page::factory()->create(['slug' => 'about']); + + $this->getJson('/api/v1/homepage') + ->assertOk() + ->assertJsonStructure([ + 'featured' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + 'latest_blog' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + 'latest_notice' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + 'latest_document' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + 'latest_related_news' => [ + '*' => ['id', 'title', 'slug', 'content_type'], + ], + 'about' => ['id', 'slug', 'content'], + 'categories' => [ + '*' => ['id', 'name', 'slug'], + ], + ]) + ->assertJsonPath('about.slug', $about->slug); + } +} diff --git a/tests/Feature/Document/DocumentTest.php b/tests/Feature/Document/DocumentTest.php index e211c54..8c62aa4 100644 --- a/tests/Feature/Document/DocumentTest.php +++ b/tests/Feature/Document/DocumentTest.php @@ -239,4 +239,44 @@ class DocumentTest extends TestCase $document->refresh(); $this->assertEquals(2, $document->version_count); } + + /** + * Test public download returns UTF-8 compatible content disposition + */ + public function test_public_document_download_uses_utf8_content_disposition(): void + { + $category = DocumentCategory::factory()->create(); + $document = Document::factory()->create([ + 'created_by_user_id' => $this->admin->id, + 'document_category_id' => $category->id, + 'access_level' => 'public', + 'status' => 'active', + 'version_count' => 0, + ]); + + $filePath = 'documents/charter-v2.pdf'; + Storage::disk('private')->put($filePath, 'pdf-content'); + Storage::put($filePath, 'pdf-content'); + + $document->addVersion( + filePath: $filePath, + originalFilename: '台灣尤塞氏症暨視聽弱協會章程V2.pdf', + mimeType: 'application/pdf', + fileSize: Storage::disk('private')->size($filePath), + uploadedBy: $this->admin, + versionNotes: '初始版本' + ); + + $response = $this->get(route('documents.public.download', [ + 'uuid' => $document->public_uuid, + ])); + + $response->assertOk(); + + $disposition = (string) $response->headers->get('Content-Disposition'); + $this->assertStringContainsString( + "filename*=UTF-8''".rawurlencode('台灣尤塞氏症暨視聽弱協會章程V2.pdf'), + $disposition + ); + } } diff --git a/tests/Unit/DownloadFileTest.php b/tests/Unit/DownloadFileTest.php new file mode 100644 index 0000000..34e77b4 --- /dev/null +++ b/tests/Unit/DownloadFileTest.php @@ -0,0 +1,28 @@ +assertSame('V2.pdf', $fallback); + } + + public function test_content_disposition_contains_utf8_filename_star(): void + { + $filename = '台灣尤塞氏症暨視聽弱協會章程V2.pdf'; + $header = DownloadFile::contentDisposition($filename); + + $this->assertStringContainsString('attachment; filename="V2.pdf"', $header); + $this->assertStringContainsString( + "filename*=UTF-8''".rawurlencode($filename), + $header + ); + } +}