@@ -14,9 +14,9 @@ import express from 'express';
1414import { createServer } from 'http' ;
1515import { WebSocket } from 'ws' ;
1616import { fileURLToPath } from 'url' ;
17- import { dirname , join } from 'path' ;
17+ import { dirname , join , resolve , sep , basename , extname } from 'path' ;
1818import { 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
2121const __filename = fileURLToPath ( import . meta. url ) ;
2222const __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+
71124const 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) => {
753810app . 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);
11641233server . 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