fix: attach token when requesting appflowy image/file

This commit is contained in:
Nathan
2025-11-16 09:39:15 +08:00
parent 8de485d60a
commit 874fb319fe
8 changed files with 184 additions and 16 deletions

View File

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

View File

@@ -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<string>('');
const blobUrlRef = useRef<string>('');
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 || '';
}

View File

@@ -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<HTMLImageElement> {
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);

View File

@@ -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 (
<img
onClick={onPreview}
src={file.url}
src={authenticatedUrl}
alt={file.name}
className={
'aspect-square h-[72px] flex-1 cursor-zoom-in overflow-hidden rounded-[4px] border border-border-primary object-cover'
@@ -45,7 +48,7 @@ function FileMediaItem({
</div>
);
}
}, [onPreview, file]);
}, [onPreview, file, authenticatedUrl]);
return (
<div

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useDatabaseContext } from '@/application/database-yjs';
import { FileMediaCellDataItem } from '@/application/database-yjs/cell.type';
import { useAuthenticatedImage } from '@/components/_shared/hooks/useAuthenticatedImage';
import { getFileUrl, isFileURL } from '@/utils/file-storage-url';
function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick: () => 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 (
<div
onClick={(e) => {
@@ -32,7 +35,7 @@ function PreviewImage({ file, onClick }: { file: FileMediaCellDataItem; onClick:
className={'cursor-zoom-in'}
>
<img
src={thumb}
src={authenticatedThumb}
alt={file.name}
className={
'aspect-square h-[28px] w-[28px] min-w-[28px] overflow-hidden rounded-[4px] border border-border-primary object-cover'

View File

@@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react';
import { useDatabaseContext, useReadOnly } from '@/application/database-yjs';
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';
@@ -56,6 +57,8 @@ function FileMediaItem({
return getFileUrl(workspaceId, viewId, fileId);
}, [file.url, workspaceId, viewId]);
const authenticatedFileUrl = useAuthenticatedImage(fileUrl);
const [hover, setHover] = useState(false);
return (
@@ -100,7 +103,7 @@ function FileMediaItem({
>
<img
draggable={false}
src={fileUrl}
src={authenticatedFileUrl}
alt={file.name}
className={'aspect-square h-full w-full overflow-hidden object-cover'}
/>

View File

@@ -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<string>('');
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<boolean> = 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 (

View File

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