qhost 0.1.0__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.
- qhost-0.1.0/LICENSE +21 -0
- qhost-0.1.0/PKG-INFO +179 -0
- qhost-0.1.0/README.md +136 -0
- qhost-0.1.0/pyproject.toml +80 -0
- qhost-0.1.0/qhost/__init__.py +3 -0
- qhost-0.1.0/qhost/_io.py +66 -0
- qhost-0.1.0/qhost/_toml.py +99 -0
- qhost-0.1.0/qhost/builders/__init__.py +124 -0
- qhost-0.1.0/qhost/builders/directory.py +56 -0
- qhost-0.1.0/qhost/builders/quarto.py +88 -0
- qhost-0.1.0/qhost/cli.py +576 -0
- qhost-0.1.0/qhost/client.py +316 -0
- qhost-0.1.0/qhost/config.py +111 -0
- qhost-0.1.0/qhost/creds.py +177 -0
- qhost-0.1.0/qhost/doctor.py +325 -0
- qhost-0.1.0/qhost/errors.py +173 -0
- qhost-0.1.0/qhost/format.py +332 -0
- qhost-0.1.0/qhost/project.py +183 -0
- qhost-0.1.0/qhost/publish.py +467 -0
- qhost-0.1.0/qhost.egg-info/PKG-INFO +179 -0
- qhost-0.1.0/qhost.egg-info/SOURCES.txt +42 -0
- qhost-0.1.0/qhost.egg-info/dependency_links.txt +1 -0
- qhost-0.1.0/qhost.egg-info/entry_points.txt +2 -0
- qhost-0.1.0/qhost.egg-info/requires.txt +16 -0
- qhost-0.1.0/qhost.egg-info/top_level.txt +3 -0
- qhost-0.1.0/qhost_core/__init__.py +85 -0
- qhost-0.1.0/qhost_core/archive.py +230 -0
- qhost-0.1.0/qhost_core/durations.py +43 -0
- qhost-0.1.0/qhost_core/models.py +244 -0
- qhost-0.1.0/qhost_core/slugs.py +66 -0
- qhost-0.1.0/qhost_core/version.py +2 -0
- qhost-0.1.0/qhostd/__init__.py +23 -0
- qhost-0.1.0/qhostd/api.py +361 -0
- qhost-0.1.0/qhostd/app.py +181 -0
- qhost-0.1.0/qhostd/config.py +208 -0
- qhost-0.1.0/qhostd/content.py +199 -0
- qhost-0.1.0/qhostd/errors.py +225 -0
- qhost-0.1.0/qhostd/locks.py +36 -0
- qhost-0.1.0/qhostd/prune.py +77 -0
- qhost-0.1.0/qhostd/reconcile.py +130 -0
- qhost-0.1.0/qhostd/security.py +482 -0
- qhost-0.1.0/qhostd/serving.py +311 -0
- qhost-0.1.0/qhostd/store.py +398 -0
- qhost-0.1.0/setup.cfg +4 -0
qhost-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan Fasnacht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
qhost-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qhost
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-hosted static-site host with a single trusted publisher.
|
|
5
|
+
Author: Jan Fasnacht
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/janfasnacht/qhost
|
|
8
|
+
Project-URL: Source, https://github.com/janfasnacht/qhost
|
|
9
|
+
Project-URL: Issues, https://github.com/janfasnacht/qhost/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/janfasnacht/qhost/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: static-site,self-hosted,file-sharing,expiring-shares,preview-environments
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
24
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pydantic>=2.6
|
|
29
|
+
Requires-Dist: httpx>=0.27
|
|
30
|
+
Requires-Dist: typer>=0.12
|
|
31
|
+
Provides-Extra: server
|
|
32
|
+
Requires-Dist: fastapi>=0.111; extra == "server"
|
|
33
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == "server"
|
|
34
|
+
Requires-Dist: bcrypt>=4.1; extra == "server"
|
|
35
|
+
Requires-Dist: python-multipart>=0.0.9; extra == "server"
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
38
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
39
|
+
Requires-Dist: hypothesis>=6; extra == "dev"
|
|
40
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
41
|
+
Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# qhost
|
|
45
|
+
|
|
46
|
+
qhost publishes a directory of static files — a built site, a rendered
|
|
47
|
+
report, a coverage tree — at a URL that requires access and expires on a
|
|
48
|
+
schedule. The operator runs one container, points a DNS record at their
|
|
49
|
+
own reverse proxy, and manages shares from the command line. It is
|
|
50
|
+
access-controlled hosting for a single trusted publisher per instance;
|
|
51
|
+
it does not isolate mutually distrusting shares from one another.
|
|
52
|
+
|
|
53
|
+
## Self-host the server
|
|
54
|
+
|
|
55
|
+
qhostd ships as `ghcr.io/janfasnacht/qhost-server`. Drop a
|
|
56
|
+
`docker-compose.yml` and `.env` somewhere on your box and start it:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# docker-compose.yml
|
|
60
|
+
services:
|
|
61
|
+
qhostd:
|
|
62
|
+
image: ghcr.io/janfasnacht/qhost-server:0.1.0
|
|
63
|
+
restart: unless-stopped
|
|
64
|
+
environment:
|
|
65
|
+
QHOST_DOMAIN: ${QHOST_DOMAIN:?}
|
|
66
|
+
QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
|
|
67
|
+
QHOST_TOKEN: ${QHOST_TOKEN:-}
|
|
68
|
+
volumes:
|
|
69
|
+
- qhost_data:/data
|
|
70
|
+
ports:
|
|
71
|
+
- "127.0.0.1:8000:8000"
|
|
72
|
+
|
|
73
|
+
volumes:
|
|
74
|
+
qhost_data:
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
# .env
|
|
79
|
+
QHOST_DOMAIN=share.example.com
|
|
80
|
+
QHOST_TRUSTED_PROXY=127.0.0.1 # whatever IP / CIDR your proxy connects from
|
|
81
|
+
QHOST_TOKEN= # leave blank to auto-generate on first start
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
docker compose up -d
|
|
86
|
+
docker compose exec qhostd cat /data/operator-token # if you left it blank
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
qhostd is now on `http://127.0.0.1:8000` of the host, ready to be
|
|
90
|
+
proxied. The full env-var surface is in
|
|
91
|
+
[`deploy/.env.example`](deploy/.env.example) and the canonical compose
|
|
92
|
+
is in [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
|
|
93
|
+
|
|
94
|
+
## Wire a reverse proxy
|
|
95
|
+
|
|
96
|
+
qhostd doesn't terminate TLS, doesn't strip incoming `X-Forwarded-For`,
|
|
97
|
+
and doesn't enforce a body-size cap before the request reaches Python.
|
|
98
|
+
A proxy in front of it has to do those three things, and qhostd has to
|
|
99
|
+
trust that proxy via `QHOST_TRUSTED_PROXY` (an IP or CIDR — see above).
|
|
100
|
+
|
|
101
|
+
Full ready-to-edit examples are in [`deploy/proxies/`](deploy/proxies/).
|
|
102
|
+
The interesting line in each:
|
|
103
|
+
|
|
104
|
+
**Caddy** ([`deploy/proxies/Caddyfile`](deploy/proxies/Caddyfile))
|
|
105
|
+
|
|
106
|
+
```caddyfile
|
|
107
|
+
{$QHOST_DOMAIN} {
|
|
108
|
+
reverse_proxy 127.0.0.1:8000 {
|
|
109
|
+
header_up X-Forwarded-For {http.request.remote.host} # overwrite, not append
|
|
110
|
+
}
|
|
111
|
+
request_body { max_size 110MB }
|
|
112
|
+
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**nginx** ([`deploy/proxies/nginx.conf`](deploy/proxies/nginx.conf))
|
|
117
|
+
|
|
118
|
+
```nginx
|
|
119
|
+
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, not append
|
|
120
|
+
client_max_body_size 110m;
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Cloudflare Tunnel** ([`deploy/proxies/cloudflared.md`](deploy/proxies/cloudflared.md))
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
ingress:
|
|
127
|
+
- hostname: share.example.com
|
|
128
|
+
service: http://127.0.0.1:8000
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`cloudflared` connects from loopback, so `QHOST_TRUSTED_PROXY=127.0.0.1`
|
|
132
|
+
is what you want.
|
|
133
|
+
|
|
134
|
+
## Install the CLI
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install qhost
|
|
138
|
+
qhost login https://share.example.com
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`qhost login` prompts for the operator token and writes it to
|
|
142
|
+
`~/.config/qhost/config.toml` (mode 0600).
|
|
143
|
+
|
|
144
|
+
## Publish
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
cd path/to/built/site && qhost publish
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`qhost publish` from a directory containing `index.html` publishes that
|
|
151
|
+
directory as-is. From a Quarto project (`_quarto.yml` present) it runs
|
|
152
|
+
`quarto render` first. Otherwise it refuses; pass `--dir <path>` to
|
|
153
|
+
point at a built output or `--build <name>` to force a specific builder.
|
|
154
|
+
|
|
155
|
+
`qhost --help` covers the rest: `publish`, `ls`, `info`, `extend`, `rm`,
|
|
156
|
+
`open`, `doctor`. Default expiry is seven days. `--password` gates a
|
|
157
|
+
share behind HTTP Basic with a generated password; `--public` makes a
|
|
158
|
+
guessable URL with no secret; the default `link` mode treats the URL
|
|
159
|
+
itself as the credential — `/s/<slug>/` carries ~80 bits of entropy and
|
|
160
|
+
is not enumerable.
|
|
161
|
+
|
|
162
|
+
`qhost publish --sandbox` serves a share under
|
|
163
|
+
`Content-Security-Policy: sandbox allow-scripts`, the only
|
|
164
|
+
intra-instance isolation primitive. `qhost doctor` flags content that
|
|
165
|
+
probably wants it.
|
|
166
|
+
|
|
167
|
+
## Trust model
|
|
168
|
+
|
|
169
|
+
One operator token authorizes all publishing on an instance; every
|
|
170
|
+
visitor is untrusted. All shares serve from the same browser origin, so
|
|
171
|
+
a share's JavaScript can reach any other share on the same instance
|
|
172
|
+
with the visitor's ambient Basic-auth credentials. For mutually
|
|
173
|
+
distrusting publishers, run separate instances on separate origins.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
Configuration: [`deploy/.env.example`](deploy/.env.example). Reverse
|
|
178
|
+
proxy examples: [`deploy/proxies/`](deploy/proxies/). Vulnerability
|
|
179
|
+
reports: [`SECURITY.md`](SECURITY.md). MIT.
|
qhost-0.1.0/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# qhost
|
|
2
|
+
|
|
3
|
+
qhost publishes a directory of static files — a built site, a rendered
|
|
4
|
+
report, a coverage tree — at a URL that requires access and expires on a
|
|
5
|
+
schedule. The operator runs one container, points a DNS record at their
|
|
6
|
+
own reverse proxy, and manages shares from the command line. It is
|
|
7
|
+
access-controlled hosting for a single trusted publisher per instance;
|
|
8
|
+
it does not isolate mutually distrusting shares from one another.
|
|
9
|
+
|
|
10
|
+
## Self-host the server
|
|
11
|
+
|
|
12
|
+
qhostd ships as `ghcr.io/janfasnacht/qhost-server`. Drop a
|
|
13
|
+
`docker-compose.yml` and `.env` somewhere on your box and start it:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
# docker-compose.yml
|
|
17
|
+
services:
|
|
18
|
+
qhostd:
|
|
19
|
+
image: ghcr.io/janfasnacht/qhost-server:0.1.0
|
|
20
|
+
restart: unless-stopped
|
|
21
|
+
environment:
|
|
22
|
+
QHOST_DOMAIN: ${QHOST_DOMAIN:?}
|
|
23
|
+
QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
|
|
24
|
+
QHOST_TOKEN: ${QHOST_TOKEN:-}
|
|
25
|
+
volumes:
|
|
26
|
+
- qhost_data:/data
|
|
27
|
+
ports:
|
|
28
|
+
- "127.0.0.1:8000:8000"
|
|
29
|
+
|
|
30
|
+
volumes:
|
|
31
|
+
qhost_data:
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
# .env
|
|
36
|
+
QHOST_DOMAIN=share.example.com
|
|
37
|
+
QHOST_TRUSTED_PROXY=127.0.0.1 # whatever IP / CIDR your proxy connects from
|
|
38
|
+
QHOST_TOKEN= # leave blank to auto-generate on first start
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
docker compose up -d
|
|
43
|
+
docker compose exec qhostd cat /data/operator-token # if you left it blank
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
qhostd is now on `http://127.0.0.1:8000` of the host, ready to be
|
|
47
|
+
proxied. The full env-var surface is in
|
|
48
|
+
[`deploy/.env.example`](deploy/.env.example) and the canonical compose
|
|
49
|
+
is in [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
|
|
50
|
+
|
|
51
|
+
## Wire a reverse proxy
|
|
52
|
+
|
|
53
|
+
qhostd doesn't terminate TLS, doesn't strip incoming `X-Forwarded-For`,
|
|
54
|
+
and doesn't enforce a body-size cap before the request reaches Python.
|
|
55
|
+
A proxy in front of it has to do those three things, and qhostd has to
|
|
56
|
+
trust that proxy via `QHOST_TRUSTED_PROXY` (an IP or CIDR — see above).
|
|
57
|
+
|
|
58
|
+
Full ready-to-edit examples are in [`deploy/proxies/`](deploy/proxies/).
|
|
59
|
+
The interesting line in each:
|
|
60
|
+
|
|
61
|
+
**Caddy** ([`deploy/proxies/Caddyfile`](deploy/proxies/Caddyfile))
|
|
62
|
+
|
|
63
|
+
```caddyfile
|
|
64
|
+
{$QHOST_DOMAIN} {
|
|
65
|
+
reverse_proxy 127.0.0.1:8000 {
|
|
66
|
+
header_up X-Forwarded-For {http.request.remote.host} # overwrite, not append
|
|
67
|
+
}
|
|
68
|
+
request_body { max_size 110MB }
|
|
69
|
+
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**nginx** ([`deploy/proxies/nginx.conf`](deploy/proxies/nginx.conf))
|
|
74
|
+
|
|
75
|
+
```nginx
|
|
76
|
+
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, not append
|
|
77
|
+
client_max_body_size 110m;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Cloudflare Tunnel** ([`deploy/proxies/cloudflared.md`](deploy/proxies/cloudflared.md))
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
ingress:
|
|
84
|
+
- hostname: share.example.com
|
|
85
|
+
service: http://127.0.0.1:8000
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`cloudflared` connects from loopback, so `QHOST_TRUSTED_PROXY=127.0.0.1`
|
|
89
|
+
is what you want.
|
|
90
|
+
|
|
91
|
+
## Install the CLI
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install qhost
|
|
95
|
+
qhost login https://share.example.com
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`qhost login` prompts for the operator token and writes it to
|
|
99
|
+
`~/.config/qhost/config.toml` (mode 0600).
|
|
100
|
+
|
|
101
|
+
## Publish
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd path/to/built/site && qhost publish
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`qhost publish` from a directory containing `index.html` publishes that
|
|
108
|
+
directory as-is. From a Quarto project (`_quarto.yml` present) it runs
|
|
109
|
+
`quarto render` first. Otherwise it refuses; pass `--dir <path>` to
|
|
110
|
+
point at a built output or `--build <name>` to force a specific builder.
|
|
111
|
+
|
|
112
|
+
`qhost --help` covers the rest: `publish`, `ls`, `info`, `extend`, `rm`,
|
|
113
|
+
`open`, `doctor`. Default expiry is seven days. `--password` gates a
|
|
114
|
+
share behind HTTP Basic with a generated password; `--public` makes a
|
|
115
|
+
guessable URL with no secret; the default `link` mode treats the URL
|
|
116
|
+
itself as the credential — `/s/<slug>/` carries ~80 bits of entropy and
|
|
117
|
+
is not enumerable.
|
|
118
|
+
|
|
119
|
+
`qhost publish --sandbox` serves a share under
|
|
120
|
+
`Content-Security-Policy: sandbox allow-scripts`, the only
|
|
121
|
+
intra-instance isolation primitive. `qhost doctor` flags content that
|
|
122
|
+
probably wants it.
|
|
123
|
+
|
|
124
|
+
## Trust model
|
|
125
|
+
|
|
126
|
+
One operator token authorizes all publishing on an instance; every
|
|
127
|
+
visitor is untrusted. All shares serve from the same browser origin, so
|
|
128
|
+
a share's JavaScript can reach any other share on the same instance
|
|
129
|
+
with the visitor's ambient Basic-auth credentials. For mutually
|
|
130
|
+
distrusting publishers, run separate instances on separate origins.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
Configuration: [`deploy/.env.example`](deploy/.env.example). Reverse
|
|
135
|
+
proxy examples: [`deploy/proxies/`](deploy/proxies/). Vulnerability
|
|
136
|
+
reports: [`SECURITY.md`](SECURITY.md). MIT.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qhost"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Self-hosted static-site host with a single trusted publisher."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Jan Fasnacht" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"static-site",
|
|
15
|
+
"self-hosted",
|
|
16
|
+
"file-sharing",
|
|
17
|
+
"expiring-shares",
|
|
18
|
+
"preview-environments",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Environment :: Web Environment",
|
|
24
|
+
"Framework :: FastAPI",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Intended Audience :: System Administrators",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: POSIX :: Linux",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
32
|
+
"Topic :: Internet :: WWW/HTTP :: Site Management",
|
|
33
|
+
"Topic :: System :: Archiving :: Mirroring",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"pydantic>=2.6",
|
|
37
|
+
"httpx>=0.27",
|
|
38
|
+
"typer>=0.12",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/janfasnacht/qhost"
|
|
43
|
+
Source = "https://github.com/janfasnacht/qhost"
|
|
44
|
+
Issues = "https://github.com/janfasnacht/qhost/issues"
|
|
45
|
+
Changelog = "https://github.com/janfasnacht/qhost/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[project.scripts]
|
|
48
|
+
qhost = "qhost.cli:app"
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
server = [
|
|
52
|
+
"fastapi>=0.111",
|
|
53
|
+
"uvicorn[standard]>=0.30",
|
|
54
|
+
"bcrypt>=4.1",
|
|
55
|
+
"python-multipart>=0.0.9",
|
|
56
|
+
]
|
|
57
|
+
dev = [
|
|
58
|
+
"pytest>=8",
|
|
59
|
+
"pytest-asyncio>=0.23",
|
|
60
|
+
"hypothesis>=6",
|
|
61
|
+
"ruff>=0.5",
|
|
62
|
+
"asgi-lifespan>=2.1",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.setuptools.packages.find]
|
|
66
|
+
where = ["."]
|
|
67
|
+
include = ["qhost_core*", "qhost*", "qhostd*"]
|
|
68
|
+
exclude = ["tests*"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 100
|
|
72
|
+
target-version = "py312"
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
|
|
76
|
+
|
|
77
|
+
[tool.pytest.ini_options]
|
|
78
|
+
testpaths = ["tests"]
|
|
79
|
+
addopts = "-ra"
|
|
80
|
+
asyncio_mode = "auto"
|
qhost-0.1.0/qhost/_io.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared filesystem helpers for the three local-state modules.
|
|
2
|
+
|
|
3
|
+
Atomic writes (tmp + chmod + replace) and mode-checks are identical for
|
|
4
|
+
`config.toml`, `credentials.toml`, and `.qhost`, so they live here. Each
|
|
5
|
+
state module imports from this — nothing else does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_MODE_WARNED: set[str] = set()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def atomic_write(path: Path, payload: str, *, mode: int) -> None:
|
|
20
|
+
"""Write `payload` to `path` via tmp + chmod + replace.
|
|
21
|
+
|
|
22
|
+
Mode is set on the tmp file *before* the rename so the visible file is
|
|
23
|
+
never briefly readable in a wider mode. Skips chmod on Windows.
|
|
24
|
+
"""
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
27
|
+
prefix=path.name + ".",
|
|
28
|
+
suffix=".tmp",
|
|
29
|
+
dir=str(path.parent),
|
|
30
|
+
)
|
|
31
|
+
tmp = Path(tmp_name)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
34
|
+
fh.write(payload)
|
|
35
|
+
if not sys.platform.startswith("win"):
|
|
36
|
+
os.chmod(tmp, mode)
|
|
37
|
+
os.replace(tmp, path)
|
|
38
|
+
except BaseException:
|
|
39
|
+
with contextlib.suppress(FileNotFoundError):
|
|
40
|
+
tmp.unlink()
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def warn_if_world_readable(path: Path) -> None:
|
|
45
|
+
"""Print a one-time stderr warning if `path` is readable by group/other.
|
|
46
|
+
|
|
47
|
+
Used for secrets files (`config.toml`, `credentials.toml`). No-op on
|
|
48
|
+
Windows. Each path is warned about at most once per process to avoid
|
|
49
|
+
spamming CLI output across multiple commands in the same invocation.
|
|
50
|
+
"""
|
|
51
|
+
if sys.platform.startswith("win"):
|
|
52
|
+
return
|
|
53
|
+
key = str(path)
|
|
54
|
+
if key in _MODE_WARNED:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
mode = path.stat().st_mode & 0o777
|
|
58
|
+
except OSError:
|
|
59
|
+
return
|
|
60
|
+
if mode & 0o077:
|
|
61
|
+
_MODE_WARNED.add(key)
|
|
62
|
+
print(
|
|
63
|
+
f"warning: {path} mode is {oct(mode)} (should be 0o600); "
|
|
64
|
+
"anyone on this machine can read it.",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Minimal TOML writer for qhost's three state files.
|
|
2
|
+
|
|
3
|
+
Reads use stdlib `tomllib` (Python 3.11+). Writing TOML is implemented in-tree
|
|
4
|
+
to avoid pulling in a runtime dep (`tomli_w`) just to emit a few dozen lines.
|
|
5
|
+
|
|
6
|
+
The subset supported is deliberately narrow:
|
|
7
|
+
- Top-level scalar `key = value` lines.
|
|
8
|
+
- Section headers `[a]`, `[a.b]`, `[a."quoted"."key"]`.
|
|
9
|
+
- String, int, and bool scalars.
|
|
10
|
+
|
|
11
|
+
Anything else (arrays, dates, multi-line strings, inline tables, floats)
|
|
12
|
+
raises `TypeError`. If a future state file needs more, extend here — not at
|
|
13
|
+
the call site.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
_BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dumps(data: Mapping[str, Any]) -> str:
|
|
26
|
+
"""Render a nested mapping to TOML text."""
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
scalars, subs = _split(data)
|
|
29
|
+
for k, v in scalars:
|
|
30
|
+
lines.append(_format_kv(k, v))
|
|
31
|
+
for k, v in subs:
|
|
32
|
+
if lines:
|
|
33
|
+
lines.append("")
|
|
34
|
+
_emit_section(lines, [k], v)
|
|
35
|
+
text = "\n".join(lines)
|
|
36
|
+
return text + "\n" if text else ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _emit_section(lines: list[str], path: list[str], data: Mapping[str, Any]) -> None:
|
|
40
|
+
scalars, subs = _split(data)
|
|
41
|
+
# Emit a header for this path when it has scalars of its own or is a leaf;
|
|
42
|
+
# otherwise the path is purely a parent and its header is implicit.
|
|
43
|
+
if scalars or not subs:
|
|
44
|
+
lines.append(f"[{_format_path(path)}]")
|
|
45
|
+
for k, v in scalars:
|
|
46
|
+
lines.append(_format_kv(k, v))
|
|
47
|
+
for k, v in subs:
|
|
48
|
+
lines.append("")
|
|
49
|
+
_emit_section(lines, [*path, k], v)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split(
|
|
53
|
+
data: Mapping[str, Any],
|
|
54
|
+
) -> tuple[list[tuple[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
|
|
55
|
+
scalars: list[tuple[str, Any]] = []
|
|
56
|
+
subs: list[tuple[str, Mapping[str, Any]]] = []
|
|
57
|
+
for k, v in data.items():
|
|
58
|
+
if isinstance(v, Mapping):
|
|
59
|
+
subs.append((k, v))
|
|
60
|
+
else:
|
|
61
|
+
scalars.append((k, v))
|
|
62
|
+
return scalars, subs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_path(path: list[str]) -> str:
|
|
66
|
+
return ".".join(_format_key(p) for p in path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_key(k: str) -> str:
|
|
70
|
+
if _BARE_KEY.fullmatch(k):
|
|
71
|
+
return k
|
|
72
|
+
return _quote_string(k)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_kv(k: str, v: Any) -> str:
|
|
76
|
+
return f"{_format_key(k)} = {_format_value(v)}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _format_value(v: Any) -> str:
|
|
80
|
+
if isinstance(v, bool):
|
|
81
|
+
return "true" if v else "false"
|
|
82
|
+
if isinstance(v, int):
|
|
83
|
+
return str(v)
|
|
84
|
+
if isinstance(v, str):
|
|
85
|
+
return _quote_string(v)
|
|
86
|
+
raise TypeError(f"unsupported TOML value type: {type(v).__name__}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _quote_string(s: str) -> str:
|
|
90
|
+
escaped = (
|
|
91
|
+
s.replace("\\", "\\\\")
|
|
92
|
+
.replace('"', '\\"')
|
|
93
|
+
.replace("\b", "\\b")
|
|
94
|
+
.replace("\t", "\\t")
|
|
95
|
+
.replace("\n", "\\n")
|
|
96
|
+
.replace("\f", "\\f")
|
|
97
|
+
.replace("\r", "\\r")
|
|
98
|
+
)
|
|
99
|
+
return f'"{escaped}"'
|