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.
Files changed (89) hide show
  1. {cista-1.3.0 → cista-1.4.1}/PKG-INFO +38 -1
  2. {cista-1.3.0 → cista-1.4.1}/README.md +33 -0
  3. {cista-1.3.0 → cista-1.4.1}/cista/__main__.py +7 -5
  4. {cista-1.3.0 → cista-1.4.1}/cista/_version.py +1 -1
  5. {cista-1.3.0 → cista-1.4.1}/cista/api.py +42 -8
  6. {cista-1.3.0 → cista-1.4.1}/cista/app.py +23 -4
  7. {cista-1.3.0 → cista-1.4.1}/cista/auth.py +260 -59
  8. {cista-1.3.0 → cista-1.4.1}/cista/config.py +9 -1
  9. cista-1.4.1/cista/frontend-build/assets/icons-DMD182WZ.js +1 -0
  10. cista-1.4.1/cista/frontend-build/assets/index-B0IgN1zv.css +1 -0
  11. cista-1.4.1/cista/frontend-build/assets/index-PhZha5OE.js +32 -0
  12. {cista-1.3.0 → cista-1.4.1}/cista/frontend-build/index.html +4 -4
  13. {cista-1.3.0 → cista-1.4.1}/cista/preview.py +8 -1
  14. cista-1.4.1/cista/sso.py +324 -0
  15. {cista-1.3.0 → cista-1.4.1}/cista/util/apphelpers.py +5 -2
  16. {cista-1.3.0 → cista-1.4.1}/pyproject.toml +4 -0
  17. cista-1.3.0/cista/frontend-build/assets/add-file-38ca9b7e.js +0 -1
  18. cista-1.3.0/cista/frontend-build/assets/add-folder-f3d443e0.js +0 -1
  19. cista-1.3.0/cista/frontend-build/assets/arrow-1760afa9.js +0 -1
  20. cista-1.3.0/cista/frontend-build/assets/arrows-h-31428902.js +0 -1
  21. cista-1.3.0/cista/frontend-build/assets/arrows-v-781a1376.js +0 -1
  22. cista-1.3.0/cista/frontend-build/assets/check-02b34fcd.js +0 -1
  23. cista-1.3.0/cista/frontend-build/assets/code-e2348499.js +0 -1
  24. cista-1.3.0/cista/frontend-build/assets/copy-6bb3930b.js +0 -1
  25. cista-1.3.0/cista/frontend-build/assets/create-file-9e37b1d6.js +0 -1
  26. cista-1.3.0/cista/frontend-build/assets/create-folder-f68cfe1f.js +0 -1
  27. cista-1.3.0/cista/frontend-build/assets/cross-d99708ee.js +0 -1
  28. cista-1.3.0/cista/frontend-build/assets/disk-68b87505.js +0 -1
  29. cista-1.3.0/cista/frontend-build/assets/download-60cf047e.js +0 -1
  30. cista-1.3.0/cista/frontend-build/assets/exclamation-ed20d895.js +0 -1
  31. cista-1.3.0/cista/frontend-build/assets/eye-4bdd3f22.js +0 -1
  32. cista-1.3.0/cista/frontend-build/assets/find-8c04a16e.js +0 -1
  33. cista-1.3.0/cista/frontend-build/assets/fullscreen-d5a124b1.js +0 -1
  34. cista-1.3.0/cista/frontend-build/assets/github-ac1f3711.js +0 -1
  35. cista-1.3.0/cista/frontend-build/assets/index-0e34deb4.css +0 -1
  36. cista-1.3.0/cista/frontend-build/assets/index-72c7a093.js +0 -25
  37. cista-1.3.0/cista/frontend-build/assets/info-7d74e0af.js +0 -1
  38. cista-1.3.0/cista/frontend-build/assets/link-86f2038d.js +0 -1
  39. cista-1.3.0/cista/frontend-build/assets/logo-304a4977.js +0 -1
  40. cista-1.3.0/cista/frontend-build/assets/loop-094fa59f.js +0 -1
  41. cista-1.3.0/cista/frontend-build/assets/menu-5aa43550.js +0 -1
  42. cista-1.3.0/cista/frontend-build/assets/next-73c63b6c.js +0 -1
  43. cista-1.3.0/cista/frontend-build/assets/open-83f85394.js +0 -1
  44. cista-1.3.0/cista/frontend-build/assets/paste-32e42b20.js +0 -1
  45. cista-1.3.0/cista/frontend-build/assets/pause-530d4eda.js +0 -1
  46. cista-1.3.0/cista/frontend-build/assets/pencil-3347872a.js +0 -1
  47. cista-1.3.0/cista/frontend-build/assets/plus-57ce8823.js +0 -1
  48. cista-1.3.0/cista/frontend-build/assets/previous-0b94b7ca.js +0 -1
  49. cista-1.3.0/cista/frontend-build/assets/reload-b01bccf2.js +0 -1
  50. cista-1.3.0/cista/frontend-build/assets/rename-aac51c4a.js +0 -1
  51. cista-1.3.0/cista/frontend-build/assets/scissors-4ab3f69f.js +0 -1
  52. cista-1.3.0/cista/frontend-build/assets/shuffle-241453fb.js +0 -1
  53. cista-1.3.0/cista/frontend-build/assets/signin-fb2b835d.js +0 -1
  54. cista-1.3.0/cista/frontend-build/assets/signout-ebb6ff80.js +0 -1
  55. cista-1.3.0/cista/frontend-build/assets/skip-92b79bc0.js +0 -1
  56. cista-1.3.0/cista/frontend-build/assets/spinner-8a1bf68f.js +0 -1
  57. cista-1.3.0/cista/frontend-build/assets/stop-2cfb5705.js +0 -1
  58. cista-1.3.0/cista/frontend-build/assets/trash-7d469999.js +0 -1
  59. cista-1.3.0/cista/frontend-build/assets/triangle-40858029.js +0 -1
  60. cista-1.3.0/cista/frontend-build/assets/unfullscreen-4710e51b.js +0 -1
  61. cista-1.3.0/cista/frontend-build/assets/up-arrow-f20ea5cf.js +0 -1
  62. cista-1.3.0/cista/frontend-build/assets/upload-cloud-80e5a96d.js +0 -1
  63. cista-1.3.0/cista/frontend-build/assets/user-70db25a5.js +0 -1
  64. cista-1.3.0/cista/frontend-build/assets/user-cog-b8ed5882.js +0 -1
  65. cista-1.3.0/cista/frontend-build/assets/volume-high-ea949028.js +0 -1
  66. cista-1.3.0/cista/frontend-build/assets/volume-low-f29113a6.js +0 -1
  67. cista-1.3.0/cista/frontend-build/assets/volume-medium-ce4f8214.js +0 -1
  68. cista-1.3.0/cista/frontend-build/assets/volume-mute-3899fd69.js +0 -1
  69. cista-1.3.0/cista/frontend-build/assets/window-b176eb67.js +0 -1
  70. cista-1.3.0/cista/frontend-build/assets/window-cross-786863fe.js +0 -1
  71. cista-1.3.0/cista/frontend-build/assets/wordwrap-04a75e12.js +0 -1
  72. cista-1.3.0/cista/frontend-build/assets/zoomin-4567de3c.js +0 -1
  73. cista-1.3.0/cista/frontend-build/assets/zoomout-55239db5.js +0 -1
  74. {cista-1.3.0 → cista-1.4.1}/.gitignore +0 -0
  75. {cista-1.3.0 → cista-1.4.1}/cista/__init__.py +0 -0
  76. {cista-1.3.0 → cista-1.4.1}/cista/droppy.py +0 -0
  77. {cista-1.3.0 → cista-1.4.1}/cista/fileio.py +0 -0
  78. /cista-1.3.0/cista/frontend-build/assets/logo-97d1d7eb.svg → /cista-1.4.1/cista/frontend-build/assets/logo-ctv8tVwU.svg +0 -0
  79. {cista-1.3.0 → cista-1.4.1}/cista/frontend-build/robots.txt +0 -0
  80. {cista-1.3.0 → cista-1.4.1}/cista/protocol.py +0 -0
  81. {cista-1.3.0 → cista-1.4.1}/cista/serve.py +0 -0
  82. {cista-1.3.0 → cista-1.4.1}/cista/server80.py +0 -0
  83. {cista-1.3.0 → cista-1.4.1}/cista/session.py +0 -0
  84. {cista-1.3.0 → cista-1.4.1}/cista/util/__init__.py +0 -0
  85. {cista-1.3.0 → cista-1.4.1}/cista/util/asynclink.py +0 -0
  86. {cista-1.3.0 → cista-1.4.1}/cista/util/filename.py +0 -0
  87. {cista-1.3.0 → cista-1.4.1}/cista/util/lrucache.py +0 -0
  88. {cista-1.3.0 → cista-1.4.1}/cista/util/pwgen.py +0 -0
  89. {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.0
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, path and imported options are preserved in config, and only
46
- custom config dir and dev mode need to be specified on subsequent runs.
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.3.0'
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
- app.blueprint(auth.bp)
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
- bp.on_request(auth.verify)
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-97d1d7eb.svg", status=308)
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
- """Raise Unauthorized or Forbidden if the request is not authorized"""
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 request.ctx.user:
70
- if request.ctx.user.privileged:
71
- return
72
- raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
73
- elif config.config.public or request.ctx.user:
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(f"Login required for {request.path}", "cookie", quiet=True)
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
- bp = Blueprint("auth")
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("/login")
281
+ @bp.get("/restricted")
82
282
  async def login_page(request):
83
- doc = Document("Cista Login")
84
- with doc.div(id="login"):
85
- with doc.form(method="POST", autocomplete="on"):
86
- doc.h1("Login")
87
- doc.input(
88
- name="username",
89
- placeholder="Username",
90
- autocomplete="username",
91
- required=True,
92
- ).br
93
- doc.input(
94
- type="password",
95
- name="password",
96
- placeholder="Password",
97
- autocomplete="current-password",
98
- required=True,
99
- ).br
100
- doc.input(type="submit", value="Login")
101
- s = session.get(request)
102
- if s:
103
- name = s["username"]
104
- with doc.form(method="POST", action="/logout"):
105
- doc.input(type="submit", value=f"Logout {name}")
106
- flash = request.cookies.message
107
- if flash:
108
- doc.dialog(
109
- flash,
110
- id="flash",
111
- open=True,
112
- style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
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
- config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
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