Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

595
db/util/activitypub.go Normal file
View File

@@ -0,0 +1,595 @@
package util
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"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", strings.ToLower(u.GetString("username")))
record.Set("preferred_username", 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
}
record, err := app.FindFirstRecordByData("trails", "iri", t.ID.String())
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
}
}
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 := 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 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("username"), trail.Id)
activityContent := fmt.Sprintf("<h1>%s</h1>%s<p><a href=\"%s\">%s</a></p>", 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
}
}
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("username"), list.Id)
activityContent := fmt.Sprintf("%s<p><a href=\"%s\">%s</a></p>", 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
}

View File

@@ -17,12 +17,15 @@ type EmailData struct {
}
var notificationTemplates = map[NotificationType]string{
TrailCreate: "{{.Author}} has created a new trail: {{.trail}}.",
TrailShare: "{{.Author}} has shared a trail with you: {{.trail}}.",
ListCreate: "{{.Author}} has created a new list: {{.list}}.",
ListShare: "{{.Author}} has shared a list with you: {{.list}}.",
NewFollower: "Good news! You have a new follower: {{.Author}}.",
TrailComment: "{{.Author}} commented on your trail '{{.trail}}': '{{.comment}}'.",
TrailShare: "{{.Author}} has shared a trail with you: {{.trail}}.",
ListShare: "{{.Author}} has shared a list with you: {{.list}}.",
NewFollower: "Good news! You have a new follower: {{.Author}}.",
TrailComment: "{{.Author}} commented on your trail '{{.trail_name}}': '{{.comment}}'.",
SummitLogCreate: "{{.Author}} created a summit log on your trail '{{.trail_name}}'.",
TrailLike: "{{.Author}} liked your trail '{{.trail_name}}'.",
CommentMention: "{{.Author}} mentioned you in a comment.",
TrailMention: "{{.Author}} mentioned you in a trail.",
SummitLogMention: "{{.Author}} mentioned you in a summit log.",
}
func GenerateHTML(appUrl string, recipientName string, authorName string, notificationType NotificationType, metadata map[string]string) (string, error) {

View File

@@ -2,12 +2,17 @@ package util
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/twpayne/go-gpx"
"github.com/twpayne/go-polyline"
@@ -31,16 +36,32 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
tags[i] = v.GetString("name")
}
polyline, err := getPolyline(app, r)
category := ""
trailCategory := r.ExpandedOne("category")
if trailCategory != nil {
category = trailCategory.GetString("name")
}
// polyline, err := getPolyline(app, r)
// if err != nil {
// polyline = ""
// }
domain := ""
if !author.GetBool("isLocal") {
domain = author.GetString("domain")
}
logCount, err := app.CountRecords("summit_logs", dbx.NewExp("trail={:id}", dbx.Params{"id": r.Id}))
if err != nil {
return nil, err
}
document := map[string]interface{}{
document := map[string]any{
"id": r.Id,
"author": r.GetString("author"),
"author": author.Id,
"author_name": author.GetString("username"),
"author_avatar": author.GetString("avatar"),
"author_avatar": author.GetString("icon"),
"name": r.GetString("name"),
"description": r.GetString("description"),
"location": r.GetString("location"),
@@ -49,15 +70,17 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
"elevation_loss": r.GetFloat("elevation_loss"),
"duration": r.GetFloat("duration"),
"difficulty": r.Get("difficulty"),
"category": r.Get("category"),
"completed": len(r.GetStringSlice("summit_logs")) > 0,
"category": category,
"completed": logCount > 0,
"date": r.GetDateTime("date").Time().Unix(),
"created": r.GetDateTime("created").Time().Unix(),
"public": r.GetBool("public"),
"thumbnail": thumbnail,
"gpx": r.GetString("gpx"),
"tags": tags,
"polyline": polyline,
// "polyline": polyline,
"domain": domain,
"iri": r.GetString("iri"),
"_geo": map[string]float64{
"lat": r.GetFloat("lat"),
"lng": r.GetFloat("lon"),
@@ -66,6 +89,9 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
if includeShares {
document["shares"] = []string{}
document["likes"] = []string{}
document["like_count"] = 0
}
return document, nil
@@ -109,28 +135,124 @@ func getPolyline(app core.App, r *core.Record) (string, error) {
return string(polyline.EncodeCoords(coordinates)), nil
}
func documentFromListRecord(r *core.Record, includeShares bool) map[string]interface{} {
func documentFromListRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) {
totalElevationGain := 0.0
totalElevationLoss := 0.0
totalDistance := 0.0
totalDuration := 0.0
trails := len(r.GetStringSlice("trails"))
if r.GetString("iri") != "" {
doc, err := documentFromRemoteRecord(r, "lists")
if err == nil {
totalElevationGain = doc["elevation_gain"].(float64)
totalElevationLoss = doc["elevation_loss"].(float64)
totalDistance = doc["distance"].(float64)
totalDuration = doc["duration"].(float64)
trails = int(doc["trails"].(float64))
}
} else {
allTrails := r.ExpandedAll("trails")
for _, t := range allTrails {
totalElevationGain += t.GetFloat("elevation_gain")
totalElevationLoss += t.GetFloat("elevation_loss")
totalDistance += t.GetFloat("distance")
totalDuration += t.GetFloat("duration")
}
}
document := map[string]interface{}{
"id": r.Id,
"author": r.GetString("author"),
"name": r.GetString("name"),
"description": r.GetString("description"),
"public": r.GetBool("public"),
"created": r.GetDateTime("created").Time().Unix(),
"trails": r.GetStringSlice("trails"),
"id": r.Id,
"author": author.Id,
"author_name": author.GetString("username"),
"author_avatar": author.GetString("icon"),
"avatar": r.GetString("avatar"),
"name": r.GetString("name"),
"description": r.GetString("description"),
"elevation_gain": totalElevationGain,
"elevation_loss": totalElevationLoss,
"distance": totalDistance,
"duration": totalDuration,
"public": r.GetBool("public"),
"created": r.GetDateTime("created").Time().Unix(),
"trails": trails,
"iri": r.GetString("iri"),
}
if includeShares {
document["shares"] = []string{}
}
return document
return document, nil
}
func documentFromRemoteRecord(r *core.Record, index string) (map[string]interface{}, error) {
client := &http.Client{}
if r.GetString("iri") == "" {
return nil, fmt.Errorf("record has no iri")
}
iri := r.GetString("iri")
url, err := url.Parse(iri)
if err != nil {
return nil, err
}
remoteRecordId := path.Base(url.Path)
searchURL := fmt.Sprintf("%s://%s/api/v1/search/%s", url.Scheme, url.Host, index)
body := []byte(fmt.Sprintf(`{"q": "%s"}`, remoteRecordId))
req, err := http.NewRequest("POST", searchURL, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch remote record: received status %d", resp.StatusCode)
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var searchResponse meilisearch.SearchResponse
json.Unmarshal(respBytes, &searchResponse)
if len(searchResponse.Hits) == 0 {
return nil, fmt.Errorf("no documents in result set")
}
document, ok := searchResponse.Hits[0].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected document format")
}
return document, nil
}
func IndexTrail(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"tags"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
return fmt.Errorf("failed to expand tags: %v", errs)
}
errs = app.ExpandRecord(r, []string{"category"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand category: %v", errs)
}
doc, err := documentFromTrailRecord(app, r, author, true)
if err != nil {
@@ -148,10 +270,14 @@ func IndexTrail(app core.App, r *core.Record, author *core.Record, client meilis
func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"tags"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand: %v", errs)
return fmt.Errorf("failed to expand tags: %v", errs)
}
errs = app.ExpandRecord(r, []string{"category"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand category: %v", errs)
}
doc, err := documentFromTrailRecord(app, r, author, true)
doc, err := documentFromTrailRecord(app, r, author, false)
if err != nil {
return err
}
@@ -177,20 +303,49 @@ func UpdateTrailShares(trailId string, shares []string, client meilisearch.Servi
return nil
}
func IndexList(r *core.Record, client meilisearch.ServiceManager) error {
documents := []map[string]interface{}{documentFromListRecord(r, true)}
func UpdateTrailLikes(trailId string, likes []string, client meilisearch.ServiceManager) error {
documents := []map[string]interface{}{
{
"id": trailId,
"like_count": len(likes),
"likes": likes,
},
}
if _, err := client.Index("trails").UpdateDocuments(documents); err != nil {
return err
}
return nil
}
if _, err := client.Index("lists").AddDocuments(documents); err != nil {
func IndexList(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"trails"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand trails: %v", errs)
}
documents, err := documentFromListRecord(r, author, true)
if err != nil {
return err
}
if _, err = client.Index("lists").AddDocuments(documents); err != nil {
return err
}
return nil
}
func UpdateList(r *core.Record, client meilisearch.ServiceManager) error {
documents := documentFromListRecord(r, false)
func UpdateList(app core.App, r *core.Record, author *core.Record, client meilisearch.ServiceManager) error {
errs := app.ExpandRecord(r, []string{"trails"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand trails: %v", errs)
}
if _, err := client.Index("lists").UpdateDocuments(documents); err != nil {
documents, err := documentFromListRecord(r, author, false)
if err != nil {
return err
}
if _, err = client.Index("lists").UpdateDocuments(documents); err != nil {
return err
}

View File

@@ -11,12 +11,15 @@ import (
type NotificationType string
const (
TrailCreate NotificationType = "trail_create"
TrailShare NotificationType = "trail_share"
ListCreate NotificationType = "list_create"
ListShare NotificationType = "list_share"
NewFollower NotificationType = "new_follower"
TrailComment NotificationType = "trail_comment"
TrailShare NotificationType = "trail_share"
ListShare NotificationType = "list_share"
NewFollower NotificationType = "new_follower"
TrailComment NotificationType = "trail_comment"
TrailLike NotificationType = "trail_like"
SummitLogCreate NotificationType = "summit_log_create"
CommentMention NotificationType = "comment_mention"
TrailMention NotificationType = "trail_mention"
SummitLogMention NotificationType = "summit_log_mention"
)
type Notification struct {
@@ -54,11 +57,14 @@ func getNotificationPermissions(app core.App, user string, notificationType Noti
return &settingsForType, nil
}
func SendNotification(app core.App, notification Notification, recipient string) error {
if notification.Author == recipient {
func SendNotification(app core.App, notification Notification, recipient *core.Record) error {
if notification.Author == recipient.Id {
return nil
}
permissions, err := getNotificationPermissions(app, recipient, notification.Type)
if !recipient.GetBool("isLocal") {
return nil
}
permissions, err := getNotificationPermissions(app, recipient.GetString("user"), notification.Type)
if err != nil {
return err
}
@@ -73,7 +79,7 @@ func SendNotification(app core.App, notification Notification, recipient string)
n.Set("type", string(notification.Type))
n.Set("metadata", notification.Metadata)
n.Set("seen", notification.Seen)
n.Set("recipient", recipient)
n.Set("recipient", recipient.Id)
n.Set("author", notification.Author)
if err := app.Save(n); err != nil {
@@ -82,15 +88,15 @@ func SendNotification(app core.App, notification Notification, recipient string)
}
if permissions.Email {
recipientUser, err := app.FindRecordById("users", recipient)
recipientActor, err := app.FindRecordById("activitypub_actors", recipient.Id)
if err != nil {
return err
}
authorUser, err := app.FindRecordById("users", notification.Author)
authorActor, err := app.FindRecordById("activitypub_actors", notification.Author)
if err != nil {
return err
}
html, err := GenerateHTML(app.Settings().Meta.AppURL, recipientUser.GetString("username"), authorUser.GetString("username"), notification.Type, notification.Metadata)
html, err := GenerateHTML(app.Settings().Meta.AppURL, recipientActor.GetString("username"), authorActor.GetString("username"), notification.Type, notification.Metadata)
if err != nil {
return err
}
@@ -100,27 +106,12 @@ func SendNotification(app core.App, notification Notification, recipient string)
Address: app.Settings().Meta.SenderAddress,
Name: app.Settings().Meta.SenderName,
},
To: []mail.Address{{Address: recipientUser.Email()}},
To: []mail.Address{{Address: recipientActor.Email()}},
Subject: "wanderer - New Notification",
HTML: html,
}
app.NewMailClient().Send(message)
}
return nil
}
func SendNotificationToFollowers(app core.App, notification Notification) error {
followers, err := app.FindRecordsByFilter("follows", "followee={:user}", "", -1, 0, dbx.Params{"user": notification.Author})
if err != nil {
return err
}
for _, f := range followers {
recipient := f.GetString("follower")
SendNotification(app, notification, recipient)
return app.NewMailClient().Send(message)
}
return nil
}