package federation import ( "context" "database/sql" "fmt" "net/url" "os" "path" "strconv" "strings" "time" "pocketbase/util" pub "github.com/go-ap/activitypub" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/security" "golang.org/x/net/html" ) func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, typ pub.ActivityVocabularyType) error { if !trail.GetBool("public") { // only broadcast the trail if it is public return nil } origin := os.Getenv("ORIGIN") if origin == "" { return fmt.Errorf("ORIGIN not set") } trailAuthor, err := app.FindRecordById("activitypub_actors", trail.GetString("author")) if err != nil { return err } collection, err := app.FindCollectionByNameOrId("activitypub_activities") if err != nil { return err } recordId := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := "https://www.w3.org/ns/activitystreams#Public" mentionedActors, handles, err := ActorsFromMentions(app, actor, trail.GetString("description")) if err != nil { return err } mentions := []string{} cc := pub.ItemCollection{pub.IRI(trailAuthor.GetString("followers"))} tags := pub.ItemCollection{} for i, m := range mentionedActors { inbox := m.GetString("inbox") mention := pub.MentionNew(pub.IRI(m.GetString("iri"))) mention.Href = pub.IRI(m.GetString("iri")) mention.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, handles[i])) tags.Append(mention) mentions = append(mentions, inbox) cc.Append(pub.IRI(inbox)) } trailObject, err := util.ObjectFromTrail(app, trail, &tags) if err != nil { return err } activity := pub.ActivityNew(pub.IRI(id), typ, trailObject) activity.Actor = pub.IRI(trailAuthor.GetString("iri")) activity.To = pub.ItemCollection{pub.IRI(to)} activity.CC = cc activity.Published = time.Now() record := core.NewRecord(collection) record.Set("id", recordId) record.Set("iri", id) record.Set("to", []string{to}) record.Set("cc", cc) record.Set("type", string(typ)) record.Set("object", trailObject) record.Set("actor", trailAuthor.GetString("iri")) record.Set("published", time.Now()) err = app.Save(record) if err != nil { return err } follows, err := app.FindRecordsByFilter("follows", "followee={:followee}&&status='accepted'", "", -1, 0, dbx.Params{"followee": trailAuthor.Id}) if err != nil { return err } recipients := mentions for _, f := range follows { follower, err := app.FindRecordById("activitypub_actors", f.GetString("follower")) if err != nil { return err } recipients = append(recipients, follower.GetString("inbox")) } return PostActivity(app, trailAuthor, activity, recipients) } func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Record, typ pub.ActivityVocabularyType) error { origin := os.Getenv("ORIGIN") if origin == "" { return fmt.Errorf("ORIGIN not set") } // author of the comment commentAuthor, err := app.FindRecordById("activitypub_actors", comment.GetString("author")) if err != nil { return err } commentTrail, err := app.FindRecordById("trails", comment.GetString("trail")) if err != nil { return err } commentTrailAuthor, err := app.FindRecordById("activitypub_actors", commentTrail.GetString("author")) if err != nil { return err } activityRecordId := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId) to := "https://www.w3.org/ns/activitystreams#Public" mentionedActors, handles, err := ActorsFromMentions(app, actor, comment.GetString("text")) if err != nil { return err } recipients := []string{} tags := pub.ItemCollection{} for i, m := range mentionedActors { mention := pub.MentionNew(pub.IRI(m.GetString("iri"))) mention.Href = pub.IRI(m.GetString("iri")) mention.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, handles[i])) tags.Append(mention) recipients = append(recipients, m.GetString("inbox")) } recipients = append(recipients, commentTrailAuthor.GetString("inbox")) cc := pub.ItemCollection{} for _, r := range recipients { cc.Append(pub.IRI(r)) } author := commentAuthor.GetString("iri") commentObject, err := util.ObjectFromComment(app, comment, &tags) if err != nil { return err } activity := pub.ActivityNew(pub.IRI(id), typ, commentObject) activity.Actor = pub.IRI(author) activity.To = pub.ItemCollection{pub.IRI(to)} activity.CC = cc activity.Published = time.Now() activity.Object = commentObject collection, err := app.FindCollectionByNameOrId("activitypub_activities") if err != nil { return err } record := core.NewRecord(collection) record.Set("id", activityRecordId) record.Set("iri", id) record.Set("to", []string{to}) record.Set("cc", recipients) record.Set("type", string(typ)) record.Set("object", commentObject) record.Set("actor", author) record.Set("published", time.Now()) err = app.Save(record) if err != nil { return err } return PostActivity(app, commentAuthor, activity, recipients) } func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.Record, typ pub.ActivityVocabularyType) error { origin := os.Getenv("ORIGIN") if origin == "" { return fmt.Errorf("ORIGIN not set") } summitLogAuthor, err := app.FindRecordById("activitypub_actors", summitLog.GetString("author")) if err != nil { return err } var summitLogAuthorId string // first check if we find the trail locally summitLogTrail, err := app.FindRecordById("trails", summitLog.GetString("trail")) if err != nil { return err } if !summitLogTrail.GetBool("public") { // only broadcast the log if the trail it belongs to is public return nil } summitLogAuthorId = summitLogTrail.GetString("author") summitLogTrailAuthor, err := app.FindRecordById("activitypub_actors", summitLogAuthorId) if err != nil { return err } collection, err := app.FindCollectionByNameOrId("activitypub_activities") if err != nil { return err } var trailIRI pub.IRI if summitLogTrailAuthor.GetBool("isLocal") { trailId := summitLog.GetString("trail") trailIRI = pub.IRI(fmt.Sprintf("%s/api/v1/trail/%s", origin, trailId)) } else { trailIRI = pub.IRI(summitLogTrail.GetString("iri")) } recordId := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId) to := pub.ItemCollection{pub.IRI("https://www.w3.org/ns/activitystreams#Public")} // someone else created the summit log on the trail -> inform the trail's author if summitLogAuthor.Id != summitLogTrailAuthor.Id { to.Append(pub.IRI(summitLogTrailAuthor.GetString("iri"))) } mentionedActors, handles, err := ActorsFromMentions(app, actor, summitLog.GetString("text")) if err != nil { return err } mentions := []string{} cc := pub.ItemCollection{pub.IRI(summitLogAuthor.GetString("followers"))} mentionTags := pub.ItemCollection{} for i, m := range mentionedActors { inbox := m.GetString("inbox") mention := pub.MentionNew(pub.IRI(m.GetString("iri"))) mention.Href = pub.IRI(m.GetString("iri")) mention.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, handles[i])) mentionTags.Append(mention) mentions = append(mentions, inbox) cc.Append(pub.IRI(inbox)) } photos := summitLog.GetStringSlice("photos") gpx := "" if summitLog.GetString("gpx") != "" { gpx = fmt.Sprintf("%s/api/v1/files/summit_logs/%s/%s", origin, summitLog.Id, summitLog.GetString("gpx")) } attachments := make(pub.ItemCollection, max(len(photos), 2)) for i := range len(photos) { iri := fmt.Sprintf("%s/api/v1/files/summit_logs/%s/%s", origin, summitLog.Id, photos[i]) attachments[i] = pub.Document{ Type: pub.ImageType, MediaType: "image/jpeg", URL: pub.IRI(iri), } } if gpx != "" { attachments.Append(pub.Document{ Type: pub.DocumentType, MediaType: "application/xml+gpx", URL: pub.IRI(gpx), }) } tags := pub.ItemCollection{ pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "elevation_gain")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, fmt.Sprintf("%fm", summitLog.GetFloat("elevation_gain")))), }, pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "elevation_loss")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, fmt.Sprintf("%fm", summitLog.GetFloat("elevation_loss")))), }, pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "distance")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, fmt.Sprintf("%fm", summitLog.GetFloat("distance")))), }, pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "duration")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, fmt.Sprintf("%fm", summitLog.GetFloat("duration")))), }, } for _, m := range mentionTags { tags.Append(m) } logObject := pub.ObjectNew(pub.NoteType) logObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, summitLog.GetString("text"))) logObject.AttributedTo = pub.IRI(summitLogAuthor.GetString("iri")) logObject.Published = summitLog.GetDateTime("created").Time() logObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/summit-log/%s", origin, summitLog.Id)) logObject.URL = pub.IRI(fmt.Sprintf("%s/trail/view/@%s/%s", origin, summitLogTrailAuthor.GetString("preferred_username"), summitLog.GetString("trail"))) logObject.InReplyTo = trailIRI logObject.Tag = tags logObject.StartTime = summitLog.GetDateTime("date").Time() logObject.Attachment = attachments activity := pub.ActivityNew(pub.IRI(id), typ, logObject) activity.Actor = pub.IRI(summitLogAuthor.GetString("iri")) activity.To = to activity.CC = cc activity.Published = time.Now() follows, err := app.FindRecordsByFilter("follows", "followee={:followee}&&status='accepted'", "", -1, 0, dbx.Params{"followee": summitLogAuthor.Id}) if err != nil { return err } recipients := mentions for _, f := range follows { follower, err := app.FindRecordById("activitypub_actors", f.GetString("follower")) if err != nil { return err } recipients = append(recipients, follower.GetString("inbox")) } if summitLogAuthor.Id != summitLogTrailAuthor.Id { recipients = append(recipients, summitLogTrailAuthor.GetString("inbox")) } err = PostActivity(app, summitLogAuthor, activity, recipients) if err != nil { return err } record := core.NewRecord(collection) record.Set("id", recordId) record.Set("iri", id) record.Set("to", to) record.Set("cc", cc) record.Set("type", string(typ)) record.Set("object", logObject) record.Set("actor", summitLogAuthor.GetString("iri")) record.Set("published", time.Now()) return app.Save(record) } func CreateListActivity(app core.App, list *core.Record, typ pub.ActivityVocabularyType) error { if !list.GetBool("public") { // only broadcast the list if it is public return nil } origin := os.Getenv("ORIGIN") if origin == "" { return fmt.Errorf("ORIGIN not set") } // author of the list listAuthor, err := app.FindRecordById("activitypub_actors", list.GetString("author")) if err != nil { return err } activityRecordId := security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet) id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId) to := "https://www.w3.org/ns/activitystreams#Public" cc := listAuthor.GetString("followers") author := listAuthor.GetString("iri") listObject, err := util.ObjectFromList(app, list) if err != nil { return err } activity := pub.ActivityNew(pub.IRI(id), typ, listObject) activity.Actor = pub.IRI(author) activity.To = pub.ItemCollection{pub.IRI(to)} activity.CC = pub.ItemCollection{pub.IRI(cc)} activity.Published = time.Now() activity.Object = listObject collection, err := app.FindCollectionByNameOrId("activitypub_activities") if err != nil { return err } follows, err := app.FindRecordsByFilter("follows", "followee={:followee}&&status='accepted'", "", -1, 0, dbx.Params{"followee": listAuthor.Id}) if err != nil { return err } recipients := []string{} for _, f := range follows { follower, err := app.FindRecordById("activitypub_actors", f.GetString("follower")) if err != nil { return err } recipients = append(recipients, follower.GetString("inbox")) } err = PostActivity(app, listAuthor, activity, recipients) if err != nil { return err } record := core.NewRecord(collection) record.Set("id", activityRecordId) record.Set("iri", id) record.Set("to", []string{to}) record.Set("cc", []string{cc}) record.Set("type", string(typ)) record.Set("object", listObject) record.Set("actor", author) record.Set("published", time.Now()) return app.Save(record) } func ProcessCreateOrUpdateActivity(app core.App, actor *core.Record, activity pub.Activity) error { var err error if strings.Contains(activity.Object.GetID().String(), "/api/v1/trail") { err = processCreateOrUpdateTrailActivity(activity, app, actor) } else if strings.Contains(activity.Object.GetID().String(), "/api/v1/summit-log") { err = processCreateOrUpdateSummitLogActivity(activity, app, actor) } else if strings.Contains(activity.Object.GetID().String(), "/api/v1/list") { err = processCreateOrUpdateListActivity(activity, app, actor) } else { err = processCreateOrUpdateCommentActivity(activity, app, actor) } if err != nil { return err } return nil } func processCreateOrUpdateTrailActivity(activity pub.Activity, app core.App, actor *core.Record) error { // no need to do anything if the actor is local if actor.GetBool("isLocal") { return nil } trail, err := util.TrailFromActivity(activity, app, actor) trailObject, _ := pub.ToObject(activity.Object) for _, t := range trailObject.Tag { if t.GetType() == pub.MentionType { mention := t.(*pub.Mention) mentionedActor, err := app.FindFirstRecordByData("activitypub_actors", "iri", mention.Href.GetID().String()) if err != nil { continue } notification := util.Notification{ Type: util.TrailMention, Metadata: map[string]string{ "id": trail.Id, "author": fmt.Sprintf("@%s@%s", actor.GetString("preferred_username"), actor.GetString("domain")), }, Seen: false, Author: actor.Id, } return util.SendNotification(app, notification, mentionedActor) } } return err } func processCreateOrUpdateCommentActivity(activity pub.Activity, app core.App, actor *core.Record) error { commentObject, err := pub.ToObject(activity.Object) if err != nil { return err } if commentObject.InReplyTo == nil { return fmt.Errorf("error processing comment: InReplyTo empty") } trailUrl, err := url.Parse(commentObject.InReplyTo.GetLink().String()) if err != nil { return err } trailId := path.Base(trailUrl.Path) var trail *core.Record trail, err = app.FindFirstRecordByFilter("trails", "iri={:iri} || id={:id}", dbx.Params{"id": trailId, "iri": commentObject.InReplyTo.GetID().String()}) // if the trail is not present on this instance fetch it if err != nil { if err == sql.ErrNoRows { trailObject, err := util.TrailObjectFromIRI(commentObject.InReplyTo.GetLink().String()) if err != nil { return err } activity := pub.ActivityNew(pub.IRI("new"), pub.CreateType, trailObject) trail, err = util.TrailFromActivity(*activity, app, actor) if err != nil { return err } } else { return err } } trailAuthor, err := app.FindRecordById("activitypub_actors", trail.GetString("author")) if err != nil { return err } // no need to do anything else if the actor is local if actor.GetBool("isLocal") { return nil } record, err := app.FindFirstRecordByData("comments", "iri", commentObject.ID.String()) if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("comments") if err != nil { return err } record = core.NewRecord(collection) } else { return err } } record.Set("iri", commentObject.ID.String()) record.Set("text", commentObject.Content.First().Value) record.Set("author", actor.Id) record.Set("trail", trail.Id) err = app.Save(record) if err != nil { return err } // send notifications to all mentioned actors for _, t := range commentObject.Tag { if t.GetType() == pub.MentionType { mention := t.(*pub.Mention) mentionedActor, err := app.FindFirstRecordByData("activitypub_actors", "iri", mention.Href.GetID().String()) if err != nil { continue } notification := util.Notification{ Type: util.CommentMention, Metadata: map[string]string{ "comment": commentObject.Content.First().Value.String(), "trail_id": trail.Id, "trail_name": trail.GetString("name"), "trail_author": fmt.Sprintf("@%s@%s", trailAuthor.GetString("preferred_username"), trailAuthor.GetString("domain")), }, Seen: false, Author: actor.Id, } return util.SendNotification(app, notification, mentionedActor) } } if activity.Type == pub.CreateType { // send a notification to the trail author notification := util.Notification{ Type: util.TrailComment, Metadata: map[string]string{ "comment": commentObject.Content.First().Value.String(), "trail_id": trail.Id, "trail_name": trail.GetString("name"), "trail_author": fmt.Sprintf("@%s@%s", trailAuthor.GetString("preferred_username"), trailAuthor.GetString("domain")), }, Seen: false, Author: actor.Id, } return util.SendNotification(app, notification, trailAuthor) } return nil } func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App, actor *core.Record) error { logObject, err := pub.ToObject(activity.Object) if err != nil { return err } trailIRI, err := url.Parse(logObject.InReplyTo.GetID().String()) if err != nil { return err } trailId := path.Base(trailIRI.Path) trail, err := app.FindFirstRecordByFilter("trails", "iri={:iri} || id={:id}", dbx.Params{"id": trailId, "iri": logObject.InReplyTo.GetID().String()}) // if the trail is not present on this instance fetch it if err != nil { if err == sql.ErrNoRows { trailObject, err := util.TrailObjectFromIRI(logObject.InReplyTo.GetLink().String()) if err != nil { return err } activity := pub.ActivityNew(pub.IRI("new"), pub.CreateType, trailObject) trail, err = util.TrailFromActivity(*activity, app, actor) if err != nil { return err } } else { return err } } trailAuthor, err := app.FindRecordById("activitypub_actors", trail.GetString("author")) if err != nil { return err } newSummitLog := false record, err := app.FindFirstRecordByData("summit_logs", "iri", logObject.ID.String()) if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("summit_logs") if err != nil { return err } record = core.NewRecord(collection) newSummitLog = true } else { return err } } // no need to do anything else if the actor is local if actor.GetBool("isLocal") { return nil } var distance, duration, elevation_gain, elevation_loss float64 tags, err := pub.ToItemCollection(logObject.Tag) if err != nil { return err } for _, tag := range tags.Collection() { tagObj, err := pub.ToObject(tag) if err != nil { continue } content := tagObj.Content.First().Value.String() switch tagObj.Name.First().Value.String() { case "elevation_gain": elevation_gain, err = strconv.ParseFloat(content[:len(content)-1], 64) case "elevation_loss": elevation_loss, err = strconv.ParseFloat(content[:len(content)-1], 64) case "duration": duration, err = strconv.ParseFloat(content[:len(content)-1], 64) case "distance": distance, err = strconv.ParseFloat(content[:len(content)-1], 64) } if err != nil { continue } } record.Set("date", logObject.StartTime) record.Set("text", logObject.Content.First().Value) record.Set("distance", distance) record.Set("duration", duration) record.Set("elevation_gain", elevation_gain) record.Set("elevation_loss", elevation_loss) record.Set("author", actor.Id) record.Set("trail", trail.Id) record.Set("iri", logObject.ID.String()) if logObject.Attachment != nil { attachments, err := pub.ToItemCollection(logObject.Attachment) if err != nil { return err } photoURLs := []string{} gpxURL := "" for _, a := range attachments.Collection() { attachment, err := pub.ToObject(a) if err != nil { continue } if attachment.Type == pub.DocumentType && attachment.MediaType == "application/xml+gpx" { gpxURL = attachment.URL.GetLink().String() } else if attachment.Type == pub.ImageType { photoURLs = append(photoURLs, attachment.URL.GetLink().String()) } } if len(photoURLs) > 0 { photos := make([]*filesystem.File, len(photoURLs)) for i, purl := range photoURLs { photo, err := filesystem.NewFileFromURL(context.Background(), purl) if err != nil { continue } photos[i] = photo } record.Set("photos", photos) } if gpxURL != "" { gpx, err := filesystem.NewFileFromURL(context.Background(), gpxURL) if err != nil { return err } record.Set("gpx", gpx) } } err = app.Save(record) if err != nil { return err } // send notifications to all mentioned actors for _, t := range logObject.Tag { if t.GetType() == pub.MentionType { mention := t.(*pub.Mention) mentionedActor, err := app.FindFirstRecordByData("activitypub_actors", "iri", mention.Href.GetID().String()) if err != nil { continue } notification := util.Notification{ Type: util.SummitLogMention, Metadata: map[string]string{ "trail_id": trail.Id, "trail_name": trail.GetString("name"), "trail_author": fmt.Sprintf("@%s@%s", trailAuthor.GetString("preferred_username"), trailAuthor.GetString("domain")), }, Seen: false, Author: actor.Id, } return util.SendNotification(app, notification, mentionedActor) } } if newSummitLog { // send a notification to the trail author notification := util.Notification{ Type: util.SummitLogCreate, Metadata: map[string]string{ "trail_id": trail.Id, "trail_name": trail.GetString("name"), "trail_author": fmt.Sprintf("@%s@%s", trailAuthor.GetString("preferred_username"), trailAuthor.GetString("domain")), }, Seen: false, Author: actor.Id, } return util.SendNotification(app, notification, trailAuthor) } return nil } func processCreateOrUpdateListActivity(activity pub.Activity, app core.App, actor *core.Record) error { // no need to do anything if the actor is local if actor.GetBool("isLocal") { return nil } _, err := util.ListFromActivity(activity, app, actor) return err } func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*core.Record, []string, error) { doc, err := html.Parse(strings.NewReader(htmlStr)) if err != nil { return nil, nil, err } var handles []string var actors []*core.Record var f func(*html.Node) f = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "a" { var isMention bool for _, attr := range n.Attr { if attr.Key == "class" && strings.Contains(attr.Val, "mention") { isMention = true break } } if isMention && n.FirstChild != nil && n.FirstChild.Type == html.TextNode { handle := strings.TrimSpace(n.FirstChild.Data) if strings.HasPrefix(handle, "@") { handles = append(handles, handle) } } } for c := n.FirstChild; c != nil; c = c.NextSibling { f(c) } } f(doc) for _, h := range handles { actor, err := GetActorByHandle(app, actor, h, false) if err != nil { continue } actors = append(actors, actor) } return actors, handles, nil }