ChatCoolify 0.2.2__tar.gz → 0.2.4__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.
Files changed (34) hide show
  1. {chatcoolify-0.2.2/src/ChatCoolify.egg-info → chatcoolify-0.2.4}/PKG-INFO +14 -1
  2. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/README.md +13 -0
  3. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/pyproject.toml +1 -1
  4. {chatcoolify-0.2.2 → chatcoolify-0.2.4/src/ChatCoolify.egg-info}/PKG-INFO +14 -1
  5. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/ChatCoolify.egg-info/SOURCES.txt +5 -1
  6. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/__init__.py +1 -1
  7. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform.py +17 -4
  8. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_runtime.py +14 -0
  9. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_web.py +11 -10
  10. chatcoolify-0.2.4/tests/test_platform_public_projection.py +82 -0
  11. chatcoolify-0.2.4/tests/test_public_patch_version.py +13 -0
  12. chatcoolify-0.2.4/tests/test_public_release_docs.py +13 -0
  13. chatcoolify-0.2.4/tests/test_site_management.py +144 -0
  14. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/LICENSE +0 -0
  15. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/setup.cfg +0 -0
  16. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/ChatCoolify.egg-info/dependency_links.txt +0 -0
  17. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/ChatCoolify.egg-info/entry_points.txt +0 -0
  18. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/ChatCoolify.egg-info/requires.txt +0 -0
  19. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/ChatCoolify.egg-info/top_level.txt +0 -0
  20. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/cli.py +0 -0
  21. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/client.py +0 -0
  22. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/config.py +0 -0
  23. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_artifact.py +0 -0
  24. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_backend.py +0 -0
  25. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_cli.py +0 -0
  26. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_client.py +0 -0
  27. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/src/chatcoolify/platform_source.py +0 -0
  28. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_cli.py +0 -0
  29. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_client.py +0 -0
  30. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_platform.py +0 -0
  31. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_platform_domains.py +0 -0
  32. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_platform_management.py +0 -0
  33. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/tests/test_platform_public.py +0 -0
  34. {chatcoolify-0.2.2 → chatcoolify-0.2.4}/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.2
3
+ Version: 0.2.4
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,16 @@ 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
+ 正式发布包包含上述签名只读投影能力;是否允许公开读取仍由项目的持久化访问策略决定。
135
+
136
+ ### 原生项目访问管理
137
+
138
+ 已认证的项目所有者可通过 `GET /__platform/manage` 进入原生控制台的准确项目设置。正式站与预览共用持久化的访问模式;公开切换需要明确确认,保存时验证预期的旧模式并再次读取结果。普通阅读账号不能管理访问权限。该能力不新增或改名 CLI 命令。
@@ -84,3 +84,16 @@ 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
+ 正式发布包包含上述签名只读投影能力;是否允许公开读取仍由项目的持久化访问策略决定。
96
+
97
+ ### 原生项目访问管理
98
+
99
+ 已认证的项目所有者可通过 `GET /__platform/manage` 进入原生控制台的准确项目设置。正式站与预览共用持久化的访问模式;公开切换需要明确确认,保存时验证预期的旧模式并再次读取结果。普通阅读账号不能管理访问权限。该能力不新增或改名 CLI 命令。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ChatCoolify"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "AI-safe Python client and CLI for the official Coolify API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatCoolify
3
- Version: 0.2.2
3
+ Version: 0.2.4
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,16 @@ 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
+ 正式发布包包含上述签名只读投影能力;是否允许公开读取仍由项目的持久化访问策略决定。
135
+
136
+ ### 原生项目访问管理
137
+
138
+ 已认证的项目所有者可通过 `GET /__platform/manage` 进入原生控制台的准确项目设置。正式站与预览共用持久化的访问模式;公开切换需要明确确认,保存时验证预期的旧模式并再次读取结果。普通阅读账号不能管理访问权限。该能力不新增或改名 CLI 命令。
@@ -25,4 +25,8 @@ 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/test_release_contract.py
28
+ tests/test_platform_public_projection.py
29
+ tests/test_public_patch_version.py
30
+ tests/test_public_release_docs.py
31
+ tests/test_release_contract.py
32
+ tests/test_site_management.py
@@ -10,7 +10,7 @@ from .client import (
10
10
  PublicApplicationSpec,
11
11
  )
12
12
 
13
- __version__ = "0.2.2"
13
+ __version__ = "0.2.4"
14
14
 
15
15
  from .platform import PlatformService
16
16
  from .platform_client import PlatformClient
@@ -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 reading and adding comments')
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>访客访问方式<select name="visibility">'
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}>私有:仅授权用户</option></select></label>'
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">私有:仅授权用户</option></select></label><button>创建网站</button><p class="feedback" role="status"></p></form><p>创建后上传本地构建目录,先预览,再发布。公开网站可直接分享,访客不需要平台账号。</p></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">仅登录:仅已授权用户</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
- return self.respond(200,self.service.set_visibility(actor,project_id,self.json().get('visibility')))
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.4"
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.4"' in (root / 'pyproject.toml').read_text()
13
+ assert '## 0.2.4' in (root / 'CHANGELOG.md').read_text()
@@ -0,0 +1,13 @@
1
+ """Public release documentation must not retain an active private-build claim."""
2
+ from pathlib import Path
3
+
4
+
5
+ def test_active_documentation_does_not_claim_the_distribution_is_a_private_build():
6
+ root = Path(__file__).resolve().parents[1]
7
+ files = [root / 'README.md', root / 'README.en.md', root / 'DEVELOP.md', *sorted((root / 'docs').glob('*.md'))]
8
+ stale = ('0.2.2+access.1', '0.2.2+publicread.1',
9
+ 'This private build does not add or rename CLI commands.',
10
+ 'not a formal PyPI release', '不是 PyPI 正式发版')
11
+ findings = [(path.relative_to(root).as_posix(), marker)
12
+ for path in files for marker in stale if marker in path.read_text()]
13
+ assert findings == []
@@ -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