diff --git a/src/sheep/utils/file-access-core.ts b/src/sheep/utils/file-access-core.ts new file mode 100644 index 0000000..1b91f6d --- /dev/null +++ b/src/sheep/utils/file-access-core.ts @@ -0,0 +1,126 @@ +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() + 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 + cache.clear() + } + return { url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, 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 +} diff --git a/src/sheep/utils/file-access.ts b/src/sheep/utils/file-access.ts new file mode 100644 index 0000000..7ca5873 --- /dev/null +++ b/src/sheep/utils/file-access.ts @@ -0,0 +1,144 @@ +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 | undefined +let pending: Promise | undefined +let lastSync = 0 +const retried = new Map() +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 { + 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) { + 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 { + 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' }), + }) + } + }, + }) +} diff --git a/src/types/file-access.d.ts b/src/types/file-access.d.ts new file mode 100644 index 0000000..0ad59d1 --- /dev/null +++ b/src/types/file-access.d.ts @@ -0,0 +1,11 @@ +import 'vue' +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $fileUrl: (source?: string | null) => string + $fileUrls: (sources: any) => string[] + $fileHtml: (html: string) => string + $fileNodes: (nodes: any) => any + $fileRetry: (source?: string | null) => Promise + } +} +export {} diff --git a/test-vectors/file-access.json b/test-vectors/file-access.json new file mode 100644 index 0000000..1d2cc5b --- /dev/null +++ b/test-vectors/file-access.json @@ -0,0 +1,29 @@ +[ + { + "fileName": "a.jpg", + "appId": "mini", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\nmini\nv1\n1793232000\nYS5qcGc", + "sign": "348b1a5dd5d20560b7537916a6ec525efd173ccc1ee4af5357ca634f26296eb4" + }, + { + "fileName": "照片 +&%.png", + "appId": "admin", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\nadmin\nv1\n1793232000\n54Wn54mHICsmJS5wbmc", + "sign": "9cdaa97a00bf333cf7af13cd915147d474367ef66c9a46adba27f2cbf910ea41" + }, + { + "fileName": "literal%2Fname.apk", + "appId": "package", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\npackage\nv1\n1793232000\nbGl0ZXJhbCUyRm5hbWUuYXBr", + "sign": "a28e0b9ec5854749128b0413f0d97c425b8ee785c0c53b41f4469ee509c37e10" + } +] diff --git a/tests/file-access.test.cjs b/tests/file-access.test.cjs new file mode 100644 index 0000000..94040c8 --- /dev/null +++ b/tests/file-access.test.cjs @@ -0,0 +1,137 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const Module = require('node:module'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const corePath = fs.existsSync(path.join(root, 'src/utils/file-access-core.ts')) + ? path.join(root, 'src/utils/file-access-core.ts') : path.join(root, 'src/sheep/utils/file-access-core.ts'); +const loaded = new Module(corePath, module); +loaded.filename = corePath; loaded.paths = Module._nodeModulePaths(path.dirname(corePath)); +loaded._compile(ts.transpileModule(fs.readFileSync(corePath, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, corePath); +const { fileAccessSignature, fileAccessCanonical, createFileAccess, rewriteFileHtml, rewriteFileNodes } = loaded.exports; +const vectors = JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8')); +for (const v of vectors) test('cross-language vector: ' + v.fileName, () => { + assert.equal(fileAccessCanonical(v.fileName,v.appId,v.kid,v.ts),v.canonical); + assert.equal(fileAccessSignature(v.fileName,v.appId,v.kid,v.ts,v.key),v.sign); +}); +let time; +function client(baseUrl='https://files.example/api') { + time = 1793232000000; + return createFileAccess({appId:'mini',kid:'v1',key:vectors[0].key,baseUrl:()=>baseUrl,now:()=>time,allowedOrigins:['https://legacy.example']}); +} +test('prefixes, absolute URLs, and bare names address the same file',()=>{ + const c=client(); + for(const value of ['/admin/sys-file/oss/file?fileName=a.jpg','/api/admin/sys-file/oss/file?fileName=a.jpg','https://files.example/api/admin/sys-file/oss/file?fileName=a.jpg','a.jpg']) { + const url=c.url(value); assert.match(url,/^https:\/\/files.example\/api\/admin\/sys-file\/oss\/file\?/); + assert.equal(new URL(url).searchParams.get('sign'),vectors[0].sign); + assert.ok(url.endsWith('fileName=a.jpg')); + } +}); +test('relative API base, no duplicate prefixes',()=>{ + const c=client('/api'); assert.match(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg'),/^\/api\/admin/); +}); +test('foreign URLs and local resources never receive credentials',()=>{ + const c=client(); + for(const value of ['https://other.example/admin/sys-file/oss/file?fileName=a.jpg','https://files.example.evil/api/admin/sys-file/oss/file?fileName=a.jpg','data:image/png;base64,AA==','blob:https://files.example/abc','/static/logo.png','https://files.example/qiniu/a.jpg']) assert.equal(c.url(value),value); +}); +test('one decode, unusual names, no duplicated signature and cache renewal',()=>{ + const c=client(), name='照片 +&%.png', source='/admin/sys-file/oss/file?fileName='+encodeURIComponent(name); + const first=c.url(source), params=new URL(first).searchParams; + assert.equal(params.get('fileName'),name); assert.equal(params.get('sign'),fileAccessSignature(name,'mini','v1',1793232000,vectors[0].key)); + assert.equal(c.url(first),first); time+=10*60000; assert.equal(c.url(source),first); + time+=16*60000; const renewed=c.url(source); assert.notEqual(renewed,first); + assert.equal(c.strip(first),c.strip(renewed)); assert.equal(c.strip(source),source); +}); +test('reject ambiguous file names and never silently use an empty key',()=>{ + const c=client(); assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName=a&fileName=b')); + assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName=')); + assert.throws(()=>fileAccessSignature('a','mini','v1',1,'')); +}); +test('clock correction renews a skewed signature',()=>{ + const c=client();time+=86400000;c.synchronize(1793232000000,1800,0); + assert.equal(new URL(c.url('a.jpg')).searchParams.get('ts'),'1793232000'); +}); +test('HTML and rich-text nodes sign for presentation without mutating business values',()=>{ + const c=client(), html='

hello

x'; + const transform=s=>c.protected(s)?c.url(s):s; + const rendered=rewriteFileHtml(html,transform);assert.match(rendered,/&sign=/);assert.match(rendered,/href="https:\/\/other.example\/a"/); + const saved=rewriteFileHtml(rendered,s=>c.strip(s));assert.doesNotMatch(saved,/[?&](?:amp;)?(?:sign|ts|kid|appId)=/);assert.match(saved,/fileName=a.jpg/); + const nodes=[{name:'img',attrs:{src:'/api/admin/sys-file/oss/file?fileName=a.jpg'}}], before=JSON.stringify(nodes); + assert.match(rewriteFileNodes(nodes,transform)[0].attrs.src,/sign=/);assert.equal(JSON.stringify(nodes),before); +}); + +test('configured deployment prefixes and encoded parameter names remain unambiguous', () => { + const c = client('https://files.example/gateway/api'); + const relative = '/gateway/api/admin/sys-file/oss/file?%66ileName=a.jpg'; + const url = c.url(relative); + assert.equal(new URL(url).pathname, '/gateway/api/admin/sys-file/oss/file'); + assert.equal(new URL(url).searchParams.get('sign'), vectors[0].sign); + assert.equal(c.url(url), url); + assert.throws(() => c.url(relative + '&fileName=b.jpg')); + const special = c.url('/admin/sys-file/oss/file?fileName=a%252Bb.png'); + assert.equal(new URL(special).searchParams.get('fileName'), 'a%2Bb.png'); +}); + +test('real runtime wrapper initializes, calibrates without login, and renews reactively', async () => { + const { computed } = require('vue'); + const filename = path.join(path.dirname(corePath), 'file-access.ts'); + const env = { + VITE_FILE_ACCESS_APP_ID: 'mini', VITE_FILE_ACCESS_KEY_ID: 'v1', VITE_FILE_ACCESS_KEY: vectors[0].key, + VITE_IMAGE_BASE_PATH: 'https://files.example/api', VITE_IMAGE_DEV_PATH: 'https://files.example/api', + VITE_APP_DEV_SERVICE: 'https://files.example/api', VITE_APP_PROD_IMAGE: 'https://files.example/api', + VITE_APP_PROD_SERVICE: 'https://files.example/api', VITE_FILE_ACCESS_ALLOWED_ORIGINS: 'https://legacy.example', + }; + const runtime = new Module(filename, module); runtime.filename = filename; runtime.paths = loaded.paths; + runtime.require = (name) => name === './file-access-core' ? loaded.exports : name === './app' ? { isDevEnv: () => true } : loaded.require(name); + const source = fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')'); + const originalFetch = global.fetch, originalUni = global.uni, realNow = Date.now; + const serverTime = realNow() + 86400000; + const info = { data: { serverTime, ttlSeconds: 1800 } }; + global.fetch = async (url, options) => { + assert.equal(url, 'https://files.example/api/admin/sys-file/oss/access-info'); + assert.equal(options.credentials, 'omit'); return { ok: true, json: async () => info }; + }; + global.uni = { request: options => { assert.match(options.url, /oss\/access-info$/); options.success({ statusCode: 200, data: info }); } }; + let elapsed = 0; + Date.now = () => realNow() + elapsed; + try { + runtime._compile(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename); + const api = runtime.exports, raw = '/admin/sys-file/oss/file?fileName=a.jpg'; + const shown = computed(() => api.accessFileUrl(raw)); + const first = shown.value; + assert.match(first, /sign=/); + assert.match(api.accessFileUrl('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg'), /sign=/); + await api.refreshFileAccess(true); + assert.equal(Number(new URL(shown.value).searchParams.get('ts')), Math.floor(serverTime / 1000)); + const calibrated = shown.value; + elapsed = 26 * 60000; + api.fileAccessRevision.value++; + assert.notEqual(shown.value, calibrated); + assert.equal(api.originalFileUrl(shown.value), api.originalFileUrl(first)); + assert.equal(raw, '/admin/sys-file/oss/file?fileName=a.jpg'); + assert.equal(api.accessFileUrl('/static/logo.png'), '/static/logo.png'); + assert.equal(api.accessFileUrl('blob:https://files.example/test'), 'blob:https://files.example/test'); + } finally { global.fetch = originalFetch; global.uni = originalUni; Date.now = realNow; } +}); + +test('frontend proxy /api is normalized to the configured file-server prefix', () => { + const c = client('https://files.example'); + assert.equal(new URL(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/admin/sys-file/oss/file'); + assert.equal(c.resolve('/api/admin/sys-file/oss/access-info'), 'https://files.example/admin/sys-file/oss/access-info'); + assert.equal(new URL(c.url('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/api/admin/sys-file/oss/file'); +}); + +test('rich text preview calls the shared signing path from the JavaScript script', () => { + const filename = path.join(root, 'src/uni_modules/tm-ui/components/tm-html/mp-node.vue'); + const content = fs.readFileSync(filename, 'utf8'); + const script = content.match(/