Federation Refactoring & Architecture Improvements (#930)
* initial commit * add lists * update permissions * fix waypoint create * needs_full_sync for activitypub trails * fixes build issues * fix list get * further CSRF protection * update API docs * adds rate limiter * fix tiptap mentions * a bit more cleanup of main.go * Fix migration order * fixes reviewed notes * require context for activitpub server calls * improve hashing for identifier * fix copy paste error * fix sync trail/list issues * fix summit log/comment duplicates * adaptions after trail merge * fix dockerignore --------- Co-authored-by: Christian Beutel <> Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
210
db/routes/activitypub.go
Normal file
210
db/routes/activitypub.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func ActivitypubActor(e *core.RequestEvent) error {
|
||||
resource := e.Request.URL.Query().Get("resource")
|
||||
resource = strings.TrimPrefix(resource, "acct:")
|
||||
|
||||
iri := e.Request.URL.Query().Get("iri")
|
||||
follows := e.Request.URL.Query().Get("follows") == "true"
|
||||
|
||||
var userActor *core.Record
|
||||
var err error
|
||||
if e.Auth != nil {
|
||||
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var actor *core.Record
|
||||
if resource != "" {
|
||||
actor, err = federation.GetActorByHandle(e.App, ctx, resource, follows)
|
||||
} else {
|
||||
actor, err = federation.GetActorByIRI(e.App, ctx, iri, follows)
|
||||
}
|
||||
if err != nil && actor == nil {
|
||||
if strings.HasPrefix(err.Error(), "webfinger") {
|
||||
return e.NotFoundError("Not found", err)
|
||||
}
|
||||
return err
|
||||
} else if err != nil && actor != nil {
|
||||
if errors.Is(err, federation.ErrProfilePrivate) {
|
||||
// this is our own profile
|
||||
if e.Auth != nil && actor.GetString("user") == e.Auth.Id {
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
||||
} else {
|
||||
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
||||
}
|
||||
}
|
||||
// we could not fetch the remote actor so we return our local cached copy
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": err.Error()})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
||||
}
|
||||
|
||||
func ActivitypubActivityProcess(e *core.RequestEvent) error {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return fmt.Errorf("ORIGIN not set")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(e.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var activity pub.Activity
|
||||
err = activity.UnmarshalJSON(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inbox := fmt.Sprintf("%s%s", origin, e.Request.Header.Get("X-Forwarded-Path"))
|
||||
|
||||
recipient, err := e.App.FindFirstRecordByData("activitypub_actors", "inbox", inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "iri", activity.Actor.GetID().String())
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
ctx, err := util.GetSafeActorContext(e.Request, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor, err = federation.GetActorByIRI(e.App, ctx, activity.Actor.GetID().String(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
verified, err := util.VerifySignature(e.App, e.Request, actor.GetString("public_key"))
|
||||
if err != nil || !verified {
|
||||
e.App.Logger().Error(err.Error())
|
||||
return e.UnauthorizedError("Invalid http signature", err)
|
||||
}
|
||||
|
||||
switch activity.Type {
|
||||
case pub.FollowType:
|
||||
err = federation.ProcessFollowActivity(e.App, actor, activity)
|
||||
case pub.AcceptType:
|
||||
err = federation.ProcessAcceptActivity(e.App, actor, activity)
|
||||
case pub.UndoType:
|
||||
err = federation.ProcessUndoActivity(e.App, actor, activity)
|
||||
case pub.UpdateType:
|
||||
fallthrough
|
||||
case pub.CreateType:
|
||||
err = federation.ProcessCreateOrUpdateActivity(e.App, actor, recipient, activity)
|
||||
case pub.DeleteType:
|
||||
err = federation.ProcessDeleteActivity(e.App, actor, activity)
|
||||
case pub.AnnounceType:
|
||||
err = federation.ProcessAnnounceActivity(e.App, actor, activity)
|
||||
case pub.LikeType:
|
||||
err = federation.ProcessLikeActivity(e.App, actor, activity)
|
||||
}
|
||||
return e.JSON(http.StatusOK, err)
|
||||
}
|
||||
|
||||
func ActivitypubActorFollow(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
followType := e.Request.PathValue("follow")
|
||||
page := e.Request.URL.Query().Get("page")
|
||||
intPage := 0
|
||||
|
||||
if page != "" {
|
||||
var err error
|
||||
intPage, err = strconv.Atoi(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
actor, err := e.App.FindRecordById("activitypub_actors", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := actor.GetString(followType)
|
||||
|
||||
if url == "" {
|
||||
return e.BadRequestError("unknown type: "+followType, nil)
|
||||
}
|
||||
collection, err := federation.FetchCollection(e.App, ctx, fmt.Sprintf("%s?page=%d", url, intPage))
|
||||
if err != nil {
|
||||
if errors.Is(err, federation.ErrProfilePrivate) {
|
||||
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
||||
} else if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, collection)
|
||||
}
|
||||
|
||||
func ActivitypubTrail(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trailObject, err := util.ObjectFromTrail(e.App, trail, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, trailObject)
|
||||
}
|
||||
|
||||
func ActivitypubComment(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
comment, err := e.App.FindRecordById("comments", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commentObject, err := util.ObjectFromComment(e.App, comment, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, commentObject)
|
||||
}
|
||||
50
db/routes/auth_token.go
Normal file
50
db/routes/auth_token.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func AuthToken(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
return apis.NewBadRequestError("Failed to read request data", err)
|
||||
}
|
||||
|
||||
hashedAPIToken := security.SHA256(data.APIToken)
|
||||
|
||||
tokenRecord, err := e.App.FindFirstRecordByFilter(
|
||||
"api_tokens",
|
||||
"token = {:hash}",
|
||||
map[string]any{"hash": hashedAPIToken},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return apis.NewNotFoundError("Invalid or revoked API token", nil)
|
||||
}
|
||||
if !tokenRecord.GetDateTime("expiration").IsZero() &&
|
||||
tokenRecord.GetDateTime("expiration").Time().Before(time.Now()) {
|
||||
return apis.NewBadRequestError("Key has expired", nil)
|
||||
}
|
||||
|
||||
tokenRecord.Set("last_used", time.Now())
|
||||
if err := e.App.Save(tokenRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRecord, _ := e.App.FindRecordById("users", tokenRecord.GetString("user"))
|
||||
token, err := userRecord.NewAuthToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"token": token,
|
||||
"record": userRecord,
|
||||
})
|
||||
}
|
||||
11
db/routes/health.go
Normal file
11
db/routes/health.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func Health(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
81
db/routes/integration_hammerhead.go
Normal file
81
db/routes/integration_hammerhead.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/hammerhead"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationHammerheadUpload(e *core.RequestEvent) error {
|
||||
h, err := loginHammerhead(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.UploadActivities(e); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func IntegrationHammerheadLogin(e *core.RequestEvent) error {
|
||||
_, err := loginHammerhead(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func loginHammerhead(e *core.RequestEvent) (*hammerhead.HammerheadApi, error) {
|
||||
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return nil, e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return nil, apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
hammerheadString := integration.GetString("hammerhead")
|
||||
if len(hammerheadString) == 0 {
|
||||
return nil, apis.NewBadRequestError("hammerhead integration missing", nil)
|
||||
}
|
||||
var hammerheadIntegration hammerhead.HammerheadIntegration
|
||||
err = json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &hammerhead.HammerheadApi{}
|
||||
|
||||
err = k.Login(hammerheadIntegration.Email, string(decryptedPassword))
|
||||
if err != nil {
|
||||
return nil, apis.NewUnauthorizedError("invalid credentials", nil)
|
||||
}
|
||||
|
||||
return k, e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
58
db/routes/integration_komoot.go
Normal file
58
db/routes/integration_komoot.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/komoot"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationKommotLogin(e *core.RequestEvent) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
komootString := integration.GetString("komoot")
|
||||
if len(komootString) == 0 {
|
||||
return apis.NewBadRequestError("komoot integration missing", nil)
|
||||
}
|
||||
var komootIntegration komoot.KomootIntegration
|
||||
err = json.Unmarshal([]byte(komootString), &komootIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k := &komoot.KomootApi{}
|
||||
|
||||
err = k.Login(komootIntegration.Email, string(decryptedPassword))
|
||||
if err != nil {
|
||||
return apis.NewUnauthorizedError("invalid credentials", nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
87
db/routes/integration_strava.go
Normal file
87
db/routes/integration_strava.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/strava"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationStravaToken(e *core.RequestEvent) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
var data strava.TokenRequest
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
return apis.NewBadRequestError("Failed to read request data", err)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
stravaString := integration.GetString("strava")
|
||||
if len(stravaString) == 0 {
|
||||
return apis.NewBadRequestError("strava integration missing", nil)
|
||||
}
|
||||
var stravaIntegration strava.StravaIntegration
|
||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request := strava.TokenRequest{
|
||||
ClientID: stravaIntegration.ClientID,
|
||||
ClientSecret: string(decryptedSecret),
|
||||
Code: data.Code,
|
||||
GrantType: "authorization_code",
|
||||
}
|
||||
r, err := strava.GetStravaToken(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.AccessToken != "" {
|
||||
stravaIntegration.AccessToken = r.AccessToken
|
||||
}
|
||||
if r.RefreshToken != "" {
|
||||
stravaIntegration.RefreshToken = r.RefreshToken
|
||||
}
|
||||
if r.AccessToken != "" {
|
||||
stravaIntegration.ExpiresAt = r.ExpiresAt
|
||||
}
|
||||
|
||||
stravaIntegration.Active = true
|
||||
|
||||
b, err := json.Marshal(stravaIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integration.Set("strava", string(b))
|
||||
err = e.App.Save(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
210
db/routes/remote_list.go
Normal file
210
db/routes/remote_list.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// --- Main Handler ---
|
||||
|
||||
func RemoteListGet(e *core.RequestEvent) error {
|
||||
handle := e.Request.URL.Query().Get("handle")
|
||||
listID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
|
||||
var record *core.Record
|
||||
var err error
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if handle != "" {
|
||||
record, err = findLocalListByRemoteInfo(e, ctx, handle, listID)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to resolve trail", err)
|
||||
}
|
||||
|
||||
if record.Id == "" || record.GetBool("needs_full_sync") {
|
||||
record, err = performFullListSync(e.App, ctx, e.Request.URL, record)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Sync failed", err)
|
||||
}
|
||||
} else {
|
||||
updatedAt := record.GetDateTime("updated").Time()
|
||||
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
|
||||
go performFullListSync(e.App, ctx, e.Request.URL, record)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
record, err = e.App.FindRecordById("lists", listID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("List not found", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return expandAndReturn(e, record, expandQuery)
|
||||
}
|
||||
|
||||
func findLocalListByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
|
||||
// 1. Get Actor to build the IRI
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorURL, _ := url.Parse(actor.GetString("iri"))
|
||||
iri := fmt.Sprintf("%s://%s/api/v1/list/%s", actorURL.Scheme, actorURL.Host, trailID)
|
||||
|
||||
// 2. Check if this IRI already exists in our DB
|
||||
existing, _ := e.App.FindFirstRecordByFilter("lists", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// 3. Not found? Return a new Shell
|
||||
collection, _ := e.App.FindCollectionByNameOrId("lists")
|
||||
shell := core.NewRecord(collection)
|
||||
shell.Set("iri", iri)
|
||||
shell.Set("author", actor.Id)
|
||||
|
||||
return shell, nil
|
||||
}
|
||||
|
||||
func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, localList *core.Record) (*core.Record, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
iri := localList.GetString("iri")
|
||||
remoteUrl, _ := url.Parse(iri)
|
||||
remoteUrl.RawQuery = reqURL.RawQuery
|
||||
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return localList, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteMap map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
|
||||
return localList, err
|
||||
}
|
||||
|
||||
err = app.RunInTransaction(func(txApp core.App) error {
|
||||
remoteID, _ := remoteMap["id"].(string)
|
||||
|
||||
// 1. Sync Files
|
||||
syncListRecordFiles(ctx, localList, "lists", remoteID, origin, remoteMap)
|
||||
|
||||
// 2. Map Relations & Simple Fields
|
||||
syncListMetadata(localList, remoteMap)
|
||||
|
||||
localList.Set("needs_full_sync", false)
|
||||
|
||||
// 3. Sync Trails
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if trails, ok := expand["trails"].([]any); ok {
|
||||
err = syncTrails(txApp, ctx, localList, origin, trails)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := txApp.Save(localList); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return localList, err
|
||||
}
|
||||
|
||||
func syncListMetadata(record *core.Record, data map[string]any) {
|
||||
delete(data, "id")
|
||||
delete(data, "avatar")
|
||||
delete(data, "author")
|
||||
delete(data, "iri")
|
||||
|
||||
record.Load(data)
|
||||
}
|
||||
|
||||
func syncListRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
|
||||
if gpx, ok := data["avatar"].(string); ok && record.GetString("avatar") == "" {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
|
||||
record.Set("avatar", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncTrails(txApp core.App, ctx context.Context, list *core.Record, origin string, trails []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("trails")
|
||||
|
||||
localTrails := make([]string, 0, len(trails))
|
||||
|
||||
for _, tData := range trails {
|
||||
raw := tData.(map[string]any)
|
||||
tID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/trail/%s", origin, tID)
|
||||
}
|
||||
|
||||
trail, _ := txApp.FindFirstRecordByData("trails", "iri", iri)
|
||||
if trail == nil {
|
||||
trail = core.NewRecord(col)
|
||||
trail.Set("needs_full_sync", true)
|
||||
}
|
||||
|
||||
syncTrailMetadata(txApp, trail, raw)
|
||||
|
||||
author := list.GetString("author")
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if authorMap, ok := expand["author"].(map[string]any); ok {
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
author = actor.Id
|
||||
}
|
||||
}
|
||||
|
||||
trail.Set("author", author)
|
||||
trail.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(trail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localTrails = append(localTrails, trail.Id)
|
||||
|
||||
}
|
||||
|
||||
list.Set("trails", localTrails)
|
||||
return nil
|
||||
}
|
||||
165
db/routes/remote_profile_follow.go
Normal file
165
db/routes/remote_profile_follow.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func RemoteProfileFollowsList(e *core.RequestEvent) error {
|
||||
handle := e.Request.PathValue("handle")
|
||||
if handle == "" {
|
||||
return e.BadRequestError("Missing required parameter 'handle'", nil)
|
||||
}
|
||||
|
||||
followType := e.Request.URL.Query().Get("type")
|
||||
if followType != "following" {
|
||||
followType = "followers"
|
||||
}
|
||||
|
||||
pageQuery := e.Request.URL.Query().Get("page")
|
||||
if pageQuery == "" {
|
||||
pageQuery = "1"
|
||||
}
|
||||
page, _ := strconv.Atoi(pageQuery)
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Resolve Target Actor
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Actor not found", err)
|
||||
}
|
||||
|
||||
collectionIRI := actor.GetString(followType)
|
||||
if collectionIRI == "" {
|
||||
return e.BadRequestError(fmt.Sprintf("Actor has no %s collection", followType), nil)
|
||||
}
|
||||
|
||||
// 2. Fetch Remote Content
|
||||
client := util.SafeHTTPClient()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s?page=%d", collectionIRI, page), nil)
|
||||
req.Header.Set("Accept", "application/activity+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Failed to fetch remote collection", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to read response body", err)
|
||||
}
|
||||
|
||||
// 3. Proper Unmarshaling using go-ap
|
||||
// This returns a pub.Item interface which could be an OrderedCollection,
|
||||
// OrderedCollectionPage, or even a simple Object.
|
||||
data, err := pub.UnmarshalJSON(body)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to unmarshal ActivityPub JSON", err)
|
||||
}
|
||||
|
||||
var items pub.ItemCollection
|
||||
var totalItems uint = 0
|
||||
|
||||
// 4. Type assertion using go-ap's type switch pattern
|
||||
err = pub.OnOrderedCollectionPage(data, func(p *pub.OrderedCollectionPage) error {
|
||||
items = p.OrderedItems
|
||||
totalItems = p.TotalItems
|
||||
return nil
|
||||
})
|
||||
|
||||
// Fallback: some instances might return a plain OrderedCollection
|
||||
// if the page isn't strictly formatted as a Page object
|
||||
if err != nil || items == nil {
|
||||
_ = pub.OnOrderedCollection(data, func(c *pub.OrderedCollection) error {
|
||||
items = c.OrderedItems
|
||||
totalItems = c.TotalItems
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Resolve IRIs to Local Records
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
resolvedItems := make([]*core.Record, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
iri := item.GetLink().String()
|
||||
if iri == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(actorIRI string) {
|
||||
defer wg.Done()
|
||||
|
||||
// We use a channel to wrap the GetActorByIRI call
|
||||
// so we can respect the context timeout
|
||||
done := make(chan *core.Record, 1)
|
||||
go func() {
|
||||
// Pass false to sync to prevent deep recursion/heavy syncing if possible
|
||||
res, err := federation.GetActorByIRI(e.App, timeoutCtx, actorIRI, false)
|
||||
if err == nil {
|
||||
done <- res
|
||||
} else {
|
||||
done <- nil
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case itemActor := <-done:
|
||||
if itemActor != nil {
|
||||
mu.Lock()
|
||||
resolvedItems = append(resolvedItems, itemActor)
|
||||
mu.Unlock()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout reached for this specific resolution
|
||||
return
|
||||
}
|
||||
}(iri)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 6. Pagination Metadata
|
||||
perPage := 10
|
||||
if len(items) > 0 {
|
||||
perPage = len(items)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"page": page,
|
||||
"perPage": perPage,
|
||||
"totalItems": totalItems,
|
||||
"totalPages": math.Ceil(float64(totalItems) / float64(perPage)),
|
||||
"items": resolvedItems,
|
||||
})
|
||||
}
|
||||
317
db/routes/remote_trail.go
Normal file
317
db/routes/remote_trail.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// --- Main Handler ---
|
||||
|
||||
func RemoteTrailGet(e *core.RequestEvent) error {
|
||||
handle := e.Request.URL.Query().Get("handle")
|
||||
trailID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
|
||||
var record *core.Record
|
||||
var err error
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Resolve the "Actual" Record or Shell
|
||||
if handle != "" {
|
||||
// If we have a handle, we are looking for a remote trail.
|
||||
// Construct the IRI first to see if we already know this trail.
|
||||
record, err = findLocalTrailByRemoteInfo(e, ctx, handle, trailID)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to resolve trail", err)
|
||||
}
|
||||
|
||||
// If the record has no ID, it's a new Shell
|
||||
if record.Id == "" || record.GetBool("needs_full_sync") {
|
||||
// Blocking sync for new records
|
||||
record, err = performFullSync(e.App, ctx, e.Request.URL, record)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Sync failed", err)
|
||||
}
|
||||
} else {
|
||||
// We already have it locally. Show and update background.
|
||||
updatedAt := record.GetDateTime("updated").Time()
|
||||
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
|
||||
go performFullSync(e.App, ctx, e.Request.URL, record)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard local fetch by ID
|
||||
record, err = e.App.FindRecordById("trails", trailID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Trail not found", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return expandAndReturn(e, record, expandQuery)
|
||||
}
|
||||
|
||||
func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
|
||||
// 1. Get Actor to build the IRI
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorURL, _ := url.Parse(actor.GetString("iri"))
|
||||
iri := fmt.Sprintf("%s://%s/api/v1/trail/%s", actorURL.Scheme, actorURL.Host, trailID)
|
||||
|
||||
// 2. Check if this IRI already exists in our DB
|
||||
existing, _ := e.App.FindFirstRecordByFilter("trails", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// 3. Not found? Return a new Shell
|
||||
collection, _ := e.App.FindCollectionByNameOrId("trails")
|
||||
shell := core.NewRecord(collection)
|
||||
shell.Set("iri", iri)
|
||||
shell.Set("author", actor.Id)
|
||||
shell.Set("like_count", 0)
|
||||
|
||||
return shell, nil
|
||||
}
|
||||
|
||||
// --- Core Sync Logic ---
|
||||
|
||||
func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTrail *core.Record) (*core.Record, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
iri := localTrail.GetString("iri")
|
||||
remoteUrl, _ := url.Parse(iri)
|
||||
remoteUrl.RawQuery = reqURL.RawQuery // Forward params
|
||||
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", remoteUrl.String(), nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return localTrail, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteMap map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
|
||||
return localTrail, err
|
||||
}
|
||||
|
||||
err = app.RunInTransaction(func(txApp core.App) error {
|
||||
remoteID, _ := remoteMap["id"].(string)
|
||||
|
||||
// 1. Sync Files
|
||||
syncRecordFiles(ctx, localTrail, "trails", remoteID, origin, remoteMap)
|
||||
|
||||
// 2. Map Relations & Simple Fields
|
||||
syncTrailMetadata(txApp, localTrail, remoteMap)
|
||||
|
||||
localTrail.Set("needs_full_sync", false)
|
||||
|
||||
if err := txApp.Save(localTrail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Sync Waypoints
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if wps, ok := expand["waypoints_via_trail"].([]any); ok {
|
||||
err = syncWaypoints(txApp, ctx, localTrail, origin, wps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync SummitLogs
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if sls, ok := expand["summit_logs_via_trail"].([]any); ok {
|
||||
err = syncSummitLogs(txApp, ctx, localTrail, origin, sls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return localTrail, err
|
||||
}
|
||||
|
||||
// --- Sub-Sync Helpers ---
|
||||
|
||||
func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
|
||||
// Resolve Category if present in expand
|
||||
if expand, ok := data["expand"].(map[string]any); ok {
|
||||
if cat, ok := expand["category"].(map[string]any); ok {
|
||||
if name, ok := cat["name"].(string); ok {
|
||||
if c, _ := app.FindFirstRecordByData("categories", "name", name); c != nil {
|
||||
record.Set("category", c.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean protected/complex fields before bulk load
|
||||
delete(data, "id")
|
||||
delete(data, "photos")
|
||||
delete(data, "gpx")
|
||||
delete(data, "author")
|
||||
delete(data, "category")
|
||||
delete(data, "iri")
|
||||
|
||||
record.Load(data)
|
||||
}
|
||||
|
||||
func syncWaypoints(txApp core.App, ctx context.Context, trail *core.Record, origin string, waypoints []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("waypoints")
|
||||
|
||||
for _, wData := range waypoints {
|
||||
raw := wData.(map[string]any)
|
||||
wpID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/waypoint/%s", origin, wpID)
|
||||
}
|
||||
|
||||
wp, _ := txApp.FindFirstRecordByData("waypoints", "iri", iri)
|
||||
if wp == nil {
|
||||
wp = core.NewRecord(col)
|
||||
}
|
||||
|
||||
syncRecordFiles(ctx, wp, "waypoints", wpID, origin, raw)
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "photos")
|
||||
wp.Load(raw)
|
||||
wp.Set("author", trail.GetString("author"))
|
||||
wp.Set("trail", trail.Id)
|
||||
wp.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(wp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncSummitLogs(txApp core.App, ctx context.Context, trail *core.Record, origin string, summitLogs []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("summit_logs")
|
||||
|
||||
for _, slData := range summitLogs {
|
||||
raw := slData.(map[string]any)
|
||||
slID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/summit_logs/%s", origin, slID)
|
||||
}
|
||||
|
||||
remoteSummitLogUrl, _ := url.Parse(iri)
|
||||
possibleLocalId := path.Base(remoteSummitLogUrl.Path)
|
||||
|
||||
sl, _ := txApp.FindFirstRecordByFilter("summit_logs", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": iri})
|
||||
if sl == nil {
|
||||
sl = core.NewRecord(col)
|
||||
}
|
||||
|
||||
author := trail.GetString("author")
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if authorMap, ok := expand["author"].(map[string]any); ok {
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
author = actor.Id
|
||||
}
|
||||
}
|
||||
|
||||
syncRecordFiles(ctx, sl, "summit_logs", slID, origin, raw)
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "photos")
|
||||
delete(raw, "gpx")
|
||||
|
||||
sl.Load(raw)
|
||||
sl.Set("author", author)
|
||||
sl.Set("trail", trail.Id)
|
||||
sl.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(sl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
|
||||
// Handle GPX
|
||||
if gpx, ok := data["gpx"].(string); ok && record.GetString("gpx") == "" {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
|
||||
record.Set("gpx", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Photos
|
||||
if photos, ok := data["photos"].([]any); ok && len(record.GetStringSlice("photos")) == 0 {
|
||||
var files []*filesystem.File
|
||||
for _, p := range photos {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, p.(string)); err == nil {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
if len(files) > 0 {
|
||||
record.Set("photos", files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, origin, col, id, name string) (*filesystem.File, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s", origin, col, id, name)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("download failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(res.Body)
|
||||
return filesystem.NewFileFromBytes(data, name)
|
||||
}
|
||||
|
||||
func expandAndReturn(e *core.RequestEvent, record *core.Record, query string) error {
|
||||
if query != "" {
|
||||
e.App.ExpandRecord(record, strings.Split(query, ","), nil)
|
||||
}
|
||||
return e.JSON(http.StatusOK, record)
|
||||
}
|
||||
186
db/routes/remote_trail_comment.go
Normal file
186
db/routes/remote_trail_comment.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func RemoteTrailCommentsList(e *core.RequestEvent) error {
|
||||
trailID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
sort := e.Request.URL.Query().Get("sort")
|
||||
|
||||
if sort == "" {
|
||||
sort = "-created"
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(e.Request.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
perPage, _ := strconv.Atoi(e.Request.URL.Query().Get("perPage"))
|
||||
if perPage < 1 {
|
||||
perPage = 30
|
||||
}
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", trailID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync remote data first (Fetch + Save)
|
||||
if trail.GetString("iri") != "" {
|
||||
_ = syncRemoteComments(e, trail)
|
||||
}
|
||||
|
||||
// 1. Calculate Offset
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// 2. Fetch the records using FindRecordsByFilter
|
||||
records, err := e.App.FindRecordsByFilter(
|
||||
"comments",
|
||||
"trail = {:trailId}",
|
||||
sort,
|
||||
perPage,
|
||||
offset,
|
||||
dbx.Params{"trailId": trail.Id},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Get total count for pagination metadata
|
||||
var totalItems int
|
||||
err = e.App.DB().
|
||||
Select("count(*)").
|
||||
From("comments").
|
||||
Where(dbx.HashExp{"trail": trail.Id}).
|
||||
Row(&totalItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Handle Expand
|
||||
if expandQuery != "" {
|
||||
errs := e.App.ExpandRecords(records, strings.Split(expandQuery, ","), nil)
|
||||
if len(errs) > 0 {
|
||||
fmt.Printf("Expand errors: %v\n", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Manually construct the response object
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"page": page,
|
||||
"perPage": perPage,
|
||||
"totalItems": totalItems,
|
||||
"totalPages": (totalItems + perPage - 1) / perPage,
|
||||
"items": records,
|
||||
})
|
||||
}
|
||||
|
||||
func syncRemoteComments(e *core.RequestEvent, trail *core.Record) error {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trailIRI := trail.GetString("iri")
|
||||
u, _ := url.Parse(trailIRI)
|
||||
|
||||
remoteTrailID := path.Base(u.Path)
|
||||
|
||||
remoteURL := fmt.Sprintf("%s://%s/api/v1/comment?filter=trail='%s'&expand=author", u.Scheme, u.Host, remoteTrailID)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", remoteURL, nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return fmt.Errorf("remote fetch failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteData struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, _ := e.App.FindCollectionByNameOrId("comments")
|
||||
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
for _, raw := range remoteData.Items {
|
||||
remoteIRI, _ := raw["iri"].(string)
|
||||
if remoteIRI == "" {
|
||||
remoteID, _ := raw["id"].(string)
|
||||
remoteIRI = fmt.Sprintf("%s://%s/api/v1/comment/%s", u.Scheme, u.Host, remoteID)
|
||||
}
|
||||
|
||||
remoteCommentUrl, _ := url.Parse(remoteIRI)
|
||||
possibleLocalId := path.Base(remoteCommentUrl.Path)
|
||||
|
||||
// Find existing record by IRI or ID to avoid duplicates
|
||||
commentRecord, _ := txApp.FindFirstRecordByFilter("comments", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": remoteIRI})
|
||||
if commentRecord == nil {
|
||||
commentRecord = core.NewRecord(collection)
|
||||
commentRecord.Set("iri", remoteIRI)
|
||||
commentRecord.Set("trail", trail.Id)
|
||||
}
|
||||
|
||||
// Resolve federated author
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if author, ok := expand["author"].(map[string]any); ok {
|
||||
authorIRI, _ := author["iri"].(string)
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorIRI, false)
|
||||
if err == nil {
|
||||
raw["author"] = actor.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "trail")
|
||||
delete(raw, "expand")
|
||||
delete(raw, "iri")
|
||||
commentRecord.Load(raw)
|
||||
|
||||
if err := txApp.Save(commentRecord); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func expandAndReturnList(e *core.RequestEvent, records []*core.Record, query string) error {
|
||||
if query != "" {
|
||||
expandPaths := strings.Split(query, ",")
|
||||
|
||||
errs := e.App.ExpandRecords(records, expandPaths, nil)
|
||||
if len(errs) > 0 {
|
||||
fmt.Printf("Expand errors: %v\n", errs)
|
||||
}
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, records)
|
||||
}
|
||||
44
db/routes/search_token.go
Normal file
44
db/routes/search_token.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
searchRules := map[string]interface{}{
|
||||
"lists": map[string]string{"filter": "public = true"},
|
||||
"trails": map[string]string{"filter": "public = true"},
|
||||
}
|
||||
|
||||
if e.Auth != nil {
|
||||
userId := e.Auth.Id
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
searchRules = map[string]any{
|
||||
"lists": map[string]string{
|
||||
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||
},
|
||||
"trails": map[string]string{
|
||||
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to generate search token", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
90
db/routes/trail_merge_routes.go
Normal file
90
db/routes/trail_merge_routes.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type mergeExecuteRequest struct {
|
||||
SourceTrailID string `json:"sourceTrailId"`
|
||||
TargetTrailID string `json:"targetTrailId"`
|
||||
Settings trailmerge.MergeSettings `json:"settings"`
|
||||
}
|
||||
|
||||
func TrailMergeSuggest(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
|
||||
var request trailmerge.SuggestRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
|
||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
response, err := trailmerge.Suggest(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func TrailMerge(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
|
||||
var request mergeExecuteRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
|
||||
source, err := e.App.FindRecordById("trails", request.SourceTrailID)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_source_not_found", err)
|
||||
}
|
||||
target, err := e.App.FindRecordById("trails", request.TargetTrailID)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_target_not_found", err)
|
||||
}
|
||||
|
||||
if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) {
|
||||
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, ctx, userActor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"acknowledged": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
197
db/routes/waypoint_cluster.go
Normal file
197
db/routes/waypoint_cluster.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
const defaultWaypointMergeRadius = 50
|
||||
|
||||
type waypointMergeSettings struct {
|
||||
Enabled bool
|
||||
Radius float64
|
||||
}
|
||||
|
||||
type waypointClusterRequest struct {
|
||||
Category string `json:"category"`
|
||||
Photos []waypointClusterPhoto `json:"photos"`
|
||||
Waypoints []waypointClusterWaypoint `json:"waypoints"`
|
||||
}
|
||||
|
||||
type waypointClusterPhoto struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type waypointClusterWaypoint struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type waypointPhotoCluster struct {
|
||||
Waypoint string `json:"waypoint,omitempty"`
|
||||
Photos []string `json:"photos"`
|
||||
SumLat float64 `json:"-"`
|
||||
SumLon float64 `json:"-"`
|
||||
Count int `json:"-"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type categorySettings struct {
|
||||
WaypointMergeEnabled *bool `json:"wp_merge_enabled"`
|
||||
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
|
||||
}
|
||||
|
||||
func WaypointCluster(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
var data waypointClusterRequest
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
return apis.NewBadRequestError("Failed to read request data", err)
|
||||
}
|
||||
|
||||
if data.Category != "" && len(data.Category) != 15 {
|
||||
return apis.NewBadRequestError("Invalid category", nil)
|
||||
}
|
||||
|
||||
for _, photo := range data.Photos {
|
||||
if photo.ID == "" {
|
||||
return apis.NewBadRequestError("Invalid photo id", nil)
|
||||
}
|
||||
if photo.Lat < -90 || photo.Lat > 90 {
|
||||
return apis.NewBadRequestError("Invalid photo latitude", nil)
|
||||
}
|
||||
if photo.Lon < -180 || photo.Lon > 180 {
|
||||
return apis.NewBadRequestError("Invalid photo longitude", nil)
|
||||
}
|
||||
}
|
||||
|
||||
for _, waypoint := range data.Waypoints {
|
||||
if waypoint.ID == "" {
|
||||
return apis.NewBadRequestError("Invalid waypoint id", nil)
|
||||
}
|
||||
if waypoint.Lat < -90 || waypoint.Lat > 90 {
|
||||
return apis.NewBadRequestError("Invalid waypoint latitude", nil)
|
||||
}
|
||||
if waypoint.Lon < -180 || waypoint.Lon > 180 {
|
||||
return apis.NewBadRequestError("Invalid waypoint longitude", nil)
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings, err := getWaypointMergeSettings(e.App, data.Category)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"mergeEnabled": mergeSettings.Enabled,
|
||||
"mergeRadius": mergeSettings.Radius,
|
||||
"clusters": clusterWaypointPhotos(data.Photos, data.Waypoints, mergeSettings),
|
||||
})
|
||||
}
|
||||
|
||||
func getWaypointMergeSettings(app core.App, categoryId string) (waypointMergeSettings, error) {
|
||||
defaultSettings := waypointMergeSettings{
|
||||
Enabled: true,
|
||||
Radius: defaultWaypointMergeRadius,
|
||||
}
|
||||
|
||||
if categoryId == "" {
|
||||
return defaultSettings, nil
|
||||
}
|
||||
|
||||
category, err := app.FindRecordById("categories", categoryId)
|
||||
if err != nil {
|
||||
return waypointMergeSettings{}, err
|
||||
}
|
||||
|
||||
var settings categorySettings
|
||||
if err := category.UnmarshalJSONField("settings", &settings); err != nil {
|
||||
return defaultSettings, nil
|
||||
}
|
||||
|
||||
if settings.WaypointMergeEnabled != nil {
|
||||
defaultSettings.Enabled = *settings.WaypointMergeEnabled
|
||||
}
|
||||
|
||||
if settings.WaypointMergeRadius != nil && *settings.WaypointMergeRadius >= 0 {
|
||||
defaultSettings.Radius = *settings.WaypointMergeRadius
|
||||
}
|
||||
|
||||
return defaultSettings, nil
|
||||
}
|
||||
|
||||
func clusterWaypointPhotos(photos []waypointClusterPhoto, waypoints []waypointClusterWaypoint, mergeSettings waypointMergeSettings) []waypointPhotoCluster {
|
||||
clusters := []waypointPhotoCluster{}
|
||||
|
||||
if mergeSettings.Enabled {
|
||||
for _, waypoint := range waypoints {
|
||||
clusters = append(clusters, newWaypointCluster(waypoint))
|
||||
}
|
||||
}
|
||||
|
||||
for _, photo := range photos {
|
||||
if !mergeSettings.Enabled {
|
||||
clusters = append(clusters, newWaypointPhotoCluster(photo))
|
||||
continue
|
||||
}
|
||||
|
||||
matchingClusterIndex := -1
|
||||
for i, cluster := range clusters {
|
||||
distanceToCenter := util.HaversineDistance(cluster.Lat, cluster.Lon, photo.Lat, photo.Lon)
|
||||
if distanceToCenter <= mergeSettings.Radius {
|
||||
matchingClusterIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchingClusterIndex >= 0 {
|
||||
addPhotoToWaypointCluster(&clusters[matchingClusterIndex], photo)
|
||||
} else {
|
||||
clusters = append(clusters, newWaypointPhotoCluster(photo))
|
||||
}
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
func newWaypointPhotoCluster(photo waypointClusterPhoto) waypointPhotoCluster {
|
||||
return waypointPhotoCluster{
|
||||
Photos: []string{photo.ID},
|
||||
SumLat: photo.Lat,
|
||||
SumLon: photo.Lon,
|
||||
Count: 1,
|
||||
Lat: photo.Lat,
|
||||
Lon: photo.Lon,
|
||||
}
|
||||
}
|
||||
|
||||
func newWaypointCluster(waypoint waypointClusterWaypoint) waypointPhotoCluster {
|
||||
return waypointPhotoCluster{
|
||||
Waypoint: waypoint.ID,
|
||||
Photos: []string{},
|
||||
SumLat: waypoint.Lat,
|
||||
SumLon: waypoint.Lon,
|
||||
Count: 1,
|
||||
Lat: waypoint.Lat,
|
||||
Lon: waypoint.Lon,
|
||||
}
|
||||
}
|
||||
|
||||
func addPhotoToWaypointCluster(cluster *waypointPhotoCluster, photo waypointClusterPhoto) {
|
||||
cluster.Photos = append(cluster.Photos, photo.ID)
|
||||
cluster.SumLat += photo.Lat
|
||||
cluster.SumLon += photo.Lon
|
||||
cluster.Count++
|
||||
cluster.Lat = cluster.SumLat / float64(cluster.Count)
|
||||
cluster.Lon = cluster.SumLon / float64(cluster.Count)
|
||||
}
|
||||
Reference in New Issue
Block a user