fix:图片访问

fix:权限修改
This commit is contained in:
dzc committed 2026-10-29 14:25:54 +08:00
1 parent 653b4688cd
commit 3ea7c4e872
6 files changed
+526

No files matched your search

+137
View File
@@ -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='<p>hello</p><img src="/api/admin/sys-file/oss/file?fileName=a.jpg"><a href="https://other.example/a">x</a>';
const transform=s=>c.protected(s)?c.url(s):s;
const rendered=rewriteFileHtml(html,transform);assert.match(rendered,/&amp;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(/<script>\s*([\s\S]*?)<\/script>/)[1];
const component = new Module(filename, module); component.filename = filename; component.paths = loaded.paths;
let preview;
component.require = name => name === './mp-node.vue' ? {} : name === '@/sheep/utils/file-access' ? { previewFiles: options => { preview=options; } } : loaded.require(name);
component._compile(ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename);
component.exports.default.methods.imgTap.call({$emit(){},opts:[0,0,0,true,0,true]},'/admin/sys-file/oss/file?fileName=a.jpg');
assert.deepEqual(preview.urls,['/admin/sys-file/oss/file?fileName=a.jpg']);
assert.doesNotMatch(content.match(/<script module="handler" lang="wxs">([\s\S]*?)<\/script>/)[1], /file-access/);
});
+79
View File
@@ -0,0 +1,79 @@
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, '..')
function loadRuntime() {
const filename = path.join(root, 'src/sheep/utils/file-access.ts')
const corePath = path.join(path.dirname(filename), 'file-access-core.ts')
const compile = source => ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText
const core = new Module(corePath, module); core.filename = corePath; core.paths = Module._nodeModulePaths(path.dirname(corePath))
core._compile(compile(fs.readFileSync(corePath, 'utf8')), corePath)
const appId = root.endsWith('pj_mini_app_cloud') ? 'mini' : 'h5'
const env = { VITE_FILE_ACCESS_APP_ID: appId, VITE_FILE_ACCESS_KEY_ID: 'v1',
VITE_FILE_ACCESS_KEY: JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'))[0].key,
VITE_IMAGE_DEV_PATH: 'https://files.example/api', VITE_APP_DEV_SERVICE: 'https://files.example/api' }
const runtime = new Module(filename, module); runtime.filename = filename; runtime.paths = core.paths
runtime.require = name => name === './file-access-core' ? core.exports : name === './app' ? { isDevEnv: () => true } : core.require(name)
runtime._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename)
return { api: runtime.exports, appId }
}
test('rich-text file links use signed downloads and local files instead of browser navigation', () => {
const previousUni = global.uni, previousDocument = global.document
const downloads = [], documents = [], previews = [], browserDownloads = []
global.uni = {
downloadFile: options => { downloads.push(options); options.success({ statusCode: 200, tempFilePath: 'blob:local-file' }) },
openDocument: options => documents.push(options), previewImage: options => previews.push(options), onNetworkStatusChange() {},
}
global.document = undefined
try {
const { api, appId } = loadRuntime()
api.openFileLink('/admin/sys-file/oss/file?fileName=report.pdf')
assert.equal(new URL(downloads[0].url).searchParams.get('appId'), appId)
assert.ok(new URL(downloads[0].url).searchParams.has('sign'))
assert.equal(documents[0].filePath, 'blob:local-file')
let appended = 0, removed = 0
let onClick
global.document = {
addEventListener(name, handler, capture) { assert.equal(name, 'click'); assert.equal(capture, true); onClick = handler },
body: { appendChild() { appended++ } },
createElement: () => ({ click() { browserDownloads.push({ href: this.href, name: this.download }) }, remove() { removed++ } }),
}
api.openFileLink('/admin/sys-file/oss/file?fileName=' + encodeURIComponent('附件 +.pdf'))
assert.deepEqual(browserDownloads, [{ href: 'blob:local-file', name: '附件 +.pdf' }])
assert.equal(appended, 1); assert.equal(removed, 1)
api.openFileLink('/admin/sys-file/oss/file?fileName=photo.png')
assert.equal(previews.length, 1)
assert.ok(new URL(previews[0].urls[0]).searchParams.has('sign'))
assert.equal(downloads.length, 2)
api.installFileAccess({ config: { globalProperties: {} }, mixin() {} })
let prevented = false
onClick({
target: { closest: () => ({ getAttribute: () => '/admin/sys-file/oss/file?fileName=linked.pdf' }) },
preventDefault() { prevented = true }, stopImmediatePropagation() {},
})
assert.equal(prevented, true)
assert.equal(browserDownloads.at(-1).name, 'linked.pdf')
let outsidePrevented = false
onClick({
target: { closest: () => ({ getAttribute: () => 'https://other.example/report.pdf' }) },
preventDefault() { outsidePrevented = true }, stopImmediatePropagation() {},
})
assert.equal(outsidePrevented, false)
} finally { global.uni = previousUni; global.document = previousDocument }
})
test('the actual rich-text component routes owned links through the file helper', () => {
const filename = path.join(root, 'src/uni_modules/tm-ui/components/tm-html/mp-node.vue')
const script = fs.readFileSync(filename, 'utf8').match(/<script>\s*([\s\S]*?)<\/script>/)[1]
const loaded = new Module(filename, module); loaded.filename = filename; loaded.paths = Module._nodeModulePaths(path.dirname(filename))
const opened = []
loaded.require = name => name === './mp-node.vue' ? {} : name === '@/sheep/utils/file-access'
? { accessFileUrl: value => value, isProtectedFile: value => value.includes('/sys-file/oss/file'), openFileLink: value => opened.push(value) }
: require(name)
loaded._compile(ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename)
const href = 'https://files.example/api/admin/sys-file/oss/file?fileName=report.pdf'
loaded.exports.default.methods.linkTap.call({ root: { $emit() {}, getText: () => '' } }, { href })
assert.deepEqual(opened, [href])
})