New endpoints: - GET /pub/outbox — paginated activity feed - POST /pub/inbox — receive Follow/Accept/Publish from remote servers - POST /pub/follow — follow a remote sx-pub server - GET /pub/followers — list accepted followers - GET /pub/following — list who we follow Federation mechanics: - HTTP Signature generation (RSA-SHA256) for signed outgoing requests - HTTP Signature verification for incoming requests - Auto-accept Follow → store follower → send Accept back - Accept handling → update following state - Publish mirroring → pin remote CID to local IPFS - deliver_to_followers → fan out signed activities to all follower inboxes - Publish now records activity in outbox for federation delivery Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
Plaintext
340 lines
12 KiB
Plaintext
;; ==========================================================================
|
|
;; sx-pub API endpoints — actor, webfinger, collections, publishing, browsing
|
|
;;
|
|
;; All responses are text/sx. No JSON.
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Actor
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-actor
|
|
:path "/pub/actor"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((actor (helper "pub-actor-data")))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(str
|
|
"(SxActor"
|
|
"\n :id \"https://" (get actor "domain") "/pub/actor\""
|
|
"\n :type \"SxPublisher\""
|
|
"\n :name \"" (get actor "display-name") "\""
|
|
"\n :summary \"" (get actor "summary") "\""
|
|
"\n :inbox \"/pub/inbox\""
|
|
"\n :outbox \"/pub/outbox\""
|
|
"\n :followers \"/pub/followers\""
|
|
"\n :following \"/pub/following\""
|
|
"\n :public-key-pem \"" (get actor "public-key-pem") "\")"))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Webfinger
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-webfinger
|
|
:path "/pub/webfinger"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((resource (helper "request-arg" "resource" ""))
|
|
(actor (helper "pub-actor-data")))
|
|
(let ((expected (str "acct:" (get actor "preferred-username") "@" (get actor "domain"))))
|
|
(if (!= resource expected)
|
|
(do
|
|
(set-response-status 404)
|
|
(str "(Error :message \"Resource not found\")"))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(str
|
|
"(SxWebfinger"
|
|
"\n :subject \"" expected "\""
|
|
"\n :actor \"https://" (get actor "domain") "/pub/actor\""
|
|
"\n :type \"SxPublisher\")"))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Collections
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-collections
|
|
:path "/pub/collections"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((collections (helper "pub-collections-data")))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(let ((items (map (fn (c)
|
|
(str "\n (SxCollection"
|
|
" :slug \"" (get c "slug") "\""
|
|
" :name \"" (get c "name") "\""
|
|
" :description \"" (get c "description") "\""
|
|
" :href \"/pub/" (get c "slug") "\")"))
|
|
collections)))
|
|
(str "(SxCollections" (join "" items) ")")))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Status
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-status
|
|
:path "/pub/status"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((status (helper "pub-status-data")))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(str
|
|
"(SxPubStatus"
|
|
"\n :healthy " (get status "healthy")
|
|
"\n :db \"" (get status "db") "\""
|
|
"\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")))))
|
|
|
|
|
|
;; ==========================================================================
|
|
;; Phase 3: Federation — outbox, inbox, follow, followers, following
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Outbox
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-outbox
|
|
:path "/pub/outbox"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((page (helper "request-arg" "page" ""))
|
|
(data (helper "pub-outbox-data" page)))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(let ((items (map (fn (a)
|
|
(str "\n (" (get a "type")
|
|
" :object-type \"" (get a "object-type") "\""
|
|
" :published \"" (get a "published") "\""
|
|
" :cid \"" (get a "cid") "\")"))
|
|
(get data "items"))))
|
|
(str
|
|
"(SxOutbox"
|
|
"\n :total " (get data "total")
|
|
"\n :page " (get data "page")
|
|
(join "" items) ")")))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Inbox
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-inbox
|
|
:path "/pub/inbox"
|
|
:method :post
|
|
:csrf false
|
|
:returns "element"
|
|
(&key)
|
|
(let ((body (helper "pub-request-body")))
|
|
(if (= body "")
|
|
(do
|
|
(set-response-status 400)
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
"(Error :message \"Empty body\")")
|
|
(let ((result (helper "pub-process-inbox" body)))
|
|
(do
|
|
(set-response-status 202)
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(str "(Accepted :result " (str result) ")"))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Follow a remote server
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-follow
|
|
:path "/pub/follow"
|
|
:method :post
|
|
:csrf false
|
|
:returns "element"
|
|
(&key)
|
|
(let ((actor-url (helper "request-form" "actor_url" "")))
|
|
(if (= actor-url "")
|
|
(do
|
|
(set-response-status 400)
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
"(Error :message \"Missing actor_url\")")
|
|
(let ((result (helper "pub-follow-remote" actor-url)))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(if (get result "error")
|
|
(do
|
|
(set-response-status 502)
|
|
(str "(Error :message \"" (get result "error") "\")"))
|
|
(str
|
|
"(FollowSent"
|
|
"\n :actor-url \"" (get result "actor-url") "\""
|
|
"\n :status \"" (get result "status") "\")")))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Followers
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-followers
|
|
:path "/pub/followers"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((data (helper "pub-followers-data")))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(let ((items (map (fn (f)
|
|
(str "\n (SxFollower"
|
|
" :acct \"" (get f "acct") "\""
|
|
" :actor-url \"" (get f "actor-url") "\")"))
|
|
data)))
|
|
(str "(SxFollowers" (join "" items) ")")))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Following
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defhandler pub-following
|
|
:path "/pub/following"
|
|
:method :get
|
|
:returns "element"
|
|
(&key)
|
|
(let ((data (helper "pub-following-data")))
|
|
(do
|
|
(set-response-header "Content-Type" "text/sx; charset=utf-8")
|
|
(let ((items (map (fn (f)
|
|
(str "\n (SxFollowing"
|
|
" :actor-url \"" (get f "actor-url") "\")"))
|
|
data)))
|
|
(str "(SxFollowing" (join "" items) ")")))))
|