172ae33592
Fix extraneous slash in unfollow id
816 lines
23 KiB
Go
816 lines
23 KiB
Go
package activityserve
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gologme/log"
|
|
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
|
|
"github.com/dchest/uniuri"
|
|
"github.com/go-fed/httpsig"
|
|
)
|
|
|
|
// 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, rejected map[string]interface{}
|
|
requested map[string]interface{}
|
|
posts map[int]map[string]interface{}
|
|
publicKey crypto.PublicKey
|
|
privateKey crypto.PrivateKey
|
|
publicKeyPem string
|
|
privateKeyPem string
|
|
publicKeyID string
|
|
OnFollow func(map[string]interface{})
|
|
OnReceiveContent func(map[string]interface{})
|
|
}
|
|
|
|
// ActorToSave is a stripped down actor representation
|
|
// with exported properties in order for json to be
|
|
// able to marshal it.
|
|
// see https://stackoverflow.com/questions/26327391/json-marshalstruct-returns
|
|
type ActorToSave struct {
|
|
Name, Summary, ActorType, IRI, PublicKey, PrivateKey string
|
|
Followers, Following, Rejected, Requested map[string]interface{}
|
|
}
|
|
|
|
// MakeActor creates and returns a new local actor we can act
|
|
// on behalf of. It also creates its files on disk
|
|
func MakeActor(name, summary, actorType string) (Actor, error) {
|
|
followers := make(map[string]interface{})
|
|
following := make(map[string]interface{})
|
|
rejected := make(map[string]interface{})
|
|
requested := make(map[string]interface{})
|
|
followersIRI := baseURL + name + "/followers"
|
|
publicKeyID := baseURL + name + "#main-key"
|
|
iri := baseURL + name
|
|
nuIri, err := url.Parse(iri)
|
|
if err != nil {
|
|
log.Info("Something went wrong when parsing the local actor uri into net/url")
|
|
return Actor{}, err
|
|
}
|
|
actor := Actor{
|
|
Name: name,
|
|
summary: summary,
|
|
actorType: actorType,
|
|
iri: iri,
|
|
nuIri: nuIri,
|
|
followers: followers,
|
|
following: following,
|
|
rejected: rejected,
|
|
requested: requested,
|
|
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.OnReceiveContent = func(activity map[string]interface{}) {}
|
|
|
|
// create actor's keypair
|
|
rng := rand.Reader
|
|
privateKey, err := rsa.GenerateKey(rng, 2048)
|
|
publicKey := privateKey.PublicKey
|
|
|
|
actor.publicKey = publicKey
|
|
actor.privateKey = privateKey
|
|
|
|
// marshal the crypto to pem
|
|
privateKeyDer := x509.MarshalPKCS1PrivateKey(privateKey)
|
|
privateKeyBlock := pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Headers: nil,
|
|
Bytes: privateKeyDer,
|
|
}
|
|
actor.privateKeyPem = string(pem.EncodeToMemory(&privateKeyBlock))
|
|
|
|
publicKeyDer, err := x509.MarshalPKIXPublicKey(&publicKey)
|
|
if err != nil {
|
|
log.Info("Can't marshal public key")
|
|
return Actor{}, err
|
|
}
|
|
|
|
publicKeyBlock := pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Headers: nil,
|
|
Bytes: publicKeyDer,
|
|
}
|
|
actor.publicKeyPem = string(pem.EncodeToMemory(&publicKeyBlock))
|
|
|
|
err = actor.save()
|
|
if err != nil {
|
|
return actor, err
|
|
}
|
|
|
|
return actor, nil
|
|
}
|
|
|
|
// GetOutboxIRI returns the outbox iri in net/url format
|
|
func (a *Actor) GetOutboxIRI() *url.URL {
|
|
iri := a.iri + "/outbox"
|
|
nuiri, _ := url.Parse(iri)
|
|
return nuiri
|
|
}
|
|
|
|
// LoadActor searches the filesystem and creates an Actor
|
|
// from the data in <name>.json
|
|
// This does not preserve events so use with caution
|
|
func LoadActor(name string) (Actor, error) {
|
|
// make sure our users can't read our hard drive
|
|
if strings.ContainsAny(name, "./ ") {
|
|
log.Info("Illegal characters in actor name")
|
|
return Actor{}, errors.New("Illegal characters in actor name")
|
|
}
|
|
jsonFile := storage + slash + "actors" + slash + name + slash + name + ".json"
|
|
fileHandle, err := os.Open(jsonFile)
|
|
if os.IsNotExist(err) {
|
|
log.Info(name)
|
|
log.Info("We don't have this kind of actor stored")
|
|
return Actor{}, err
|
|
}
|
|
byteValue, err := ioutil.ReadAll(fileHandle)
|
|
if err != nil {
|
|
log.Info("Error reading actor file")
|
|
return Actor{}, err
|
|
}
|
|
jsonData := make(map[string]interface{})
|
|
json.Unmarshal(byteValue, &jsonData)
|
|
|
|
nuIri, err := url.Parse(jsonData["IRI"].(string))
|
|
if err != nil {
|
|
log.Info("Something went wrong when parsing the local actor uri into net/url")
|
|
return Actor{}, err
|
|
}
|
|
|
|
publicKeyDecoded, rest := pem.Decode([]byte(jsonData["PublicKey"].(string)))
|
|
if publicKeyDecoded == nil {
|
|
log.Info(rest)
|
|
panic("failed to parse PEM block containing the public key")
|
|
}
|
|
publicKey, err := x509.ParsePKIXPublicKey(publicKeyDecoded.Bytes)
|
|
if err != nil {
|
|
log.Info("Can't parse public keys")
|
|
log.Info(err)
|
|
return Actor{}, err
|
|
}
|
|
privateKeyDecoded, rest := pem.Decode([]byte(jsonData["PrivateKey"].(string)))
|
|
if privateKeyDecoded == nil {
|
|
log.Info(rest)
|
|
panic("failed to parse PEM block containing the private key")
|
|
}
|
|
privateKey, err := x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes)
|
|
if err != nil {
|
|
log.Info("Can't parse private keys")
|
|
log.Info(err)
|
|
return Actor{}, err
|
|
}
|
|
|
|
actor := Actor{
|
|
Name: name,
|
|
summary: jsonData["Summary"].(string),
|
|
actorType: jsonData["ActorType"].(string),
|
|
iri: jsonData["IRI"].(string),
|
|
nuIri: nuIri,
|
|
followers: jsonData["Followers"].(map[string]interface{}),
|
|
following: jsonData["Following"].(map[string]interface{}),
|
|
rejected: jsonData["Rejected"].(map[string]interface{}),
|
|
requested: jsonData["Requested"].(map[string]interface{}),
|
|
publicKey: publicKey,
|
|
privateKey: privateKey,
|
|
publicKeyPem: jsonData["PublicKey"].(string),
|
|
privateKeyPem: jsonData["PrivateKey"].(string),
|
|
followersIRI: baseURL + name + "/followers",
|
|
publicKeyID: baseURL + name + "#main-key",
|
|
}
|
|
|
|
actor.OnFollow = func(activity map[string]interface{}) { actor.Accept(activity) }
|
|
actor.OnReceiveContent = func(activity map[string]interface{}) {}
|
|
|
|
return actor, nil
|
|
}
|
|
|
|
// GetActor attempts to LoadActor and if it doesn't exist
|
|
// creates one
|
|
func GetActor(name, summary, actorType string) (Actor, error) {
|
|
actor, err := LoadActor(name)
|
|
|
|
if err != nil {
|
|
log.Info("Actor doesn't exist, creating...")
|
|
actor, err = MakeActor(name, summary, actorType)
|
|
if err != nil {
|
|
log.Info("Can't create actor!")
|
|
return Actor{}, err
|
|
}
|
|
}
|
|
|
|
// if the info provided for the actor is different
|
|
// from what the actor has, edit the actor
|
|
save := false
|
|
if summary != actor.summary {
|
|
actor.summary = summary
|
|
save = true
|
|
}
|
|
if actorType != actor.actorType {
|
|
actor.actorType = actorType
|
|
save = true
|
|
}
|
|
// if anything changed write it to disk
|
|
if save {
|
|
actor.save()
|
|
}
|
|
|
|
return actor, nil
|
|
}
|
|
|
|
// func LoadActorFromIRI(iri string) a Actor{
|
|
// TODO, this should parse the iri and load the right actor
|
|
// }
|
|
|
|
// save the actor to file
|
|
func (a *Actor) save() error {
|
|
|
|
// check if we already have a directory to save actors
|
|
// and if not, create it
|
|
dir := storage + slash + "actors" + slash + a.Name + slash + "items"
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
os.MkdirAll(dir, 0755)
|
|
}
|
|
|
|
actorToSave := ActorToSave{
|
|
Name: a.Name,
|
|
Summary: a.summary,
|
|
ActorType: a.actorType,
|
|
IRI: a.iri,
|
|
Followers: a.followers,
|
|
Following: a.following,
|
|
Rejected: a.rejected,
|
|
Requested: a.requested,
|
|
PublicKey: a.publicKeyPem,
|
|
PrivateKey: a.privateKeyPem,
|
|
}
|
|
|
|
actorJSON, err := json.MarshalIndent(actorToSave, "", "\t")
|
|
if err != nil {
|
|
log.Info("error Marshalling actor json")
|
|
return err
|
|
}
|
|
// log.Info(actorToSave)
|
|
// log.Info(string(actorJSON))
|
|
err = ioutil.WriteFile(storage+slash+"actors"+slash+a.Name+slash+a.Name+".json", actorJSON, 0644)
|
|
if err != nil {
|
|
log.Printf("WriteFileJson ERROR: %+v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *Actor) whoAmI() string {
|
|
|
|
self := make(map[string]interface{})
|
|
self["@context"] = context()
|
|
self["type"] = a.actorType
|
|
self["id"] = baseURL + a.Name
|
|
self["name"] = a.Name
|
|
self["preferredUsername"] = a.Name
|
|
self["summary"] = a.summary
|
|
self["inbox"] = baseURL + a.Name + "/inbox"
|
|
self["outbox"] = baseURL + a.Name + "/outbox"
|
|
self["followers"] = baseURL + a.Name + "/peers/followers"
|
|
self["following"] = baseURL + a.Name + "/peers/following"
|
|
self["publicKey"] = map[string]string{
|
|
"id": baseURL + a.Name + "#main-key",
|
|
"owner": baseURL + a.Name,
|
|
"publicKeyPem": a.publicKeyPem,
|
|
}
|
|
selfString, _ := json.Marshal(self)
|
|
return string(selfString)
|
|
}
|
|
|
|
func (a *Actor) newItemID() (hash string, url string) {
|
|
hash = uniuri.New()
|
|
return hash, baseURL + a.Name + "/item/" + hash
|
|
}
|
|
|
|
func (a *Actor) newID() (hash string, url string) {
|
|
hash = uniuri.New()
|
|
return hash, baseURL + a.Name + "/" + hash
|
|
}
|
|
|
|
// TODO Reply(content string, inReplyTo string)
|
|
|
|
// ReplyNote sends a note to a specific actor in reply to
|
|
// a post
|
|
//TODO
|
|
|
|
// 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.newItemID()
|
|
create := make(map[string]interface{})
|
|
note := make(map[string]interface{})
|
|
create["@context"] = context()
|
|
create["actor"] = baseURL + a.Name
|
|
create["cc"] = a.followersIRI
|
|
create["id"] = id
|
|
create["object"] = note
|
|
note["attributedTo"] = baseURL + a.Name
|
|
note["cc"] = a.followersIRI
|
|
note["content"] = content
|
|
if inReplyTo != "" {
|
|
note["inReplyTo"] = inReplyTo
|
|
}
|
|
note["id"] = id
|
|
note["published"] = time.Now().Format(time.RFC3339)
|
|
note["url"] = create["id"]
|
|
note["type"] = "Note"
|
|
note["to"] = "https://www.w3.org/ns/activitystreams#Public"
|
|
create["published"] = note["published"]
|
|
create["type"] = "Create"
|
|
go a.sendToFollowers(create)
|
|
err := a.saveItem(hash, 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")
|
|
}
|
|
}
|
|
|
|
// 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+hash+".json", JSON, 0644)
|
|
if err != nil {
|
|
log.Printf("WriteFileJson ERROR: %+v", err)
|
|
return err
|
|
}
|
|
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) {
|
|
return a.signedHTTPPost(content, to.String())
|
|
}
|
|
|
|
// 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"] = context()
|
|
if page == 0 {
|
|
themap["first"] = baseURL + a.Name + "/peers/" + who + "?page=1"
|
|
themap["id"] = baseURL + a.Name + "/peers/" + who
|
|
themap["totalItems"] = strconv.Itoa(len(collection))
|
|
themap["type"] = "OrderedCollection"
|
|
} else if page == 1 { // implement pagination
|
|
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 + "/peers/" + 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")
|
|
}
|
|
|
|
// signedHTTPPost performs an HTTP post on behalf of Actor with the
|
|
// request-target, date, host and digest headers signed
|
|
// with the actor's private key.
|
|
func (a *Actor) signedHTTPPost(content map[string]interface{}, to string) (err error) {
|
|
b, err := json.Marshal(content)
|
|
if err != nil {
|
|
log.Info("Can't marshal JSON")
|
|
log.Info(err)
|
|
return
|
|
}
|
|
postSigner, _, _ := httpsig.NewSigner([]httpsig.Algorithm{httpsig.RSA_SHA256}, "SHA-256", []string{"(request-target)", "date", "host", "digest"}, httpsig.Signature)
|
|
|
|
byteCopy := make([]byte, len(b))
|
|
copy(byteCopy, b)
|
|
buf := bytes.NewBuffer(byteCopy)
|
|
req, err := http.NewRequest("POST", to, buf)
|
|
if err != nil {
|
|
log.Info(err)
|
|
return
|
|
}
|
|
|
|
// I prefer to deal with strings and just parse to net/url if and when
|
|
// needed, even if here we do one extra round trip
|
|
iri, err := url.Parse(to)
|
|
if err != nil {
|
|
log.Error("cannot parse url for POST, check your syntax")
|
|
return err
|
|
}
|
|
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("Host", iri.Host)
|
|
req.Header.Add("Accept", "application/activity+json; charset=utf-8")
|
|
req.Header.Add("Content-Type", "application/activity+json; charset=utf-8")
|
|
err = postSigner.SignRequest(a.privateKey, a.publicKeyID, req, byteCopy)
|
|
if err != nil {
|
|
log.Info(err)
|
|
return
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Info(err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if !isSuccess(resp.StatusCode) {
|
|
responseData, _ := ioutil.ReadAll(resp.Body)
|
|
err = fmt.Errorf("POST request to %s failed (%d): %s\nResponse: %s \nRequest: %s \nHeaders: %s", to, resp.StatusCode, resp.Status, FormatJSON(responseData), FormatJSON(byteCopy), FormatHeaders(req.Header))
|
|
log.Info(err)
|
|
return
|
|
}
|
|
responseData, _ := ioutil.ReadAll(resp.Body)
|
|
log.Errorf("POST request to %s succeeded (%d): %s \nResponse: %s \nRequest: %s \nHeaders: %s", to, resp.StatusCode, resp.Status, FormatJSON(responseData), FormatJSON(byteCopy), FormatHeaders(req.Header))
|
|
return
|
|
}
|
|
|
|
func (a *Actor) signedHTTPGet(address string) (string, error) {
|
|
req, err := http.NewRequest("GET", address, nil)
|
|
if err != nil {
|
|
log.Error("cannot create new http.request")
|
|
return "", err
|
|
}
|
|
|
|
iri, err := url.Parse(address)
|
|
if err != nil {
|
|
log.Error("cannot parse url for GET, check your syntax")
|
|
return "", err
|
|
}
|
|
|
|
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", fmt.Sprintf("%s %s %s", userAgent, libName, version))
|
|
req.Header.Add("host", iri.Host)
|
|
req.Header.Add("digest", "")
|
|
req.Header.Add("Accept", "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"")
|
|
|
|
// set up the http signer
|
|
signer, _, _ := httpsig.NewSigner([]httpsig.Algorithm{httpsig.RSA_SHA256}, "SHA-256", []string{"(request-target)", "date", "host", "digest"}, httpsig.Signature)
|
|
err = signer.SignRequest(a.privateKey, a.publicKeyID, req, nil)
|
|
if err != nil {
|
|
log.Error("Can't sign the request")
|
|
return "", err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Error("Cannot perform the GET request")
|
|
log.Error(err)
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
responseData, _ := ioutil.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("GET request to %s failed (%d): %s \n%s", iri.String(), resp.StatusCode, resp.Status, FormatJSON(responseData))
|
|
}
|
|
|
|
responseData, _ := ioutil.ReadAll(resp.Body)
|
|
fmt.Println("GET request succeeded:", iri.String(), req.Header, resp.StatusCode, resp.Status, "\n", FormatJSON(responseData))
|
|
|
|
responseText := string(responseData)
|
|
return responseText, nil
|
|
}
|
|
|
|
// NewFollower records a new follower to the actor file
|
|
func (a *Actor) NewFollower(iri string, inbox string) error {
|
|
a.followers[iri] = inbox
|
|
return a.save()
|
|
}
|
|
|
|
// appendToOutbox adds a new line with the id of the activity
|
|
// to outbox.txt
|
|
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.txt"
|
|
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 + "\n"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// batchSend sends a batch of http posts to a list of recipients
|
|
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
|
|
}
|
|
|
|
// send to followers sends a batch of http posts to each one of the followers
|
|
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{})
|
|
hash, id := a.newItemID()
|
|
|
|
follow["@context"] = context()
|
|
follow["actor"] = a.iri
|
|
follow["id"] = 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(hash, follow)
|
|
a.requested[user] = hash
|
|
a.save()
|
|
// we are going to save the request here
|
|
// and save the follow only on accept so look at
|
|
// the http handler for the accept code
|
|
}()
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// if we have a request to follow this user cancel it
|
|
cancelRequest := false
|
|
if _, ok := a.requested[user]; ok {
|
|
log.Info("Cancelling follow request")
|
|
cancelRequest = true
|
|
// then continue to send the unfollow to the receipient
|
|
// to inform them that the request is cancelled.
|
|
} else if _, ok := a.following[user]; !ok {
|
|
log.Info("We are not following this user, ignoring...")
|
|
return
|
|
}
|
|
|
|
log.Info("Unfollowing " + user)
|
|
|
|
var hash string
|
|
// find the id of the original follow
|
|
if cancelRequest {
|
|
hash = a.requested[user].(string)
|
|
} else {
|
|
hash = a.following[user].(string)
|
|
}
|
|
|
|
// create an undo activiy
|
|
undo := make(map[string]interface{})
|
|
undo["@context"] = context()
|
|
undo["actor"] = a.iri
|
|
undo["id"] = baseURL + "item/" + hash + "/undo"
|
|
undo["type"] = "Undo"
|
|
|
|
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
|
|
}
|
|
|
|
PrettyPrint(undo)
|
|
go func() {
|
|
err := a.signedHTTPPost(undo, remoteUser.inbox)
|
|
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
|
|
if cancelRequest {
|
|
delete(a.requested, user)
|
|
} else {
|
|
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 := []string{"https://www.w3.org/ns/activitystreams#Public"}
|
|
hash, id := a.newItemID()
|
|
|
|
announce := make(map[string]interface{})
|
|
|
|
announce["@context"] = context()
|
|
announce["id"] = id
|
|
announce["type"] = "Announce"
|
|
announce["object"] = url
|
|
announce["actor"] = a.iri
|
|
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.appendToOutbox(announce["id"].(string))
|
|
a.saveItem(hash, announce)
|
|
a.sendToFollowers(announce)
|
|
}
|
|
|
|
func (a *Actor) followersSlice() []string {
|
|
followersSlice := make([]string, 0)
|
|
followersSlice = append(followersSlice, a.followersIRI)
|
|
for k := range a.followers {
|
|
followersSlice = append(followersSlice, k)
|
|
}
|
|
return followersSlice
|
|
}
|
|
|
|
// Accept a follow request
|
|
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
|
|
if newFollower == follow["object"] {
|
|
log.Info("You can't follow yourself")
|
|
return
|
|
}
|
|
|
|
follower, err := NewRemoteActor(follow["actor"].(string))
|
|
|
|
// check if this user is already following us
|
|
if _, ok := a.followers[newFollower]; ok {
|
|
log.Info("You're already following us, yay!")
|
|
// do nothing, they're already following us
|
|
} else {
|
|
a.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
|
|
|
|
// remove @context from the inner activity
|
|
delete(follow, "@context")
|
|
|
|
accept := make(map[string]interface{})
|
|
|
|
accept["@context"] = "https://www.w3.org/ns/activitystreams"
|
|
accept["to"] = follow["actor"]
|
|
_, accept["id"] = a.newID()
|
|
accept["actor"] = a.iri
|
|
accept["object"] = follow
|
|
accept["type"] = "Accept"
|
|
|
|
if err != nil {
|
|
log.Info("Couldn't retrieve remote actor info, maybe server is down?")
|
|
log.Info(err)
|
|
}
|
|
|
|
// Maybe we need to save this accept?
|
|
go a.signedHTTPPost(accept, follower.inbox)
|
|
|
|
}
|
|
|
|
// Followers returns the list of followers
|
|
func (a *Actor) Followers() map[string]string {
|
|
f := make(map[string]string)
|
|
for follower, inbox := range a.followers {
|
|
f[follower] = inbox.(string)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// Following returns the list of followers
|
|
func (a *Actor) Following() map[string]string {
|
|
f := make(map[string]string)
|
|
for followee, hash := range a.following {
|
|
f[followee] = hash.(string)
|
|
}
|
|
return f
|
|
}
|