diff --git a/.gitignore b/.gitignore index 0683b83..fc5d989 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ serverlet/serverlet *.swp serverlet/actor.json -serverlet/config.json \ No newline at end of file +serverlet/config.json +serverlet/object.json \ No newline at end of file diff --git a/activitypub/inbox.go b/activitypub/inbox.go index 38ef0ca..ef14c96 100644 --- a/activitypub/inbox.go +++ b/activitypub/inbox.go @@ -9,7 +9,7 @@ import ( "github.com/pichuchen/hatsuaki/datastore/config" ) -// 這邊會接收所有 /.activitypub/actor 開頭的請求 +// 這邊會接收所有 /.activitypub/actor/inbox 開頭的請求 // 舉例來說會像是 GET /.activitypub/actor/alice func RouteActorInbox(w http.ResponseWriter, r *http.Request) { slog.Debug("activitypub.RouteActorInbox", "request", r.URL.String()) diff --git a/activitypub/outbox.go b/activitypub/outbox.go new file mode 100644 index 0000000..f151930 --- /dev/null +++ b/activitypub/outbox.go @@ -0,0 +1,116 @@ +package activitypub + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/pichuchen/hatsuaki/datastore/actor" + "github.com/pichuchen/hatsuaki/datastore/config" + "github.com/pichuchen/hatsuaki/datastore/object" +) + +// 這邊會接收所有 /.activitypub/actor/{actor}/outbox 開頭的請求 +// 舉例來說會像是 GET /.activitypub/actor/alice/outbox +// Outbox 的用途是讓沒有收到先前消息的人可以查詢某個 Actor 的所有發送過的消息。 +func RouteActorOutbox(w http.ResponseWriter, r *http.Request) { + + // 在 Get 的部分標準中並沒有要求一定要驗證簽章 + // 然而在 Mastdon 的實作當中因為支援封鎖伺服器功能, + // 因此需要鑑別 (Authentication) 來判斷是否有權限查看。 + + username := r.PathValue("actor") + a, err := actor.FindActorByUsername(username) + if err != nil { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "actor not found"}) + return + } + + page := r.URL.Query().Get("page") + if page == "true" { + // 如果有 page=true 的參數,則回傳一個 OrderedCollection + // 這個 OrderedCollection 會包含所有的 Object + RouteActorOutboxPage(w, r, a) + return + } + + w.Header().Set("Content-Type", "application/activity+json") + m := map[string]interface{}{} + + c := []interface{}{} + c = append(c, "https://www.w3.org/ns/activitystreams") + c = append(c, "https://w3id.org/security/v1") + m["@context"] = c + + id := "https://" + config.GetDomain() + "/.activitypub/actor/" + a.GetUsername() + "/outbox" + + // 這邊是在 ActivityPub 中的必要 (MUST) 欄位 + m["id"] = id + m["type"] = "OrderedCollection" + m["totalItems"] = a.GetObjectsCount() + m["first"] = id + "?page=true" + m["last"] = id + "?page=true" + + json.NewEncoder(w).Encode(m) + +} + +// RouteActorOutboxPage 會回傳一個 OrderedCollection +// 這個 OrderedCollection 會包含所有的 Object +func RouteActorOutboxPage(w http.ResponseWriter, r *http.Request, a *actor.Actor) { + w.Header().Set("Content-Type", "application/activity+json") + m := map[string]interface{}{} + + c := []interface{}{} + c = append(c, "https://www.w3.org/ns/activitystreams") + c = append(c, "https://w3id.org/security/v1") + m["@context"] = c + + id := "https://" + config.GetDomain() + "/.activitypub/actor/" + a.GetUsername() + "/outbox" + + // 這邊是在 ActivityPub 中的必要 (MUST) 欄位 + m["id"] = id + m["type"] = "OrderedCollection" + m["totalItems"] = a.GetObjectsCount() + m["first"] = id + "?page=true" + m["last"] = id + "?page=true" + + objectIDs, err := a.GetObjects() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "internal server error"}) + return + } + orderedItems := []interface{}{} + + for _, oid := range objectIDs { + o, err := object.FindObjectByID(oid) + if err != nil { + slog.Warn("activitypub.RouteActorOutboxPage", "error", err.Error()) + continue + } + activityMap := map[string]interface{}{} + actor := "https://" + config.GetDomain() + "/.activitypub/actor/" + a.GetUsername() + activityMap["id"] = o.GetFullID() + "/activity" + activityMap["type"] = "Create" + activityMap["published"] = o.GetPublished() + activityMap["actor"] = actor + + objectMap := map[string]interface{}{} + + objectMap["id"] = o.GetFullID() + objectMap["type"] = "Note" + objectMap["published"] = o.GetPublished() + objectMap["attributedTo"] = o.GetAttributedTo() + objectMap["content"] = o.GetContent() + + activityMap["object"] = objectMap + + orderedItems = append(orderedItems, activityMap) + } + + m["orderedItems"] = orderedItems + + json.NewEncoder(w).Encode(m) +} diff --git a/activitypub/route.go b/activitypub/route.go index a983c75..e6da86e 100644 --- a/activitypub/route.go +++ b/activitypub/route.go @@ -15,6 +15,7 @@ func Route(w http.ResponseWriter, r *http.Request) { mux := http.NewServeMux() mux.HandleFunc("GET /.activitypub/actor/{actor}", RouteActor) mux.HandleFunc("GET /.activitypub/actor/{actor}/inbox", RouteActorInbox) + mux.HandleFunc("GET /.activitypub/actor/{actor}/outbox", RouteActorOutbox) mux.ServeHTTP(w, r) } diff --git a/datastore/actor/object.go b/datastore/actor/object.go new file mode 100644 index 0000000..dca8c6e --- /dev/null +++ b/datastore/actor/object.go @@ -0,0 +1,34 @@ +package actor + +func (a *Actor) GetObjects() ([]string, error) { + n, ok := (*a)["objects"] + if !ok { + return []string{}, nil + } + + is := n.([]interface{}) + objects := make([]string, len(is)) + for i, v := range is { + objects[i] = v.(string) + } + return objects, nil +} + +func (a *Actor) GetObjectsCount() int { + objects, err := a.GetObjects() + if err != nil { + return 0 + } + + return len(objects) +} + +func (a *Actor) AppendObject(objectID string) { + objects, err := a.GetObjects() + if err != nil { + objects = []string{} + } + + objects = append(objects, objectID) + (*a)["objects"] = objects +} diff --git a/datastore/object/main.go b/datastore/object/main.go new file mode 100644 index 0000000..6bcb408 --- /dev/null +++ b/datastore/object/main.go @@ -0,0 +1,85 @@ +package object + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "log/slog" + "math/big" + "os" + "sync" + "time" +) + +type Object map[string]interface{} + +var datastore = &sync.Map{} + +func LoadObject(filepath string) error { + slog.Debug("object.Load", "info", "load objects") + + f, err := os.ReadFile(filepath) + if err != nil { + return err + } + + tmpMap := map[string]interface{}{} + tmpDatastore := sync.Map{} + + err = json.Unmarshal(f, &tmpMap) + if err != nil { + return err + } + + for k, v := range tmpMap { + m := v.(map[string]interface{}) + a := Object(m) + tmpDatastore.Store(k, &a) + } + + // old datastore should be garbage collected + datastore = &tmpDatastore + slog.Info("object.Load", "info", "objects loaded") + return nil +} + +func SaveObject(filepath string) error { + slog.Debug("object.Save", "info", "save objects", "filepath", filepath) + + tmpMap := map[string]interface{}{} + datastore.Range(func(k, v interface{}) bool { + tmpMap[k.(string)] = v + return true + }) + + f, err := json.MarshalIndent(tmpMap, "", " ") + if err != nil { + return err + } + + err = os.WriteFile(filepath, f, 0644) + if err != nil { + return err + } + + slog.Info("object.Save", "info", "objects saved") + return nil +} + +func FindObjectByID(id string) (*Object, error) { + if v, ok := datastore.Load(id); ok { + return v.(*Object), nil + } + return nil, fmt.Errorf("object not found") +} + +func GenerateUUIDv7() string { + // UUIDv7 + var buf [16]byte + rand.Read(buf[:]) + t := big.NewInt(time.Now().UnixMilli()) + t.FillBytes(buf[:6]) + buf[6] = 0x70 | (buf[6] & 0x0f) + buf[8] = 0x80 | (buf[8] & 0x3f) + return fmt.Sprintf("%x-%x-%x-%x-%x", buf[:4], buf[4:6], buf[6:8], buf[8:10], buf[10:]) +} diff --git a/datastore/object/note.go b/datastore/object/note.go new file mode 100644 index 0000000..9ab519a --- /dev/null +++ b/datastore/object/note.go @@ -0,0 +1,52 @@ +package object + +import ( + "strings" + "time" + + "github.com/pichuchen/hatsuaki/datastore/config" +) + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note +func NewNote() *Object { + id := GenerateUUIDv7() + note := Object{ + "id": id, + "type": "Note", + "published": time.Now().Format(time.RFC3339), + } + datastore.Store(id, ¬e) + return ¬e +} + +func (o *Object) GetFullID() string { + id := o.GetID() + if strings.HasPrefix(id, "https://") { + return id + } + return "https://" + config.GetDomain() + "/.activitypub/object/" + id +} + +func (o *Object) GetID() string { + return (*o)["id"].(string) +} + +func (o *Object) GetPublished() string { + return (*o)["published"].(string) +} + +func (o *Object) GetContent() string { + return (*o)["content"].(string) +} + +func (o *Object) SetContent(content string) { + (*o)["content"] = content +} + +func (o *Object) GetAttributedTo() string { + return (*o)["attributedTo"].(string) +} + +func (o *Object) SetAttributedTo(actor string) { + (*o)["attributedTo"] = actor +} diff --git a/serverlet/main.go b/serverlet/main.go index 6c6f1c0..54453b0 100644 --- a/serverlet/main.go +++ b/serverlet/main.go @@ -10,6 +10,7 @@ import ( "github.com/pichuchen/hatsuaki/datastore/actor" "github.com/pichuchen/hatsuaki/datastore/config" + "github.com/pichuchen/hatsuaki/datastore/object" ) // 這個檔案的用途是整個系統的最初進入點 @@ -58,6 +59,31 @@ func main() { slog.Error("main", "error", err) } + err = object.LoadObject("./object.json") + if errors.Is(err, os.ErrNotExist) { + slog.Info("main", "object", "object.json not found, creating a new one") + n := object.NewNote() + a, err := actor.FindActorByUsername("instance.actor") + if err != nil { + slog.Error("main", "error", err) + } + + n.SetContent("Hello, World!") + n.SetAttributedTo("https://" + config.GetDomain() + "/.activitypub/actor/instance.actor") + a.AppendObject(n.GetID()) + + err = object.SaveObject("./object.json") + if err != nil { + slog.Error("main", "error", err) + } + err = actor.SaveActor("./actor.json") + if err != nil { + slog.Error("main", "error", err) + } + } else if err != nil { + slog.Error("main", "error", err) + } + mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { XForwardedFor := r.Header.Get("X-Forwarded-For") diff --git a/web/index/timeline.html b/web/index/timeline.html index a796f72..c7c71d2 100644 --- a/web/index/timeline.html +++ b/web/index/timeline.html @@ -42,43 +42,7 @@