fix:图片访问
fix:权限修改
This commit is contained in:
1 parent
dec9fd96e3
commit
d86bc810cc
69 files changed
+1264
-458
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import * as CryptoJS from 'crypto-js'
|
||||
|
||||
export const FILE_ACCESS_FIELDS = ['appId', 'kid', 'ts', 'sign']
|
||||
const FILE_PATHS = ['/sys-file/oss/file', '/admin/sys-file/oss/file', '/api/admin/sys-file/oss/file']
|
||||
export interface FileAccessOptions {
|
||||
appId: string; kid: string; key: string; baseUrl: () => string
|
||||
allowedOrigins?: string[]; now?: () => number
|
||||
}
|
||||
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
|
||||
const encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
const origin = (s: string) => {
|
||||
const match = /^(https?):\/\/([^/?#]+)/i.exec(s)
|
||||
if (!match || match[2].includes('@')) return ''
|
||||
return (match[1] + '://' + match[2]).toLowerCase().replace(match[1].toLowerCase() === 'https' ? /:443$/ : /:80$/, '')
|
||||
}
|
||||
export function fileAccessCanonical(file: string, app: string, kid: string, ts: number): string {
|
||||
const name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(file)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return ['v1', 'oss-file-read', app, kid, String(ts), name].join('\n')
|
||||
}
|
||||
export function fileAccessSignature(file: string, app: string, kid: string, ts: number, key: string): string {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
|
||||
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||
}
|
||||
export function createFileAccess(options: FileAccessOptions) {
|
||||
const now = options.now || Date.now
|
||||
let offset = 0, ttl = 1800
|
||||
const cache = new Map<string, { url: string; ts: number }>()
|
||||
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
|
||||
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||
function resolve(source?: string | null): string {
|
||||
const raw = typeof source === 'string' ? source.trim() : ''
|
||||
if (!raw || /^(?:data:|blob:|file:|wxfile:|content:|\/static\/|static\/|\/assets\/|\/src\/|@\/|#)/i.test(raw)) return raw
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw
|
||||
if (raw.startsWith('//')) return (base().startsWith('http:') ? 'http:' : 'https:') + raw
|
||||
const root = base()
|
||||
if (/^[^/?#:\\]+\.[a-z0-9]{1,10}$/i.test(raw)) return root + '/admin/sys-file/oss/file?fileName=' + encode(raw)
|
||||
const path = raw.startsWith('/') ? raw : '/' + raw
|
||||
if (/^(?:\/api)?\/admin\/sys-file\/oss\/(?:file|access-info)(?:[?#]|$)/.test(path)) return root + path.replace(/^\/api\//, '/')
|
||||
const prefix = root.replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
if ((prefix && path.startsWith(prefix + '/')) || path.startsWith('/api/')) return (origin(root) || '') + path
|
||||
if (path.startsWith('/sys-file/oss/')) return root + '/admin' + path
|
||||
if (raw.startsWith('?')) return root + '/admin/sys-file/oss/file' + raw
|
||||
return root + path
|
||||
}
|
||||
function parse(source?: string | null) {
|
||||
const resolved = resolve(source)
|
||||
const ownOrigin = origin(resolved)
|
||||
if (ownOrigin && !allowed().includes(ownOrigin)) return null
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !ownOrigin) return null
|
||||
const relative = ownOrigin ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved
|
||||
const path = relative.split(/[?#]/)[0]
|
||||
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
if (!FILE_PATHS.includes(path) && path !== prefix + '/admin/sys-file/oss/file') return null
|
||||
const hashAt = resolved.indexOf('#'), head = hashAt < 0 ? resolved : resolved.slice(0, hashAt)
|
||||
const hash = hashAt < 0 ? '' : resolved.slice(hashAt)
|
||||
const question = head.indexOf('?')
|
||||
const endpoint = question < 0 ? head : head.slice(0, question)
|
||||
const pairs = (question < 0 ? '' : head.slice(question + 1)).split('&').filter(Boolean).map(pair => {
|
||||
const at = pair.indexOf('='); return [decode(at < 0 ? pair : pair.slice(0, at)), decode(at < 0 ? '' : pair.slice(at + 1))]
|
||||
})
|
||||
const names = pairs.filter(([key]) => key === 'fileName')
|
||||
if (names.length !== 1 || !names[0][1] || /[\r\n]/.test(names[0][1])) throw new Error('Invalid file-center URL')
|
||||
const extras = pairs.filter(([key]) => key !== 'fileName' && !FILE_ACCESS_FIELDS.includes(key))
|
||||
const unsigned = endpoint + '?' + [...extras, ['fileName', names[0][1]]].map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + hash
|
||||
return { endpoint, file: names[0][1], extras, hash, unsigned }
|
||||
}
|
||||
function url(source?: string | null, force = false): string {
|
||||
const parsed = parse(source)
|
||||
if (!parsed) return resolve(source)
|
||||
const seconds = Math.floor((now() + offset) / 1000), cached = cache.get(parsed.unsigned)
|
||||
if (!force && cached && seconds >= cached.ts - 300 && seconds < cached.ts + ttl - Math.min(300, ttl / 6)) return cached.url
|
||||
if (!options.appId || !options.kid) throw new Error('Missing file access configuration')
|
||||
const sign = fileAccessSignature(parsed.file, options.appId, options.kid, seconds, options.key)
|
||||
const pairs = [...parsed.extras, ['appId', options.appId], ['kid', options.kid], ['ts', String(seconds)], ['sign', sign], ['fileName', parsed.file]]
|
||||
const result = parsed.endpoint + '?' + pairs.map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + parsed.hash
|
||||
if (cache.size >= 2048) cache.delete(cache.keys().next().value as string)
|
||||
cache.set(parsed.unsigned, { url: result, ts: seconds })
|
||||
return result
|
||||
}
|
||||
function strip(source?: string | null): string {
|
||||
if (!source) return source || ''
|
||||
const parsed = parse(source)
|
||||
if (!parsed) return source
|
||||
// Preserve the original origin/path form in persisted values.
|
||||
const hashAt = source.indexOf('#'), hash = hashAt < 0 ? '' : source.slice(hashAt)
|
||||
const head = hashAt < 0 ? source : source.slice(0, hashAt), at = head.indexOf('?')
|
||||
if (at < 0) return source
|
||||
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
|
||||
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
|
||||
}
|
||||
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
|
||||
if (!Number.isFinite(serverTime) || serverTime <= 0) return
|
||||
offset = serverTime + roundTripMs / 2 - now()
|
||||
if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds
|
||||
// url() already checks cached timestamps against the corrected clock. Keep
|
||||
// signatures that are still usable so a small clock correction cannot reload every image.
|
||||
}
|
||||
function invalidate(source?: string | null) {
|
||||
const parsed = parse(source)
|
||||
if (parsed) cache.delete(parsed.unsigned)
|
||||
}
|
||||
return { url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
}
|
||||
|
||||
const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
|
||||
const escapeAttribute = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
||||
/** Rewrite only resource attributes; no DOM/window dependency (also used by mini programs). */
|
||||
export function rewriteFileHtml(html: string, transform: (url: string) => string): string {
|
||||
if (typeof html !== 'string' || !html) return html
|
||||
return html.replace(/<[a-z][^>]*>/gi, tag => tag.replace(/\b(src|href|poster|srcset)\s*=\s*(["'])([\s\S]*?)\2/gi, (attribute, name, quote, raw) => {
|
||||
const value = unescapeAttribute(raw)
|
||||
const next = name.toLowerCase() === 'srcset' && !value.startsWith('data:')
|
||||
? value.split(',').map(item => item.trim().replace(/^\S+/, transform)).join(', ')
|
||||
: transform(value)
|
||||
return next === value ? attribute : name + '=' + quote + escapeAttribute(next) + quote
|
||||
}).replace(/url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi, (all, quote, value) => {
|
||||
const next = transform(unescapeAttribute(value)); return next === value ? all : 'url(' + quote + escapeAttribute(next) + quote + ')'
|
||||
}))
|
||||
}
|
||||
export function rewriteFileNodes(nodes: any, transform: (url: string) => string): any {
|
||||
if (typeof nodes === 'string') return rewriteFileHtml(nodes, transform)
|
||||
if (Array.isArray(nodes)) return nodes.map(node => rewriteFileNodes(node, transform))
|
||||
if (!nodes || typeof nodes !== 'object') return nodes
|
||||
const copy = { ...nodes }
|
||||
if (copy.attrs) {
|
||||
copy.attrs = { ...copy.attrs }
|
||||
for (const key of ['src', 'href', 'poster']) if (typeof copy.attrs[key] === 'string') copy.attrs[key] = transform(copy.attrs[key])
|
||||
}
|
||||
if (copy.children) copy.children = rewriteFileNodes(copy.children, transform)
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { watch } from 'vue';
|
||||
import { accessFileUrl, fileAccessRevision, retryFileAccess } from './file-access';
|
||||
|
||||
/** Renew editor resources without replacing the document or changing its undo history. */
|
||||
export function bindFileAccessDom(root: HTMLElement) {
|
||||
let scheduled = false;
|
||||
let stopped = false;
|
||||
const refresh = () => {
|
||||
if (stopped) return;
|
||||
for (const node of root.querySelectorAll<HTMLElement>('[src], [href], [poster]')) {
|
||||
for (const attr of ['src', 'href', 'poster']) {
|
||||
const source = node.getAttribute(attr);
|
||||
if (!source) continue;
|
||||
const target = accessFileUrl(source);
|
||||
if (target !== source) node.setAttribute(attr, target);
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scheduled || stopped) return;
|
||||
scheduled = true;
|
||||
queueMicrotask(() => { scheduled = false; refresh(); });
|
||||
};
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'href', 'poster'] });
|
||||
const unwatch = watch(fileAccessRevision, schedule, { flush: 'post' });
|
||||
const onError = (event: Event) => {
|
||||
const node = event.target;
|
||||
if (node instanceof HTMLElement) void retryFileAccess(node.getAttribute('src')).then(schedule);
|
||||
};
|
||||
root.addEventListener('error', onError, true);
|
||||
refresh();
|
||||
return () => {
|
||||
stopped = true;
|
||||
observer.disconnect();
|
||||
unwatch();
|
||||
root.removeEventListener('error', onError, true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ref } from 'vue'
|
||||
import { createFileAccess, rewriteFileHtml, rewriteFileNodes } from './file-access-core'
|
||||
|
||||
|
||||
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: () => import.meta.env.VITE_IMAGE_BASE_PATH || import.meta.env.VITE_API_URL || '/api',
|
||||
allowedOrigins: [import.meta.env.VITE_IMAGE_BASE_PATH, typeof location === 'undefined' ? '' : location.origin, ...(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>()
|
||||
const retrying = new Map<string, Promise<boolean>>()
|
||||
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 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): Promise<boolean> {
|
||||
if (!isProtectedFile(source)) return false
|
||||
const raw = originalFileUrl(source), previous = retried.get(raw) || 0
|
||||
const active = retrying.get(raw)
|
||||
if (active) return active
|
||||
if (Date.now() - previous < 30 * 60 * 1000) return false
|
||||
if (retried.size >= 2048) retried.clear()
|
||||
retried.set(raw, Date.now())
|
||||
const task = (async () => {
|
||||
const previousUrl = fileUrl(source)
|
||||
await refreshFileAccess(true)
|
||||
client.invalidate(source)
|
||||
if (fileUrl(source) === previousUrl) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100))
|
||||
client.invalidate(source)
|
||||
}
|
||||
fileAccessRevision.value++
|
||||
return true
|
||||
})()
|
||||
retrying.set(raw, task)
|
||||
try { return await task } finally { retrying.delete(raw) }
|
||||
}
|
||||
type FileDownloader = (url: string, query: Record<string, never>, name: string) => unknown
|
||||
/** Keep owned file links inside the application's fetch/blob download flow. */
|
||||
export function handleFileLinkClick(event: MouseEvent, download: FileDownloader): boolean {
|
||||
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
||||
const href = anchor?.getAttribute('href')
|
||||
if (event.defaultPrevented || !href || !isProtectedFile(href)) return false
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
const name = /[?&]fileName=([^&#]*)/.exec(resolveFileUrl(href))
|
||||
const filename = anchor?.getAttribute('download') || (name ? decodeURIComponent(name[1].replace(/\+/g, ' ')) : '文件')
|
||||
void download(originalFileUrl(href), {}, filename)
|
||||
return true
|
||||
}
|
||||
export function installFileAccess(app: any, download?: FileDownloader) {
|
||||
Object.assign(app.config.globalProperties, {
|
||||
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
|
||||
})
|
||||
startFileAccess()
|
||||
if (typeof window !== 'undefined') {
|
||||
if (download) document.addEventListener('click', event => { handleFileLinkClick(event, download) }, true)
|
||||
window.addEventListener('online', () => { retried.clear(); void refreshFileAccess(true) })
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stopFileAccess()
|
||||
else { retried.clear(); startFileAccess() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function readAccessInfo() {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const response = await fetch(client.resolve('/admin/sys-file/oss/access-info'), { credentials: 'omit', cache: 'no-store', signal: controller.signal })
|
||||
if (!response.ok) throw new Error('File clock unavailable')
|
||||
return await response.json()
|
||||
} finally { clearTimeout(timeout) }
|
||||
}
|
||||
+8
-2
@@ -1,3 +1,4 @@
|
||||
import { accessFileUrl, refreshFileAccess, isProtectedFile } from './file-access'
|
||||
import { nextTick } from "vue"
|
||||
import router from "/@/router/index"
|
||||
import pinia from "/@/stores/index"
|
||||
@@ -255,9 +256,10 @@ export function base64Encrypt(src: string) {
|
||||
* @param fileName 文件名称
|
||||
* @returns {*}
|
||||
*/
|
||||
export function downBlobFile(url: any, query: any, fileName: string) {
|
||||
export function downBlobFile(url: any, query: any, fileName: string, retried = false) {
|
||||
return request({
|
||||
url: url,
|
||||
url: accessFileUrl(url),
|
||||
...(isProtectedFile(url) ? { baseURL: '' } : {}),
|
||||
method: "get",
|
||||
responseType: "blob",
|
||||
params: query,
|
||||
@@ -266,6 +268,10 @@ export function downBlobFile(url: any, query: any, fileName: string) {
|
||||
handleBlobFile(response, fileName)
|
||||
})
|
||||
.catch(async (error) => {
|
||||
if (!retried && isProtectedFile(url)) {
|
||||
await refreshFileAccess(true)
|
||||
return downBlobFile(url, query, fileName, true)
|
||||
}
|
||||
let msg = "下载失败"
|
||||
try {
|
||||
if (error instanceof Blob) {
|
||||
|
||||
Reference in new issue
Block a user