447 lines
13 KiB
Go
447 lines
13 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/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"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 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
|
|
// 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 map[string]interface{}
|
|
}
|
|
|
|
// MakeActor returns a new local actor we can act
|
|
// on behalf of
|
|
func MakeActor(name, summary, actorType string) (Actor, error) {
|
|
followers := make(map[string]interface{})
|
|
following := 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,
|
|
followersIRI: followersIRI,
|
|
publicKeyID: publicKeyID,
|
|
}
|
|
|
|
// 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
|
|
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
|
|
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("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
|
|
}
|
|
|
|
// publicKeyNewLines := strings.ReplaceAll(jsonData["PublicKey"].(string), "\\n", "\n")
|
|
// privateKeyNewLines := strings.ReplaceAll(jsonData["PrivateKey"].(string), "\\n", "\n")
|
|
|
|
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{}),
|
|
publicKey: publicKey,
|
|
privateKey: privateKey,
|
|
publicKeyPem: jsonData["PublicKey"].(string),
|
|
privateKeyPem: jsonData["PrivateKey"].(string),
|
|
followersIRI: baseURL + name + "/followers",
|
|
publicKeyID: baseURL + name + "#main-key",
|
|
}
|
|
|
|
return actor, nil
|
|
}
|
|
|
|
// func LoadActorFromIRI(iri string) a 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,
|
|
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 {
|
|
return `{"@context": "https://www.w3.org/ns/activitystreams",
|
|
"type": "` + a.actorType + `",
|
|
"id": "` + baseURL + a.name + `",
|
|
"name": "` + a.name + `",
|
|
"preferredUsername": "` + a.name + `",
|
|
"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",
|
|
"publicKey": {
|
|
"id": "` + baseURL + a.name + `#main-key",
|
|
"owner": "` + baseURL + a.name + `",
|
|
"publicKeyPem": "` + strings.ReplaceAll(a.publicKeyPem, "\n", "\\n") + `"
|
|
}
|
|
}`
|
|
}
|
|
|
|
func (a *Actor) newIDhash() string {
|
|
return uniuri.New()
|
|
}
|
|
|
|
func (a *Actor) newIDurl() string {
|
|
return baseURL + a.name + "/" + a.newIDhash()
|
|
}
|
|
|
|
// CreateNote posts an activityPub note to our followers
|
|
func (a *Actor) CreateNote(content string) {
|
|
// for now I will just write this to the outbox
|
|
|
|
id := a.newIDurl()
|
|
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 + "/" + 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/" + 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"
|
|
to, _ := url.Parse("https://cybre.space/inbox")
|
|
go a.send(create, to)
|
|
a.saveItem(id, create)
|
|
}
|
|
|
|
func (a *Actor) saveItem(id 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)
|
|
if err != nil {
|
|
log.Printf("WriteFileJson ERROR: %+v", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// GetFollowers returns a list of people that follow us
|
|
func (a *Actor) GetFollowers(page int) (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
|
|
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["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 {
|
|
items = append(items, k)
|
|
}
|
|
themap["orderedItems"] = items
|
|
themap["partOf"] = baseURL + a.name + "/followers"
|
|
themap["totalItems"] = len(a.followers)
|
|
themap["type"] = "OrderedCollectionPage"
|
|
}
|
|
response, _ = json.Marshal(themap)
|
|
return
|
|
}
|
|
|
|
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}, []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")
|
|
sum := sha256.Sum256(b)
|
|
req.Header.Add("Digest",
|
|
fmt.Sprintf("SHA-256=%s",
|
|
base64.StdEncoding.EncodeToString(sum[:])))
|
|
err = postSigner.SignRequest(a.privateKey, a.publicKeyID, req)
|
|
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)
|
|
fmt.Printf("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}, []string{"(request-target)", "date", "host", "digest"}, httpsig.Signature)
|
|
err = signer.SignRequest(a.privateKey, a.publicKeyID, req)
|
|
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) error {
|
|
a.followers[iri] = struct{}{}
|
|
return a.save()
|
|
}
|