@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/.prettierignore +20 -0
- package/.vscode/mcp.json +8 -0
- package/AGENTS.md +432 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +481 -0
- package/cli/plugins.ts +14 -0
- package/client/agent-annotations-host.ts +19 -0
- package/client/app.ts +38 -0
- package/client/components/back-button.tsx +49 -0
- package/client/components/breadcrumbs.tsx +167 -0
- package/client/components/inbox-badge.ts +49 -0
- package/client/components/inbox-button.tsx +132 -0
- package/client/components/inbox-header-button.tsx +25 -0
- package/client/components/loading.tsx +35 -0
- package/client/components/page-container.tsx +24 -0
- package/client/components/page-header.tsx +33 -0
- package/client/components/route-child-page.tsx +121 -0
- package/client/components/route-dialog.tsx +10 -0
- package/client/components/route-drawer.tsx +10 -0
- package/client/components/route-overlay.tsx +190 -0
- package/client/components/ui/alert.tsx +75 -0
- package/client/components/ui/badge.tsx +51 -0
- package/client/components/ui/breadcrumb.tsx +121 -0
- package/client/components/ui/button.tsx +58 -0
- package/client/components/ui/collapsible.tsx +19 -0
- package/client/components/ui/context-menu.tsx +271 -0
- package/client/components/ui/dialog.tsx +160 -0
- package/client/components/ui/dropdown-menu.tsx +270 -0
- package/client/components/ui/empty.tsx +103 -0
- package/client/components/ui/field.tsx +236 -0
- package/client/components/ui/input-group.tsx +158 -0
- package/client/components/ui/input.tsx +20 -0
- package/client/components/ui/kbd.tsx +26 -0
- package/client/components/ui/label.tsx +18 -0
- package/client/components/ui/popover.tsx +89 -0
- package/client/components/ui/select.tsx +200 -0
- package/client/components/ui/separator.tsx +22 -0
- package/client/components/ui/sheet.tsx +137 -0
- package/client/components/ui/sidebar.tsx +723 -0
- package/client/components/ui/skeleton.tsx +13 -0
- package/client/components/ui/spinner.tsx +20 -0
- package/client/components/ui/tabs.tsx +79 -0
- package/client/components/ui/textarea.tsx +17 -0
- package/client/components/ui/toast.tsx +231 -0
- package/client/components/ui/tooltip.tsx +63 -0
- package/client/components/use-route-overlay.ts +26 -0
- package/client/config/api.ts +13 -0
- package/client/config/app.ts +8 -0
- package/client/config/auth.ts +18 -0
- package/client/config/index.ts +17 -0
- package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
- package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
- package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
- package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
- package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
- package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
- package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
- package/client/extensions/nocobase-file-component-ui/README.md +13 -0
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
- package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
- package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
- package/client/extensions/nocobase-inbox/frame.ts +31 -0
- package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
- package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
- package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
- package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
- package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
- package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
- package/client/extensions/nocobase-inbox/model.ts +428 -0
- package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
- package/client/extensions/nocobase-inbox/registry.ts +303 -0
- package/client/extensions/nocobase-inbox/source.ts +117 -0
- package/client/hooks/use-mobile.ts +21 -0
- package/client/index.tsx +60 -0
- package/client/layouts/app-layout.tsx +105 -0
- package/client/layouts/components/app-brand.tsx +49 -0
- package/client/layouts/components/app-sidebar.tsx +196 -0
- package/client/layouts/components/header-actions.tsx +50 -0
- package/client/layouts/components/language-switcher.tsx +95 -0
- package/client/layouts/components/layout-header.tsx +17 -0
- package/client/layouts/components/navigation-menu.tsx +411 -0
- package/client/layouts/components/sidebar-footer.tsx +107 -0
- package/client/layouts/components/surface-empty.tsx +35 -0
- package/client/layouts/components/user-menu.tsx +127 -0
- package/client/layouts/index.ts +2 -0
- package/client/layouts/settings-layout.tsx +172 -0
- package/client/layouts/use-sidebar-preference.ts +57 -0
- package/client/lib/constants.ts +5 -0
- package/client/lib/toaster.ts +56 -0
- package/client/lib/utils.ts +1 -0
- package/client/locales/en-US.ts +178 -0
- package/client/locales/index.ts +9 -0
- package/client/locales/zh-CN.ts +161 -0
- package/client/pages/auth/device.tsx +41 -0
- package/client/pages/auth/forgot-password.tsx +39 -0
- package/client/pages/auth/login.tsx +66 -0
- package/client/pages/auth/register.tsx +46 -0
- package/client/pages/auth/reset-password.tsx +38 -0
- package/client/pages/auth/shared.tsx +111 -0
- package/client/pages/home.tsx +16 -0
- package/client/pages/inbox.tsx +12 -0
- package/client/plugins.ts +41 -0
- package/client/react-providers.ts +27 -0
- package/client/route-overrides.ts +9 -0
- package/client/routes.ts +75 -0
- package/client/routing/app-router.tsx +127 -0
- package/client/routing/client-route.tsx +129 -0
- package/client/routing/route-context.tsx +85 -0
- package/client/routing/route-navigation.ts +153 -0
- package/client/routing/route-tree.tsx +42 -0
- package/client/routing/standalone-page-layout.tsx +15 -0
- package/client/runtime.ts +26 -0
- package/client/service-provider.ts +40 -0
- package/client/source-extensions.ts +19 -0
- package/client/startup.tsx +22 -0
- package/client/styles.css +122 -0
- package/client/theme/index.ts +4 -0
- package/client/theme/theme-context.ts +12 -0
- package/client/theme/theme-preferences.ts +84 -0
- package/client/theme/theme-presets.ts +7 -0
- package/client/theme/theme-provider.tsx +106 -0
- package/client/theme/theme-settings.tsx +176 -0
- package/client/theme/themes/compact.css +140 -0
- package/client/theme/themes/default.css +117 -0
- package/client/vite-env.d.ts +5 -0
- package/components.json +27 -0
- package/config.example.yml +172 -0
- package/database/main/migrations/.gitkeep +0 -0
- package/database/main/migrations/202610060001_create_device_code.ts +37 -0
- package/database/main/seeds/.gitkeep +0 -0
- package/database/tsconfig.json +4 -0
- package/ecosystem.config.js +35 -0
- package/eslint.config.js +24 -0
- package/index.html +100 -0
- package/package.json +200 -3
- package/playwright.config.ts +10 -0
- package/public/assets/favicon.ico +0 -0
- package/public/assets/logo-dark.png +0 -0
- package/public/assets/logo-mark-dark.png +0 -0
- package/public/assets/logo-mark.png +0 -0
- package/public/assets/logo.png +0 -0
- package/server/app.ts +50 -0
- package/server/config/ai.ts +27 -0
- package/server/config/api.ts +8 -0
- package/server/config/app.ts +40 -0
- package/server/config/auth.ts +36 -0
- package/server/config/authorization.ts +18 -0
- package/server/config/caching.ts +21 -0
- package/server/config/database.ts +22 -0
- package/server/config/drive.ts +30 -0
- package/server/config/i18n.ts +17 -0
- package/server/config/index.ts +73 -0
- package/server/config/jobs.ts +27 -0
- package/server/config/logging.ts +23 -0
- package/server/config/notification.ts +11 -0
- package/server/config/queue.ts +28 -0
- package/server/config/scheduler.ts +23 -0
- package/server/config/secrets.ts +10 -0
- package/server/config/server.ts +26 -0
- package/server/config/session.ts +60 -0
- package/server/config/snowflake.ts +21 -0
- package/server/config/spa.ts +24 -0
- package/server/config/users.ts +11 -0
- package/server/config/workflow.ts +24 -0
- package/server/config.ts +24 -0
- package/server/embedded.ts +23 -0
- package/server/locales/en-US.ts +10 -0
- package/server/locales/index.ts +10 -0
- package/server/locales/zh-CN.ts +5 -0
- package/server/plugins.ts +41 -0
- package/server/providers/index.ts +5 -0
- package/server/routes/index.ts +6 -0
- package/server/runtime.ts +24 -0
- package/server/standalone.ts +29 -0
- package/server/tsconfig.json +4 -0
- package/tailwind.config.mjs +59 -0
- package/tests/components/app-layout-routes.test.tsx +132 -0
- package/tests/components/app-title.test.tsx +57 -0
- package/tests/components/auth-i18n.test.tsx +87 -0
- package/tests/components/back-button.test.tsx +80 -0
- package/tests/components/file-preview.test.tsx +101 -0
- package/tests/components/header-hover.test.tsx +87 -0
- package/tests/components/inbox.test.tsx +139 -0
- package/tests/components/language-switcher.test.tsx +298 -0
- package/tests/components/layout-containers.test.tsx +15 -0
- package/tests/components/layout-i18n.test.tsx +187 -0
- package/tests/components/loading.test.tsx +25 -0
- package/tests/components/navigation-menu.test.tsx +355 -0
- package/tests/components/page-harness.test.tsx +318 -0
- package/tests/components/primitive-labels.test.tsx +80 -0
- package/tests/components/sidebar-permissions.test.tsx +110 -0
- package/tests/components/sidebar-preference.test.tsx +42 -0
- package/tests/fixtures/theme-tokens.html +12 -0
- package/tests/fixtures/theme-tokens.tsx +111 -0
- package/tests/logic/account-permissions.test.tsx +204 -0
- package/tests/logic/app-locale-coverage.test.ts +136 -0
- package/tests/logic/app-server.test.ts +1589 -0
- package/tests/logic/asset-url-build.test.ts +106 -0
- package/tests/logic/client-auth.test.tsx +122 -0
- package/tests/logic/client-plugin-registry.test.ts +63 -0
- package/tests/logic/client-route-recovery.test.tsx +80 -0
- package/tests/logic/client-routes.test.ts +126 -0
- package/tests/logic/client-runtime.test.ts +112 -0
- package/tests/logic/client-settings.test.tsx +841 -0
- package/tests/logic/client-shell.test.tsx +466 -0
- package/tests/logic/client-theme.test.tsx +441 -0
- package/tests/logic/config.test.ts +197 -0
- package/tests/logic/dev-client-watch.test.ts +108 -0
- package/tests/logic/dev-routes-production-build.test.ts +168 -0
- package/tests/logic/device-code-migration.test.ts +70 -0
- package/tests/logic/lifecycle.test.ts +221 -0
- package/tests/logic/plugin-commands.test.ts +69 -0
- package/tests/logic/plugin-page-authz.test.ts +76 -0
- package/tests/logic/route-navigation.test.ts +66 -0
- package/tests/logic/route-overlay.test.tsx +353 -0
- package/tests/logic/server-imports.test.ts +88 -0
- package/tests/logic/tailwind-sources.test.ts +151 -0
- package/tests/logic/template-package-name.test.ts +88 -0
- package/tests/logic/theme-preferences.test.ts +86 -0
- package/tests/logic/theme-tokens.test.ts +248 -0
- package/tests/logic/toaster.test.tsx +108 -0
- package/tests/logic/user-menu-sign-out.test.tsx +98 -0
- package/tests/setup/client-config.ts +27 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +12 -0
- package/tsconfig.server.json +19 -0
- package/vite.config.ts +92 -0
- package/vitest.config.ts +37 -0
- package/README.md +0 -3
package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { messageKey } from '../../lib/message-key.js';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import type { FileRecord } from '../../types';
|
|
4
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
|
5
|
+
import ReactMarkdown from 'react-markdown';
|
|
6
|
+
import remarkGfm from 'remark-gfm';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
resolveOfficeEmbedUrl,
|
|
10
|
+
resolveOfficeOpenXmlFormat,
|
|
11
|
+
type FilePreviewKind,
|
|
12
|
+
} from '../../lib/file-preview';
|
|
13
|
+
import { Button } from '#components/ui/button';
|
|
14
|
+
import { resolveSafeFileUrl } from '../../lib/file-url';
|
|
15
|
+
import { FileThumbnail } from '../file-thumbnail';
|
|
16
|
+
|
|
17
|
+
import { OfficeOpenXmlPreview } from './office-open-xml-preview';
|
|
18
|
+
|
|
19
|
+
const OFFICE_PREVIEW_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
export interface FilePreviewContentProps {
|
|
22
|
+
readonly file: FileRecord;
|
|
23
|
+
readonly kind: FilePreviewKind;
|
|
24
|
+
readonly url?: string;
|
|
25
|
+
readonly text?: string;
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
readonly onDownload?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FilePreviewContent(
|
|
31
|
+
inputProps: FilePreviewContentProps,
|
|
32
|
+
): ReactElement {
|
|
33
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
34
|
+
const { file, kind, url, text, error, onDownload } = inputProps;
|
|
35
|
+
|
|
36
|
+
const format =
|
|
37
|
+
kind === 'ooxml' ? resolveOfficeOpenXmlFormat(file) : undefined;
|
|
38
|
+
if (format) {
|
|
39
|
+
return (
|
|
40
|
+
<OfficeOpenXmlPreview
|
|
41
|
+
key={`${file.id}:${format}:${url ?? ''}:${error ?? ''}`}
|
|
42
|
+
file={file}
|
|
43
|
+
format={format}
|
|
44
|
+
url={url}
|
|
45
|
+
error={error}
|
|
46
|
+
onDownload={onDownload}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (kind === 'office') {
|
|
52
|
+
return (
|
|
53
|
+
<OfficePreview
|
|
54
|
+
file={file}
|
|
55
|
+
url={url}
|
|
56
|
+
error={error}
|
|
57
|
+
onDownload={onDownload}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (error)
|
|
62
|
+
return (
|
|
63
|
+
<div role='alert'>{t(messageKey(error), { defaultValue: error })}</div>
|
|
64
|
+
);
|
|
65
|
+
if (!url)
|
|
66
|
+
return kind === 'unsupported' ? (
|
|
67
|
+
<DownloadFallback file={file} onDownload={onDownload} />
|
|
68
|
+
) : (
|
|
69
|
+
<div role='status'>
|
|
70
|
+
{t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
switch (kind) {
|
|
74
|
+
case 'image':
|
|
75
|
+
return <ImagePreview file={file} url={url} onDownload={onDownload} />;
|
|
76
|
+
case 'pdf':
|
|
77
|
+
return (
|
|
78
|
+
<iframe title={file.filename} src={url} className='h-[70vh] w-full' />
|
|
79
|
+
);
|
|
80
|
+
case 'audio':
|
|
81
|
+
return <audio controls src={url} className='w-full' />;
|
|
82
|
+
case 'video':
|
|
83
|
+
return <video controls src={url} className='max-h-[70vh] max-w-full' />;
|
|
84
|
+
case 'markdown':
|
|
85
|
+
return <MarkdownPreview text={text} />;
|
|
86
|
+
case 'text':
|
|
87
|
+
return (
|
|
88
|
+
<pre className='max-h-[70vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-sm'>
|
|
89
|
+
{text ??
|
|
90
|
+
t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
91
|
+
</pre>
|
|
92
|
+
);
|
|
93
|
+
default:
|
|
94
|
+
return <DownloadFallback file={file} onDownload={onDownload} />;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function MarkdownPreview(inputProps: { readonly text?: string }): ReactElement {
|
|
99
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
100
|
+
const { text } = inputProps;
|
|
101
|
+
|
|
102
|
+
if (text === undefined)
|
|
103
|
+
return (
|
|
104
|
+
<div role='status'>
|
|
105
|
+
{t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
return (
|
|
109
|
+
<article className='prose max-h-[70vh] max-w-none overflow-auto rounded-md bg-muted/30 p-4'>
|
|
110
|
+
<ReactMarkdown
|
|
111
|
+
remarkPlugins={[remarkGfm]}
|
|
112
|
+
urlTransform={(url) => resolveSafeFileUrl(url) ?? ''}
|
|
113
|
+
components={{
|
|
114
|
+
a: ({ children, ...props }) => (
|
|
115
|
+
<a {...props} target='_blank' rel='noreferrer'>
|
|
116
|
+
{children}
|
|
117
|
+
</a>
|
|
118
|
+
),
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{text}
|
|
122
|
+
</ReactMarkdown>
|
|
123
|
+
</article>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ImagePreview(inputProps: {
|
|
128
|
+
readonly file: FileRecord;
|
|
129
|
+
readonly url: string;
|
|
130
|
+
readonly onDownload?: () => void;
|
|
131
|
+
}): ReactElement {
|
|
132
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
133
|
+
const { file, url, onDownload } = inputProps;
|
|
134
|
+
|
|
135
|
+
// The browser decodes the image itself, so its error event is the only signal
|
|
136
|
+
// that a corrupt or mislabelled file would otherwise leave as a broken icon.
|
|
137
|
+
// Remember which URL failed rather than a flag, so a new URL is tried again.
|
|
138
|
+
const [failedUrl, setFailedUrl] = useState<string>();
|
|
139
|
+
if (url === failedUrl)
|
|
140
|
+
return (
|
|
141
|
+
<DownloadFallback
|
|
142
|
+
file={file}
|
|
143
|
+
message={t('files.imageFailed', {
|
|
144
|
+
defaultValue:
|
|
145
|
+
'This image could not be displayed. It may be corrupted or in an unsupported format.',
|
|
146
|
+
})}
|
|
147
|
+
thumbnailIconOnly
|
|
148
|
+
onDownload={onDownload}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
return (
|
|
152
|
+
<img
|
|
153
|
+
src={url}
|
|
154
|
+
alt={file.filename}
|
|
155
|
+
className='max-h-[70vh] max-w-full object-contain'
|
|
156
|
+
onError={() => setFailedUrl(url)}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function OfficePreview(inputProps: {
|
|
162
|
+
readonly file: FileRecord;
|
|
163
|
+
readonly url?: string;
|
|
164
|
+
readonly error?: string;
|
|
165
|
+
readonly onDownload?: () => void;
|
|
166
|
+
}): ReactElement {
|
|
167
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
168
|
+
const { file, url, error, onDownload } = inputProps;
|
|
169
|
+
|
|
170
|
+
const [failed, setFailed] = useState(false);
|
|
171
|
+
const [loaded, setLoaded] = useState(false);
|
|
172
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
173
|
+
const embedUrl = url ? resolveOfficeEmbedUrl(url) : undefined;
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const iframe = iframeRef.current;
|
|
176
|
+
if (!iframe || !embedUrl) return undefined;
|
|
177
|
+
const handleError = () => setFailed(true);
|
|
178
|
+
iframe.addEventListener('error', handleError);
|
|
179
|
+
return () => iframe.removeEventListener('error', handleError);
|
|
180
|
+
}, [embedUrl]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!embedUrl || loaded) return undefined;
|
|
183
|
+
const timeout = window.setTimeout(
|
|
184
|
+
() => setFailed(true),
|
|
185
|
+
OFFICE_PREVIEW_TIMEOUT_MS,
|
|
186
|
+
);
|
|
187
|
+
return () => window.clearTimeout(timeout);
|
|
188
|
+
}, [embedUrl, loaded]);
|
|
189
|
+
if (!url && !error)
|
|
190
|
+
return (
|
|
191
|
+
<div role='status'>
|
|
192
|
+
{t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
if (!embedUrl || failed) {
|
|
196
|
+
return (
|
|
197
|
+
<DownloadFallback
|
|
198
|
+
file={file}
|
|
199
|
+
message={
|
|
200
|
+
(error ? t(messageKey(error), { defaultValue: error }) : undefined) ??
|
|
201
|
+
(failed
|
|
202
|
+
? t('files.officeFailed', {
|
|
203
|
+
defaultValue: 'Office Online could not load this file.',
|
|
204
|
+
})
|
|
205
|
+
: t('files.officeUrl', {
|
|
206
|
+
defaultValue:
|
|
207
|
+
'Office Online requires an internet-accessible absolute file URL.',
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
onDownload={onDownload}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return (
|
|
215
|
+
<iframe
|
|
216
|
+
ref={iframeRef}
|
|
217
|
+
title={file.filename}
|
|
218
|
+
src={embedUrl}
|
|
219
|
+
className='h-[70vh] w-full'
|
|
220
|
+
onLoad={() => setLoaded(true)}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function DownloadFallback(inputProps: {
|
|
226
|
+
readonly file: FileRecord;
|
|
227
|
+
readonly message?: string;
|
|
228
|
+
readonly thumbnailIconOnly?: boolean;
|
|
229
|
+
readonly onDownload?: () => void;
|
|
230
|
+
}): ReactElement {
|
|
231
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
232
|
+
const {
|
|
233
|
+
file,
|
|
234
|
+
message = t('files.previewUnavailable', {
|
|
235
|
+
defaultValue: 'Preview is unavailable for this file type.',
|
|
236
|
+
}),
|
|
237
|
+
thumbnailIconOnly = false,
|
|
238
|
+
onDownload,
|
|
239
|
+
} = inputProps;
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className='flex flex-col items-center gap-3 py-8'>
|
|
243
|
+
<div className='h-24 w-24'>
|
|
244
|
+
{/* After an image fails, a thumbnail would load the same bytes again. */}
|
|
245
|
+
<FileThumbnail file={file} iconOnly={thumbnailIconOnly} />
|
|
246
|
+
</div>
|
|
247
|
+
<p>{message}</p>
|
|
248
|
+
{onDownload ? (
|
|
249
|
+
<Button type='button' onClick={onDownload}>
|
|
250
|
+
{t('files.downloadFile', { defaultValue: 'Download file' })}
|
|
251
|
+
</Button>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '#components/ui/button';
|
|
5
|
+
import type { OfficeOpenXmlFormat } from '../../lib/file-preview.js';
|
|
6
|
+
import { fileUrlCredentials } from '../../lib/file-url.js';
|
|
7
|
+
import type { FileRecord } from '../../types.js';
|
|
8
|
+
import { FileThumbnail } from '../file-thumbnail.js';
|
|
9
|
+
|
|
10
|
+
// Covers the file request, the Viewer import and the first render together, so a request or renderer that never
|
|
11
|
+
// settles ends in the download fallback instead of an indefinite loading state.
|
|
12
|
+
const OFFICE_OPEN_XML_PREVIEW_TIMEOUT_MS = 180_000;
|
|
13
|
+
|
|
14
|
+
interface OfficeOpenXmlViewer {
|
|
15
|
+
load(source: string | ArrayBuffer): Promise<void>;
|
|
16
|
+
destroy(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OfficeOpenXmlPreviewProps {
|
|
20
|
+
readonly file: FileRecord;
|
|
21
|
+
readonly format: OfficeOpenXmlFormat;
|
|
22
|
+
readonly url?: string;
|
|
23
|
+
readonly error?: string;
|
|
24
|
+
readonly onDownload?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function OfficeOpenXmlPreview({
|
|
28
|
+
file,
|
|
29
|
+
format,
|
|
30
|
+
url,
|
|
31
|
+
error,
|
|
32
|
+
onDownload,
|
|
33
|
+
}: OfficeOpenXmlPreviewProps): ReactElement {
|
|
34
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
35
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const [loaded, setLoaded] = useState(false);
|
|
37
|
+
const [viewerError, setViewerError] = useState<string>();
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const host = hostRef.current;
|
|
41
|
+
if (!host || !url || error) return undefined;
|
|
42
|
+
|
|
43
|
+
let active = true;
|
|
44
|
+
let viewer: OfficeOpenXmlViewer | undefined;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const reportViewerError = (cause: unknown): void => {
|
|
47
|
+
if (!active || isAbortError(cause)) return;
|
|
48
|
+
// The first failure settles the preview; later ones, including the timeout, are ignored.
|
|
49
|
+
active = false;
|
|
50
|
+
const failedViewer = viewer;
|
|
51
|
+
viewer = undefined;
|
|
52
|
+
failedViewer?.destroy();
|
|
53
|
+
setViewerError(
|
|
54
|
+
cause instanceof OfficeOpenXmlPreviewError
|
|
55
|
+
? cause.message
|
|
56
|
+
: t('files.ooxmlLoadFailed', {
|
|
57
|
+
defaultValue: 'Unable to render this Office Open XML file.',
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
const timeout = window.setTimeout(() => {
|
|
62
|
+
reportViewerError(
|
|
63
|
+
new OfficeOpenXmlPreviewError(
|
|
64
|
+
t('files.ooxmlLoadTimedOut', {
|
|
65
|
+
defaultValue: `The preview did not finish loading within ${OFFICE_OPEN_XML_PREVIEW_TIMEOUT_MS / 60_000} minutes.`,
|
|
66
|
+
minutes: OFFICE_OPEN_XML_PREVIEW_TIMEOUT_MS / 60_000,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
controller.abort();
|
|
71
|
+
}, OFFICE_OPEN_XML_PREVIEW_TIMEOUT_MS);
|
|
72
|
+
|
|
73
|
+
void (async () => {
|
|
74
|
+
const data = await fetchOfficeOpenXml(url, controller.signal, t);
|
|
75
|
+
if (!active) return;
|
|
76
|
+
const createdViewer = await createOfficeOpenXmlViewer(
|
|
77
|
+
format,
|
|
78
|
+
host,
|
|
79
|
+
reportViewerError,
|
|
80
|
+
);
|
|
81
|
+
if (!active) {
|
|
82
|
+
createdViewer.destroy();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
viewer = createdViewer;
|
|
86
|
+
await viewer.load(data);
|
|
87
|
+
if (!active) return;
|
|
88
|
+
window.clearTimeout(timeout);
|
|
89
|
+
setLoaded(true);
|
|
90
|
+
})().catch(reportViewerError);
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
active = false;
|
|
94
|
+
window.clearTimeout(timeout);
|
|
95
|
+
controller.abort();
|
|
96
|
+
viewer?.destroy();
|
|
97
|
+
};
|
|
98
|
+
}, [error, format, t, url]);
|
|
99
|
+
|
|
100
|
+
const resolvedError = error ?? viewerError;
|
|
101
|
+
if (resolvedError) {
|
|
102
|
+
return (
|
|
103
|
+
<OfficeOpenXmlDownloadFallback
|
|
104
|
+
file={file}
|
|
105
|
+
message={resolvedError}
|
|
106
|
+
onDownload={onDownload}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!url) {
|
|
111
|
+
return (
|
|
112
|
+
<div role='status'>
|
|
113
|
+
{t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className='relative h-[70vh] min-h-0 w-full overflow-hidden bg-muted/30'>
|
|
120
|
+
{!loaded ? (
|
|
121
|
+
<div
|
|
122
|
+
role='status'
|
|
123
|
+
className='absolute inset-0 z-10 flex items-center justify-center bg-background'
|
|
124
|
+
>
|
|
125
|
+
{t('files.loadingPreview', { defaultValue: 'Loading preview...' })}
|
|
126
|
+
</div>
|
|
127
|
+
) : null}
|
|
128
|
+
<div
|
|
129
|
+
ref={hostRef}
|
|
130
|
+
className='h-full min-h-0 w-full overflow-hidden'
|
|
131
|
+
data-office-open-xml-format={format}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Carries a message that is already fit to show to the user. */
|
|
138
|
+
class OfficeOpenXmlPreviewError extends Error {}
|
|
139
|
+
|
|
140
|
+
async function fetchOfficeOpenXml(
|
|
141
|
+
url: string,
|
|
142
|
+
signal: AbortSignal,
|
|
143
|
+
t: ReturnType<typeof useTranslation>['t'],
|
|
144
|
+
): Promise<ArrayBuffer> {
|
|
145
|
+
const response = await fetch(url, {
|
|
146
|
+
credentials: fileUrlCredentials(url),
|
|
147
|
+
signal,
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new OfficeOpenXmlPreviewError(
|
|
151
|
+
t('files.previewRequestFailed', {
|
|
152
|
+
defaultValue: `Preview request failed (${response.status}).`,
|
|
153
|
+
status: response.status,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return await response.arrayBuffer();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isAbortError(cause: unknown): boolean {
|
|
161
|
+
return cause instanceof Error && cause.name === 'AbortError';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function createOfficeOpenXmlViewer(
|
|
165
|
+
format: OfficeOpenXmlFormat,
|
|
166
|
+
host: HTMLElement,
|
|
167
|
+
onError: (error: Error) => void,
|
|
168
|
+
): Promise<OfficeOpenXmlViewer> {
|
|
169
|
+
const commonOptions = {
|
|
170
|
+
mode: 'main' as const,
|
|
171
|
+
useGoogleFonts: false,
|
|
172
|
+
onError,
|
|
173
|
+
};
|
|
174
|
+
switch (format) {
|
|
175
|
+
case 'docx': {
|
|
176
|
+
const { DocxScrollViewer } = await import('@silurus/ooxml/docx');
|
|
177
|
+
return new DocxScrollViewer(host, {
|
|
178
|
+
...commonOptions,
|
|
179
|
+
enableTextSelection: true,
|
|
180
|
+
gap: 16,
|
|
181
|
+
progressiveLayout: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
case 'pptx': {
|
|
185
|
+
const { PptxScrollViewer } = await import('@silurus/ooxml/pptx');
|
|
186
|
+
return new PptxScrollViewer(host, {
|
|
187
|
+
...commonOptions,
|
|
188
|
+
enableTextSelection: true,
|
|
189
|
+
gap: 16,
|
|
190
|
+
progressiveLayout: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
case 'xlsx': {
|
|
194
|
+
const { XlsxViewer } = await import('@silurus/ooxml/xlsx');
|
|
195
|
+
return new XlsxViewer(host, {
|
|
196
|
+
...commonOptions,
|
|
197
|
+
showZoomSlider: true,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function OfficeOpenXmlDownloadFallback({
|
|
204
|
+
file,
|
|
205
|
+
message,
|
|
206
|
+
onDownload,
|
|
207
|
+
}: {
|
|
208
|
+
readonly file: FileRecord;
|
|
209
|
+
readonly message: string;
|
|
210
|
+
readonly onDownload?: () => void;
|
|
211
|
+
}): ReactElement {
|
|
212
|
+
const { t } = useTranslation('@nocobase/app-plugin-file');
|
|
213
|
+
return (
|
|
214
|
+
<div className='flex flex-col items-center gap-3 py-8'>
|
|
215
|
+
<div className='h-24 w-24'>
|
|
216
|
+
<FileThumbnail file={file} />
|
|
217
|
+
</div>
|
|
218
|
+
<p role='alert'>{message}</p>
|
|
219
|
+
{onDownload ? (
|
|
220
|
+
<Button type='button' onClick={onDownload}>
|
|
221
|
+
{t('files.downloadFile', { defaultValue: 'Download file' })}
|
|
222
|
+
</Button>
|
|
223
|
+
) : null}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { FileList } from './components/file-list';
|
|
2
|
+
export { FilePreviewDialog } from './components/file-preview-dialog';
|
|
3
|
+
export { FilePreviewField } from './components/file-preview-field';
|
|
4
|
+
export { FileThumbnail } from './components/file-thumbnail';
|
|
5
|
+
export { FileUploadField } from './components/file-upload-field';
|
|
6
|
+
export type * from './types';
|
|
7
|
+
export { clientFileRepositoryManagerToken } from '@nocobase/app-plugin-file/client';
|
|
8
|
+
export type { ClientFileRepository } from '@nocobase/app-plugin-file/client';
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { FileRecord } from '@nocobase/app-plugin-file/client';
|
|
2
|
+
|
|
3
|
+
export type FilePreviewKind =
|
|
4
|
+
| 'image'
|
|
5
|
+
| 'pdf'
|
|
6
|
+
| 'audio'
|
|
7
|
+
| 'video'
|
|
8
|
+
| 'text'
|
|
9
|
+
| 'markdown'
|
|
10
|
+
| 'ooxml'
|
|
11
|
+
| 'office'
|
|
12
|
+
| 'unsupported';
|
|
13
|
+
|
|
14
|
+
export type OfficeOpenXmlFormat = 'docx' | 'xlsx' | 'pptx';
|
|
15
|
+
|
|
16
|
+
const ACTIVE_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
17
|
+
'application/xhtml+xml',
|
|
18
|
+
'application/xml',
|
|
19
|
+
'image/svg+xml',
|
|
20
|
+
'text/html',
|
|
21
|
+
'text/xml',
|
|
22
|
+
]);
|
|
23
|
+
const ACTIVE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
24
|
+
'.htm',
|
|
25
|
+
'.html',
|
|
26
|
+
'.svg',
|
|
27
|
+
'.xhtml',
|
|
28
|
+
'.xml',
|
|
29
|
+
]);
|
|
30
|
+
const OFFICE_OPEN_XML_EXTENSIONS: Readonly<
|
|
31
|
+
Record<string, OfficeOpenXmlFormat>
|
|
32
|
+
> = {
|
|
33
|
+
'.docx': 'docx',
|
|
34
|
+
'.pptx': 'pptx',
|
|
35
|
+
'.xlsx': 'xlsx',
|
|
36
|
+
};
|
|
37
|
+
const OFFICE_OPEN_XML_MIME_TYPES: Readonly<
|
|
38
|
+
Record<string, OfficeOpenXmlFormat>
|
|
39
|
+
> = {
|
|
40
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
|
41
|
+
'pptx',
|
|
42
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
43
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
|
44
|
+
'docx',
|
|
45
|
+
};
|
|
46
|
+
const OFFICE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
47
|
+
'.doc',
|
|
48
|
+
'.odf',
|
|
49
|
+
'.odg',
|
|
50
|
+
'.odm',
|
|
51
|
+
'.odp',
|
|
52
|
+
'.ods',
|
|
53
|
+
'.odt',
|
|
54
|
+
'.otg',
|
|
55
|
+
'.oth',
|
|
56
|
+
'.otp',
|
|
57
|
+
'.ots',
|
|
58
|
+
'.ott',
|
|
59
|
+
'.ppt',
|
|
60
|
+
'.xls',
|
|
61
|
+
]);
|
|
62
|
+
const OFFICE_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
63
|
+
'application/msword',
|
|
64
|
+
'application/vnd.ms-excel',
|
|
65
|
+
'application/vnd.ms-powerpoint',
|
|
66
|
+
'application/vnd.oasis.opendocument.formula',
|
|
67
|
+
'application/vnd.oasis.opendocument.graphics',
|
|
68
|
+
'application/vnd.oasis.opendocument.graphics-template',
|
|
69
|
+
'application/vnd.oasis.opendocument.presentation',
|
|
70
|
+
'application/vnd.oasis.opendocument.presentation-template',
|
|
71
|
+
'application/vnd.oasis.opendocument.spreadsheet',
|
|
72
|
+
'application/vnd.oasis.opendocument.spreadsheet-template',
|
|
73
|
+
'application/vnd.oasis.opendocument.text',
|
|
74
|
+
'application/vnd.oasis.opendocument.text-master',
|
|
75
|
+
'application/vnd.oasis.opendocument.text-template',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export function fileExtension(filename: string): string {
|
|
79
|
+
const dot = filename.lastIndexOf('.');
|
|
80
|
+
return dot < 0 ? '' : filename.slice(dot).toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeMimeType(value: string): string {
|
|
84
|
+
return value.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Active markup runs script when a browser treats it as a document.
|
|
88
|
+
export function isActiveMarkupMimeType(value: string): boolean {
|
|
89
|
+
const mimeType = normalizeMimeType(value);
|
|
90
|
+
return ACTIVE_MIME_TYPES.has(mimeType) || mimeType.endsWith('+xml');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isSafeImagePreview(file: FileRecord): boolean {
|
|
94
|
+
const mimeType = normalizeMimeType(file.mimeType);
|
|
95
|
+
return (
|
|
96
|
+
mimeType.startsWith('image/') &&
|
|
97
|
+
mimeType !== 'image/svg+xml' &&
|
|
98
|
+
!ACTIVE_EXTENSIONS.has(fileExtension(file.filename))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveOfficeOpenXmlFormat(
|
|
103
|
+
file: FileRecord,
|
|
104
|
+
): OfficeOpenXmlFormat | undefined {
|
|
105
|
+
const mimeType = normalizeMimeType(file.mimeType);
|
|
106
|
+
const extension = fileExtension(file.filename);
|
|
107
|
+
if (
|
|
108
|
+
isActiveMarkupMimeType(mimeType) ||
|
|
109
|
+
ACTIVE_EXTENSIONS.has(extension) ||
|
|
110
|
+
OFFICE_EXTENSIONS.has(extension)
|
|
111
|
+
) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const extensionFormat = OFFICE_OPEN_XML_EXTENSIONS[extension];
|
|
116
|
+
if (extensionFormat) return extensionFormat;
|
|
117
|
+
return OFFICE_OPEN_XML_MIME_TYPES[mimeType];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveFilePreviewKind(file: FileRecord): FilePreviewKind {
|
|
121
|
+
const mimeType = normalizeMimeType(file.mimeType);
|
|
122
|
+
const extension = fileExtension(file.filename);
|
|
123
|
+
if (isActiveMarkupMimeType(mimeType) || ACTIVE_EXTENSIONS.has(extension)) {
|
|
124
|
+
return 'unsupported';
|
|
125
|
+
}
|
|
126
|
+
if (mimeType === 'text/markdown' || extension === '.md') return 'markdown';
|
|
127
|
+
if (resolveOfficeOpenXmlFormat(file)) return 'ooxml';
|
|
128
|
+
if (OFFICE_MIME_TYPES.has(mimeType) || OFFICE_EXTENSIONS.has(extension)) {
|
|
129
|
+
return 'office';
|
|
130
|
+
}
|
|
131
|
+
if (isSafeImagePreview(file)) return 'image';
|
|
132
|
+
if (mimeType === 'application/pdf' || extension === '.pdf') return 'pdf';
|
|
133
|
+
if (mimeType.startsWith('audio/')) return 'audio';
|
|
134
|
+
if (mimeType.startsWith('video/')) return 'video';
|
|
135
|
+
if (
|
|
136
|
+
mimeType.startsWith('text/') ||
|
|
137
|
+
mimeType === 'application/json' ||
|
|
138
|
+
extension === '.json'
|
|
139
|
+
) {
|
|
140
|
+
return 'text';
|
|
141
|
+
}
|
|
142
|
+
return 'unsupported';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resolveOfficeEmbedUrl(value: string): string | undefined {
|
|
146
|
+
let source: URL;
|
|
147
|
+
try {
|
|
148
|
+
source = new URL(value);
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
if (source.protocol !== 'http:' && source.protocol !== 'https:') {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (source.username || source.password) return undefined;
|
|
156
|
+
if (!isPublicOfficeHostname(source.hostname)) return undefined;
|
|
157
|
+
const embed = new URL('https://view.officeapps.live.com/op/embed.aspx');
|
|
158
|
+
embed.searchParams.set('src', source.toString());
|
|
159
|
+
return embed.toString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isPublicOfficeHostname(hostname: string): boolean {
|
|
163
|
+
const value = hostname
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.replace(/^\[|\]$/gu, '')
|
|
166
|
+
.replace(/\.$/u, '');
|
|
167
|
+
if (
|
|
168
|
+
!value ||
|
|
169
|
+
value === 'localhost' ||
|
|
170
|
+
value.endsWith('.localhost') ||
|
|
171
|
+
value.endsWith('.local') ||
|
|
172
|
+
value.endsWith('.internal') ||
|
|
173
|
+
value.endsWith('.lan') ||
|
|
174
|
+
!value.includes('.') ||
|
|
175
|
+
value === '::1' ||
|
|
176
|
+
value === '0:0:0:0:0:0:0:1' ||
|
|
177
|
+
value.includes(':')
|
|
178
|
+
) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const octets = value.split('.').map(Number);
|
|
182
|
+
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const [first = -1, second = -1] = octets;
|
|
186
|
+
return !(
|
|
187
|
+
first === 0 ||
|
|
188
|
+
first === 10 ||
|
|
189
|
+
first === 127 ||
|
|
190
|
+
(first === 169 && second === 254) ||
|
|
191
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
192
|
+
(first === 192 && second === 168)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function resolveSafeFileUrl(
|
|
2
|
+
value: string,
|
|
3
|
+
trustedBlobUrls: ReadonlySet<string> = new Set(),
|
|
4
|
+
): string | undefined {
|
|
5
|
+
const url = value.trim();
|
|
6
|
+
if (!url) return undefined;
|
|
7
|
+
if (trustedBlobUrls.has(url) && url.startsWith('blob:')) return url;
|
|
8
|
+
try {
|
|
9
|
+
const resolved = new URL(url, window.location.href);
|
|
10
|
+
return resolved.protocol === 'http:' || resolved.protocol === 'https:'
|
|
11
|
+
? url
|
|
12
|
+
: undefined;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fileUrlCredentials(url: string): RequestCredentials {
|
|
19
|
+
return new URL(url, window.location.href).origin === window.location.origin
|
|
20
|
+
? 'same-origin'
|
|
21
|
+
: 'omit';
|
|
22
|
+
}
|