sx-pub Phase 2: publish to IPFS, browse collections, resolve by path + CID
New endpoints: - POST /pub/publish — pin SX content to IPFS, store path→CID in DB - GET /pub/browse/<collection> — list published documents - GET /pub/doc/<collection>/<slug> — resolve path to CID, fetch from IPFS - GET /pub/cid/<cid> — direct CID fetch (immutable, cache forever) New helpers: pub-publish, pub-collection-items, pub-resolve-document, pub-fetch-cid Tested: published stdlib.sx (6.9KB) → QmQQyR3Ltqi5sFiwZh5dutPbAM4QsEBnw419RyNnTj4fFM Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,10 @@ def _register_sx_helpers() -> None:
|
||||
"pub-actor-data": _pub_actor_data,
|
||||
"pub-collections-data": _pub_collections_data,
|
||||
"pub-status-data": _pub_status_data,
|
||||
"pub-publish": _pub_publish,
|
||||
"pub-collection-items": _pub_collection_items,
|
||||
"pub-resolve-document": _pub_resolve_document,
|
||||
"pub-fetch-cid": _pub_fetch_cid,
|
||||
})
|
||||
|
||||
|
||||
@@ -1740,3 +1744,23 @@ async def _pub_collections_data():
|
||||
async def _pub_status_data():
|
||||
from .pub_helpers import check_status
|
||||
return await check_status()
|
||||
|
||||
|
||||
async def _pub_publish(collection, slug, content, title="", summary=""):
|
||||
from .pub_helpers import publish_document
|
||||
return await publish_document(collection, slug, content, title, summary)
|
||||
|
||||
|
||||
async def _pub_collection_items(collection_slug):
|
||||
from .pub_helpers import collection_items
|
||||
return await collection_items(collection_slug)
|
||||
|
||||
|
||||
async def _pub_resolve_document(collection_slug, slug):
|
||||
from .pub_helpers import resolve_document
|
||||
return await resolve_document(collection_slug, slug)
|
||||
|
||||
|
||||
async def _pub_fetch_cid(cid):
|
||||
from .pub_helpers import fetch_cid
|
||||
return await fetch_cid(cid)
|
||||
|
||||
@@ -142,3 +142,171 @@ async def check_status() -> dict[str, Any]:
|
||||
status["healthy"] = "false"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Publishing + Browsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def publish_document(collection_slug: str, slug: str, content: str,
|
||||
title: str = "", summary: str = "") -> dict[str, Any]:
|
||||
"""Pin SX content to IPFS and store in DB. Returns doc info dict."""
|
||||
import hashlib
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.sx_pub import SxPubCollection, SxPubDocument
|
||||
from shared.utils.ipfs_client import add_bytes
|
||||
|
||||
# Resolve collection
|
||||
result = await g.s.execute(
|
||||
select(SxPubCollection).where(SxPubCollection.slug == collection_slug)
|
||||
)
|
||||
collection = result.scalar_one_or_none()
|
||||
if collection is None:
|
||||
return {"error": f"Collection not found: {collection_slug}"}
|
||||
|
||||
# Hash content
|
||||
content_bytes = content.encode("utf-8")
|
||||
content_hash = hashlib.sha3_256(content_bytes).hexdigest()
|
||||
|
||||
# Pin to IPFS
|
||||
try:
|
||||
cid = await add_bytes(content_bytes, pin=True)
|
||||
except Exception as e:
|
||||
logger.error("IPFS pin failed for %s/%s: %s", collection_slug, slug, e)
|
||||
return {"error": f"IPFS pin failed: {e}"}
|
||||
|
||||
# Upsert document
|
||||
result = await g.s.execute(
|
||||
select(SxPubDocument).where(
|
||||
SxPubDocument.collection_id == collection.id,
|
||||
SxPubDocument.slug == slug,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
|
||||
if doc is None:
|
||||
doc = SxPubDocument(
|
||||
collection_id=collection.id,
|
||||
slug=slug,
|
||||
title=title or slug,
|
||||
summary=summary,
|
||||
content_hash=content_hash,
|
||||
ipfs_cid=cid,
|
||||
size_bytes=len(content_bytes),
|
||||
status="published",
|
||||
)
|
||||
g.s.add(doc)
|
||||
else:
|
||||
doc.content_hash = content_hash
|
||||
doc.ipfs_cid = cid
|
||||
doc.size_bytes = len(content_bytes)
|
||||
doc.status = "published"
|
||||
if title:
|
||||
doc.title = title
|
||||
if summary:
|
||||
doc.summary = summary
|
||||
|
||||
await g.s.flush()
|
||||
logger.info("Published %s/%s → %s (%d bytes)", collection_slug, slug, cid, len(content_bytes))
|
||||
|
||||
return {
|
||||
"path": f"/pub/{collection_slug}/{slug}",
|
||||
"cid": cid,
|
||||
"hash": content_hash,
|
||||
"size": len(content_bytes),
|
||||
"collection": collection_slug,
|
||||
"slug": slug,
|
||||
"title": doc.title or slug,
|
||||
}
|
||||
|
||||
|
||||
async def collection_items(collection_slug: str) -> dict[str, Any]:
|
||||
"""List published documents in a collection."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.sx_pub import SxPubCollection, SxPubDocument
|
||||
|
||||
result = await g.s.execute(
|
||||
select(SxPubCollection).where(SxPubCollection.slug == collection_slug)
|
||||
)
|
||||
collection = result.scalar_one_or_none()
|
||||
if collection is None:
|
||||
return {"error": f"Collection not found: {collection_slug}"}
|
||||
|
||||
result = await g.s.execute(
|
||||
select(SxPubDocument).where(
|
||||
SxPubDocument.collection_id == collection.id,
|
||||
SxPubDocument.status == "published",
|
||||
).order_by(SxPubDocument.slug)
|
||||
)
|
||||
docs = result.scalars().all()
|
||||
|
||||
return {
|
||||
"collection": collection_slug,
|
||||
"name": collection.name,
|
||||
"description": collection.description or "",
|
||||
"items": [
|
||||
{
|
||||
"slug": d.slug,
|
||||
"title": d.title or d.slug,
|
||||
"summary": d.summary or "",
|
||||
"cid": d.ipfs_cid or "",
|
||||
"size": d.size_bytes or 0,
|
||||
}
|
||||
for d in docs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def resolve_document(collection_slug: str, slug: str) -> dict[str, Any]:
|
||||
"""Resolve a document path to its content via IPFS."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.sx_pub import SxPubCollection, SxPubDocument
|
||||
from shared.utils.ipfs_client import get_bytes
|
||||
|
||||
result = await g.s.execute(
|
||||
select(SxPubCollection).where(SxPubCollection.slug == collection_slug)
|
||||
)
|
||||
collection = result.scalar_one_or_none()
|
||||
if collection is None:
|
||||
return {"error": "not-found"}
|
||||
|
||||
result = await g.s.execute(
|
||||
select(SxPubDocument).where(
|
||||
SxPubDocument.collection_id == collection.id,
|
||||
SxPubDocument.slug == slug,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None or not doc.ipfs_cid:
|
||||
return {"error": "not-found"}
|
||||
|
||||
content_bytes = await get_bytes(doc.ipfs_cid)
|
||||
if content_bytes is None:
|
||||
return {"error": "ipfs-unavailable"}
|
||||
|
||||
return {
|
||||
"slug": doc.slug,
|
||||
"title": doc.title or doc.slug,
|
||||
"summary": doc.summary or "",
|
||||
"cid": doc.ipfs_cid,
|
||||
"collection": collection_slug,
|
||||
"content": content_bytes.decode("utf-8", errors="replace"),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_cid(cid: str) -> dict[str, Any]:
|
||||
"""Fetch raw content from IPFS by CID."""
|
||||
from shared.utils.ipfs_client import get_bytes
|
||||
|
||||
content_bytes = await get_bytes(cid)
|
||||
if content_bytes is None:
|
||||
return {"error": "not-found"}
|
||||
|
||||
return {
|
||||
"cid": cid,
|
||||
"content": content_bytes.decode("utf-8", errors="replace"),
|
||||
"size": len(content_bytes),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user