Skip to content

Commit 3cf533a

Browse files
committed
feat: v1.9.0 — media download improvements, mobile image fix, website copy fix
Changes: - fix: website copy button now copies the active tab's command (#24) - fix: mobile image upload base64 compatibility for Android browsers (#21) - feat: improve media download delivery (from PR #27 by @papayachat) - path security hardening with symlink resolution - MEDIA_ALLOWED_DIRS config for custom allowed directories - Content-Disposition download mode with UTF-8 filename support - MEDIA: path parsing supports filenames with spaces - file cards open in download mode - chore: bump version to v1.9.0 - chore: close resolved issues #7 #8 #19 #22 #23
1 parent 3449a62 commit 3cf533a

6 files changed

Lines changed: 100 additions & 13 deletions

File tree

docs/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,9 @@ <h3 class="font-bold mb-4" style="color:var(--txt)">飞书交流群</h3>
868868

869869
// ===== 复制代码 =====
870870
function copyCode(btn){
871-
const pre=btn.nextElementSibling;
871+
const container=btn.parentElement;
872+
const pre=container.querySelector('pre:not(.hidden)');
873+
if(!pre)return;
872874
const text=pre.textContent;
873875
navigator.clipboard.writeText(text).then(()=>{
874876
const orig=btn.textContent;

h5/src/markdown.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ export function renderMarkdown(text) {
120120

121121
let output = result.join('\n')
122122
// MEDIA: 路径替换为音频/视频/文件播放器
123-
output = output.replace(/MEDIA:(\/[^\s<"]+)/g, (_, path) => {
123+
output = output.replace(/MEDIA:(\/[^\n<"]+)/g, (_, rawPath) => {
124+
const path = rawPath.trim()
124125
const src = `/media?path=${encodeURIComponent(path)}`
125126
if (/\.(mp3|wav|ogg|m4a|aac|flac|opus|wma)$/i.test(path)) {
126127
return `<div class="voice-bubble" data-src="${src}"><span class="voice-icon">&#9654;</span><span class="voice-bar"></span><span class="voice-dur">0″</span></div>`
@@ -131,7 +132,8 @@ export function renderMarkdown(text) {
131132
const ext = path.split('.').pop().toLowerCase()
132133
const iconMap = { pdf: '📄', doc: '📝', docx: '📝', txt: '📃', md: '📃', json: '📋', csv: '📊', zip: '📦', rar: '📦' }
133134
const icon = iconMap[ext] || '📎'
134-
return `<div class="msg-file-card" onclick="window.open('${src}','_blank')"><span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${fileName}</span></div></div>`
135+
const dlSrc = `${src}&download=1`
136+
return `<div class="msg-file-card" onclick="window.open('${dlSrc}','_blank')"><span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${fileName}</span></div></div>`
135137
})
136138
return output
137139
}

h5/src/media.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,12 @@ function renderPreviews() {
143143
/** 构建附件数组(发送给 Gateway) */
144144
export function getAttachments() {
145145
return _attachments.map(a => {
146-
const match = /^data:([^;]+);base64,(.+)$/.exec(a.data)
146+
// 兼容移动端浏览器:data URL 可能包含额外参数如 charset=utf-8
147+
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.+)$/s.exec(a.data)
147148
if (!match) return null
148149
const mimeType = match[1]
149-
const content = match[2]
150+
// 移动端浏览器可能在 base64 中混入换行/空格,需清理
151+
const content = match[2].replace(/[\s\r\n]/g, '')
150152
const cat = a.category || mediaCategory(mimeType)
151153
// Gateway 目前只处理 image/* 附件,但我们仍然发送完整信息以便未来兼容
152154
return { type: cat, mimeType, content, fileName: a.name }

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clawapp",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"private": true,
55
"description": "ClawApp - OpenClaw AI 智能体的 H5 移动端聊天客户端",
66
"homepage": "https://clawapp.qt.cool",

server/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,12 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here
1414
# 设置后自动切换为 password 认证,优先级高于 token
1515
# OPENCLAW_GATEWAY_PASSWORD=your-gateway-password-here
1616

17+
# 允许通过 /media 访问的额外目录(多个用逗号分隔)
18+
# 默认已允许 /tmp 和 /var/folders
19+
# MEDIA_ALLOWED_DIRS=~/.openclaw/workspace,/Users/yourname/Downloads
20+
21+
# 设为 1 可允许访问任意路径媒体文件(不推荐公网场景)
22+
# MEDIA_ALLOW_ALL=0
23+
1724
# 额外允许的 CORS 来源(多个用逗号分隔,用于反向代理/隧道场景)
1825
# ALLOWED_ORIGINS=https://your-domain.com,https://xxx.trycloudflare.com

server/index.js

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import express from 'express';
1414
import { createServer } from 'http';
1515
import { WebSocket } from 'ws';
1616
import { fileURLToPath } from 'url';
17-
import { dirname, join } from 'path';
17+
import { dirname, join, resolve, sep, basename, extname } from 'path';
1818
import { randomUUID, randomBytes, generateKeyPairSync, createHash, sign as ed25519Sign, createPrivateKey } from 'crypto';
19-
import { readFileSync, writeFileSync, existsSync, createReadStream, statSync } from 'fs';
19+
import { readFileSync, writeFileSync, existsSync, createReadStream, statSync, realpathSync } from 'fs';
2020

2121
const __filename = fileURLToPath(import.meta.url);
2222
const __dirname = dirname(__filename);
@@ -68,13 +68,70 @@ function updateEnvToken(newToken) {
6868
}
6969

7070
// 配置
71+
const DEFAULT_MEDIA_ALLOWED_DIRS = ['/tmp', '/var/folders'];
72+
73+
function expandHomePath(value) {
74+
const str = String(value || '').trim();
75+
if (!str) return '';
76+
if (str === '~') return process.env.HOME || str;
77+
if (str.startsWith('~/')) return join(process.env.HOME || '', str.slice(2));
78+
return str;
79+
}
80+
81+
function normalizePathForCompare(value, { mustExist = false } = {}) {
82+
const expanded = expandHomePath(value);
83+
if (!expanded) return '';
84+
try {
85+
if (existsSync(expanded)) {
86+
return realpathSync(expanded);
87+
}
88+
} catch {}
89+
if (mustExist) return '';
90+
return resolve(expanded);
91+
}
92+
93+
function parseAllowedMediaDirs(value) {
94+
return String(value || '')
95+
.split(',')
96+
.map(part => normalizePathForCompare(part))
97+
.filter(Boolean);
98+
}
99+
100+
function isPathInsideDir(targetPath, dirPath) {
101+
if (!targetPath || !dirPath) return false;
102+
return targetPath === dirPath || targetPath.startsWith(dirPath.endsWith(sep) ? dirPath : `${dirPath}${sep}`);
103+
}
104+
105+
function buildContentDisposition(filename) {
106+
const originalName = String(filename || 'download')
107+
.replace(/[\r\n"]/g, '_')
108+
.trim() || 'download';
109+
const extension = extname(originalName);
110+
const baseName = originalName.slice(0, originalName.length - extension.length) || 'download';
111+
const asciiBaseName = baseName
112+
.normalize('NFKD')
113+
.replace(/[^\x20-\x7E]+/g, '_')
114+
.replace(/[%;\\]/g, '_')
115+
.trim() || 'download';
116+
const asciiExtension = extension
117+
.normalize('NFKD')
118+
.replace(/[^\x20-\x7E]+/g, '')
119+
.replace(/[%;\\]/g, '') || '';
120+
const fallbackName = `${asciiBaseName}${asciiExtension}` || 'download';
121+
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(originalName)}`;
122+
}
123+
71124
const CONFIG = {
72125
port: parseInt(process.env.PROXY_PORT, 10) || 3210,
73126
proxyToken: process.env.PROXY_TOKEN || '',
74127
gatewayUrl: process.env.OPENCLAW_GATEWAY_URL || 'ws://127.0.0.1:18789',
75128
gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN || '',
76129
gatewayPassword: process.env.OPENCLAW_GATEWAY_PASSWORD || '',
77130
mediaAllowAll: process.env.MEDIA_ALLOW_ALL === '1',
131+
mediaAllowedDirs: [
132+
...DEFAULT_MEDIA_ALLOWED_DIRS.map(dir => normalizePathForCompare(dir, { mustExist: false })),
133+
...parseAllowedMediaDirs(process.env.MEDIA_ALLOWED_DIRS),
134+
],
78135
h5DistPath: join(__dirname, '../h5/dist'),
79136
};
80137

@@ -753,9 +810,13 @@ app.get('/health', (req, res) => {
753810
app.get('/media', (req, res) => {
754811
const filePath = req.query.path;
755812
if (!filePath || !existsSync(filePath)) return res.status(404).send('Not Found');
756-
if (!CONFIG.mediaAllowAll && !filePath.startsWith('/tmp/') && !filePath.startsWith('/var/folders/')) return res.status(403).send('Forbidden');
757-
const stat = statSync(filePath);
758-
const ext = filePath.split('.').pop().toLowerCase();
813+
const resolvedFilePath = normalizePathForCompare(filePath, { mustExist: true });
814+
if (!resolvedFilePath) return res.status(404).send('Not Found');
815+
if (!CONFIG.mediaAllowAll && !CONFIG.mediaAllowedDirs.some(dir => isPathInsideDir(resolvedFilePath, dir))) {
816+
return res.status(403).send('Forbidden');
817+
}
818+
const stat = statSync(resolvedFilePath);
819+
const ext = resolvedFilePath.split('.').pop().toLowerCase();
759820
const mime = {
760821
// 音频
761822
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4',
@@ -774,8 +835,16 @@ app.get('/media', (req, res) => {
774835
zip: 'application/zip', rar: 'application/x-rar-compressed',
775836
'7z': 'application/x-7z-compressed', tar: 'application/x-tar', gz: 'application/gzip',
776837
}[ext] || 'application/octet-stream';
777-
res.set({ 'Content-Type': mime, 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=3600' });
778-
createReadStream(filePath).pipe(res);
838+
const headers = {
839+
'Content-Type': mime,
840+
'Content-Length': stat.size,
841+
'Cache-Control': 'public, max-age=3600',
842+
};
843+
if (req.query.download === '1') {
844+
headers['Content-Disposition'] = buildContentDisposition(basename(resolvedFilePath));
845+
}
846+
res.set(headers);
847+
createReadStream(resolvedFilePath).pipe(res);
779848
});
780849

781850
// ==================== API 路由 ====================
@@ -1164,6 +1233,11 @@ const server = createServer(app);
11641233
server.listen(CONFIG.port, () => {
11651234
log.info(`代理服务端已启动: http://0.0.0.0:${CONFIG.port}`);
11661235
log.info(`架构: 手机 ←SSE+POST→ 代理服务端 ←WS→ Gateway(${CONFIG.gatewayUrl})`);
1236+
if (CONFIG.mediaAllowAll) {
1237+
log.warn('媒体文件访问已全开放 (MEDIA_ALLOW_ALL=1)');
1238+
} else {
1239+
log.info(`媒体文件允许目录: ${CONFIG.mediaAllowedDirs.join(', ')}`);
1240+
}
11671241
if (_isFirstRun) {
11681242
log.info('首次运行,请在浏览器中打开上述地址设置连接密码');
11691243
} else if (CONFIG.proxyToken) {

0 commit comments

Comments
 (0)