opengraph_cards.py
python
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