gabriel / musehub public
opengraph_cards.py python
255 lines 8.3 KB
Raw
sha256:7d5985ef251de9f0154f9b185a75cf36174bf73e0c34f5eca133e7bd20224bf4 Merge branch 'feat/opengraph-repo-cards' into dev Human 23 days ago
1 """Open Graph repo card — data collection, content hashing, and PNG cache.
2
3 musehub#129: dynamic ``og:image`` PNGs for public repo pages. Cards are
4 content-addressed in the blob store (``objects/sha256:{hash}``) and
5 regenerated only when visible metadata changes.
6 """
7
8 from __future__ import annotations
9
10 import asyncio
11 import base64
12 import hashlib
13 import json
14 import logging
15 from dataclasses import dataclass
16
17 from sqlalchemy import func, select as sa_select
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from musehub.auth.request_signing import MSignContext
21 from musehub.db.musehub_identity_models import MusehubIdentity
22 from musehub.db.musehub_social_models import MusehubIssue
23 from musehub.services import musehub_repository
24 from musehub.services.opengraph_renderer import get_opengraph_renderer
25 from musehub.services.spectral_sigil import generate_sigil
26 from musehub.storage.backends import get_backend
27
28 logger = logging.getLogger(__name__)
29
30 # Bump when the HTML/CSS template changes to bust all cached PNGs.
31 LAYOUT_VERSION: int = 1
32
33 # Minimal valid 1×1 PNG for stub renders and test fixtures.
34 _STUB_PNG: bytes = base64.b64decode(
35 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
36 )
37
38 _render_locks: dict[str, asyncio.Lock] = {}
39
40
41 @dataclass(frozen=True)
42 class OpenGraphCardContext:
43 """Inputs that affect the visible OG card — all fields feed the content hash."""
44
45 owner: str
46 repo_slug: str
47 description: str
48 domain_id: str | None
49 total_commits: int
50 fork_count: int
51 open_issue_count: int
52 owner_identity_hex: str
53 identity_type: str
54 avatar_png_b64: str
55
56
57 def description_fallback(owner: str) -> str:
58 """Default description when the repo has none (matches ui_repo.py)."""
59 return f"Music composition repository by {owner}"
60
61
62 def hash_payload_from_context(ctx: OpenGraphCardContext) -> dict[str, object]:
63 """Return canonical dict hashed for cache keys and ``?cb=`` bust params."""
64 return {
65 "v": 1,
66 "owner": ctx.owner,
67 "slug": ctx.repo_slug,
68 "description": ctx.description,
69 "domain_id": ctx.domain_id or "",
70 "total_commits": ctx.total_commits,
71 "fork_count": ctx.fork_count,
72 "open_issue_count": ctx.open_issue_count,
73 "owner_identity_hex": ctx.owner_identity_hex,
74 "identity_type": ctx.identity_type,
75 "layout_version": LAYOUT_VERSION,
76 }
77
78
79 def compute_content_hash(ctx: OpenGraphCardContext) -> str:
80 """SHA-256 hex digest of canonical card-visible metadata."""
81 payload = hash_payload_from_context(ctx)
82 canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
83 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
84
85
86 def cache_object_id(content_hash_hex: str) -> str:
87 """BlobBackend object_id for a cached OG PNG."""
88 return f"sha256:{content_hash_hex}"
89
90
91 def cache_bust_token(content_hash_hex: str, length: int = 12) -> str:
92 """Short token for ``?cb=`` on og:image URLs (crawler cache bust)."""
93 return content_hash_hex[:length]
94
95
96 def build_opengraph_image_url_from_request(
97 request_url_base: str,
98 owner: str,
99 repo_slug: str,
100 content_hash_hex: str,
101 public_url: str,
102 allowed_hosts: set[str] | frozenset[str],
103 request_hostname: str | None,
104 ) -> str:
105 """Build og:image URL using request host when allowlisted, else ``public_url``."""
106 cb = cache_bust_token(content_hash_hex)
107 path = f"/{owner}/{repo_slug}/opengraph-image?cb={cb}"
108 if request_hostname and request_hostname in allowed_hosts:
109 base = request_url_base.rstrip("/")
110 else:
111 base = public_url.rstrip("/")
112 return f"{base}{path}"
113
114
115 async def resolve_owner_avatar(owner: str, db: AsyncSession) -> tuple[str, str, str]:
116 """Return (identity_hex, identity_type, avatar_png_b64) for the owner handle."""
117 import hashlib as _hashlib
118
119 result = await db.execute(
120 sa_select(MusehubIdentity).where(
121 MusehubIdentity.handle == owner,
122 MusehubIdentity.deleted_at.is_(None),
123 )
124 )
125 identity = result.scalar_one_or_none()
126 if identity is not None:
127 hex_id = identity.identity_id.split(":", 1)[-1]
128 identity_type = identity.identity_type
129 else:
130 hex_id = _hashlib.sha256(owner.encode()).hexdigest()
131 identity_type = "agent"
132
133 svg_bytes = generate_sigil(
134 identity_id=hex_id,
135 handle=owner,
136 identity_type=identity_type,
137 domain_counts={},
138 )
139 avatar_png_b64 = _rasterize_sigil_b64(svg_bytes)
140 return hex_id, identity_type, avatar_png_b64
141
142
143 def _rasterize_sigil_b64(svg_bytes: bytes) -> str:
144 """Rasterize sigil SVG to a base64 PNG string for HTML embedding."""
145 try:
146 import cairosvg
147
148 png = cairosvg.svg2png(bytestring=svg_bytes, output_width=80, output_height=80)
149 return base64.b64encode(png).decode("ascii")
150 except Exception:
151 logger.exception("opengraph: cairosvg rasterize failed; using empty avatar")
152 return base64.b64encode(_STUB_PNG).decode("ascii")
153
154
155 async def collect_repo_card_context(
156 db: AsyncSession,
157 owner: str,
158 repo_slug: str,
159 claims: MSignContext | None,
160 ) -> OpenGraphCardContext | None:
161 """Build card context for a repo, or None when the repo is not accessible."""
162 repo = await musehub_repository.get_repo_by_owner_slug(db, owner, repo_slug)
163 if repo is None:
164 return None
165 if repo.visibility != "public" and claims is None:
166 return None
167
168 repo_id = repo.repo_id
169 ref = await musehub_repository.resolve_head_ref(db, repo_id)
170
171 stats, forks, issue_count, avatar = await asyncio.gather(
172 musehub_repository.get_repo_home_stats(db, repo_id, ref),
173 musehub_repository.list_repo_forks_flat(db, repo_id),
174 db.execute(
175 sa_select(func.count()).select_from(MusehubIssue).where(
176 MusehubIssue.repo_id == repo_id,
177 MusehubIssue.state == "open",
178 )
179 ),
180 resolve_owner_avatar(owner, db),
181 )
182 identity_hex, identity_type, avatar_b64 = avatar
183 open_issues = issue_count.scalar_one_or_none() or 0
184 desc = (repo.description or "").strip() or description_fallback(owner)
185
186 return OpenGraphCardContext(
187 owner=owner,
188 repo_slug=repo_slug,
189 description=desc,
190 domain_id=repo.domain_id,
191 total_commits=int(stats.get("total_commits") or 0),
192 fork_count=forks.total,
193 open_issue_count=open_issues,
194 owner_identity_hex=identity_hex,
195 identity_type=identity_type,
196 avatar_png_b64=avatar_b64,
197 )
198
199
200 def build_card_context_from_repo_page(
201 *,
202 owner: str,
203 repo_slug: str,
204 description: str,
205 domain_id: str | None,
206 total_commits: int,
207 fork_count: int,
208 open_issue_count: int,
209 owner_identity_hex: str,
210 identity_type: str,
211 avatar_png_b64: str,
212 ) -> OpenGraphCardContext:
213 """Construct context from values already gathered by repo_page (avoids duplicate queries)."""
214 desc = (description or "").strip() or description_fallback(owner)
215 return OpenGraphCardContext(
216 owner=owner,
217 repo_slug=repo_slug,
218 description=desc,
219 domain_id=domain_id,
220 total_commits=total_commits,
221 fork_count=fork_count,
222 open_issue_count=open_issue_count,
223 owner_identity_hex=owner_identity_hex,
224 identity_type=identity_type,
225 avatar_png_b64=avatar_png_b64,
226 )
227
228
229 async def get_or_create_repo_card_png(ctx: OpenGraphCardContext) -> tuple[bytes, str]:
230 """Return (png_bytes, content_hash_hex), using blob cache with singleflight render."""
231 content_hash = compute_content_hash(ctx)
232 object_id = cache_object_id(content_hash)
233 backend = get_backend()
234
235 cached = await backend.get(object_id)
236 if cached:
237 return cached, content_hash
238
239 lock = _render_locks.setdefault(content_hash, asyncio.Lock())
240 async with lock:
241 cached = await backend.get(object_id)
242 if cached:
243 return cached, content_hash
244
245 renderer = get_opengraph_renderer()
246 png = await renderer.render(ctx)
247 await backend.put(object_id, png)
248 return png, content_hash
249
250
251 async def shutdown_opengraph_renderer() -> None:
252 """Release Playwright browser resources on app shutdown."""
253 from musehub.services.opengraph_renderer import shutdown_renderer
254
255 await shutdown_renderer()
File History 1 commit
sha256:7d5985ef251de9f0154f9b185a75cf36174bf73e0c34f5eca133e7bd20224bf4 Merge branch 'feat/opengraph-repo-cards' into dev Human 23 days ago