cista 1.3.0__tar.gz → 1.4.1__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.
- {cista-1.3.0 → cista-1.4.1}/PKG-INFO +38 -1
- {cista-1.3.0 → cista-1.4.1}/README.md +33 -0
- {cista-1.3.0 → cista-1.4.1}/cista/__main__.py +7 -5
- {cista-1.3.0 → cista-1.4.1}/cista/_version.py +1 -1
- {cista-1.3.0 → cista-1.4.1}/cista/api.py +42 -8
- {cista-1.3.0 → cista-1.4.1}/cista/app.py +23 -4
- {cista-1.3.0 → cista-1.4.1}/cista/auth.py +260 -59
- {cista-1.3.0 → cista-1.4.1}/cista/config.py +9 -1
- cista-1.4.1/cista/frontend-build/assets/icons-DMD182WZ.js +1 -0
- cista-1.4.1/cista/frontend-build/assets/index-B0IgN1zv.css +1 -0
- cista-1.4.1/cista/frontend-build/assets/index-PhZha5OE.js +32 -0
- {cista-1.3.0 → cista-1.4.1}/cista/frontend-build/index.html +4 -4
- {cista-1.3.0 → cista-1.4.1}/cista/preview.py +8 -1
- cista-1.4.1/cista/sso.py +324 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/apphelpers.py +5 -2
- {cista-1.3.0 → cista-1.4.1}/pyproject.toml +4 -0
- cista-1.3.0/cista/frontend-build/assets/add-file-38ca9b7e.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/add-folder-f3d443e0.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/arrow-1760afa9.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/arrows-h-31428902.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/arrows-v-781a1376.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/check-02b34fcd.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/code-e2348499.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/copy-6bb3930b.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/create-file-9e37b1d6.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/create-folder-f68cfe1f.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/cross-d99708ee.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/disk-68b87505.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/download-60cf047e.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/exclamation-ed20d895.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/eye-4bdd3f22.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/find-8c04a16e.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/fullscreen-d5a124b1.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/github-ac1f3711.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/index-0e34deb4.css +0 -1
- cista-1.3.0/cista/frontend-build/assets/index-72c7a093.js +0 -25
- cista-1.3.0/cista/frontend-build/assets/info-7d74e0af.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/link-86f2038d.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/logo-304a4977.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/loop-094fa59f.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/menu-5aa43550.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/next-73c63b6c.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/open-83f85394.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/paste-32e42b20.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/pause-530d4eda.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/pencil-3347872a.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/plus-57ce8823.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/previous-0b94b7ca.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/reload-b01bccf2.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/rename-aac51c4a.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/scissors-4ab3f69f.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/shuffle-241453fb.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/signin-fb2b835d.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/signout-ebb6ff80.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/skip-92b79bc0.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/spinner-8a1bf68f.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/stop-2cfb5705.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/trash-7d469999.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/triangle-40858029.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/unfullscreen-4710e51b.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/up-arrow-f20ea5cf.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/upload-cloud-80e5a96d.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/user-70db25a5.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/user-cog-b8ed5882.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/volume-high-ea949028.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/volume-low-f29113a6.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/volume-medium-ce4f8214.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/volume-mute-3899fd69.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/window-b176eb67.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/window-cross-786863fe.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/wordwrap-04a75e12.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/zoomin-4567de3c.js +0 -1
- cista-1.3.0/cista/frontend-build/assets/zoomout-55239db5.js +0 -1
- {cista-1.3.0 → cista-1.4.1}/.gitignore +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/__init__.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/droppy.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/fileio.py +0 -0
- /cista-1.3.0/cista/frontend-build/assets/logo-97d1d7eb.svg → /cista-1.4.1/cista/frontend-build/assets/logo-ctv8tVwU.svg +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/frontend-build/robots.txt +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/protocol.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/serve.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/server80.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/session.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/__init__.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/asynclink.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/filename.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/lrucache.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/util/pwgen.py +0 -0
- {cista-1.3.0 → cista-1.4.1}/cista/watching.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cista
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.1
|
|
4
4
|
Summary: Dropbox-like file server with modern web interface
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/Vasanko/cista-storage
|
|
6
6
|
Author: Vasanko
|
|
@@ -17,6 +17,10 @@ Requires-Dist: argon2-cffi>=25.1.0
|
|
|
17
17
|
Requires-Dist: av>=15.0.0
|
|
18
18
|
Requires-Dist: blake3>=1.0.5
|
|
19
19
|
Requires-Dist: docopt>=0.6.2
|
|
20
|
+
Requires-Dist: fastapi-vue>=0.5.1
|
|
21
|
+
Requires-Dist: fastapi[standard]>=0.128.0
|
|
22
|
+
Requires-Dist: html5tagger>=1.3.0
|
|
23
|
+
Requires-Dist: httpx>=0.28.0
|
|
20
24
|
Requires-Dist: inotify>=0.2.12
|
|
21
25
|
Requires-Dist: msgspec>=0.19.0
|
|
22
26
|
Requires-Dist: natsort>=8.4.0
|
|
@@ -85,6 +89,39 @@ pip install cista --break-system-packages
|
|
|
85
89
|
|
|
86
90
|
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
|
87
91
|
|
|
92
|
+
## Authentication
|
|
93
|
+
|
|
94
|
+
Cista supports three authentication modes:
|
|
95
|
+
|
|
96
|
+
### Built-in Authentication (default)
|
|
97
|
+
|
|
98
|
+
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
|
99
|
+
|
|
100
|
+
```fish
|
|
101
|
+
uvx cista --user admin --privileged # Create admin user
|
|
102
|
+
uvx cista --user guest # Create regular user
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Privileged users can manage other users and change settings via the Admin Settings menu.
|
|
106
|
+
|
|
107
|
+
### Public Mode
|
|
108
|
+
|
|
109
|
+
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
|
110
|
+
|
|
111
|
+
### Paskia SSO Authentication
|
|
112
|
+
|
|
113
|
+
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
|
114
|
+
|
|
115
|
+
```fish
|
|
116
|
+
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
In Paskia mode:
|
|
120
|
+
- All `/auth/*` requests are proxied to the Paskia backend
|
|
121
|
+
- Users with `cista:login` permission can access files
|
|
122
|
+
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
|
123
|
+
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
|
124
|
+
|
|
88
125
|
### Internet Access
|
|
89
126
|
|
|
90
127
|
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
|
@@ -38,6 +38,39 @@ pip install cista --break-system-packages
|
|
|
38
38
|
|
|
39
39
|
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
|
40
40
|
|
|
41
|
+
## Authentication
|
|
42
|
+
|
|
43
|
+
Cista supports three authentication modes:
|
|
44
|
+
|
|
45
|
+
### Built-in Authentication (default)
|
|
46
|
+
|
|
47
|
+
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
|
48
|
+
|
|
49
|
+
```fish
|
|
50
|
+
uvx cista --user admin --privileged # Create admin user
|
|
51
|
+
uvx cista --user guest # Create regular user
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Privileged users can manage other users and change settings via the Admin Settings menu.
|
|
55
|
+
|
|
56
|
+
### Public Mode
|
|
57
|
+
|
|
58
|
+
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
|
59
|
+
|
|
60
|
+
### Paskia SSO Authentication
|
|
61
|
+
|
|
62
|
+
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
|
63
|
+
|
|
64
|
+
```fish
|
|
65
|
+
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
In Paskia mode:
|
|
69
|
+
- All `/auth/*` requests are proxied to the Paskia backend
|
|
70
|
+
- Users with `cista:login` permission can access files
|
|
71
|
+
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
|
72
|
+
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
|
73
|
+
|
|
41
74
|
### Internet Access
|
|
42
75
|
|
|
43
76
|
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
|
@@ -42,13 +42,17 @@ Options:
|
|
|
42
42
|
--import-droppy Import Droppy config from ~/.droppy/config
|
|
43
43
|
--dev Developer mode (reloads, friendlier crashes, more logs)
|
|
44
44
|
|
|
45
|
-
Listen address
|
|
46
|
-
|
|
45
|
+
Listen address and path are preserved in config,
|
|
46
|
+
and only config dir and dev mode need to be specified on subsequent runs.
|
|
47
47
|
|
|
48
48
|
User management:
|
|
49
49
|
--user NAME Create or modify user
|
|
50
50
|
--privileged Give the user full admin rights
|
|
51
51
|
--password Reset password
|
|
52
|
+
|
|
53
|
+
Environment:
|
|
54
|
+
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
|
|
55
|
+
https://git.zi.fi/leovasanko/paskia
|
|
52
56
|
"""
|
|
53
57
|
|
|
54
58
|
first_time_help = """\
|
|
@@ -107,6 +111,7 @@ def _main():
|
|
|
107
111
|
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
|
108
112
|
)
|
|
109
113
|
settings = droppy.readconf()
|
|
114
|
+
# Droppy's public flag is kept as-is (same name in our config)
|
|
110
115
|
if path:
|
|
111
116
|
settings["path"] = path
|
|
112
117
|
elif not exists:
|
|
@@ -115,9 +120,6 @@ def _main():
|
|
|
115
120
|
settings["listen"] = listen
|
|
116
121
|
elif not exists:
|
|
117
122
|
settings["listen"] = ":8000"
|
|
118
|
-
if not exists and not import_droppy:
|
|
119
|
-
# We have no users, so make it public
|
|
120
|
-
settings["public"] = True
|
|
121
123
|
operation = config.update_config(settings)
|
|
122
124
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
|
123
125
|
# Prepare to serve
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# This file is automatically generated by hatch build.
|
|
2
|
-
__version__ = '1.
|
|
2
|
+
__version__ = '1.4.1'
|
|
@@ -3,9 +3,10 @@ import typing
|
|
|
3
3
|
from secrets import token_bytes
|
|
4
4
|
|
|
5
5
|
import msgspec
|
|
6
|
-
from sanic import Blueprint
|
|
6
|
+
from sanic import Blueprint, json
|
|
7
|
+
from sanic.exceptions import BadRequest
|
|
7
8
|
|
|
8
|
-
from cista import __version__, config, watching
|
|
9
|
+
from cista import __version__, auth, config, sso, watching
|
|
9
10
|
from cista.fileio import FileServer
|
|
10
11
|
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
|
11
12
|
from cista.util.apphelpers import asend, websocket_wrapper
|
|
@@ -92,6 +93,28 @@ async def control(req, ws):
|
|
|
92
93
|
@bp.websocket("watch")
|
|
93
94
|
@websocket_wrapper
|
|
94
95
|
async def watch(req, ws):
|
|
96
|
+
# Build user info from either built-in auth or SSO
|
|
97
|
+
user_info = None
|
|
98
|
+
if sso.paskia_enabled():
|
|
99
|
+
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
|
100
|
+
try:
|
|
101
|
+
await sso.validate_sso_request(req)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass # Ignore auth errors, user_info stays None
|
|
104
|
+
if sso_user := getattr(req.ctx, "sso_user", None):
|
|
105
|
+
ctx = sso_user.get("ctx", {})
|
|
106
|
+
perms = ctx.get("permissions", [])
|
|
107
|
+
user_info = {
|
|
108
|
+
"username": ctx.get("user", {}).get("display_name", ""),
|
|
109
|
+
"privileged": "cista:admin" in perms,
|
|
110
|
+
}
|
|
111
|
+
elif req.ctx.user:
|
|
112
|
+
# Built-in auth: use local user database
|
|
113
|
+
user_info = {
|
|
114
|
+
"username": req.ctx.username,
|
|
115
|
+
"privileged": req.ctx.user.privileged,
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
await ws.send(
|
|
96
119
|
msgspec.json.encode(
|
|
97
120
|
{
|
|
@@ -99,13 +122,9 @@ async def watch(req, ws):
|
|
|
99
122
|
"name": config.config.name or config.config.path.name,
|
|
100
123
|
"version": __version__,
|
|
101
124
|
"public": config.config.public,
|
|
125
|
+
"paskia": sso.paskia_enabled(),
|
|
102
126
|
},
|
|
103
|
-
"user":
|
|
104
|
-
"username": req.ctx.username,
|
|
105
|
-
"privileged": req.ctx.user.privileged,
|
|
106
|
-
}
|
|
107
|
-
if req.ctx.user
|
|
108
|
-
else None,
|
|
127
|
+
"user": user_info,
|
|
109
128
|
}
|
|
110
129
|
).decode()
|
|
111
130
|
)
|
|
@@ -136,3 +155,18 @@ def subscribe(uuid, ws):
|
|
|
136
155
|
watching.format_space(watching.state.space),
|
|
137
156
|
watching.format_root(watching.state.root),
|
|
138
157
|
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@bp.put("config/public")
|
|
161
|
+
async def update_public(request):
|
|
162
|
+
await auth.verify(request, privileged=True)
|
|
163
|
+
try:
|
|
164
|
+
public = request.json["public"]
|
|
165
|
+
if not isinstance(public, bool):
|
|
166
|
+
raise ValueError("public must be a boolean")
|
|
167
|
+
except KeyError:
|
|
168
|
+
raise BadRequest("Missing public field") from None
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
raise BadRequest(str(e)) from None
|
|
171
|
+
config.update_config({"public": public})
|
|
172
|
+
return json({"message": "Public access setting updated", "public": public})
|
|
@@ -18,7 +18,7 @@ from setproctitle import setproctitle
|
|
|
18
18
|
from stream_zip import ZIP_AUTO, stream_zip
|
|
19
19
|
from zstandard import ZstdCompressor
|
|
20
20
|
|
|
21
|
-
from cista import auth, config, preview, session, watching
|
|
21
|
+
from cista import auth, config, preview, session, sso, watching
|
|
22
22
|
from cista.api import bp
|
|
23
23
|
from cista.util.apphelpers import handle_sanic_exception
|
|
24
24
|
|
|
@@ -26,7 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception
|
|
|
26
26
|
sanic.helpers._ENTITY_HEADERS = frozenset()
|
|
27
27
|
|
|
28
28
|
app = Sanic("cista", strict_slashes=True)
|
|
29
|
-
|
|
29
|
+
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
|
30
|
+
if sso.paskia_enabled():
|
|
31
|
+
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
|
32
|
+
else:
|
|
33
|
+
app.blueprint(auth.bp) # Built-in auth routes
|
|
30
34
|
app.blueprint(preview.bp)
|
|
31
35
|
app.blueprint(bp)
|
|
32
36
|
app.exception(Exception)(handle_sanic_exception)
|
|
@@ -52,6 +56,7 @@ async def main_stop(app):
|
|
|
52
56
|
quit.set()
|
|
53
57
|
watching.stop(app)
|
|
54
58
|
app.ctx.threadexec.shutdown()
|
|
59
|
+
await sso.close_client()
|
|
55
60
|
logger.debug("Cista worker threads all finished")
|
|
56
61
|
|
|
57
62
|
|
|
@@ -74,10 +79,23 @@ async def use_session(req):
|
|
|
74
79
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
@app.on_response
|
|
83
|
+
async def forward_sso_cookies(req, res):
|
|
84
|
+
"""Forward Set-Cookie headers from SSO validation to client."""
|
|
85
|
+
if cookies := getattr(req.ctx, "sso_cookies", None):
|
|
86
|
+
for cookie in cookies:
|
|
87
|
+
res.headers.add("set-cookie", cookie)
|
|
88
|
+
|
|
89
|
+
|
|
77
90
|
@app.before_server_start
|
|
78
91
|
def http_fileserver(app):
|
|
79
92
|
bp = Blueprint("fileserver")
|
|
80
|
-
|
|
93
|
+
|
|
94
|
+
@bp.on_request
|
|
95
|
+
async def verify_fileserver(request):
|
|
96
|
+
"""Verify access to file server routes."""
|
|
97
|
+
await auth.verify(request)
|
|
98
|
+
|
|
81
99
|
bp.static(
|
|
82
100
|
"/files/",
|
|
83
101
|
config.config.path,
|
|
@@ -211,7 +229,7 @@ async def wwwroot(req, path=""):
|
|
|
211
229
|
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
|
212
230
|
async def favicon(req):
|
|
213
231
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
|
214
|
-
return redirect("/assets/logo-
|
|
232
|
+
return redirect("/assets/logo-ctv8tVwU.svg", status=308)
|
|
215
233
|
|
|
216
234
|
|
|
217
235
|
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|
@@ -239,6 +257,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
|
|
239
257
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
|
240
258
|
async def zip_download(req, keys, zipfile, ext):
|
|
241
259
|
"""Download a zip archive of the given keys"""
|
|
260
|
+
await auth.verify(req)
|
|
242
261
|
|
|
243
262
|
wanted = set(keys.split("+"))
|
|
244
263
|
files = get_files(wanted)
|
|
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
|
|
12
12
|
from cista import config, session
|
|
13
13
|
from cista.util import pwgen
|
|
14
14
|
|
|
15
|
+
_LOGIN_PAGE_CSS = """\
|
|
16
|
+
/* ===========================================
|
|
17
|
+
LOGIN PAGE STYLES
|
|
18
|
+
Must match ModalDialog.vue global styles.
|
|
19
|
+
=========================================== */
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body {
|
|
22
|
+
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
|
|
23
|
+
font-size: 1rem;
|
|
24
|
+
margin: 0;
|
|
25
|
+
min-height: 100vh;
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
background: transparent;
|
|
30
|
+
}
|
|
31
|
+
.login-card {
|
|
32
|
+
background: #ddd;
|
|
33
|
+
color: #000;
|
|
34
|
+
border-radius: 0.5rem;
|
|
35
|
+
box-shadow: 0 0 1rem #0008;
|
|
36
|
+
width: 100%;
|
|
37
|
+
max-width: 320px;
|
|
38
|
+
}
|
|
39
|
+
h1 {
|
|
40
|
+
background: #146;
|
|
41
|
+
color: #fff;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0.5rem 1rem;
|
|
44
|
+
font-size: 1.2rem;
|
|
45
|
+
font-weight: normal;
|
|
46
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
47
|
+
}
|
|
48
|
+
.content {
|
|
49
|
+
padding: 1rem;
|
|
50
|
+
}
|
|
51
|
+
.message {
|
|
52
|
+
color: #444;
|
|
53
|
+
margin: 0 0 0.5rem 0;
|
|
54
|
+
font-size: 0.875rem;
|
|
55
|
+
}
|
|
56
|
+
form {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: auto 1fr;
|
|
59
|
+
gap: 0.5rem 1rem;
|
|
60
|
+
align-items: center;
|
|
61
|
+
}
|
|
62
|
+
label {
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
}
|
|
65
|
+
input[type="text"],
|
|
66
|
+
input[type="password"] {
|
|
67
|
+
font: inherit;
|
|
68
|
+
font-size: 1rem;
|
|
69
|
+
padding: 0.5rem;
|
|
70
|
+
border: 2px solid #888;
|
|
71
|
+
border-radius: 0.25rem;
|
|
72
|
+
background: #fff;
|
|
73
|
+
color: #000;
|
|
74
|
+
min-width: 0;
|
|
75
|
+
}
|
|
76
|
+
input:focus {
|
|
77
|
+
outline: none;
|
|
78
|
+
border-color: #f80;
|
|
79
|
+
}
|
|
80
|
+
.button-row {
|
|
81
|
+
grid-column: 1 / -1;
|
|
82
|
+
display: flex;
|
|
83
|
+
justify-content: flex-end;
|
|
84
|
+
margin-top: 0.5rem;
|
|
85
|
+
}
|
|
86
|
+
button {
|
|
87
|
+
font: inherit;
|
|
88
|
+
font-size: 1rem;
|
|
89
|
+
padding: 0.5rem 1rem;
|
|
90
|
+
background: #146;
|
|
91
|
+
color: #fff;
|
|
92
|
+
border: none;
|
|
93
|
+
border-radius: 0.25rem;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
button:hover { background: #f80; }
|
|
97
|
+
button:disabled {
|
|
98
|
+
background: #888;
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
}
|
|
101
|
+
.error {
|
|
102
|
+
grid-column: 1 / -1;
|
|
103
|
+
color: #c00;
|
|
104
|
+
font-size: 0.875rem;
|
|
105
|
+
min-height: 1.2em;
|
|
106
|
+
margin: 0;
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_LOGIN_PAGE_JS = """\
|
|
111
|
+
const form = document.getElementById('loginForm');
|
|
112
|
+
const error = document.getElementById('error');
|
|
113
|
+
const submitBtn = document.getElementById('submitBtn');
|
|
114
|
+
const usernameField = document.getElementById('username');
|
|
115
|
+
const passwordField = document.getElementById('password');
|
|
116
|
+
const isInIframe = window.parent !== window;
|
|
117
|
+
|
|
118
|
+
// Focus username field on load
|
|
119
|
+
usernameField.focus();
|
|
120
|
+
|
|
121
|
+
const showError = (msg) => {
|
|
122
|
+
error.textContent = msg;
|
|
123
|
+
submitBtn.disabled = false;
|
|
124
|
+
submitBtn.textContent = 'Log in';
|
|
125
|
+
// Focus and select the relevant field
|
|
126
|
+
if (msg.toLowerCase().includes('password')) {
|
|
127
|
+
passwordField.focus();
|
|
128
|
+
passwordField.select();
|
|
129
|
+
} else {
|
|
130
|
+
usernameField.focus();
|
|
131
|
+
usernameField.select();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
form.onsubmit = async (e) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
error.textContent = '';
|
|
138
|
+
submitBtn.disabled = true;
|
|
139
|
+
submitBtn.textContent = 'Logging in...';
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch('/auth/login', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
'Accept': 'application/json'
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
username: usernameField.value,
|
|
150
|
+
password: passwordField.value
|
|
151
|
+
})
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
if (isInIframe) {
|
|
156
|
+
window.parent.postMessage({type: 'auth-success'}, '*');
|
|
157
|
+
} else {
|
|
158
|
+
window.location.href = '/';
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const data = await res.json();
|
|
162
|
+
showError(data.message || data.detail || 'Login failed');
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
showError('Connection error. Please try again.');
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
# Import for SSO validation (lazily loaded to avoid circular imports)
|
|
171
|
+
_sso_module = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _get_sso():
|
|
175
|
+
global _sso_module
|
|
176
|
+
if _sso_module is None:
|
|
177
|
+
from cista import sso
|
|
178
|
+
|
|
179
|
+
_sso_module = sso
|
|
180
|
+
return _sso_module
|
|
181
|
+
|
|
182
|
+
|
|
15
183
|
_argon = argon2.PasswordHasher()
|
|
16
184
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
|
17
185
|
|
|
@@ -63,62 +231,106 @@ class LoginResponse(msgspec.Struct):
|
|
|
63
231
|
error: str = ""
|
|
64
232
|
|
|
65
233
|
|
|
66
|
-
def verify(request, *, privileged=False):
|
|
67
|
-
"""
|
|
234
|
+
async def verify(request, *, privileged=False):
|
|
235
|
+
"""Verify that the request is authorized.
|
|
236
|
+
|
|
237
|
+
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
|
238
|
+
For built-in mode, checks session-based authentication.
|
|
239
|
+
For public mode (config.public=True), skips auth unless privileged is required.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
request: The Sanic request object
|
|
243
|
+
privileged: If True, requires admin privileges (always enforced even in public mode)
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
Unauthorized: If authentication is required
|
|
247
|
+
Forbidden: If access is denied
|
|
248
|
+
"""
|
|
249
|
+
# Public mode: skip auth unless privileged access is required
|
|
250
|
+
if config.config.public and not privileged:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
sso = _get_sso()
|
|
254
|
+
if sso.paskia_enabled():
|
|
255
|
+
perm = "cista:admin" if privileged else "cista:login"
|
|
256
|
+
await sso.validate_sso_request(request, perm=perm)
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
user = getattr(request.ctx, "user", None)
|
|
68
260
|
if privileged:
|
|
69
|
-
if
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
261
|
+
if user and user.privileged:
|
|
262
|
+
return
|
|
263
|
+
raise Forbidden(
|
|
264
|
+
"Access Forbidden: Only for privileged users",
|
|
265
|
+
quiet=True,
|
|
266
|
+
)
|
|
267
|
+
if user:
|
|
74
268
|
return
|
|
75
|
-
raise Unauthorized(
|
|
269
|
+
raise Unauthorized(
|
|
270
|
+
f"Login required for {request.path}",
|
|
271
|
+
"cookie",
|
|
272
|
+
context={"auth": {"iframe": "/auth/restricted"}},
|
|
273
|
+
quiet=True,
|
|
274
|
+
)
|
|
76
275
|
|
|
77
276
|
|
|
78
|
-
|
|
277
|
+
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
|
|
278
|
+
bp = Blueprint("auth", url_prefix="/auth")
|
|
79
279
|
|
|
80
280
|
|
|
81
|
-
@bp.get("/
|
|
281
|
+
@bp.get("/restricted")
|
|
82
282
|
async def login_page(request):
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
283
|
+
"""Login page that works both standalone and in paskia iframe."""
|
|
284
|
+
s = session.get(request)
|
|
285
|
+
|
|
286
|
+
# Check if already logged in
|
|
287
|
+
if s:
|
|
288
|
+
# Already authenticated - signal success if in iframe
|
|
289
|
+
return html(_login_success_page(s["username"]))
|
|
290
|
+
|
|
291
|
+
doc = Document("Cista - Login")
|
|
292
|
+
# Add paskia-compatible styling and scripts
|
|
293
|
+
doc.style(_LOGIN_PAGE_CSS)
|
|
294
|
+
with doc.div(class_="login-card"):
|
|
295
|
+
doc.h1("Authentication Required")
|
|
296
|
+
with doc.div(class_="content"):
|
|
297
|
+
with doc.form(method="POST", id="loginForm", autocomplete="on"):
|
|
298
|
+
doc.label("Username:", for_="username")
|
|
299
|
+
doc.input(
|
|
300
|
+
type="text",
|
|
301
|
+
id="username",
|
|
302
|
+
name="username",
|
|
303
|
+
autocomplete="username webauthn",
|
|
304
|
+
required=True,
|
|
305
|
+
)
|
|
306
|
+
doc.label("Password:", for_="password")
|
|
307
|
+
doc.input(
|
|
308
|
+
type="password",
|
|
309
|
+
id="password",
|
|
310
|
+
name="password",
|
|
311
|
+
autocomplete="current-password webauthn",
|
|
312
|
+
required=True,
|
|
313
|
+
)
|
|
314
|
+
with doc.div(class_="button-row"):
|
|
315
|
+
doc.button("Log in", type="submit", id="submitBtn")
|
|
316
|
+
doc.p("", class_="error", id="error")
|
|
317
|
+
|
|
318
|
+
# JavaScript for AJAX login and postMessage communication
|
|
319
|
+
doc.script_(_LOGIN_PAGE_JS)
|
|
320
|
+
|
|
114
321
|
res = html(doc)
|
|
115
|
-
if flash:
|
|
116
|
-
res.cookies.delete_cookie("flash")
|
|
117
322
|
if s is False:
|
|
118
323
|
session.delete(res)
|
|
119
324
|
return res
|
|
120
325
|
|
|
121
326
|
|
|
327
|
+
def _login_success_page(username: str) -> str:
|
|
328
|
+
"""Minimal page that signals auth-success to parent iframe."""
|
|
329
|
+
return str(
|
|
330
|
+
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
122
334
|
@bp.post("/login")
|
|
123
335
|
async def login_post(request):
|
|
124
336
|
try:
|
|
@@ -149,7 +361,7 @@ async def login_post(request):
|
|
|
149
361
|
return res
|
|
150
362
|
|
|
151
363
|
|
|
152
|
-
@bp.post("/logout")
|
|
364
|
+
@bp.post("/api/logout")
|
|
153
365
|
async def logout_post(request):
|
|
154
366
|
s = request.ctx.session
|
|
155
367
|
msg = "Logged out" if s else "Not logged in"
|
|
@@ -196,7 +408,7 @@ async def change_password(request):
|
|
|
196
408
|
|
|
197
409
|
@bp.get("/users")
|
|
198
410
|
async def list_users(request):
|
|
199
|
-
verify(request, privileged=True)
|
|
411
|
+
await verify(request, privileged=True)
|
|
200
412
|
users = []
|
|
201
413
|
for name, user in config.config.users.items():
|
|
202
414
|
users.append(
|
|
@@ -211,7 +423,7 @@ async def list_users(request):
|
|
|
211
423
|
|
|
212
424
|
@bp.post("/users")
|
|
213
425
|
async def create_user(request):
|
|
214
|
-
verify(request, privileged=True)
|
|
426
|
+
await verify(request, privileged=True)
|
|
215
427
|
try:
|
|
216
428
|
if request.headers.content_type == "application/json":
|
|
217
429
|
username = request.json["username"]
|
|
@@ -240,7 +452,7 @@ async def create_user(request):
|
|
|
240
452
|
|
|
241
453
|
@bp.put("/users/<username>")
|
|
242
454
|
async def update_user(request, username):
|
|
243
|
-
verify(request, privileged=True)
|
|
455
|
+
await verify(request, privileged=True)
|
|
244
456
|
try:
|
|
245
457
|
if request.headers.content_type == "application/json":
|
|
246
458
|
changes = request.json
|
|
@@ -273,7 +485,7 @@ async def update_user(request, username):
|
|
|
273
485
|
|
|
274
486
|
@bp.delete("/users/<username>")
|
|
275
487
|
async def delete_user(request, username):
|
|
276
|
-
verify(request, privileged=True)
|
|
488
|
+
await verify(request, privileged=True)
|
|
277
489
|
if username not in config.config.users:
|
|
278
490
|
raise BadRequest("User does not exist")
|
|
279
491
|
try:
|
|
@@ -281,14 +493,3 @@ async def delete_user(request, username):
|
|
|
281
493
|
except Exception as e:
|
|
282
494
|
raise BadRequest(str(e)) from e
|
|
283
495
|
return json({"message": f"User {username} deleted"})
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
@bp.put("/config/public")
|
|
287
|
-
async def update_public(request):
|
|
288
|
-
verify(request, privileged=True)
|
|
289
|
-
try:
|
|
290
|
-
public = request.json["public"]
|
|
291
|
-
except KeyError:
|
|
292
|
-
raise BadRequest("Missing public field") from None
|
|
293
|
-
config.update_config({"public": public})
|
|
294
|
-
return json({"message": "Public setting updated"})
|
|
@@ -152,7 +152,15 @@ def modifies_config(
|
|
|
152
152
|
def load_config():
|
|
153
153
|
global config
|
|
154
154
|
init_confdir()
|
|
155
|
-
|
|
155
|
+
raw = conffile.read_bytes()
|
|
156
|
+
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
|
|
157
|
+
# Migrate from old authentication field if present
|
|
158
|
+
raw_dict = msgspec.toml.decode(raw)
|
|
159
|
+
if "authentication" in raw_dict and "public" not in raw_dict:
|
|
160
|
+
# Old config with authentication mode: migrate to public bool
|
|
161
|
+
new_public = raw_dict["authentication"] == "none"
|
|
162
|
+
config = msgspec.structs.replace(config, public=new_public)
|
|
163
|
+
update_config({}) # Save the migrated config
|
|
156
164
|
|
|
157
165
|
|
|
158
166
|
@modifies_config
|