diff --git a/Areas/Admin/Controllers/RoutingProviderController.cs b/Areas/Admin/Controllers/RoutingProviderController.cs index dc95c696..1e7754ba 100644 --- a/Areas/Admin/Controllers/RoutingProviderController.cs +++ b/Areas/Admin/Controllers/RoutingProviderController.cs @@ -40,7 +40,8 @@ public async Task Index(CancellationToken cancellationToken) /// Displays a new typed OSRM configuration. public async Task Create(CancellationToken cancellationToken) => - View(await PopulateMappingsAsync(new RoutingProviderEditViewModel(), cancellationToken)); + View(await PopulateMappingsAsync(new RoutingProviderEditViewModel + { VerificationFromLongitude = 0, VerificationFromLatitude = 0, VerificationToLongitude = 0.01, VerificationToLatitude = 0 }, cancellationToken)); /// Creates one allowlisted OSRM configuration. [HttpPost, ValidateAntiForgeryToken] @@ -145,7 +146,8 @@ private async Task PopulateMappingsAsync( private static RoutingProviderEditViewModel ToModel(RoutingProviderConfiguration provider) => new() { - Id = provider.Id, DisplayName = provider.DisplayName, BaseEndpoint = provider.BaseEndpoint ?? string.Empty, + Id = provider.Id, DisplayName = provider.DisplayName, AdapterType = provider.AdapterType, + BaseEndpoint = provider.BaseEndpoint ?? string.Empty, CredentialRequired = provider.CredentialRequired, CredentialPresent = provider.CredentialPresent, PersonalRoutingAccess = provider.PersonalRoutingAccess, Enabled = provider.Enabled, Attribution = provider.Attribution, diff --git a/Areas/Admin/Models/RoutingProviderViewModels.cs b/Areas/Admin/Models/RoutingProviderViewModels.cs index 29ba6643..3f36c75e 100644 --- a/Areas/Admin/Models/RoutingProviderViewModels.cs +++ b/Areas/Admin/Models/RoutingProviderViewModels.cs @@ -17,6 +17,9 @@ public sealed record RoutingProviderRowViewModel( /// Contains allowlisted OSRM configuration and mapping edit fields. public sealed class RoutingProviderEditViewModel : IValidatableObject { + /// Gets or sets the explicit adapter owned by this configuration. + [EnumDataType(typeof(RoutingAdapterType))] + public RoutingAdapterType AdapterType { get; set; } = RoutingAdapterType.OsrmCompatible; /// Gets or sets the provider identity for edits. public Guid Id { get; set; } @@ -96,7 +99,8 @@ public sealed class RoutingProviderEditViewModel : IValidatableObject /// Validates coordinates and credential-required completeness. public IEnumerable Validate(ValidationContext validationContext) { - if (CredentialRequired && !CredentialPresent && string.IsNullOrWhiteSpace(Credential)) + if (AdapterType == RoutingAdapterType.OsrmCompatible && CredentialRequired + && !CredentialPresent && string.IsNullOrWhiteSpace(Credential)) yield return new ValidationResult("A credential is required for this configuration.", [nameof(Credential)]); foreach (var (value, name, minimum, maximum) in new[] { @@ -105,10 +109,11 @@ public IEnumerable Validate(ValidationContext validationContex (VerificationToLongitude, nameof(VerificationToLongitude), -180d, 180d), (VerificationToLatitude, nameof(VerificationToLatitude), -90d, 90d) }) - if (value is not double coordinate || !double.IsFinite(coordinate) || coordinate < minimum || coordinate > maximum) + if (AdapterType == RoutingAdapterType.OsrmCompatible + && (value is not double coordinate || !double.IsFinite(coordinate) || coordinate < minimum || coordinate > maximum)) yield return new ValidationResult("A finite in-range verification coordinate is required.", [name]); - if (Mappings.Count(item => !string.IsNullOrWhiteSpace(item.OsrmProfile)) is 0 or > 8) - yield return new ValidationResult("Map between one and eight transport profiles.", [nameof(Mappings)]); + if (Mappings.Count(item => !string.IsNullOrWhiteSpace(item.OsrmProfile)) > 100) + yield return new ValidationResult("Too many transport-profile mappings.", [nameof(Mappings)]); } } diff --git a/Areas/Admin/Views/RoutingProvider/_Form.cshtml b/Areas/Admin/Views/RoutingProvider/_Form.cshtml index 5b3db9b6..0318f196 100644 --- a/Areas/Admin/Views/RoutingProvider/_Form.cshtml +++ b/Areas/Admin/Views/RoutingProvider/_Form.cshtml @@ -5,8 +5,8 @@
-
-
+
+
Geoapify always uses its fixed official endpoint; this value is ignored.
Leave blank to preserve the @(Model.CredentialPresent ? "stored credential" : "empty credential").
@@ -33,8 +33,17 @@
-
+
+ @if (Model.AdapterType == Wayfarer.Models.RoutingAdapterType.Geoapify) + { + + } + else + { } + +
} + diff --git a/Areas/Api/Controllers/MobileRoutingController.cs b/Areas/Api/Controllers/MobileRoutingController.cs new file mode 100644 index 00000000..c27cad8c --- /dev/null +++ b/Areas/Api/Controllers/MobileRoutingController.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Wayfarer.Models; +using Wayfarer.Services; +using Wayfarer.Services.ExternalRouting; + +namespace Wayfarer.Areas.Api.Controllers; + +/// Exposes authenticated provider-neutral mobile routing without provider selection or persistence. +[Route("api/mobile/routing")] +public sealed class MobileRoutingController( + ApplicationDbContext dbContext, ILogger logger, IMobileCurrentUserAccessor userAccessor, + MobileRoutingService routing) : MobileApiController(dbContext, logger, userAccessor) +{ + /// Returns no-contact capability for one stable Wayfarer transport profile identity. + [HttpGet("capability/{transportProfileId:guid}")] + public async Task Capability(Guid transportProfileId, CancellationToken cancellationToken) + { + var (user, error) = await EnsureAuthenticatedUserAsync(cancellationToken); + return error ?? Ok(await routing.CapabilityAsync(user!.Id, transportProfileId, cancellationToken)); + } + + /// Generates one bounded provider-neutral route without mutating server domain state. + [HttpPost("route")] + public async Task Route(MobileRouteRequest request, CancellationToken cancellationToken) + { + var (user, error) = await EnsureAuthenticatedUserAsync(cancellationToken); + if (error != null) return error; + if (request.AdditionalFields is { Count: > 0 } || request.Anchors.Count > 3) + return BadRequest(MobileRouteResponse.Failure("invalid-request")); + var points = new[] { request.Origin }.Concat(request.Anchors).Concat([request.Destination]) + .Select(item => new RouteCoordinate(item.Longitude, item.Latitude)).ToArray(); + var result = await routing.RouteAsync(user!.Id, request.TransportProfileId, points, cancellationToken); + return Ok(MobileRouteResponse.From(result)); + } +} + +/// Contains only server-resolved mobile route inputs. +public sealed class MobileRouteRequest +{ + public Guid TransportProfileId { get; set; } + public required MobileRouteCoordinate Origin { get; set; } + public required MobileRouteCoordinate Destination { get; set; } + public IReadOnlyList Anchors { get; set; } = []; + [JsonExtensionData] public Dictionary? AdditionalFields { get; set; } +} + +/// Contains one WGS84 coordinate with no provider semantics. +public sealed record MobileRouteCoordinate(double Longitude, double Latitude); + +/// Contains bounded provider-neutral route output and no secret/admin endpoint fields. +public sealed record MobileRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, + double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, + DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, + Guid? TransportProfileId, IReadOnlyList? MatchPoints, + IReadOnlyList? Attribution, string? StorageMode) +{ + public static MobileRouteResponse From(MobileRouteServiceResult value) => new(value.Succeeded, value.Outcome, + value.Geometry, value.DistanceMetres, value.DurationSeconds, value.Instructions, value.GeneratedAt, + value.Provider, value.ProviderConfigurationId, value.MappingIdentity, value.TransportProfileId, + value.MatchPoints, value.Attribution, value.StorageMode); + public static MobileRouteResponse Failure(string outcome) => From(MobileRouteServiceResult.Failure(outcome)); +} diff --git a/Areas/User/Controllers/LocationProviderSettingsController.cs b/Areas/User/Controllers/LocationProviderSettingsController.cs index a5655a99..237898ba 100644 --- a/Areas/User/Controllers/LocationProviderSettingsController.cs +++ b/Areas/User/Controllers/LocationProviderSettingsController.cs @@ -14,7 +14,9 @@ namespace Wayfarer.Areas.User.Controllers; [Area("User"), Authorize(Roles = "User")] public sealed class LocationProviderSettingsController( ApplicationDbContext dbContext, PersonalProviderCredentialService credentials, - LegacyMapboxMigrationService migration, ReverseGeocodingService reverseGeocoding) : Controller + LegacyMapboxMigrationService migration, ReverseGeocodingService reverseGeocoding, + GeoapifyVerificationService? geoapifyVerification = null, + GeoapifyLocationBackfillService? geoapifyBackfill = null) : Controller { /// Displays masked provider authority and provider-native usage status. public async Task Index(CancellationToken cancellationToken) @@ -52,7 +54,11 @@ public async Task SaveProfile(LocationProviderProfileInput input, && (provider != PersonalLocationProvider.Mapbox || profile.HasCurrentPermanentGeocodingConsent())) selection.Select(PersonalProviderCapability.Geocoding, provider); else if (selection.GeocodingProviderKey == key) selection.Select(PersonalProviderCapability.Geocoding, null); - if (input.ActiveForRouting && input.RoutingAuthorized) selection.Select(PersonalProviderCapability.Routing, provider); + var routingVerified = profile.RoutingVerification == PersonalProviderVerification.Verified + && profile.RoutingVerifiedCredentialGeneration == profile.CredentialGeneration + && profile.RoutingVerifiedConfigurationGeneration == profile.RoutingGeneration; + if (input.ActiveForRouting && input.RoutingAuthorized && routingVerified) + selection.Select(PersonalProviderCapability.Routing, provider); else if (selection.RoutingProviderKey == key) selection.Select(PersonalProviderCapability.Routing, null); await dbContext.SaveChangesAsync(cancellationToken); return RedirectToAction(nameof(Index)); @@ -85,6 +91,33 @@ public async Task VerifyMapboxPermanent(CancellationToken cancell return RedirectToAction(nameof(Index)); } + /// Runs one explicit Geoapify capability verification without changing selection. + [HttpPost, ValidateAntiForgeryToken] + public async Task VerifyGeoapify(PersonalProviderCapability capability, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + var result = geoapifyVerification == null ? PersonalProviderVerification.Unavailable + : capability == PersonalProviderCapability.Geocoding + ? await geoapifyVerification.VerifyGeocodingAsync(userId, cancellationToken) + : await geoapifyVerification.VerifyRoutingAsync(userId, cancellationToken); + TempData["ProviderStatus"] = $"Geoapify {capability.ToString().ToLowerInvariant()} verification: {result}. No provider was selected automatically."; + return RedirectToAction(nameof(Index)); + } + + /// Runs one explicit bounded Location backfill for the authenticated owner. + [HttpPost, ValidateAntiForgeryToken] + public async Task BackfillGeoapify(CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + if (geoapifyBackfill == null) return RedirectToAction(nameof(Index)); + var result = await geoapifyBackfill.RunAsync(userId, cancellationToken); + TempData["ProviderStatus"] = $"Backfill scanned {result.Scanned}, enriched {result.Succeeded}, no result {result.NoResult}, unavailable {result.Unavailable}, remaining {result.RemainingEstimate}." + + (result.Exhausted ? " The rolling safety guard is exhausted; retry after admitted credits age out." : string.Empty); + return RedirectToAction(nameof(Index)); + } + /// Explicitly revokes one credential without deleting profiles, usage, or domain data. [HttpPost, ValidateAntiForgeryToken] public async Task Revoke(string providerKey, bool confirmed, CancellationToken cancellationToken) diff --git a/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs index aa43822a..7cda358e 100644 --- a/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs +++ b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs @@ -26,7 +26,9 @@ public sealed record LocationProviderProfileViewModel( public sealed class LocationProviderProfileInput { [Required, RegularExpression("geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty; - [DataType(DataType.Password), StringLength(2048)] public string? ReplacementCredential { get; set; } + [DataType(DataType.Password), StringLength(2048), RegularExpression(@"^[^\s\p{Cc}]*$", + ErrorMessage = "Credentials cannot contain whitespace or control characters.")] + public string? ReplacementCredential { get; set; } public bool GeocodingAuthorized { get; set; } public bool RoutingAuthorized { get; set; } public bool ActiveForGeocoding { get; set; } diff --git a/Areas/User/Views/LocationProviderSettings/Index.cshtml b/Areas/User/Views/LocationProviderSettings/Index.cshtml index 4ef88d38..0a5279f6 100644 --- a/Areas/User/Views/LocationProviderSettings/Index.cshtml +++ b/Areas/User/Views/LocationProviderSettings/Index.cshtml @@ -56,6 +56,20 @@
+
+
+ + +
+
+ + +
+
+

Backfill processes at most 100 of your wholly unenriched Locations in chronological order. Existing, manual, and imported enrichment is preserved.

+
+ +
} @if (profile.ProviderKey == "mapbox") { diff --git a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue index 206d405c..366b5f48 100644 --- a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue +++ b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue @@ -95,6 +95,9 @@ function discard(): void { function boundedMessage(error: unknown): string { if (!(error instanceof ExternalRouteProposalError)) return 'Route generation is unavailable. The draft is unchanged.'; + if (error.code === 'unmapped-transport-profile') return 'Route suggestions are not configured for this transport profile.'; + if (error.code === 'unsupported-transport-profile') return 'This routing provider does not support the mapped transport mode.'; + if (error.code.includes('unavailable') || error.code.includes('configuration')) return 'Route suggestions are temporarily unavailable.'; if (error.code.includes('stale') || error.code.includes('expired')) return 'This proposal is stale or expired. Generate it again.'; if (error.code.includes('rate') || error.code.includes('budget')) return 'The routing request limit was reached. Try again later.'; return 'The routing provider could not produce a safe route. The draft is unchanged.'; @@ -111,7 +114,11 @@ onUnmounted(() => {

External routed path

{{ capability.providerDisplayName }} · {{ capability.mappedProfileLabel }}

{{ capability.disclosure }}

-

{{ capability.attribution }}

+

+ Powered by Geoapify · + © OpenStreetMap contributors +

+

{{ capability.attribution }}

Save the transport-profile change before generating a new proposal.