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.
- uedition_editor/__about__.py +6 -0
- uedition_editor/__init__.py +69 -0
- uedition_editor/__main__.py +9 -0
- uedition_editor/api/__init__.py +72 -0
- uedition_editor/api/auth.py +208 -0
- uedition_editor/api/branches.py +175 -0
- uedition_editor/api/configs.py +86 -0
- uedition_editor/api/files.py +861 -0
- uedition_editor/api/tests.py +39 -0
- uedition_editor/api/util.py +130 -0
- uedition_editor/cli/__init__.py +38 -0
- uedition_editor/cron.py +73 -0
- uedition_editor/frontend/dist/assets/CodeMirrorEditor-B5eS4_KA.js +30 -0
- uedition_editor/frontend/dist/assets/CodeMirrorEditor-DUGUrxLC.css +1 -0
- uedition_editor/frontend/dist/assets/FolderEditor-B9BUwgA7.js +1 -0
- uedition_editor/frontend/dist/assets/ImageEditor-oxxa_GP1.js +1 -0
- uedition_editor/frontend/dist/assets/PdfEditor-tNZQw0sE.js +1 -0
- uedition_editor/frontend/dist/assets/TeiEditor-DcMDb81l.js +103 -0
- uedition_editor/frontend/dist/assets/index-B32uewzl.css +1 -0
- uedition_editor/frontend/dist/assets/index-C_mk-V9Y.js +13 -0
- uedition_editor/frontend/dist/assets/index-Vcq4gwWv.js +1 -0
- uedition_editor/frontend/dist/index.html +17 -0
- uedition_editor/frontend/dist/ueditor.svg +1 -0
- uedition_editor/settings.py +516 -0
- uedition_editor/state.py +7 -0
- uedition_editor-2.0.0.dist-info/METADATA +76 -0
- uedition_editor-2.0.0.dist-info/RECORD +30 -0
- uedition_editor-2.0.0.dist-info/WHEEL +4 -0
- uedition_editor-2.0.0.dist-info/entry_points.txt +2 -0
- uedition_editor-2.0.0.dist-info/licenses/LICENSE.txt +9 -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,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
|