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:
2026-03-25 01:30:05 +00:00
parent 7b3d763291
commit cf130c4174
3 changed files with 312 additions and 1 deletions

View File

@@ -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/<collection_slug>"
: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/<collection_slug>/<slug>"
: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/<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")))))

View File

@@ -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)

View File

@@ -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),
}