
简介面向Linphone自定义部署场景的PHP服务端代码旨在解决局域网或私有服务器上图片消息中转必须依赖官方服务器的问题帮助用户摆脱外部网络限制同时提升数据传输的私密性和可控性。该代码适合需要在受限网络环境中搭建VoIP通信服务的开发者和运维人员也适合希望深入理解Linphone消息传输机制的PHP后端学习者可作为快速落地私有图片中转服务的起点。资源包共1个文件类型为PHP压缩后大小仅2KB全部逻辑集中在lft.php中结构紧凑便于直接阅读、修改和移植。实现细节覆盖图片上传接收与格式、大小校验基于GD库的转码与质量优化文件类型白名单、安全命名等防攻击策略以及与SIP/MSRP协议适配的消息转发流程同时还包含身份验证、日志记录等辅助机制并预留了API集成和性能优化的扩展接口。已有294人学习下载这份代码对于搭建内部通信基础设施、排查Linphone消息链路异常以及开展二次开发均具有较高的参考价值。1. lft.php 到底是什么一个 PHP 文件把 SIP 里的图片链接搬成文件用 Linphone 给同事发截图对面收到一条文字消息里面是一串http://.../xxxx.jpg的链接点开还是 404。这基本不是 SIP 账号或路由的问题而是缺了一个叫lft.php的中转服务端。Linphone 在传递图片时默认不把图片本体塞进 SIP 信令发件端先把图片上传到一个 HTTP 地址信令里只带这个地址收件端再去取。lft.php 就是那个接收图片并落地到 Web 目录的 PHP 脚本它做的事很窄接收表单上传或远端 URL 拉取把文件保存到指定目录再回传一个绝对 URL。适合自己搭 Linphone 服务、折腾 SDK 或者给团队内部做消息联调的开发者不需要复杂架构一个 PHP 文件加一个可写目录就够了。2. 先看懂握手lft.php 与 Linphone 的文件中转协议和运行环境2.1 信令和文件分离SIP 消息里其实只有一个 URLLinphone 的图片文件传输走的是典型的“信令带链接、文件走 HTTP”模式。发件端在发送图片时用linphone_core_set_file_upload_server()指定一个 HTTP 服务端点图片先被 multipart/form-data POST 到这个端点服务端把文件保存下来并返回一个可访问的绝对 URL客户端拿到这个 URL 之后再把它放进 SIP MESSAGE或聊天室的 XML 消息里发给对端。收件端收到消息后解析出 URL再通过 HTTP 拉取图片展示。这个设计的好处是 SIP 信令只负责传送文本文件本体不受信令大小限制也不用担心 SIP 消息被网关截断。坏处是链路上多了一个 HTTP 依赖文件上传端点挂了、URL 拼接错了、保存目录不可写图片就会退化成一条打不开的文本链接而且客户端几乎不会报错。lft.php 就在这条链路的中转位置它质量的唯一检验标准是能不能稳定接收文件并返回一个让收件端能直接 GET 的地址。实际部署的时候我一般会把它放在独立的 Web 目录下而不是直接塞进已经跑业务的站点根目录。这样可以避免后续加路由规则时不小心把图片请求重写回入口文件。下面按这个假设继续讲。2.2 运行环境PHP 版本、扩展和目录权限这份资源本身是单文件 PHP 脚本所以运行环境是决定它能否工作的关键。我的建议是 PHP 7.4 起步最高测到 PHP 8.3因为新版本 curl 扩展的行为有变化稍后会在避坑部分展开。必要的扩展是 curl、fileinfo、mbstring这三个在常见 PHP 发行版里默认都有但有些精简版环境会关掉 fileinfo直接导致文件类型校验失败。项目建议说明PHP 版本7.4 - 8.38.0 之后要关注 curl 参数和字符串函数弃用必装扩展curl / fileinfo / mbstringfileinfo 用于 MIME 白名单校验Web 服务器Nginx 或 Apache重点是 upload 目录禁止解析 PHP目录权限目录 755、文件 644运行账户对 upload/ 有写权限请求方式GET POST处理 URL 拉取和表单直传两种模式如果你是从零开始搭最省事的路径是先用 PHP 内置服务器跑通在项目目录执行php -S 0.0.0.0:8080把 lft.php 放在这个目录下先用浏览器和 curl 验证逻辑再往 Nginx 或宝塔面板迁移。这样能隔离掉“代码问题”和“服务器配置问题”避免一次排查两个变量。用 vscode 配置 php 环境时可以顺手把 Xdebug 的 CLI 模式调好后面联调会方便很多如果你习惯 phpstorm直接用它内置的 PHP interpreter 跑内置服务器也行。2.3 目录结构和请求链路我习惯这样组织文件/var/www/html/lft ├── lft.php ├── upload/ │ ├── 2024/ │ │ └── 08/ │ │ └── 4f8c2a1b7d9e.jpg │ └── .htaccess # Apache 下禁止 PHP 执行 └── logs/ └── transfer.logupload/ 按年月分子目录一是避免单目录文件过多二是方便后续写清理策略。logs/ 存转发日志联调阶段几乎没有比日志更直接的定位手段。lft.php 的完整请求链路是发件端 POST 或 GET lft.php → 脚本校验来源和文件类型 → 保存到 upload/ → 返回绝对 URL → 收件端 GET 这个 URL 拉取图片。需要注意返回的绝对 URL 不能写死成127.0.0.1或内网 IP否则对端拿到也打不开。我一般把站点根地址单独抽成配置项部署时改一处即可。这个细节会在避坑章节里再提到。3. 把 lft.php 拆开中转逻辑、代码骨架和四个安全底线3.1 一份能用的单文件骨架先给出一份可以直接用的最小实现。这个版本不依赖任何框架也不引入 Composer单文件丢到服务器上就能跑。代码里保留两个分支表单直传收件端上传和 URL 拉取中转远端文件。?php declare(strict_types1); $uploadDir __DIR__ . /upload/; $baseUrl http://your.server/lft; // 改成你的站点根地址 $maxSize 10 * 1024 * 1024; // 10MB按需调整 $allowedExt [jpg, jpeg, png, gif, webp]; header(Content-Type: application/json; charsetutf-8); try { if ($_SERVER[REQUEST_METHOD] POST) { // 模式 A客户端把图片 POST 上来 if (empty($_FILES[file])) { throw new RuntimeException(missing file field); } $file $_FILES[file]; if ($file[error] ! UPLOAD_ERR_OK) { throw new RuntimeException(upload error: . $file[error]); } if ($file[size] $maxSize) { throw new RuntimeException(file too large); } $tmpPath $file[tmp_name]; $ext strtolower(pathinfo($file[name], PATHINFO_EXTENSION)); if (!in_array($ext, $allowedExt, true)) { throw new RuntimeException(ext not allowed); } // 用 fileinfo 校验真实 MIME而不是相信扩展名 $finfo finfo_open(FILEINFO_MIME_TYPE); $mime finfo_file($finfo, $tmpPath); finfo_close($finfo); if (!in_array($mime, [image/jpeg, image/png, image/gif, image/webp], true)) { throw new RuntimeException(mime not allowed: . $mime); } $newName bin2hex(random_bytes(8)) . . . $ext; $subDir date(Y/m); $destDir $uploadDir . $subDir; if (!is_dir($destDir)) { mkdir($destDir, 0755, true); } if (!move_uploaded_file($tmpPath, $destDir . / . $newName)) { throw new RuntimeException(move_uploaded_file failed); } echo json_encode([ code 0, url $baseUrl . /upload/ . $subDir . / . $newName, ]); exit; } // 模式 BGET 方式接收一个远端 URL拉到本地保存 $src $_GET[url] ?? ; if ($src || !filter_var($src, FILTER_VALIDATE_URL)) { throw new RuntimeException(invalid url); } $parsed parse_url($src); $host $parsed[host] ?? ; $ip gethostbyname($host); if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) false) { throw new RuntimeException(private ip not allowed); } $ch curl_init($src); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_FOLLOWLOCATION true, CURLOPT_MAXREDIRS 3, CURLOPT_CONNECTTIMEOUT 5, CURLOPT_TIMEOUT 20, ]); $data curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno ! 0 || $data false) { throw new RuntimeException(curl error: . $errno); } $ext strtolower(pathinfo(parse_url($src, PHP_URL_PATH), PATHINFO_EXTENSION)); if ($ext || !in_array($ext, $allowedExt, true)) { $ext jpg; // 兜底但别用用户传来的后缀直接拼路径 } $newName bin2hex(random_bytes(8)) . . . $ext; $subDir date(Y/m); $destDir $uploadDir . $subDir; if (!is_dir($destDir)) { mkdir($destDir, 0755, true); } if (file_put_contents($destDir . / . $newName, $data) false) { throw new RuntimeException(save failed); } echo json_encode([ code 0, url $baseUrl . /upload/ . $subDir . / . $newName, size strlen($data), ]); exit; } catch (Throwable $e) { http_response_code(400); echo json_encode([code 1, error $e-getMessage()]); exit; }这份代码的逻辑分成三段。第一段定义配置uploadDir决定文件落在哪里baseUrl决定返回的 URL 前缀maxSize和allowedExt做最基础的输入门槛。第二段处理 POST也就是“客户端把图片传上来”的场景代码里最关键的判断在扩展名和 MIME 双重校验不能只信扩展名这一点避坑章节会展开说。第三段处理 GET URL 拉取它用了FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE组合参数127.0.0.1、10.x、192.168.x这类地址会被直接拒绝主要是防 SSRF——否则你就是给内网探测开了一个免费代理。参数说明random_bytes(8)加bin2hex生成 16 位十六进制随机文件名加上年月目录基本避免文件名碰撞和信息泄露。CURLOPT_FOLLOWLOCATION开着因为不少图床会 302 跳转一次CURLOPT_TIMEOUT控制在 20 秒避免慢链接占住 PHP-FPM 进程。返回值统一是 JSON方便 Linphone 客户端解析。有的客户端只想要裸 URL可以把输出改成echo $url;但那样联调时错误信息不好看建议先用 JSON联调完再按需改。3.2 两种调用方式和细微差别模式 A 的调用上传表单的字段名必须叫file和$_FILES[file]保持一致。很多刚开始调的人会随手改成image然后接口一直 400却盯着签名和权限看半天。模式 B 的调用简单得多只需要把源 URL 放到url参数里。POST /lft.php (multipart/form-data, field: file) GET /lft.php?urlhttps://example.com/path/photo.jpg两种模式返回的都是同样的 JSON 结构url字段就是收件端去 GET 的那条地址。模式 B 适合做消息转发如果对端发过来的本身就是 URL而你又想把文件落到自己服务器上就用 GET 方式再中转一次。这个做法在面对外部图床防盗链时会省很多麻烦因为 curl 带了完整 User-Agent 去拉取比手机端直接浏览器访问更像一个正常请求。这里有个容易被忽略的细节模式 B 在保存前其实没有校验文件内容。如果需要严格模式可以在下载完成后用finfo_file再校验一次而不是只靠 URL 后缀推断扩展名。代码里注释写了“兜底”生产环境建议把那段注释替换成真实校验成本不高收益是上传目录里不会出现伪装成 jpg 的 PHP 文件。3.3 上线前必须盯住的四个安全底线第一上传目录必须禁止执行 PHP。用 Nginx 的话在 location 里把 upload 目录的fastcgi_pass摘掉用 Apache 就在 upload/ 下放一个.htaccess内容写成php_flag engine off。这一步做不到前面所有防御都等于零。第二扩展名白名单和 MIME 白名单要同时存在。扩展名可以被伪造但finfo_file读出来的是文件头魔术字节伪造成本高很多。两个校验都过才进move_uploaded_file。第三文件名一律服务端生成。pathinfo($file[name], PATHINFO_EXTENSION)只取扩展名不取完整文件名避免中文文件名和路径穿越问题。完整的原名最多写进日志不要拼进保存路径。第四URL 参数要防内网地址。这就是FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE的作用。前者排除私有网段后者排除保留网段。如果你不需要 URL 拉取模式建议直接把模式 B 的分支删掉少一个入口就少一类风险。4. 接入 Linphone三套客户端的配置差异与联调脚本4.1 桌面端改 linphonerc 配置文件桌面端 Linphone 的配置集中在linphonerc文件里。Windows 在%APPDATA%\Linphone\Linux 在~/.config/linphone/macOS 在~/Library/Preferences/linphone/。需要在配置里加上file_upload_url和目标 URL 参数。[net] file_upload_urlhttp://your.server/lft.php file_upload_url_param$TOKEN这个配置的含义是客户端发送图片时先把文件 POST 到file_upload_url指向的地址然后从响应中取出图片 URL再放进 SIP 消息。$TOKEN是一个宏部分版本会在请求里自动替换成当前账号相关的凭据如果你的 lft.php 不需要鉴权这个参数可以留空。需要注意不同版本里这个键可能被归到[misc]段如果只改[net]不生效可以把两段都写上再把不用的那段注释掉。改完配置后需要重启 Linphone 或重新登录账号。我用linphonec验证过一次它启动时会立刻读取配置文件所以在会话内修改不会热加载。桌面端还有一个隐藏点如果配置里同时存在file_upload_url和file_upload_url_param请求体是multipart/form-data还是application/x-www-form-urlencoded由版本决定最好在 lft.php 里加一行日志把$_SERVER[CONTENT_TYPE]打出来确认避免客户端和服务端鸡同鸭讲。4.2 Android/iOS走的是 liblinphone 内核移动端的情况稍微不一样。Android 和 iOS 的 Linphone 底层都是 liblinphone配置文件从linphonerc变成了 Android 的res/raw/linphonerc或 iOS 的配置文件但键名没变依然是file_upload_url。如果你是集成开发而不是直接装官方 App可以用 SDK 提供的 setter 动态设置。LinphoneCore *core linphone_core_new(config, nullptr); linphone_core_set_file_upload_server(core, http://your.server/lft.php); linphone_core_set_file_upload_param(core, $TOKEN);移动端的坑在于官方 App 默认配置可能已经指向官方中转服务或者完全没有配这项。集成到自己 App 里时必须确认设置的file_upload_url是全局生效而不是只在某个账号配置里生效。一个比较常见的翻车现象是测试时用桌面端加了自己的 lft.php 能跑通换到 Android 模拟器上就变成“对方只收到链接”十有八九是 SDK 那侧没设置成功或者设置之后没有重新初始化 core。4.3 用一条 curl 命令验证服务端可用性在接客户端之前先把服务端链路打穿。这是我会做的第一步。# 表单直传 curl -s -X POST -F filetest.png http://127.0.0.1:8080/lft.php # URL 拉取 curl -s http://127.0.0.1:8080/lft.php?urlhttps://example.com/a.jpg第一组命令的预期输出是包含code:0和url字段的 JSON。拿到 URL 后再用浏览器或 curl 去 GET 这个 URL确认图片能正常显示。如果 URL 能访问服务端这一步就通了。第二组命令测的是模式 B用来验证 curl 拉取和 SSRF 过滤是否正常。两条命令建议在服务器本机跑一遍再用另一台机器跑一遍后者能顺便验证baseUrl有没有写错。联调时日志很重要。我一般会在 lft.php 里加一个最原始的写文件日志把请求时间、来源 IP、文件大小、最终 URL 都记下来输出到logs/transfer.log。不要一开始就上日志组件单文件脚本里一个file_put_contents(..., FILE_APPEND)就够用了等链路稳定了再换正式方案。5. 避坑盘点五个复现率高的问题和对应解法5.1 图片上传成功但对方收到一条打不开的链接现象客户端显示图片发送成功没有报错但收件方只收到文字消息里面是一串 URL点开 404 或者无响应。原因lft.php 确实保存了文件也返回了 URL但收件方所在的网络访问不到这个 URL。最常见是baseUrl写成了http://127.0.0.1/lft在服务器本机看没问题换一台设备就 404。另一个常见原因是发件端根本没有走到 lft.php而是把文件发到了 Linphone 官方默认的临时中转或者直接退化成链接。解决把baseUrl改成公网可访问的域名或 IP不要用内网地址。然后在 lft.php 入口加一行请求日志确认是否有来自发件客户端的 POST 进来。如果日志里没有任何记录说明配置没生效回到第 4 章的配置文件检查file_upload_url所在段是否正确。5.2 中文文件名变成乱码或百分号编码现象图片能保存但返回的 URL 里文件名显示为%E6%88%AA%E5%9B%BE...或者服务器上保存的文件名直接乱码。原因客户端上传时带了中文原始文件名代码取扩展名时路径没有经过 UTF-8 规范化某些环境下pathinfo()会对中文字节做转义。更隐蔽的情况是客户端 POST 的 filename 里带了路径嵌套服务端虽然只取了扩展名但文件系统编码不一致导致乱码。解决不信任原始文件名。只取pathinfo($file[name], PATHINFO_EXTENSION)然后完全丢弃原名用random_bytes生成新文件名。这样从源头消灭中文编码问题。如果确实需要保留关联信息把原始文件名转成 UTF-8 后写进日志不要写进文件系统。5.3 PHP 7.4 跑得好好的升级 PHP 8.3 就报错现象同一个 lft.php在 PHP 7.4 下正常切换到 PHP 8.3 后上传报curl error或函数未定义错误接口直接 400。原因PHP 8 之后curl_setopt_array对某些 CURLOPT 常量做了更严格校验CURLOPT_RETURNTRANSFER这类常量本身没问题但curl_init($src)的参数如果被认为是非法 URL会直接抛异常而不是返回 false。另外each()、create_function()这类旧函数在 PHP 8 已经移除如果手里的代码是从老教程抄来的很可能踩到。解决先确认两个版本的差异。我的做法是在 8.3 下打开error_reporting(E_ALL)把异常完整打出来通常错误信息会直接给出具体函数名或行号。代码里尽量避免用被移除的旧函数对于 curl统一用curl_init()加curl_setopt_array()不要用 7.x 时代那种一个选项一行写的旧风格。如果生产环境暂时升不了级也可以把 PHP 版本钉在 8.1这是目前兼容性最稳的折中版本。5.4 Nginx 下图片打不开Apache 下一切正常现象同一个 lft.php在 Apache 环境一切正常迁移到 Nginx 后上传接口能返回 URL但点击 URL 访问上传的图片返回 404。部分站点还会出现open_basedir报错。原因Nginx 的配置没有区分 PHP 动态请求和静态文件请求。如果 location / 把所有请求都转发给了fastcgi_pass那么访问upload/2024/08/xxx.jpg时Nginx 也会交给 PHP-FPM 处理而 PHP-FPM 的脚本路径不对自然 404。更常见的 lft.php 放在子目录而 Nginx 配置里的 root 指向了别的位置。解决给静态目录单独加 location只处理图片文件不走 FastCGI。大致配置思路如下location ^~ /lft/upload/ { expires 7d; access_log off; } location /lft/lft.php { include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }如果用的是宝塔面板站点设置里把上传目录的“静态化”打开即可。不要直接改全局 Nginx 配置面板生成的文件会在面板更新时被覆盖这也是很多人改完第二天又失效的原因。5.5 upload 目录被植入 PHP 文件现象服务器上 upload/ 目录里出现了一些不在自己上传记录里的 PHP 文件或者图片文件被附加了恶意内容。原因这是最容易致命的一点。如果脚本只校验了扩展名而没校验 MIME攻击者可以用shell.jpg这种名字上传一个内容为 PHP 的文本只要 upload 目录允许执行 PHP这个文件就能被当作 webshell 执行。另一个入口是模式 B 的 URL 拉取下载了一个内容为 PHP 的“图片”并保存为 jpg但由于服务器配置原因.jpg也可能被交给 PHP 解析。解决两件事一起做。第一finfo_fileMIME 校验必须在保存前执行确保内容是真实图片第二上传目录必须禁止 PHP 执行。Nginx 侧把 upload 目录的fastcgi_pass移除Apache 侧用.htaccess关掉engine off。做完之后再对上传目录扫描一遍把可疑文件删掉。6. 交付前最后一件事用一条命令把整条链路验证完在把 lft.php 交给别人或部署到生产前我会用一条命令跑完“上传 - 返回 URL - 下载回源”的闭环。步骤很简单先准备一张测试图用 curl 上传把返回 JSON 里的 URL 截出来再对这个 URL 发起一次 GET比对字节数。# 上传 curl -s -X POST -F filetest.png http://your.server/lft.php -o resp.json # 取出 url 字段 url$(php -r $djson_decode(file_get_contents(resp.json), true); echo $d[url];) # 下载回源比较字节数 curl -s $url -o test.download.png ls -l test.png test.download.png两个文件大小一致说明整条链路通了。接下来我还会检查三件事一是日志里有没有出现 400 记录二是 upload/ 目录下新增文件的权限是不是 644三是拿手机 4G 网络访问一次返回的 URL确认不是仅在局域网内可达。这三项都过了这个中转服务才算真正能用。从那以后我每次上线这类中转服务都强制自己走一遍上述流程包括把baseUrl从127.0.0.1换成域名、确认禁掉了 upload 目录的 PHP 执行权限、再用日志确认真的有人 POST 上来过。别嫌这步啰嗦这条链路最容易出问题的也就是这些点。希望帮到你。本文还有配套的精品资源点击获取