diff --git a/activitypub.go b/activitypub.go index 6a3b0a1..323bf90 100644 --- a/activitypub.go +++ b/activitypub.go @@ -22,6 +22,7 @@ import ( "net/http/httputil" "net/url" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -45,6 +46,11 @@ const ( apCacheTime = time.Minute ) +var ( + apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$") + apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]{12,16})$") +) + var instanceColl *Collection func initActivityPub(app *App) { @@ -351,11 +357,76 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request a := streams.NewAccept() p := c.PersonObject() var to *url.URL - var isFollow, isUnfollow bool + var isFollow, isUnfollow, isLike bool + var likePostID string fullActor := &activitystreams.Person{} var remoteUser *RemoteUser res := &streams.Resolver{ + LikeCallback: func(l *streams.Like) error { + isLike = true + + // 1) Use the Like concrete type here + // 2) Errors are propagated to res.Deserialize call below + m["@context"] = []string{activitystreams.Namespace} + b, _ := json.Marshal(m) + if debugging { + log.Info("Like: %s", b) + } + + _, likeID := l.GetId() + if likeID == nil { + log.Error("Didn't resolve Like ID") + } + if p := l.HasObject(0); p == streams.NoPresence { + return fmt.Errorf("no object for Like activity at index 0") + } + + obj := l.Raw().GetObjectIRI(0) + /* + // TODO: handle this more robustly + l.ResolveObject(&streams.Resolver{ + LinkCallback: func(link *streams.Link) error { + return nil + }, + }, 0) + */ + + // Get post ID from URL + var collAlias, slug string + if m := apCollectionPostIRIRegex.FindStringSubmatch(obj.String()); len(m) == 3 { + collAlias = m[1] + slug = m[2] + } else if m = apDraftPostIRIRegex.FindStringSubmatch(obj.String()); len(m) == 2 { + likePostID = m[1] + } else { + return fmt.Errorf("unable to match objectIRI: %s", obj) + } + + // Get postID if all we have is collection and slug + if collAlias != "" && slug != "" { + c, err := app.db.GetCollection(collAlias) + if err != nil { + return err + } + p, err := app.db.GetPost(slug, c.ID) + if err != nil { + return err + } + likePostID = p.ID + } + + // Finally, get actor information + _, from := l.GetActor(0) + if from == nil { + return fmt.Errorf("No valid actor string") + } + fullActor, remoteUser, err = getActor(app, from.String()) + if err != nil { + return err + } + return nil + }, FollowCallback: func(f *streams.Follow) error { isFollow = true @@ -435,6 +506,48 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request return err } + // Handle synchronous activities + if isLike { + t, err := app.db.Begin() + if err != nil { + log.Error("Unable to start transaction: %v", err) + return fmt.Errorf("unable to start transaction: %v", err) + } + + var remoteUserID int64 + if remoteUser != nil { + remoteUserID = remoteUser.ID + } else { + remoteUserID, err = apAddRemoteUser(app, t, fullActor) + } + + // Add like + _, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, NOW())", likePostID, remoteUserID) + if err != nil { + if !app.db.isDuplicateKeyErr(err) { + t.Rollback() + log.Error("Couldn't add like in DB: %v\n", err) + return fmt.Errorf("Couldn't add like in DB: %v", err) + } else { + t.Rollback() + log.Error("Couldn't add like in DB: %v\n", err) + return fmt.Errorf("Couldn't add like in DB: %v", err) + } + } + + err = t.Commit() + if err != nil { + t.Rollback() + log.Error("Rolling back after Commit(): %v\n", err) + return fmt.Errorf("Rolling back after Commit(): %v\n", err) + } + + if debugging { + log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL) + } + return impart.RenderActivityJSON(w, "", http.StatusOK) + } + go func() { if to == nil { if debugging { @@ -469,6 +582,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request if remoteUser != nil { followerID = remoteUser.ID } else { + // TODO: use apAddRemoteUser() here, instead! // Add follower locally, since it wasn't found before res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) if err != nil { diff --git a/database_activitypub.go b/database_activitypub.go new file mode 100644 index 0000000..9df3724 --- /dev/null +++ b/database_activitypub.go @@ -0,0 +1,49 @@ +/* + * Copyright © 2024 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "database/sql" + "fmt" + "github.com/writeas/web-core/activitystreams" + "github.com/writeas/web-core/log" +) + +func apAddRemoteUser(app *App, t *sql.Tx, fullActor *activitystreams.Person) (int64, error) { + // Add remote user locally, since it wasn't found before + res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL) + if err != nil { + t.Rollback() + return -1, fmt.Errorf("couldn't add new remoteuser in DB: %v", err) + } + + remoteUserID, err := res.LastInsertId() + if err != nil { + t.Rollback() + return -1, fmt.Errorf("no lastinsertid for followers, rolling back: %v", err) + } + + // Add in key + _, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, remoteUserID, fullActor.PublicKey.PublicKeyPEM) + if err != nil { + if !app.db.isDuplicateKeyErr(err) { + t.Rollback() + log.Error("Couldn't add follower keys in DB: %v\n", err) + return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err) + } else { + t.Rollback() + log.Error("Couldn't add follower keys in DB: %v\n", err) + return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err) + } + } + + return remoteUserID, nil +} diff --git a/migrations/migrations.go b/migrations/migrations.go index fc638ee..6b5b094 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -71,6 +71,7 @@ var migrations = []Migration{ New("support newsletters", supportLetters), // V12 -> V13 New("support password resetting", supportPassReset), // V13 -> V14 New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15 + New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0) } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v16.go b/migrations/v16.go new file mode 100644 index 0000000..03ce78a --- /dev/null +++ b/migrations/v16.go @@ -0,0 +1,38 @@ +/* + * Copyright © 2024 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportRemoteLikes(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE remote_likes ( + post_id ` + db.typeChar(16) + ` NOT NULL, + remote_user_id ` + db.typeInt() + ` NOT NULL, + created ` + db.typeDateTime() + ` NOT NULL, + PRIMARY KEY (post_id,remote_user_id) +)`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +}