html-golive 0.4.1__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.
- golive/__init__.py +3 -0
- golive/backends/__init__.py +1 -0
- golive/backends/auth/__init__.py +1 -0
- golive/backends/auth/base.py +17 -0
- golive/backends/auth/none.py +12 -0
- golive/backends/auth/oauth.py +330 -0
- golive/backends/auth/presets.py +84 -0
- golive/backends/auth/token.py +49 -0
- golive/backends/data/__init__.py +5 -0
- golive/backends/data/supabase.py +151 -0
- golive/backends/factory.py +51 -0
- golive/backends/images/__init__.py +15 -0
- golive/backends/images/base.py +34 -0
- golive/backends/images/command.py +139 -0
- golive/backends/images/s3.py +88 -0
- golive/backends/postgrest.py +138 -0
- golive/backends/registry/__init__.py +1 -0
- golive/backends/registry/sqlite_store.py +202 -0
- golive/backends/registry/supabase_store.py +184 -0
- golive/backends/storage/__init__.py +1 -0
- golive/backends/storage/local.py +103 -0
- golive/backends/storage/s3.py +195 -0
- golive/backends/storage/supabase_store.py +242 -0
- golive/cli.py +791 -0
- golive/config.py +490 -0
- golive/core/__init__.py +1 -0
- golive/core/audit_log.py +277 -0
- golive/core/bundle.py +945 -0
- golive/core/cdn_localizer.py +222 -0
- golive/core/clone_analyzer.py +562 -0
- golive/core/clone_fetcher.py +920 -0
- golive/core/clone_patcher.py +543 -0
- golive/core/clone_site.py +315 -0
- golive/core/code_safety_checker.py +364 -0
- golive/core/css_style_enhancer.py +659 -0
- golive/core/data_role_tagger.py +456 -0
- golive/core/http_client.py +82 -0
- golive/core/keymap_rules.py +143 -0
- golive/core/migrate_check.py +189 -0
- golive/core/paths.py +71 -0
- golive/core/preview_server.py +780 -0
- golive/core/publish_utils.py +189 -0
- golive/core/slug_checker.py +150 -0
- golive/inject/__init__.py +29 -0
- golive/inject/_escape.py +72 -0
- golive/inject/editor.py +434 -0
- golive/inject/supabase_api.py +309 -0
- golive/inject/template_api.py +431 -0
- golive/inject/watermark.py +239 -0
- golive/resources/css_styles/apple.css +184 -0
- golive/resources/css_styles/bloomberg.css +208 -0
- golive/resources/css_styles/carbon.css +145 -0
- golive/resources/css_styles/cowork.css +184 -0
- golive/resources/css_styles/cyberpunk.css +269 -0
- golive/resources/css_styles/dreamy.css +143 -0
- golive/resources/css_styles/earthy.css +143 -0
- golive/resources/css_styles/fresh.css +143 -0
- golive/resources/css_styles/glass.css +157 -0
- golive/resources/css_styles/ink.css +308 -0
- golive/resources/css_styles/macaron.css +143 -0
- golive/resources/css_styles/minimal.css +197 -0
- golive/resources/css_styles/morandi.css +142 -0
- golive/resources/css_styles/newspaper.css +317 -0
- golive/resources/css_styles/palace.css +345 -0
- golive/resources/css_styles/steampunk.css +215 -0
- golive/resources/css_styles/vivid.css +152 -0
- golive/resources/css_styles/xhs-fun.css +209 -0
- golive/resources/css_styles/xhs.css +183 -0
- golive/security/__init__.py +1 -0
- golive/security/ai_review.py +252 -0
- golive/security/rules.yaml +94 -0
- golive/security/scanner.py +328 -0
- golive/server/__init__.py +1 -0
- golive/server/app.py +395 -0
- golive/server/editor_api.py +166 -0
- html_golive-0.4.1.dist-info/METADATA +324 -0
- html_golive-0.4.1.dist-info/RECORD +81 -0
- html_golive-0.4.1.dist-info/WHEEL +5 -0
- html_golive-0.4.1.dist-info/entry_points.txt +2 -0
- html_golive-0.4.1.dist-info/licenses/LICENSE +21 -0
- html_golive-0.4.1.dist-info/top_level.txt +1 -0
golive/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""golive.backends.auth.base — AuthProvider interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthProvider(ABC):
|
|
7
|
+
"""Auth abstraction for serve-mode write operations (and M3 editor)."""
|
|
8
|
+
|
|
9
|
+
name = "base"
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def verify(self, request_headers: dict) -> bool:
|
|
13
|
+
"""Return True when the request is allowed to perform write ops."""
|
|
14
|
+
|
|
15
|
+
def identity(self, request_headers: dict) -> str:
|
|
16
|
+
"""Best-effort caller identity (empty string when unknown)."""
|
|
17
|
+
return ""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""golive.backends.auth.none — no-auth provider (default for single-user)."""
|
|
2
|
+
|
|
3
|
+
from golive.backends.auth.base import AuthProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoneAuth(AuthProvider):
|
|
7
|
+
"""Allows everything. Fine on localhost; use TokenAuth on shared hosts."""
|
|
8
|
+
|
|
9
|
+
name = "none"
|
|
10
|
+
|
|
11
|
+
def verify(self, request_headers: dict) -> bool:
|
|
12
|
+
return True
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""golive.backends.auth.oauth — generic OIDC AuthProvider (M3).
|
|
2
|
+
|
|
3
|
+
Works with any OpenID Connect IdP that publishes a discovery document
|
|
4
|
+
(``/.well-known/openid-configuration``): Google, Keycloak, Authentik,
|
|
5
|
+
Okta, Auth0, self-hosted Dex … GitHub's OAuth is not OIDC — use an OIDC
|
|
6
|
+
bridge (e.g. Dex) or a provider that issues OIDC tokens.
|
|
7
|
+
|
|
8
|
+
Flow (implemented in golive.server.app via this provider):
|
|
9
|
+
GET /auth/login -> 302 to IdP authorization endpoint (state + PKCE)
|
|
10
|
+
GET /auth/callback -> code -> token -> userinfo -> golive session cookie
|
|
11
|
+
GET /auth/logout -> clear cookie (+ optional IdP end_session redirect)
|
|
12
|
+
GET /auth/me -> current session identity JSON
|
|
13
|
+
|
|
14
|
+
Session model: in-memory dict with TTL (restart invalidates sessions —
|
|
15
|
+
acceptable for M3; a shared store lands in M4). The cookie carries only
|
|
16
|
+
a random session id, HMAC-signed with a server secret so a forged id is
|
|
17
|
+
rejected even before the dict lookup.
|
|
18
|
+
|
|
19
|
+
Config (golive.yaml):
|
|
20
|
+
auth:
|
|
21
|
+
provider: oidc
|
|
22
|
+
oidc:
|
|
23
|
+
issuer: https://accounts.google.com
|
|
24
|
+
client_id: xxx.apps.googleusercontent.com
|
|
25
|
+
client_secret_env: GOLIVE_OIDC_CLIENT_SECRET
|
|
26
|
+
redirect_uri: http://localhost:8787/auth/callback
|
|
27
|
+
scopes: "openid email profile"
|
|
28
|
+
session_ttl: 28800
|
|
29
|
+
cookie_secret_env: GOLIVE_COOKIE_SECRET
|
|
30
|
+
force_secure_cookie: false
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import base64
|
|
36
|
+
import hashlib
|
|
37
|
+
import hmac
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import secrets
|
|
41
|
+
import time
|
|
42
|
+
import urllib.parse
|
|
43
|
+
from typing import Optional
|
|
44
|
+
|
|
45
|
+
from golive.backends.auth.base import AuthProvider
|
|
46
|
+
|
|
47
|
+
COOKIE_NAME = "golive_session"
|
|
48
|
+
STATE_TTL = 600 # seconds an auth request (state/PKCE) stays valid
|
|
49
|
+
DEFAULT_TIMEOUT = 15
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OIDCError(RuntimeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _b64url(data: bytes) -> str:
|
|
57
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_or_create_cookie_secret() -> str:
|
|
61
|
+
"""Return a stable cookie-signing secret persisted under GOLIVE_HOME.
|
|
62
|
+
|
|
63
|
+
Generated once (0600 file) so signed session cookies survive a server
|
|
64
|
+
restart without the operator having to set GOLIVE_COOKIE_SECRET. If the
|
|
65
|
+
home dir is unwritable we fall back to an ephemeral per-process key and
|
|
66
|
+
warn — sessions then die on restart, but auth still works.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
from golive.core.paths import get_home
|
|
70
|
+
path = get_home() / ".cookie_secret"
|
|
71
|
+
if path.exists():
|
|
72
|
+
val = path.read_text(encoding="utf-8").strip()
|
|
73
|
+
if val:
|
|
74
|
+
return val
|
|
75
|
+
val = secrets.token_hex(32)
|
|
76
|
+
path.write_text(val, encoding="utf-8")
|
|
77
|
+
try:
|
|
78
|
+
os.chmod(path, 0o600)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
return val
|
|
82
|
+
except Exception as e: # noqa: BLE001 — degrade to ephemeral
|
|
83
|
+
import sys
|
|
84
|
+
print(f"⚠️ could not persist cookie secret ({e}); using an "
|
|
85
|
+
f"ephemeral key — sessions will not survive a restart. "
|
|
86
|
+
f"Set GOLIVE_COOKIE_SECRET to silence this.", file=sys.stderr)
|
|
87
|
+
return secrets.token_hex(32)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OIDCAuth(AuthProvider):
|
|
91
|
+
"""Generic OIDC provider with PKCE + signed session cookies."""
|
|
92
|
+
|
|
93
|
+
name = "oidc"
|
|
94
|
+
|
|
95
|
+
def __init__(self, issuer: str = "", client_id: str = "",
|
|
96
|
+
client_secret: str = "", redirect_uri: str = "",
|
|
97
|
+
scopes: str = "openid email profile",
|
|
98
|
+
session_ttl: int = 8 * 3600, cookie_secret: str = "",
|
|
99
|
+
force_secure_cookie: bool = False):
|
|
100
|
+
if not issuer:
|
|
101
|
+
from golive.config import get_config
|
|
102
|
+
au = get_config().auth
|
|
103
|
+
issuer = au.oidc_issuer
|
|
104
|
+
client_id = client_id or au.oidc_client_id
|
|
105
|
+
client_secret = client_secret or au.oidc_client_secret
|
|
106
|
+
redirect_uri = redirect_uri or au.oidc_redirect_uri
|
|
107
|
+
scopes = au.oidc_scopes or scopes
|
|
108
|
+
session_ttl = au.oidc_session_ttl or session_ttl
|
|
109
|
+
cookie_secret = cookie_secret or au.oidc_cookie_secret
|
|
110
|
+
force_secure_cookie = au.oidc_force_secure_cookie
|
|
111
|
+
if not issuer or not client_id:
|
|
112
|
+
raise OIDCError("auth.oidc.issuer and auth.oidc.client_id are required")
|
|
113
|
+
self.issuer = issuer.rstrip("/")
|
|
114
|
+
self.client_id = client_id
|
|
115
|
+
self.client_secret = client_secret
|
|
116
|
+
self.redirect_uri = redirect_uri
|
|
117
|
+
self.scopes = scopes
|
|
118
|
+
self.session_ttl = int(session_ttl)
|
|
119
|
+
self.force_secure_cookie = bool(force_secure_cookie)
|
|
120
|
+
# cookie secret precedence: explicit arg > env > persisted file.
|
|
121
|
+
# The persisted file lives under GOLIVE_HOME so sessions survive a
|
|
122
|
+
# restart even when the operator never set GOLIVE_COOKIE_SECRET
|
|
123
|
+
# (falls back to an ephemeral key only if the file is unwritable).
|
|
124
|
+
_sec = cookie_secret or os.environ.get("GOLIVE_COOKIE_SECRET", "")
|
|
125
|
+
if not _sec:
|
|
126
|
+
_sec = _load_or_create_cookie_secret()
|
|
127
|
+
self._cookie_secret = _sec.encode("utf-8")
|
|
128
|
+
self._discovery: Optional[dict] = None
|
|
129
|
+
self._sessions: dict = {} # sid -> {sub, email, name, exp}
|
|
130
|
+
self._pending: dict = {} # state -> {verifier, exp}
|
|
131
|
+
|
|
132
|
+
# ── discovery ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def discovery(self) -> dict:
|
|
135
|
+
if self._discovery is None:
|
|
136
|
+
import requests
|
|
137
|
+
url = self.issuer + "/.well-known/openid-configuration"
|
|
138
|
+
resp = requests.get(url, timeout=DEFAULT_TIMEOUT)
|
|
139
|
+
if resp.status_code != 200:
|
|
140
|
+
raise OIDCError(f"OIDC discovery failed (HTTP {resp.status_code}) at {url}")
|
|
141
|
+
doc = resp.json()
|
|
142
|
+
for key in ("authorization_endpoint", "token_endpoint"):
|
|
143
|
+
if key not in doc:
|
|
144
|
+
raise OIDCError(f"OIDC discovery document missing {key}")
|
|
145
|
+
self._discovery = doc
|
|
146
|
+
return self._discovery
|
|
147
|
+
|
|
148
|
+
# ── login: build the authorization redirect ─────────────────────────────
|
|
149
|
+
|
|
150
|
+
def _prune_pending(self) -> None:
|
|
151
|
+
now = time.time()
|
|
152
|
+
for k in [k for k, v in self._pending.items() if v["exp"] < now]:
|
|
153
|
+
self._pending.pop(k, None)
|
|
154
|
+
|
|
155
|
+
def begin_login(self) -> str:
|
|
156
|
+
"""Return the IdP authorization URL (registers state + PKCE)."""
|
|
157
|
+
self._prune_pending()
|
|
158
|
+
doc = self.discovery()
|
|
159
|
+
state = secrets.token_urlsafe(24)
|
|
160
|
+
verifier = _b64url(secrets.token_bytes(48))
|
|
161
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
162
|
+
self._pending[state] = {"verifier": verifier,
|
|
163
|
+
"exp": time.time() + STATE_TTL}
|
|
164
|
+
params = {
|
|
165
|
+
"response_type": "code",
|
|
166
|
+
"client_id": self.client_id,
|
|
167
|
+
"redirect_uri": self.redirect_uri,
|
|
168
|
+
"scope": self.scopes,
|
|
169
|
+
"state": state,
|
|
170
|
+
"code_challenge": challenge,
|
|
171
|
+
"code_challenge_method": "S256",
|
|
172
|
+
}
|
|
173
|
+
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
|
|
174
|
+
|
|
175
|
+
# ── callback: code -> tokens -> userinfo -> session ─────────────────────
|
|
176
|
+
|
|
177
|
+
def complete_login(self, code: str, state: str) -> dict:
|
|
178
|
+
"""Exchange the code; return {sid, cookie_value, user}. Raises OIDCError."""
|
|
179
|
+
import requests
|
|
180
|
+
|
|
181
|
+
pend = self._pending.pop(state or "", None)
|
|
182
|
+
if pend is None or pend["exp"] < time.time():
|
|
183
|
+
raise OIDCError("invalid or expired state (possible CSRF) — retry login")
|
|
184
|
+
|
|
185
|
+
doc = self.discovery()
|
|
186
|
+
data = {
|
|
187
|
+
"grant_type": "authorization_code",
|
|
188
|
+
"code": code,
|
|
189
|
+
"redirect_uri": self.redirect_uri,
|
|
190
|
+
"client_id": self.client_id,
|
|
191
|
+
"code_verifier": pend["verifier"],
|
|
192
|
+
}
|
|
193
|
+
if self.client_secret:
|
|
194
|
+
data["client_secret"] = self.client_secret
|
|
195
|
+
resp = requests.post(doc["token_endpoint"], data=data,
|
|
196
|
+
headers={"Accept": "application/json"},
|
|
197
|
+
timeout=DEFAULT_TIMEOUT)
|
|
198
|
+
if resp.status_code != 200:
|
|
199
|
+
raise OIDCError(f"token exchange failed (HTTP {resp.status_code}): "
|
|
200
|
+
f"{resp.text[:200]}")
|
|
201
|
+
tokens = resp.json()
|
|
202
|
+
|
|
203
|
+
user = self._resolve_user(doc, tokens)
|
|
204
|
+
if not (user.get("sub") or user.get("email")):
|
|
205
|
+
raise OIDCError("IdP returned no usable identity (sub/email)")
|
|
206
|
+
|
|
207
|
+
sid = secrets.token_urlsafe(32)
|
|
208
|
+
self._sessions[sid] = {**user, "exp": time.time() + self.session_ttl}
|
|
209
|
+
return {"sid": sid, "cookie_value": self._sign_sid(sid), "user": user}
|
|
210
|
+
|
|
211
|
+
def _resolve_user(self, doc: dict, tokens: dict) -> dict:
|
|
212
|
+
"""userinfo endpoint preferred; fall back to id_token claims."""
|
|
213
|
+
import requests
|
|
214
|
+
|
|
215
|
+
access_token = tokens.get("access_token", "")
|
|
216
|
+
userinfo_ep = doc.get("userinfo_endpoint", "")
|
|
217
|
+
if access_token and userinfo_ep:
|
|
218
|
+
try:
|
|
219
|
+
resp = requests.get(
|
|
220
|
+
userinfo_ep,
|
|
221
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
222
|
+
timeout=DEFAULT_TIMEOUT)
|
|
223
|
+
if resp.status_code == 200:
|
|
224
|
+
ui = resp.json()
|
|
225
|
+
return {"sub": str(ui.get("sub", "")),
|
|
226
|
+
"email": str(ui.get("email", "")).lower(),
|
|
227
|
+
"name": str(ui.get("name", "")
|
|
228
|
+
or ui.get("preferred_username", ""))}
|
|
229
|
+
except requests.RequestException:
|
|
230
|
+
pass
|
|
231
|
+
# fallback: unverified id_token payload decode (transport already
|
|
232
|
+
# authenticated via TLS + client credentials at the token endpoint)
|
|
233
|
+
id_token = tokens.get("id_token", "")
|
|
234
|
+
if id_token and id_token.count(".") == 2:
|
|
235
|
+
try:
|
|
236
|
+
payload = id_token.split(".")[1]
|
|
237
|
+
payload += "=" * (-len(payload) % 4)
|
|
238
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
239
|
+
return {"sub": str(claims.get("sub", "")),
|
|
240
|
+
"email": str(claims.get("email", "")).lower(),
|
|
241
|
+
"name": str(claims.get("name", ""))}
|
|
242
|
+
except (ValueError, TypeError):
|
|
243
|
+
pass
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
# ── cookie signing / session lookup ─────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def _sign_sid(self, sid: str) -> str:
|
|
249
|
+
mac = hmac.new(self._cookie_secret, sid.encode("ascii"),
|
|
250
|
+
hashlib.sha256).hexdigest()
|
|
251
|
+
return f"{sid}.{mac}"
|
|
252
|
+
|
|
253
|
+
def _verify_cookie(self, cookie_value: str) -> Optional[str]:
|
|
254
|
+
if not cookie_value or "." not in cookie_value:
|
|
255
|
+
return None
|
|
256
|
+
sid, mac = cookie_value.rsplit(".", 1)
|
|
257
|
+
expect = hmac.new(self._cookie_secret, sid.encode("ascii"),
|
|
258
|
+
hashlib.sha256).hexdigest()
|
|
259
|
+
if not hmac.compare_digest(mac, expect):
|
|
260
|
+
return None
|
|
261
|
+
return sid
|
|
262
|
+
|
|
263
|
+
def _prune_sessions(self) -> None:
|
|
264
|
+
now = time.time()
|
|
265
|
+
for k in [k for k, v in self._sessions.items() if v["exp"] < now]:
|
|
266
|
+
self._sessions.pop(k, None)
|
|
267
|
+
|
|
268
|
+
def session_user(self, request_headers: dict) -> Optional[dict]:
|
|
269
|
+
"""Resolve the session cookie into a user dict (or None)."""
|
|
270
|
+
headers = {str(k).lower(): str(v) for k, v in (request_headers or {}).items()}
|
|
271
|
+
raw = headers.get("cookie", "")
|
|
272
|
+
cookie_value = ""
|
|
273
|
+
for part in raw.split(";"):
|
|
274
|
+
k, _, v = part.strip().partition("=")
|
|
275
|
+
if k == COOKIE_NAME:
|
|
276
|
+
cookie_value = v
|
|
277
|
+
break
|
|
278
|
+
sid = self._verify_cookie(cookie_value)
|
|
279
|
+
if not sid:
|
|
280
|
+
return None
|
|
281
|
+
self._prune_sessions()
|
|
282
|
+
sess = self._sessions.get(sid)
|
|
283
|
+
if not sess or sess["exp"] < time.time():
|
|
284
|
+
self._sessions.pop(sid, None)
|
|
285
|
+
return None
|
|
286
|
+
return {"sub": sess.get("sub", ""), "email": sess.get("email", ""),
|
|
287
|
+
"name": sess.get("name", "")}
|
|
288
|
+
|
|
289
|
+
def logout(self, request_headers: dict) -> None:
|
|
290
|
+
headers = {str(k).lower(): str(v) for k, v in (request_headers or {}).items()}
|
|
291
|
+
raw = headers.get("cookie", "")
|
|
292
|
+
for part in raw.split(";"):
|
|
293
|
+
k, _, v = part.strip().partition("=")
|
|
294
|
+
if k == COOKIE_NAME:
|
|
295
|
+
sid = self._verify_cookie(v)
|
|
296
|
+
if sid:
|
|
297
|
+
self._sessions.pop(sid, None)
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
def build_cookie(self, cookie_value: str, secure: bool) -> str:
|
|
301
|
+
attrs = [f"{COOKIE_NAME}={cookie_value}", "Path=/", "HttpOnly",
|
|
302
|
+
"SameSite=Lax", f"Max-Age={self.session_ttl}"]
|
|
303
|
+
if secure or self.force_secure_cookie:
|
|
304
|
+
attrs.append("Secure")
|
|
305
|
+
return "; ".join(attrs)
|
|
306
|
+
|
|
307
|
+
def clear_cookie(self) -> str:
|
|
308
|
+
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
|
309
|
+
|
|
310
|
+
def end_session_url(self, post_logout_redirect: str = "") -> str:
|
|
311
|
+
"""IdP end_session URL when the IdP advertises one ('' otherwise)."""
|
|
312
|
+
try:
|
|
313
|
+
ep = self.discovery().get("end_session_endpoint", "")
|
|
314
|
+
except OIDCError:
|
|
315
|
+
return ""
|
|
316
|
+
if not ep:
|
|
317
|
+
return ""
|
|
318
|
+
if post_logout_redirect:
|
|
319
|
+
return ep + "?" + urllib.parse.urlencode(
|
|
320
|
+
{"post_logout_redirect_uri": post_logout_redirect})
|
|
321
|
+
return ep
|
|
322
|
+
|
|
323
|
+
# ── AuthProvider interface ──────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def verify(self, request_headers: dict) -> bool:
|
|
326
|
+
return self.session_user(request_headers) is not None
|
|
327
|
+
|
|
328
|
+
def identity(self, request_headers: dict) -> str:
|
|
329
|
+
user = self.session_user(request_headers)
|
|
330
|
+
return (user or {}).get("email", "")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""golive.backends.auth.presets — one-line OIDC provider presets.
|
|
2
|
+
|
|
3
|
+
Set ``auth.oidc.preset: google`` (or okta / auth0 / keycloak / authentik /
|
|
4
|
+
azure) and golive fills in the well-known public fields for that IdP —
|
|
5
|
+
issuer template, default scopes — so you only supply ``client_id`` and
|
|
6
|
+
the secret env. Explicit ``auth.oidc.*`` fields always override a preset.
|
|
7
|
+
|
|
8
|
+
Presets only ever contain PUBLIC, non-secret values. Never put a client
|
|
9
|
+
secret here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# Each preset supplies the fields that are safe to hard-code for a given
|
|
15
|
+
# IdP. `issuer` may contain a `{domain}` / `{tenant}` placeholder that is
|
|
16
|
+
# resolved from auth.oidc.domain / auth.oidc.tenant when present.
|
|
17
|
+
PRESETS: dict = {
|
|
18
|
+
"google": {
|
|
19
|
+
"issuer": "https://accounts.google.com",
|
|
20
|
+
"scopes": "openid email profile",
|
|
21
|
+
},
|
|
22
|
+
"auth0": {
|
|
23
|
+
# requires auth.oidc.domain: your-tenant.us.auth0.com
|
|
24
|
+
"issuer": "https://{domain}",
|
|
25
|
+
"scopes": "openid email profile",
|
|
26
|
+
},
|
|
27
|
+
"okta": {
|
|
28
|
+
# requires auth.oidc.domain: your-org.okta.com
|
|
29
|
+
"issuer": "https://{domain}",
|
|
30
|
+
"scopes": "openid email profile",
|
|
31
|
+
},
|
|
32
|
+
"azure": {
|
|
33
|
+
# requires auth.oidc.tenant: <tenant-id> or "organizations"/"common"
|
|
34
|
+
"issuer": "https://login.microsoftonline.com/{tenant}/v2.0",
|
|
35
|
+
"scopes": "openid email profile",
|
|
36
|
+
},
|
|
37
|
+
"keycloak": {
|
|
38
|
+
# requires auth.oidc.issuer OR domain+realm; kept generic
|
|
39
|
+
# e.g. issuer: https://kc.example.com/realms/myrealm
|
|
40
|
+
"scopes": "openid email profile",
|
|
41
|
+
},
|
|
42
|
+
"authentik": {
|
|
43
|
+
# requires auth.oidc.issuer: https://authentik.example.com/application/o/<slug>/
|
|
44
|
+
"scopes": "openid email profile",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SUPPORTED = sorted(PRESETS.keys())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_preset(preset: str, *, domain: str = "", tenant: str = "",
|
|
52
|
+
realm: str = "") -> dict:
|
|
53
|
+
"""Return {issuer, scopes} for a named preset, or raise ValueError.
|
|
54
|
+
|
|
55
|
+
`domain` / `tenant` / `realm` fill placeholders where a preset needs
|
|
56
|
+
tenant-specific host info. Fields the preset omits are returned empty
|
|
57
|
+
so the caller keeps whatever the user configured explicitly.
|
|
58
|
+
"""
|
|
59
|
+
key = (preset or "").strip().lower()
|
|
60
|
+
if key not in PRESETS:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"unknown auth.oidc.preset: {preset!r} "
|
|
63
|
+
f"(supported: {', '.join(SUPPORTED)})"
|
|
64
|
+
)
|
|
65
|
+
spec = dict(PRESETS[key])
|
|
66
|
+
issuer = spec.get("issuer", "")
|
|
67
|
+
if "{domain}" in issuer:
|
|
68
|
+
if not domain:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"auth.oidc.preset: {key} requires auth.oidc.domain "
|
|
71
|
+
f"(e.g. your-tenant.{key}.com)"
|
|
72
|
+
)
|
|
73
|
+
issuer = issuer.replace("{domain}", domain.strip().rstrip("/"))
|
|
74
|
+
if "{tenant}" in issuer:
|
|
75
|
+
if not tenant:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"auth.oidc.preset: azure requires auth.oidc.tenant "
|
|
78
|
+
"(tenant id, or 'organizations' / 'common')"
|
|
79
|
+
)
|
|
80
|
+
issuer = issuer.replace("{tenant}", tenant.strip())
|
|
81
|
+
out = {"scopes": spec.get("scopes", "")}
|
|
82
|
+
if issuer:
|
|
83
|
+
out["issuer"] = issuer.rstrip("/")
|
|
84
|
+
return out
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""golive.backends.auth.token — static-token auth provider.
|
|
2
|
+
|
|
3
|
+
Token source order:
|
|
4
|
+
1. explicit token passed to constructor (from golive.yaml)
|
|
5
|
+
2. env GOLIVE_TOKEN
|
|
6
|
+
|
|
7
|
+
Clients authenticate with either header:
|
|
8
|
+
Authorization: Bearer <token>
|
|
9
|
+
X-Golive-Token: <token>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hmac
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from golive.backends.auth.base import AuthProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenAuth(AuthProvider):
|
|
19
|
+
name = "token"
|
|
20
|
+
|
|
21
|
+
def __init__(self, token: str = ""):
|
|
22
|
+
self.token = token or os.environ.get("GOLIVE_TOKEN", "")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def configured(self) -> bool:
|
|
26
|
+
return bool(self.token)
|
|
27
|
+
|
|
28
|
+
def verify(self, request_headers: dict) -> bool:
|
|
29
|
+
if not self.configured:
|
|
30
|
+
return False
|
|
31
|
+
# normalize header keys to lowercase
|
|
32
|
+
headers = {str(k).lower(): str(v) for k, v in (request_headers or {}).items()}
|
|
33
|
+
candidate = headers.get("x-golive-token", "")
|
|
34
|
+
if not candidate:
|
|
35
|
+
auth = headers.get("authorization", "")
|
|
36
|
+
if auth.lower().startswith("bearer "):
|
|
37
|
+
candidate = auth[7:].strip()
|
|
38
|
+
if not candidate:
|
|
39
|
+
return False
|
|
40
|
+
return hmac.compare_digest(candidate, self.token)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_auth_provider():
|
|
44
|
+
"""Factory: TokenAuth when GOLIVE_TOKEN is set, else NoneAuth."""
|
|
45
|
+
tok = os.environ.get("GOLIVE_TOKEN", "")
|
|
46
|
+
if tok:
|
|
47
|
+
return TokenAuth(tok)
|
|
48
|
+
from golive.backends.auth.none import NoneAuth
|
|
49
|
+
return NoneAuth()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""golive.backends.data.supabase — Python-side template CRUD (PostgREST).
|
|
2
|
+
|
|
3
|
+
The open-source data layer stores TemplateAPI records in one table:
|
|
4
|
+
|
|
5
|
+
create table if not exists golive_templates (
|
|
6
|
+
id uuid primary key default gen_random_uuid(),
|
|
7
|
+
model_code text not null,
|
|
8
|
+
name text not null,
|
|
9
|
+
user_id text not null default '',
|
|
10
|
+
description text not null default '',
|
|
11
|
+
content jsonb,
|
|
12
|
+
version text not null default '1.0.0',
|
|
13
|
+
sort_index bigint not null default 0,
|
|
14
|
+
created_at timestamptz not null default now(),
|
|
15
|
+
updated_at timestamptz not null default now(),
|
|
16
|
+
unique (model_code, name, user_id)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
``model_code`` maps the intranet modelCode concept: a namespace per site
|
|
20
|
+
(or per logical dataset). The injected window.TemplateAPI (see
|
|
21
|
+
golive.inject.template_api) talks PostgREST directly from the browser;
|
|
22
|
+
this module is the server-side/CLI twin used by ``golive data ...``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from golive.backends.postgrest import PostgrestClient, client_from_config
|
|
31
|
+
|
|
32
|
+
DEFAULT_TABLE = "golive_templates"
|
|
33
|
+
|
|
34
|
+
CREATE_TABLE_SQL = """\
|
|
35
|
+
create table if not exists {table} (
|
|
36
|
+
id uuid primary key default gen_random_uuid(),
|
|
37
|
+
model_code text not null,
|
|
38
|
+
name text not null,
|
|
39
|
+
user_id text not null default '',
|
|
40
|
+
description text not null default '',
|
|
41
|
+
content jsonb,
|
|
42
|
+
version text not null default '1.0.0',
|
|
43
|
+
sort_index bigint not null default 0,
|
|
44
|
+
created_at timestamptz not null default now(),
|
|
45
|
+
updated_at timestamptz not null default now(),
|
|
46
|
+
unique (model_code, name, user_id)
|
|
47
|
+
);
|
|
48
|
+
-- RLS example (anon key in the browser NEEDS policies like these):
|
|
49
|
+
-- alter table {table} enable row level security;
|
|
50
|
+
-- create policy "read all" on {table} for select using (true);
|
|
51
|
+
-- create policy "insert" on {table} for insert with check (true);
|
|
52
|
+
-- create policy "update" on {table} for update using (true);
|
|
53
|
+
-- create policy "delete" on {table} for delete using (true);
|
|
54
|
+
-- Tighten the policies to your auth setup before going to production.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TemplateStore:
|
|
59
|
+
"""CRUD for golive_templates via PostgREST."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, client: Optional[PostgrestClient] = None,
|
|
62
|
+
table: str = ""):
|
|
63
|
+
self.client = client or client_from_config()
|
|
64
|
+
if not table:
|
|
65
|
+
from golive.config import get_config
|
|
66
|
+
table = get_config().data.templates_table or DEFAULT_TABLE
|
|
67
|
+
self.table = table
|
|
68
|
+
|
|
69
|
+
# ── queries ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def list(self, model_code: str, name_prefix: str = "", user_id: str = "",
|
|
72
|
+
page_no: int = 1, page_size: int = 20) -> dict:
|
|
73
|
+
params = {
|
|
74
|
+
"model_code": f"eq.{model_code}",
|
|
75
|
+
"order": "sort_index.desc,created_at.desc",
|
|
76
|
+
"limit": str(page_size),
|
|
77
|
+
"offset": str((max(page_no, 1) - 1) * page_size),
|
|
78
|
+
}
|
|
79
|
+
if user_id:
|
|
80
|
+
params["user_id"] = f"eq.{user_id}"
|
|
81
|
+
if name_prefix:
|
|
82
|
+
params["name"] = f"like.{name_prefix}*"
|
|
83
|
+
rows, total = self.client.select(self.table, params, count=True)
|
|
84
|
+
return {"total": total or 0, "list": rows}
|
|
85
|
+
|
|
86
|
+
def get(self, template_id: str) -> Optional[dict]:
|
|
87
|
+
rows, _ = self.client.select(
|
|
88
|
+
self.table, {"id": f"eq.{template_id}", "limit": "1"})
|
|
89
|
+
return rows[0] if rows else None
|
|
90
|
+
|
|
91
|
+
def count(self, model_code: str) -> int:
|
|
92
|
+
return self.client.count(self.table,
|
|
93
|
+
{"model_code": f"eq.{model_code}"})
|
|
94
|
+
|
|
95
|
+
# ── writes ──────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def create(self, model_code: str, name: str, content=None,
|
|
98
|
+
description: str = "", version: str = "1.0.0",
|
|
99
|
+
user_id: str = "") -> dict:
|
|
100
|
+
row = {
|
|
101
|
+
"model_code": model_code,
|
|
102
|
+
"name": name,
|
|
103
|
+
"user_id": user_id,
|
|
104
|
+
"description": description,
|
|
105
|
+
"content": self._jsonify(content),
|
|
106
|
+
"version": version,
|
|
107
|
+
}
|
|
108
|
+
created = self.client.insert(self.table, row)
|
|
109
|
+
return created[0] if created else row
|
|
110
|
+
|
|
111
|
+
def update(self, template_id: str, patch: dict) -> dict:
|
|
112
|
+
values = {"updated_at": "now()"}
|
|
113
|
+
mapping = {"name": "name", "desc": "description",
|
|
114
|
+
"description": "description", "version": "version",
|
|
115
|
+
"model_code": "model_code", "modelCode": "model_code",
|
|
116
|
+
"sort_index": "sort_index"}
|
|
117
|
+
for k, col in mapping.items():
|
|
118
|
+
if k in patch and patch[k] is not None:
|
|
119
|
+
values[col] = patch[k]
|
|
120
|
+
if "content" in patch:
|
|
121
|
+
values["content"] = self._jsonify(patch["content"])
|
|
122
|
+
rows = self.client.update(self.table,
|
|
123
|
+
{"id": f"eq.{template_id}"}, values)
|
|
124
|
+
if not rows:
|
|
125
|
+
raise KeyError(f"template not found: {template_id}")
|
|
126
|
+
return rows[0]
|
|
127
|
+
|
|
128
|
+
def upsert(self, model_code: str, name: str, content=None,
|
|
129
|
+
user_id: str = "", **kw) -> dict:
|
|
130
|
+
rows, _ = self.client.select(self.table, {
|
|
131
|
+
"model_code": f"eq.{model_code}", "name": f"eq.{name}",
|
|
132
|
+
"user_id": f"eq.{user_id}", "limit": "1"})
|
|
133
|
+
if rows:
|
|
134
|
+
return self.update(rows[0]["id"], {"content": content, **kw})
|
|
135
|
+
return self.create(model_code, name, content=content,
|
|
136
|
+
user_id=user_id, **kw)
|
|
137
|
+
|
|
138
|
+
def delete(self, template_id: str) -> bool:
|
|
139
|
+
return self.client.delete(self.table,
|
|
140
|
+
{"id": f"eq.{template_id}"}) > 0
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _jsonify(content):
|
|
144
|
+
if content is None:
|
|
145
|
+
return {}
|
|
146
|
+
if isinstance(content, str):
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(content)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return {"raw": content}
|
|
151
|
+
return content
|