plain 0.66.0__py3-none-any.whl → 0.67.0__py3-none-any.whl
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.
- plain/CHANGELOG.md +11 -0
- plain/cli/agent/request.py +111 -120
- plain/internal/middleware/hosts.py +0 -5
- plain/preflight/README.md +2 -2
- plain/preflight/security.py +3 -3
- plain/runtime/global_settings.py +4 -3
- {plain-0.66.0.dist-info → plain-0.67.0.dist-info}/METADATA +1 -1
- {plain-0.66.0.dist-info → plain-0.67.0.dist-info}/RECORD +11 -11
- {plain-0.66.0.dist-info → plain-0.67.0.dist-info}/WHEEL +0 -0
- {plain-0.66.0.dist-info → plain-0.67.0.dist-info}/entry_points.txt +0 -0
- {plain-0.66.0.dist-info → plain-0.67.0.dist-info}/licenses/LICENSE +0 -0
plain/CHANGELOG.md
CHANGED
@@ -1,5 +1,16 @@
|
|
1
1
|
# plain changelog
|
2
2
|
|
3
|
+
## [0.67.0](https://github.com/dropseed/plain/releases/plain@0.67.0) (2025-09-22)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- `ALLOWED_HOSTS` now defaults to `[]` (empty list) which allows all hosts, making it easier for development setups ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
|
8
|
+
- Empty `ALLOWED_HOSTS` in production now triggers a preflight error instead of a warning to ensure proper security configuration ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
|
9
|
+
|
10
|
+
### Upgrade instructions
|
11
|
+
|
12
|
+
- No changes required
|
13
|
+
|
3
14
|
## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
|
4
15
|
|
5
16
|
### What's changed
|
plain/cli/agent/request.py
CHANGED
@@ -46,136 +46,127 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
46
46
|
click.secho("This command only works when DEBUG=True", fg="red", err=True)
|
47
47
|
return
|
48
48
|
|
49
|
-
#
|
50
|
-
|
51
|
-
settings.ALLOWED_HOSTS = ["*"]
|
49
|
+
# Create test client
|
50
|
+
client = Client()
|
52
51
|
|
53
|
-
|
54
|
-
|
55
|
-
|
52
|
+
# If user_id provided, force login
|
53
|
+
if user_id:
|
54
|
+
try:
|
55
|
+
# Get the User model using plain.auth utility
|
56
|
+
from plain.auth import get_user_model
|
56
57
|
|
57
|
-
|
58
|
-
if user_id:
|
59
|
-
try:
|
60
|
-
# Get the User model using plain.auth utility
|
61
|
-
from plain.auth import get_user_model
|
62
|
-
|
63
|
-
User = get_user_model()
|
64
|
-
|
65
|
-
# Get the user
|
66
|
-
try:
|
67
|
-
user = User.query.get(id=user_id)
|
68
|
-
client.force_login(user)
|
69
|
-
click.secho(
|
70
|
-
f"Authenticated as user {user_id}", fg="green", dim=True
|
71
|
-
)
|
72
|
-
except User.DoesNotExist:
|
73
|
-
click.secho(f"User {user_id} not found", fg="red", err=True)
|
74
|
-
return
|
75
|
-
|
76
|
-
except Exception as e:
|
77
|
-
click.secho(f"Authentication error: {e}", fg="red", err=True)
|
78
|
-
return
|
79
|
-
|
80
|
-
# Parse additional headers
|
81
|
-
header_dict = {}
|
82
|
-
for header in headers:
|
83
|
-
if ":" in header:
|
84
|
-
key, value = header.split(":", 1)
|
85
|
-
header_dict[key.strip()] = value.strip()
|
58
|
+
User = get_user_model()
|
86
59
|
|
87
|
-
|
88
|
-
if data and content_type and "json" in content_type.lower():
|
60
|
+
# Get the user
|
89
61
|
try:
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
62
|
+
user = User.query.get(id=user_id)
|
63
|
+
client.force_login(user)
|
64
|
+
click.secho(
|
65
|
+
f"Authenticated as user {user_id}", fg="green", dim=True
|
66
|
+
)
|
67
|
+
except User.DoesNotExist:
|
68
|
+
click.secho(f"User {user_id} not found", fg="red", err=True)
|
94
69
|
return
|
95
70
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
elif method == "PUT":
|
115
|
-
response = client.put(**kwargs)
|
116
|
-
elif method == "PATCH":
|
117
|
-
response = client.patch(**kwargs)
|
118
|
-
elif method == "DELETE":
|
119
|
-
response = client.delete(**kwargs)
|
120
|
-
elif method == "HEAD":
|
121
|
-
response = client.head(**kwargs)
|
122
|
-
elif method == "OPTIONS":
|
123
|
-
response = client.options(**kwargs)
|
124
|
-
elif method == "TRACE":
|
125
|
-
response = client.trace(**kwargs)
|
126
|
-
else:
|
127
|
-
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
71
|
+
except Exception as e:
|
72
|
+
click.secho(f"Authentication error: {e}", fg="red", err=True)
|
73
|
+
return
|
74
|
+
|
75
|
+
# Parse additional headers
|
76
|
+
header_dict = {}
|
77
|
+
for header in headers:
|
78
|
+
if ":" in header:
|
79
|
+
key, value = header.split(":", 1)
|
80
|
+
header_dict[key.strip()] = value.strip()
|
81
|
+
|
82
|
+
# Prepare request data
|
83
|
+
if data and content_type and "json" in content_type.lower():
|
84
|
+
try:
|
85
|
+
# Validate JSON
|
86
|
+
json.loads(data)
|
87
|
+
except json.JSONDecodeError as e:
|
88
|
+
click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
|
128
89
|
return
|
129
90
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
91
|
+
# Make the request
|
92
|
+
method = method.upper()
|
93
|
+
kwargs = {
|
94
|
+
"path": path,
|
95
|
+
"follow": follow,
|
96
|
+
"headers": header_dict or None,
|
97
|
+
}
|
98
|
+
|
99
|
+
if method in ("POST", "PUT", "PATCH") and data:
|
100
|
+
kwargs["data"] = data
|
101
|
+
if content_type:
|
102
|
+
kwargs["content_type"] = content_type
|
103
|
+
|
104
|
+
# Call the appropriate client method
|
105
|
+
if method == "GET":
|
106
|
+
response = client.get(**kwargs)
|
107
|
+
elif method == "POST":
|
108
|
+
response = client.post(**kwargs)
|
109
|
+
elif method == "PUT":
|
110
|
+
response = client.put(**kwargs)
|
111
|
+
elif method == "PATCH":
|
112
|
+
response = client.patch(**kwargs)
|
113
|
+
elif method == "DELETE":
|
114
|
+
response = client.delete(**kwargs)
|
115
|
+
elif method == "HEAD":
|
116
|
+
response = client.head(**kwargs)
|
117
|
+
elif method == "OPTIONS":
|
118
|
+
response = client.options(**kwargs)
|
119
|
+
elif method == "TRACE":
|
120
|
+
response = client.trace(**kwargs)
|
121
|
+
else:
|
122
|
+
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
123
|
+
return
|
124
|
+
|
125
|
+
# Display response information
|
126
|
+
click.secho(
|
127
|
+
f"HTTP {response.status_code}",
|
128
|
+
fg="green" if response.status_code < 400 else "red",
|
129
|
+
bold=True,
|
130
|
+
)
|
131
|
+
|
132
|
+
# Show additional response info first
|
133
|
+
if hasattr(response, "user"):
|
134
|
+
click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
|
135
|
+
|
136
|
+
if hasattr(response, "resolver_match") and response.resolver_match:
|
137
|
+
match = response.resolver_match
|
138
|
+
url_name = match.namespaced_url_name or match.url_name or "unnamed"
|
139
|
+
click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
|
140
|
+
|
141
|
+
# Show headers
|
142
|
+
if response.headers:
|
143
|
+
click.secho("Response Headers:", fg="yellow", bold=True)
|
144
|
+
for key, value in response.headers.items():
|
145
|
+
click.echo(f" {key}: {value}")
|
146
|
+
click.echo()
|
147
|
+
|
148
|
+
# Show response content last
|
149
|
+
if response.content:
|
150
|
+
content_type = response.headers.get("Content-Type", "")
|
151
|
+
|
152
|
+
if "json" in content_type.lower():
|
153
|
+
try:
|
154
|
+
json_data = response.json()
|
155
|
+
click.secho("Response Body (JSON):", fg="yellow", bold=True)
|
156
|
+
click.echo(json.dumps(json_data, indent=2))
|
157
|
+
except Exception:
|
170
158
|
click.secho("Response Body:", fg="yellow", bold=True)
|
171
|
-
|
172
|
-
|
159
|
+
click.echo(response.content.decode("utf-8", errors="replace"))
|
160
|
+
elif "html" in content_type.lower():
|
161
|
+
click.secho("Response Body (HTML):", fg="yellow", bold=True)
|
162
|
+
content = response.content.decode("utf-8", errors="replace")
|
163
|
+
click.echo(content)
|
173
164
|
else:
|
174
|
-
click.secho("
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
165
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
166
|
+
content = response.content.decode("utf-8", errors="replace")
|
167
|
+
click.echo(content)
|
168
|
+
else:
|
169
|
+
click.secho("(No response body)", fg="yellow", dim=True)
|
179
170
|
|
180
171
|
except Exception as e:
|
181
172
|
click.secho(f"Request failed: {e}", fg="red", err=True)
|
@@ -159,7 +159,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
|
|
159
159
|
Check that the host looks valid and matches a host or host pattern in the
|
160
160
|
given list of ``allowed_hosts``. Supported patterns:
|
161
161
|
|
162
|
-
- ``*`` matches anything
|
163
162
|
- ``.example.com`` matches a domain and all its subdomains
|
164
163
|
(e.g. ``example.com`` and ``sub.example.com``)
|
165
164
|
- ``example.com`` matches exactly that domain
|
@@ -176,10 +175,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
|
|
176
175
|
host_ip = parse_ip_address(host)
|
177
176
|
|
178
177
|
for pattern in allowed_hosts:
|
179
|
-
# Wildcard matches everything
|
180
|
-
if pattern == "*":
|
181
|
-
return True
|
182
|
-
|
183
178
|
# Check CIDR notation patterns using walrus operator
|
184
179
|
if network := parse_cidr_pattern(pattern):
|
185
180
|
if host_ip and host_ip in network:
|
plain/preflight/README.md
CHANGED
@@ -51,11 +51,11 @@ def custom_deploy_check(package_configs, **kwargs):
|
|
51
51
|
|
52
52
|
## Silencing preflight checks
|
53
53
|
|
54
|
-
The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.
|
54
|
+
The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.E020`).
|
55
55
|
|
56
56
|
```python
|
57
57
|
# app/settings.py
|
58
58
|
PREFLIGHT_SILENCED_CHECKS = [
|
59
|
-
"security.
|
59
|
+
"security.E020", # Allow empty ALLOWED_HOSTS in deployment
|
60
60
|
]
|
61
61
|
```
|
plain/preflight/security.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
from plain.exceptions import ImproperlyConfigured
|
2
2
|
from plain.runtime import settings
|
3
3
|
|
4
|
-
from .messages import Warning
|
4
|
+
from .messages import Error, Warning
|
5
5
|
from .registry import register_check
|
6
6
|
|
7
7
|
SECRET_KEY_MIN_LENGTH = 50
|
@@ -81,9 +81,9 @@ def check_allowed_hosts(package_configs, **kwargs):
|
|
81
81
|
[]
|
82
82
|
if settings.ALLOWED_HOSTS
|
83
83
|
else [
|
84
|
-
|
84
|
+
Error(
|
85
85
|
"ALLOWED_HOSTS must not be empty in deployment.",
|
86
|
-
id="security.
|
86
|
+
id="security.E020",
|
87
87
|
)
|
88
88
|
]
|
89
89
|
)
|
plain/runtime/global_settings.py
CHANGED
@@ -23,9 +23,10 @@ URLS_ROUTER: str
|
|
23
23
|
# MARK: HTTP and Security
|
24
24
|
|
25
25
|
# Hosts/domain names that are valid for this site.
|
26
|
-
#
|
27
|
-
# "
|
28
|
-
|
26
|
+
# - An empty list [] allows all hosts (useful for development).
|
27
|
+
# - ".example.com" matches example.com and all subdomains
|
28
|
+
# - "192.168.1.0/24" matches IP addresses in that CIDR range
|
29
|
+
ALLOWED_HOSTS: list[str] = []
|
29
30
|
|
30
31
|
# Default headers for all responses.
|
31
32
|
DEFAULT_RESPONSE_HEADERS = {
|
@@ -1,5 +1,5 @@
|
|
1
1
|
plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
|
2
|
-
plain/CHANGELOG.md,sha256=
|
2
|
+
plain/CHANGELOG.md,sha256=8Ljw7VTQP1cn6sUy_1Pum2cmHsnmeRqbL8oBIWvxw-s,16604
|
3
3
|
plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
|
4
4
|
plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
|
5
5
|
plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
|
@@ -44,7 +44,7 @@ plain/cli/agent/docs.py,sha256=ubX3ZeRHxVaetLk9fjiN9mJ07GZExC-CHUvQoX2DD7c,2464
|
|
44
44
|
plain/cli/agent/llmdocs.py,sha256=AUpNDb1xSOsSpzGOiFvpzUe4f7PUGMiR9cI13aVZouo,5038
|
45
45
|
plain/cli/agent/md.py,sha256=7r1II8ckubBFOZNGPASWaPmJdgByWFPINLqIOzRetLQ,2581
|
46
46
|
plain/cli/agent/prompt.py,sha256=rugYyQHV7JDNqGrx3_PPShwwqYlnEVbxw8RsczOo8tg,1253
|
47
|
-
plain/cli/agent/request.py,sha256=
|
47
|
+
plain/cli/agent/request.py,sha256=uUbZoAhSSjOLtzgAAzacvqCS_27H-AvoeqIxpzjUjAA,5800
|
48
48
|
plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
|
49
49
|
plain/csrf/middleware.py,sha256=KF8ngFadWdS0MHXC1dTLx-K3VtD6Xs-3RDjFqpiiEjQ,5053
|
50
50
|
plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
|
@@ -75,7 +75,7 @@ plain/internal/handlers/exception.py,sha256=TbPYtgZ7ITJahUKhQWkptHK28Lb4zh_nOviN
|
|
75
75
|
plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
|
76
76
|
plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
77
77
|
plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
|
78
|
-
plain/internal/middleware/hosts.py,sha256=
|
78
|
+
plain/internal/middleware/hosts.py,sha256=vKdjElDk1-Ju6FUXtm5t0AfPPN-DPhjS31PkRrhM6vc,5799
|
79
79
|
plain/internal/middleware/https.py,sha256=bjysNX6_2l4Rp4DpzF2XYjH-m6gfHNq2x-MN2hHGW78,804
|
80
80
|
plain/internal/middleware/slash.py,sha256=qBTJ-yvOM0d8gUISUC7Fx22LG6aj76uhOiNHjvTkAAs,2840
|
81
81
|
plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
|
@@ -89,16 +89,16 @@ plain/packages/README.md,sha256=iNqMtwFDVNf2TqKUzLKQW5Y4_GsssmdB4cVerzu27Ro,2674
|
|
89
89
|
plain/packages/__init__.py,sha256=OpQny0xLplPdPpozVUUkrW2gB-IIYyDT1b4zMzOcCC4,160
|
90
90
|
plain/packages/config.py,sha256=2U7b1cp_kqIuLdSeHGCLrSUV77TdfGRsww3PcXOazaA,2910
|
91
91
|
plain/packages/registry.py,sha256=6ogeHZ8t3kBSoLoI7998r0kIbkEPhLGn-7yi-1qVjVo,7969
|
92
|
-
plain/preflight/README.md,sha256=
|
92
|
+
plain/preflight/README.md,sha256=6fA6zXmNUtO5xMrVV-0JLyzSJsIIEEGZyFSAW53nYYE,1764
|
93
93
|
plain/preflight/__init__.py,sha256=j4-yPnrM5hmjumrdkBLOQjFHzRHpA6wCjiFpMNBjIqY,619
|
94
94
|
plain/preflight/files.py,sha256=D_pBSwRXpXy2-3FWywweozuxrhIaR8w5hpPA2d6XMPs,522
|
95
95
|
plain/preflight/messages.py,sha256=B6VyXzu7HTJHaPVK4G1L_1HVHG87CT7JPtcDk8QYSeE,2322
|
96
96
|
plain/preflight/registry.py,sha256=vcqzaE1MIneNL_ydKPy_1zrSThnzsrWARSClLCJ-4b8,2331
|
97
|
-
plain/preflight/security.py,sha256=
|
97
|
+
plain/preflight/security.py,sha256=71cW35LMOlciAbtWXf50EhKhQQCZ-2LlQgXJ6Zxhxv4,2443
|
98
98
|
plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
|
99
99
|
plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
|
100
100
|
plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
|
101
|
-
plain/runtime/global_settings.py,sha256=
|
101
|
+
plain/runtime/global_settings.py,sha256=EZ9mdDkwJkXGWDs0bnYnNmpfX49GP30RyU5HgxlrRPc,5872
|
102
102
|
plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
|
103
103
|
plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
|
104
104
|
plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
|
@@ -161,8 +161,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
|
|
161
161
|
plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
|
162
162
|
plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
|
163
163
|
plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
|
164
|
-
plain-0.
|
165
|
-
plain-0.
|
166
|
-
plain-0.
|
167
|
-
plain-0.
|
168
|
-
plain-0.
|
164
|
+
plain-0.67.0.dist-info/METADATA,sha256=w_ZT2zv65VtVbl4yh0tKrVw8q9PeDj0cKWeeLm3pPNU,4488
|
165
|
+
plain-0.67.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
166
|
+
plain-0.67.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
|
167
|
+
plain-0.67.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
168
|
+
plain-0.67.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|