Files
wanderer/db/routes/remote_list.go
2026-06-04 10:52:15 +02:00

253 lines
6.6 KiB
Go

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)
}
if record.Id == "" {
// Local content that does not exist: performFullListSync
// short-circuits local IRIs and returns the unsaved shell —
// surface a real 404 instead of access/expand on a missing record.
return e.NotFoundError("List not found", nil)
}
} else {
updatedAt := record.GetDateTime("updated").Time()
iri := record.GetString("iri")
if time.Now().UTC().Sub(updatedAt) > remoteSyncThreshold {
if _, alreadySyncing := listSyncing.LoadOrStore(iri, struct{}{}); !alreadySyncing {
urlCopy := *e.Request.URL
bgCtx := context.WithValue(context.Background(), "actor", ctx.Value("actor"))
go func() {
defer listSyncing.Delete(iri)
performFullListSync(e.App, bgCtx, &urlCopy, record)
}()
}
}
}
} else {
record, err = e.App.FindRecordById("lists", listID)
if err != nil {
return e.NotFoundError("List 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 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) {
iri := localList.GetString("iri")
// Never federate with ourselves (see performFullSync in remote_trail.go).
if iri == "" || util.IsLocalIRI(iri) {
if localList.GetBool("needs_full_sync") {
localList.Set("needs_full_sync", false)
if err := app.Save(localList); err != nil {
return localList, err
}
}
return localList, 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, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return localList, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return localList, fmt.Errorf("remote list fetch %s returned: %d", remoteUrl.String(), res.StatusCode)
}
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
}