Files
wanderer/db/routes/remote_trail.go
slothful-vassal 3b8f00fd26 feat: Refine trail category model (#1059)
* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
2026-06-29 03:16:26 +02:00

461 lines
12 KiB
Go

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) {
var federatedCategoryName, federatedSubcategoryName string
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 {
federatedCategoryName = name
}
}
if subcat, ok := expand["subcategory"].(map[string]any); ok {
if name, ok := subcat["name"].(string); ok {
federatedSubcategoryName = name
}
}
}
if federatedCategoryName != "" {
record.Set("federated_category_name", federatedCategoryName)
}
if federatedSubcategoryName != "" {
record.Set("federated_subcategory_name", federatedSubcategoryName)
}
category, subcategory, err := util.ResolveCategoryAndSubcategoryByNormalizedNames(app, federatedCategoryName, federatedSubcategoryName)
if err == nil && category != nil {
record.Set("category", category.Id)
if subcategory != nil {
record.Set("subcategory", subcategory.Id)
} else {
record.Set("subcategory", "")
}
} else if err == nil && federatedCategoryName != "" {
record.Set("category", "")
record.Set("subcategory", "")
}
// 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, "subcategory")
delete(data, "tags")
delete(data, "iri")
delete(data, "federated_category_name")
delete(data, "federated_subcategory_name")
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)
}