mirror of https://github.com/jlelse/GoBlog
424 lines
12 KiB
Go
424 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-fed/httpsig"
|
|
)
|
|
|
|
var (
|
|
apPrivateKey *rsa.PrivateKey
|
|
apPostSigner httpsig.Signer
|
|
apPostSignMutex *sync.Mutex = &sync.Mutex{}
|
|
webfingerResources map[string]*configBlog
|
|
webfingerAccts map[string]string
|
|
)
|
|
|
|
func initActivityPub() error {
|
|
if !appConfig.ActivityPub.Enabled {
|
|
return nil
|
|
}
|
|
// Add hooks
|
|
postHooks[postPostHook] = append(postHooks[postPostHook], func(p *post) {
|
|
if p.isPublishedSectionPost() {
|
|
p.apPost()
|
|
}
|
|
})
|
|
postHooks[postUpdateHook] = append(postHooks[postUpdateHook], func(p *post) {
|
|
if p.isPublishedSectionPost() {
|
|
p.apUpdate()
|
|
}
|
|
})
|
|
postHooks[postDeleteHook] = append(postHooks[postDeleteHook], func(p *post) {
|
|
p.apDelete()
|
|
})
|
|
// Prepare webfinger
|
|
webfingerResources = map[string]*configBlog{}
|
|
webfingerAccts = map[string]string{}
|
|
for name, blog := range appConfig.Blogs {
|
|
acct := "acct:" + name + "@" + appConfig.Server.publicHostname
|
|
webfingerResources[acct] = blog
|
|
webfingerResources[blog.apIri()] = blog
|
|
webfingerAccts[blog.apIri()] = acct
|
|
}
|
|
// Read key and prepare signing
|
|
pkfile, err := os.ReadFile(appConfig.ActivityPub.KeyPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
privateKeyDecoded, _ := pem.Decode(pkfile)
|
|
if privateKeyDecoded == nil {
|
|
return errors.New("failed to decode private key")
|
|
}
|
|
apPrivateKey, err = x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
|
digestAlgorithm := httpsig.DigestSha256
|
|
headersToSign := []string{httpsig.RequestTarget, "date", "host", "digest"}
|
|
apPostSigner, _, err = httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Init send queue
|
|
if err = initAPSendQueue(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
|
|
blog, ok := webfingerResources[r.URL.Query().Get("resource")]
|
|
if !ok {
|
|
serveError(w, r, "Resource not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"subject": webfingerAccts[blog.apIri()],
|
|
"aliases": []string{
|
|
webfingerAccts[blog.apIri()],
|
|
blog.apIri(),
|
|
},
|
|
"links": []map[string]string{
|
|
{
|
|
"rel": "self",
|
|
"type": contentTypeAS,
|
|
"href": blog.apIri(),
|
|
},
|
|
{
|
|
"rel": "http://webfinger.net/rel/profile-page",
|
|
"type": "text/html",
|
|
"href": blog.apIri(),
|
|
},
|
|
},
|
|
})
|
|
w.Header().Set(contentType, "application/jrd+json"+charsetUtf8Suffix)
|
|
_, _ = writeMinified(w, contentTypeJSON, b)
|
|
}
|
|
|
|
func apHandleInbox(w http.ResponseWriter, r *http.Request) {
|
|
blogName := chi.URLParam(r, "blog")
|
|
blog := appConfig.Blogs[blogName]
|
|
if blog == nil {
|
|
serveError(w, r, "Inbox not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
blogIri := blog.apIri()
|
|
// Verify request
|
|
requestActor, requestKey, requestActorStatus, err := apVerifySignature(r)
|
|
if err != nil {
|
|
// Send 401 because signature could not be verified
|
|
serveError(w, r, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if requestActorStatus != 0 {
|
|
if requestActorStatus == http.StatusGone || requestActorStatus == http.StatusNotFound {
|
|
u, err := url.Parse(requestKey)
|
|
if err == nil {
|
|
u.Fragment = ""
|
|
u.RawFragment = ""
|
|
_ = apRemoveFollower(blogName, u.String())
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
}
|
|
serveError(w, r, "Error when trying to get request actor", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Parse activity
|
|
activity := map[string]interface{}{}
|
|
err = json.NewDecoder(r.Body).Decode(&activity)
|
|
_ = r.Body.Close()
|
|
if err != nil {
|
|
serveError(w, r, "Failed to decode body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Get and check activity actor
|
|
activityActor, ok := activity["actor"].(string)
|
|
if !ok {
|
|
serveError(w, r, "actor in activity is no string", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if activityActor != requestActor.ID {
|
|
serveError(w, r, "Request actor isn't activity actor", http.StatusForbidden)
|
|
return
|
|
}
|
|
// Do
|
|
switch activity["type"] {
|
|
case "Follow":
|
|
apAccept(blogName, blog, activity)
|
|
case "Undo":
|
|
{
|
|
if object, ok := activity["object"].(map[string]interface{}); ok {
|
|
if objectType, ok := object["type"].(string); ok && objectType == "Follow" {
|
|
if iri, ok := object["actor"].(string); ok && iri == activityActor {
|
|
_ = apRemoveFollower(blogName, activityActor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case "Create":
|
|
{
|
|
if object, ok := activity["object"].(map[string]interface{}); ok {
|
|
inReplyTo, hasReplyToString := object["inReplyTo"].(string)
|
|
id, hasID := object["id"].(string)
|
|
if hasReplyToString && hasID && len(inReplyTo) > 0 && len(id) > 0 && strings.Contains(inReplyTo, blogIri) {
|
|
// It's an ActivityPub reply; save reply as webmention
|
|
_ = createWebmention(id, inReplyTo)
|
|
} else if content, hasContent := object["content"].(string); hasContent && hasID && len(id) > 0 {
|
|
// May be a mention; find links to blog and save them as webmentions
|
|
if links, err := allLinksFromHTML(strings.NewReader(content), id); err == nil {
|
|
for _, link := range links {
|
|
if strings.Contains(link, blogIri) {
|
|
_ = createWebmention(id, link)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case "Delete":
|
|
case "Block":
|
|
{
|
|
if object, ok := activity["object"].(string); ok && len(object) > 0 && object == activityActor {
|
|
_ = apRemoveFollower(blogName, activityActor)
|
|
}
|
|
}
|
|
case "Like":
|
|
{
|
|
likeObject, likeObjectOk := activity["object"].(string)
|
|
if likeObjectOk && len(likeObject) > 0 && strings.Contains(likeObject, blogIri) {
|
|
sendNotification(fmt.Sprintf("%s liked %s", activityActor, likeObject))
|
|
}
|
|
}
|
|
case "Announce":
|
|
{
|
|
announceObject, announceObjectOk := activity["object"].(string)
|
|
if announceObjectOk && len(announceObject) > 0 && strings.Contains(announceObject, blogIri) {
|
|
sendNotification(fmt.Sprintf("%s announced %s", activityActor, announceObject))
|
|
}
|
|
}
|
|
}
|
|
// Return 200
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func apVerifySignature(r *http.Request) (*asPerson, string, int, error) {
|
|
verifier, err := httpsig.NewVerifier(r)
|
|
if err != nil {
|
|
// Error with signature header etc.
|
|
return nil, "", 0, err
|
|
}
|
|
keyID := verifier.KeyId()
|
|
actor, statusCode, err := apGetRemoteActor(keyID)
|
|
if err != nil || actor == nil || statusCode != 0 {
|
|
// Actor not found or something else bad
|
|
return nil, keyID, statusCode, err
|
|
}
|
|
if actor.PublicKey == nil || actor.PublicKey.PublicKeyPem == "" {
|
|
return nil, keyID, 0, errors.New("Actor has no public key")
|
|
}
|
|
block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem))
|
|
if block == nil {
|
|
return nil, keyID, 0, errors.New("Public key invalid")
|
|
}
|
|
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
// Unable to parse public key
|
|
return nil, keyID, 0, err
|
|
}
|
|
return actor, keyID, 0, verifier.Verify(pubKey, httpsig.RSA_SHA256)
|
|
}
|
|
|
|
func handleWellKnownHostMeta(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(contentType, "application/xrd+xml"+charsetUtf8Suffix)
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="https://` + r.Host + `/.well-known/webfinger?resource={uri}"/></XRD>`))
|
|
}
|
|
|
|
func apGetRemoteActor(iri string) (*asPerson, int, error) {
|
|
req, err := http.NewRequest(http.MethodGet, iri, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Accept", contentTypeAS)
|
|
req.Header.Set(userAgent, appUserAgent)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if !apRequestIsSuccess(resp.StatusCode) {
|
|
return nil, resp.StatusCode, nil
|
|
}
|
|
actor := &asPerson{}
|
|
err = json.NewDecoder(resp.Body).Decode(actor)
|
|
defer resp.Body.Close()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return actor, 0, nil
|
|
}
|
|
|
|
func apGetAllInboxes(blog string) ([]string, error) {
|
|
rows, err := appDbQuery("select distinct inbox from activitypub_followers where blog = @blog", sql.Named("blog", blog))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inboxes := []string{}
|
|
for rows.Next() {
|
|
var inbox string
|
|
err = rows.Scan(&inbox)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inboxes = append(inboxes, inbox)
|
|
}
|
|
return inboxes, nil
|
|
}
|
|
|
|
func apAddFollower(blog, follower, inbox string) error {
|
|
_, err := appDbExec("insert or replace into activitypub_followers (blog, follower, inbox) values (@blog, @follower, @inbox)", sql.Named("blog", blog), sql.Named("follower", follower), sql.Named("inbox", inbox))
|
|
return err
|
|
}
|
|
|
|
func apRemoveFollower(blog, follower string) error {
|
|
_, err := appDbExec("delete from activitypub_followers where blog = @blog and follower = @follower", sql.Named("blog", blog), sql.Named("follower", follower))
|
|
return err
|
|
}
|
|
|
|
func apRemoveInbox(inbox string) error {
|
|
_, err := appDbExec("delete from activitypub_followers where inbox = @inbox", sql.Named("inbox", inbox))
|
|
return err
|
|
}
|
|
|
|
func (p *post) apPost() {
|
|
n := p.toASNote()
|
|
apSendToAllFollowers(p.Blog, map[string]interface{}{
|
|
"@context": asContext,
|
|
"actor": appConfig.Blogs[p.Blog].apIri(),
|
|
"id": p.fullURL(),
|
|
"published": n.Published,
|
|
"type": "Create",
|
|
"object": n,
|
|
})
|
|
if n.InReplyTo != "" {
|
|
// Is reply, so announce it
|
|
time.Sleep(30 * time.Second)
|
|
p.apAnnounce()
|
|
}
|
|
}
|
|
|
|
func (p *post) apUpdate() {
|
|
apSendToAllFollowers(p.Blog, map[string]interface{}{
|
|
"@context": asContext,
|
|
"actor": appConfig.Blogs[p.Blog].apIri(),
|
|
"id": p.fullURL(),
|
|
"published": time.Now().Format("2006-01-02T15:04:05-07:00"),
|
|
"type": "Update",
|
|
"object": p.toASNote(),
|
|
})
|
|
}
|
|
|
|
func (p *post) apAnnounce() {
|
|
apSendToAllFollowers(p.Blog, map[string]interface{}{
|
|
"@context": asContext,
|
|
"actor": appConfig.Blogs[p.Blog].apIri(),
|
|
"id": p.fullURL() + "#announce",
|
|
"published": p.toASNote().Published,
|
|
"type": "Announce",
|
|
"object": p.fullURL(),
|
|
})
|
|
}
|
|
|
|
func (p *post) apDelete() {
|
|
apSendToAllFollowers(p.Blog, map[string]interface{}{
|
|
"@context": asContext,
|
|
"actor": appConfig.Blogs[p.Blog].apIri(),
|
|
"id": p.fullURL() + "#delete",
|
|
"type": "Delete",
|
|
"object": map[string]string{
|
|
"id": p.fullURL(),
|
|
"type": "Tombstone",
|
|
},
|
|
})
|
|
}
|
|
|
|
func apAccept(blogName string, blog *configBlog, follow map[string]interface{}) {
|
|
// it's a follow, write it down
|
|
newFollower := follow["actor"].(string)
|
|
log.Println("New follow request:", newFollower)
|
|
// check we aren't following ourselves
|
|
if newFollower == follow["object"] {
|
|
// actor and object are equal
|
|
return
|
|
}
|
|
follower, status, err := apGetRemoteActor(newFollower)
|
|
if err != nil || status != 0 {
|
|
// Couldn't retrieve remote actor info
|
|
log.Println("Failed to retrieve remote actor info:", newFollower)
|
|
return
|
|
}
|
|
// Add or update follower
|
|
inbox := follower.Inbox
|
|
if endpoints := follower.Endpoints; endpoints != nil && endpoints.SharedInbox != "" {
|
|
inbox = endpoints.SharedInbox
|
|
}
|
|
if err = apAddFollower(blogName, follower.ID, inbox); err != nil {
|
|
return
|
|
}
|
|
// remove @context from the inner activity
|
|
delete(follow, "@context")
|
|
accept := map[string]interface{}{
|
|
"@context": asContext,
|
|
"to": follow["actor"],
|
|
"actor": blog.apIri(),
|
|
"object": follow,
|
|
"type": "Accept",
|
|
}
|
|
_, accept["id"] = apNewID(blog)
|
|
_ = apQueueSendSigned(blog.apIri(), follower.Inbox, accept)
|
|
}
|
|
|
|
func apSendToAllFollowers(blog string, activity interface{}) {
|
|
inboxes, err := apGetAllInboxes(blog)
|
|
if err != nil {
|
|
log.Println("Failed to retrieve inboxes:", err.Error())
|
|
return
|
|
}
|
|
apSendTo(appConfig.Blogs[blog].apIri(), activity, inboxes)
|
|
}
|
|
|
|
func apSendTo(blogIri string, activity interface{}, inboxes []string) {
|
|
for _, i := range inboxes {
|
|
go func(inbox string) {
|
|
_ = apQueueSendSigned(blogIri, inbox, activity)
|
|
}(i)
|
|
}
|
|
}
|
|
|
|
func apNewID(blog *configBlog) (hash string, url string) {
|
|
return hash, blog.apIri() + generateRandomString(16)
|
|
}
|
|
|
|
func (b *configBlog) apIri() string {
|
|
return appConfig.Server.PublicAddress + b.Path
|
|
}
|
|
|
|
func apRequestIsSuccess(code int) bool {
|
|
return code == http.StatusOK || code == http.StatusCreated || code == http.StatusAccepted || code == http.StatusNoContent
|
|
}
|