153 lines
6.8 KiB
TypeScript
153 lines
6.8 KiB
TypeScript
import { ref } from 'vue'
|
|
import { createFileAccess, rewriteFileHtml, rewriteFileNodes } from './file-access-core'
|
|
import { isDevEnv } from './app'
|
|
|
|
export const fileAccessRevision = ref(0)
|
|
const client = createFileAccess({
|
|
appId: import.meta.env.VITE_FILE_ACCESS_APP_ID,
|
|
kid: import.meta.env.VITE_FILE_ACCESS_KEY_ID,
|
|
key: import.meta.env.VITE_FILE_ACCESS_KEY,
|
|
baseUrl: () => isDevEnv() ? (import.meta.env.VITE_IMAGE_DEV_PATH || import.meta.env.VITE_APP_DEV_SERVICE) : import.meta.env.VITE_APP_PROD_IMAGE,
|
|
allowedOrigins: [import.meta.env.VITE_IMAGE_DEV_PATH, import.meta.env.VITE_APP_DEV_SERVICE, import.meta.env.VITE_APP_PROD_IMAGE, import.meta.env.VITE_APP_PROD_SERVICE, ...(import.meta.env.VITE_FILE_ACCESS_ALLOWED_ORIGINS || '').split(',')],
|
|
})
|
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
let pending: Promise<void> | undefined
|
|
let lastSync = 0
|
|
const retried = new Map<string, number>()
|
|
export function fileUrl(source?: string | null): string {
|
|
void fileAccessRevision.value
|
|
try { return client.url(source) } catch { return '' }
|
|
}
|
|
export const accessFileUrl = (source?: string | null) => isProtectedFile(source) ? fileUrl(source) : (source || '')
|
|
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
|
|
export const originalFileUrl = (source?: string | null) => client.strip(source)
|
|
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
|
|
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
|
|
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
|
|
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
|
|
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
|
|
export const isProtectedFile = (source?: string | null) => { try { return client.protected(source) } catch { return false } }
|
|
export async function refreshFileAccess(force = false): Promise<void> {
|
|
if (pending) return pending
|
|
if (!force && Date.now() - lastSync < 60000) return
|
|
pending = (async () => {
|
|
const started = Date.now()
|
|
try {
|
|
const result: any = await readAccessInfo()
|
|
const info = result?.data || result
|
|
if (info?.serverTime) client.synchronize(Number(info.serverTime), Number(info.ttlSeconds), Date.now() - started)
|
|
lastSync = Date.now()
|
|
} catch { /* Retain the last clock offset and signed access; never fall back to a bare URL. */ }
|
|
finally { fileAccessRevision.value++; pending = undefined }
|
|
})()
|
|
return pending
|
|
}
|
|
export function startFileAccess() {
|
|
if (!timer) timer = setInterval(() => { fileAccessRevision.value++ }, 60000)
|
|
void refreshFileAccess()
|
|
}
|
|
export function stopFileAccess() { if (timer) clearInterval(timer); timer = undefined }
|
|
export async function retryFileAccess(source?: string | null) {
|
|
if (!isProtectedFile(source)) return false
|
|
const raw = originalFileUrl(source), previous = retried.get(raw) || 0
|
|
if (Date.now() - previous < 30 * 60 * 1000) return false
|
|
if (retried.size >= 2048) retried.clear()
|
|
retried.set(raw, Date.now())
|
|
const previousUrl = fileUrl(source)
|
|
await refreshFileAccess(true)
|
|
client.clear()
|
|
if (fileUrl(source) === previousUrl) {
|
|
await new Promise(resolve => setTimeout(resolve, 1100))
|
|
client.clear()
|
|
}
|
|
fileAccessRevision.value++
|
|
return true
|
|
}
|
|
export function installFileAccess(app: any) {
|
|
if (typeof uni.addInterceptor === 'function') {
|
|
uni.addInterceptor('uploadFile', {
|
|
invoke(options: any) {
|
|
options.header = { ...(options.header || {}), ...fileUploadHeaders(options.url) }
|
|
},
|
|
})
|
|
}
|
|
Object.assign(app.config.globalProperties, {
|
|
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
|
|
})
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('click', event => {
|
|
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
|
const href = anchor?.getAttribute('href')
|
|
if (event.defaultPrevented || !href || !isProtectedFile(href)) return
|
|
event.preventDefault()
|
|
event.stopImmediatePropagation()
|
|
openFileLink(href)
|
|
}, true)
|
|
}
|
|
app.mixin({
|
|
onShow() { startFileAccess() },
|
|
})
|
|
uni.onNetworkStatusChange(({ isConnected }: any) => {
|
|
if (isConnected) { retried.clear(); void refreshFileAccess(true) }
|
|
})
|
|
}
|
|
|
|
function readAccessInfo(): Promise<any> {
|
|
return new Promise((resolve, reject) => uni.request({
|
|
url: client.resolve('/admin/sys-file/oss/access-info'), method: 'GET', timeout: 5000,
|
|
success: (response: any) => response.statusCode === 200 ? resolve(response.data) : reject(new Error('File clock unavailable')),
|
|
fail: reject,
|
|
}))
|
|
}
|
|
/** All preview entrypoints sign at the moment of opening, including already-open pages. */
|
|
export function previewFiles(options: any) {
|
|
const values = { ...options, urls: fileUrls(options.urls) }
|
|
if (typeof options.current === 'string') values.current = fileUrl(options.current)
|
|
return uni.previewImage(values)
|
|
}
|
|
/** One clock/signature retry. Only successful downloads are delivered to the caller. */
|
|
export function downloadFile(options: any) {
|
|
const run = (retry: boolean) => uni.downloadFile({
|
|
...options, url: fileUrl(options.url),
|
|
success: async (response: any) => {
|
|
if (response.statusCode === 200) { options.success?.(response); options.complete?.(response); return }
|
|
if (retry && response.statusCode === 403 && isProtectedFile(options.url)) {
|
|
await refreshFileAccess(true); client.clear(); run(false); return
|
|
}
|
|
const error = { errMsg: '文件下载失败', statusCode: response.statusCode }
|
|
options.fail?.(error); options.complete?.(error)
|
|
if (!options.fail) uni.showToast({ title: error.errMsg, icon: 'none' })
|
|
},
|
|
complete: undefined,
|
|
fail: (error: any) => { options.fail?.(error); options.complete?.(error) },
|
|
})
|
|
return run(true)
|
|
}
|
|
|
|
/** Open owned rich-text file links without navigating the browser to the file endpoint. */
|
|
export function openFileLink(source: string) {
|
|
const match = /[?&]fileName=([^&#]*)/.exec(resolveFileUrl(source))
|
|
const filename = match ? decodeURIComponent(match[1].replace(/\+/g, ' ')) : '文件'
|
|
if (/\.(?:jpe?g|png|gif|webp|bmp|svg|ico)$/i.test(filename)) {
|
|
return previewFiles({ urls: [source] })
|
|
}
|
|
return downloadFile({
|
|
url: source,
|
|
success: (response: any) => {
|
|
if (typeof document !== 'undefined') {
|
|
const anchor = document.createElement('a')
|
|
anchor.href = response.tempFilePath
|
|
anchor.download = filename
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
anchor.remove()
|
|
} else {
|
|
uni.openDocument({
|
|
filePath: response.tempFilePath, showMenu: true,
|
|
fail: () => uni.showToast({ title: '无法打开此文件', icon: 'none' }),
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|