feat(cms): sync site assets, revalidate webhook, and document download naming

This commit is contained in:
2026-02-10 23:38:31 +08:00
parent c4969cd4d2
commit b6e18a83ec
27 changed files with 1019 additions and 26 deletions

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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', '文章已成功歸檔');
}

View File

@@ -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
);
}

View File

@@ -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', '頁面已成功發布');
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}

View 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();
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Article extends Model
@@ -47,6 +48,7 @@ class Article extends Model
'status',
'access_level',
'featured_image_path',
'featured_image_storage_path',
'featured_image_alt',
'author_name',
'author_user_id',
@@ -447,7 +449,12 @@ class Article extends Model
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;
}
}

View 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;
}
}

View 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;
}
}

View 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()}");
}
}
}

View 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';
}
}