package routes import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "pocketbase/federation" "pocketbase/util" "strconv" "strings" "sync" "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/filesystem" ) // remoteSyncThreshold is the minimum age of a remote record before a background sync is triggered. // Configurable via POCKETBASE_FEDERATION_SYNC_INTERVAL (minutes). Default: 60. var remoteSyncThreshold = func() time.Duration { if v := os.Getenv("POCKETBASE_FEDERATION_SYNC_INTERVAL"); v != "" { if minutes, err := strconv.Atoi(v); err == nil && minutes > 0 { return time.Duration(minutes) * time.Minute } } return 60 * time.Minute }() // trailSyncing and listSyncing track IRIs currently being synced to prevent concurrent duplicate syncs. var trailSyncing sync.Map var listSyncing sync.Map // --- 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 { if errors.Is(err, federation.ErrProfilePrivate) { return e.NotFoundError("profile is private", err) } 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) } if record.Id == "" { // Local content that does not exist (e.g. a stale URL to a // missing local trail): performFullSync short-circuits local // IRIs and returns the unsaved shell — surface a real 404 // instead of running access/expand on a non-existent record. return e.NotFoundError("Trail not found", nil) } } else { // We already have it locally. Show and update background. updatedAt := record.GetDateTime("updated").Time() iri := record.GetString("iri") if time.Now().UTC().Sub(updatedAt) > remoteSyncThreshold { if _, alreadySyncing := trailSyncing.LoadOrStore(iri, struct{}{}); !alreadySyncing { urlCopy := *e.Request.URL bgCtx := context.WithValue(context.Background(), "actor", ctx.Value("actor")) go func() { defer trailSyncing.Delete(iri) performFullSync(e.App, bgCtx, &urlCopy, record) }() } } } } else { // Standard local fetch by ID record, err = e.App.FindRecordById("trails", trailID) if err != nil { return e.NotFoundError("Trail not found", nil) } } reqInfo, err := e.RequestInfo() if err != nil { return err } canAccess, err := e.App.CanAccessRecord(record, reqInfo, record.Collection().ViewRule) if err != nil || !canAccess { return e.ForbiddenError("forbidden", err) } 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 && !errors.Is(err, federation.ErrProfilePrivate) { return nil, err } if actor == 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) { iri := localTrail.GetString("iri") // Never federate with ourselves: a trail whose IRI is empty or points back // to this instance is local content (we are the source of truth). Syncing it // would fetch our own origin (or fail on an empty URL); just clear the stale // flag so the record is no longer stuck in a permanent re-sync loop. if iri == "" || util.IsLocalIRI(iri) { if localTrail.GetBool("needs_full_sync") { localTrail.Set("needs_full_sync", false) if err := app.Save(localTrail); err != nil { return localTrail, err } } return localTrail, nil } client := util.SafeHTTPClient() remoteUrl, _ := url.Parse(iri) query := reqURL.Query() query.Del("handle") remoteUrl.RawQuery = query.Encode() 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 { return localTrail, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return localTrail, fmt.Errorf("remote trail fetch %s returned: %d", remoteUrl.String(), res.StatusCode) } 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) } } } } // Resolve Tags localTagIds := resolveAndSyncTags(app, data) if len(localTagIds) > 0 { record.Set("tags", localTagIds) } // Clean protected/complex fields before bulk load delete(data, "id") delete(data, "photos") delete(data, "gpx") delete(data, "author") delete(data, "category") delete(data, "tags") delete(data, "iri") record.Load(data) } func resolveAndSyncTags(app core.App, data map[string]any) []string { var localTagIds []string expand, ok := data["expand"].(map[string]any) if !ok { return localTagIds } remoteTags, ok := expand["tags"].([]any) if !ok { return localTagIds } tagCol, _ := app.FindCollectionByNameOrId("tags") for _, t := range remoteTags { tagMap, ok := t.(map[string]any) if !ok { continue } tagName, _ := tagMap["name"].(string) if tagName == "" { continue } localTag, _ := app.FindFirstRecordByData("tags", "name", tagName) if localTag == nil { localTag = core.NewRecord(tagCol) localTag.Set("name", tagName) if err := app.Save(localTag); err != nil { continue } } localTagIds = append(localTagIds, localTag.Id) } return localTagIds } 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-log/%s", origin, slID) } sl, _ := txApp.FindFirstRecordByData("summit_logs", "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) }