diff --git a/TODO b/TODO index 9f11405..7577b40 100644 --- a/TODO +++ b/TODO @@ -39,8 +39,8 @@ [ ] Create debug flag [ ] Write to following only upon accept (waiting to actually get an accept so that I can test this) -[ ] Implement webfinger -[ ] Make sure masto finds signature +[✔] Implement webfinger +[✔] Make sure masto finds signature [ ] Implement Unfollow [ ] Implement accept (accept when other follow us) (done but can't test it pending http signatures) @@ -65,6 +65,4 @@ [ ] Check if an early failure in announcing posts causes a problem to the following ones [ ] Handle followers and following uri's [ ] Do I care about the inbox? -[ ] Maybe look at implementing lock files? -[ ] Check if it's worth it to reuse pubActor instead of creating - a new one every time \ No newline at end of file +[ ] Maybe look at implementing lock files? \ No newline at end of file diff --git a/activityserve/actor.go b/activityserve/actor.go index 117644d..4f1076b 100644 --- a/activityserve/actor.go +++ b/activityserve/actor.go @@ -30,16 +30,16 @@ import ( // Actor represents a local actor we can act on // behalf of. type Actor struct { - name, summary, actorType, iri string - followersIRI string - nuIri *url.URL - followers, following map[string]interface{} - posts map[int]map[string]interface{} - publicKey crypto.PublicKey - privateKey crypto.PrivateKey - publicKeyPem string - privateKeyPem string - publicKeyID string + name, summary, actorType, iri string + followersIRI string + nuIri *url.URL + followers, following, rejected map[string]interface{} + posts map[int]map[string]interface{} + publicKey crypto.PublicKey + privateKey crypto.PrivateKey + publicKeyPem string + privateKeyPem string + publicKeyID string } // ActorToSave is a stripped down actor representation @@ -48,7 +48,7 @@ type Actor struct { // see https://stackoverflow.com/questions/26327391/json-marshalstruct-returns type ActorToSave struct { Name, Summary, ActorType, IRI, PublicKey, PrivateKey string - Followers, Following map[string]interface{} + Followers, Following, Rejected map[string]interface{} } // MakeActor returns a new local actor we can act @@ -56,6 +56,7 @@ type ActorToSave struct { func MakeActor(name, summary, actorType string) (Actor, error) { followers := make(map[string]interface{}) following := make(map[string]interface{}) + rejected := make(map[string]interface{}) followersIRI := baseURL + name + "/followers" publicKeyID := baseURL + name + "#main-key" iri := baseURL + name @@ -72,6 +73,7 @@ func MakeActor(name, summary, actorType string) (Actor, error) { nuIri: nuIri, followers: followers, following: following, + rejected: rejected, followersIRI: followersIRI, publicKeyID: publicKeyID, } @@ -183,6 +185,7 @@ func LoadActor(name string) (Actor, error) { nuIri: nuIri, followers: jsonData["Followers"].(map[string]interface{}), following: jsonData["Following"].(map[string]interface{}), + rejected: jsonData["Rejected"].(map[string]interface{}), publicKey: publicKey, privateKey: privateKey, publicKeyPem: jsonData["PublicKey"].(string), @@ -215,6 +218,7 @@ func (a *Actor) save() error { IRI: a.iri, Followers: a.followers, Following: a.following, + Rejected: a.rejected, PublicKey: a.publicKeyPem, PrivateKey: a.privateKeyPem, } @@ -263,6 +267,8 @@ func (a *Actor) newIDurl() string { return baseURL + a.name + "/" + a.newIDhash() } +// 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 @@ -278,7 +284,7 @@ func (a *Actor) CreateNote(content string) { note["attributedTo"] = baseURL + a.name note["cc"] = a.followersIRI note["content"] = content - note["inReplyTo"] = "https://cybre.space/@qwazix/102688373602724023" + // note["inReplyTo"] = "https://cybre.space/@qwazix/102688373602724023" note["id"] = baseURL + a.name + "/note/" + id note["published"] = time.Now().Format(time.RFC3339) note["url"] = create["id"] @@ -286,16 +292,24 @@ func (a *Actor) CreateNote(content string) { note["to"] = "https://www.w3.org/ns/activitystreams#Public" create["published"] = note["published"] create["type"] = "Create" - to, _ := url.Parse("https://cybre.space/inbox") - go a.send(create, to) - a.saveItem(id, create) + go a.sendToFollowers(create) + err := a.saveItem(id, create) + if err != nil { + log.Info("Could not save note to disk") + } + err = a.appendToOutbox(id) + if err != nil { + log.Info("Could not append Note to outbox.txt") + } } -func (a *Actor) saveItem(id string, content map[string]interface{}) error { +// saveItem saves an activity to disk under the actor and with the id as +// filename +func (a *Actor) saveItem(hash string, content map[string]interface{}) error { JSON, _ := json.MarshalIndent(content, "", "\t") dir := storage + slash + "actors" + slash + a.name + slash + "items" - err := ioutil.WriteFile(dir+slash+id+".json", JSON, 0644) + err := ioutil.WriteFile(dir+slash+hash+".json", JSON, 0644) if err != nil { log.Printf("WriteFileJson ERROR: %+v", err) return err @@ -303,6 +317,24 @@ func (a *Actor) saveItem(id string, content map[string]interface{}) error { return nil } +func (a *Actor) loadItem(hash string) (item map[string]interface{}, err error) { + dir := storage + slash + "actors" + slash + a.name + slash + "items" + jsonFile := dir + slash + hash + ".json" + fileHandle, err := os.Open(jsonFile) + if os.IsNotExist(err) { + log.Info("We don't have this item stored") + return + } + byteValue, err := ioutil.ReadAll(fileHandle) + if err != nil { + log.Info("Error reading item file") + return + } + json.Unmarshal(byteValue, &item) + + return +} + // send is here for backward compatibility and maybe extra pre-processing // not always required func (a *Actor) send(content map[string]interface{}, to *url.URL) (err error) { @@ -328,7 +360,7 @@ func (a *Actor) GetFollowers(page int) (response []byte, err error) { items = append(items, k) } themap["orderedItems"] = items - themap["partOf"] = baseURL + a.name + "/followers" + themap["partOf"] = baseURL + a.name + "/followers" themap["totalItems"] = len(a.followers) themap["type"] = "OrderedCollectionPage" } @@ -363,7 +395,7 @@ func (a *Actor) signedHTTPPost(content map[string]interface{}, to string) (err e } req.Header.Add("Accept-Charset", "utf-8") req.Header.Add("Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT") - req.Header.Add("User-Agent", userAgent + " " + version) + req.Header.Add("User-Agent", userAgent+" "+version) req.Header.Add("Host", iri.Host) req.Header.Add("Accept", "application/activity+json") sum := sha256.Sum256(b) @@ -441,7 +473,119 @@ func (a *Actor) signedHTTPGet(address string) (string, error) { } // NewFollower records a new follower to the actor file -func (a *Actor) NewFollower(iri string) error { - a.followers[iri] = struct{}{} +func (a *Actor) NewFollower(iri string, inbox string) error { + a.followers[iri] = inbox return a.save() } + +func (a *Actor) appendToOutbox(iri string) (err error) { + // create outbox file if it doesn't exist + var outbox *os.File + + outboxFilePath := storage + slash + "actors" + slash + a.name + slash + "outbox" + outbox, err = os.OpenFile(outboxFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Info("Cannot create or open outbox file") + log.Info(err) + return err + } + defer outbox.Close() + + outbox.Write([]byte(iri)) + + return nil +} + +func (a *Actor) batchSend(activity map[string]interface{}, recipients []string) (err error) { + for _, v := range recipients { + err := a.signedHTTPPost(activity, v) + if err != nil { + log.Info("Failed to deliver message to " + v) + } + } + return +} + +func (a *Actor) sendToFollowers(activity map[string]interface{}) (err error) { + recipients := make([]string, len(a.followers)) + + i := 0 + for _, inbox := range a.followers { + recipients[i] = inbox.(string) + i++ + } + a.batchSend(activity, recipients) + return +} + +// Follow a remote user by their iri +func (a *Actor) Follow(user string) (err error) { + remote, err := NewRemoteActor(user) + if err != nil { + log.Info("Can't contact " + user + " to get their inbox") + return + } + + follow := make(map[string]interface{}) + id := a.newIDhash() + + follow["@context"] = context() + follow["actor"] = a.iri + follow["id"] = baseURL + a.name + "/" + id + follow["object"] = user + follow["type"] = "Follow" + + // if we are not already following them + if _, ok := a.following[user]; !ok { + // if we have not been rejected previously + if _, ok := a.rejected[user]; !ok { + go func() { + err := a.signedHTTPPost(follow, remote.inbox) + if err != nil { + log.Info("Couldn't follow " + user) + log.Info(err) + return + } + // save the activity + a.saveItem(id, follow) + // we are going to save only on accept so look at + // the http handler for the accept code + }() + } + } + + return nil +} + +// 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" + announce := make(map[string]interface{}) + + announce["@context"] = context() + announce["object"] = url + announce["actor"] = a.name + announce["to"] = toURL + + // cc this to all our followers one by one + // I've seen activities to just include the url of the + // collection but for now this works. + + // It seems that sharedInbox will be deprecated + // so this is probably a better idea anyway (#APConf) + announce["cc"] = a.followersSlice() + + // add a timestamp + announce["published"] = time.Now().Format(time.RFC3339) + + a.sendToFollowers(announce) +} + +func (a *Actor) followersSlice() []string { + followersSlice := make([]string, len(a.followers)) + for k := range a.followers { + followersSlice = append(followersSlice, k) + } + return followersSlice +} diff --git a/activityserve/http.go b/activityserve/http.go index 396786e..5208bb3 100644 --- a/activityserve/http.go +++ b/activityserve/http.go @@ -176,12 +176,14 @@ func Serve() { return } + follower, err := NewRemoteActor(activity["actor"].(string)) + // check if this user is already following us if _, ok := actor.followers[newFollower]; ok { log.Info("You're already following us, yay!") // do nothing, they're already following us } else { - actor.NewFollower(newFollower) + actor.NewFollower(newFollower, follower.inbox) } // send accept anyway even if they are following us already // this is very verbose. I would prefer creating a map by hand @@ -198,7 +200,6 @@ func Serve() { accept["object"] = activity accept["type"] = "Accept" - follower, err := NewRemoteActor(activity["actor"].(string)) if err != nil { log.Info("Couldn't retrieve remote actor info, maybe server is down?") @@ -207,6 +208,44 @@ func Serve() { go actor.signedHTTPPost(accept, follower.inbox) + case "Accept": + acceptor := activity["actor"].(string) + actor, err := LoadActor(mux.Vars(r)["actor"]) // load the actor from disk + if err != nil { + log.Error("No such actor") + return + } + + follow := activity["object"].(map[string]interface{}) + id := follow["id"].(string) + + // check if the object of the follow is us + if follow["actor"].(string) != baseURL+actor.name { + log.Info("This is not for us, ignoring") + return + } + // try to get the hash only + hash := strings.Replace(id, baseURL+actor.name+"/", "", 1) + // if there are still slashes in the result this means the + // above didn't work + if strings.ContainsAny(hash, "/") { + log.Info("The id of this follow is probably wrong") + return + } + + // Have we already requested this follow or are we following anybody that + // sprays accepts? + savedFollowRequest, err := actor.loadItem(hash) + if err != nil { + log.Info("We never requested this follow, ignoring the Accept") + return + } + if savedFollowRequest["id"] != id { + log.Info("Id mismatch between Follow request and Accept") + return + } + actor.following[acceptor] = hash + actor.save() default: } diff --git a/main.go b/main.go index 53056fc..638d0ea 100644 --- a/main.go +++ b/main.go @@ -53,9 +53,11 @@ func main() { // actor, _ := activityserve.MakeActor("activityserve_test_actor_2", "This is an activityserve test actor", "Service") // actor, _ := activityserve.LoadActor("activityserve_test_actor_2") + // actor.Follow("https://cybre.space/users/tzo") // actor.CreateNote("Hello World!") - activityserve.LoadActor("activityserve_test_actor_2") + actor, _ := activityserve.LoadActor("activityserve_test_actor_2") + actor.CreateNote("Hello World, again!") activityserve.Serve() }