uEdition-Editor 2.0.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.
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """About this package."""
5
+
6
+ __version__ = "2.0.0"
@@ -0,0 +1,69 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The main uEditor server."""
5
+
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from copy import deepcopy
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.exceptions import HTTPException
12
+ from fastapi.responses import RedirectResponse, Response
13
+ from fastapi.staticfiles import StaticFiles
14
+ from httpx import AsyncClient
15
+ from uvicorn.config import LOGGING_CONFIG
16
+
17
+ from uedition_editor import cron
18
+ from uedition_editor.api import router as api_router
19
+ from uedition_editor.settings import init_settings
20
+
21
+ logger = logging.getLogger(__name__)
22
+ # Configure logging
23
+ conf = deepcopy(LOGGING_CONFIG)
24
+ conf["loggers"]["uedition_editor"] = {
25
+ "level": logging.INFO,
26
+ "qualname": "uedition_editor",
27
+ "handlers": ["default"],
28
+ }
29
+ conf["formatters"]["default"]["fmt"] = "%(levelprefix)s %(name)-40s %(message)s"
30
+ conf["root"] = {"level": logging.INFO}
31
+ if init_settings.test: # pragma: no cover
32
+ conf["loggers"]["uedition_editor"]["level"] = logging.DEBUG
33
+ logging.config.dictConfig(conf)
34
+ logger.debug("Logging configuration set up")
35
+
36
+
37
+ @asynccontextmanager
38
+ async def lifespan(app: FastAPI): # noqa:ARG001
39
+ """Startup/shutdown handler."""
40
+ await cron.track_branches.func()
41
+ yield
42
+
43
+
44
+ app = FastAPI(lifespan=lifespan)
45
+ app.include_router(api_router)
46
+
47
+ if init_settings.dev:
48
+
49
+ @app.get("/app/{path:path}")
50
+ async def ui_dev(path: str):
51
+ """Proxy the development frontend server."""
52
+ async with AsyncClient() as client:
53
+ response = await client.get(f"http://localhost:5173/app/{path}")
54
+ if response.status_code == 200: # noqa:PLR2004
55
+ return Response(content=response.content, media_type=response.headers["Content-Type"])
56
+ else:
57
+ raise HTTPException(response.status_code)
58
+
59
+ else:
60
+ app.mount("/app", StaticFiles(packages=[("uedition_editor", "frontend/dist")], html=True))
61
+
62
+
63
+ @app.get("/", response_class=RedirectResponse)
64
+ def redirect_to_app(timestamp: int | None = None):
65
+ """Redirect to the application UI."""
66
+ if timestamp is not None:
67
+ return f"/app/?timestamp={timestamp}"
68
+ else:
69
+ return "/app/"
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The uEditor module entry point."""
5
+
6
+ from uedition_editor.cli import app
7
+
8
+ if __name__ == "__main__":
9
+ app()
@@ -0,0 +1,72 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The uEditor server API."""
5
+
6
+ from typing import Literal
7
+
8
+ from fastapi import APIRouter
9
+ from pydantic import BaseModel
10
+ from pygit2 import GitError, Repository
11
+ from pygit2.enums import RepositoryOpenFlag
12
+
13
+ from uedition_editor.__about__ import __version__
14
+ from uedition_editor.api.auth import router as auth_router
15
+ from uedition_editor.api.branches import router as branches_router
16
+ from uedition_editor.settings import init_settings
17
+
18
+ router = APIRouter(prefix="/api")
19
+ router.include_router(auth_router)
20
+ router.include_router(branches_router)
21
+ if init_settings.test: # pragma: no cover
22
+ from uedition_editor.api.tests import router as tests_router
23
+
24
+ router.include_router(tests_router)
25
+
26
+
27
+ class APIStatusAuth(BaseModel):
28
+ """Pydantic model for validating the auth settings."""
29
+
30
+ provider: Literal["no-auth"] | Literal["email"] | Literal["email-password"] | Literal["github"]
31
+
32
+
33
+ class APIStatusGit(BaseModel):
34
+ """Pydantic model for validating the git API status."""
35
+
36
+ enabled: bool
37
+ default_branch: str | None = None
38
+ protect_default_branch: bool | None = None
39
+
40
+
41
+ class APIStatus(BaseModel):
42
+ """Pydantic model for validating the API status."""
43
+
44
+ ready: bool
45
+ """Indicate whether the API is ready."""
46
+ git: APIStatusGit
47
+ """Indicate the Git status."""
48
+ auth: APIStatusAuth
49
+ """Indicate the authentication system status."""
50
+ version: str
51
+ """The backend API version."""
52
+
53
+
54
+ @router.get("", response_model=APIStatus)
55
+ def api() -> dict:
56
+ """Return the status of the API."""
57
+ api_status = {
58
+ "ready": True,
59
+ "git": {
60
+ "enabled": False,
61
+ },
62
+ "auth": {"provider": init_settings.auth.provider},
63
+ "version": __version__,
64
+ }
65
+ try:
66
+ Repository(init_settings.base_path, flags=RepositoryOpenFlag.NO_SEARCH)
67
+ api_status["git"]["enabled"] = True
68
+ api_status["git"]["default_branch"] = init_settings.git.default_branch
69
+ api_status["git"]["protect_default_branch"] = init_settings.git.protect_default_branch
70
+ except GitError:
71
+ pass
72
+ return api_status
@@ -0,0 +1,208 @@
1
+ """Authentication functionality."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta, timezone
5
+ from secrets import token_hex
6
+ from typing import Annotated
7
+ from urllib.parse import urlencode
8
+
9
+ import jwt
10
+ from fastapi import APIRouter, Cookie, Depends, Response
11
+ from fastapi.exceptions import HTTPException
12
+ from fastapi.responses import RedirectResponse
13
+ from httpx import AsyncClient
14
+ from pydantic import BaseModel, EmailStr
15
+
16
+ from uedition_editor.settings import init_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+ router = APIRouter(prefix="/auth")
20
+
21
+
22
+ def get_current_user(ueditor_user: Annotated[str | None, Cookie()] = None):
23
+ """Get the current user from the session cookie."""
24
+ if ueditor_user is not None:
25
+ try:
26
+ data = jwt.decode(
27
+ ueditor_user,
28
+ key=init_settings.session.key,
29
+ algorithms="HS512",
30
+ options={"require": ["exp", "iat", "name", "nbf", "provider", "sub"]},
31
+ )
32
+ if init_settings.auth.provider in ["no-auth", "email"]:
33
+ if data["sub"] != init_settings.auth.email or data["provider"] != init_settings.auth.provider:
34
+ raise HTTPException(401)
35
+ elif init_settings.auth.provider == "email-password":
36
+ if data["provider"] != init_settings.auth.provider:
37
+ raise HTTPException(401)
38
+ for user in init_settings.auth.users:
39
+ if data["sub"] == user.email:
40
+ return data
41
+ raise HTTPException(401)
42
+ return data
43
+ except jwt.InvalidTokenError as e:
44
+ logger.error(e)
45
+ raise HTTPException(401) from e
46
+ raise HTTPException(401)
47
+
48
+
49
+ class EmailPasswordLoginModel(BaseModel):
50
+ """Model for validating login via email and password."""
51
+
52
+ email: EmailStr
53
+ password: str
54
+
55
+
56
+ @router.post("/login", status_code=204)
57
+ def login(response: Response, auth: EmailPasswordLoginModel | None = None) -> None:
58
+ """Log the user in using one of the local authentication methods."""
59
+ if init_settings.auth.provider in ["no-auth", "email"]:
60
+ now = datetime.now(timezone.utc)
61
+ ueditor_user = jwt.encode(
62
+ {
63
+ "sub": init_settings.auth.email,
64
+ "name": init_settings.auth.name,
65
+ "exp": now + timedelta(days=14),
66
+ "iat": now,
67
+ "nbf": now,
68
+ "provider": init_settings.auth.provider,
69
+ },
70
+ init_settings.session.key,
71
+ algorithm="HS512",
72
+ )
73
+ response.set_cookie(
74
+ "ueditor_user",
75
+ ueditor_user,
76
+ expires=now + timedelta(14),
77
+ secure=True,
78
+ samesite="strict",
79
+ )
80
+ return
81
+ elif init_settings.auth.provider == "email-password" and auth is not None:
82
+ for user in init_settings.auth.users:
83
+ if user.email == auth.email and user.password == auth.password:
84
+ now = datetime.now(timezone.utc)
85
+ ueditor_user = jwt.encode(
86
+ {
87
+ "sub": user.email,
88
+ "name": user.name,
89
+ "exp": now + timedelta(days=14),
90
+ "iat": now,
91
+ "nbf": now,
92
+ "provider": init_settings.auth.provider,
93
+ },
94
+ init_settings.session.key,
95
+ algorithm="HS512",
96
+ )
97
+ response.set_cookie(
98
+ "ueditor_user",
99
+ ueditor_user,
100
+ expires=now + timedelta(14),
101
+ secure=True,
102
+ samesite="strict",
103
+ )
104
+ return
105
+ raise HTTPException(403)
106
+
107
+
108
+ @router.delete("/login", status_code=204)
109
+ def logout(response: Response) -> None:
110
+ """Log the user out."""
111
+ response.delete_cookie(
112
+ "ueditor_user",
113
+ secure=True,
114
+ samesite="strict",
115
+ )
116
+
117
+
118
+ OIDC_STATES = {}
119
+
120
+
121
+ @router.get("/oidc/login", response_class=RedirectResponse)
122
+ def start_oidc_login():
123
+ """Start an OIDC (or equivalent) authorization process."""
124
+ if init_settings.auth.provider != "github":
125
+ raise HTTPException(404)
126
+ state = token_hex(64)
127
+ now = datetime.now(tz=timezone.utc).timestamp()
128
+ valid_until = now + 600
129
+ OIDC_STATES[state] = valid_until
130
+ for key in list(OIDC_STATES.keys()):
131
+ if now > OIDC_STATES[key]:
132
+ del OIDC_STATES[key]
133
+ params = {
134
+ "client_id": init_settings.auth.client_id,
135
+ "redirect_uri": f"{init_settings.auth.callback_base}/api/auth/oidc/callback",
136
+ "scope": "read:user",
137
+ "state": state,
138
+ }
139
+ return f"https://github.com/login/oauth/authorize?{urlencode(params)}"
140
+
141
+
142
+ @router.get("/oidc/callback", response_class=RedirectResponse)
143
+ async def complete_oidc_login(code: str, state: str, redirect_response: Response):
144
+ """Complete an OIDC (or equivalent) authorization process."""
145
+ if init_settings.auth.provider != "github":
146
+ raise HTTPException(404)
147
+ now = datetime.now(tz=timezone.utc)
148
+ if state not in OIDC_STATES or now.timestamp() > OIDC_STATES[state]:
149
+ raise HTTPException(403)
150
+ async with AsyncClient() as client:
151
+ params = {
152
+ "client_id": init_settings.auth.client_id,
153
+ "client_secret": init_settings.auth.client_secret,
154
+ "code": code,
155
+ "redirect_uri": f"{init_settings.auth.callback_base}/api/auth/oidc/callback",
156
+ }
157
+ response = await client.get(
158
+ f"https://github.com/login/oauth/access_token?{urlencode(params)}",
159
+ headers={"Accept": "application/json"},
160
+ )
161
+ if response.status_code != 200: # noqa:PLR2004
162
+ raise HTTPException(403)
163
+ token = response.json()
164
+ response = await client.get(
165
+ "https://api.github.com/user",
166
+ headers={
167
+ "Accept": "application/vnd.github+json",
168
+ "Authorization": f"Bearer {token['access_token']}",
169
+ },
170
+ )
171
+ if response.status_code != 200: # noqa: PLR2004
172
+ raise HTTPException(403)
173
+ user_data = response.json()
174
+ if user_data["email"] not in init_settings.auth.users:
175
+ raise HTTPException(403)
176
+ ueditor_user = jwt.encode(
177
+ {
178
+ "sub": user_data["email"],
179
+ "name": user_data["name"],
180
+ "exp": now + timedelta(days=14),
181
+ "iat": now,
182
+ "nbf": now,
183
+ "provider": init_settings.auth.provider,
184
+ },
185
+ init_settings.session.key,
186
+ algorithm="HS512",
187
+ )
188
+ redirect_response.set_cookie(
189
+ "ueditor_user",
190
+ ueditor_user,
191
+ expires=now + timedelta(14),
192
+ secure=True,
193
+ samesite="strict",
194
+ )
195
+ return "/app"
196
+
197
+
198
+ class UserModel(BaseModel):
199
+ """Pydantic model for validating user responses."""
200
+
201
+ sub: str
202
+ name: str
203
+
204
+
205
+ @router.get("/user", response_model=UserModel)
206
+ def user(current_user: Annotated[dict, Depends(get_current_user)]):
207
+ """Return the currently logged in user."""
208
+ return current_user
@@ -0,0 +1,175 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The uEditor API for accessing branches."""
5
+
6
+ import logging
7
+ from typing import Annotated
8
+
9
+ from fastapi import APIRouter, Depends, Header
10
+ from fastapi.exceptions import HTTPException
11
+ from pydantic import BaseModel, Field
12
+ from pygit2 import GitError, Repository, Signature
13
+ from pygit2.enums import RepositoryOpenFlag
14
+
15
+ from uedition_editor import cron
16
+ from uedition_editor.api.auth import get_current_user
17
+ from uedition_editor.api.configs import router as configs_router
18
+ from uedition_editor.api.files import router as files_router
19
+ from uedition_editor.api.util import (
20
+ BranchContextManager,
21
+ RemoteRepositoryCallbacks,
22
+ commit_and_push,
23
+ de_slugify,
24
+ fetch_and_pull_branch,
25
+ fetch_repo,
26
+ pull_branch,
27
+ slugify,
28
+ uedition_lock,
29
+ )
30
+ from uedition_editor.settings import init_settings
31
+ from uedition_editor.state import local_branches, remote_branches
32
+
33
+ logger = logging.getLogger(__name__)
34
+ router = APIRouter(prefix="/branches")
35
+ router.include_router(files_router, prefix="/{branch_id}")
36
+ router.include_router(configs_router, prefix="/{branch_id}")
37
+
38
+
39
+ class BranchModel(BaseModel):
40
+ """A model of a single branch."""
41
+
42
+ id: str
43
+ title: str
44
+ nogit: bool = False
45
+ update_from_default: bool = False
46
+ modified_files: list[str] = []
47
+
48
+
49
+ class BranchesModel(BaseModel):
50
+ """A model combining local and remote branches."""
51
+
52
+ local: list[BranchModel]
53
+ remote: list[BranchModel]
54
+
55
+
56
+ @router.get("", response_model=BranchesModel)
57
+ async def branches(
58
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
59
+ ) -> list:
60
+ """Fetch the available branches."""
61
+ return {"local": local_branches, "remote": remote_branches}
62
+
63
+
64
+ @router.patch("", status_code=204)
65
+ async def synchronise_remote() -> None:
66
+ """Synchronise with the git remote."""
67
+ await cron.track_branches.func()
68
+
69
+
70
+ class CreateBranchModel(BaseModel):
71
+ """Model representing a new branch."""
72
+
73
+ title: str = Field(min_length=1)
74
+
75
+
76
+ @router.post("", response_model=BranchModel)
77
+ async def create_branch(
78
+ data: CreateBranchModel,
79
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
80
+ x_ueditor_import_branch: Annotated[bool, Header()] = False, # noqa: FBT002
81
+ ) -> dict:
82
+ """Create a new branch."""
83
+ async with uedition_lock:
84
+ try:
85
+ branch_id = slugify(data.title)
86
+ repo = Repository(init_settings.base_path, flags=RepositoryOpenFlag.NO_SEARCH)
87
+ if branch_id in repo.branches.local:
88
+ raise HTTPException(422, detail=[{"msg": "this branch name is already in use"}])
89
+ if init_settings.git.remote_name in list(repo.remotes.names()):
90
+ fetch_and_pull_branch(
91
+ repo,
92
+ init_settings.git.remote_name,
93
+ init_settings.git.default_branch,
94
+ )
95
+ repo.checkout(repo.branches[init_settings.git.default_branch])
96
+ if x_ueditor_import_branch:
97
+ commit, reference = repo.resolve_refish(f"{init_settings.git.remote_name}/{branch_id}")
98
+ repo.branches.local.create(branch_id, commit)
99
+ repo.checkout(repo.branches[branch_id])
100
+ repo.branches[branch_id].upstream = repo.branches[f"{init_settings.git.remote_name}/{branch_id}"]
101
+ await cron.insecure_track_branches()
102
+ return {"id": branch_id, "title": de_slugify(data.title)}
103
+ else:
104
+ for remote_branch_id in repo.branches.remote:
105
+ if remote_branch_id.endswith(branch_id):
106
+ raise HTTPException(
107
+ 422,
108
+ detail=[{"msg": "this branch name is already used in the remote repository"}],
109
+ )
110
+ last_default_commit = repo.revparse_single(str(repo.branches[init_settings.git.default_branch].target))
111
+ repo.branches.local.create(branch_id, last_default_commit)
112
+ repo.checkout(repo.branches[branch_id])
113
+ if init_settings.git.remote_name in list(repo.remotes.names()):
114
+ repo.remotes[init_settings.git.remote_name].push(
115
+ [f"refs/heads/{branch_id}"],
116
+ callbacks=RemoteRepositoryCallbacks(),
117
+ )
118
+ fetch_repo(repo, init_settings.git.remote_name)
119
+ repo.branches[branch_id].upstream = repo.branches[f"{init_settings.git.remote_name}/{branch_id}"]
120
+ await cron.insecure_track_branches()
121
+ return {"id": branch_id, "title": data.title}
122
+ except GitError as ge:
123
+ logger.error(ge)
124
+ raise HTTPException(500, "Git error") from ge
125
+
126
+
127
+ @router.post("/{branch_id}/merge-from-default", status_code=204)
128
+ async def merge_from_default(
129
+ branch_id: str,
130
+ current_user: Annotated[dict, Depends(get_current_user)],
131
+ ) -> None:
132
+ """Merge all changes from the default branch."""
133
+ branch_id = branch_id.replace("%2F", "/")
134
+ async with BranchContextManager(branch_id) as repo:
135
+ repo.checkout(repo.branches[init_settings.git.default_branch])
136
+ if init_settings.git.remote_name in list(repo.remotes.names()):
137
+ fetch_repo(repo, init_settings.git.remote_name)
138
+ pull_branch(repo, init_settings.git.default_branch)
139
+ repo.checkout(repo.branches[branch_id])
140
+ default_branch_head = repo.revparse_single(init_settings.git.default_branch)
141
+ diff = repo.diff(default_branch_head)
142
+ if diff.stats.files_changed > 0:
143
+ repo.merge(default_branch_head.id)
144
+ commit_and_push(
145
+ repo,
146
+ init_settings.git.remote_name,
147
+ branch_id,
148
+ f"Merged {init_settings.git.default_branch}",
149
+ Signature(current_user["name"], current_user["sub"]),
150
+ extra_parents=[default_branch_head.id],
151
+ )
152
+ await cron.insecure_track_branches()
153
+
154
+
155
+ @router.delete("/{branch_id}", status_code=204)
156
+ async def delete_branch(
157
+ branch_id: str,
158
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
159
+ local_delete: Annotated[bool, Header(alias="x-ueditor-delete-local-only")] = False, # noqa:FBT002
160
+ ) -> None:
161
+ """Delete the given branch locally and remotely."""
162
+ branch_id = branch_id.replace("%2F", "/")
163
+ async with BranchContextManager(branch_id) as repo:
164
+ fetch_and_pull_branch(repo, init_settings.git.remote_name, branch_id)
165
+ if init_settings.git.default_branch == branch_id:
166
+ raise HTTPException(422, detail=[{"msg": "you cannot delete the default branch"}])
167
+ repo.checkout(repo.branches[init_settings.git.default_branch])
168
+ if init_settings.git.remote_name in list(repo.remotes.names()) and not local_delete:
169
+ if repo.branches[branch_id].upstream is not None:
170
+ repo.remotes[init_settings.git.remote_name].push(
171
+ [f":refs/heads/{branch_id}"], callbacks=RemoteRepositoryCallbacks()
172
+ )
173
+ if branch_id in repo.branches:
174
+ repo.branches.delete(branch_id)
175
+ await cron.insecure_track_branches()
@@ -0,0 +1,86 @@
1
+ # SPDX-FileCopyrightText: 2024-present Mark Hall <mark.hall@work.room3b.eu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The uEditor API for accessing configurations."""
5
+
6
+ import logging
7
+ import os
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends
11
+ from fastapi.exceptions import HTTPException
12
+ from fastapi.responses import Response
13
+
14
+ from uedition_editor.api.auth import get_current_user
15
+ from uedition_editor.api.util import BranchContextManager, BranchNotFoundError
16
+ from uedition_editor.settings import (
17
+ TEINode,
18
+ UEditionSettings,
19
+ UEditorSettings,
20
+ get_uedition_settings,
21
+ get_ueditor_settings,
22
+ init_settings,
23
+ )
24
+
25
+ router = APIRouter(prefix="/configs")
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @router.get("/uedition", response_model=UEditionSettings)
30
+ async def uedition_config(
31
+ branch_id: str,
32
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
33
+ ) -> dict:
34
+ """Fetch the uEdition configuration."""
35
+ branch_id = branch_id.replace("%2F", "/")
36
+ try:
37
+ async with BranchContextManager(branch_id):
38
+ settings = get_uedition_settings()
39
+ if "tei" in settings.sphinx_config:
40
+ if "blocks" in settings.sphinx_config["tei"]:
41
+ settings.sphinx_config["tei"]["blocks"] = [
42
+ TEINode(**block).model_dump() for block in settings.sphinx_config["tei"]["blocks"]
43
+ ]
44
+ if "marks" in settings.sphinx_config["tei"]:
45
+ settings.sphinx_config["tei"]["marks"] = [
46
+ TEINode(**mark).model_dump() for mark in settings.sphinx_config["tei"]["marks"]
47
+ ]
48
+ return settings.model_dump()
49
+ except BranchNotFoundError as bnfe:
50
+ raise HTTPException(404) from bnfe
51
+
52
+
53
+ @router.get("/ueditor", response_model=UEditorSettings)
54
+ async def tei_config(
55
+ branch_id: str,
56
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
57
+ ) -> dict:
58
+ """Fetch the uEditor configuration."""
59
+ branch_id = branch_id.replace("%2F", "/")
60
+ try:
61
+ async with BranchContextManager(branch_id):
62
+ return get_ueditor_settings().model_dump()
63
+ except BranchNotFoundError as bnfe:
64
+ raise HTTPException(404) from bnfe
65
+
66
+
67
+ @router.get("/ui-stylesheet")
68
+ async def ui_stylesheet(
69
+ branch_id: str,
70
+ current_user: Annotated[dict, Depends(get_current_user)], # noqa:ARG001
71
+ ) -> str:
72
+ """Fetch the configured CSS stylesheets."""
73
+ branch_id = branch_id.replace("%2F", "/")
74
+ try:
75
+ async with BranchContextManager(branch_id):
76
+ tmp = []
77
+ for filename in get_ueditor_settings().ui.css_files:
78
+ full_path = os.path.join(init_settings.base_path, filename)
79
+ if os.path.exists(full_path):
80
+ with open(full_path) as in_f:
81
+ tmp.append(in_f.read())
82
+ else:
83
+ raise HTTPException(404)
84
+ return Response("\n\n".join(tmp), media_type="text/css")
85
+ except BranchNotFoundError as bnfe:
86
+ raise HTTPException(404) from bnfe