mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-28 10:18:47 +08:00
fix: attach token when requesting appflowy image/file
This commit is contained in:
15
.github/workflows/web_docker.yml
vendored
15
.github/workflows/web_docker.yml
vendored
@@ -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 }}
|
||||
|
||||
50
src/components/_shared/hooks/useAuthenticatedImage.ts
Normal file
50
src/components/_shared/hooks/useAuthenticatedImage.ts
Normal 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 || '';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user