设计实践

让 Photopea 的“保存”直接存到你自己的服务器

让 Photopea 的“保存”直接存到你自己的服务器:在打开 Photopea 的配置里写上 server 地址,用户按保存时,Photopea 把文件 POST 过来。本文用 Python 标准库写一个接收端,负责解析 2000 字节的 JSON 头、按 start/size 切出文件、加上 CORS 头,并返回提示消息。

BINBHO · 图文教程

让 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 步:上线

  1. 把服务部署在 HTTPS 后面(例如反向代理到 8085 端口)。
  2. 生成 Photopea 地址:https://www.photopea.com# 加上 URL 编码后的配置 JSON。
  3. 打开这个地址,编辑后按 File → Save,Photopea 会显示服务器返回的 message。
  4. 正式使用一定要加鉴权,例如在 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 字节头 + 文件”这一条,用测试把它固定下来,后面接存储、版本管理都很直接。