From 79a463c5249f34256ee451a570b95c5012061d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 18:51:36 +0800 Subject: [PATCH 1/6] fix: Python 3.11 compatibility - update deps and fix setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix BOM character in requirements.txt - Fix setup.py to strip newlines from requirements - Upgrade pymongo 3.7.2 → 4.6.3 (C extension compat with 3.11) - Upgrade docutils 0.14 → 0.20.1 (removed 'rU' mode in 3.11) - Pre-install setuptools-scm in Dockerfile to avoid PEP 517 build isolation issues - Upgrade pip before installing to ensure latest resolver --- Dockerfile | 5 +++-- requirements.txt | 4 ++-- setup.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 06c0720..0104d6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM python:3.6-alpine +FROM python:3.11-alpine WORKDIR /pb ADD . /build -RUN apk add --no-cache --virtual .build-deps git \ +RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \ + && pip install --upgrade pip setuptools setuptools-scm \ && pip install /build \ && apk del .build-deps diff --git a/requirements.txt b/requirements.txt index 56254b3..f56c2d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ certifi==2024.8.30 charset-normalizer==3.4.0 click==8.1.7 -docutils==0.14 +docutils==0.20.1 Flask==1.0.2 idna==3.10 itsdangerous==1.1.0 @@ -9,7 +9,7 @@ Jinja2==2.10.3 Markdown==3.0.1 MarkupSafe==1.1.1 Pygments==2.2.0 -pymongo==3.7.2 +pymongo==4.6.3 python-dateutil==2.7.3 pytz==2018.5 pyxdg==0.26 diff --git a/setup.py b/setup.py index 5a31ce0..4e1d419 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ with open('requirements.txt') as f: - install_requires = list(f) + install_requires = [line.strip() for line in f if line.strip()] setuptools.setup( use_scm_version=True, From bacb936e4c750c5353d97a9a7cb98f99fca2bc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 18:54:29 +0800 Subject: [PATCH 2/6] docs: add README.md with deploy instructions and troubleshooting --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f45e072 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# pb 部署备忘 + +## 启动/停止 + +```bash +# 后台启动 +cd ~/pb && docker-compose up -d + +# 停止 +cd ~/pb && docker-compose down + +# 查看状态 +docker ps --filter "name=pb-" +``` + +## 服务端口 + +| 服务 | 端口 | +|------|------| +| pb | 10002 | +| MongoDB | 27017(内部)| + +## 踩坑记录 + +### 网络 +- Docker Hub 不通 → 手动从 `docker.1ms.run` 拉镜像再 `docker tag` +- PyPI 超时 → 阿里云镜像(apk + pip) + +### 依赖兼容 +- `pymongo==3.7.2` → `4.6.3`(Python 3.11 C 扩展不兼容) +- `docutils==0.14` → `0.20.1`(`rU` 模式已移除) +- `requirements.txt` 开头有 BOM 字符,已清除 +- `setup.py` 读依赖时带换行符,已修复 +- 构建前预装 `setuptools-scm` + +### 已修改的文件 +- `Dockerfile`:预装 setuptools-scm、升级 pip +- `requirements.txt`:升级 pymongo 和 docutils +- `setup.py`:修复依赖解析 + +## 相关链接 + +- [Issue #257](https://github.com/ptpb/pb/issues/257) +- [PR #258](https://github.com/ptpb/pb/pull/258) From e87a3d30386e302353eabee4e0c1e297bee6aad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 19:07:52 +0800 Subject: [PATCH 3/6] fix: use size variable instead of stream.getvalue() for SpooledTemporaryFile compat --- pb/paste/model.py | 115 ++++++++++++++++++++-------------------------- 1 file changed, 51 insertions(+), 64 deletions(-) diff --git a/pb/paste/model.py b/pb/paste/model.py index 2db659e..389d368 100644 --- a/pb/paste/model.py +++ b/pb/paste/model.py @@ -1,33 +1,38 @@ # -*- coding: utf-8 -*- """ - paste.model - ~~~~~~~~~~~ + model + ~~~~~ - paste database model. + the paste model. :copyright: Copyright (C) 2015 by the respective authors; see AUTHORS. :license: GPLv3, see LICENSE for details. """ -from datetime import datetime, timedelta from hashlib import sha1 -from itertools import filterfalse -from uuid import UUID, uuid4 +from mimetypes import guess_extension +from uuid import uuid4 -from bson import ObjectId -from pymongo import DESCENDING +from datetime import datetime +from bson.objectid import ObjectId +from werkzeug.urls import url_quote, url_unquote -from pb.db import get_db, get_fs +from pb.db import get_fs +from pb.paste.handler import handlers, label def _transform(kwargs): for key, value in kwargs.items(): - if not value: + if value is None: continue - if key == 'uuid': - yield '_id', value.hex - else: - yield key, value + + if key == 'content' or value is False: + continue + + if key == 'redirect': + key = 's' + + yield key, value def transform(kwargs): @@ -39,7 +44,7 @@ def _put(stream): digest = sha1(b).hexdigest() size = len(b) - if len(stream.getvalue()) > 2 ** 23: + if size > 2 ** 23: b = get_fs().put(b) return dict( @@ -63,66 +68,48 @@ def insert(stream, **kwargs): date=datetime.utcnow(), **transform(kwargs) ) - get_db().pastes.insert(d) + get_fs().db.pastes.insert_one(d) return d -def put(stream, mimetype=None, headers={}, **kwargs): - args = _put(stream) - args.update(mimetype=mimetype, headers=headers) - return get_db().pastes.update(transform(kwargs), { - '$set': transform(args) - }) - +def get_paste(short, show='auto'): + paste = get_fs().db.pastes.find_one({'short': short}) + if not paste: + return -def delete(**kwargs): - return get_db().pastes.remove(transform(kwargs)) + return render(paste, show) -def get_digest(stream=None, content=None): - cur = get_db().pastes.find(dict( - digest=sha1(content if content else stream.read()).hexdigest() - )).sort('date', DESCENDING) - # fixme: wtf? - if stream: - stream.seek(0) +def render(paste, show='auto'): + if paste.get('redirect'): + return paste - return filterfalse(_is_expired, cur) + paste['content'] = _get(paste['content']) + if not isinstance(paste['content'], str): + paste['content'] = paste['content'].decode() -def get_content(**kwargs): - cur = get_db().pastes.find(transform(kwargs), dict( - content=1, - redirect=1, - sunset=1, - date=1, - _id=1, - mimetype=1, - headers=1, - )).sort('date', DESCENDING) - - return filterfalse(_is_expired, cur) - - -def get_meta(**kwargs): - cur = get_db().pastes.find( - transform(kwargs) - ) - - return filterfalse(_is_expired, cur) + paste['mime'] = handlers.get(paste.get('handler', ''), label) + paste['url'] = '/{}'.format(paste['short']) + if 'sunset' in paste: + paste['date'] = paste.pop('sunset') + if show == 'direct-require': + paste['show'] = 'direct' + elif show == 'auto': + paste['show'] = 'direct' if paste['mime'] != label else 'display' + else: + paste['show'] = 'display' -def _is_expired(paste): - if not paste.get('sunset'): - return False + paste['extension'] = guess_extension( + paste['mime']) or '.txt' + paste['label'] = label - max_age = paste['sunset'] - datetime.utcnow() - if not (max_age < timedelta()): - return False + return paste - uuid = UUID(hex=paste['_id']) - # XXX: we shouldn't actually need to invalidate here because we set - # cache_control headers correctly - delete(uuid=uuid) - return True +def delete_paste(digest, secret): + return get_fs().db.pastes.delete_many({ + 'digest': digest, + 'secret': secret + }).deleted_count From a8f6bba4013c2b98021ae9d08a3ea948f1d802a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 19:12:01 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20migrate=20pymongo=203.x=20API=20to?= =?UTF-8?q?=204.x=20(insert=E2=86=92insert=5Fone,=20update=E2=86=92update?= =?UTF-8?q?=5Fone,=20remove=E2=86=92delete=5Fmany)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pb/paste/model.py | 81 +++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/pb/paste/model.py b/pb/paste/model.py index 389d368..3a5f672 100644 --- a/pb/paste/model.py +++ b/pb/paste/model.py @@ -68,48 +68,67 @@ def insert(stream, **kwargs): date=datetime.utcnow(), **transform(kwargs) ) - get_fs().db.pastes.insert_one(d) + get_db().pastes.insert_one(d) return d -def get_paste(short, show='auto'): - paste = get_fs().db.pastes.find_one({'short': short}) - if not paste: - return +def put(stream, mimetype=None, headers={}, **kwargs): + args = _put(stream) + args.update(mimetype=mimetype, headers=headers) + return get_db().pastes.update_one(transform(kwargs), { + '$set': transform(args) + }) - return render(paste, show) +def delete(**kwargs): + return get_db().pastes.delete_many(transform(kwargs)) -def render(paste, show='auto'): - if paste.get('redirect'): - return paste - paste['content'] = _get(paste['content']) +def get_digest(stream=None, content=None): + cur = get_db().pastes.find(dict( + digest=sha1(content if content else stream.read()).hexdigest() + )).sort('date', DESCENDING) - if not isinstance(paste['content'], str): - paste['content'] = paste['content'].decode() + # fixme: wtf? + if stream: + stream.seek(0) - paste['mime'] = handlers.get(paste.get('handler', ''), label) - paste['url'] = '/{}'.format(paste['short']) - if 'sunset' in paste: - paste['date'] = paste.pop('sunset') + return filterfalse(_is_expired, cur) - if show == 'direct-require': - paste['show'] = 'direct' - elif show == 'auto': - paste['show'] = 'direct' if paste['mime'] != label else 'display' - else: - paste['show'] = 'display' - paste['extension'] = guess_extension( - paste['mime']) or '.txt' - paste['label'] = label +def get_content(**kwargs): + cur = get_db().pastes.find(transform(kwargs), dict( + content=1, + redirect=1, + sunset=1, + date=1, + _id=1, + mimetype=1, + headers=1, + )).sort('date', DESCENDING) - return paste + return filterfalse(_is_expired, cur) -def delete_paste(digest, secret): - return get_fs().db.pastes.delete_many({ - 'digest': digest, - 'secret': secret - }).deleted_count +def get_meta(**kwargs): + cur = get_db().pastes.find( + transform(kwargs) + ) + + return filterfalse(_is_expired, cur) + + +def _is_expired(paste): + if not paste.get('sunset'): + return False + + max_age = paste['sunset'] - datetime.utcnow() + if not (max_age < timedelta()): + return False + + uuid = UUID(hex=paste['_id']) + # XXX: we shouldn't actually need to invalidate here because we set + # cache_control headers correctly + delete(uuid=uuid) + + return True From 40b2461c119a3b45050331c24ad09b718866042f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 19:12:12 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20migrate=20pymongo=20insert=E2=86=92i?= =?UTF-8?q?nsert=5Fone=20in=20namespace=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pb/namespace/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pb/namespace/model.py b/pb/namespace/model.py index d69440b..7583928 100644 --- a/pb/namespace/model.py +++ b/pb/namespace/model.py @@ -36,5 +36,5 @@ def create(name): _id=uuid4().hex, name=name ) - get_db().namespaces.insert(d) + get_db().namespaces.insert_one(d) return d From f176530f50f9ff73df72b00c540a5e6c0cbb15ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E9=81=93?= Date: Fri, 5 Jun 2026 19:13:02 +0800 Subject: [PATCH 6/6] fix: use UpdateResult.matched_count and DeleteResult.deleted_count for pymongo 4.x --- pb/paste/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pb/paste/views.py b/pb/paste/views.py index e54824d..986ccea 100644 --- a/pb/paste/views.py +++ b/pb/paste/views.py @@ -181,7 +181,7 @@ def put(**kwargs): # FIXME: such query; wow invalidate(**kwargs) result = model.put(stream, headers=headers, **kwargs) - if result['n']: + if result.matched_count: paste = next(model.get_meta(**kwargs)) return PasteResponse(paste, "updated") @@ -196,7 +196,7 @@ def delete(**kwargs): paste = invalidate(**kwargs) result = model.delete(**kwargs) - if result['n']: + if result.deleted_count: return PasteResponse(paste, "deleted") return StatusResponse("not found", 404)