feat: add plugin system (#1034)
* feat: add plugin system * fix db docker build * fix hammerhead readme, add strava subscription news to docs * fixes and sdk improvements * fix: reduce Meilisearch load, debounce federation sync (#1012) * optimize meili trail index * several fixes --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.56.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> * Release v0.19.2 (#1035) * chore: release v0.19.2 * add changelog --------- Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com> Co-authored-by: Christian Beutel <> * speed up plugin sync and several small fixes * concepts for security improvements and process stability * improve concept * security concept implemented * remove insecure TLS * worker concept implemented * fixes and cleanup * fixes * docu * mermaid, namings * WASM plugin host improvements, plugin logging * fix db migration * Improve plugin config and category mapping UI * fixes * further fixes * remove manual test sync * fix db migration and strava mapping * type added, UI improvements * fix plugin card toggle clickable area * optimize synch status card layout * plugin type 'trails' instead of 'integration' * session auth validation in UI * fix komoot date and waypoints * improve category mapping * fix send to hammerhead: trail name * plugin setup error handling improved * fix review findings * re-mapping added * rename remote_category --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Flomp <Flomp@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
912
db/plugins/importer/importer.go
Normal file
912
db/plugins/importer/importer.go
Normal file
@@ -0,0 +1,912 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
urlpath "path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pocketbase/pluginsystem"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
UserID string
|
||||
ActorID string
|
||||
DefaultPublic bool
|
||||
CreateSummitLogForCompleted bool
|
||||
CategoryMapping map[string]string
|
||||
Manifest pluginsystem.Manifest
|
||||
Policy pluginsystem.RequestPolicyContext
|
||||
Auth map[string]any
|
||||
}
|
||||
|
||||
// Result tells the sync loop whether a plugin item created a new trail or was
|
||||
// skipped because the same provider/external id had already been imported.
|
||||
type Result struct {
|
||||
TrailID string
|
||||
Created bool
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// ImportTrail is the boundary between plugin output and wanderer records. It
|
||||
// validates the provider identity, deduplicates by trail_external_reference,
|
||||
// stores the GPX/photos, maps GPX metrics onto the trail record, and creates the
|
||||
// optional related waypoints and summit log.
|
||||
func ImportTrail(ctx context.Context, app core.App, item pluginsystem.TrailImport, opts Options) (*Result, error) {
|
||||
if item.Source.Provider == "" || item.Source.ExternalID == "" {
|
||||
return nil, fmt.Errorf("source provider and externalId are required")
|
||||
}
|
||||
if existing, err := util.FindTrailByExternalReferenceForUser(app, opts.UserID, item.Source.Provider, item.Source.ExternalID); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
return &Result{TrailID: existing.Id, Skipped: true}, nil
|
||||
}
|
||||
|
||||
gpxBytes, parsedGPX, err := decodeAndParseGPX(item.Track)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gpxFile, err := filesystem.NewFileFromBytes(gpxBytes, safeGPXFileName(item.Name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trails")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
metrics := metricsFromGPX(parsedGPX)
|
||||
trackIndex := trackDistanceIndexFromGPX(parsedGPX)
|
||||
applyProviderStart(&metrics, trackIndex, item.Metadata)
|
||||
applyProviderMetrics(&metrics, item.Metadata)
|
||||
public := publicFromPrivacy(item.Privacy, opts.DefaultPublic)
|
||||
categoryID := categoryIDForImport(app, item, opts.CategoryMapping)
|
||||
date := dateFromImport(item, metrics)
|
||||
mediaBudget := &pluginMediaBudget{}
|
||||
photos := photoFiles(ctx, app, item.Photos, opts, mediaBudget)
|
||||
|
||||
record.Load(map[string]any{
|
||||
"name": fallbackName(item.Name),
|
||||
"description": item.Description,
|
||||
"public": public,
|
||||
"completed": item.Kind == "completed",
|
||||
"distance": metrics.Distance,
|
||||
"elevation_gain": metrics.ElevationGain,
|
||||
"elevation_loss": metrics.ElevationLoss,
|
||||
"duration": metrics.Duration,
|
||||
"date": date,
|
||||
"lat": metrics.StartLat,
|
||||
"lon": metrics.StartLon,
|
||||
"difficulty": "easy",
|
||||
"category": categoryID,
|
||||
"author": opts.ActorID,
|
||||
})
|
||||
record.Set("gpx", gpxFile)
|
||||
if len(photos) > 0 {
|
||||
record.Set("photos", photos)
|
||||
}
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := util.EnsureTrailExternalReference(app, record.Id, item.Source.Provider, item.Source.ExternalID, opts.Manifest.ID, ProviderCategoryFromImport(item)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := createWaypoints(ctx, app, item.Waypoints, opts, mediaBudget, record.Id, trackIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.CreateSummitLogForCompleted && item.Kind == "completed" {
|
||||
if err := createSummitLog(app, record.Id, opts.ActorID, date, metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{TrailID: record.Id, Created: true}, nil
|
||||
}
|
||||
|
||||
type trailMetrics struct {
|
||||
Distance float64
|
||||
ElevationGain float64
|
||||
ElevationLoss float64
|
||||
Duration float64
|
||||
StartLat float64
|
||||
StartLon float64
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
type geoPoint struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
}
|
||||
|
||||
type trackDistanceIndex struct {
|
||||
points []indexedTrackPoint
|
||||
segments []indexedTrackSegment
|
||||
}
|
||||
|
||||
type indexedTrackPoint struct {
|
||||
point geoPoint
|
||||
distance float64
|
||||
}
|
||||
|
||||
type indexedTrackSegment struct {
|
||||
start geoPoint
|
||||
end geoPoint
|
||||
startDistance float64
|
||||
length float64
|
||||
}
|
||||
|
||||
const maxProviderStartDistanceMeters = 1000
|
||||
|
||||
// decodeAndParseGPX keeps the importer strict for now: plugins must return GPX
|
||||
// as base64 so the host can compute canonical trail metrics itself.
|
||||
func decodeAndParseGPX(track pluginsystem.Track) ([]byte, *gpx.GPX, error) {
|
||||
if track.Format != "gpx" {
|
||||
return nil, nil, fmt.Errorf("unsupported track format %q", track.Format)
|
||||
}
|
||||
if track.ContentBase64 == "" {
|
||||
return nil, nil, fmt.Errorf("track contentBase64 is required")
|
||||
}
|
||||
|
||||
content, err := base64.StdEncoding.DecodeString(track.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decode GPX: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := gpx.Parse(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse GPX: %w", err)
|
||||
}
|
||||
|
||||
return content, parsed, nil
|
||||
}
|
||||
|
||||
// metricsFromGPX derives fallback trail fields from the GPX. Provider metadata
|
||||
// may override summary metrics and, when plausible, the displayed start point.
|
||||
func metricsFromGPX(gpxData *gpx.GPX) trailMetrics {
|
||||
uphillDownhill := gpxData.UphillDownhill()
|
||||
movingData := gpxData.MovingData()
|
||||
timeBounds := gpxData.TimeBounds()
|
||||
|
||||
metrics := trailMetrics{
|
||||
Distance: gpxData.Length2D(),
|
||||
ElevationGain: uphillDownhill.Uphill,
|
||||
ElevationLoss: uphillDownhill.Downhill,
|
||||
Duration: movingData.MovingTime + movingData.StoppedTime,
|
||||
StartTime: timeBounds.StartTime,
|
||||
}
|
||||
|
||||
for _, track := range gpxData.Tracks {
|
||||
for _, segment := range track.Segments {
|
||||
if len(segment.Points) == 0 {
|
||||
continue
|
||||
}
|
||||
metrics.StartLat = segment.Points[0].Latitude
|
||||
metrics.StartLon = segment.Points[0].Longitude
|
||||
return metrics
|
||||
}
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// applyProviderStart lets providers correct the displayed trail start when the
|
||||
// provider's intended start is close to the imported GPX track. Implausible
|
||||
// starts are ignored so broken metadata does not move trails off their geometry.
|
||||
func applyProviderStart(metrics *trailMetrics, trackIndex trackDistanceIndex, metadata map[string]any) {
|
||||
if metrics == nil || len(metadata) == 0 {
|
||||
return
|
||||
}
|
||||
start, ok := providerStartFromMetadata(metadata)
|
||||
if !ok || !providerStartNearTrack(trackIndex, start) {
|
||||
return
|
||||
}
|
||||
metrics.StartLat = start.Lat
|
||||
metrics.StartLon = start.Lon
|
||||
}
|
||||
|
||||
func providerStartFromMetadata(metadata map[string]any) (geoPoint, bool) {
|
||||
raw, ok := metadata["providerStart"]
|
||||
if !ok {
|
||||
return geoPoint{}, false
|
||||
}
|
||||
values, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return geoPoint{}, false
|
||||
}
|
||||
lat, ok := floatMetadata(values, "lat")
|
||||
if !ok {
|
||||
lat, ok = floatMetadata(values, "latitude")
|
||||
}
|
||||
if !ok {
|
||||
return geoPoint{}, false
|
||||
}
|
||||
lon, ok := floatMetadata(values, "lon")
|
||||
if !ok {
|
||||
lon, ok = floatMetadata(values, "longitude")
|
||||
}
|
||||
if !ok || lat < -90 || lat > 90 || lon < -180 || lon > 180 {
|
||||
return geoPoint{}, false
|
||||
}
|
||||
return geoPoint{Lat: lat, Lon: lon}, true
|
||||
}
|
||||
|
||||
func providerStartNearTrack(trackIndex trackDistanceIndex, start geoPoint) bool {
|
||||
distance, ok := trackIndex.nearest(start)
|
||||
return ok && distance.offTrack <= maxProviderStartDistanceMeters
|
||||
}
|
||||
|
||||
type trackDistance struct {
|
||||
fromStart float64
|
||||
offTrack float64
|
||||
}
|
||||
|
||||
func trackDistanceIndexFromGPX(gpxData *gpx.GPX) trackDistanceIndex {
|
||||
index := trackDistanceIndex{}
|
||||
if gpxData == nil {
|
||||
return index
|
||||
}
|
||||
totalDistance := 0.0
|
||||
for _, track := range gpxData.Tracks {
|
||||
for _, segment := range track.Segments {
|
||||
var previous geoPoint
|
||||
hasPrevious := false
|
||||
for _, point := range segment.Points {
|
||||
current := geoPoint{Lat: point.Latitude, Lon: point.Longitude}
|
||||
if !hasPrevious {
|
||||
index.points = append(index.points, indexedTrackPoint{
|
||||
point: current,
|
||||
distance: totalDistance,
|
||||
})
|
||||
previous = current
|
||||
hasPrevious = true
|
||||
continue
|
||||
}
|
||||
length := util.HaversineDistanceMeters(previous.Lat, previous.Lon, current.Lat, current.Lon)
|
||||
if length > 0 {
|
||||
index.segments = append(index.segments, indexedTrackSegment{
|
||||
start: previous,
|
||||
end: current,
|
||||
startDistance: totalDistance,
|
||||
length: length,
|
||||
})
|
||||
totalDistance += length
|
||||
}
|
||||
index.points = append(index.points, indexedTrackPoint{
|
||||
point: current,
|
||||
distance: totalDistance,
|
||||
})
|
||||
previous = current
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (index trackDistanceIndex) nearest(point geoPoint) (trackDistance, bool) {
|
||||
var nearest trackDistance
|
||||
found := false
|
||||
for _, candidate := range index.points {
|
||||
offTrack := util.HaversineDistanceMeters(point.Lat, point.Lon, candidate.point.Lat, candidate.point.Lon)
|
||||
if !found || offTrack < nearest.offTrack {
|
||||
nearest = trackDistance{fromStart: candidate.distance, offTrack: offTrack}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
for _, segment := range index.segments {
|
||||
offTrack, t := pointToSegmentProjectionMeters(point, segment.start, segment.end)
|
||||
fromStart := segment.startDistance + segment.length*t
|
||||
if !found || offTrack < nearest.offTrack {
|
||||
nearest = trackDistance{fromStart: fromStart, offTrack: offTrack}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return nearest, found
|
||||
}
|
||||
|
||||
func pointToSegmentProjectionMeters(point geoPoint, start geoPoint, end geoPoint) (float64, float64) {
|
||||
const earthRadius = 6371000.0
|
||||
latRad := point.Lat * math.Pi / 180
|
||||
toXY := func(p geoPoint) (float64, float64) {
|
||||
x := (p.Lon - point.Lon) * math.Pi / 180 * math.Cos(latRad) * earthRadius
|
||||
y := (p.Lat - point.Lat) * math.Pi / 180 * earthRadius
|
||||
return x, y
|
||||
}
|
||||
|
||||
startX, startY := toXY(start)
|
||||
endX, endY := toXY(end)
|
||||
dx := endX - startX
|
||||
dy := endY - startY
|
||||
lengthSquared := dx*dx + dy*dy
|
||||
if lengthSquared == 0 {
|
||||
return math.Hypot(startX, startY), 0
|
||||
}
|
||||
t := -(startX*dx + startY*dy) / lengthSquared
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
closestX := startX + t*dx
|
||||
closestY := startY + t*dy
|
||||
return math.Hypot(closestX, closestY), t
|
||||
}
|
||||
|
||||
// applyProviderMetrics lets plugins preserve provider-provided summary metrics
|
||||
// where those values are more authoritative than values recalculated from a
|
||||
// simplified/import GPX. GPX parsing remains mandatory and provides fallback
|
||||
// metrics plus the start coordinate.
|
||||
func applyProviderMetrics(metrics *trailMetrics, metadata map[string]any) {
|
||||
if metrics == nil || len(metadata) == 0 {
|
||||
return
|
||||
}
|
||||
if value, ok := positiveFloatMetadata(metadata, "distance"); ok {
|
||||
metrics.Distance = value
|
||||
}
|
||||
if value, ok := positiveFloatMetadata(metadata, "elevationGain"); ok {
|
||||
metrics.ElevationGain = value
|
||||
}
|
||||
if value, ok := positiveFloatMetadata(metadata, "elevationLoss"); ok {
|
||||
metrics.ElevationLoss = value
|
||||
}
|
||||
if value, ok := positiveFloatMetadata(metadata, "duration"); ok {
|
||||
metrics.Duration = value
|
||||
}
|
||||
}
|
||||
|
||||
func positiveFloatMetadata(metadata map[string]any, key string) (float64, bool) {
|
||||
value, ok := floatMetadata(metadata, key)
|
||||
return value, ok && value > 0
|
||||
}
|
||||
|
||||
func floatMetadata(metadata map[string]any, key string) (float64, bool) {
|
||||
switch value := metadata[key].(type) {
|
||||
case float64:
|
||||
return value, true
|
||||
case float32:
|
||||
floatValue := float64(value)
|
||||
return floatValue, true
|
||||
case int:
|
||||
floatValue := float64(value)
|
||||
return floatValue, true
|
||||
case int64:
|
||||
floatValue := float64(value)
|
||||
return floatValue, true
|
||||
case int32:
|
||||
floatValue := float64(value)
|
||||
return floatValue, true
|
||||
case json.Number:
|
||||
parsed, err := value.Float64()
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// publicFromPrivacy respects explicit provider privacy when present and falls
|
||||
// back to the user's wanderer default when the plugin leaves privacy unset.
|
||||
func publicFromPrivacy(privacy *string, defaultPublic bool) bool {
|
||||
if privacy == nil || *privacy == "" {
|
||||
return defaultPublic
|
||||
}
|
||||
return *privacy == "public"
|
||||
}
|
||||
|
||||
// dateFromImport chooses the best available trail date: provider start time,
|
||||
// GPX start time, then the import time.
|
||||
func dateFromImport(item pluginsystem.TrailImport, metrics trailMetrics) time.Time {
|
||||
if item.StartedAt != nil {
|
||||
return *item.StartedAt
|
||||
}
|
||||
if !metrics.StartTime.IsZero() {
|
||||
return metrics.StartTime
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// createWaypoints persists plugin-provided waypoints after the trail exists so
|
||||
// they can reference the imported trail record.
|
||||
func createWaypoints(ctx context.Context, app core.App, waypoints []pluginsystem.Waypoint, opts Options, mediaBudget *pluginMediaBudget, trailID string, trackIndex trackDistanceIndex) error {
|
||||
if len(waypoints) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("waypoints")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, waypoint := range waypoints {
|
||||
record := core.NewRecord(collection)
|
||||
icon := waypoint.Icon
|
||||
if icon == "" {
|
||||
icon = "circle"
|
||||
}
|
||||
distanceFromStart := 0.0
|
||||
if distance, ok := trackIndex.nearest(geoPoint{Lat: waypoint.Lat, Lon: waypoint.Lon}); ok {
|
||||
distanceFromStart = distance.fromStart
|
||||
}
|
||||
photos := photoFiles(ctx, app, waypoint.Photos, opts, mediaBudget)
|
||||
record.Load(map[string]any{
|
||||
"name": waypoint.Name,
|
||||
"description": waypoint.Description,
|
||||
"lat": waypoint.Lat,
|
||||
"lon": waypoint.Lon,
|
||||
"icon": icon,
|
||||
"author": opts.ActorID,
|
||||
"distance_from_start": distanceFromStart,
|
||||
"trail": trailID,
|
||||
})
|
||||
if len(photos) > 0 {
|
||||
record.Set("photos", photos)
|
||||
}
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// photoFiles converts plugin photo descriptors into PocketBase file objects.
|
||||
// Individual photo failures are logged and skipped so one broken media URL does
|
||||
// not fail the whole trail import.
|
||||
type pluginMediaBudget struct {
|
||||
items int
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func (b *pluginMediaBudget) remainingBytes() int64 {
|
||||
remaining := util.DefaultPluginMaxImportMediaBytes - b.bytes
|
||||
if remaining < util.DefaultPluginMediaMaxBytes {
|
||||
return remaining
|
||||
}
|
||||
return util.DefaultPluginMediaMaxBytes
|
||||
}
|
||||
|
||||
func photoFiles(ctx context.Context, app core.App, photos []pluginsystem.Photo, opts Options, budget *pluginMediaBudget) []*filesystem.File {
|
||||
if len(photos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
files := make([]*filesystem.File, 0, len(photos))
|
||||
now := time.Now()
|
||||
for _, photo := range photos {
|
||||
if budget.items >= util.DefaultPluginMaxImportMediaItems {
|
||||
app.Logger().Warn("skipping plugin photo because media item limit was reached", "limit", util.DefaultPluginMaxImportMediaItems)
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
app.Logger().Warn("skipping plugin photo because import context was cancelled", "error", err)
|
||||
return files
|
||||
}
|
||||
if photo.Source.ExpiresAt != nil && photo.Source.ExpiresAt.Before(now) {
|
||||
app.Logger().Warn("skipping expired plugin photo", "external_id", photo.ExternalID)
|
||||
continue
|
||||
}
|
||||
maxBytes := budget.remainingBytes()
|
||||
if maxBytes <= 0 {
|
||||
app.Logger().Warn("skipping plugin photo because aggregate media byte limit was reached", "external_id", photo.ExternalID, "limit", util.DefaultPluginMaxImportMediaBytes)
|
||||
continue
|
||||
}
|
||||
|
||||
file, bytesRead, err := photoFile(ctx, photo, opts, maxBytes)
|
||||
if err != nil {
|
||||
app.Logger().Warn("skipping plugin photo", "external_id", photo.ExternalID, "error", err)
|
||||
continue
|
||||
}
|
||||
if file != nil {
|
||||
files = append(files, file)
|
||||
budget.items++
|
||||
budget.bytes += bytesRead
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// photoFile fetches one plugin-provided photo source. URL sources are validated
|
||||
// before PocketBase performs the server-side download.
|
||||
func photoFile(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*filesystem.File, int64, error) {
|
||||
switch photo.Source.Type {
|
||||
case "url":
|
||||
if photo.Source.URL == "" {
|
||||
return nil, 0, fmt.Errorf("photo URL is empty")
|
||||
}
|
||||
if err := validateRemoteMediaURLSyntax(photo.Source.URL); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
fetched, err := util.FetchPublicURL(ctx, photo.Source.URL, maxBytes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType))
|
||||
return file, int64(len(fetched.Body)), err
|
||||
case "connector":
|
||||
fetched, err := fetchConnectorMedia(ctx, photo, opts, maxBytes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
file, err := filesystem.NewFileFromBytes(fetched.Body, safeMediaFileName(photo.Filename, urlPathBase(fetched.FinalURL), fetched.ContentType, photo.ContentType))
|
||||
return file, int64(len(fetched.Body)), err
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unsupported photo source type %q", photo.Source.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchConnectorMedia(ctx context.Context, photo pluginsystem.Photo, opts Options, maxBytes int64) (*util.SafeFetchResult, error) {
|
||||
if photo.Source.MediaRef == nil {
|
||||
return nil, fmt.Errorf("connector mediaRef is required")
|
||||
}
|
||||
ref := *photo.Source.MediaRef
|
||||
if ref.AssetID != "" && ref.Path == "" {
|
||||
return nil, fmt.Errorf("mediaRef.assetId is metadata only; path is required")
|
||||
}
|
||||
target := pluginsystem.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: ref.Connector,
|
||||
Path: ref.Path,
|
||||
Query: ref.Query,
|
||||
}
|
||||
resolved, err := pluginsystem.ResolveRequestTarget(opts.Manifest, target, opts.Policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ref.Auth != "" {
|
||||
if !resolved.Connector.SupportsMediaAuth {
|
||||
return nil, fmt.Errorf("connector %q does not support media auth", ref.Connector)
|
||||
}
|
||||
if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, ref.Auth) {
|
||||
return nil, fmt.Errorf("auth context %q is not permitted for connector %q", ref.Auth, ref.Connector)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolved.URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pluginsystem.InjectRequestAuthForContext(opts.Manifest, opts.Auth, ref.Auth, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var storageRedirect *storageRedirectTarget
|
||||
client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{
|
||||
BaseURL: resolved.Connector.BaseURL,
|
||||
AllowPrivate: resolved.Connector.AllowPrivate,
|
||||
TLSMode: resolved.Connector.TLS.Mode,
|
||||
TLSCABundle: resolved.Connector.TLS.CABundle,
|
||||
}, func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
previous := resolved.URL
|
||||
if len(via) > 0 {
|
||||
previous = via[len(via)-1].URL
|
||||
}
|
||||
if err := pluginsystem.ValidateConnectorRedirect(resolved.Connector, previous, req.URL); err == nil {
|
||||
return nil
|
||||
}
|
||||
origin, err := pluginsystem.ConnectorStorageRedirectOrigin(resolved.Connector, previous, req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stripConnectorAuth(req, opts.Manifest, ref.Auth)
|
||||
storageRedirect = &storageRedirectTarget{
|
||||
URL: req.URL.String(),
|
||||
Origin: origin,
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if storageRedirect != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
return fetchStorageRedirectMedia(ctx, *storageRedirect, maxBytes)
|
||||
}
|
||||
body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil
|
||||
}
|
||||
|
||||
type storageRedirectTarget struct {
|
||||
URL string
|
||||
Origin pluginsystem.ResolvedConnectorOrigin
|
||||
}
|
||||
|
||||
func fetchStorageRedirectMedia(ctx context.Context, redirect storageRedirectTarget, maxBytes int64) (*util.SafeFetchResult, error) {
|
||||
storageConnector := pluginsystem.ResolvedConnectorTarget{
|
||||
Name: redirect.Origin.Name,
|
||||
BaseURL: redirect.Origin.BaseURL,
|
||||
BasePath: redirect.Origin.BasePath,
|
||||
AllowPrivate: redirect.Origin.AllowPrivate,
|
||||
TLS: redirect.Origin.TLS,
|
||||
AllowedPathPrefixes: []string{"/"},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, redirect.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := util.ConnectorHTTPClient(util.ConnectorHTTPPolicy{
|
||||
BaseURL: redirect.Origin.BaseURL,
|
||||
AllowPrivate: redirect.Origin.AllowPrivate,
|
||||
TLSMode: redirect.Origin.TLS.Mode,
|
||||
TLSCABundle: redirect.Origin.TLS.CABundle,
|
||||
}, func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
previous := req.URL
|
||||
if len(via) > 0 {
|
||||
previous = via[len(via)-1].URL
|
||||
}
|
||||
return pluginsystem.ValidateConnectorRedirect(storageConnector, previous, req.URL)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := util.ReadBoundedForPlugin(resp.Body, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &util.SafeFetchResult{Body: body, ContentType: resp.Header.Get("Content-Type"), FinalURL: resp.Request.URL.String()}, nil
|
||||
}
|
||||
|
||||
func stripConnectorAuth(req *http.Request, manifest pluginsystem.Manifest, authName string) {
|
||||
req.Header.Del(pluginsystem.AuthHeaderAuthorization)
|
||||
if authName == "" {
|
||||
return
|
||||
}
|
||||
authContext, ok := manifest.Auth.Contexts[authName]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if authContext.Name != "" {
|
||||
req.Header.Del(authContext.Name)
|
||||
req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.Name)
|
||||
}
|
||||
if authContext.SecretField != "" {
|
||||
req.Header.Del(authContext.SecretField)
|
||||
req.URL.RawQuery = removeRawQueryParamOrdered(req.URL.RawQuery, authContext.SecretField)
|
||||
}
|
||||
}
|
||||
|
||||
func validateRemoteMediaURLSyntax(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid media URL: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("unsupported media URL scheme %q", parsed.Scheme)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("media URL has no host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func urlPathBase(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return urlpath.Base(parsed.Path)
|
||||
}
|
||||
|
||||
func removeRawQueryParamOrdered(rawQuery string, name string) string {
|
||||
if rawQuery == "" || name == "" {
|
||||
return rawQuery
|
||||
}
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
rawName := part
|
||||
if idx := strings.Index(rawName, "="); idx >= 0 {
|
||||
rawName = rawName[:idx]
|
||||
}
|
||||
decodedName, err := url.QueryUnescape(rawName)
|
||||
if err == nil && decodedName == name {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
return strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
// createSummitLog mirrors completed imported trails into summit_logs when the
|
||||
// user has enabled that compatibility option.
|
||||
func createSummitLog(app core.App, trailID string, actorID string, date time.Time, metrics trailMetrics) error {
|
||||
collection, err := app.FindCollectionByNameOrId("summit_logs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Load(map[string]any{
|
||||
"distance": metrics.Distance,
|
||||
"elevation_gain": metrics.ElevationGain,
|
||||
"elevation_loss": metrics.ElevationLoss,
|
||||
"duration": metrics.Duration,
|
||||
"date": date,
|
||||
"author": actorID,
|
||||
"trail": trailID,
|
||||
})
|
||||
|
||||
return app.Save(record)
|
||||
}
|
||||
|
||||
func categoryIDForImport(app core.App, item pluginsystem.TrailImport, mapping map[string]string) string {
|
||||
if category, matched := CategoryFromProviderMapping(app, ProviderCategoryFromImport(item), mapping); matched {
|
||||
return category
|
||||
}
|
||||
return categoryIDForActivityType(app, item.ActivityType)
|
||||
}
|
||||
|
||||
func ProviderCategoryFromImport(item pluginsystem.TrailImport) string {
|
||||
value, _ := item.Metadata["providerCategory"].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
value, _ = item.Metadata["sourceSport"].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func CategoryFromProviderMapping(app core.App, providerCategory string, mapping map[string]string) (string, bool) {
|
||||
providerCategory = strings.TrimSpace(providerCategory)
|
||||
if providerCategory == "" || len(mapping) == 0 {
|
||||
return "", false
|
||||
}
|
||||
rawTarget, matched := mapping[providerCategory]
|
||||
if !matched {
|
||||
return "", false
|
||||
}
|
||||
target := strings.TrimSpace(rawTarget)
|
||||
if target == "" {
|
||||
return "", true
|
||||
}
|
||||
if category, err := app.FindRecordById("categories", target); err == nil && category != nil {
|
||||
return category.Id, true
|
||||
}
|
||||
category, _ := app.FindFirstRecordByData("categories", "name", target)
|
||||
if category == nil {
|
||||
return "", false
|
||||
}
|
||||
return category.Id, true
|
||||
}
|
||||
|
||||
// categoryIDForActivityType maps common provider activity labels to wanderer's
|
||||
// built-in categories. Unknown labels intentionally leave the category empty.
|
||||
func categoryIDForActivityType(app core.App, activityType string) string {
|
||||
categoryMap := map[string]string{
|
||||
"hiking": "Hiking",
|
||||
"hike": "Hiking",
|
||||
"walking": "Walking",
|
||||
"walk": "Walking",
|
||||
"running": "Walking",
|
||||
"run": "Walking",
|
||||
"biking": "Biking",
|
||||
"cycling": "Biking",
|
||||
"ride": "Biking",
|
||||
"mtb": "Biking",
|
||||
"skiing": "Skiing",
|
||||
"canoeing": "Canoeing",
|
||||
"climbing": "Climbing",
|
||||
}
|
||||
|
||||
name := categoryMap[strings.ToLower(activityType)]
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
category, _ := app.FindFirstRecordByData("categories", "name", name)
|
||||
if category == nil {
|
||||
return ""
|
||||
}
|
||||
return category.Id
|
||||
}
|
||||
|
||||
func fallbackName(name string) string {
|
||||
if strings.TrimSpace(name) != "" {
|
||||
return name
|
||||
}
|
||||
return "Imported trail"
|
||||
}
|
||||
|
||||
// safeGPXFileName turns provider trail names into filesystem-safe GPX filenames.
|
||||
func safeGPXFileName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "imported-trail"
|
||||
}
|
||||
name = filepath.Base(name)
|
||||
name = strings.TrimSuffix(name, filepath.Ext(name))
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '-'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
return name + ".gpx"
|
||||
}
|
||||
|
||||
// safeMediaFileName picks the first safe candidate filename and adds a best
|
||||
// effort extension when providers only expose a content type.
|
||||
func safeMediaFileName(candidates ...string) string {
|
||||
filename := ""
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || strings.Contains(candidate, "/") {
|
||||
continue
|
||||
}
|
||||
base := filepath.Base(candidate)
|
||||
if base == "." || base == ".." {
|
||||
continue
|
||||
}
|
||||
filename = candidate
|
||||
break
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "photo"
|
||||
}
|
||||
filename = filepath.Base(filename)
|
||||
filename = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '-'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, filename)
|
||||
if ext := filepath.Ext(filename); ext == "" || ext == "." {
|
||||
filename += extensionFromContentTypes(candidates...)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func extensionFromContentTypes(candidates ...string) string {
|
||||
for _, candidate := range candidates {
|
||||
if extensions, err := mime.ExtensionsByType(strings.TrimSpace(candidate)); err == nil && len(extensions) > 0 {
|
||||
return extensions[0]
|
||||
}
|
||||
}
|
||||
return ".jpg"
|
||||
}
|
||||
432
db/plugins/importer/importer_test.go
Normal file
432
db/plugins/importer/importer_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pluginsystem "pocketbase/pluginsystem"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
const sampleGPX = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="46.000000" lon="8.000000"><ele>100</ele><time>2026-01-01T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="46.001000" lon="8.001000"><ele>120</ele><time>2026-01-01T10:10:00Z</time></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`
|
||||
|
||||
func gpxTrack() pluginsystem.Track {
|
||||
return pluginsystem.Track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(sampleGPX)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAndParseGPX(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
raw, parsed, err := decodeAndParseGPX(gpxTrack())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed == nil {
|
||||
t.Fatal("expected parsed gpx")
|
||||
}
|
||||
if string(raw) != sampleGPX {
|
||||
t.Fatal("decoded bytes do not match input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported format", func(t *testing.T) {
|
||||
if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "tcx", ContentBase64: "x"}); err == nil {
|
||||
t.Fatal("expected error for unsupported format")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty content", func(t *testing.T) {
|
||||
if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "gpx"}); err == nil {
|
||||
t.Fatal("expected error for empty content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid base64", func(t *testing.T) {
|
||||
if _, _, err := decodeAndParseGPX(pluginsystem.Track{Format: "gpx", ContentBase64: "!!!not-base64"}); err == nil {
|
||||
t.Fatal("expected error for invalid base64")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid gpx", func(t *testing.T) {
|
||||
track := pluginsystem.Track{Format: "gpx", ContentBase64: base64.StdEncoding.EncodeToString([]byte("not gpx"))}
|
||||
if _, _, err := decodeAndParseGPX(track); err == nil {
|
||||
t.Fatal("expected error for invalid gpx")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsFromGPX(t *testing.T) {
|
||||
_, parsed, err := decodeAndParseGPX(gpxTrack())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
metrics := metricsFromGPX(parsed)
|
||||
if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 {
|
||||
t.Fatalf("unexpected start point: %v, %v", metrics.StartLat, metrics.StartLon)
|
||||
}
|
||||
if metrics.Distance <= 0 {
|
||||
t.Fatalf("expected positive distance, got %v", metrics.Distance)
|
||||
}
|
||||
if metrics.ElevationGain <= 0 {
|
||||
t.Fatalf("expected positive elevation gain, got %v", metrics.ElevationGain)
|
||||
}
|
||||
if !metrics.StartTime.Equal(time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("unexpected start time: %v", metrics.StartTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderMetrics(t *testing.T) {
|
||||
metrics := trailMetrics{
|
||||
Distance: 1,
|
||||
ElevationGain: 2,
|
||||
ElevationLoss: 3,
|
||||
Duration: 4,
|
||||
StartLat: 46,
|
||||
StartLon: 8,
|
||||
}
|
||||
|
||||
applyProviderMetrics(&metrics, map[string]any{
|
||||
"distance": 1234.5,
|
||||
"elevationGain": 234.5,
|
||||
"elevationLoss": 45.5,
|
||||
"duration": 3600,
|
||||
})
|
||||
|
||||
if metrics.Distance != 1234.5 {
|
||||
t.Fatalf("distance = %v", metrics.Distance)
|
||||
}
|
||||
if metrics.ElevationGain != 234.5 {
|
||||
t.Fatalf("elevation gain = %v", metrics.ElevationGain)
|
||||
}
|
||||
if metrics.ElevationLoss != 45.5 {
|
||||
t.Fatalf("elevation loss = %v", metrics.ElevationLoss)
|
||||
}
|
||||
if metrics.Duration != 3600 {
|
||||
t.Fatalf("duration = %v", metrics.Duration)
|
||||
}
|
||||
if metrics.StartLat != 46 || metrics.StartLon != 8 {
|
||||
t.Fatalf("provider metadata must not override start point")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProviderStart(t *testing.T) {
|
||||
_, parsed, err := decodeAndParseGPX(gpxTrack())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
trackIndex := trackDistanceIndexFromGPX(parsed)
|
||||
|
||||
t.Run("uses plausible provider start", func(t *testing.T) {
|
||||
metrics := metricsFromGPX(parsed)
|
||||
applyProviderStart(&metrics, trackIndex, map[string]any{
|
||||
"providerStart": map[string]any{
|
||||
"lat": 45.9995,
|
||||
"lon": 7.9995,
|
||||
},
|
||||
})
|
||||
|
||||
if metrics.StartLat != 45.9995 || metrics.StartLon != 7.9995 {
|
||||
t.Fatalf("unexpected provider start: %v, %v", metrics.StartLat, metrics.StartLon)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores distant provider start", func(t *testing.T) {
|
||||
metrics := metricsFromGPX(parsed)
|
||||
applyProviderStart(&metrics, trackIndex, map[string]any{
|
||||
"providerStart": map[string]any{
|
||||
"lat": 47.0,
|
||||
"lon": 8.0,
|
||||
},
|
||||
})
|
||||
|
||||
if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 {
|
||||
t.Fatalf("distant provider start should be ignored: %v, %v", metrics.StartLat, metrics.StartLon)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores invalid provider start", func(t *testing.T) {
|
||||
metrics := metricsFromGPX(parsed)
|
||||
applyProviderStart(&metrics, trackIndex, map[string]any{
|
||||
"providerStart": map[string]any{
|
||||
"lat": 91.0,
|
||||
"lon": 8.0,
|
||||
},
|
||||
})
|
||||
|
||||
if metrics.StartLat != 46.0 || metrics.StartLon != 8.0 {
|
||||
t.Fatalf("invalid provider start should be ignored: %v, %v", metrics.StartLat, metrics.StartLon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrackDistanceIndexNearest(t *testing.T) {
|
||||
_, parsed, err := decodeAndParseGPX(gpxTrack())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
trackIndex := trackDistanceIndexFromGPX(parsed)
|
||||
total := util.HaversineDistanceMeters(46.0, 8.0, 46.001, 8.001)
|
||||
|
||||
t.Run("start point", func(t *testing.T) {
|
||||
distance, ok := trackIndex.nearest(geoPoint{Lat: 46.0, Lon: 8.0})
|
||||
if !ok {
|
||||
t.Fatal("expected nearest distance")
|
||||
}
|
||||
if distance.fromStart != 0 {
|
||||
t.Fatalf("got %v, want 0", distance.fromStart)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mid segment projection", func(t *testing.T) {
|
||||
distance, ok := trackIndex.nearest(geoPoint{Lat: 46.0005, Lon: 8.0005})
|
||||
if !ok {
|
||||
t.Fatal("expected nearest distance")
|
||||
}
|
||||
if distance.fromStart < total*0.45 || distance.fromStart > total*0.55 {
|
||||
t.Fatalf("got %v, want about half of %v", distance.fromStart, total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("end point", func(t *testing.T) {
|
||||
distance, ok := trackIndex.nearest(geoPoint{Lat: 46.001, Lon: 8.001})
|
||||
if !ok {
|
||||
t.Fatal("expected nearest distance")
|
||||
}
|
||||
if distance.fromStart < total-0.001 || distance.fromStart > total+0.001 {
|
||||
t.Fatalf("got %v, want %v", distance.fromStart, total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyProviderMetricsIgnoresEmptyValues(t *testing.T) {
|
||||
metrics := trailMetrics{
|
||||
Distance: 1,
|
||||
ElevationGain: 2,
|
||||
ElevationLoss: 3,
|
||||
Duration: 4,
|
||||
}
|
||||
|
||||
applyProviderMetrics(&metrics, map[string]any{
|
||||
"distance": 0,
|
||||
"elevationGain": -1,
|
||||
"elevationLoss": "",
|
||||
"duration": nil,
|
||||
})
|
||||
|
||||
if metrics.Distance != 1 || metrics.ElevationGain != 2 || metrics.ElevationLoss != 3 || metrics.Duration != 4 {
|
||||
t.Fatalf("unexpected metrics after empty metadata: %#v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFromPrivacy(t *testing.T) {
|
||||
public := "public"
|
||||
private := "private"
|
||||
empty := ""
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
privacy *string
|
||||
defaultPublic bool
|
||||
want bool
|
||||
}{
|
||||
{"nil keeps default true", nil, true, true},
|
||||
{"nil keeps default false", nil, false, false},
|
||||
{"explicit public", &public, false, true},
|
||||
{"explicit private", &private, true, false},
|
||||
{"empty keeps default", &empty, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := publicFromPrivacy(tc.privacy, tc.defaultPublic); got != tc.want {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryIDForImportDoesNotFallbackWhenProviderMappingIsBlank(t *testing.T) {
|
||||
item := pluginsystem.TrailImport{
|
||||
ActivityType: "biking",
|
||||
Metadata: map[string]any{
|
||||
"providerCategory": " Ride ",
|
||||
},
|
||||
}
|
||||
|
||||
if got := categoryIDForImport(nil, item, map[string]string{"Ride": ""}); got != "" {
|
||||
t.Fatalf("expected blank provider mapping to suppress activity fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCategoryFromImport(t *testing.T) {
|
||||
if got := ProviderCategoryFromImport(pluginsystem.TrailImport{
|
||||
Metadata: map[string]any{"providerCategory": " Ride "},
|
||||
}); got != "Ride" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := ProviderCategoryFromImport(pluginsystem.TrailImport{
|
||||
Metadata: map[string]any{"sourceSport": " hiking "},
|
||||
}); got != "hiking" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateFromImport(t *testing.T) {
|
||||
started := time.Date(2025, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("uses StartedAt", func(t *testing.T) {
|
||||
item := pluginsystem.TrailImport{StartedAt: &started}
|
||||
if got := dateFromImport(item, trailMetrics{}); !got.Equal(started) {
|
||||
t.Fatalf("got %v, want %v", got, started)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to metrics start time", func(t *testing.T) {
|
||||
metricStart := time.Date(2024, 1, 2, 3, 0, 0, 0, time.UTC)
|
||||
if got := dateFromImport(pluginsystem.TrailImport{}, trailMetrics{StartTime: metricStart}); !got.Equal(metricStart) {
|
||||
t.Fatalf("got %v, want %v", got, metricStart)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to now", func(t *testing.T) {
|
||||
got := dateFromImport(pluginsystem.TrailImport{}, trailMetrics{})
|
||||
if time.Since(got) > time.Minute {
|
||||
t.Fatalf("expected ~now, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFallbackName(t *testing.T) {
|
||||
if got := fallbackName("My Trail"); got != "My Trail" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := fallbackName(""); got != "Imported trail" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := fallbackName(" "); got != "Imported trail" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeGPXFileName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"track.gpx": "track.gpx",
|
||||
"My Trip": "My Trip.gpx",
|
||||
"": "imported-trail.gpx",
|
||||
"../../etc/passwd": "passwd.gpx",
|
||||
"a:b*c?": "a-b-c-.gpx",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := safeGPXFileName(in); got != want {
|
||||
t.Fatalf("safeGPXFileName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeMediaFileName(t *testing.T) {
|
||||
t.Run("keeps valid filename", func(t *testing.T) {
|
||||
if got := safeMediaFileName("photo.jpg"); got != "photo.jpg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("skips empty and slashed candidates", func(t *testing.T) {
|
||||
if got := safeMediaFileName("", "a/b.jpg", "c.png"); got != "c.png" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("falls back to photo.jpg when no candidate", func(t *testing.T) {
|
||||
if got := safeMediaFileName(""); got != "photo.jpg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("rejects slashed traversal candidate", func(t *testing.T) {
|
||||
// Candidates containing "/" are rejected outright (not stripped), so a
|
||||
// path-traversal candidate falls back to the safe default name.
|
||||
if got := safeMediaFileName("../../x.png"); got != "photo.jpg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("rejects dotdot candidate", func(t *testing.T) {
|
||||
if got := safeMediaFileName(".."); got != "photo.jpg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtensionFromContentTypes(t *testing.T) {
|
||||
if got := extensionFromContentTypes("application/x-unknown-xyz"); got != ".jpg" {
|
||||
t.Fatalf("expected .jpg fallback, got %q", got)
|
||||
}
|
||||
if got := extensionFromContentTypes("image/png"); !strings.HasPrefix(got, ".") {
|
||||
t.Fatalf("expected an extension, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRemoteMediaURLSyntax(t *testing.T) {
|
||||
t.Run("rejects non-http scheme", func(t *testing.T) {
|
||||
if err := validateRemoteMediaURLSyntax("ftp://example.com/x"); err == nil {
|
||||
t.Fatal("expected error for ftp scheme")
|
||||
}
|
||||
})
|
||||
t.Run("rejects missing host", func(t *testing.T) {
|
||||
if err := validateRemoteMediaURLSyntax("http://"); err == nil {
|
||||
t.Fatal("expected error for missing host")
|
||||
}
|
||||
})
|
||||
t.Run("allows http syntax", func(t *testing.T) {
|
||||
if err := validateRemoteMediaURLSyntax("https://8.8.8.8/photo.jpg"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhotoFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("empty url", func(t *testing.T) {
|
||||
photo := pluginsystem.Photo{Source: pluginsystem.MediaSource{Type: "url"}}
|
||||
if _, _, err := photoFile(ctx, photo, Options{}, 1024); err == nil {
|
||||
t.Fatal("expected error for empty url")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported type", func(t *testing.T) {
|
||||
photo := pluginsystem.Photo{Source: pluginsystem.MediaSource{Type: "carrier"}}
|
||||
if _, _, err := photoFile(ctx, photo, Options{}, 1024); err == nil {
|
||||
t.Fatal("expected error for unsupported source type")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPluginMediaBudgetRemainingBytes(t *testing.T) {
|
||||
budget := &pluginMediaBudget{}
|
||||
if got := budget.remainingBytes(); got != util.DefaultPluginMediaMaxBytes {
|
||||
t.Fatalf("got %d, want per-file limit %d", got, util.DefaultPluginMediaMaxBytes)
|
||||
}
|
||||
budget.bytes = util.DefaultPluginMaxImportMediaBytes - 10
|
||||
if got := budget.remainingBytes(); got != 10 {
|
||||
t.Fatalf("got %d, want remaining aggregate budget", got)
|
||||
}
|
||||
budget.bytes = util.DefaultPluginMaxImportMediaBytes
|
||||
if got := budget.remainingBytes(); got != 0 {
|
||||
t.Fatalf("got %d, want exhausted budget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRawQueryParamOrdered(t *testing.T) {
|
||||
raw := "z=last&api_key=secret&a=first&api_key=second"
|
||||
if got := removeRawQueryParamOrdered(raw, "api_key"); got != "z=last&a=first" {
|
||||
t.Fatalf("unexpected query: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user