diff --git a/activitypub/accept.go b/activitypub/accept.go new file mode 100644 index 0000000..fb2821b --- /dev/null +++ b/activitypub/accept.go @@ -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) + +} diff --git a/activitypub/inbox.go b/activitypub/inbox.go index 0889163..460a003 100644 --- a/activitypub/inbox.go +++ b/activitypub/inbox.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "time" "github.com/pichuchen/hatsuaki/datastore/actor" "github.com/pichuchen/hatsuaki/datastore/config" @@ -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) { diff --git a/activitypub/send.go b/activitypub/send.go new file mode 100644 index 0000000..6b02908 --- /dev/null +++ b/activitypub/send.go @@ -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 + +} diff --git a/datastore/actor/main.go b/datastore/actor/main.go index 7a0e1ad..5e7f4ad 100644 --- a/datastore/actor/main.go +++ b/datastore/actor/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "strings" "sync" "github.com/pichuchen/hatsuaki/activitypub/signature" @@ -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) } @@ -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{} + +} diff --git a/datastore/config/main.go b/datastore/config/main.go index 29f435f..83e6c4b 100644 --- a/datastore/config/main.go +++ b/datastore/config/main.go @@ -9,6 +9,8 @@ type Config struct { // Domain 是這個服務的域名,舉例來說 @alice@example.com 的 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 @@ -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 { diff --git a/serverlet/main.go b/serverlet/main.go index 2e1af96..07e303c 100644 --- a/serverlet/main.go +++ b/serverlet/main.go @@ -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)