From 0979f847e926c94cf281a20bb61376b79295671b Mon Sep 17 00:00:00 2001 From: Michael Demetriou Date: Sat, 14 Sep 2019 11:12:15 +0300 Subject: [PATCH] Add /following endpoint, move followers and following endpoints to /peers/{follow*}, move posts to /items/{hash}, implement unfollow --- TODO | 10 +-- activityserve/actor.go | 155 +++++++++++++++++++++++++++++++---------- activityserve/http.go | 39 +++++++++-- 3 files changed, 158 insertions(+), 46 deletions(-) diff --git a/TODO b/TODO index 079d091..f0ab480 100644 --- a/TODO +++ b/TODO @@ -1,10 +1,10 @@ [✔] Follow users -[ ] Announcements +[✔] Announcements [✔] Federate the post to our followers (hardcoded for now) [✔] Handle more than one local actors [✔] Handle the /actor endpoint [✔] Create configuration file -[ ] Implement database backend +[✔] Implement database backend [✔] Create a file with the actors we have, their following and their followers. [✔] `MakeActor` should create a file with that actor. @@ -12,7 +12,7 @@ [✔] `actor.Follow` should write the new following to file [✔] Handle being followed [✔] When followed, the handler should write the new follower to file - [ ] Make sure we send our boosts to all our followers + [✔] Make sure we send our boosts to all our followers [ ] Write incoming activities to disk (do we have to?) [ ] Write all the announcements (boosts) to the database to their correct actors @@ -33,7 +33,7 @@ (waiting to actually get an accept so that I can test this) [✔] Implement webfinger [✔] Make sure masto finds signature -[ ] Implement Unfollow +[✔] Implement Unfollow [✔] Implement accept (accept when other follow us) (done but can't test it pending http signatures) Works in pleroma/pixelfed not working on masto @@ -50,5 +50,5 @@ [ ] Remove them? [ ] Leave them read-only? [ ] Leave them as is? -[ ] Handle followers and following uri's +[✔] Handle followers and following uri's [ ] Do I care about the inbox? \ No newline at end of file diff --git a/activityserve/actor.go b/activityserve/actor.go index e6740ed..d7191b4 100644 --- a/activityserve/actor.go +++ b/activityserve/actor.go @@ -40,7 +40,7 @@ type Actor struct { publicKeyPem string privateKeyPem string publicKeyID string - OnFollow func(map[string]interface{}) + OnFollow func(map[string]interface{}) } // ActorToSave is a stripped down actor representation @@ -78,9 +78,9 @@ func MakeActor(name, summary, actorType string) (Actor, error) { followersIRI: followersIRI, publicKeyID: publicKeyID, } - + // set auto accept by default (this could be a configuration value) - actor.OnFollow = func(activity map[string]interface{}) {actor.Accept(activity)} + actor.OnFollow = func(activity map[string]interface{}) { actor.Accept(activity) } // create actor's keypair rng := rand.Reader @@ -252,9 +252,8 @@ func (a *Actor) whoAmI() string { "summary": "` + a.summary + `", "inbox": "` + baseURL + a.name + `/inbox", "outbox": "` + baseURL + a.name + `/outbox", - "followers": "` + baseURL + a.name + `/followers", - "following": "` + baseURL + a.name + `/following", - "liked": "` + baseURL + a.name + `/liked", + "followers": "` + baseURL + a.name + `/peers/followers", + "following": "` + baseURL + a.name + `/peers/following", "publicKey": { "id": "` + baseURL + a.name + `#main-key", "owner": "` + baseURL + a.name + `", @@ -263,33 +262,39 @@ func (a *Actor) whoAmI() string { }` } -func (a *Actor) newIDhash() string { - return uniuri.New() -} - -func (a *Actor) newIDurl() string { - return baseURL + a.name + "/" + a.newIDhash() +func (a *Actor) newID() (hash string, url string) { + hash = uniuri.New() + return hash, baseURL + a.name + "/item/" + hash } // TODO Reply(content string, inReplyTo string) -// CreateNote posts an activityPub note to our followers -func (a *Actor) CreateNote(content string) { - // for now I will just write this to the outbox +// ReplyNote sends a note to a specific actor in reply to +// a post +//TODO - hash := a.newIDhash() +// DM sends a direct message to a user +// TODO + +// CreateNote posts an activityPub note to our followers +// +func (a *Actor) CreateNote(content, inReplyTo string) { + // for now I will just write this to the outbox + hash, id := a.newID() create := make(map[string]interface{}) note := make(map[string]interface{}) create["@context"] = context() create["actor"] = baseURL + a.name create["cc"] = a.followersIRI - create["id"] = baseURL + a.name + "/" + hash + create["id"] = id create["object"] = note note["attributedTo"] = baseURL + a.name note["cc"] = a.followersIRI note["content"] = content - // note["inReplyTo"] = "https://cybre.space/@qwazix/102688373602724023" - note["id"] = baseURL + a.name + "/note/" + hash + if inReplyTo != "" { + note["inReplyTo"] = inReplyTo + } + note["id"] = id note["published"] = time.Now().Format(time.RFC3339) note["url"] = create["id"] note["type"] = "Note" @@ -301,7 +306,7 @@ func (a *Actor) CreateNote(content string) { if err != nil { log.Info("Could not save note to disk") } - err = a.appendToOutbox(baseURL + a.name + "/" + hash) + err = a.appendToOutbox(id) if err != nil { log.Info("Could not append Note to outbox.txt") } @@ -345,33 +350,52 @@ func (a *Actor) send(content map[string]interface{}, to *url.URL) (err error) { return a.signedHTTPPost(content, to.String()) } -// GetFollowers returns a list of people that follow us -func (a *Actor) GetFollowers(page int) (response []byte, err error) { +// getPeers gets followers or following depending on `who` +func (a *Actor) getPeers(page int, who string) (response []byte, err error) { // if there's no page parameter mastodon displays an // OrderedCollection with info of where to find orderedCollectionPages // with the actual information. We are mirroring that behavior + + var collection map[string]interface{} + if who == "followers" { + collection = a.followers + } else if who == "following" { + collection = a.following + } else { + return nil, errors.New("cannot find collection" + who) + } themap := make(map[string]interface{}) themap["@context"] = "https://www.w3.org/ns/activitystreams" if page == 0 { - themap["first"] = baseURL + a.name + "/followers?page=1" - themap["id"] = baseURL + a.name + "/followers" - themap["totalItems"] = strconv.Itoa(len(a.followers)) + themap["first"] = baseURL + a.name + "/" + who + "?page=1" + themap["id"] = baseURL + a.name + "/" + who + themap["totalItems"] = strconv.Itoa(len(collection)) themap["type"] = "OrderedCollection" } else if page == 1 { // implement pagination - themap["id"] = baseURL + a.name + "followers?page=" + strconv.Itoa(page) - items := make([]string, 0, len(a.followers)) - for k := range a.followers { + themap["id"] = baseURL + a.name + who + "?page=" + strconv.Itoa(page) + items := make([]string, 0, len(collection)) + for k := range collection { items = append(items, k) } themap["orderedItems"] = items - themap["partOf"] = baseURL + a.name + "/followers" - themap["totalItems"] = len(a.followers) + themap["partOf"] = baseURL + a.name + "/" + who + themap["totalItems"] = len(collection) themap["type"] = "OrderedCollectionPage" } response, _ = json.Marshal(themap) return } +// GetFollowers returns a list of people that follow us +func (a *Actor) GetFollowers(page int) (response []byte, err error) { + return a.getPeers(page, "followers") +} + +// GetFollowing returns a list of people that we follow +func (a *Actor) GetFollowing(page int) (response []byte, err error) { + return a.getPeers(page, "following") +} + func (a *Actor) signedHTTPPost(content map[string]interface{}, to string) (err error) { b, err := json.Marshal(content) if err != nil { @@ -531,11 +555,11 @@ func (a *Actor) Follow(user string) (err error) { } follow := make(map[string]interface{}) - id := a.newIDhash() + _, id := a.newID() follow["@context"] = context() follow["actor"] = a.iri - follow["id"] = baseURL + a.name + "/" + id + follow["id"] = id follow["object"] = user follow["type"] = "Follow" @@ -561,13 +585,69 @@ func (a *Actor) Follow(user string) (err error) { return nil } +// Unfollow the user declared by the iri in `user` +// this recreates the original follow activity +// , wraps it in an Undo activity, sets it's +// id to the id of the original Follow activity that +// was accepted when initially following that user +// (this is read from the `actor.following` map +func (a *Actor) Unfollow(user string){ + log.Info("Unfollowing " + user) + + // create an undo activiy + undo := make(map[string]interface{}) + undo["@context"] = context() + undo["actor"] = a.iri + + // find the id of the original follow + hash := a.following[user].(string) + + follow := make(map[string]interface{}) + + follow["@context"] = context() + follow["actor"] = a.iri + follow["id"] = baseURL + "/item/" + hash + follow["object"] = user + follow["type"] = "Follow" + + // add the properties to the undo activity + undo["object"] = follow + + // get the remote user's inbox + remoteUser, err := NewRemoteActor(user) + if err != nil { + log.Info("Failed to contact remote actor") + return + } + + // only if we're already following them + if _, ok := a.following[user]; ok { + PrettyPrint(undo) + go func() { + err := a.signedHTTPPost(remoteUser.inbox, undo) + if err != nil { + log.Info("Couldn't unfollow " + user) + log.Info(err) + return + } + // if there was no error then delete the follow + // from the list + delete(a.following, user) + a.save() + }() + } +} + // Announce this activity to our followers func (a *Actor) Announce(url string) { // our announcements are public. Public stuff have a "To" to the url below toURL := "https://www.w3.org/ns/activitystreams#Public" + id, hash := a.newID() + announce := make(map[string]interface{}) announce["@context"] = context() + announce["id"] = id announce["object"] = url announce["actor"] = a.name announce["to"] = toURL @@ -583,6 +663,8 @@ func (a *Actor) Announce(url string) { // add a timestamp announce["published"] = time.Now().Format(time.RFC3339) + a.appendToOutbox(announce["id"].(string)) + a.saveItem(hash, announce) a.sendToFollowers(announce) } @@ -595,7 +677,7 @@ func (a *Actor) followersSlice() []string { } // Accept a follow request -func (a *Actor) Accept(follow map[string]interface{}){ +func (a *Actor) Accept(follow map[string]interface{}) { // it's a follow, write it down newFollower := follow["actor"].(string) // check we aren't following ourselves @@ -623,7 +705,7 @@ func (a *Actor) Accept(follow map[string]interface{}){ accept["@context"] = "https://www.w3.org/ns/activitystreams" accept["to"] = follow["actor"] - accept["id"] = a.newIDurl() + accept["id"], _ = a.newID() accept["actor"] = a.iri accept["object"] = follow accept["type"] = "Accept" @@ -633,6 +715,7 @@ func (a *Actor) Accept(follow map[string]interface{}){ log.Info(err) } + // Maybe we need to save this accept? go a.signedHTTPPost(accept, follower.inbox) - -} \ No newline at end of file + +} diff --git a/activityserve/http.go b/activityserve/http.go index 8379dd9..9bdb4d7 100644 --- a/activityserve/http.go +++ b/activityserve/http.go @@ -237,12 +237,17 @@ func Serve() { default: } - } - var followersHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + var peersHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/activity+json; charset=utf-8") username := mux.Vars(r)["actor"] + collection := mux.Vars(r)["peers"] + if collection != "followers" && collection != "following" { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 - No such collection")) + return + } actor, err := LoadActor(username) // error out if this actor does not exist if err != nil { @@ -256,21 +261,45 @@ func Serve() { } else { page, _ = strconv.Atoi(pageS) } - response, _ := actor.GetFollowers(page) + response, _ := actor.getPeers(page, collection) w.Write(response) } + var postHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/activity+json; charset=utf-8") + username := mux.Vars(r)["actor"] + hash := mux.Vars(r)["hash"] + actor, err := LoadActor(username) + // error out if this actor does not exist + if err != nil { + log.Info("Can't create local actor") + return + } + post, err := actor.loadItem(hash) + if err != nil { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "404 - post not found") + return + } + postJSON, err := json.Marshal(post) + if err!= nil{ + log.Info("failed to marshal json from item " + hash + " text") + return + } + w.Write(postJSON) + } + // Add the handlers to a HTTP server gorilla := mux.NewRouter() gorilla.HandleFunc("/.well-known/webfinger", webfingerHandler) - gorilla.HandleFunc("/{actor}/followers", followersHandler) + gorilla.HandleFunc("/{actor}/peers/{peers}", peersHandler) gorilla.HandleFunc("/{actor}/outbox", outboxHandler) gorilla.HandleFunc("/{actor}/outbox/", outboxHandler) gorilla.HandleFunc("/{actor}/inbox", inboxHandler) gorilla.HandleFunc("/{actor}/inbox/", inboxHandler) gorilla.HandleFunc("/{actor}/", actorHandler) gorilla.HandleFunc("/{actor}", actorHandler) - // gorilla.HandleFunc("/{actor}/post/{hash}", postHandler) + gorilla.HandleFunc("/{actor}/item/{hash}", postHandler) http.Handle("/", gorilla) log.Fatal(http.ListenAndServe(":8081", nil))