feat: add S3 configuration and utility functions for blog image handling
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s

This commit is contained in:
2025-10-26 07:56:19 +08:00
parent 031c2b25e2
commit 7f58c9c847
10 changed files with 98 additions and 30 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# S3 Configuration for Blog Images
VITE_S3_ENDPOINT_BLOG=https://s3.example.com
VITE_S3_BUCKET_BLOG=my-blog-bucket

View File

@@ -19,6 +19,9 @@ env:
CONTAINER_REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
CONTAINER_IMAGE_NAME: ${{ secrets.CONTAINER_IMAGE_NAME }}
CONTAINER_IMAGE_TAG: ${{ secrets.CONTAINER_IMAGE_TAG }}
# S3 Configuration for Blog
VITE_S3_ENDPOINT_BLOG: ${{ secrets.VITE_S3_ENDPOINT_BLOG }}
VITE_S3_BUCKET_BLOG: ${{ secrets.VITE_S3_BUCKET_BLOG }}
jobs:
build-and-push:

5
.gitignore vendored
View File

@@ -12,6 +12,11 @@ dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -13,6 +13,14 @@ RUN npm ci
# Copy project files
COPY . .
# Build arguments for environment variables
ARG VITE_S3_ENDPOINT_BLOG
ARG VITE_S3_BUCKET_BLOG
# Set environment variables for Vite build
ENV VITE_S3_ENDPOINT_BLOG=${VITE_S3_ENDPOINT_BLOG}
ENV VITE_S3_BUCKET_BLOG=${VITE_S3_BUCKET_BLOG}
# Build the application
RUN npm run build

View File

@@ -13,6 +13,10 @@ CONTAINER_REGISTRY_PASSWORD="${CONTAINER_REGISTRY_PASSWORD:-password}"
CONTAINER_IMAGE_NAME="${CONTAINER_IMAGE_NAME:-image-name}"
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG:-latest}"
# Application Environment Variables
VITE_S3_ENDPOINT_BLOG="${VITE_S3_ENDPOINT_BLOG:-}"
VITE_S3_BUCKET_BLOG="${VITE_S3_BUCKET_BLOG:-}"
# =============================================================================
# Functions
# =============================================================================
@@ -33,6 +37,8 @@ print_help() {
echo " CONTAINER_REGISTRY_PASSWORD Registry password (default: password)"
echo " CONTAINER_IMAGE_NAME Image name (default: image-name)"
echo " CONTAINER_IMAGE_TAG Image tag (default: latest)"
echo " VITE_S3_ENDPOINT_BLOG S3 endpoint for blog images"
echo " VITE_S3_BUCKET_BLOG S3 bucket for blog images"
echo ""
echo "Examples:"
echo " $0 build"
@@ -51,8 +57,20 @@ build_image() {
echo "Image: ${FULL_IMAGE_NAME}"
echo ""
# Prepare build args for S3 configuration
BUILD_ARGS=""
if [ -n "${VITE_S3_ENDPOINT_BLOG}" ]; then
BUILD_ARGS="${BUILD_ARGS} --build-arg VITE_S3_ENDPOINT_BLOG=${VITE_S3_ENDPOINT_BLOG}"
echo "S3 Endpoint (Blog): ${VITE_S3_ENDPOINT_BLOG}"
fi
if [ -n "${VITE_S3_BUCKET_BLOG}" ]; then
BUILD_ARGS="${BUILD_ARGS} --build-arg VITE_S3_BUCKET_BLOG=${VITE_S3_BUCKET_BLOG}"
echo "S3 Bucket (Blog): ${VITE_S3_BUCKET_BLOG}"
fi
echo ""
# Build the Docker image using Dockerfile in current directory
docker build -t "${FULL_IMAGE_NAME}" .
docker build ${BUILD_ARGS} -t "${FULL_IMAGE_NAME}" .
# Check if build was successful
if [ $? -eq 0 ]; then

View File

@@ -3,9 +3,9 @@
*/
import { apiRequest } from '../utils/api';
import { getS3Url } from './s3Config';
import type {
UploadPresignedURLResponse,
DownloadPresignedURLResponse,
CreatePostResponse,
} from './types';
@@ -27,22 +27,6 @@ export async function getUploadPresignedURL(fileName: string): Promise<UploadPre
return response.json();
}
/**
* Get presigned URL for file download
*/
export async function getDownloadPresignedURL(fileKey: string): Promise<DownloadPresignedURLResponse> {
const response = await apiRequest(`${API_BASE}/file/download`, {
method: 'POST',
body: JSON.stringify({ fileKey }),
});
if (!response.ok) {
throw new Error(`Failed to get download URL: ${response.statusText}`);
}
return response.json();
}
/**
* Upload file to S3 using presigned URL
*/
@@ -97,10 +81,10 @@ export async function uploadImage(
// Step 2: Upload file to S3
await uploadFileToS3(uploadUrl, file, onProgress);
// Step 3: Get download URL
const { url: downloadUrl } = await getDownloadPresignedURL(fileKey);
// Step 3: Generate public S3 URL from fileKey
const url = getS3Url(fileKey);
return { fileKey, url: downloadUrl };
return { fileKey, url };
}
/**

View File

@@ -18,10 +18,12 @@ import {
useEffect,
useRef,
useState,
useMemo,
} from 'react';
import ImageResizer from './ImageResizer';
import { $isImageNode } from './ImageNode';
import { getS3Url } from '../s3Config';
type ImageStatus =
| { error: true }
@@ -143,6 +145,14 @@ export default function ImageComponent({
const [editor] = useLexicalComposerContext();
const [isLoadError, setIsLoadError] = useState<boolean>(false);
// Generate actual image URL from fileKey or use src
const imageUrl = useMemo(() => {
if (_fileKey) {
return getS3Url(_fileKey);
}
return src; // Fallback to src for data URLs during upload
}, [_fileKey, src]);
const $onEscape = useCallback(
() => {
if (isSelected) {
@@ -259,7 +269,7 @@ export default function ImageComponent({
) : (
<LazyImage
className={isFocused ? 'focused' : null}
src={src}
src={imageUrl}
altText={altText}
imageRef={imageRef}
width={width}

View File

@@ -47,7 +47,6 @@ export type SerializedImageNode = Spread<
altText: string;
height?: number;
maxWidth: number;
src: string;
width?: number;
fileKey?: string;
uploadProgress?: number;
@@ -85,7 +84,11 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, height, width, maxWidth, src, fileKey, uploadProgress, uploadError } = serializedNode;
const { altText, height, width, maxWidth, fileKey, uploadProgress, uploadError } = serializedNode;
// Generate src from fileKey if available, otherwise use empty string
const src = fileKey ? '' : ''; // src will be generated from fileKey when rendering
return $createImageNode({
altText,
height,
@@ -150,7 +153,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
altText: this.getAltText(),
height: this.__height === 'inherit' ? 0 : this.__height,
maxWidth: this.__maxWidth,
src: this.getSrc(),
// Don't export src - it's mutable and will be generated from fileKey
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,

39
src/blog/s3Config.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* S3 configuration utility for Blog
*/
// S3 endpoint and bucket from environment variables
export const S3_ENDPOINT_BLOG = import.meta.env.VITE_S3_ENDPOINT_BLOG || 'https://s3.amazonaws.com';
export const S3_BUCKET_BLOG = import.meta.env.VITE_S3_BUCKET_BLOG || 'default-bucket';
/**
* Generate S3 URL from file key
* @param fileKey - The S3 file key (e.g., "uploads/image-123.png")
* @returns Full S3 URL to access the file
*/
export function getS3Url(fileKey: string): string {
// Remove leading slash if present
const key = fileKey.startsWith('/') ? fileKey.slice(1) : fileKey;
// Construct URL: https://s3.example.com/bucket-name/uploads/image-123.png
return `${S3_ENDPOINT_BLOG}/${S3_BUCKET_BLOG}/${key}`;
}
/**
* Extract file key from S3 URL
* @param url - Full S3 URL
* @returns File key or null if not a valid S3 URL
*/
export function extractFileKeyFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
// Remove empty parts and bucket name
const filteredParts = pathParts.filter(p => p && p !== S3_BUCKET_BLOG);
return filteredParts.join('/') || null;
} catch {
return null;
}
}

View File

@@ -8,11 +8,6 @@ export interface UploadPresignedURLResponse {
expireAt: number;
}
export interface DownloadPresignedURLResponse {
url: string;
expireAt: number;
}
export interface CreatePostResponse {
postId: string;
}