ChatCoolify 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chatcoolify-0.2.2/src/ChatCoolify.egg-info → chatcoolify-0.2.3}/PKG-INFO +15 -1
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/README.md +14 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/pyproject.toml +1 -1
- {chatcoolify-0.2.2 → chatcoolify-0.2.3/src/ChatCoolify.egg-info}/PKG-INFO +15 -1
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/ChatCoolify.egg-info/SOURCES.txt +4 -1
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/__init__.py +1 -1
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform.py +17 -4
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_runtime.py +14 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_web.py +11 -10
- chatcoolify-0.2.3/tests/test_platform_public_projection.py +82 -0
- chatcoolify-0.2.3/tests/test_public_patch_version.py +13 -0
- chatcoolify-0.2.3/tests/test_site_management.py +144 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/LICENSE +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/setup.cfg +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/ChatCoolify.egg-info/dependency_links.txt +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/ChatCoolify.egg-info/entry_points.txt +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/ChatCoolify.egg-info/requires.txt +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/ChatCoolify.egg-info/top_level.txt +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/cli.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/client.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/config.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_artifact.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_backend.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_cli.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_client.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/src/chatcoolify/platform_source.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_cli.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_client.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_platform.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_platform_domains.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_platform_management.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_platform_public.py +0 -0
- {chatcoolify-0.2.2 → chatcoolify-0.2.3}/tests/test_release_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ChatCoolify
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AI-safe Python client and CLI for the official Coolify API
|
|
5
5
|
Author-email: ChatArch <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,3 +123,17 @@ python -m twine check dist/*
|
|
|
123
123
|
## 许可证
|
|
124
124
|
|
|
125
125
|
MIT。
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
## 签名访客的公开只读投影
|
|
129
|
+
|
|
130
|
+
公开项目可以由受信任服务显式实现 `GET` / `HEAD /api/public/catalog`、`/api/public/state` 和 `/api/public/projects/{id}`(ID 为 1–80 个 ASCII 字母、数字、下划线或连字符)。平台仍按应用和站点绑定签名,访客上下文保持 `authenticated=False`,不因签名或客户端身份声明成为已登录所有者。普通私有 API 不在访客白名单中;此扩展不改变既有评论提交协议。
|
|
131
|
+
|
|
132
|
+
后端必须独立验证准确的方法、路径/查询、正文与站点 read 权限,并生成明确允许的公开字段。不要把原始项目材料、来源连接、人工私人记录或凭据直接当成公开响应。投影路径只读;私有读取、管理写入和 CSRF 仍执行各自的认证规则。项目可见性是项目级策略,切换前同时核对预览与生产范围,不以一层登录遮罩替代后端权限。
|
|
133
|
+
|
|
134
|
+
本工作树的 `0.2.2+access.1` 是基于已发布 `0.2.2` 的私有验证构建,不是 PyPI 正式发版。
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### Native project access management
|
|
138
|
+
|
|
139
|
+
Authenticated project owners can follow `GET /__platform/manage` to the existing console project anchor. Browser settings use the persisted project visibility for both production and preview, with an explicit public confirmation, stale-mode precondition and GET readback after saving. Ordinary reader accounts cannot manage access. This private build does not add or rename CLI commands.
|
|
@@ -84,3 +84,17 @@ python -m twine check dist/*
|
|
|
84
84
|
## 许可证
|
|
85
85
|
|
|
86
86
|
MIT。
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## 签名访客的公开只读投影
|
|
90
|
+
|
|
91
|
+
公开项目可以由受信任服务显式实现 `GET` / `HEAD /api/public/catalog`、`/api/public/state` 和 `/api/public/projects/{id}`(ID 为 1–80 个 ASCII 字母、数字、下划线或连字符)。平台仍按应用和站点绑定签名,访客上下文保持 `authenticated=False`,不因签名或客户端身份声明成为已登录所有者。普通私有 API 不在访客白名单中;此扩展不改变既有评论提交协议。
|
|
92
|
+
|
|
93
|
+
后端必须独立验证准确的方法、路径/查询、正文与站点 read 权限,并生成明确允许的公开字段。不要把原始项目材料、来源连接、人工私人记录或凭据直接当成公开响应。投影路径只读;私有读取、管理写入和 CSRF 仍执行各自的认证规则。项目可见性是项目级策略,切换前同时核对预览与生产范围,不以一层登录遮罩替代后端权限。
|
|
94
|
+
|
|
95
|
+
本工作树的 `0.2.2+access.1` 是基于已发布 `0.2.2` 的私有验证构建,不是 PyPI 正式发版。
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
### Native project access management
|
|
99
|
+
|
|
100
|
+
Authenticated project owners can follow `GET /__platform/manage` to the existing console project anchor. Browser settings use the persisted project visibility for both production and preview, with an explicit public confirmation, stale-mode precondition and GET readback after saving. Ordinary reader accounts cannot manage access. This private build does not add or rename CLI commands.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ChatCoolify
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: AI-safe Python client and CLI for the official Coolify API
|
|
5
5
|
Author-email: ChatArch <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,3 +123,17 @@ python -m twine check dist/*
|
|
|
123
123
|
## 许可证
|
|
124
124
|
|
|
125
125
|
MIT。
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
## 签名访客的公开只读投影
|
|
129
|
+
|
|
130
|
+
公开项目可以由受信任服务显式实现 `GET` / `HEAD /api/public/catalog`、`/api/public/state` 和 `/api/public/projects/{id}`(ID 为 1–80 个 ASCII 字母、数字、下划线或连字符)。平台仍按应用和站点绑定签名,访客上下文保持 `authenticated=False`,不因签名或客户端身份声明成为已登录所有者。普通私有 API 不在访客白名单中;此扩展不改变既有评论提交协议。
|
|
131
|
+
|
|
132
|
+
后端必须独立验证准确的方法、路径/查询、正文与站点 read 权限,并生成明确允许的公开字段。不要把原始项目材料、来源连接、人工私人记录或凭据直接当成公开响应。投影路径只读;私有读取、管理写入和 CSRF 仍执行各自的认证规则。项目可见性是项目级策略,切换前同时核对预览与生产范围,不以一层登录遮罩替代后端权限。
|
|
133
|
+
|
|
134
|
+
本工作树的 `0.2.2+access.1` 是基于已发布 `0.2.2` 的私有验证构建,不是 PyPI 正式发版。
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### Native project access management
|
|
138
|
+
|
|
139
|
+
Authenticated project owners can follow `GET /__platform/manage` to the existing console project anchor. Browser settings use the persisted project visibility for both production and preview, with an explicit public confirmation, stale-mode precondition and GET readback after saving. Ordinary reader accounts cannot manage access. This private build does not add or rename CLI commands.
|
|
@@ -25,4 +25,7 @@ tests/test_platform.py
|
|
|
25
25
|
tests/test_platform_domains.py
|
|
26
26
|
tests/test_platform_management.py
|
|
27
27
|
tests/test_platform_public.py
|
|
28
|
-
tests/
|
|
28
|
+
tests/test_platform_public_projection.py
|
|
29
|
+
tests/test_public_patch_version.py
|
|
30
|
+
tests/test_release_contract.py
|
|
31
|
+
tests/test_site_management.py
|
|
@@ -374,12 +374,19 @@ class PlatformService:
|
|
|
374
374
|
'WHERE p.owner_id=? OR g.user_id=? ORDER BY p.created_at LIMIT 1000', (actor.user_id,actor.user_id))]
|
|
375
375
|
return [self.project(actor,p) for p in ids]
|
|
376
376
|
|
|
377
|
-
def set_visibility(self,actor,project_id,visibility):
|
|
377
|
+
def set_visibility(self,actor,project_id,visibility,*,expected_visibility=None):
|
|
378
378
|
"""Publishing access is independent of management-account permissions."""
|
|
379
379
|
self.check(actor,project_id,'manage_visibility')
|
|
380
380
|
if visibility not in {'public','private'}:
|
|
381
381
|
raise PlatformError(422,'Invalid project visibility')
|
|
382
|
+
if expected_visibility is not None and expected_visibility not in {'public','private'}:
|
|
383
|
+
raise PlatformError(422,'Invalid expected project visibility')
|
|
382
384
|
with self.lock:
|
|
385
|
+
if expected_visibility is not None:
|
|
386
|
+
with self.connection() as db:
|
|
387
|
+
current=db.execute('SELECT visibility FROM projects WHERE id=?',(project_id,)).fetchone()
|
|
388
|
+
if not current or current['visibility']!=expected_visibility:
|
|
389
|
+
raise PlatformError(409,'Project visibility changed; reload current settings')
|
|
383
390
|
# Tightening access takes effect before any backend request. Opening
|
|
384
391
|
# access happens only after all registered comment policies agree.
|
|
385
392
|
if visibility=='private':
|
|
@@ -846,7 +853,7 @@ class PlatformService:
|
|
|
846
853
|
raise PlatformError(403,'Same-origin guest CSRF validation required')
|
|
847
854
|
with self.connection() as db:
|
|
848
855
|
sites=[row[0] for row in db.execute('SELECT site_id FROM services WHERE project_id=?',(app['project_id'],))]
|
|
849
|
-
access={'authenticated':False,'read':True,'csrf_token':csrf,
|
|
856
|
+
access={'authenticated':False,'read':True,'csrf_token':csrf,'can_manage_project':False,
|
|
850
857
|
'subject':'guest:'+digest(cookie.encode())[:32],
|
|
851
858
|
'site_permissions':{site:{'read':True,'comment':True,'manage':False,'owner':False,
|
|
852
859
|
'management_until':0} for site in sites}}
|
|
@@ -897,6 +904,8 @@ class PlatformService:
|
|
|
897
904
|
(digest(token.encode()),app['project_id'],site)).fetchone()
|
|
898
905
|
grants[site]=grant['expires'] if grant and grant['expires']>time.time() else 0
|
|
899
906
|
return {'authenticated':True,'subject':session.principal.user_id,'read':True,'csrf_token':session.csrf_token,
|
|
907
|
+
'can_manage_project':project['role']=='owner',
|
|
908
|
+
'management_url':self.control_url+'/#project-'+app['project_id'] if project['role']=='owner' else None,
|
|
900
909
|
'site_permissions':{site:{'read':True,'comment':True,'manage':project['role']=='owner',
|
|
901
910
|
'owner':project['role']=='owner','management_until':until} for site,until in grants.items()}}
|
|
902
911
|
|
|
@@ -1018,11 +1027,15 @@ class PlatformService:
|
|
|
1018
1027
|
parsed=urlsplit(downstream)
|
|
1019
1028
|
public_api=re.fullmatch(r'/api/sites/([A-Za-z0-9_-]{1,64})/(config|comments)',parsed.path)
|
|
1020
1029
|
same_site=public_api and public_api[1]==binding['site_id']
|
|
1030
|
+
# An explicit backend opt-in namespace, never the private API.
|
|
1031
|
+
# Preserve the signed anonymous identity and site read scope; the
|
|
1032
|
+
# backend must independently project public fields and reject writes.
|
|
1033
|
+
projection=re.fullmatch(r'/api/public/(?:catalog|state|projects/[A-Za-z0-9_-]{1,80})',parsed.path)
|
|
1021
1034
|
permitted=(method in {'GET','HEAD'} and
|
|
1022
|
-
(parsed.path in {'/widget.js','/widget.css','/board'} or same_site))
|
|
1035
|
+
(parsed.path in {'/widget.js','/widget.css','/board'} or same_site or projection))
|
|
1023
1036
|
submission=method=='POST' and same_site and public_api[2]=='comments'
|
|
1024
1037
|
if not permitted and not submission:
|
|
1025
|
-
raise PlatformError(403,'Guest access is limited to
|
|
1038
|
+
raise PlatformError(403,'Guest access is limited to registered public reads and adding comments')
|
|
1026
1039
|
# POST was already checked by authorize(); GET can use the freshly
|
|
1027
1040
|
# issued internal cookie when a visitor opens a service directly.
|
|
1028
1041
|
cookie=self._guest_cookie(app,access.get('_guest_cookie') or payload.get('guest_token',''))
|
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
import mimetypes
|
|
15
15
|
import os
|
|
16
16
|
import secrets
|
|
17
|
+
import re
|
|
17
18
|
import time
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
from urllib.error import HTTPError, URLError
|
|
@@ -191,6 +192,19 @@ class FrontendHandler(BaseHTTPRequestHandler):
|
|
|
191
192
|
return self.respond(503,{'error':'Guest session unavailable'})
|
|
192
193
|
self._guest_response_cookies.append(
|
|
193
194
|
f'{GUEST_COOKIE}={guest}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={age}')
|
|
195
|
+
if path == '/__platform/manage' and self.command in {'GET', 'HEAD'}:
|
|
196
|
+
if access.get('authenticated') is not True or access.get('can_manage_project') is not True:
|
|
197
|
+
return self.respond(403, {'error': 'Project owner permission required'})
|
|
198
|
+
target=access.get('management_url')
|
|
199
|
+
if not isinstance(target,str):
|
|
200
|
+
return self.respond(503, {'error': 'Management destination unavailable'})
|
|
201
|
+
destination=urlsplit(target)
|
|
202
|
+
control=urlsplit(os.environ['PLATFORM_CONTROL_URL'])
|
|
203
|
+
if (destination.scheme!=control.scheme or destination.netloc!=control.netloc
|
|
204
|
+
or destination.username or destination.password or destination.path!='/'
|
|
205
|
+
or destination.query or not re.fullmatch(r'project-[A-Za-z0-9_-]{1,80}',destination.fragment)):
|
|
206
|
+
return self.respond(503, {'error': 'Management destination unavailable'})
|
|
207
|
+
return self.respond(303, b'', headers={'Location':target})
|
|
194
208
|
if path == '/__platform/session':
|
|
195
209
|
return self.respond(200, access)
|
|
196
210
|
if path == '/__platform/artifact' and self.command in {'GET', 'HEAD'}:
|
|
@@ -177,12 +177,12 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
177
177
|
if project['role']=='owner':
|
|
178
178
|
public_selected=' selected' if project['visibility']=='public' else ''
|
|
179
179
|
private_selected=' selected' if project['visibility']=='private' else ''
|
|
180
|
-
policy=(f'<form class="visibility-form" data-kind="visibility" data-project="{project_id}">'
|
|
181
|
-
'<label
|
|
180
|
+
policy=(f'<form class="visibility-form" data-kind="visibility" data-project="{project_id}" data-current-visibility="{project["visibility"]}">'
|
|
181
|
+
'<label>整站访问方式<select name="visibility">'
|
|
182
182
|
f'<option value="public"{public_selected}>公开:任何人可访问</option>'
|
|
183
|
-
f'<option value="private"{private_selected}
|
|
184
|
-
'<button>保存访问设置</button><p class="feedback" role="status"></p></form>')
|
|
185
|
-
cards.append(f'<article><div class="card-title"><h2>{html.escape(project["name"])}</h2>'
|
|
183
|
+
f'<option value="private"{private_selected}>仅登录:仅已授权用户</option></select></label>'
|
|
184
|
+
'<button>保存访问设置</button><span class="policy-note">正式网站与预览环境共用此设置;仅登录时限已授权账号访问。</span><p class="feedback" role="status"></p></form>')
|
|
185
|
+
cards.append(f'<article id="project-{project_id}"><div class="card-title"><h2>{html.escape(project["name"])}</h2>'
|
|
186
186
|
f'<span class="badge">{visibility} · {html.escape(role)}</span></div>'
|
|
187
187
|
f'<p class="slug">{html.escape(project["slug"])}</p>{policy}'+''.join(links)+
|
|
188
188
|
f'<details><summary>上传与发布命令</summary><pre>'
|
|
@@ -193,7 +193,7 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
193
193
|
empty='<p class="empty">还没有项目。在下方创建网站,再上传本地构建目录。</p>'
|
|
194
194
|
style='''*{box-sizing:border-box}body{margin:0;background:#f5f6f3;color:#202b24;font:16px/1.6 system-ui,sans-serif}
|
|
195
195
|
header,main{max-width:1080px;margin:auto;padding:24px}header{display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid #dce2d8}header a{color:inherit;text-decoration:none;font-weight:700}.account{display:flex;align-items:center;gap:14px;min-width:0;font-size:14px}.account span{overflow-wrap:anywhere}
|
|
196
|
-
button{background:white;border:1px solid #b9c7b5;padding:8px 14px;border-radius:7px;color:inherit;cursor:pointer;white-space:nowrap}button:disabled{opacity:.6;cursor:wait}h1{font-size:32px;margin:20px 0 6px}h2{margin:0;font-size:21px;overflow-wrap:anywhere}.intro,.muted,.slug{color:#64705e}.intro{margin:0 0 28px}.cards{display:grid;gap:18px}article,.create{background:white;border:1px solid #dce2d8;border-radius:12px;padding:24px;min-width:0}.card-title{display:flex;align-items:start;justify-content:space-between;gap:16px}.badge{background:#eef2e9;border-radius:6px;padding:3px 9px;white-space:nowrap;font-size:13px}.slug{margin:6px 0 18px;font-size:14px}.address{display:grid;grid-template-columns:100px minmax(0,1fr);gap:12px;margin:24px 0}.address a{color:#355d36;overflow-wrap:anywhere}small{display:block;color:#7a631e;overflow-wrap:anywhere}form{display:flex;align-items:end;gap:10px;flex-wrap:wrap}label{display:grid;gap:5px;color:#53614e;font-size:13px;min-width:0}input,select{font:15px system-ui;padding:9px 10px;border:1px solid #becab7;border-radius:6px;background:white;color:#202b24;min-width:0;max-width:100%}.domain-form{margin-top:12px}.domain-form input{width:190px}.suffix{font-size:13px;color:#64705e;align-self:center;overflow-wrap:anywhere}.feedback{flex-basis:100%;margin:0;color:#715a1d;font-size:13px;overflow-wrap:anywhere}.feedback:empty{display:none}.visibility-form{padding:14px;background:#f5f7f1;border-radius:8px}.create{margin-top:24px}.create h2{margin-bottom:14px}.create p{color:#64705e;font-size:14px}details{margin-top:20px;border-top:1px solid #e6ebe2;padding-top:14px;font-size:13px}summary{cursor:pointer;color:#64705e}code,pre{overflow-wrap:anywhere;white-space:pre-wrap;font-size:12px}aside{margin-top:24px;padding:18px;background:#edf1e8;border-radius:10px;font-size:14px}.empty{padding:24px;background:white;border-radius:12px}
|
|
196
|
+
button{background:white;border:1px solid #b9c7b5;padding:8px 14px;border-radius:7px;color:inherit;cursor:pointer;white-space:nowrap}button:disabled{opacity:.6;cursor:wait}h1{font-size:32px;margin:20px 0 6px}h2{margin:0;font-size:21px;overflow-wrap:anywhere}.intro,.muted,.slug{color:#64705e}.intro{margin:0 0 28px}.cards{display:grid;gap:18px}article,.create{background:white;border:1px solid #dce2d8;border-radius:12px;padding:24px;min-width:0}.card-title{display:flex;align-items:start;justify-content:space-between;gap:16px}.badge{background:#eef2e9;border-radius:6px;padding:3px 9px;white-space:nowrap;font-size:13px}.slug{margin:6px 0 18px;font-size:14px}.address{display:grid;grid-template-columns:100px minmax(0,1fr);gap:12px;margin:24px 0}.address a{color:#355d36;overflow-wrap:anywhere}small{display:block;color:#7a631e;overflow-wrap:anywhere}form{display:flex;align-items:end;gap:10px;flex-wrap:wrap}label{display:grid;gap:5px;color:#53614e;font-size:13px;min-width:0}input,select{font:15px system-ui;padding:9px 10px;border:1px solid #becab7;border-radius:6px;background:white;color:#202b24;min-width:0;max-width:100%}.domain-form{margin-top:12px}.domain-form input{width:190px}.suffix{font-size:13px;color:#64705e;align-self:center;overflow-wrap:anywhere}.feedback{flex-basis:100%;margin:0;color:#715a1d;font-size:13px;overflow-wrap:anywhere}.feedback:empty{display:none}.policy-note{flex-basis:100%;font-size:13px;color:#64705e}.visibility-form{padding:14px;background:#f5f7f1;border-radius:8px}.create{margin-top:24px}.create h2{margin-bottom:14px}.create p{color:#64705e;font-size:14px}details{margin-top:20px;border-top:1px solid #e6ebe2;padding-top:14px;font-size:13px}summary{cursor:pointer;color:#64705e}code,pre{overflow-wrap:anywhere;white-space:pre-wrap;font-size:12px}aside{margin-top:24px;padding:18px;background:#edf1e8;border-radius:10px;font-size:14px}.empty{padding:24px;background:white;border-radius:12px}
|
|
197
197
|
@media(max-width:540px){header,main{padding:18px}.card-title{display:block}.badge{display:inline-block;margin-top:8px}article,.create{padding:18px}.address{grid-template-columns:1fr;gap:6px}header{align-items:start}.account{flex-wrap:wrap;justify-content:end}.domain-form label,.domain-form input,.visibility-form label,.visibility-form select,.create label,.create input,.create select{width:100%}.suffix{flex-basis:100%}form button{width:100%}}'''
|
|
198
198
|
script=r'''const csrf=__CSRF__;
|
|
199
199
|
async function request(path,method,payload){const r=await fetch(path,{method,headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:payload===undefined?undefined:JSON.stringify(payload)});let data={};try{data=await r.json()}catch(e){}if(!r.ok)throw new Error(data.error||'请求失败,请重试');return data}
|
|
@@ -201,7 +201,7 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
201
201
|
document.querySelectorAll('form[data-kind]').forEach(form=>{form.onsubmit=async event=>{event.preventDefault();const button=form.querySelector('button'),message=form.querySelector('.feedback'),data=Object.fromEntries(new FormData(form));button.disabled=true;message.textContent='正在保存…';try{
|
|
202
202
|
const base='/api/platform/v1/projects',id=form.dataset.project,kind=form.dataset.kind;
|
|
203
203
|
if(kind==='create'){await request(base,'POST',{slug:data.slug,name:data.name||data.slug,visibility:data.visibility,request_key:'create-'+data.slug});location.reload();return}
|
|
204
|
-
if(kind==='visibility'){await request(base+'/'+id,'PATCH',{visibility:data.visibility});location.reload();return}
|
|
204
|
+
if(kind==='visibility'){if(data.visibility==='public'&&!confirm('确认公开此项目的正式网站和预览环境?公开后的读写仍按应用权限执行。')){message.textContent='已取消,访问设置未修改。';return}await request(base+'/'+id,'PATCH',{visibility:data.visibility,expected_visibility:form.dataset.currentVisibility});const saved=await request(base+'/'+id,'GET');if(saved.visibility!==data.visibility)throw new Error('访问设置生效状态未确认,请刷新核对');location.reload();return}
|
|
205
205
|
const route=base+'/'+id+'/domains/'+form.dataset.environment;
|
|
206
206
|
await request(route,'PUT',{hostname:data.hostname});
|
|
207
207
|
if(form.dataset.live!=='1'){message.textContent='域名已预留。首次上传并发布后即可访问。';return}
|
|
@@ -211,11 +211,11 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
211
211
|
message.textContent='正在更新域名;验证成功前保持原入口。';
|
|
212
212
|
for(let n=0;n<60;n++){await new Promise(resolve=>setTimeout(resolve,2500));const result=await request(base+'/'+id+'/deployments/'+job.id,'GET');if(result.state==='finished'&&result.runtime_active&&result.runtime_verified&&!result.domain.pending){location.reload();return}if(['failed','cancelled','configuration_failed','provenance_mismatch'].includes(result.state)){delete form.dataset.requestKey;throw new Error('域名更新未完成,原入口保留。可重试或查看部署状态。')}}
|
|
213
213
|
message.textContent='部署仍在进行,可稍后刷新查看。';
|
|
214
|
-
}catch(error){message.textContent=error.message}finally{button.disabled=false}}});'''.replace('__CSRF__',json.dumps(session.csrf_token).replace('<','\\u003c'))
|
|
214
|
+
}catch(error){message.textContent=form.dataset.kind==='visibility'?'未确认设置生效,请刷新核对当前访问方式后再操作;请勿直接重复保存。':error.message}finally{button.disabled=false}}});'''.replace('__CSRF__',json.dumps(session.csrf_token).replace('<','\\u003c'))
|
|
215
215
|
page=f'''<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>我的网站 · ChatCoolify</title><style>{style}</style>
|
|
216
216
|
<header><a href="/">ChatCoolify</a><div class="account"><span>{name}</span><button id="logout">退出</button></div></header>
|
|
217
217
|
<main><h1>我的网站</h1><p class="intro">{len(projects)} 个项目 · 这里管理你的网站;访客访问公开网站无需登录。</p><section class="cards">{''.join(cards) or empty}</section>
|
|
218
|
-
<section class="create"><h2>创建网站</h2><form data-kind="create"><label>网站名称<input name="name" placeholder="我的网站" maxlength="160"></label><label>默认域名前缀<input name="slug" placeholder="my-site" pattern="[a-z][a-z0-9-]{{1,47}}[a-z0-9]" required maxlength="49"></label><label>访客访问<select name="visibility"><option value="public" selected>公开:任何人可访问</option><option value="private"
|
|
218
|
+
<section class="create"><h2>创建网站</h2><form data-kind="create"><label>网站名称<input name="name" placeholder="我的网站" maxlength="160"></label><label>默认域名前缀<input name="slug" placeholder="my-site" pattern="[a-z][a-z0-9-]{{1,47}}[a-z0-9]" required maxlength="49"></label><label>访客访问<select name="visibility"><option value="public" selected>公开:任何人可访问</option><option value="private">仅登录:仅已授权用户</option></select></label><button>创建网站</button><p class="feedback" role="status"></p></form><p>创建后上传本地构建目录,先预览,再发布。公开网站可直接分享,访客不需要平台账号。</p></section>
|
|
219
219
|
<aside>账号登录保护网站管理、部署与设置。公开阅读与普通留言独立于管理账号;选择私有时才要求授权访问。构建和运行使用同一套 Coolify 基础设施。</aside></main><script>{script}</script></html>'''.encode()
|
|
220
220
|
self.respond(200,page,'text/html; charset=utf-8')
|
|
221
221
|
|
|
@@ -252,7 +252,8 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
252
252
|
project_id=route[1]
|
|
253
253
|
if len(route)==2 and self.command=='GET':return self.respond(200,self.service.project(actor,project_id))
|
|
254
254
|
if len(route)==2 and self.command=='PATCH':
|
|
255
|
-
|
|
255
|
+
value=self.json()
|
|
256
|
+
return self.respond(200,self.service.set_visibility(actor,project_id,value.get('visibility'),expected_visibility=value.get('expected_visibility')))
|
|
256
257
|
self.service.check(actor,project_id)
|
|
257
258
|
if len(route)==3 and route[2]=='domains' and self.command=='GET':
|
|
258
259
|
result=self.service.domains(actor,project_id)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Opt-in /api/public projections relay as signed read-only guests only."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
from email.message import Message
|
|
5
|
+
import pytest
|
|
6
|
+
from chatcoolify import PlatformError
|
|
7
|
+
from chatcomment.access import verify_context
|
|
8
|
+
from test_platform_public import public_site,guest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize('path',[
|
|
12
|
+
'/api/public/catalog?offset=0&limit=100',
|
|
13
|
+
'/api/public/state',
|
|
14
|
+
'/api/public/projects/0123456789abcdefabcd',
|
|
15
|
+
])
|
|
16
|
+
@pytest.mark.parametrize('method',['GET','HEAD'])
|
|
17
|
+
def test_guest_projection_is_signed_scoped_read_only(public_site,monkeypatch,path,method):
|
|
18
|
+
import chatcoolify.platform as platform
|
|
19
|
+
service,owner,app,private,store,backend=public_site
|
|
20
|
+
access,context=guest(service,app)
|
|
21
|
+
captured=[]
|
|
22
|
+
class Response:
|
|
23
|
+
code=200
|
|
24
|
+
headers={'Content-Type':'application/json'}
|
|
25
|
+
def __enter__(self):return self
|
|
26
|
+
def __exit__(self,*args):pass
|
|
27
|
+
def read(self,limit):return b'{"public_read_only":true}'
|
|
28
|
+
class Opener:
|
|
29
|
+
def open(self,request,timeout):
|
|
30
|
+
captured.append(request)
|
|
31
|
+
return Response()
|
|
32
|
+
monkeypatch.setattr(platform,'build_opener',lambda *args:Opener())
|
|
33
|
+
response=service.service_request(app,{**context,'method':method,
|
|
34
|
+
'path':'/__platform/services/comments'+path,'body_b64':'',
|
|
35
|
+
'subject':owner.user_id,'authenticated':True,
|
|
36
|
+
'site_permissions':{'public-site':{'owner':True,'manage':True}}})
|
|
37
|
+
assert response['status']==200 and len(captured)==1
|
|
38
|
+
with service.connection() as db:
|
|
39
|
+
key=db.execute('SELECT secret FROM services WHERE project_id=?',(app['project_id'],)).fetchone()[0]
|
|
40
|
+
headers=Message()
|
|
41
|
+
for name,value in captured[0].header_items():headers[name]=value
|
|
42
|
+
signed=verify_context(headers,key=key,method=method,path=path,body=b'')
|
|
43
|
+
assert not signed.authenticated and signed.subject.startswith('guest:')
|
|
44
|
+
scope=signed.scope('public-site')
|
|
45
|
+
assert scope.read and not scope.owner and not scope.manage and scope.management_until==0
|
|
46
|
+
assert not signed.scope('other-site').read
|
|
47
|
+
with service.connection() as db:
|
|
48
|
+
assert db.execute('SELECT COUNT(*) FROM readers').fetchone()[0]==0
|
|
49
|
+
assert db.execute('SELECT COUNT(*) FROM management').fetchone()[0]==0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize('path,method',[
|
|
53
|
+
('/api/catalog','GET'),('/api/state','GET'),
|
|
54
|
+
('/api/projects/project-id','GET'),('/api/public/directions','GET'),
|
|
55
|
+
('/api/public/projects/../../state','GET'),
|
|
56
|
+
('/api/public/projects/project-id/notes','GET'),
|
|
57
|
+
('/api/public/catalog/','GET'),('/api/public/state','POST'),
|
|
58
|
+
('/api/public/catalog','POST'),('/api/public/projects/project-id','PATCH'),
|
|
59
|
+
('/api/public/state','DELETE'),('/api/public/projects/project-id','PUT'),
|
|
60
|
+
])
|
|
61
|
+
def test_guest_projection_cannot_expand_private_paths_or_write(public_site,monkeypatch,path,method):
|
|
62
|
+
import chatcoolify.platform as platform
|
|
63
|
+
service,owner,app,private,store,backend=public_site
|
|
64
|
+
access,context=guest(service,app)
|
|
65
|
+
def forbidden(*args,**kwargs):raise AssertionError('forbidden request reached backend')
|
|
66
|
+
monkeypatch.setattr(platform,'build_opener',forbidden)
|
|
67
|
+
with pytest.raises(PlatformError) as error:
|
|
68
|
+
service.service_request(app,{**context,'method':method,
|
|
69
|
+
'path':'/__platform/services/comments'+path,'body_b64':''})
|
|
70
|
+
assert error.value.status==403
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_public_projection_never_opens_private_project(public_site,monkeypatch):
|
|
74
|
+
import chatcoolify.platform as platform
|
|
75
|
+
service,owner,app,private,store,backend=public_site
|
|
76
|
+
access,context=guest(service,app)
|
|
77
|
+
def forbidden(*args,**kwargs):raise AssertionError('private project reached backend')
|
|
78
|
+
monkeypatch.setattr(platform,'build_opener',forbidden)
|
|
79
|
+
with pytest.raises(PlatformError) as error:
|
|
80
|
+
service.service_request(private,{**context,'method':'GET',
|
|
81
|
+
'path':'/__platform/services/comments/api/public/catalog','body_b64':''})
|
|
82
|
+
assert error.value.status==401
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The authorized forward patch must not keep a private build version."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from chatcoolify import __version__
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_authorized_public_patch_version():
|
|
7
|
+
assert __version__ == "0.2.3"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_public_patch_is_declared_in_metadata_and_changelog():
|
|
11
|
+
root = Path(__file__).resolve().parents[1]
|
|
12
|
+
assert 'version = "0.2.3"' in (root / 'pyproject.toml').read_text()
|
|
13
|
+
assert '## 0.2.3' in (root / 'CHANGELOG.md').read_text()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Owner-only native management handoff reuses the existing platform console."""
|
|
2
|
+
from http.client import HTTPConnection
|
|
3
|
+
from http.server import ThreadingHTTPServer
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from chatcoolify import PlatformError
|
|
10
|
+
from chatcoolify.platform_runtime import FrontendHandler, COOKIE
|
|
11
|
+
from chatcoolify.platform_web import PlatformHandler, COOKIE as CONTROL_COOKIE
|
|
12
|
+
from test_platform_public import public_site, owner_reader, guest
|
|
13
|
+
from test_platform_management import local_server
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_owner_management_metadata_is_project_scoped_even_without_bindings(public_site):
|
|
17
|
+
service, owner, app, private, store, backend = public_site
|
|
18
|
+
for target in [app, private]:
|
|
19
|
+
token = owner_reader(service, target)
|
|
20
|
+
value = service.authorize(target, {'method': 'GET', 'reader_token': token})
|
|
21
|
+
assert value['can_manage_project'] is True
|
|
22
|
+
assert value['management_url'] == service.control_url + '/#project-' + target['project_id']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_guest_and_project_viewer_have_no_management_destination(public_site):
|
|
26
|
+
service, owner, app, private, store, backend = public_site
|
|
27
|
+
value, _ = guest(service, app)
|
|
28
|
+
assert not value.get('can_manage_project') and not value.get('management_url')
|
|
29
|
+
member = service.users.create_user(owner, 'reader@example.test', 'Example-reader-password!')
|
|
30
|
+
service.grant(owner, app['project_id'], member.user_id, 'viewer')
|
|
31
|
+
token = owner_reader(service, app, member.username, 'Example-reader-password!')
|
|
32
|
+
value = service.authorize(app, {'method': 'GET', 'reader_token': token,
|
|
33
|
+
'can_manage_project': True, 'management_url': 'https://evil.example/'})
|
|
34
|
+
assert value['can_manage_project'] is False and not value.get('management_url')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize('identity,expected', [('owner',303),('viewer',403),('guest',403),('tampered-target',503)])
|
|
38
|
+
def test_native_manage_route_uses_verified_owner_only(public_site, monkeypatch, identity, expected):
|
|
39
|
+
service, owner, app, private, store, backend = public_site
|
|
40
|
+
token = ''
|
|
41
|
+
if identity in ['owner','tampered-target']:
|
|
42
|
+
token = owner_reader(service, app)
|
|
43
|
+
elif identity == 'viewer':
|
|
44
|
+
member = service.users.create_user(owner, 'reader@example.test', 'Example-reader-password!')
|
|
45
|
+
service.grant(owner, app['project_id'], member.user_id, 'viewer')
|
|
46
|
+
token = owner_reader(service, app, member.username, 'Example-reader-password!')
|
|
47
|
+
class Runtime(FrontendHandler):
|
|
48
|
+
def broker(self, endpoint, payload):
|
|
49
|
+
if endpoint == 'canonical': return 200, {'url': app['url']}
|
|
50
|
+
assert endpoint == 'authorize'
|
|
51
|
+
value = service.authorize(app, payload)
|
|
52
|
+
if identity == 'tampered-target': value['management_url'] = 'https://evil.example/#project-x'
|
|
53
|
+
return 200, value
|
|
54
|
+
monkeypatch.setenv('PLATFORM_PUBLIC_URLS', app['url'])
|
|
55
|
+
monkeypatch.setenv('PLATFORM_CONTROL_URL', service.control_url)
|
|
56
|
+
with local_server(Runtime) as url:
|
|
57
|
+
from urllib.parse import urlsplit
|
|
58
|
+
parsed = urlsplit(url)
|
|
59
|
+
conn = HTTPConnection(parsed.hostname, parsed.port)
|
|
60
|
+
conn.request('GET', '/__platform/manage', headers={'Host': urlsplit(app['url']).netloc,
|
|
61
|
+
'Cookie': COOKIE+'='+token, 'X-User':'owner','X-Role':'owner'})
|
|
62
|
+
response = conn.getresponse(); body = response.read()
|
|
63
|
+
assert response.status == expected
|
|
64
|
+
if expected == 303: assert response.getheader('Location') == service.control_url+'/#project-'+app['project_id']
|
|
65
|
+
else: assert response.getheader('Location') is None
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_native_console_has_exact_project_anchor_and_safe_public_confirmation(public_site):
|
|
70
|
+
service, owner, app, private, store, backend = public_site
|
|
71
|
+
login = service.login('owner@example.test', 'Example-owner-password!')
|
|
72
|
+
with local_server(PlatformHandler, platform=service) as url:
|
|
73
|
+
from urllib.parse import urlsplit
|
|
74
|
+
parsed = urlsplit(url); conn = HTTPConnection(parsed.hostname, parsed.port)
|
|
75
|
+
conn.request('GET','/',headers={'Cookie':CONTROL_COOKIE+'='+login['token']})
|
|
76
|
+
response = conn.getresponse(); body = response.read().decode()
|
|
77
|
+
assert response.status == 200
|
|
78
|
+
assert 'id="project-'+app['project_id']+'"' in body
|
|
79
|
+
assert 'confirm(' in body and '公开' in body
|
|
80
|
+
assert '保存访问设置' in body and '<option value="private" selected>' in body
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def control_login(service, username, password):
|
|
85
|
+
value=service.login(username,password)
|
|
86
|
+
value['csrf_token']=service.control_session(value['token']).csrf_token
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def visibility_http(service, url, project, token, csrf, data, *, spoof=False):
|
|
91
|
+
from urllib.parse import urlsplit
|
|
92
|
+
parsed=urlsplit(url);conn=HTTPConnection(parsed.hostname,parsed.port)
|
|
93
|
+
headers={'Content-Type':'application/json','Origin':service.control_url}
|
|
94
|
+
if token:headers['Cookie']=CONTROL_COOKIE+'='+token
|
|
95
|
+
if csrf:headers['X-CSRF-Token']=csrf
|
|
96
|
+
if spoof:headers.update({'X-User':'owner','X-Role':'owner'})
|
|
97
|
+
conn.request('PATCH','/api/platform/v1/projects/'+project,body=json.dumps(data),headers=headers)
|
|
98
|
+
response=conn.getresponse();status=response.status;body=response.read();conn.close()
|
|
99
|
+
return status,json.loads(body)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_visibility_http_rejects_stale_expected_mode_without_reopening(public_site):
|
|
103
|
+
service,owner,app,private,store,backend=public_site
|
|
104
|
+
service.set_visibility(owner,app['project_id'],'private')
|
|
105
|
+
login=control_login(service,'owner@example.test','Example-owner-password!')
|
|
106
|
+
with local_server(PlatformHandler,platform=service) as url:
|
|
107
|
+
status,body=visibility_http(service,url,app['project_id'],login['token'],login['csrf_token'],
|
|
108
|
+
{'visibility':'public','expected_visibility':'public'})
|
|
109
|
+
assert status==409
|
|
110
|
+
assert service.project(owner,app['project_id'])['visibility']=='private'
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_visibility_http_checks_fresh_expected_mode_for_both_transitions(public_site):
|
|
114
|
+
service,owner,app,private,store,backend=public_site
|
|
115
|
+
login=control_login(service,'owner@example.test','Example-owner-password!')
|
|
116
|
+
with local_server(PlatformHandler,platform=service) as url:
|
|
117
|
+
for old,new in [('public','private'),('private','public'),('public','private')]:
|
|
118
|
+
status,body=visibility_http(service,url,app['project_id'],login['token'],login['csrf_token'],
|
|
119
|
+
{'visibility':new,'expected_visibility':old})
|
|
120
|
+
assert status==200 and body['visibility']==new
|
|
121
|
+
assert service.project(owner,app['project_id'])['visibility']==new
|
|
122
|
+
status,_=visibility_http(service,url,app['project_id'],login['token'],login['csrf_token'],
|
|
123
|
+
{'visibility':'public','expected_visibility':'not-a-mode'})
|
|
124
|
+
assert status==422
|
|
125
|
+
assert service.project(owner,app['project_id'])['visibility']=='private'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize('identity,csrf_ok,spoof,expected',[
|
|
129
|
+
('guest',False,False,401),('guest',False,True,401),('viewer',True,True,403),('owner',False,False,403)])
|
|
130
|
+
def test_visibility_http_keeps_owner_and_csrf_gate(public_site,identity,csrf_ok,spoof,expected):
|
|
131
|
+
service,owner,app,private,store,backend=public_site
|
|
132
|
+
service.set_visibility(owner,app['project_id'],'private')
|
|
133
|
+
login={}
|
|
134
|
+
if identity=='owner':login=control_login(service,'owner@example.test','Example-owner-password!')
|
|
135
|
+
if identity=='viewer':
|
|
136
|
+
member=service.users.create_user(owner,'reader@example.test','Example-reader-password!')
|
|
137
|
+
service.grant(owner,app['project_id'],member.user_id,'viewer')
|
|
138
|
+
login=control_login(service,member.username,'Example-reader-password!')
|
|
139
|
+
with local_server(PlatformHandler,platform=service) as url:
|
|
140
|
+
status,_=visibility_http(service,url,app['project_id'],login.get('token'),login.get('csrf_token') if csrf_ok else None,
|
|
141
|
+
{'visibility':'public','expected_visibility':'private'},spoof=spoof)
|
|
142
|
+
assert status==expected
|
|
143
|
+
assert service.project(owner,app['project_id'])['visibility']=='private'
|
|
144
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|