Signature PDF 1.11.0 API 使用详解

逐请求实测讲解 Signature PDF 1.11.0 的全部 HTTP API 面:/api/share 三接口的密钥生命周期与全部错误分支、/api/file/get 路由遮蔽截断 bug、/sign /image2svg /compress /ocr 表单端点、协作页面辅助端点与 /cron 清理,含 3 个 v1.11.0 实证 bug 与无分页过滤排序的如实说明。

Signature PDFSignature PDFPDF电子签名PDF 处理2026/11/02

Signature PDF 1.11.0 API 使用详解

目标:把 Signature PDF 1.11.0 的整个 HTTP 面讲清——它没有独立 CLI(容器内 pdfsig/certutil/ocrmypdf 均不存在,源码包也无 bin/console 入口),一切集成与自动化只能走 HTTP。本文按 v1.11.0 源码 app.php 路由表逐条枚举接口,对 4 个 /api/* JSON 接口、4 个表单处理端点(/sign、/image2svg、/compress、/ocr)、协作页面辅助端点与 /cron 清理端点做了成功分支与全部错误分支的逐请求实测,并沉淀路由遮蔽、孤儿目录、异常泄密等 3 个 v1.11.0 实证 bug。已在 Ubuntu 26.04.1 LTS(x86_64,Docker 29.9.2 / Compose v5.6.1)运行中的 signaturepdf-1.11.0-stack 环境逐项实测(2026-11-02),源码结论以 v1.11.0 tag 实拉核对。

一、环境信息

  • 操作系统:Ubuntu 26.04.1 LTS(x86_64,内核 7.0.0-38-generic)
  • 应用版本:Signature PDF v1.11.0,官方镜像 xgaia/signaturepdf:1.11.0(镜像 ID e4e417983384,基础镜像 php:8.2-apache)
  • 运行栈:《Signature PDF 1.11.0 Docker Compose 部署指南》 所建 Compose 栈——服务名 signaturepdf、容器 signaturepdf-1.11.0-web、端口 8140:80、网络 10.89.60.0/24(网关 10.89.60.1)、数据目录 ./data:/data
  • 本部署注入的环境变量:SERVERNAME=localhost:8140、UPLOAD_MAX_FILESIZE=48M、POST_MAX_SIZE=48M、MAX_FILE_UPLOADS=401、PDF_STORAGE_PATH=/data、DEFAULT_LANGUAGE=en_US.UTF-8;config.ini 其余键取模板默认(PDF_STORAGE_ENCRYPTION=false,但镜像预装 GPG,加密实际生效,见配置篇 5.7)
  • 框架:PHP Fat-Free Framework(响应头 X-Powered-By: Fat-Free Framework 可辨),路由全部集中在 src/app.php,无中间件层、无 API 版本前缀、无 OpenAPI 描述文件
  • 取证方式:宿主侧 curl http://localhost:8140/... 逐请求观察,docker exec 检查 /data 落盘状态;源码结论来自 v1.11.0 源码包(app.php、ApiController.class.php、MainController.class.php、PDFSignature.class.php、GPGCryptography.class.php、Compression.class.php、Config.class.php)
  • 测试素材:contract-test.pdf(2 页 A4、1061 字节)、overlay-b.svg(448 字节签名图层)、g3_hello.png(309 字节位图)、g1_final.pdf(20410 字节多层签名件,改名 multi-signed.pdf 使用)

💡 说明:本篇与部署篇、配置篇共用同一运行栈。配置键(PDF_LOCAL_PATH、ADMIN_AUTHORIZED_IP、PDF_STORAGE_PATH 等)的改法与生效机制见 《Signature PDF 1.11.0 配置参数详解》,本文只引用其结果并聚焦请求/响应协议层。所有回显均为实测原文照抄。

二、前置条件

  1. 栈已拉起且服务就绪:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8140/signature

实测回显:

200
  1. 备好测试素材(下文命令均以此目录为基准):
cd /opt/data/quzuo/tmp/signature-pdf
ls -la contract-test.pdf overlay-b.svg e2e/g3_hello.png e2e/g1_final.pdf

实测回显:

-rw-r--r-- 1 root root  1061 Nov  2 08:25 contract-test.pdf
-rw-r--r-- 1 root root   448 Nov  2 11:40 overlay-b.svg
-rw-r--r-- 1 root root   309 Nov  2 13:00 e2e/g3_hello.png
-rw-r--r-- 1 root root 20410 Nov  2 12:58 e2e/g1_final.pdf
  1. 共享域接口要求 PDF_STORAGE_PATH 已配置且目录可写(本部署经环境变量注入为 /data)。留空时 /api/share/new 直接 500,见 5.1 错误分支。

  2. 本地文件域接口要求 config.ini 配置 PDF_LOCAL_PATH(默认注释未开)。5.4 的实测通过热改 config.ini 完成,测毕已恢复。

三、API 总览

3.1 完整路由表

以下逐行摘自 v1.11.0 src/app.php(@name: 为 F3 命名路由别名),是本软件对外 HTTP 面的全集:

方法路径处理函数类别
GET/HEAD/MainController->index页面
GET/signatureMainController->signature页面
GET/signature/@hashMainController->signatureHash页面
POST/image2svgMainController->image2svg表单处理
POST/signMainController->sign表单处理
POST/shareMainController->share表单处理
GET/signature/@hash/delete/@keyMainController->signatureDeletion页面辅助
GET/signature/@hash/pdfMainController->signaturePdf页面辅助
POST/signature/@hash/saveMainController->signatureSave页面辅助
GET/signature/@hash/nblayersMainController->signatureNblayers页面辅助
GET/cronMainController->cron运维
GET/organizationMainController->organization页面
GET/metadataMainController->metadata页面
GET/ocrMainController->metadata页面
POST/ocrMainController->ocr表单处理
GET/administrationMainController->administration页面(受 IP 白名单)
GET/compressMainController->compression页面
POST/compressMainController->compress表单处理
GET/api/file/getApiController->fileGetJSON API(被遮蔽,见 5.4)
GET/api/file/getApiController->fileSaveJSON API(重复注册)
POST/api/share/newApiController->shareNewJSON API
GET/api/share/get/@hash/@symmkeyApiController->shareGetJSON API
GET/api/share/delete/@hash/@adminkeyApiController->shareDeleteJSON API

3.2 三类接口的分工

  • 真 JSON API(/api/* 4 条):面向程序化集成,机器可读的请求/响应契约。/api/share/* 三条覆盖「创建共享→取回 PDF→删除共享」全生命周期;/api/file/get 名义上读写服务器本地文件,实际因路由遮蔽只剩「写」,见 5.4。
  • 表单处理端点(/sign、/image2svg、/compress、/ocr、/share、/signature/@hash/save):本是首页各标签页的表单后端,但请求是标准 multipart/form-data、响应是文件流,同样可以直接用 curl 驱动,构成事实上的处理 API。本文全部实测。
  • 页面与运维端点(其余 GET):返回 HTML 或纯文本,其中 /cron 是设计给系统 crontab 调用的清理钩子(5.10),/administration 受 IP 白名单控制(配置篇 6.1)。

3.3 响应形态速查

形态出现位置实例
JSON/api/share/* 全部响应、/api/file/get 无(其错误走 HTML){"message":"File not found"}
二进制附件所有返回 PDF/SVG 的端点Content-Disposition: attachment; filename="..."
HTML 错误页F3 $f3->error() 与未捕获 PHP 异常<p>HTTP 403 (POST /sign)</p>、<p>Undefined variable $f3</p>
纯文本/compress、/ocr 的业务错误、/administration 拒绝、/signature/@hash/nblayers 计数File is not a PDF、0

四、认证与首次调用

4.1 无令牌体系:能力即密钥

Signature PDF 没有任何全局 API Key、Token、签名头或 OAuth。它的访问控制是三套彼此独立的机制:

  1. 持有即授权(共享域):创建共享时服务端生成三把随机密钥(hash、symmetrickey、adminkey),后续接口靠 URL 路径参数携带对应密钥鉴权。密钥即能力,泄露等于授权。
  2. 配置门(本地文件域):/api/file/get 要求 config.ini 设了 PDF_LOCAL_PATH,否则一律 403;路径还受「不含 ..、realpath 必须落在根目录内、文件存在、扩展名 pdf/png/jpg/jpeg」四重校验(Config.class.php::getApiLocalFilePath)。
  3. IP 白名单(管理页):/administration 校验来源 IP ∈ ADMIN_AUTHORIZED_IP(硬编码追加 localhost/127.0.0.1/::1)。

/sign、/image2svg、/compress、/ocr 四个处理端点完全匿名可用,无任何前置授权——暴露公网时靠上传限额与业务自身约束兜底。

4.2 首次调用:创建一个共享

选择 POST /api/share/new 作为「第一次 API 调用」,因为它是唯一自描述、返回机器可读 JSON 且不需要任何前置凭据的接口:

curl -s -D e2e/d5_new_headers.txt \
  -F "pdf=@contract-test.pdf" -F "duration=+1 hour" \
  http://localhost:8140/api/share/new

实测响应头(全文照抄):

HTTP/1.1 201 Created
Date: Mon, 02 Nov 2026 06:59:49 GMT
Server: Apache/2.4.69 (Debian)
X-Powered-By: Fat-Free Framework
Set-Cookie: PHPSESSID=fb8ed0c4188afb67fba54e84929d7304; path=/; HttpOnly; SameSite=Lax
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Pragma: no-cache
Cache-Control: no-cache, no-store, must-revalidate
Expires: Thu, 01 Jan 1970 00:00:00 +0000
Content-Length: 169
Content-Type: text/html; charset=UTF-8

实测响应体:

{"adminkey":"bBweCsR1WBynohVCmriC","hash":"pnvaytqkafup3sa","symmetrickey":"5OJeMxaulsGCZj6","url":"http:\/\/localhost:8140\/signature\/pnvaytqkafup3sa#5OJeMxaulsGCZj6"}

注意两点:JSON 体的 Content-Type 实际是 text/html; charset=UTF-8(源码直接 echo json_encode(...),未设头),客户端解析时不要依赖 MIME 嗅探;url 字段是给人用的页面链接,# 片段后的 symmetrickey 由前端 JS 写入同名 Cookie,程序化取件走 5.2 的路径参数形式即可。

4.3 密钥与参数约定

名称长度/字符集生成与存放用途
hash15 位,小写字母+数字GPGCryptography::createSymmetricKey() 产物转小写;即 /data/<hash> 目录名共享的唯一标识,出现在所有共享域 URL 中
symmetrickey15 位,大小写字母+数字只回传给调用方;浏览器场景下存于 Cookie 与链接 # 片段GPG 对称解密口令,/api/share/get 的凭据
adminkey20 位,大小写字母+数字明文写入 /data/<hash>.admin(实测 20 字节),同时回传/api/share/delete 的凭据
durationPHP 相对时间串原文写入 /data/<hash>.expire(+1 hour 实测 7 字节),并把该文件 mtime 设为到期时刻共享保留期;非法串直接 500(5.1)

密钥空间为 0-9a-zA-Z(62 字符,random_int 采样),15 位约 89 bit、20 位约 119 bit。页面默认保留期选项为 1hour/1day/1week/1month/6months/1year,API 侧则可传任意合法 PHP 相对时间串。

五、各 API 域详解

5.1 共享域:POST /api/share/new

请求:multipart/form-data,字段 pdf(文件,MIME 必须以 application/pdf 开头)与 duration(字符串,见 4.3)。无其他参数。

成功分支(4.2 已示 201 与 JSON 体)。落盘状态实测:

docker exec signaturepdf-1.11.0-web ls -la /data/pnvaytqkafup3sa.admin /data/pnvaytqkafup3sa.expire /data/pnvaytqkafup3sa
-rw-r--r-- 1 www-data www-data   20 Nov  2 06:59 /data/pnvaytqkafup3sa.admin
-rw-r--r-- 1 www-data www-data    7 Nov  2  2026 /data/pnvaytqkafup3sa.expire

/data/pnvaytqkafup3sa:
total 16
drwxr-xr-x 2 www-data www-data 4096 Nov  2 06:59 .
drwxr-xr-x 4 www-data www-data 4096 Nov  2 06:59 ..
-rw-r--r-- 1 www-data www-data   99 Nov  2 06:59 filename.txt.gpg
-rw-r--r-- 1 www-data www-data 1141 Nov  2 06:59 original.pdf.gpg

目录内只有 original.pdf.gpg(1061 字节原文加密后 1141 字节)与 filename.txt.gpg(原始文件名 99 字节密文)——GPG 加密实际开启(配置篇 5.7),密钥不在服务端。

错误分支全集:

缺 duration 字段——400,共享域唯一带明确 JSON 错误的参数校验:

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@contract-test.pdf" http://localhost:8140/api/share/new
{"message":"Missing parameter `duration`"}HTTP 400

duration 非法(不是 PHP 相对时间串)——500 HTML 错误页,且留下孤儿目录:

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@contract-test.pdf" -F "duration=bogus" http://localhost:8140/api/share/new
docker exec signaturepdf-1.11.0-web ls /data
HTTP/1.1 500 Internal Server Error
...
<p>date_modify(): Failed to parse time string (bogus) at position 0 (b): The timezone could not be found in the database</p>
HTTP 500
315zze24jczpa3a5bh6i.admin
dev0mcgeql7uwf3
dev0mcgeql7uwf3.expire
local-sample.pdf
opg6whyvs6q3nio
opg6whyvs6q3nio.expire
probe.pdf

源码顺序是 mkdir → 写 .expire → touch 到期时刻,date_modify 在第三步抛异常,于是留下一个空目录 dev0mcgeql7uwf3 加一个 .expire(实测 ls -la 该目录为空,.expire 内容即 bogus 5 字节)。

上传非 PDF——也是 500 且留孤儿目录(pdf 字段 MIME 校验失败后文件未落位,createShare 的 rename 扑空):

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@e2e/f_notpdf.txt" -F "duration=+1 hour" http://localhost:8140/api/share/new
HTTP/1.1 500 Internal Server Error
...
<p>rename(/tmp/pdfsignature_share_0WAkfW2Kqx8lO1x6ae8306dd85cc2.65565397EopA2t_original.pdf): Failed to open stream: No such file or directory</p>
HTTP 500

实测孤儿目录 opg6whyvs6q3nio 同样为空、.expire 内容 +1 hour(7 字节)。注意:源码里 Invalid file uploaded 的 400 JSON 分支(count($files) 为 0)在「字段名正确但 MIME 非 PDF」场景下不会触发,实际走到 500——集成时以 500 为准。

PDF_STORAGE_PATH 留空——500,错误信息是 PHP 弃用告警而非业务文案:

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@contract-test.pdf" -F "duration=+1 hour" http://localhost:8140/api/share/new
HTTP/1.1 500 Internal Server Error
...
<p>is_dir(): Passing null to parameter #1 ($filename) of type string is deprecated</p>
HTTP 500

(实验做法:把 config.ini 的 PDF_STORAGE_PATH=/data 热改为空串,测毕恢复。shareNew 直接对未配置的全局变量做 is_dir(),PHP 8.2 弃用告警被 F3 错误页透出。)

5.2 共享域:GET /api/share/get/@hash/@symmkey

请求:无请求体,凭据全在路径。取回 4.2 创建的共享:

curl -s -D e2e/d5_get_headers.txt -o e2e/d5_get.pdf \
  http://localhost:8140/api/share/get/pnvaytqkafup3sa/5OJeMxaulsGCZj6

实测响应头(关键行):

HTTP/1.1 200 OK
Content-Disposition: attachment; filename="contract-test_signe-0x.pdf"
Content-Length: 1061
Content-Type: application/pdf

落盘 e2e/d5_get.pdf 实测 1061 字节,与原始件一致。下载文件名规则:filename.txt(原件基名)中 .pdf 替换为 _signe-<N>x.pdf,N 是当前签名图层数(PDFSignature::getPublicFilename,图层即目录内 *.svg.pdf 文件数),0 层即 -0x。

错误分支:

密钥错——403 JSON:

curl -s -w 'HTTP %{http_code}\n' http://localhost:8140/api/share/get/3xksmy2xp2q7syb/ZZZwrongkey12345
{"message":"Unauthorized access"}HTTP 403

hash 不存在——不是 404 而是 500,异常信息泄露存储根路径:

curl -s -w 'HTTP %{http_code}\n' http://localhost:8140/api/share/get/zzzzzzzzzzzzzzzz/AAAAAAAAAAAAAAA
HTTP/1.1 500 Internal Server Error
...
<p>touch(): Unable to create file /data/zzzzzzzzzzzzzzzz/.lock because No such file or directory</p>
HTTP 500

(shareGet 不做目录存在性检查,直接构造 PDFSignature 并 touch .lock 失败。)

副作用提醒:每次 share/get 成功后,服务端解密产生的临时明文与新生成的 final.pdf 会被 clean() 回收(DEBUG=false 时),但共享目录本身保留、可重复取回,直至到期或被删。

5.3 共享域:GET /api/share/delete/@hash/@adminkey

请求:路径携带 hash + adminkey。删除 4.2 的共享:

curl -s -D e2e/d5_del_headers.txt http://localhost:8140/api/share/delete/pnvaytqkafup3sa/bBweCsR1WBynohVCmriC
docker exec signaturepdf-1.11.0-web ls /data

实测:

HTTP/1.1 200 OK
{"message":"Shared PDF successfully deleted"}

删除后 /data 目录列表:

315zze24jczpa3a5bh6i.admin
local-sample.pdf
opg6whyvs6q3nio
opg6whyvs6q3nio.expire
probe.pdf

pnvaytqkafup3sa 目录与 .admin、.expire 旁路文件一并清除(shareDelete 依次 hardUnlink(.lock)、hardUnlink(目录)、unlink(.admin)、unlink(.expire))。

错误分支(用另一组凭据实测):

curl -s -w 'HTTP %{http_code}\n' http://localhost:8140/api/share/delete/<hash>/ZZZnotadminkey1
curl -s -w 'HTTP %{http_code}\n' http://localhost:8140/api/share/delete/zzzzzzzzzzzzzzzzzzzz/whateverkey12345
{"message":"Unauthorized access"}HTTP 403
{"message":"File not found"}HTTP 404

adminkey 为明文比对(file_get_contents(<hash>.admin) !== adminkey),错 key 403、目录不存在 404,与 share/get 的 500 行为不同——同一共享域内错误码语义不统一,集成时逐接口处理。对同一共享重复 delete:第二次即落入 404 分支,天然幂等。

5.4 本地文件域:GET /api/file/get(v1.11.0 实证 bug:路由遮蔽)

这是本文最重要的坑。app.php 把 GET /api/file/get 注册了两次:

$f3->route('GET @api_file_get: /api/file/get', 'ApiController->fileGet');
$f3->route('GET @api_file_save: /api/file/get', 'ApiController->fileSave');

F3 同名路由后注册者生效,实际命中的是 fileSave——它把请求体原样写入 path 指定的文件(去掉扩展名后强制拼 .pdf)。也就是说:名义上的「读」接口在 v1.11.0 完全不可用,且一次无请求体的 GET 会把目标文件清空。

门控分支(未配 PDF_LOCAL_PATH,默认状态):

curl -s -w 'HTTP %{http_code}\n' 'http://localhost:8140/api/file/get?path=probe.pdf'
HTTP/1.1 403 Forbidden
...
<p>HTTP 403 (GET /api/file/get?path=probe.pdf)</p>
HTTP 403

配置门通过后的校验分支(热设 PDF_LOCAL_PATH=/data 实测):

curl -s -w 'HTTP %{http_code}\n' 'http://localhost:8140/api/file/get?path=../../etc/passwd'
curl -s -w 'HTTP %{http_code}\n' 'http://localhost:8140/api/file/get?path=nothere.pdf'
curl -s -w 'HTTP %{http_code}\n' 'http://localhost:8140/api/file/get?path=local-sample.txt'
HTTP/1.1 403 Forbidden
<p>HTTP 403 (GET /api/file/get?path=../../etc/passwd)</p>
HTTP 403
HTTP/1.1 403 Forbidden
<p>HTTP 403 (GET /api/file/get?path=nothere.pdf)</p>
HTTP 403
HTTP/1.1 403 Forbidden
HTTP 403

穿越、不存在、非法扩展名全部 403,getApiLocalFilePath 的四重校验有效。

遮蔽 bug 主案:合法路径、无请求体 GET——200 但 0 字节,且源文件被截断:

curl -s -D e2e/d5_fileget_headers.txt -o /dev/null 'http://localhost:8140/api/file/get?path=local-sample.pdf'
docker exec signaturepdf-1.11.0-web ls -la /data/local-sample.pdf
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Length: 0
Content-Type: text/html; charset=UTF-8
-rw-r--r-- 1 www-data www-data 0 Nov  2 06:59 /data/local-sample.pdf

带请求体重发,则请求体被写回目标文件(1061 字节恢复):

curl -s -X GET -w 'HTTP %{http_code}\n' --data-binary @contract-test.pdf 'http://localhost:8140/api/file/get?path=local-sample.pdf'
docker exec signaturepdf-1.11.0-web ls -la /data/local-sample.pdf
HTTP 200
-rw-r--r-- 1 www-data www-data 1061 Nov  2 07:00 /data/local-sample.pdf

(注意必须 -X GET 配合 --data-binary;直接 --data-binary 会发成 POST。)POST 方法未注册,405:

curl -s -w 'HTTP %{http_code}\n' -F "x=1" http://localhost:8140/api/file/get
HTTP/1.1 405 Method Not Allowed
<p>HTTP 405 (POST /api/file/get)</p>
HTTP 405

结论与用法建议:v1.11.0 上该端点只能当「按路径写文件」用(语义危险:路径即写入目标),不要指望它读文件;若需程序化取回服务器本地 PDF,目前只能先经 /api/share/new 转成共享再 share/get。上游修复方向是删掉重复注册行(fileGet 的读逻辑本身完整:pdf/png/jpg/jpeg 对应 Content-Type、Content-Disposition: attachment)。

5.5 签名处理端点:POST /sign

请求:multipart/form-data,字段 pdf(必须)、svg[](可多个,MIME 须 image/svg+xml)、flatten(可选,任意真值触发展平)。

成功分支:

curl -s -D e2e/s3_sign2_headers.txt -o e2e/s3_sign2.pdf \
  -F "pdf=@contract-test.pdf" -F "svg[]=@overlay-b.svg" \
  http://localhost:8140/sign

实测响应头(关键行):

HTTP/1.1 200 OK
Content-Disposition: attachment; filename="contract-test_signe.pdf"
Content-Length: 7258
Content-Type: application/pdf

1061 字节原件 + 1 个 SVG 图层 → 7258 字节响应,文件名规则 _signe.pdf(此端点不带 -Nx 后缀)。加 flatten=1 走 Ghostscript 展平,体积显著增大(图层栅格化):

curl -s -D e2e/s3_sign_flat_headers.txt -o e2e/s3_sign_flat.pdf \
  -F "pdf=@contract-test.pdf" -F "svg[]=@overlay-b.svg" -F "flatten=1" \
  http://localhost:8140/sign
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="contract-test_signe.pdf"
Content-Length: 68808

错误分支:

缺 svg[]——403 HTML(无任何图层时拒签):

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@contract-test.pdf" http://localhost:8140/sign
HTTP/1.1 403 Forbidden
<p>HTTP 403 (POST /sign)</p>
HTTP 403

pdf 字段传非 PDF——500「Undefined variable $f3」(v1.11.0 实证 bug):

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@e2e/f_notpdf.txt" -F "svg[]=@overlay-b.svg" http://localhost:8140/sign
HTTP/1.1 500 Internal Server Error
<p>Undefined variable $f3</p>
HTTP 500

根因在 MainController::sign:MIME 校验回调里写了 $f3->error(403),但该闭包没有 use ($f3),PHP 8 下触发未定义变量错误——本应 403 的输入校验分支变成了 500。集成侧按「非 PDF 输入 → 500」处理即可,勿重试。

5.6 图像矢量化端点:POST /image2svg

请求:任意字段名、image/* MIME 的文件一个。位图签名转矢量(convert → mkbitmap → potrace 三连):

curl -s -D e2e/f_i2s_headers.txt -o e2e/f_i2s_out.svg \
  -F "image=@e2e/g3_hello.png" http://localhost:8140/image2svg

实测响应头(关键行)与响应体开头:

HTTP/1.1 200 OK
Content-Length: 531
Content-Type: image/svg+xml
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
 "http://www.w3.org/TR/2001/RE...

309 字节 PNG → 531 字节 SVG。非图像 MIME 的输入走 403($imageFile 为空时 $f3->error(403))。此端点即前端「上传图片当签名」按钮的后端,产出的 SVG 直接喂给 5.5 的 svg[] 字段即可串成位图签名流水线。

5.7 压缩端点:POST /compress

请求:字段 pdf(必须 PDF)、compressionType(可选:medium→Ghostscript /ebook、low→/printer、其余(含缺省)→/screen)。

三种结局实测。压不动——204 No Content(输出不比输入小就不返回文件,小 PDF 必走此分支):

curl -s -w 'HTTP %{http_code} bytes %{size_download}\n' \
  -F "pdf=@contract-test.pdf" http://localhost:8140/compress
HTTP/1.1 204 No Content
HTTP 204 bytes 0

压缩有效——200 附件(20410 字节多层签名件,compressionType=medium):

curl -s -D e2e/f_comp_ok_headers.txt -o e2e/f_comp_ok.pdf \
  -F "pdf=@e2e/multi-signed.pdf" -F "compressionType=medium" \
  http://localhost:8140/compress
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="multi-signed_compressed.pdf"
Content-Length: 18553
Content-Type: application/pdf

20410 → 18553 字节。非 PDF——403 纯文本(此端点不走 F3 错误页):

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@e2e/f_notpdf.txt" http://localhost:8140/compress
HTTP/1.1 403 Forbidden
File is not a PDF
HTTP 403

5.8 OCR 端点:POST /ocr

请求:字段 pdf。官方镜像未预装 ocrmypdf(容器内 which ocrmypdf 无结果),实测直接 500,且异常文案泄露临时文件路径:

curl -s -w 'HTTP %{http_code}\n' -F "pdf=@contract-test.pdf" http://localhost:8140/ocr
HTTP/1.1 500 Internal Server Error
...
<p>unlink(/tmp/20261102070724_6ae836ac22f9a_0f0952bc23256fceb5ce62d2250a3940_ocr.pdf): No such file or directory</p>
HTTP 500

源码逻辑:shell_exec("ocrmypdf ...") 返回 false → 走失败分支 → 对从未生成的 _ocr.pdf 执行 unlink 又抛二次异常,把「缺件」伪装成了「删除失败」。要在官方镜像用 OCR,需自装 ocrmypdf 后重测;本环境判定该端点不可用。

5.9 协作页面辅助端点(/signature/@hash/*)

这组端点配合 4.2 的 url 使用,鉴权凭据是名为 <hash> 的 Cookie(值 = symmetrickey,由前端从链接 # 片段写入;curl 场景手工注入 Cookie 文件即可复现)。完整闭环实测:

rm -f e2e/d5_cookies.txt
curl -s -c e2e/d5_cookies.txt -o /dev/null http://localhost:8140/signature
printf "#HttpOnly_localhost\tFALSE\t/\tFALSE\t0\t%s\t%s\n" "<hash>" "<symmetrickey>" >> e2e/d5_cookies.txt
curl -s -b e2e/d5_cookies.txt -D e2e/d5_pdf0_headers.txt -o e2e/d5_pdf0.pdf http://localhost:8140/signature/<hash>/pdf
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="contract-test_signe-0x.pdf"
Content-Length: 1061

签名保存(POST 图层,无 JSON 响应,302 回页面):

curl -s -b e2e/d5_cookies.txt -w 'save HTTP %{http_code} redirect=%{redirect_url}\n' \
  -F "svg[]=@overlay-b.svg" http://localhost:8140/signature/<hash>/save
save HTTP 302 redirect=http://localhost:8140/signature/pnvaytqkafup3sa#5OJeMxaulsGCZj6

图层计数与再次取件:

curl -s -b e2e/d5_cookies.txt http://localhost:8140/signature/<hash>/nblayers
curl -s -b e2e/d5_cookies.txt -D e2e/d5_pdf1_headers.txt -o e2e/d5_pdf1.pdf http://localhost:8140/signature/<hash>/pdf
docker exec signaturepdf-1.11.0-web ls -la /data/<hash>
nblayers=1
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="contract-test_signe-1x.pdf"
Content-Length: 7258
total 40
-rw-r--r-- 1 www-data www-data 7355 Nov  2 06:59 20261102065950528403.sign.pdf.gpg
-rw-r--r-- 1 www-data www-data 6476 Nov  2 06:59 20261102065950528403.svg.pdf.gpg
-rw-r--r-- 1 www-data www-data   99 Nov  2 06:59 filename.txt.gpg
-rw-r--r-- 1 www-data www-data 7335 Nov  2 06:59 final.pdf.gpg
-rw-r--r-- 1 www-data www-data 1141 Nov  2 06:59 original.pdf.gpg

取件文件名后缀从 -0x 变 -1x,与 nblayers 一致;目录内新增 <时间戳>.svg.pdf.gpg(图层)、<时间戳>.sign.pdf.gpg 与 final.pdf.gpg(合成结果)。错误分支:不带 Cookie 直接 GET /signature/<hash>/pdf → 403(verifyEncryption 失败,文案 Unable to decrypt pdf because of wrong symmetric key);hash 不存在 → 500(同 5.2 的 touch .lock 异常)。GET /signature/@hash/delete/@key 与 5.3 逻辑同构(页面版,成功 302 回首页,失败一律 403),本文未单独实测。

5.10 运维端点:GET /cron

设计给系统 crontab 的清理钩子:遍历 <PDF_STORAGE_PATH>*.expire,filemtime 已过期的删除共享目录、.expire,但不删 .admin。实测(当时 /data 有 3 个历史遗留:空孤儿目录 dev0mcgeql7uwf3(已过期)、孤儿目录 opg6whyvs6q3nio(+1 hour 未到期)、残留 315zze24jczpa3a5bh6i.admin):

curl -s -w 'HTTP %{http_code} bytes %{size_download}\n' http://localhost:8140/cron
docker exec signaturepdf-1.11.0-web ls -la /data
HTTP/1.1 200 OK
HTTP 200 bytes 0
total 24
-rw-r--r-- 1 www-data www-data   20 Nov  2 05:02 315zze24jczpa3a5bh6i.admin
drwxr-xr-x 2 www-data www-data 4096 Nov  2 06:40 opg6whyvs6q3nio
-rw-r--r-- 1 www-data www-data    7 Nov  2  2026 opg6whyvs6q3nio.expire
-rw-r--r-- 1 www-data www-data 1061 Nov  2 06:42 probe.pdf

对照执行前列表:dev0mcgeql7uwf3 目录与其 .expire 被清走(即使目录是空的,孤儿也按到期回收);opg6whyvs6q3nio 未到期原样保留;315zze24jczpa3a5bh6i.admin 因 cron 不处理 .admin 而永久残留(20 字节无害,但监控目录内容时注意)。响应恒为 200、零字节,无输出可解析,幂等可重跑。建议部署侧加:

* * * * * curl -sf http://localhost:8140/cron >/dev/null

5.11 页面端点速记

GET /、/signature、/signature/@hash、/organization、/metadata、/ocr、/compress 返回 HTML 单页应用外壳,程序化价值仅两处:/metadata 页内嵌 var defaultFields = {...} 可直接读出元数据默认字段配置(实测 PDF_LOCAL_PATH 同批热改后回显 {"title":{"type":"text"},"author":{"type":"text"},"creationDate":{"type":"date"},"subject":{"type":"email"}});/administration 在未授权 IP 下返回 200 + 纯文本拒绝(非 403!):

curl -s -w '\nHTTP %{http_code}\n' http://localhost:8140/administration
You (10.89.60.1) are not allowed to access this file. Check MainController.class.php for more information.
HTTP 200

源码用 die() 直接输出并回 200,且泄露来源 IP 与文件名——做健康检查/监控时勿以 200 判定「管理页开放」。

六、通用约定:分页、过滤与排序

本软件没有任何分页、过滤、排序参数——如实说明:共享域没有「列出我的共享」类接口(不存在可枚举的列表,故无从分页),处理端点是单发请求-响应,/api/file/get 的 path 查询参数只是定位符。集成时需要的「集合语义」只能自建索引(保存每次 share/new 返回的三元组)。

替代性的通用约定汇总:

约定内容
请求编码上传一律 multipart/form-data;无 JSON 请求体接口
凭据位置全在 URL 路径参数(/api/share/*)或 Cookie(/signature/<hash>/*),无 Header 凭据
durationPHP 相对时间串(+1 hour、+1 day…),非法值 500 且留孤儿目录
密钥长度hash 15(小写)、symmetrickey 15(大小写+数字)、adminkey 20
文件命名共享取回 <原件基名去.pdf>_signe-<图层数>x.pdf;/sign 固定 _signe.pdf;压缩固定 _compressed.pdf
错误契约共享域 JSON;F3 错误为 HTML 错误页;/compress、/ocr、/cron、/administration 纯文本;状态码语义逐接口不一致(403/404/500 混用)
JSON 的 Content-Type恒为 text/html; charset=UTF-8,解析端勿依赖 MIME
幂等性delete 重复调用第二次 404;/cron 可重跑;share/new 每次全新三元组
大小限额单文件受 UPLOAD_MAX_FILESIZE/POST_MAX_SIZE(本部署 48M),多文件受 MAX_FILE_UPLOADS(本部署 401)

七、常见问题

Q1:GET /api/file/get 返回 200 但 0 字节,服务器上的文件还被打空了? v1.11.0 实证 bug:app.php 对 GET /api/file/get 重复注册,fileSave 遮蔽 fileGet,无请求体的 GET 等价于「用空串覆写目标文件」(5.4 实测:1061 字节 → 0 字节)。任何调用方在配置 PDF_LOCAL_PATH 后都可能踩中,务必确认调用的是「写」语义。

Q2:/sign 传了非 PDF 返回 500 Undefined variable $f3,是我用错了吗? 用法没错,是源码闭包漏 use ($f3) 的缺陷(5.5):本应回 403 的输入校验变成 500。按「非 PDF → 500、不重试」处理即可。

Q3:/api/share/new 用非法 duration 或非 PDF 调用失败后,/data 里多了空目录怎么办? 实证行为:两种失败都留下空目录 + .expire(5.1,F8 实测目录为空)。到期后 GET /cron 会按 .expire 的 mtime 回收(5.10 实测 dev0mcgeql7uwf3 被清走);若 .expire 的 mtime 未正确设置(如 bogus 场景 touch 落当前时刻),同样在下次 cron 扫到时清理。

Q4:share/get 对不存在的 hash 为什么是 500 而不是 404? shareGet 无目录存在性检查,直接 touch <目录>/.lock 失败抛异常(5.2)。错误信息还会泄露存储根路径(/data/...),生产环境建议 DEBUG=false 并在反代层屏蔽 500 响应体。

Q5:/ocr 永远 500 unlink(..._ocr.pdf): No such file or directory? 官方镜像未装 ocrmypdf(5.8)。这是缺件被二次异常掩盖的表现,不是文件问题;自装组件前该端点视为不可用。

Q6:/administration 返回 200,管理页是不是裸奔? 不是。200 体是 die() 输出的拒绝文案(5.11),真实配置面板只对 ADMIN_AUTHORIZED_IP 白名单开放。监控脚本勿把 200 当「可访问」。

Q7:共享到期的 PDF 谁负责删? 没有自动删除——必须外部定时调 GET /cron(5.10)。cron 只清目录与 .expire,.admin 文件会永久残留(实测 315zze24jczpa3a5bh6i.admin),需要的话按 .admin 无对应目录的规则自行巡检。

Q8:想程序化「取回服务器本地已有 PDF」,有什么正路? v1.11.0 没有可用通道(5.4 的读能力被遮蔽)。变通:把文件放进 PDF_STORAGE_PATH 之外再经 share/new 上传(客户端侧中转),或等上游修复重复注册后使用 fileGet。

八、参考链接