Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,16 @@ OPENCLAW_GATEWAY_PASSWORD=你的-gateway-password
| `OPENCLAW_GATEWAY_URL` | 否 | `ws://127.0.0.1:18789` | Gateway 地址(Docker 下自动设为 `host.docker.internal`) |
| `OPENCLAW_GATEWAY_TOKEN` | 二选一 | - | Gateway 认证 token |
| `OPENCLAW_GATEWAY_PASSWORD` | 二选一 | - | Gateway 密码认证(Tailscale Funnel 场景,优先级高于 token) |
| `MEDIA_ALLOWED_DIRS` | 否 | - | 允许通过 `/media` 访问的额外目录,逗号分隔。默认已允许 `/tmp/` 和 `/var/folders/` |
| `MEDIA_ALLOW_ALL` | 否 | `0` | 设为 `1` 允许访问任意路径的媒体文件(默认仅 `/tmp/` 和 `/var/folders/`) |
| `ALLOWED_ORIGINS` | 否 | - | 额外 CORS 白名单,逗号分隔 |

例如要允许 OpenClaw 的 workspace 目录被手机下载:

```env
MEDIA_ALLOWED_DIRS=~/.openclaw/workspace
```

---

<h2 id="structure">项目结构</h2>
Expand Down
7 changes: 6 additions & 1 deletion h5/src/chat-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1007,7 +1007,12 @@ function appendFilesToEl(el, files) {
card.innerHTML = `<span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${escapeText(f.name || '文件')}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
if (f.url) {
card.style.cursor = 'pointer'
card.onclick = () => window.open(f.url, '_blank')
card.onclick = () => {
const url = (f.url.includes('/media?') && !/[?&]download=1(?:&|$)/.test(f.url))
? `${f.url}${f.url.includes('?') ? '&' : '?'}download=1`
: f.url
window.open(url, '_blank')
}
} else if (f.data) {
card.style.cursor = 'pointer'
card.onclick = () => {
Expand Down
18 changes: 10 additions & 8 deletions h5/src/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,20 @@ export function renderMarkdown(text) {

let output = result.join('\n')
// MEDIA: 路径替换为音频/视频/文件播放器
output = output.replace(/MEDIA:(\/[^\s<"]+)/g, (_, path) => {
const src = `/media?path=${encodeURIComponent(path)}`
if (/\.(mp3|wav|ogg|m4a|aac|flac|opus|wma)$/i.test(path)) {
output = output.replace(/MEDIA:(\/[^\n<"]+)/g, (_, path) => {
const mediaPath = path.replace(/\s+$/, '')
const src = `/media?path=${encodeURIComponent(mediaPath)}`
const downloadSrc = `${src}&download=1`
if (/\.(mp3|wav|ogg|m4a|aac|flac|opus|wma)$/i.test(mediaPath)) {
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>`
}
if (/\.(mp4|mov|webm|mkv|avi|flv)$/i.test(path)) return `<div class="msg-video-wrap"><video controls preload="metadata" playsinline src="${src}" class="msg-video"></video></div>`
if (/\.(jpe?g|png|gif|webp|heic|svg)$/i.test(path)) return `<img src="${src}" alt="${escapeHtml(path.split('/').pop())}" class="msg-img" />`
const fileName = escapeHtml(path.split('/').pop())
const ext = path.split('.').pop().toLowerCase()
if (/\.(mp4|mov|webm|mkv|avi|flv)$/i.test(mediaPath)) return `<div class="msg-video-wrap"><video controls preload="metadata" playsinline src="${src}" class="msg-video"></video></div>`
if (/\.(jpe?g|png|gif|webp|heic|svg)$/i.test(mediaPath)) return `<img src="${src}" alt="${escapeHtml(mediaPath.split('/').pop())}" class="msg-img" />`
const fileName = escapeHtml(mediaPath.split('/').pop())
const ext = mediaPath.split('.').pop().toLowerCase()
const iconMap = { pdf: '📄', doc: '📝', docx: '📝', txt: '📃', md: '📃', json: '📋', csv: '📊', zip: '📦', rar: '📦' }
const icon = iconMap[ext] || '📎'
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>`
return `<div class="msg-file-card" onclick="window.open('${downloadSrc}','_blank')"><span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${fileName}</span></div></div>`
})
return output
}
Expand Down
7 changes: 7 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,12 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here
# 设置后自动切换为 password 认证,优先级高于 token
# OPENCLAW_GATEWAY_PASSWORD=your-gateway-password-here

# 允许通过 /media 访问的额外目录(多个用逗号分隔)
# 默认已允许 /tmp 和 /var/folders
# MEDIA_ALLOWED_DIRS=~/.openclaw/workspace,/Users/yourname/Downloads

# 设为 1 可允许访问任意路径媒体文件(不推荐公网场景)
# MEDIA_ALLOW_ALL=0

# 额外允许的 CORS 来源(多个用逗号分隔,用于反向代理/隧道场景)
# ALLOWED_ORIGINS=https://your-domain.com,https://xxx.trycloudflare.com
88 changes: 81 additions & 7 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import express from 'express';
import { createServer } from 'http';
import { WebSocket } from 'ws';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { dirname, join, resolve, sep, basename, extname } from 'path';
import { randomUUID, randomBytes, generateKeyPairSync, createHash, sign as ed25519Sign, createPrivateKey } from 'crypto';
import { readFileSync, writeFileSync, existsSync, createReadStream, statSync } from 'fs';
import { readFileSync, writeFileSync, existsSync, createReadStream, statSync, realpathSync } from 'fs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -68,13 +68,70 @@ function updateEnvToken(newToken) {
}

// 配置
const DEFAULT_MEDIA_ALLOWED_DIRS = ['/tmp', '/var/folders'];

function expandHomePath(value) {
const str = String(value || '').trim();
if (!str) return '';
if (str === '~') return process.env.HOME || str;
if (str.startsWith('~/')) return join(process.env.HOME || '', str.slice(2));
return str;
}

function normalizePathForCompare(value, { mustExist = false } = {}) {
const expanded = expandHomePath(value);
if (!expanded) return '';
try {
if (existsSync(expanded)) {
return realpathSync(expanded);
}
} catch {}
if (mustExist) return '';
return resolve(expanded);
}

function parseAllowedMediaDirs(value) {
return String(value || '')
.split(',')
.map(part => normalizePathForCompare(part))
.filter(Boolean);
}

function isPathInsideDir(targetPath, dirPath) {
if (!targetPath || !dirPath) return false;
return targetPath === dirPath || targetPath.startsWith(dirPath.endsWith(sep) ? dirPath : `${dirPath}${sep}`);
}

function buildContentDisposition(filename) {
const originalName = String(filename || 'download')
.replace(/[\r\n"]/g, '_')
.trim() || 'download';
const extension = extname(originalName);
const baseName = originalName.slice(0, originalName.length - extension.length) || 'download';
const asciiBaseName = baseName
.normalize('NFKD')
.replace(/[^\x20-\x7E]+/g, '_')
.replace(/[%;\\]/g, '_')
.trim() || 'download';
const asciiExtension = extension
.normalize('NFKD')
.replace(/[^\x20-\x7E]+/g, '')
.replace(/[%;\\]/g, '') || '';
const fallbackName = `${asciiBaseName}${asciiExtension}` || 'download';
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(originalName)}`;
}

const CONFIG = {
port: parseInt(process.env.PROXY_PORT, 10) || 3210,
proxyToken: process.env.PROXY_TOKEN || '',
gatewayUrl: process.env.OPENCLAW_GATEWAY_URL || 'ws://127.0.0.1:18789',
gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN || '',
gatewayPassword: process.env.OPENCLAW_GATEWAY_PASSWORD || '',
mediaAllowAll: process.env.MEDIA_ALLOW_ALL === '1',
mediaAllowedDirs: [
...DEFAULT_MEDIA_ALLOWED_DIRS.map(dir => normalizePathForCompare(dir, { mustExist: false })),
...parseAllowedMediaDirs(process.env.MEDIA_ALLOWED_DIRS),
],
h5DistPath: join(__dirname, '../h5/dist'),
};

Expand Down Expand Up @@ -753,9 +810,13 @@ app.get('/health', (req, res) => {
app.get('/media', (req, res) => {
const filePath = req.query.path;
if (!filePath || !existsSync(filePath)) return res.status(404).send('Not Found');
if (!CONFIG.mediaAllowAll && !filePath.startsWith('/tmp/') && !filePath.startsWith('/var/folders/')) return res.status(403).send('Forbidden');
const stat = statSync(filePath);
const ext = filePath.split('.').pop().toLowerCase();
const resolvedFilePath = normalizePathForCompare(filePath, { mustExist: true });
if (!resolvedFilePath) return res.status(404).send('Not Found');
if (!CONFIG.mediaAllowAll && !CONFIG.mediaAllowedDirs.some(dir => isPathInsideDir(resolvedFilePath, dir))) {
return res.status(403).send('Forbidden');
}
const stat = statSync(resolvedFilePath);
const ext = resolvedFilePath.split('.').pop().toLowerCase();
const mime = {
// 音频
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4',
Expand All @@ -774,8 +835,16 @@ app.get('/media', (req, res) => {
zip: 'application/zip', rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed', tar: 'application/x-tar', gz: 'application/gzip',
}[ext] || 'application/octet-stream';
res.set({ 'Content-Type': mime, 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=3600' });
createReadStream(filePath).pipe(res);
const headers = {
'Content-Type': mime,
'Content-Length': stat.size,
'Cache-Control': 'public, max-age=3600',
};
if (req.query.download === '1') {
headers['Content-Disposition'] = buildContentDisposition(basename(resolvedFilePath));
}
res.set(headers);
createReadStream(resolvedFilePath).pipe(res);
});

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