diff --git a/sx/sx/handlers/pub-api.sx b/sx/sx/handlers/pub-api.sx index cee64460..11c8a47a 100644 --- a/sx/sx/handlers/pub-api.sx +++ b/sx/sx/handlers/pub-api.sx @@ -1,5 +1,5 @@ ;; ========================================================================== -;; sx-pub Phase 1 API endpoints — actor, webfinger, collections, status +;; sx-pub API endpoints — actor, webfinger, collections, publishing, browsing ;; ;; All responses are text/sx. No JSON. ;; ========================================================================== @@ -96,3 +96,122 @@ "\n :ipfs \"" (get status "ipfs") "\"" "\n :actor \"" (get status "actor") "\"" "\n :domain \"" (or (get status "domain") "unknown") "\")")))) + + +;; ========================================================================== +;; Phase 2: Publishing + Browsing +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Publish +;; -------------------------------------------------------------------------- + +(defhandler pub-publish + :path "/pub/publish" + :method :post + :csrf false + :returns "element" + (&key) + (let ((collection (helper "request-form" "collection" "")) + (slug (helper "request-form" "slug" "")) + (content (helper "request-form" "content" "")) + (title (helper "request-form" "title" "")) + (summary (helper "request-form" "summary" ""))) + (if (or (= collection "") (= slug "") (= content "")) + (do + (set-response-status 400) + (set-response-header "Content-Type" "text/sx; charset=utf-8") + "(Error :message \"Missing collection, slug, or content\")") + (let ((result (helper "pub-publish" collection slug content title summary))) + (if (get result "error") + (do + (set-response-status 500) + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (str "(Error :message \"" (get result "error") "\")")) + (do + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (str + "(Published" + "\n :path \"" (get result "path") "\"" + "\n :cid \"" (get result "cid") "\"" + "\n :hash \"" (get result "hash") "\"" + "\n :size " (get result "size") + "\n :collection \"" (get result "collection") "\"" + "\n :slug \"" (get result "slug") "\"" + "\n :title \"" (get result "title") "\")"))))))) + + +;; -------------------------------------------------------------------------- +;; Browse collection +;; -------------------------------------------------------------------------- + +(defhandler pub-browse-collection + :path "/pub/browse/" + :method :get + :returns "element" + (&key collection_slug) + (let ((data (helper "pub-collection-items" collection_slug))) + (if (get data "error") + (do + (set-response-status 404) + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (str "(Error :message \"" (get data "error") "\")")) + (do + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (let ((items (map (fn (d) + (str "\n (SxDocument" + " :slug \"" (get d "slug") "\"" + " :title \"" (get d "title") "\"" + " :summary \"" (get d "summary") "\"" + " :cid \"" (get d "cid") "\"" + " :size " (get d "size") ")")) + (get data "items")))) + (str + "(SxCollection" + "\n :slug \"" (get data "collection") "\"" + "\n :name \"" (get data "name") "\"" + "\n :description \"" (get data "description") "\"" + (join "" items) ")")))))) + + +;; -------------------------------------------------------------------------- +;; Resolve document by path +;; -------------------------------------------------------------------------- + +(defhandler pub-document + :path "/pub/doc//" + :method :get + :returns "element" + (&key collection_slug slug) + (let ((data (helper "pub-resolve-document" collection_slug slug))) + (if (get data "error") + (do + (set-response-status 404) + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (str "(Error :message \"" (get data "error") "\")")) + (do + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (set-response-header "X-IPFS-CID" (get data "cid")) + (get data "content"))))) + + +;; -------------------------------------------------------------------------- +;; Direct CID fetch +;; -------------------------------------------------------------------------- + +(defhandler pub-cid + :path "/pub/cid/" + :method :get + :returns "element" + (&key cid) + (let ((data (helper "pub-fetch-cid" cid))) + (if (get data "error") + (do + (set-response-status 404) + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (str "(Error :message \"" (get data "error") "\")")) + (do + (set-response-header "Content-Type" "text/sx; charset=utf-8") + (set-response-header "Cache-Control" "public, max-age=31536000, immutable") + (get data "content"))))) diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index c8874f9b..9ecbb368 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -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) diff --git a/sx/sxc/pages/pub_helpers.py b/sx/sxc/pages/pub_helpers.py index 3fd19a5b..48ed7c42 100644 --- a/sx/sxc/pages/pub_helpers.py +++ b/sx/sxc/pages/pub_helpers.py @@ -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), + }