feat: implement authenticated resource handling for image and PDF previews via AuthImage and AuthIframe components
Build and Push Multi-Platform Images / build-and-push (push) Successful in 17s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 17s
This commit is contained in:
@@ -9,6 +9,7 @@ import type { Client } from '../api/inbox';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
|
||||
import DocumentSearchModal from './DocumentSearchModal';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -468,7 +469,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`, '_blank');
|
||||
openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -508,9 +509,9 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
</Form>
|
||||
</Col>
|
||||
<Col span={14} style={{ height: '100%' }}>
|
||||
<iframe
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
<AuthIframe
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
title="PDF Preview"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal, Input, List, Image, Typography, Space, Pagination, Spin, Button } from 'antd';
|
||||
import { Modal, Input, List, Typography, Space, Pagination, Spin, Button } from 'antd';
|
||||
import { SearchOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { AuthImage } from '../utils/auth-resource';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -88,12 +89,11 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Image
|
||||
<AuthImage
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
|
||||
width={80}
|
||||
height={110}
|
||||
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
||||
preview={false}
|
||||
/>
|
||||
}
|
||||
title={doc.title}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
|
||||
import { Table, Popover, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
import type { PosteingangDocument } from '../api/posteingang';
|
||||
import DocumentEditModal from '../components/DocumentEditModal';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { AuthImage } from '../utils/auth-resource';
|
||||
|
||||
export default function ManuellBearbeitenPage() {
|
||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||
@@ -55,10 +56,9 @@ export default function ManuellBearbeitenPage() {
|
||||
};
|
||||
|
||||
const popoverContent = (id: number) => (
|
||||
<Image
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||
width={400}
|
||||
preview={false}
|
||||
<AuthImage
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||
width={400}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -69,10 +69,9 @@ export default function ManuellBearbeitenPage() {
|
||||
width: 150,
|
||||
render: (_: any, record: PosteingangDocument) => (
|
||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||
<Image
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||
<AuthImage
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||
width={100}
|
||||
preview={false}
|
||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
|
||||
import { Table, Popover, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
|
||||
import { AuthImage } from '../utils/auth-resource';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
@@ -55,10 +56,9 @@ export default function PosteingangPage() {
|
||||
};
|
||||
|
||||
const popoverContent = (id: number) => (
|
||||
<Image
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||
width={400}
|
||||
preview={false}
|
||||
<AuthImage
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||
width={400}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -69,10 +69,9 @@ export default function PosteingangPage() {
|
||||
width: 150,
|
||||
render: (_: any, record: PosteingangDocument) => (
|
||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||
<Image
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||
<AuthImage
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||
width={100}
|
||||
preview={false}
|
||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { getAccessToken } from '../auth/oidc';
|
||||
|
||||
export function useAuthUrl(url: string | null | undefined): string | null {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let objectUrl: string | undefined;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (cancelled) return;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (cancelled || !res.ok) return;
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
} catch {
|
||||
// image/resource just won't show
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setBlobUrl(null);
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
interface AuthImageProps {
|
||||
src: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
style?: CSSProperties;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export function AuthImage({ src, width, height, style, alt = '' }: AuthImageProps) {
|
||||
const blobUrl = useAuthUrl(src);
|
||||
if (!blobUrl) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height: height ?? width,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: style?.borderRadius ?? 0,
|
||||
}}
|
||||
>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={blobUrl} width={width} height={height} style={style} alt={alt} />;
|
||||
}
|
||||
|
||||
interface AuthIframeProps {
|
||||
src: string;
|
||||
style?: CSSProperties;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function AuthIframe({ src, style, title }: AuthIframeProps) {
|
||||
const hashIdx = src.indexOf('#');
|
||||
const cleanUrl = hashIdx >= 0 ? src.slice(0, hashIdx) : src;
|
||||
const hash = hashIdx >= 0 ? src.slice(hashIdx) : '';
|
||||
|
||||
const blobUrl = useAuthUrl(cleanUrl);
|
||||
|
||||
if (!blobUrl) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <iframe src={`${blobUrl}${hash}`} style={{ border: 'none', ...style }} title={title} />;
|
||||
}
|
||||
|
||||
export async function openAuthUrl(url: string): Promise<void> {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user