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 { paperlessApi } from '../api/paperless';
|
||||||
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless';
|
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
|
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
|
||||||
import DocumentSearchModal from './DocumentSearchModal';
|
import DocumentSearchModal from './DocumentSearchModal';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
@@ -468,7 +469,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={14} style={{ height: '100%' }}>
|
<Col span={14} style={{ height: '100%' }}>
|
||||||
<iframe
|
<AuthIframe
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
||||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
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 { SearchOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
import { paperlessApi } from '../api/paperless';
|
import { paperlessApi } from '../api/paperless';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
|
import { AuthImage } from '../utils/auth-resource';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -88,12 +89,11 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
|||||||
>
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<Image
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
|
||||||
width={80}
|
width={80}
|
||||||
height={110}
|
height={110}
|
||||||
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
||||||
preview={false}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={doc.title}
|
title={doc.title}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { ReloadOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { posteingangApi } from '../api/posteingang';
|
import { posteingangApi } from '../api/posteingang';
|
||||||
import type { PosteingangDocument } from '../api/posteingang';
|
import type { PosteingangDocument } from '../api/posteingang';
|
||||||
import DocumentEditModal from '../components/DocumentEditModal';
|
import DocumentEditModal from '../components/DocumentEditModal';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
|
import { AuthImage } from '../utils/auth-resource';
|
||||||
|
|
||||||
export default function ManuellBearbeitenPage() {
|
export default function ManuellBearbeitenPage() {
|
||||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||||
@@ -55,10 +56,9 @@ export default function ManuellBearbeitenPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const popoverContent = (id: number) => (
|
const popoverContent = (id: number) => (
|
||||||
<Image
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||||
width={400}
|
width={400}
|
||||||
preview={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,10 +69,9 @@ export default function ManuellBearbeitenPage() {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (_: any, record: PosteingangDocument) => (
|
render: (_: any, record: PosteingangDocument) => (
|
||||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||||
<Image
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||||
width={100}
|
width={100}
|
||||||
preview={false}
|
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { ReloadOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { posteingangApi } from '../api/posteingang';
|
import { posteingangApi } from '../api/posteingang';
|
||||||
@@ -55,10 +56,9 @@ export default function PosteingangPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const popoverContent = (id: number) => (
|
const popoverContent = (id: number) => (
|
||||||
<Image
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
||||||
width={400}
|
width={400}
|
||||||
preview={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,10 +69,9 @@ export default function PosteingangPage() {
|
|||||||
width: 150,
|
width: 150,
|
||||||
render: (_: any, record: PosteingangDocument) => (
|
render: (_: any, record: PosteingangDocument) => (
|
||||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||||
<Image
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||||
width={100}
|
width={100}
|
||||||
preview={false}
|
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</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