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.
Files changed (81) hide show
  1. golive/__init__.py +3 -0
  2. golive/backends/__init__.py +1 -0
  3. golive/backends/auth/__init__.py +1 -0
  4. golive/backends/auth/base.py +17 -0
  5. golive/backends/auth/none.py +12 -0
  6. golive/backends/auth/oauth.py +330 -0
  7. golive/backends/auth/presets.py +84 -0
  8. golive/backends/auth/token.py +49 -0
  9. golive/backends/data/__init__.py +5 -0
  10. golive/backends/data/supabase.py +151 -0
  11. golive/backends/factory.py +51 -0
  12. golive/backends/images/__init__.py +15 -0
  13. golive/backends/images/base.py +34 -0
  14. golive/backends/images/command.py +139 -0
  15. golive/backends/images/s3.py +88 -0
  16. golive/backends/postgrest.py +138 -0
  17. golive/backends/registry/__init__.py +1 -0
  18. golive/backends/registry/sqlite_store.py +202 -0
  19. golive/backends/registry/supabase_store.py +184 -0
  20. golive/backends/storage/__init__.py +1 -0
  21. golive/backends/storage/local.py +103 -0
  22. golive/backends/storage/s3.py +195 -0
  23. golive/backends/storage/supabase_store.py +242 -0
  24. golive/cli.py +791 -0
  25. golive/config.py +490 -0
  26. golive/core/__init__.py +1 -0
  27. golive/core/audit_log.py +277 -0
  28. golive/core/bundle.py +945 -0
  29. golive/core/cdn_localizer.py +222 -0
  30. golive/core/clone_analyzer.py +562 -0
  31. golive/core/clone_fetcher.py +920 -0
  32. golive/core/clone_patcher.py +543 -0
  33. golive/core/clone_site.py +315 -0
  34. golive/core/code_safety_checker.py +364 -0
  35. golive/core/css_style_enhancer.py +659 -0
  36. golive/core/data_role_tagger.py +456 -0
  37. golive/core/http_client.py +82 -0
  38. golive/core/keymap_rules.py +143 -0
  39. golive/core/migrate_check.py +189 -0
  40. golive/core/paths.py +71 -0
  41. golive/core/preview_server.py +780 -0
  42. golive/core/publish_utils.py +189 -0
  43. golive/core/slug_checker.py +150 -0
  44. golive/inject/__init__.py +29 -0
  45. golive/inject/_escape.py +72 -0
  46. golive/inject/editor.py +434 -0
  47. golive/inject/supabase_api.py +309 -0
  48. golive/inject/template_api.py +431 -0
  49. golive/inject/watermark.py +239 -0
  50. golive/resources/css_styles/apple.css +184 -0
  51. golive/resources/css_styles/bloomberg.css +208 -0
  52. golive/resources/css_styles/carbon.css +145 -0
  53. golive/resources/css_styles/cowork.css +184 -0
  54. golive/resources/css_styles/cyberpunk.css +269 -0
  55. golive/resources/css_styles/dreamy.css +143 -0
  56. golive/resources/css_styles/earthy.css +143 -0
  57. golive/resources/css_styles/fresh.css +143 -0
  58. golive/resources/css_styles/glass.css +157 -0
  59. golive/resources/css_styles/ink.css +308 -0
  60. golive/resources/css_styles/macaron.css +143 -0
  61. golive/resources/css_styles/minimal.css +197 -0
  62. golive/resources/css_styles/morandi.css +142 -0
  63. golive/resources/css_styles/newspaper.css +317 -0
  64. golive/resources/css_styles/palace.css +345 -0
  65. golive/resources/css_styles/steampunk.css +215 -0
  66. golive/resources/css_styles/vivid.css +152 -0
  67. golive/resources/css_styles/xhs-fun.css +209 -0
  68. golive/resources/css_styles/xhs.css +183 -0
  69. golive/security/__init__.py +1 -0
  70. golive/security/ai_review.py +252 -0
  71. golive/security/rules.yaml +94 -0
  72. golive/security/scanner.py +328 -0
  73. golive/server/__init__.py +1 -0
  74. golive/server/app.py +395 -0
  75. golive/server/editor_api.py +166 -0
  76. html_golive-0.4.1.dist-info/METADATA +324 -0
  77. html_golive-0.4.1.dist-info/RECORD +81 -0
  78. html_golive-0.4.1.dist-info/WHEEL +5 -0
  79. html_golive-0.4.1.dist-info/entry_points.txt +2 -0
  80. html_golive-0.4.1.dist-info/licenses/LICENSE +21 -0
  81. html_golive-0.4.1.dist-info/top_level.txt +1 -0
golive/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """html-golive — self-hosted one-command HTML deployment."""
2
+
3
+ __version__ = "0.4.1"
@@ -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,5 @@
1
+ """golive.backends.data — data-layer backends (TemplateAPI storage).
2
+
3
+ ``none`` (default): no data layer; pages calling TemplateAPI get a stub.
4
+ ``supabase``: templates stored in a PostgREST table (golive_templates).
5
+ """
@@ -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