Skip to content

Commit

Permalink
feat: Support accept follow
Browse files Browse the repository at this point in the history
  • Loading branch information
PichuChen committed Aug 1, 2024
1 parent c19ca68 commit 79f6c0a
Show file tree
Hide file tree
Showing 6 changed files with 323 additions and 9 deletions.
27 changes: 27 additions & 0 deletions activitypub/accept.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package activitypub

import (
"log/slog"

"github.com/pichuchen/hatsuaki/datastore/actor"
)

func SendAccept(senderActor *actor.Actor, recevierActorID string, followActiveObjectID string) {
slog.Info("SendAccept", "sender", senderActor.GetUsername(), "receiver", recevierActorID, "object", followActiveObjectID)

acceptActive := map[string]interface{}{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"actor": senderActor.GetFullID(),
"object": followActiveObjectID,
}

// the object is transient, in which case the id MAY be omitted
// acceptActive["id"] = senderActor.GetFullID() + "/outbox/" + time.Now().String()

// append to the sender's outbox
// senderActor.AppendOutboxObject(acceptActive["id"].(string))

SendActivity(senderActor.GetUsername(), recevierActorID, acceptActive)

}
56 changes: 47 additions & 9 deletions activitypub/inbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
"time"

"github.com/pichuchen/hatsuaki/datastore/actor"
"github.com/pichuchen/hatsuaki/datastore/config"
Expand Down Expand Up @@ -155,19 +156,56 @@ func PostActorInbox(w http.ResponseWriter, r *http.Request, a *actor.Actor) {
}

func PostActorInboxFollow(w http.ResponseWriter, r *http.Request, a *actor.Actor, requestMap map[string]interface{}) {
slog.Info("activitypub.PostActorInboxFollow", "info", "follow")
slog.Info("activitypub.PostActorInboxFollow", "info", "follow", "requestMap.object", requestMap["object"])
followID := requestMap["id"].(string)

// 這邊的 objectID 是我們自己站上的 actor 的 ID
var objectID string
switch o := requestMap["object"].(type) {
case string:
objectID = o
case map[string]interface{}:
objectID = o["id"].(string)
default:
slog.Warn("activitypub.PostActorInboxFollow", "error", "object type error")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})
return
}

// 這邊是在 ActivityPub 中的必要 (MUST) 欄位
w.Header().Set("Content-Type", "application/activity+json")
// 檢查 objectID 是否等同於我們自己站上的 actor 的 ID
if objectID != a.GetFullID() {
slog.Warn("activitypub.PostActorInboxFollow", "error", "objectID not match")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})
return
}

// 這邊是在 ActivityPub 中的必要 (MUST) 欄位
m := map[string]interface{}{}
m["id"] = "https://" + config.GetDomain() + "/.activitypub/actor/" + a.GetUsername() + "/inbox"
// 這邊的 actorID 是提出 follow 請求的 actor 的 ID
var actorID string
switch a := requestMap["actor"].(type) {
case string:
actorID = a
case map[string]interface{}:
actorID = a["id"].(string)
default:
slog.Warn("activitypub.PostActorInboxFollow", "error", "actor type error")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})
return
}

// 這邊是在 ActivityPub 中的必要 (MUST) 欄位
m["type"] = "OrderedCollection"
slog.Info("activitypub.PostActorInboxFollow", "followID", followID)

if config.GetEnableAutoAcceptFollow() {
// 這邊暫停五秒是讓 Debug Log 看起來比較清楚
time.Sleep(5 * time.Second)
slog.Info("activitypub.PostActorInboxFollow", "info", "auto accept follow")
SendAccept(a, actorID, followID)
a.AppendFollowerID(actorID)
actor.SaveActor("./actor.json")
}

json.NewEncoder(w).Encode(m)
}

func PostSharedInbox(w http.ResponseWriter, r *http.Request) {
Expand Down
178 changes: 178 additions & 0 deletions activitypub/send.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package activitypub

import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"

"github.com/pichuchen/hatsuaki/activitypub/signature"
"github.com/pichuchen/hatsuaki/datastore/actor"
)

func SendActivity(senderUsername string, recevierActorID string, activity map[string]interface{}) {
// 這邊應該要把 activity 送到 recevierActorID 的 inbox

// 首先要先取的對方的 inbox 位置
inbox, err := GetInboxByActorID(recevierActorID, false)
if err != nil {
slog.Error("GetInboxByActorID failed", "error", err)
return
}

// 然後送出 activity
activityByte, err := json.Marshal(activity)
if err != nil {
slog.Error("Marshal activity failed", "error", err)
return
}

req, err := http.NewRequest("POST", inbox, strings.NewReader(string(activityByte)))
if err != nil {
slog.Error("Create request failed", "error", err)
return
}

req.Header.Set("Content-Type", "application/activity+json")
req.Header.Set("Accept", "application/activity+json, application/ld+json")

// Add Date
gmtTimeLoc := time.FixedZone("GMT", 0)
s := time.Now().In(gmtTimeLoc).Format(http.TimeFormat)
req.Header.Add("Date", s)

// Add Host
req.Header.Add("Host", req.URL.Host)

// Add Signature
senderActor, err := actor.FindActorByUsername(senderUsername)
if err != nil {
slog.Error("SendActivity", "error", err)
return
}
keyID := fmt.Sprintf("%s#main-key", senderActor.GetFullID())
signature.Signature(senderActor.GetPrivateKey(), keyID, req)

slog.Info("SendActivity", "activity", string(activityByte))

req.Body = io.NopCloser(strings.NewReader(string(activityByte)))

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
slog.Error("SendActivity failed", "error", err)
return
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("Read response body failed", "Error", err)
return
}

slog.Info("SendActivity response", "body", string(respBody))

if resp.StatusCode != http.StatusOK {
slog.Error("SendActivity failed", "status code", resp.StatusCode)
return
}

slog.Info("SendActivity success", "activity", activity, "receiver", recevierActorID)

}

// GetInboxByActorID 會回傳 actor 的 inbox 位置,
// 如果 sign 是 true 的話,則需要對回傳的位置進行簽章
func GetInboxByActorID(actorID string, sign bool) (string, error) {
reqURL := actorID
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return "", err
}

req.Header.Set("Accept", "application/activity+json, application/ld+json")
// Add Date
gmtTimeLoc := time.FixedZone("GMT", 0)
s := time.Now().In(gmtTimeLoc).Format(http.TimeFormat)
req.Header.Add("Date", s)

// Add Host
req.Header.Add("Host", req.URL.Host)

// Add Signature
if sign {
instanceActor, err := actor.FindActorByUsername("instance.actor")
if err != nil {
slog.Error("GetInboxByActorID", "error", err)
return "", err
}
keyID := fmt.Sprintf("%s#main-key", instanceActor.GetFullID())
signature.Signature(instanceActor.GetPrivateKey(), keyID, req)
}

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
slog.Error("GetInboxByActorID", "error", err)
return "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status code: %d", resp.StatusCode)
}

respByte, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("Read response body failed", "Error", err)
return "", err
}

respMap := map[string]interface{}{}
err = json.Unmarshal(respByte, &respMap)
if err != nil {
slog.Error("Unmarshal response body failed", "Error", err, "body", string(respByte))
return "", err
}

slog.Info("Get actor success", "actor", actorID, "Response", respMap)

errorStr, ok := respMap["error"]
if ok {
if strings.Contains(errorStr.(string), "Request not signed") {
slog.Info("request not signed, retry with signature")
return GetInboxByActorID(actorID, true)
}
slog.Error("Get actor failed", "actor", actorID, "Error", errorStr)
return "", errors.New("get actor failed")
}

// 如果有 sharedInbox 的話,就優先回傳 sharedInbox
sharedInbox, ok := respMap["endpoints"].(map[string]interface{})["sharedInbox"]
if ok {
sharedInboxStr, ok := sharedInbox.(string)
if ok {
return sharedInboxStr, nil
}
}

inbox, ok := respMap["inbox"]
if !ok {
slog.Error("No inbox in actor", "actor", actorID)
return "", errors.New("no inbox in actor")
}

inboxStr, ok := inbox.(string)
if !ok {
slog.Error("Inbox is not string", "inbox", inbox)
return "", errors.New("inbox is not string")
}

return inboxStr, nil

}
60 changes: 60 additions & 0 deletions datastore/actor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"

"github.com/pichuchen/hatsuaki/activitypub/signature"
Expand Down Expand Up @@ -91,6 +92,16 @@ func FindActorByUsername(username string) (actor *Actor, err error) {
return nil, fmt.Errorf("actor not found")
}

func FindActorByFullID(fullID string) (actor *Actor, err error) {
slog.Info("actor.FindActorByFullID", "fullID", fullID)
prefix := "https://" + config.GetDomain() + "/.activitypub/actor/"
if !strings.HasPrefix(fullID, prefix) {
return nil, fmt.Errorf("invalid fullID")
}
username := fullID[len("https://"+config.GetDomain()+"/.activitypub/actor/"):]
return FindActorByUsername(username)
}

func (a *Actor) GetUsername() string {
return (*a)["username"].(string)
}
Expand Down Expand Up @@ -159,3 +170,52 @@ func VerifyPassword(username, password string) error {
}
return nil
}

func (a *Actor) AppendFollowerID(followerID string) {
ids := a.GetFollowerIDs()
ids = append(ids, followerID)
(*a)["followers"] = ids
}

func (a *Actor) GetFollowerIDs() []string {
n, ok := (*a)["followers"]
if !ok {
return []string{}
}
switch v := n.(type) {
case []string:
return v
case []interface{}:
ids := make([]string, len(v))
for i, val := range v {
ids[i] = val.(string)
}
return ids
}
return []string{}
}

func (a *Actor) AppendFollowingID(followingID string) {
ids := a.GetFollowingIDs()
ids = append(ids, followingID)
(*a)["following"] = ids
}

func (a *Actor) GetFollowingIDs() []string {
n, ok := (*a)["following"]
if !ok {
return []string{}
}
switch v := n.(type) {
case []string:
return v
case []interface{}:
ids := make([]string, len(v))
for i, val := range v {
ids[i] = val.(string)
}
return ids
}
return []string{}

}
10 changes: 10 additions & 0 deletions datastore/config/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type Config struct {
// Domain 是這個服務的域名,舉例來說 @[email protected] 的 domain 就是 example.com
Domain string `json:"domain"`
LoginJWTSecret string `json:"login_jwt_secret"`
// The Accept or Reject MAY be generated automatically - https://www.w3.org/TR/activitypub/#follow-activity-inbox
EnableAutoAcceptFollow bool `json:"enable_auto_accept_follow"`
}

var runningConfig Config
Expand All @@ -25,6 +27,14 @@ func SetLoginJWTSecret(secret string) {
runningConfig.LoginJWTSecret = secret
}

func GetEnableAutoAcceptFollow() bool {
return runningConfig.EnableAutoAcceptFollow
}

func SetEnableAutoAcceptFollow(b bool) {
runningConfig.EnableAutoAcceptFollow = b
}

func LoadConfig(filepath string) error {
f, err := os.ReadFile(filepath)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions serverlet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func main() {
slog.Error("main", "error", err)
}
config.SetLoginJWTSecret(base64.StdEncoding.EncodeToString(key))
config.SetEnableAutoAcceptFollow(true)
err = config.SaveConfig("./config.json")
if err != nil {
slog.Error("main", "error", err)
Expand Down

0 comments on commit 79f6c0a

Please sign in to comment.