diff --git a/.github/workflows/web_docker.yml b/.github/workflows/web_docker.yml index c9dbc79d..b70ddb68 100644 --- a/.github/workflows/web_docker.yml +++ b/.github/workflows/web_docker.yml @@ -27,6 +27,11 @@ on: type: boolean required: false default: true + test: + description: 'If true, append _test to tag and skip latest tag' + type: boolean + required: false + default: false env: LATEST_TAG: latest @@ -65,6 +70,9 @@ jobs: run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then T="${{ github.event.inputs.tag_name }}" + if [ "${{ github.event.inputs.test }}" == "true" ]; then + T="${T}_test" + fi else # Extract tag from ref (e.g., refs/tags/v1.2.3 -> v1.2.3) T="${GITHUB_REF#refs/tags/}" @@ -89,7 +97,7 @@ jobs: cache-to: type=gha,mode=max,scope=appflowy_web-${{ matrix.job.arch }} tags: | ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.arch }} - ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.tag_latest == 'true') || github.event_name == 'push' && format('{0}:{1}-{2}', env.IMAGE_NAME, env.LATEST_TAG, matrix.job.arch) || '' }} + ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.tag_latest == 'true' && github.event.inputs.test != 'true') || (github.event_name == 'push' && format('{0}:{1}-{2}', env.IMAGE_NAME, env.LATEST_TAG, matrix.job.arch)) || '' }} labels: ${{ steps.meta.outputs.labels }} provenance: false build-args: | @@ -114,6 +122,9 @@ jobs: run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then T="${{ github.event.inputs.tag_name }}" + if [ "${{ github.event.inputs.test }}" == "true" ]; then + T="${T}_test" + fi else # Extract tag from ref (e.g., refs/tags/v1.2.3 -> v1.2.3) T="${GITHUB_REF#refs/tags/}" @@ -129,7 +140,7 @@ jobs: push: true - name: Create and push manifest for appflowy_web:latest - if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.tag_latest == 'true') || github.event_name == 'push' }} + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.tag_latest == 'true' && github.event.inputs.test != 'true') || (github.event_name == 'push') }} uses: Noelware/docker-manifest-action@0.4.3 with: inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_web:${{ env.LATEST_TAG }} diff --git a/src/components/_shared/hooks/useAuthenticatedImage.ts b/src/components/_shared/hooks/useAuthenticatedImage.ts new file mode 100644 index 00000000..3329096f --- /dev/null +++ b/src/components/_shared/hooks/useAuthenticatedImage.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from 'react'; + +import { getImageUrl, revokeBlobUrl } from '@/utils/authenticated-image'; + +/** + * Hook to handle authenticated image loading for AppFlowy file storage URLs + * Returns the authenticated blob URL or the original URL if authentication is not needed + * + * @param src - The image source URL + * @returns The authenticated image URL (blob URL) or original URL + */ +export function useAuthenticatedImage(src: string | undefined): string { + const [authenticatedSrc, setAuthenticatedSrc] = useState(''); + const blobUrlRef = useRef(''); + + useEffect(() => { + if (!src) { + setAuthenticatedSrc(''); + return; + } + + let isMounted = true; + + getImageUrl(src) + .then((url) => { + if (isMounted) { + setAuthenticatedSrc(url); + blobUrlRef.current = url; + } + }) + .catch((error) => { + console.error('Failed to load authenticated image:', error); + if (isMounted) { + setAuthenticatedSrc(''); + } + }); + + return () => { + isMounted = false; + // Clean up blob URL if it was created + if (blobUrlRef.current && blobUrlRef.current.startsWith('blob:')) { + revokeBlobUrl(blobUrlRef.current); + blobUrlRef.current = ''; + } + }; + }, [src]); + + return authenticatedSrc || src || ''; +} + diff --git a/src/components/_shared/image-render/ImageRender.tsx b/src/components/_shared/image-render/ImageRender.tsx index 07e49c27..8971398f 100644 --- a/src/components/_shared/image-render/ImageRender.tsx +++ b/src/components/_shared/image-render/ImageRender.tsx @@ -2,6 +2,7 @@ import { Skeleton } from '@mui/material'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ErrorOutline } from '@/assets/icons/error.svg'; +import { useAuthenticatedImage } from '@/components/_shared/hooks/useAuthenticatedImage'; interface ImageRenderProps extends React.HTMLAttributes { src: string; @@ -12,6 +13,7 @@ export function ImageRender({ src, ...props }: ImageRenderProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [hasError, setHasError] = useState(false); + const authenticatedSrc = useAuthenticatedImage(src); return ( <> @@ -30,7 +32,7 @@ export function ImageRender({ src, ...props }: ImageRenderProps) { width: loading ? 1 : '100%', }} draggable={false} - src={src} + src={authenticatedSrc} {...props} onLoad={(e) => { props.onLoad?.(e); diff --git a/src/components/database/components/cell/file-media/FileMediaItem.tsx b/src/components/database/components/cell/file-media/FileMediaItem.tsx index 2710017a..e82711b1 100644 --- a/src/components/database/components/cell/file-media/FileMediaItem.tsx +++ b/src/components/database/components/cell/file-media/FileMediaItem.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { FileMediaCellDataItem, FileMediaType } from '@/application/database-yjs/cell.type'; +import { useAuthenticatedImage } from '@/components/_shared/hooks/useAuthenticatedImage'; import FileIcon from '@/components/database/components/cell/file-media/FileIcon'; import FileMediaMore from '@/components/database/components/cell/file-media/FileMediaMore'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -19,13 +20,15 @@ function FileMediaItem({ onUpdateName: (name: string) => void; onDelete: () => void; }) { + const authenticatedUrl = useAuthenticatedImage(file.url); + const renderItem = useMemo(() => { switch (file.file_type) { case FileMediaType.Image: return ( {file.name} ); } - }, [onPreview, file]); + }, [onPreview, file, authenticatedUrl]); return (
void }) { @@ -23,6 +24,8 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: return url.toString() + '&w=240&q=80'; }, [file.url, workspaceId, viewId]); + const authenticatedThumb = useAuthenticatedImage(thumb); + return (
{ @@ -32,7 +35,7 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: className={'cursor-zoom-in'} > {file.name} {file.name} diff --git a/src/components/editor/components/blocks/image/Img.tsx b/src/components/editor/components/blocks/image/Img.tsx index 3aef54b3..feda119b 100644 --- a/src/components/editor/components/blocks/image/Img.tsx +++ b/src/components/editor/components/blocks/image/Img.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ErrorOutline } from '@/assets/icons/error.svg'; @@ -24,6 +24,8 @@ function Img({ status: number; statusText: string; } | null>(null); + const previousBlobUrlRef = useRef(''); + const isMountedRef = useRef(true); const handleCheckImage = useCallback(async (url: string) => { setLoading(true); @@ -37,16 +39,41 @@ function Img({ const startTime = Date.now(); const attemptCheck: () => Promise = async () => { + // Don't proceed if component is unmounted + if (!isMountedRef.current) { + return false; + } + try { const result = await checkImage(url); + // Don't update state if component is unmounted + if (!isMountedRef.current) { + // Revoke blob URL if component unmounted during fetch + if (result.ok && result.validatedUrl && result.validatedUrl.startsWith('blob:')) { + URL.revokeObjectURL(result.validatedUrl); + } + + return false; + } + // Success case if (result.ok) { + // Revoke previous blob URL if it exists and is different + if (previousBlobUrlRef.current && previousBlobUrlRef.current !== result.validatedUrl) { + if (previousBlobUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(previousBlobUrlRef.current); + } + } + setImgError(null); setLoading(false); - setLocalUrl(result.validatedUrl || ''); + const newUrl = result.validatedUrl || ''; + + setLocalUrl(newUrl); + previousBlobUrlRef.current = newUrl; setTimeout(() => { - if (onLoad) { + if (onLoad && isMountedRef.current) { onLoad(); } }, 200); @@ -92,7 +119,17 @@ function Img({ }, []); useEffect(() => { + isMountedRef.current = true; void handleCheckImage(url); + + // Cleanup: revoke blob URL when component unmounts or URL changes + return () => { + isMountedRef.current = false; + if (previousBlobUrlRef.current && previousBlobUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(previousBlobUrlRef.current); + previousBlobUrlRef.current = ''; + } + }; }, [handleCheckImage, url]); return ( diff --git a/src/utils/image.ts b/src/utils/image.ts index 6920cd51..84e08ad1 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,4 +1,13 @@ -export const checkImage = async(url: string) => { +import { getTokenParsed } from '@/application/session/token'; +import { isAppFlowyFileStorageUrl } from '@/utils/file-storage-url'; +import { getConfigValue } from '@/utils/runtime-config'; + +const resolveImageUrl = (url: string): string => { + if (!url) return ''; + return url.startsWith('http') ? url : `${getConfigValue('APPFLOWY_BASE_URL', '')}${url}`; +}; + +export const checkImage = async (url: string) => { return new Promise((resolve: (data: { ok: boolean, status: number, @@ -6,6 +15,61 @@ export const checkImage = async(url: string) => { error?: string, validatedUrl?: string, }) => void) => { + // If it's an AppFlowy file storage URL, use authenticated fetch + if (isAppFlowyFileStorageUrl(url)) { + const token = getTokenParsed(); + + if (!token) { + resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + error: 'No authentication token available', + }); + return; + } + + const fullUrl = resolveImageUrl(url); + + fetch(fullUrl, { + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }) + .then((response) => { + if (response.ok) { + // Convert to blob URL for use in img tag + return response.blob().then((blob) => { + const blobUrl = URL.createObjectURL(blob); + + resolve({ + ok: true, + status: 200, + statusText: 'OK', + validatedUrl: blobUrl, + }); + }); + } else { + resolve({ + ok: false, + status: response.status, + statusText: response.statusText, + error: `Failed to fetch image: ${response.statusText}`, + }); + } + }) + .catch((error) => { + resolve({ + ok: false, + status: 500, + statusText: 'Internal Error', + error: error.message || 'Failed to fetch image', + }); + }); + return; + } + + // For non-AppFlowy URLs, use the original Image() approach const img = new Image(); // Set a timeout to handle very slow loads @@ -20,10 +84,6 @@ export const checkImage = async(url: string) => { img.onload = () => { clearTimeout(timeoutId); - // Add cache-busting parameter to prevent browser caching - // which can sometimes hide image loading issues - // const cacheBuster = `?cb=${Date.now()}`; - resolve({ ok: true, status: 200, @@ -43,6 +103,5 @@ export const checkImage = async(url: string) => { }; img.src = url; - }); }; \ No newline at end of file