package util import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "database/sql" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "net/url" "os" "path" "strconv" "strings" "time" pub "github.com/go-ap/activitypub" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/security" ) func ActorFromUser(app core.App, u *core.Record) (*core.Record, error) { encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") if len(encryptionKey) == 0 { return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set") } collection, err := app.FindCollectionByNameOrId("activitypub_actors") if err != nil { return nil, err } priv, pub, err := generateKeyPair() if err != nil { return nil, err } privBytes := x509.MarshalPKCS1PrivateKey(priv) privEncrypted, err := security.Encrypt(privBytes, encryptionKey) if err != nil { return nil, err } pubBytes, err := x509.MarshalPKIXPublicKey(pub) if err != nil { return nil, err } pubPem := pem.EncodeToMemory(&pem.Block{ Type: "PUBLIC KEY", Bytes: pubBytes, }) settings, err := app.FindFirstRecordByData("settings", "user", u.Id) if err != nil { return nil, err } record := core.NewRecord(collection) origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN environment variable not set") } id := fmt.Sprintf("%s/api/v1/activitypub/user/%s", origin, strings.ToLower(u.GetString("username"))) url, err := url.Parse(origin) if err != nil { return nil, err } domain := strings.TrimPrefix(url.Hostname(), "www.") record.Set("username", u.GetString("username")) record.Set("preferred_username", strings.ToLower(u.GetString("username"))) record.Set("domain", domain) record.Set("summary", settings.GetString("bio")) record.Set("published", u.GetDateTime("created")) record.Set("iri", id) if u.GetString("avatar") != "" { record.Set("icon", fmt.Sprintf("%s/api/v1/files/users/%s/%s", origin, u.Id, u.GetString("avatar"))) } record.Set("inbox", id+"/inbox") record.Set("outbox", id+"/outbox") record.Set("followers", id+"/followers") record.Set("following", id+"/following") record.Set("isLocal", true) record.Set("public_key", string(pubPem)) record.Set("private_key", privEncrypted) record.Set("user", u.Id) record.Set("last_fetched", time.Now()) err = app.Save(record) if err != nil { return nil, err } return record, nil } func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, nil, err } pub := &priv.PublicKey return priv, pub, nil } func SyncOutbox(app core.App, actor *core.Record) error { return fetchOutboxPage(app, actor, actor.GetString("outbox")+"?page=1") } func fetchOutboxPage(app core.App, actor *core.Record, pageURL string) error { client := &http.Client{} req, err := http.NewRequest(http.MethodGet, pageURL, nil) if err != nil { return err } req.Header.Add("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`) resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } var page pub.OrderedCollectionPage err = json.Unmarshal(body, &page) if err != nil { return err } for _, item := range page.OrderedItems { activity, err := pub.ToActivity(item) if err != nil { return err } if activity.Type != pub.CreateType { continue } } if page.Next != nil { return fetchOutboxPage(app, actor, page.Next.GetID().String()) } return nil } func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) { t, err := pub.ToObject(activity.Object) if err != nil { return nil, err } iri := t.ID.String() var record *core.Record if actor.GetBool(("isLocal")) { trailUrl, parseErr := url.Parse(iri) if parseErr != nil { return nil, parseErr } trailId := path.Base(trailUrl.Path) record, err = app.FindRecordById("trails", trailId) } else { record, err = app.FindFirstRecordByData("trails", "iri", iri) } if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("trails") if err != nil { return nil, err } record = core.NewRecord(collection) record.Set("id", security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet)) } else { return nil, err } } else { // this trail exists already // nothing more to do return record, nil } var distance, duration, elevation_gain, elevation_loss float64 var diffculty, category string trailTags := []string{} tags, err := pub.ToItemCollection(t.Tag) if err != nil { return nil, 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 "category": category = content case "difficulty": diffculty = content 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) case "tag": existingTag, err := app.FindFirstRecordByData("tags", "name", content) if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("tags") if err != nil { continue } existingTag = core.NewRecord(collection) existingTag.Set("name", content) err = app.Save(existingTag) if err != nil { continue } } else { continue } } trailTags = append(trailTags, existingTag.Id) } if err != nil { continue } } record.Set("name", t.Name.First().Value) record.Set("description", t.Content.First().Value) record.Set("location", t.Location.(*pub.Place).Name.First().Value) record.Set("lat", t.Location.(*pub.Place).Latitude) record.Set("lon", t.Location.(*pub.Place).Longitude) record.Set("distance", distance) record.Set("elevation_gain", elevation_gain) record.Set("elevation_loss", elevation_loss) record.Set("duration", duration) record.Set("difficulty", diffculty) record.Set("date", t.StartTime.Unix()) record.Set("tags", trailTags) record.Set("public", true) record.Set("iri", t.ID.String()) record.Set("author", actor.Id) categoryRecord, err := app.FindFirstRecordByData("categories", "name", category) if err == nil { record.Set("category", categoryRecord.Id) } if t.Attachment != nil { attachments, err := pub.ToItemCollection(t.Attachment) if err != nil { return nil, 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 := []*filesystem.File{} 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 nil, err } record.Set("gpx", gpx) } } return record, app.Save(record) } func ObjectFromTrail(app core.App, trail *core.Record, mentions *pub.ItemCollection) (*pub.Object, error) { origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN not set") } trailAuthor, err := app.FindRecordById("activitypub_actors", trail.GetString("author")) if err != nil { return nil, err } errs := app.ExpandRecord(trail, []string{"tags"}, nil) if len(errs) > 0 { return nil, fmt.Errorf("failed to expand tags: %v", errs) } errs = app.ExpandRecord(trail, []string{"category"}, nil) if len(errs) > 0 { return nil, fmt.Errorf("failed to expand category: %v", errs) } category := "" categoryRecord := trail.ExpandedOne("category") if categoryRecord != nil { category = categoryRecord.GetString("name") } tagRecords := trail.ExpandedAll("tags") tags := pub.ItemCollection{ pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "category")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, category)), }, pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "difficulty")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, trail.GetString("difficulty"))), }, pub.Object{ Type: pub.NoteType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "elevation_gain")), Content: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, fmt.Sprintf("%fm", trail.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", trail.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", trail.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", trail.GetFloat("duration")))), }, } if mentions != nil { for _, m := range *mentions { tags.Append(m) } } for _, v := range tagRecords { hashtag := pub.ObjectNew(pub.NoteType) hashtag.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, "tag")) hashtag.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, v.GetString("name"))) tags.Append(hashtag) } photos := trail.GetStringSlice("photos") gpx := "" if trail.GetString("gpx") != "" { gpx = fmt.Sprintf("%s/api/v1/files/trails/%s/%s", origin, trail.Id, trail.GetString("gpx")) } attachments := make(pub.ItemCollection, max(len(photos), 2)) for i := range min(len(photos), 3) { iri := fmt.Sprintf("%s/api/v1/files/trails/%s/%s", origin, trail.Id, photos[i]) attachments[i] = pub.Image{ 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), }) } activityURL := fmt.Sprintf("%s/trail/view/@%s/%s", origin, trailAuthor.GetString("preferred_username"), trail.Id) activityContent := fmt.Sprintf("

%s

%s

%s

", trail.GetString("name"), trail.GetString("description"), activityURL, activityURL) trailObject := pub.ObjectNew(pub.NoteType) trailObject.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, trail.GetString("name"))) trailObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, activityContent)) trailObject.Location = pub.Place{ Type: pub.PlaceType, Name: pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, trail.GetString("location"))), Latitude: trail.GetFloat("lat"), Longitude: trail.GetFloat("lon"), } trailObject.AttributedTo = pub.IRI(trailAuthor.GetString("iri")) trailObject.Published = trail.GetDateTime("created").Time() trailObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/trail/%s", origin, trail.Id)) trailObject.URL = pub.IRI(activityURL) trailObject.StartTime = trail.GetDateTime("date").Time() trailObject.Attachment = attachments trailObject.Tag = tags return trailObject, nil } func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) { l, err := pub.ToObject(activity.Object) if err != nil { return nil, err } record, err := app.FindFirstRecordByData("lists", "iri", l.ID.String()) if err != nil { if err == sql.ErrNoRows { collection, err := app.FindCollectionByNameOrId("lists") if err != nil { return nil, err } record = core.NewRecord(collection) record.Set("id", security.RandomStringWithAlphabet(core.DefaultIdLength, core.DefaultIdAlphabet)) } else { return nil, err } } else { // this list exists already // nothing more to do return record, nil } record.Set("name", l.Name.First().Value) record.Set("description", l.Content.First().Value) record.Set("public", true) record.Set("iri", l.ID.String()) record.Set("author", actor.Id) if l.Attachment != nil { avatarURL := "" attachments, err := pub.ToItemCollection(l.Attachment) if err != nil { return nil, err } for _, a := range attachments.Collection() { attachment, err := pub.ToObject(a) if err != nil { continue } if attachment.Type == pub.ImageType { avatarURL = attachment.URL.GetLink().String() } } if avatarURL != "" { avatar, err := filesystem.NewFileFromURL(context.Background(), avatarURL) if err != nil { return nil, err } record.Set("avatar", avatar) } } err = app.Save(record) return record, err } func ObjectFromList(app core.App, list *core.Record) (*pub.Object, error) { origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN not set") } listAuthor, err := app.FindRecordById("activitypub_actors", list.GetString("author")) if err != nil { return nil, err } avatar := "" if list.GetString("avatar") != "" { avatar = fmt.Sprintf("%s/api/v1/files/lists/%s/%s", origin, list.Id, list.GetString("avatar")) } attachments := make(pub.ItemCollection, 2) if avatar != "" { attachments[0] = pub.Image{ Type: pub.ImageType, MediaType: "image/jpeg", URL: pub.IRI(avatar), } } activityURL := fmt.Sprintf("%s/lists/@%s/%s", origin, listAuthor.GetString("preferred_username"), list.Id) activityContent := fmt.Sprintf("%s

%s

", list.GetString("description"), activityURL, activityURL) listObject := pub.ObjectNew(pub.NoteType) listObject.Name = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, list.GetString("name"))) listObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, activityContent)) listObject.AttributedTo = pub.IRI(listAuthor.GetString("iri")) listObject.Published = list.GetDateTime("created").Time() listObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/list/%s", origin, list.Id)) listObject.URL = pub.IRI(activityURL) listObject.Attachment = attachments return listObject, nil } func ObjectFromComment(app core.App, comment *core.Record, mentions *pub.ItemCollection) (*pub.Object, error) { origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN not set") } commentAuthor, err := app.FindRecordById("activitypub_actors", comment.GetString("author")) if err != nil { return nil, err } commentTrail, err := app.FindRecordById("trails", comment.GetString("trail")) if err != nil { return nil, err } commentTrailAuthor, err := app.FindRecordById("activitypub_actors", commentTrail.GetString("author")) if err != nil { return nil, err } trailURL := "" if commentTrailAuthor.GetBool("isLocal") { trailURL = fmt.Sprintf("https://%s/api/v1/trail/%s", commentTrailAuthor.GetString("domain"), comment.GetString("trail")) } else { trailURL = commentTrail.GetString("iri") } commentObject := pub.ObjectNew(pub.NoteType) commentObject.ID = pub.IRI(fmt.Sprintf("%s/api/v1/comment/%s", origin, comment.Id)) commentObject.Content = pub.NaturalLanguageValuesNew(pub.LangRefValueNew(pub.NilLangRef, comment.GetString("text"))) commentObject.Published = comment.GetDateTime("created").Time() commentObject.AttributedTo = pub.IRI(commentAuthor.GetString("iri")) commentObject.InReplyTo = pub.IRI(trailURL) if mentions != nil { commentObject.Tag = *mentions } return commentObject, nil } func TrailObjectFromIRI(iri string) (*pub.Object, error) { fetchURL := strings.Replace(iri, "api/v1/trail", "api/v1/activitypub/trail", 1) client := &http.Client{} req, err := http.NewRequest(http.MethodGet, fetchURL, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var object pub.Object err = json.Unmarshal(body, &object) if err != nil { return nil, err } return &object, nil }