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

This commit is contained in:
2026-05-10 22:30:27 +02:00
parent 1ed3afd2e2
commit 76ce4cf900
5 changed files with 129 additions and 23 deletions
@@ -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
}
}