让 Photopea 的“保存”直接存到你自己的服务器:在打开 Photopea 的配置里写上
server地址,用户按保存时,Photopea 把文件 POST 过来。本文用 Python 标准库写一个接收端,负责解析 2000 字节的 JSON 头、按start/size切出文件、加上 CORS 头,并返回提示消息。
先看清成品
解析逻辑在本机用 pytest 跑了 4 个测试,全部通过。接收端在本机启动后,用 curl 发送了按官方格式拼出的请求:返回
200和{"message": "已保存 local_1_2-….png"},响应带Access-Control-Allow-Origin: *,OPTIONS 请求返回204。没有让真实的 Photopea 向它保存(那需要公网 HTTPS 地址和手动点保存),请求格式以官方 API 页面为准。
注意:Photopea 网页版和 API 可以免费使用;去广告的 Premium、白标嵌入用的 Distributor 账号的价格和条款以官方页面为准,本文不列数字。只编辑你有权使用的图片和字体,不要用它去除他人水印,也不要共享 Premium 账号。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
receiver.py |
标准库 HTTP 服务,监听 8085 端口 |
| 格式白名单 | png/jpg/psd/webp/svg/pdf |
| 安全检查 | 50 MB 上限,start/size 越界检查,文件名清洗 |
make_payload.py |
本地拼一个合法的测试请求 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.10 以上(本机 3.13) | 无第三方依赖,测试要 pytest |
| 公网 HTTPS | 正式使用时需要 | Photopea 在 https 页面上运行,向 http 地址发请求会被浏览器拦截 |
文件结构
27-photopea-C-src/
├── receiver.py
├── test_receiver.py
└── make_payload.py
第 1 步:理解请求格式
打开 Photopea 时,在网址的 # 后面放一段 JSON 配置(需 URL 编码),例如:
{
"files": ["https://example.com/poster.psd"],
"server": {
"version": 1,
"url": "https://your-domain.example/save",
"formats": ["psd:true", "png"]
}
}
用户保存时,请求体的结构:
[ 2000 字节:JSON 头,不足部分补齐 ][ 文件 1 ][ 文件 2 ] ...
JSON 头:{"source": "...", "versions": [{"format": "psd", "start": 0, "size": N}, ...]}
start 从 2000 字节之后开始算
服务器返回的 JSON 里可以带 message(显示给用户),还可以带 script、newSource,字段含义见官方 API 页面。本文只用了 message。
第 2 步:接收端
receiver.py:
"""接收 Photopea "server" 保存请求:请求体前 2000 字节是 JSON(不足补空格),之后是文件数据。
JSON 形如 {"source": "...", "versions": [{"format": "png", "start": 0, "size": 1234}, ...]},
start 相对于 2000 字节头之后。"""
import json, re, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
OUT = Path("saved"); OUT.mkdir(exist_ok=True)
HEADER = 2000
MAX_BODY = 50 * 1024 * 1024
ALLOWED = {"png", "jpg", "psd", "webp", "svg", "pdf"}
def parse(body: bytes):
if len(body) < HEADER:
raise ValueError("请求体不足 2000 字节")
meta = json.loads(body[:HEADER].decode("utf-8").strip().rstrip("\x00"))
data = body[HEADER:]
files = []
for v in meta.get("versions", []):
fmt = str(v["format"]).split(":")[0].lower() # "jpg:0.8" -> "jpg"
if fmt not in ALLOWED:
raise ValueError(f"不接受的格式 {fmt}")
start, size = int(v["start"]), int(v["size"])
if start < 0 or size < 0 or start + size > len(data):
raise ValueError("start/size 越界")
files.append((fmt, data[start:start + size]))
return meta, files
def safe_name(source: str) -> str:
stem = re.sub(r"[^\w.-]", "_", source.split("/")[-1])[:60] or "doc"
return stem.rsplit(".", 1)[0]
class H(BaseHTTPRequestHandler):
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "*")
def do_OPTIONS(self):
self.send_response(204); self._cors(); self.end_headers()
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
if n > MAX_BODY:
return self._reply(413, {"message": "文件太大"})
try:
meta, files = parse(self.rfile.read(n))
except (ValueError, KeyError, json.JSONDecodeError) as e:
return self._reply(400, {"message": f"保存失败:{e}"})
stem, ts = safe_name(meta.get("source", "")), time.strftime("%Y%m%d-%H%M%S")
names = []
for fmt, blob in files:
p = OUT / f"{stem}-{ts}.{fmt}"
p.write_bytes(blob); names.append(p.name)
self._reply(200, {"message": "已保存 " + ", ".join(names)})
def _reply(self, code, obj):
b = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code); self._cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
if __name__ == "__main__":
print("listening on :8085"); ThreadingHTTPServer(("0.0.0.0", 8085), H).serve_forever()
要点:
"jpg:0.8"这样的格式要先去掉冒号后面的参数,再查白名单。- 文件名只取
source的最后一段,非法字符替换成下划线,避免../之类的路径穿越。 - 出错时也返回带 CORS 头的 JSON,否则浏览器连错误信息都读不到。
第 3 步:测试
test_receiver.py:
import json, pytest
from receiver import parse, safe_name
def payload(versions, data):
head = json.dumps({"source": "local,1,2", "versions": versions}).encode()
return head.ljust(2000, b" ") + data
def test_two_versions():
meta, files = parse(payload([{"format": "png", "start": 0, "size": 3}, {"format": "jpg:0.8", "start": 3, "size": 2}], b"PNGJP"))
assert files == [("png", b"PNG"), ("jpg", b"JP")]
def test_out_of_range():
with pytest.raises(ValueError, match="越界"):
parse(payload([{"format": "png", "start": 0, "size": 99}], b"abc"))
def test_bad_format():
with pytest.raises(ValueError, match="格式"):
parse(payload([{"format": "exe", "start": 0, "size": 1}], b"a"))
def test_safe_name():
assert safe_name("https://x.com/a/../海报 v2.psd") == "海报_v2"
pip install pytest
python -m pytest -q
检查结果:4 passed。
第 4 步:本地联调
make_payload.py:
import json, sys
png = open(sys.argv[1], "rb").read()
head = json.dumps({"source": "local,1,2", "versions": [{"format": "png", "start": 0, "size": len(png)}]}).encode()
sys.stdout.buffer.write(head.ljust(2000, b" ") + png)
python receiver.py &
printf '\x89PNG\r\n\x1a\nfake' > t.png
python make_payload.py t.png > body.bin
curl -i -X POST --data-binary @body.bin localhost:8085/
本机输出(节选):
HTTP/1.0 200 OK
Access-Control-Allow-Origin: *
{"message": "已保存 local_1_2-20261021-150114.png"}
检查结果:saved/ 目录下多了一个文件,内容就是 t.png。
第 5 步:上线
- 把服务部署在 HTTPS 后面(例如反向代理到 8085 端口)。
- 生成 Photopea 地址:
https://www.photopea.com#加上 URL 编码后的配置 JSON。 - 打开这个地址,编辑后按 File → Save,Photopea 会显示服务器返回的
message。 - 正式使用一定要加鉴权,例如在
url里带一次性 token,由服务端校验。本文示例没有鉴权,只适合本地使用。
调试示例
json.loads 报 Extra data
本机故意复现了这个错误:把 JSON 头和文件直接拼在一起,不补齐到 2000 字节。接收端读前 2000 字节时把文件内容也读了进来,报 JSONDecodeError Extra data: line 1 column 72 (char 71)。按格式用 ljust(2000, b" ") 补齐就正常了。接收端另外检查了请求体不足 2000 字节的情况。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| Photopea 提示保存失败 | 缺 CORS 头,或用了 http 地址 | 用 HTTPS 并带上 Access-Control-Allow-Origin |
start/size 越界 |
请求被代理截断了 | 调大代理的请求体限制 |
413 文件太大 |
超过 50 MB | 按需修改 MAX_BODY |
| 所有人都能往里写 | 示例没有鉴权 | 在地址里带 token 并校验 |
模板规则
- 先按官方格式写解析函数和测试,再写 HTTP 部分。
- 外部传来的文件名一律清洗。
- 每个响应(包括出错的)都带 CORS 头。
- 公网部署必须加鉴权和大小限制。
项目资源
| 用途 | 地址 |
|---|---|
| Photopea API 总览(配置 JSON、server 保存) | https://www.photopea.com/api/ |
| 账号与 Distributor | https://www.photopea.com/api/accounts |
| Python http.server | https://docs.python.org/3/library/http.server.html |
| MDN:CORS | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS |
server 保存让 Photopea 变成你系统里的编辑器:素材从你的服务器打开,编辑完再存回去。协议只有“2000 字节头 + 文件”这一条,用测试把它固定下来,后面接存储、版本管理都很直接。