From cd799a20246cb90c3db9ede50519760bedf0d09c Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 14:03:46 +0200 Subject: [PATCH] feat: add S3 object copying and enhanced media URL rewriting for Ghost posts --- apps/api/src/ghost.ts | 54 +++++++++++++++++++++++++++++++++----- apps/api/src/storage/s3.ts | 16 ++++++++++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/api/src/ghost.ts b/apps/api/src/ghost.ts index ae1e527..0360b6a 100644 --- a/apps/api/src/ghost.ts +++ b/apps/api/src/ghost.ts @@ -1,7 +1,7 @@ import express from 'express'; import jwt from 'jsonwebtoken'; import { fetch } from 'undici'; -import { getPresignedUrl, getPublicUrlForKey } from './storage/s3'; +import { getPresignedUrl, getPublicUrlForKey, copyObject } from './storage/s3'; const router = express.Router(); @@ -58,6 +58,8 @@ router.post('/post', async (req, res) => { return res.status(400).json({ error: 'title and html are required' }); } + const replacePairs: Array<{ from: string; to: string }> = []; + async function rewriteInternalMediaUrl(src: string, bucketFallback: string): Promise { try { if (!src) return src; @@ -66,14 +68,31 @@ router.post('/post', async (req, res) => { if (!isInternal) return src; // parse query const base = 'http://local'; - const u = new URL(src, base); + const decoded = src.replace(/&/g, '&'); + const u = new URL(decoded, base); const key = u.searchParams.get('key') || ''; const bucket = u.searchParams.get('bucket') || bucketFallback; if (!key || !bucket) return src; - // Prefer a public base if provided, else presigned URL - const publicUrl = getPublicUrlForKey(key); - if (publicUrl) return publicUrl; - return await getPresignedUrl({ bucket, key, expiresInSeconds: 60 * 60 * 24 * 7 }); + // Prefer a public base if provided (and optionally copy), else presigned URL + let out: string; + const publicBase = process.env.PUBLIC_MEDIA_BASE_URL; + if (publicBase) { + const destBucket = process.env.PUBLIC_MEDIA_BUCKET || ''; + if (destBucket) { + try { + await copyObject({ srcBucket: bucket, srcKey: key, destBucket, destKey: key }); + } catch (e) { + console.warn('[Ghost] Copy to public bucket failed, will still use public base URL', e); + } + } + out = getPublicUrlForKey(key) as string; + } else { + out = await getPresignedUrl({ bucket, key, expiresInSeconds: 60 * 60 * 24 * 7 }); + } + if (out !== src) { + replacePairs.push({ from: src, to: out }); + } + return out; } catch { return src; } @@ -98,10 +117,33 @@ router.post('/post', async (req, res) => { return out; } + // Inspect incoming HTML for srcs before rewriting + try { + const re = /]*src=["']([^"']+)["'][^>]*>/gi; + const found = [...(html || '').matchAll(re)].map(m => m[1]); + console.log('[Ghost] Incoming feature_image (raw):', feature_image || '(none)'); + console.log('[Ghost] Incoming count:', found.length); + console.log('[Ghost] Incoming srcs (first 5):', found.slice(0, 5)); + console.log('[Ghost] HTML contains /api/media/obj:', (html || '').includes('/api/media/obj')); + } catch {} + const bucketDefault = process.env.S3_BUCKET || ''; const rewrittenFeatureImage = await rewriteInternalMediaUrl(feature_image || '', bucketDefault); const rewrittenHtml = await rewriteHtmlImagesToPublic(html, bucketDefault); + // Debug logs to trace rewriting + try { + const base = process.env.PUBLIC_MEDIA_BASE_URL || '(none)'; + console.log('[Ghost] PUBLIC_MEDIA_BASE_URL:', base); + console.log('[Ghost] Replacements count:', replacePairs.length); + if (rewrittenFeatureImage && rewrittenFeatureImage !== (feature_image || '')) { + console.log('[Ghost] feature_image rewritten'); + } + if (replacePairs.length > 0) { + console.log('[Ghost] Sample replacements:', replacePairs.slice(0, 3)); + } + } catch {} + const payload = { posts: [ { diff --git a/apps/api/src/storage/s3.ts b/apps/api/src/storage/s3.ts index 3072251..b845172 100644 --- a/apps/api/src/storage/s3.ts +++ b/apps/api/src/storage/s3.ts @@ -1,4 +1,4 @@ -import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export function getS3Client() { @@ -94,3 +94,17 @@ export async function downloadObject(params: { bucket: string; key: string }): P console.log('[S3] Download done', { bucket: params.bucket, key: params.key, bytes: buffer.length, contentType }); return { buffer, contentType }; } + +export async function copyObject(params: { srcBucket: string; srcKey: string; destBucket: string; destKey: string }) { + const s3 = getS3Client(); + const copySource = `/${params.srcBucket}/${encodeURIComponent(params.srcKey).replace(/%2F/g, '/')}`; + console.log('[S3] Copy start', { from: { bucket: params.srcBucket, key: params.srcKey }, to: { bucket: params.destBucket, key: params.destKey } }); + await s3.send(new CopyObjectCommand({ + Bucket: params.destBucket, + Key: params.destKey, + CopySource: copySource, + MetadataDirective: 'COPY', + })); + console.log('[S3] Copy done', { bucket: params.destBucket, key: params.destKey }); + return { bucket: params.destBucket, key: params.destKey }; +}