feat(cms): sync site assets, revalidate webhook, and document download naming
This commit is contained in:
16
.env.example
16
.env.example
@@ -57,3 +57,19 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
|
|||||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
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"
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,6 +16,8 @@ Homestead.yaml
|
|||||||
auth.json
|
auth.json
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ImportHugoContent extends Command
|
|||||||
|
|
||||||
private int $tagsCreated = 0;
|
private int $tagsCreated = 0;
|
||||||
|
|
||||||
private int $imagescopied = 0;
|
private int $imagesCopied = 0;
|
||||||
|
|
||||||
private int $skipped = 0;
|
private int $skipped = 0;
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ class ImportHugoContent extends Command
|
|||||||
['Pages created', $this->pagesCreated],
|
['Pages created', $this->pagesCreated],
|
||||||
['Categories created', $this->categoriesCreated],
|
['Categories created', $this->categoriesCreated],
|
||||||
['Tags created', $this->tagsCreated],
|
['Tags created', $this->tagsCreated],
|
||||||
['Images copied', $this->imagescopied],
|
['Images copied', $this->imagesCopied],
|
||||||
['Skipped (_index.md etc.)', $this->skipped],
|
['Skipped (_index.md etc.)', $this->skipped],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -360,7 +360,15 @@ class ImportHugoContent extends Command
|
|||||||
{
|
{
|
||||||
$this->info("Copying images from: {$imagesPath}");
|
$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)) {
|
if (! $isDryRun && ! is_dir($destPath)) {
|
||||||
File::makeDirectory($destPath, 0755, true);
|
File::makeDirectory($destPath, 0755, true);
|
||||||
@@ -382,7 +390,12 @@ class ImportHugoContent extends Command
|
|||||||
File::copy($file->getPathname(), $destFile);
|
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.
|
* 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
|
private function resolveImagePath(?string $hugoPath): ?string
|
||||||
{
|
{
|
||||||
@@ -519,9 +532,13 @@ class ImportHugoContent extends Command
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove leading "images/" prefix
|
$path = ltrim($hugoPath, '/');
|
||||||
$relativePath = preg_replace('#^images/#', '', $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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/Console/Commands/NextjsPushAssets.php
Normal file
22
app/Console/Commands/NextjsPushAssets.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\NextjsRepoSyncService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class NextjsPushAssets extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nextjs:push-assets';
|
||||||
|
|
||||||
|
protected $description = 'Stage/commit/push Next.js public asset changes (public/uploads and public/images)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
NextjsRepoSyncService::pushPublicAssets();
|
||||||
|
$this->info('Done (best-effort). Check logs if nothing happened.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ use App\Models\ArticleAttachment;
|
|||||||
use App\Models\ArticleCategory;
|
use App\Models\ArticleCategory;
|
||||||
use App\Models\ArticleTag;
|
use App\Models\ArticleTag;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Services\SiteAssetSyncService;
|
||||||
|
use App\Services\SiteRevalidationService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -117,7 +119,15 @@ class ArticleController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($request->hasFile('featured_image')) {
|
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);
|
$article = Article::create($articleData);
|
||||||
@@ -148,6 +158,10 @@ class ArticleController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($validated['save_action'] === 'publish') {
|
||||||
|
SiteRevalidationService::revalidateArticle($article->slug);
|
||||||
|
}
|
||||||
|
|
||||||
$message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿';
|
$message = $validated['save_action'] === 'publish' ? '文章已成功發布' : '文章已儲存為草稿';
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
@@ -227,13 +241,35 @@ class ArticleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->hasFile('featured_image')) {
|
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);
|
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) {
|
} 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_path'] = null;
|
||||||
|
$updateData['featured_image_storage_path'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$article->update($updateData);
|
$article->update($updateData);
|
||||||
@@ -264,6 +300,10 @@ class ArticleController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($article->isPublished()) {
|
||||||
|
SiteRevalidationService::revalidateArticle($article->slug);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.articles.show', $article)
|
->route('admin.articles.show', $article)
|
||||||
->with('status', '文章已成功更新');
|
->with('status', '文章已成功更新');
|
||||||
@@ -276,6 +316,8 @@ class ArticleController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$title = $article->title;
|
$title = $article->title;
|
||||||
|
$slug = $article->slug;
|
||||||
|
$wasPublished = $article->isPublished();
|
||||||
$article->delete();
|
$article->delete();
|
||||||
|
|
||||||
AuditLog::create([
|
AuditLog::create([
|
||||||
@@ -285,6 +327,10 @@ class ArticleController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($wasPublished) {
|
||||||
|
SiteRevalidationService::revalidateArticle($slug);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.articles.index')
|
->route('admin.articles.index')
|
||||||
->with('status', '文章已成功刪除');
|
->with('status', '文章已成功刪除');
|
||||||
@@ -313,6 +359,8 @@ class ArticleController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
SiteRevalidationService::revalidateArticle($article->slug);
|
||||||
|
|
||||||
return back()->with('status', '文章已成功發布');
|
return back()->with('status', '文章已成功發布');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +387,8 @@ class ArticleController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
SiteRevalidationService::revalidateArticle($article->slug);
|
||||||
|
|
||||||
return back()->with('status', '文章已成功歸檔');
|
return back()->with('status', '文章已成功歸檔');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\AuditLog;
|
|||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\DocumentCategory;
|
use App\Models\DocumentCategory;
|
||||||
use App\Models\DocumentVersion;
|
use App\Models\DocumentVersion;
|
||||||
|
use App\Support\DownloadFile;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -238,9 +239,10 @@ class DocumentController extends Controller
|
|||||||
// Log access
|
// Log access
|
||||||
$document->logAccess('download', auth()->user());
|
$document->logAccess('download', auth()->user());
|
||||||
|
|
||||||
return Storage::disk('private')->download(
|
return DownloadFile::fromDisk(
|
||||||
$version->file_path,
|
disk: 'private',
|
||||||
$version->original_filename
|
path: $version->file_path,
|
||||||
|
downloadName: $version->original_filename
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use App\Services\SiteRevalidationService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
@@ -72,6 +73,10 @@ class PageController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($validated['status'] === 'published') {
|
||||||
|
SiteRevalidationService::revalidatePage($page->slug);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.pages.show', $page)
|
->route('admin.pages.show', $page)
|
||||||
->with('status', '頁面已成功建立');
|
->with('status', '頁面已成功建立');
|
||||||
@@ -135,6 +140,10 @@ class PageController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($page->isPublished()) {
|
||||||
|
SiteRevalidationService::revalidatePage($page->slug);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.pages.show', $page)
|
->route('admin.pages.show', $page)
|
||||||
->with('status', '頁面已成功更新');
|
->with('status', '頁面已成功更新');
|
||||||
@@ -147,6 +156,8 @@ class PageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$title = $page->title;
|
$title = $page->title;
|
||||||
|
$slug = $page->slug;
|
||||||
|
$wasPublished = $page->isPublished();
|
||||||
$page->delete();
|
$page->delete();
|
||||||
|
|
||||||
AuditLog::create([
|
AuditLog::create([
|
||||||
@@ -156,6 +167,10 @@ class PageController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($wasPublished) {
|
||||||
|
SiteRevalidationService::revalidatePage($slug);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('admin.pages.index')
|
->route('admin.pages.index')
|
||||||
->with('status', '頁面已成功刪除');
|
->with('status', '頁面已成功刪除');
|
||||||
@@ -176,6 +191,8 @@ class PageController extends Controller
|
|||||||
'ip_address' => request()->ip(),
|
'ip_address' => request()->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
SiteRevalidationService::revalidatePage($page->slug);
|
||||||
|
|
||||||
return back()->with('status', '頁面已成功發布');
|
return back()->with('status', '頁面已成功發布');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use App\Http\Resources\ArticleCollectionResource;
|
|||||||
use App\Http\Resources\ArticleResource;
|
use App\Http\Resources\ArticleResource;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\ArticleAttachment;
|
use App\Models\ArticleAttachment;
|
||||||
|
use App\Support\DownloadFile;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class ArticleController extends Controller
|
class ArticleController extends Controller
|
||||||
{
|
{
|
||||||
@@ -87,9 +87,10 @@ class ArticleController extends Controller
|
|||||||
|
|
||||||
$attachment->incrementDownloadCount();
|
$attachment->incrementDownloadCount();
|
||||||
|
|
||||||
return Storage::disk('public')->download(
|
return DownloadFile::fromDisk(
|
||||||
$attachment->file_path,
|
disk: 'public',
|
||||||
$attachment->original_filename
|
path: $attachment->file_path,
|
||||||
|
downloadName: $attachment->original_filename
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Models\DocumentCategory;
|
use App\Models\DocumentCategory;
|
||||||
|
use App\Support\DownloadFile;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -109,9 +110,10 @@ class PublicDocumentController extends Controller
|
|||||||
// Log access
|
// Log access
|
||||||
$document->logAccess('download', $user);
|
$document->logAccess('download', $user);
|
||||||
|
|
||||||
return Storage::disk('private')->download(
|
return DownloadFile::fromDisk(
|
||||||
$currentVersion->file_path,
|
disk: 'private',
|
||||||
$currentVersion->original_filename
|
path: $currentVersion->file_path,
|
||||||
|
downloadName: $currentVersion->original_filename
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +141,10 @@ class PublicDocumentController extends Controller
|
|||||||
// Log access
|
// Log access
|
||||||
$document->logAccess('download', $user);
|
$document->logAccess('download', $user);
|
||||||
|
|
||||||
return Storage::disk('private')->download(
|
return DownloadFile::fromDisk(
|
||||||
$version->file_path,
|
disk: 'private',
|
||||||
$version->original_filename
|
path: $version->file_path,
|
||||||
|
downloadName: $version->original_filename
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
app/Jobs/PushNextjsPublicAssetsJob.php
Normal file
32
app/Jobs/PushNextjsPublicAssetsJob.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\NextjsRepoSyncService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PushNextjsPublicAssetsJob implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse bursts of uploads into a single push.
|
||||||
|
*/
|
||||||
|
public int $uniqueFor = 60;
|
||||||
|
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
return 'nextjs-public-assets-push';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
NextjsRepoSyncService::pushPublicAssets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Article extends Model
|
class Article extends Model
|
||||||
@@ -47,6 +48,7 @@ class Article extends Model
|
|||||||
'status',
|
'status',
|
||||||
'access_level',
|
'access_level',
|
||||||
'featured_image_path',
|
'featured_image_path',
|
||||||
|
'featured_image_storage_path',
|
||||||
'featured_image_alt',
|
'featured_image_alt',
|
||||||
'author_name',
|
'author_name',
|
||||||
'author_user_id',
|
'author_user_id',
|
||||||
@@ -447,7 +449,12 @@ class Article extends Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return relative path for Next.js frontend (images served from public/)
|
// Admin-uploaded images stored in Laravel storage
|
||||||
|
if (str_starts_with($this->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;
|
return '/'.$this->featured_image_path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
app/Services/NextjsRepoSyncService.php
Normal file
111
app/Services/NextjsRepoSyncService.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
class NextjsRepoSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stage+commit+push assets under Next.js public/ that were copied by SiteAssetSyncService / ImportHugoContent.
|
||||||
|
*
|
||||||
|
* Best-effort and never throws: failure should not block the CMS workflow.
|
||||||
|
*/
|
||||||
|
public static function pushPublicAssets(): void
|
||||||
|
{
|
||||||
|
$repoPath = static::resolveRepoRoot();
|
||||||
|
if (! $repoPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = Cache::lock('nextjs-repo-assets-push', 120);
|
||||||
|
if (! $lock->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
101
app/Services/SiteAssetSyncService.php
Normal file
101
app/Services/SiteAssetSyncService.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class SiteAssetSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Copy a file from Laravel's public disk into usher-site/public so Next.js can serve it as a static asset.
|
||||||
|
*
|
||||||
|
* Returns the relative path under Next.js public/ (no leading slash), e.g. "uploads/articles/images/foo.jpg".
|
||||||
|
* If the Next.js public path is not configured / not present, returns null and callers should fall back
|
||||||
|
* to serving from Laravel storage.
|
||||||
|
*/
|
||||||
|
public static function syncPublicDiskFileToNext(string $publicDiskPath): ?string
|
||||||
|
{
|
||||||
|
$publicDiskPath = ltrim($publicDiskPath, '/');
|
||||||
|
|
||||||
|
$nextPublicRoot = static::resolveNextPublicRoot();
|
||||||
|
if (! $nextPublicRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = Storage::disk('public')->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Services/SiteRevalidationService.php
Normal file
48
app/Services/SiteRevalidationService.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SiteRevalidationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Revalidate article cache on the Next.js frontend.
|
||||||
|
*/
|
||||||
|
public static function revalidateArticle(?string $slug = null): void
|
||||||
|
{
|
||||||
|
static::revalidate('article', $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate page cache on the Next.js frontend.
|
||||||
|
*/
|
||||||
|
public static function revalidatePage(?string $slug = null): void
|
||||||
|
{
|
||||||
|
static::revalidate('page', $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function revalidate(string $type, ?string $slug = null): void
|
||||||
|
{
|
||||||
|
$url = config('services.nextjs.revalidate_url');
|
||||||
|
$token = config('services.nextjs.revalidate_token');
|
||||||
|
|
||||||
|
if (! $url || ! $token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = ['type' => $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()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Support/DownloadFile.php
Normal file
71
app/Support/DownloadFile.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class DownloadFile
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a download response with UTF-8 filename support.
|
||||||
|
*/
|
||||||
|
public static function fromDisk(
|
||||||
|
string $disk,
|
||||||
|
string $path,
|
||||||
|
string $downloadName,
|
||||||
|
array $headers = []
|
||||||
|
): Response {
|
||||||
|
$safeName = static::asciiFallbackFilename($downloadName);
|
||||||
|
$response = Storage::disk($disk)->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ return [
|
|||||||
'throw' => false,
|
'throw' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Private files remain under storage/app and are served via controller responses.
|
||||||
|
'private' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app'),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
|
|
||||||
'public' => [
|
'public' => [
|
||||||
'driver' => 'local',
|
'driver' => 'local',
|
||||||
'root' => storage_path('app/public'),
|
'root' => storage_path('app/public'),
|
||||||
|
|||||||
@@ -31,4 +31,21 @@ return [
|
|||||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
'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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
28
database/factories/ArticleCategoryFactory.php
Normal file
28
database/factories/ArticleCategoryFactory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ArticleCategory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<ArticleCategory>
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
database/factories/ArticleFactory.php
Normal file
79
database/factories/ArticleFactory.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Article>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
database/factories/ArticleTagFactory.php
Normal file
26
database/factories/ArticleTagFactory.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\ArticleTag;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<ArticleTag>
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
database/factories/PageFactory.php
Normal file
47
database/factories/PageFactory.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Page>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('articles', function (Blueprint $table) {
|
||||||
|
// Keep original Laravel storage path when we also sync into usher-site/public.
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
55
tests/Feature/Cms/AdminCmsAccessTest.php
Normal file
55
tests/Feature/Cms/AdminCmsAccessTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Cms;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\FinancialWorkflowPermissionsSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminCmsAccessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
91
tests/Feature/Cms/ArticleApiTest.php
Normal file
91
tests/Feature/Cms/ArticleApiTest.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Cms;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\ArticleCategory;
|
||||||
|
use App\Models\ArticleTag;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ArticleApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_articles_index_only_returns_active_and_public_for_guests(): void
|
||||||
|
{
|
||||||
|
Article::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/Feature/Cms/HomepageApiTest.php
Normal file
51
tests/Feature/Cms/HomepageApiTest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Cms;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Page;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HomepageApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_homepage_endpoint_returns_expected_sections(): void
|
||||||
|
{
|
||||||
|
Article::factory()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,4 +239,44 @@ class DocumentTest extends TestCase
|
|||||||
$document->refresh();
|
$document->refresh();
|
||||||
$this->assertEquals(2, $document->version_count);
|
$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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
tests/Unit/DownloadFileTest.php
Normal file
28
tests/Unit/DownloadFileTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Support\DownloadFile;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DownloadFileTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_ascii_fallback_filename_keeps_extension(): void
|
||||||
|
{
|
||||||
|
$fallback = DownloadFile::asciiFallbackFilename('台灣尤塞氏症暨視聽弱協會章程V2.pdf');
|
||||||
|
|
||||||
|
$this->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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user