From 6de476260d4aca41768860b1c59198c213e0dfef Mon Sep 17 00:00:00 2001 From: Hoernschen Date: Sat, 17 Oct 2020 12:13:15 +0200 Subject: [PATCH] Finished Prototype --- 1602787120 Baseline Measurement.csv | 11 + config/config.go | 36 ++ config/configController.go | 69 +++ entities/activity/activity.go | 13 + entities/activity/activityController.go | 29 + entities/actor/actor.go | 15 + entities/actor/actorController.go | 75 +++ entities/actor/actorDatabaseConnector.go | 76 +++ entities/collection/collection.go | 5 + entities/collection/collectionController.go | 350 ++++++++++++ .../collection/collectionDatabaseConnector.go | 62 +++ entities/object/object.go | 11 + entities/object/objectController.go | 49 ++ entities/object/objectDatabaseConnector.go | 70 +++ entities/user/user.go | 19 + entities/user/userController.go | 103 ++++ entities/user/userDatabaseConnector.go | 132 +++++ go.mod | 10 + go.sum | 55 ++ main.go | 88 +++ sqlite.db | Bin 0 -> 53248 bytes ssl.crt | 24 + ssl.key | 27 + utils/database/databaseConnector.go | 117 ++++ utils/encryptionService.go | 76 +++ utils/logger.go | 23 + utils/requestChecker.go | 89 +++ utils/router/route.go | 12 + utils/router/router.go | 27 + workloadGenerator.go | 516 ++++++++++++++++++ 30 files changed, 2189 insertions(+) create mode 100644 1602787120 Baseline Measurement.csv create mode 100644 config/config.go create mode 100644 config/configController.go create mode 100644 entities/activity/activity.go create mode 100644 entities/activity/activityController.go create mode 100644 entities/actor/actor.go create mode 100644 entities/actor/actorController.go create mode 100644 entities/actor/actorDatabaseConnector.go create mode 100644 entities/collection/collection.go create mode 100644 entities/collection/collectionController.go create mode 100644 entities/collection/collectionDatabaseConnector.go create mode 100644 entities/object/object.go create mode 100644 entities/object/objectController.go create mode 100644 entities/object/objectDatabaseConnector.go create mode 100644 entities/user/user.go create mode 100644 entities/user/userController.go create mode 100644 entities/user/userDatabaseConnector.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 sqlite.db create mode 100644 ssl.crt create mode 100644 ssl.key create mode 100644 utils/database/databaseConnector.go create mode 100644 utils/encryptionService.go create mode 100644 utils/logger.go create mode 100644 utils/requestChecker.go create mode 100644 utils/router/route.go create mode 100644 utils/router/router.go create mode 100644 workloadGenerator.go diff --git a/1602787120 Baseline Measurement.csv b/1602787120 Baseline Measurement.csv new file mode 100644 index 0000000..3e61a7b --- /dev/null +++ b/1602787120 Baseline Measurement.csv @@ -0,0 +1,11 @@ +Iteration,Start,End +1,1602787120,1602787240 +2,1602787240,1602787360 +3,1602787360,1602787480 +4,1602787480,1602787600 +5,1602787600,1602787720 +6,1602787720,1602787840 +7,1602787840,1602787961 +8,1602787961,1602788081 +9,1602788081,1602788201 +10,1602788201,1602788321 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..313e8aa --- /dev/null +++ b/config/config.go @@ -0,0 +1,36 @@ +package config + +import ( + "time" +) + +var ServerName string = "Hoernschen's ActivityPub Server" +var Version string = "0.1" + +var Homeserver string +var Port string + +var PrivateKey []byte +var PublicKey []byte +var KeyId string +var VerifyKeys map[string]map[string][]byte + +var Packetloss int +var UnavailableTill int64 +var Consensus bool +var AuthentificationCheck bool +var Signing bool +var Encryption bool +var HttpString string + +//var BackoffPolicy *backoff.Exponential + +func SetDefaultParams() { + Packetloss = 0.0 + UnavailableTill = time.Now().Unix() + Consensus = true + AuthentificationCheck = true + Signing = true + Encryption = true + HttpString = "https" +} diff --git a/config/configController.go b/config/configController.go new file mode 100644 index 0000000..74029ce --- /dev/null +++ b/config/configController.go @@ -0,0 +1,69 @@ +package config + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" +) + +type SetParamBody struct { + Packetloss int `json:"packetloss,omitempty"` + UnavailableTill int64 `json:"unavailableTill,omitempty"` + Consensus bool `json:"consensus,omitempty"` + AuthentificationCheck bool `json:"authentificationCheck,omitempty"` + Signing bool `json:"signing,omitempty"` + Encryption bool `json:"encryption,omitempty"` +} + +func SetParams(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + request := SetParamBody{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&request) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Could not parse JSON: %s", err)); err != nil { + panic(err) + } + return + } + + Packetloss = request.Packetloss + UnavailableTill = request.UnavailableTill + Consensus = request.Consensus + AuthentificationCheck = request.AuthentificationCheck + Signing = request.Signing + Encryption = request.Signing + HttpString = "https" + if !Encryption { + HttpString = "http" + } + + w.WriteHeader(http.StatusOK) +} + +func Reset(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + + /* + if err := device.InitServerSigningKey(); err != nil { + log.Fatal(err) + } + config.VerifyKeys = make(map[string]map[string][]byte) + */ + + database.DB.Close() + os.Remove("sqlite.db") + + if err := database.InitDB("sqlite.db"); err != nil { + log.Fatal(err) + } + + SetDefaultParams() + + w.WriteHeader(http.StatusOK) +} diff --git a/entities/activity/activity.go b/entities/activity/activity.go new file mode 100644 index 0000000..8417476 --- /dev/null +++ b/entities/activity/activity.go @@ -0,0 +1,13 @@ +package activity + +import "git.nutfactory.org/hoernschen/ActivityPub/entities/object" + +type Activity struct { + Context string `json:"@context,omitempty"` + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Actor string `json:"actor,omitempty"` + Object *object.Object `json:"object,omitempty"` + Published int64 `json:"published,omitempty"` + To string `json:"to,omitempty"` +} diff --git a/entities/activity/activityController.go b/entities/activity/activityController.go new file mode 100644 index 0000000..38769c7 --- /dev/null +++ b/entities/activity/activityController.go @@ -0,0 +1,29 @@ +package activity + +import ( + "time" + + "git.nutfactory.org/hoernschen/ActivityPub/entities/object" + "git.nutfactory.org/hoernschen/ActivityPub/utils" +) + +func New(id string, actorOfActivity string, objectOfActivity *object.Object, userId string) (newActivity *Activity) { + published := objectOfActivity.Published + to := objectOfActivity.To + if published == 0 { + published = time.Now().Unix() + } + if to == "" { + to = utils.GenerateFollowersUrl(userId) + } + newActivity = &Activity{ + Context: utils.GetDefaultContext(), + Id: id, + Type: "Create", + Actor: actorOfActivity, + Object: objectOfActivity, + Published: published, + To: to, + } + return +} diff --git a/entities/actor/actor.go b/entities/actor/actor.go new file mode 100644 index 0000000..a1d778a --- /dev/null +++ b/entities/actor/actor.go @@ -0,0 +1,15 @@ +package actor + +type Actor struct { + Context string `json:"@context,omitempty"` + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferredUsername,omitempty"` + Summary string `json:"summary,omitempty"` + Inbox string `json:"inbox,omitempty"` + Outbox string `json:"outbox,omitempty"` + Followers string `json:"followers,omitempty"` + Following string `json:"following,omitempty"` + Liked string `json:"liked,omitempty"` +} diff --git a/entities/actor/actorController.go b/entities/actor/actorController.go new file mode 100644 index 0000000..b89b729 --- /dev/null +++ b/entities/actor/actorController.go @@ -0,0 +1,75 @@ +package actor + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "git.nutfactory.org/hoernschen/ActivityPub/config" + "git.nutfactory.org/hoernschen/ActivityPub/utils" +) + +func New(userId string) (newActor *Actor) { + id := utils.GenerateProfileUrl(userId) + newActor = GenerateProfile(id, userId) + return +} + +func GenerateProfile(id string, userId string) (profile *Actor) { + profile = &Actor{ + Context: utils.GetDefaultContext(), + Id: id, + Name: userId, + PreferredUsername: userId, + Type: "Person", + Inbox: utils.GenerateInboxUrl(userId), + Outbox: utils.GenerateOutboxUrl(userId), + Followers: utils.GenerateFollowersUrl(userId), + Following: utils.GenerateFollowingUrl(userId), + Liked: utils.GenerateLikedUrl(userId), + } + return +} + +func GetProfileHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", utils.GetContentTypeString()) + foundActor, err := ReadActor(fmt.Sprintf("%s://%s%s", config.HttpString, config.Homeserver, r.URL.RequestURI())) + if foundActor == nil || err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Actor not found: %s", err)); err != nil { + panic(err) + } + return + } + profile := GenerateProfile(foundActor.Id, foundActor.Name) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(profile); err != nil { + panic(err) + } +} + +func GetProfile(actorId string) (err error, profile *Actor) { + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequest(http.MethodGet, actorId, bytes.NewBuffer(nil)) + if err != nil { + return + } + req.Header["Content-Type"] = []string{utils.GetContentTypeString()} + r, err := client.Do(req) + if err != nil { + return + } + if r.StatusCode != http.StatusOK { + err = utils.HandleHTTPError(r) + return + } else { + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&profile) + if err != nil { + return + } + } + return +} diff --git a/entities/actor/actorDatabaseConnector.go b/entities/actor/actorDatabaseConnector.go new file mode 100644 index 0000000..c4f96f5 --- /dev/null +++ b/entities/actor/actorDatabaseConnector.go @@ -0,0 +1,76 @@ +package actor + +import ( + "fmt" + + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" +) + +func CreateActor(actor *Actor) (err error) { + sqlStmt := fmt.Sprintf(`INSERT INTO actor + (id, type, name, preferredUsername, summary, inbox, outbox, followers, following, liked) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + + tx, err := database.DB.Begin() + if err != nil { + return + } + stmt, err := tx.Prepare(sqlStmt) + if err != nil { + return + } + defer stmt.Close() + _, err = stmt.Exec( + actor.Id, + actor.Type, + actor.Name, + actor.PreferredUsername, + actor.Summary, + actor.Inbox, + actor.Outbox, + actor.Followers, + actor.Following, + actor.Liked, + ) + if err != nil { + tx.Rollback() + return + } + tx.Commit() + return +} + +func ReadActor(id string) (foundActor *Actor, err error) { + queryStmt := fmt.Sprintf(`SELECT id, type, name, preferredUsername, summary, inbox, outbox, followers, following, liked + FROM actor + WHERE id = '%s'`, id) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + if rows.Next() { + foundActor = &Actor{} + err = rows.Scan( + &foundActor.Id, + &foundActor.Type, + &foundActor.Name, + &foundActor.PreferredUsername, + &foundActor.Summary, + &foundActor.Inbox, + &foundActor.Outbox, + &foundActor.Followers, + &foundActor.Following, + &foundActor.Liked, + ) + if err != nil { + return + } + } + + return +} diff --git a/entities/collection/collection.go b/entities/collection/collection.go new file mode 100644 index 0000000..c8d9616 --- /dev/null +++ b/entities/collection/collection.go @@ -0,0 +1,5 @@ +package collection + +type OutboxResponse struct { + Location string `json:"Location,omitempty"` +} diff --git a/entities/collection/collectionController.go b/entities/collection/collectionController.go new file mode 100644 index 0000000..1d4c8ce --- /dev/null +++ b/entities/collection/collectionController.go @@ -0,0 +1,350 @@ +package collection + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "math/rand" + "net/http" + "strings" + "time" + + "git.nutfactory.org/hoernschen/ActivityPub/config" + "git.nutfactory.org/hoernschen/ActivityPub/entities/activity" + "git.nutfactory.org/hoernschen/ActivityPub/entities/actor" + "git.nutfactory.org/hoernschen/ActivityPub/entities/object" + "git.nutfactory.org/hoernschen/ActivityPub/entities/user" + "git.nutfactory.org/hoernschen/ActivityPub/utils" + + "github.com/cenkalti/backoff/v4" + "github.com/gorilla/mux" +) + +func PostInboxHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", utils.GetContentTypeString()) + newActivity := &activity.Activity{} + err := utils.CheckRequest(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + return + } + token, err := utils.GetAccessToken(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + return + } + if token == "" { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Missing Token"); err != nil { + panic(err) + } + return + } + vars := mux.Vars(r) + actorName := vars["actorName"] + if actorName == "" { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Missing Actor"); err != nil { + panic(err) + } + return + } + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&newActivity) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Could not parse JSON: %s", err)); err != nil { + panic(err) + } + return + } + + if newActivity.Type == "Follow" { + err = CreateCollectionObject(utils.GenerateFollowersUrl(actorName), newActivity.Actor) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Collection Object: %s", err)); err != nil { + panic(err) + } + return + } + log.Printf("%s follows %s", newActivity.Actor, newActivity.To) + } else if newActivity.Type == "Create" { + foundObject, err := object.ReadObject(newActivity.Object.Id) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Reading Object: %s", err)); err != nil { + panic(err) + } + return + } + if foundObject == nil { + _ = object.CreateObject(newActivity.Object) + /* + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Object: %s", err)); err != nil { + panic(err) + } + return + } + */ + } + _ = CreateCollectionObject(utils.GenerateInboxUrl(actorName), newActivity.Object.Id) + /* + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Collection Object: %s", err)); err != nil { + panic(err) + } + return + } + */ + log.Printf("%s to %s: %s", newActivity.Actor, newActivity.To, newActivity.Object.Content) + } else { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Unsupported Activity Type"); err != nil { + panic(err) + } + return + } + + w.WriteHeader(http.StatusOK) +} + +func PostOutboxHandler(w http.ResponseWriter, r *http.Request) { + packetLossNumber := rand.Intn(100) + if packetLossNumber > config.Packetloss { + w.Header().Set("Content-Type", utils.GetContentTypeString()) + newActivity := &activity.Activity{} + err := utils.CheckRequest(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + return + } + token, err := utils.GetAccessToken(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + return + } + foundUser, err := user.ReadUserFromToken(token) + if err != nil || foundUser == nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Unknown Token: %s", err)); err != nil { + panic(err) + } + return + } + buf := new(bytes.Buffer) + buf.ReadFrom(r.Body) + err = json.Unmarshal(buf.Bytes(), newActivity) + if err != nil || newActivity.Type == "Note" { + postedObject := &object.Object{} + err = json.Unmarshal(buf.Bytes(), postedObject) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Could not parse JSON: %s")); err != nil { + panic(err) + } + return + } + var newObject *object.Object + if postedObject.Id == "" { + err, newObject = object.New( + postedObject.Type, + postedObject.AttributedTo, + postedObject.Content, + postedObject.Published, + postedObject.To, + foundUser.Id, + ) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Error Creating Object: %s")); err != nil { + panic(err) + } + return + } + err = object.CreateObject(newObject) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Object: %s")); err != nil { + panic(err) + } + return + } + } else { + newObject = postedObject + } + newActivity = activity.New( + newObject.Id, + newObject.AttributedTo, + newObject, + foundUser.Id, + ) + } + + var recipients []string + if newActivity.Type == "Follow" { + err = CreateCollectionObject(utils.GenerateFollowingUrl(foundUser.Id), newActivity.To) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Collection Object: %s")); err != nil { + panic(err) + } + return + } + recipients = append(recipients, newActivity.To) + } else if newActivity.Type == "Create" { + err = CreateCollectionObject(utils.GenerateOutboxUrl(foundUser.Id), newActivity.Id) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Collection Object: %s")); err != nil { + panic(err) + } + return + } + err, recipients = GetReciepientsForActivity(newActivity) + } + + for _, recipient := range recipients { + log.Printf("Send Activity to Recipient %s", recipient) + + operation := func() error { + return SendActivity(newActivity, recipient, token) + } + notify := func(err error, duration time.Duration) { + log.Printf("Error Sending Activity, retrying in %ss: %s", duration/1000000000, err) + } + backoff.RetryNotify(operation, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 16), notify) + + //go retryActivity(newActivity, recipient, token) + } + + response := OutboxResponse{ + Location: newActivity.Id, + } + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + panic(err) + } + } +} + +/* +func retryActivity(activityToSend *activity.Activity, recipient string, token string) (err error) { + b, cancel := config.BackoffPolicy.Start(context.Background()) + defer cancel() + + for backoff.Continue(b) { + err := SendActivity(activityToSend, recipient, token) + if err == nil { + return nil + } + } + err = errors.New("Not able to send activity") + return +} +*/ + +func GetReciepientsForActivity(activityToSend *activity.Activity) (err error, recipientsWithoutDuplicates []string) { + recipients := []string{} + if strings.Contains(activityToSend.To, config.Homeserver) { + var foundActor *actor.Actor + foundActor, err = actor.ReadActor(activityToSend.To) + if err != nil { + return + } + if foundActor == nil { + var foundCollectionObjects []string + foundCollectionObjects, err = ReadCollectionObjects(activityToSend.To) + if err != nil { + return + } + if len(foundCollectionObjects) <= 0 { + err = errors.New("No Recipients") + return + } + recipients = append(recipients, foundCollectionObjects...) + } else { + recipients = append(recipients, foundActor.Id) + } + } else { + // Not Implemented + } + recipientsWithoutDuplicates = utils.RemoveDuplicates(recipients) + return +} + +func SendActivity(activityToSend *activity.Activity, recipient string, token string) (err error) { + if strings.Contains(recipient, config.Homeserver) { + if activityToSend.Type == "Follow" { + followers := fmt.Sprintf("%sfollowers/", activityToSend.To) + err = CreateCollectionObject(followers, activityToSend.Actor) + if err != nil { + return + } + log.Printf("%s follows %s", activityToSend.Actor, activityToSend.To) + } else if activityToSend.Type == "Create" { + id := activityToSend.Id + if activityToSend.Object != nil { + id = activityToSend.Object.Id + } + inbox := fmt.Sprintf("%sinbox", recipient) + err = CreateCollectionObject(inbox, id) + if err != nil { + return + } + log.Printf("%s to %s: %s", activityToSend.Actor, recipient, activityToSend.Object.Content) + } + if err != nil { + return + } + } else { + activityToSend.To = recipient + var profile *actor.Actor + err, profile = actor.GetProfile(recipient) + if err != nil { + return + } + var reqBody []byte + reqBody, err = json.Marshal(activityToSend) + if err != nil { + return + } + client := &http.Client{Timeout: 2 * time.Second} + var req *http.Request + log.Printf("Inbox: %s", profile.Inbox) + req, err = http.NewRequest(http.MethodPost, profile.Inbox, bytes.NewBuffer(reqBody)) + if err != nil { + return + } + req.Header["Content-Type"] = []string{utils.GetContentTypeString()} + req.Header["Authorization"] = []string{fmt.Sprintf("Bearer %s")} + var res *http.Response + res, err = client.Do(req) + if err != nil { + return + } + if res.StatusCode != http.StatusOK { + err = utils.HandleHTTPError(res) + return + } + } + return +} diff --git a/entities/collection/collectionDatabaseConnector.go b/entities/collection/collectionDatabaseConnector.go new file mode 100644 index 0000000..7d00302 --- /dev/null +++ b/entities/collection/collectionDatabaseConnector.go @@ -0,0 +1,62 @@ +package collection + +import ( + "fmt" + + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" +) + +func CreateCollectionObject(collectionId string, objectId string) (err error) { + sqlStmt := fmt.Sprintf(`INSERT INTO collectionObject + (collectionId, objectId) + VALUES + (?, ?)`) + + tx, err := database.DB.Begin() + if err != nil { + return + } + + stmt, err := tx.Prepare(sqlStmt) + if err != nil { + return + } + defer stmt.Close() + + _, err = stmt.Exec( + collectionId, + objectId, + ) + if err != nil { + tx.Rollback() + return + } + tx.Commit() + return +} + +func ReadCollectionObjects(collectionId string) (collectionObjects []string, err error) { + queryStmt := fmt.Sprintf(`SELECT objectId + FROM collectionObject + WHERE collectionId = '%s'`, collectionId) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + for rows.Next() { + var collectionObject string + err = rows.Scan( + &collectionObject, + ) + if err != nil { + return + } + collectionObjects = append(collectionObjects, collectionObject) + } + + return +} diff --git a/entities/object/object.go b/entities/object/object.go new file mode 100644 index 0000000..d53ef8c --- /dev/null +++ b/entities/object/object.go @@ -0,0 +1,11 @@ +package object + +type Object struct { + Context string `json:"@context,omitempty"` + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + AttributedTo string `json:"attributedTo,omitempty"` + Content string `json:"content,omitempty"` + Published int64 `json:"published,omitempty"` + To string `json:"to,omitempty"` +} diff --git a/entities/object/objectController.go b/entities/object/objectController.go new file mode 100644 index 0000000..0d1d956 --- /dev/null +++ b/entities/object/objectController.go @@ -0,0 +1,49 @@ +package object + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "git.nutfactory.org/hoernschen/ActivityPub/config" + "git.nutfactory.org/hoernschen/ActivityPub/utils" +) + +func New(objectType string, attributedTo string, content string, published int64, to string, userId string) (err error, newObject *Object) { + err, id := utils.CreateUUID() + url := fmt.Sprintf("%s://%s/%s/posts/%s", config.HttpString, config.Homeserver, userId, id) + if published == 0 { + published = time.Now().Unix() + } + if attributedTo == "" { + attributedTo = utils.GenerateProfileUrl(userId) + } + newObject = &Object{ + Context: utils.GetDefaultContext(), + Id: url, + Type: objectType, + AttributedTo: attributedTo, + Content: content, + Published: published, + To: to, + } + return +} + +func GetPostHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", utils.GetContentTypeString()) + foundObject, err := ReadObject(r.URL.RequestURI()) + if foundObject == nil || err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Post not found: %s", err)); err != nil { + panic(err) + } + return + } + foundObject.Context = utils.GetDefaultContext() + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(foundObject); err != nil { + panic(err) + } +} diff --git a/entities/object/objectDatabaseConnector.go b/entities/object/objectDatabaseConnector.go new file mode 100644 index 0000000..516cc18 --- /dev/null +++ b/entities/object/objectDatabaseConnector.go @@ -0,0 +1,70 @@ +package object + +import ( + "fmt" + + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" +) + +func CreateObject(object *Object) (err error) { + sqlStmt := fmt.Sprintf(`INSERT INTO object + (id, type, attributedTo, content, published, toActor) + VALUES + (?, ?, ?, ?, ?, ?)`) + + tx, err := database.DB.Begin() + if err != nil { + return + } + + stmt, err := tx.Prepare(sqlStmt) + if err != nil { + return + } + defer stmt.Close() + + _, err = stmt.Exec( + object.Id, + object.Type, + object.AttributedTo, + object.Content, + object.Published, + object.To, + ) + if err != nil { + tx.Rollback() + return + } + tx.Commit() + return +} + +func ReadObject(id string) (foundObject *Object, err error) { + queryStmt := fmt.Sprintf(`SELECT id, type, attributedTo, content, published, toActor + FROM object + WHERE id = '%s'`, id) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + if rows.Next() { + foundObject = &Object{} + err = rows.Scan( + &foundObject.Id, + &foundObject.Type, + &foundObject.AttributedTo, + &foundObject.Content, + &foundObject.Published, + &foundObject.To, + ) + if err != nil { + return + } + } + + return +} diff --git a/entities/user/user.go b/entities/user/user.go new file mode 100644 index 0000000..1c103ca --- /dev/null +++ b/entities/user/user.go @@ -0,0 +1,19 @@ +package user + +type User struct { + Id string `json:"id,omitempty"` + Password string `json:"password,omitempty"` + Actor string `json:"actor,omitempty"` + Sessions []string `json:"sessions,omitempty"` +} + +type RegisterRequest struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +type RegisterResponse struct { + UserId string `json:"user_id,omitempty"` + Token string `json:"token,omitempty"` + Actor string `json:"actor,omitempty"` +} diff --git a/entities/user/userController.go b/entities/user/userController.go new file mode 100644 index 0000000..a6271b8 --- /dev/null +++ b/entities/user/userController.go @@ -0,0 +1,103 @@ +package user + +import ( + "encoding/json" + "fmt" + "net/http" + + "git.nutfactory.org/hoernschen/ActivityPub/entities/actor" + "git.nutfactory.org/hoernschen/ActivityPub/utils" +) + +func New(id string, password string) (err error, newUser *User) { + err, hashedPassword := utils.Hash([]byte(password)) + if err != nil { + return + } + newUser = &User{ + Id: id, + Password: hashedPassword, + } + return +} + +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + request := RegisterRequest{} + err := utils.CheckRequest(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + return + } + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&request) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Could not parse JSON: %s"); err != nil { + panic(err) + } + return + } + err, newUser := New(request.Username, request.Password) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Error creating User Object: %s")); err != nil { + panic(err) + } + return + } + foundUser, err := ReadUser(newUser.Id) + if foundUser != nil || err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Username already in use: %s", err)); err != nil { + panic(err) + } + return + } + newActor := actor.New(newUser.Id) + err = actor.CreateActor(newActor) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(fmt.Sprintf("Database Error Creating Actor: %s")); err != nil { + panic(err) + } + return + } + newUser.Actor = newActor.Id + err = CreateUser(newUser) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Database Error Creating User: %s"); err != nil { + panic(err) + } + return + } + err, token := utils.CreateToken() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Error Creating Token: %s"); err != nil { + panic(err) + } + return + } + err = CreateSession(newUser.Id, token) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode("Error Creating Session: %s"); err != nil { + panic(err) + } + return + } + response := RegisterResponse{ + UserId: newUser.Id, + Token: token, + Actor: newActor.Id, + } + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + panic(err) + } +} diff --git a/entities/user/userDatabaseConnector.go b/entities/user/userDatabaseConnector.go new file mode 100644 index 0000000..fd633d5 --- /dev/null +++ b/entities/user/userDatabaseConnector.go @@ -0,0 +1,132 @@ +package user + +import ( + "fmt" + + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" +) + +func CreateUser(user *User) (err error) { + sqlStmt := fmt.Sprintf(`INSERT INTO user + (id, password, actor) + VALUES + (?, ?, ?)`) + + tx, err := database.DB.Begin() + if err != nil { + return + } + + stmt, err := tx.Prepare(sqlStmt) + if err != nil { + return + } + defer stmt.Close() + + _, err = stmt.Exec(user.Id, user.Password, user.Actor) + if err != nil { + tx.Rollback() + return + } + tx.Commit() + return +} + +func CreateSession(userId string, token string) (err error) { + sqlStmt := fmt.Sprintf(`INSERT INTO session + (token, userId) + VALUES + (?, ?)`) + + tx, err := database.DB.Begin() + if err != nil { + return + } + + stmt, err := tx.Prepare(sqlStmt) + if err != nil { + return + } + defer stmt.Close() + + _, err = stmt.Exec(token, userId) + if err != nil { + tx.Rollback() + return + } + tx.Commit() + return +} + +func ReadUser(id string) (foundUser *User, err error) { + queryStmt := fmt.Sprintf(`SELECT id, password, actor + FROM user + WHERE id = '%s'`, id) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + if rows.Next() { + foundUser = &User{} + err = rows.Scan(&foundUser.Id, &foundUser.Password, &foundUser.Actor) + if err != nil { + return + } + foundUser.Sessions, err = ReadSessionsForUser(foundUser.Id) + } + + return +} + +func ReadUserFromToken(token string) (foundUser *User, err error) { + queryStmt := fmt.Sprintf(`SELECT u.id, u.password, u.actor + FROM user as u + join session as s on u.id = s.userId + WHERE s.token = '%s'`, token) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + if rows.Next() { + foundUser = &User{} + err = rows.Scan(&foundUser.Id, &foundUser.Password, &foundUser.Actor) + if err != nil { + return + } + foundUser.Sessions, err = ReadSessionsForUser(foundUser.Id) + } + + return +} + +func ReadSessionsForUser(userId string) (sessions []string, err error) { + queryStmt := fmt.Sprintf(`SELECT token + FROM session + WHERE userId = '%s'`, userId) + + rows, err := database.DB.Query(queryStmt) + if err != nil { + return + } + + defer rows.Close() + + for rows.Next() { + var session string + err = rows.Scan(&session) + if err != nil { + return + } + sessions = append(sessions, session) + } + + return +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4444fd --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.nutfactory.org/hoernschen/ActivityPub + +go 1.14 + +require ( + github.com/cenkalti/backoff/v4 v4.1.0 + github.com/gorilla/mux v1.8.0 + github.com/lestrrat-go/backoff v1.0.0 + github.com/mattn/go-sqlite3 v1.14.4 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7a899de --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +git.nutfactory.org/hoernschen/Matrix v0.0.0-20201012141628-da9196f38986 h1:kR6rjqDOGSfjWYQqTiy1HGLrAnbZvAwSkum4DMzj+5U= +git.nutfactory.org/hoernschen/Matrix v0.0.0-20201012141628-da9196f38986/go.mod h1:ewiecRT3bLcsnjp5kKuEXOrOGk/0nojoJFNnGaAyGXw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/cenkalti/backoff v1.1.0 h1:QnvVp8ikKCDWOsFheytRCoYWYPO/ObCTBGxT19Hc+yE= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc= +github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lestrrat-go/backoff v1.0.0 h1:nR+UgAhdhwfw2i+xznuHRlj81oMYa7u3lXun0xcsXUU= +github.com/lestrrat-go/backoff v1.0.0/go.mod h1:c7OnDlnHsFXbH1vyIS8+txH+THcc+QFlSQTrJVe4EIM= +github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA= +github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI= +github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d h1:/iIZNFGxc/a7C3yWjGcnboV+Tkc7mxr+p6fDztwoxuM= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2020.1.5 h1:nI5egYTGJakVyOryqLs1cQO5dO0ksin5XXs2pspk75k= +honnef.co/go/tools v0.0.1-2020.1.5/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= diff --git a/main.go b/main.go new file mode 100644 index 0000000..53eeac9 --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "crypto/tls" + "log" + "net/http" + "os" + + "git.nutfactory.org/hoernschen/ActivityPub/config" + "git.nutfactory.org/hoernschen/ActivityPub/entities/actor" + "git.nutfactory.org/hoernschen/ActivityPub/entities/collection" + "git.nutfactory.org/hoernschen/ActivityPub/entities/object" + "git.nutfactory.org/hoernschen/ActivityPub/entities/user" + "git.nutfactory.org/hoernschen/ActivityPub/utils/database" + "git.nutfactory.org/hoernschen/ActivityPub/utils/router" +) + +var keyPath = "./ssl.key" +var certPath = "./ssl.crt" + +var routes = router.Routes{ + // General + router.Route{"Reset", "GET", "/reset", config.Reset}, + router.Route{"SetParams", "GET", "/setparams", config.SetParams}, + + // Users + router.Route{"Register", "POST", "/register", user.RegisterHandler}, + + // Actors + router.Route{"GetProfile", "GET", "/{actorName}/", actor.GetProfileHandler}, + + // Objects + router.Route{"GetPost", "GET", "/{actorName}/posts/{postId}", object.GetPostHandler}, + + // Collections + /* + router.Route{"GetInbox", "GET", "/{actorName}/inbox", collection.GetInboxHandler}, + router.Route{"GetOutbox", "GET", "/{actorName}/outbox", collection.GetOutboxHandler}, + router.Route{"GetFollowers", "GET", "/{actorName}/followers", collection.GetFollowersHandler}, + router.Route{"GetFollowing", "GET", "/{actorName}/following", collection.GetFollowingHandler}, + router.Route{"GetLiked", "GET", "/{actorName}/liked", collection.GetLikedHandler}, + */ + router.Route{"PostInbox", "POST", "/{actorName}/inbox/", collection.PostInboxHandler}, + router.Route{"PostOutbox", "POST", "/{actorName}/outbox/", collection.PostOutboxHandler}, + /* + router.Route{"PostFollowers", "POST", "/{actorName}/followers", collection.PostFollowersHandler}, + router.Route{"PostFollowing", "POST", "/{actorName}/following", collection.PostFollowingHandler}, + router.Route{"PostLiked", "POST", "/{actorName}/liked", collection.PostLikedHandler}, + */ +} + +func main() { + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + config.Homeserver = "localhost" + if len(os.Args) > 1 { + config.Homeserver = os.Args[1] + } + log.Printf("Start homeserver on name %s", config.Homeserver) + + /* + if err := device.InitServerSigningKey(); err != nil { + log.Fatal(err) + } + config.VerifyKeys = make(map[string]map[string][]byte) + */ + /* + config.BackoffPolicy = backoff.NewExponential( + backoff.WithInterval(500*time.Millisecond), + backoff.WithMaxRetries(16), + ) + */ + os.Remove("sqlite.db") + + if err := database.InitDB("sqlite.db"); err != nil { + log.Fatal(err) + } + defer database.DB.Close() + + config.SetDefaultParams() + + router := router.NewRouter(routes) + + httpErr := http.ListenAndServeTLS(":443", certPath, keyPath, router) + if httpErr != nil { + log.Fatal(httpErr) + } + +} diff --git a/sqlite.db b/sqlite.db new file mode 100644 index 0000000000000000000000000000000000000000..f7bc89867e1e8a5b96951290106c309dc4a44776 GIT binary patch literal 53248 zcmeI%%Wl&^6o6s77pJ*3Wl>pCg>V;$Ml?uKmk5=BqN<|Y(jc`96gi0triq=}j@m8* zFTjF*S@H_3cmQ691uKL-&K+4i!2FdQd&c(oobOC6S#$jQ$PO&y%=LO^VC*RyimED) z4MR~BE!^_qHvWs+E8|AES7(ls4mIV$!MA+jx01`=D1}nt=la+6U-=*F&D{6=ms~mb zIeU}+lXNrv2q1s}0tg_000Ia|Ch#_!(zmwM;jO@IbuHiWecN?LyWG=yt=gy=jp~!5 znlb7$b~5R7;GSEK(Wsp?j92x;=hgbD@vL^bYlNo;zU3Wu#JNnmRL*I7bxT!jr(<3D z7v1nvplJ>QSG1d>A)A%aLEcQK^vz9mcvnOd4Ha#fxj3R85riE-<(~-prte?6UVL^p z49RQ4&iI2~&-AV*x7kk1y_&S#K``4qbGu#l((?St1#yqg0?70tg_000IagfB*srARwCn>;JM< zoO}^L009ILKmY**5I_I{1Y{Io{a?nalQRMcAb