Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Areas/Admin/Views/ApiToken/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<p class="mb-1"><strong>Created At:</strong> @token.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</p>
<p class="mb-0">
<strong>Token:</strong>
<code class="border p-2 rounded bg-light">@token.Token</code>
<code class="border p-2 rounded bg-light">@token.DisplayToken</code>
</p>
</div>
<div class="btn-group">
Expand Down
10 changes: 3 additions & 7 deletions Areas/Api/Controllers/LocationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ public async Task<IActionResult> CheckIn([FromBody] GpsLoggerLocationDto dto)
var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync(
dto.Latitude, dto.Longitude, apiToken.Token ?? string.Empty, apiToken.Name ?? string.Empty);

_logger.LogInformation(
$"Check-in, user has mapbox Api token, we got reverse geocoding data: {locationInfo.FullAddress}");
_logger.LogInformation("Check-in reverse geocoding completed.");

location.FullAddress = locationInfo.FullAddress;
location.Address = locationInfo.Address;
Expand Down Expand Up @@ -675,8 +674,7 @@ await _placeVisitDetectionService.ProcessPingAsync(
var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync(
dto.Latitude, dto.Longitude, apiToken.Token, apiToken.Name);

_logger.LogInformation(
$"Log-location, user has mapbox Api token, we got reverse geocoding data: {locationInfo.FullAddress}");
_logger.LogInformation("Log-location reverse geocoding completed.");

location.FullAddress = locationInfo.FullAddress;
location.Address = locationInfo.Address;
Expand Down Expand Up @@ -1055,9 +1053,7 @@ public async Task<IActionResult> Update(int id, [FromBody] LocationUpdateRequest
var locationInfo = await _reverseGeocodingService.GetReverseGeocodingDataAsync(
lat, lon, apiToken.Token, apiToken.Name);

_logger.LogInformation(
"Update: reverse geocoding refreshed for location {LocationId}: {Address}",
id, locationInfo.FullAddress);
_logger.LogInformation("Update reverse geocoding completed for location {LocationId}.", id);

location.FullAddress = locationInfo.FullAddress;
location.Address = locationInfo.Address;
Expand Down
2 changes: 1 addition & 1 deletion Areas/Manager/Views/ApiToken/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<p class="mb-1"><strong>Created At:</strong> @token.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</p>
<p class="mb-0">
<strong>Token:</strong>
<code class="border p-2 rounded bg-light">@token.Token</code>
<code class="border p-2 rounded bg-light">@token.DisplayToken</code>
</p>
</div>
<div class="btn-group">
Expand Down
7 changes: 6 additions & 1 deletion Areas/User/Controllers/ApiTokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ public async Task<IActionResult> StoreThirdPartyToken(string thirdPartyServiceNa
SetAlert("User not authenticated.", "danger");
return RedirectToAction("Index", "Home", new { area = "" });
}
if (Wayfarer.Models.LocationProviders.PersonalProviderKeys.IsLegacyMapbox(thirdPartyServiceName))
{
SetAlert("Configure Mapbox under Personal location providers; provider credentials are protected there.", "warning");
return RedirectToAction("Index", "LocationProviderSettings");
}

// Check if token exists for current user before creating it
bool exists = await _dbContext.ApiTokens.AnyAsync(t =>
Expand Down Expand Up @@ -203,4 +208,4 @@ public async Task<IActionResult> DeleteConfirmed(int tokenId)
}
}
}
}
}
144 changes: 144 additions & 0 deletions Areas/User/Controllers/LocationProviderSettingsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Wayfarer.Areas.User.LocationProviderModels;
using Wayfarer.Models;
using Wayfarer.Models.LocationProviders;
using Wayfarer.Services.LocationProviders;

namespace Wayfarer.Areas.User.Controllers;

/// <summary>Manages only the authenticated user's protected provider profiles, selections, and safety guards.</summary>
[Area("User"), Authorize(Roles = "User")]
public sealed class LocationProviderSettingsController(
ApplicationDbContext dbContext, PersonalProviderCredentialService credentials,
LegacyMapboxMigrationService migration) : Controller
{
/// <summary>Displays masked provider authority and provider-native usage status.</summary>
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Challenge();
await migration.MigrateAsync(userId, cancellationToken);
return View(await BuildAsync(userId, cancellationToken));
}

/// <summary>Replaces a credential only when nonblank and changes explicit capability selections independently.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SaveProfile(LocationProviderProfileInput input, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Challenge();
if (!ModelState.IsValid) return View("Index", await BuildAsync(userId, cancellationToken));
var provider = ParseProvider(input.ProviderKey);
var key = PersonalProviderKeys.Key(provider);
var profile = await dbContext.PersonalLocationProviderProfiles
.SingleOrDefaultAsync(item => item.UserId == userId && item.ProviderKey == key, cancellationToken)
?? PersonalLocationProviderProfile.Create(userId, provider);
if (dbContext.Entry(profile).State == EntityState.Detached) dbContext.Add(profile);
if (!string.IsNullOrWhiteSpace(input.ReplacementCredential)) credentials.Replace(profile, input.ReplacementCredential);
profile.SetAuthorization(PersonalProviderCapability.Geocoding, input.GeocodingAuthorized);
profile.SetAuthorization(PersonalProviderCapability.Routing, input.RoutingAuthorized);

var selection = await dbContext.PersonalLocationProviderSelections.SingleOrDefaultAsync(
item => item.UserId == userId, cancellationToken) ?? PersonalLocationProviderSelection.Create(userId);
if (dbContext.Entry(selection).State == EntityState.Detached) dbContext.Add(selection);
if (input.ActiveForGeocoding && input.GeocodingAuthorized) 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);
else if (selection.RoutingProviderKey == key) selection.Select(PersonalProviderCapability.Routing, null);
await dbContext.SaveChangesAsync(cancellationToken);
return RedirectToAction(nameof(Index));
}

/// <summary>Explicitly revokes one credential without deleting profiles, usage, or domain data.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Revoke(string providerKey, bool confirmed, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Challenge();
if (!confirmed) return RedirectToAction(nameof(Index));
var profile = await dbContext.PersonalLocationProviderProfiles.SingleOrDefaultAsync(
item => item.UserId == userId && item.ProviderKey == providerKey, cancellationToken);
if (profile != null) { credentials.Revoke(profile); await dbContext.SaveChangesAsync(cancellationToken); }
return RedirectToAction(nameof(Index));
}

/// <summary>Updates only a provider-native guard; lowering never deletes or resets usage.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SaveGuard(LocationProviderGuardInput input, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Challenge();
if (!ModelState.IsValid) return RedirectToAction(nameof(Index));
if (input.GuardKey == "geoapify")
{
var guard = await dbContext.GeoapifyUsageGuards.SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken)
?? new GeoapifyUsageGuard { UserId = userId };
if (dbContext.Entry(guard).State == EntityState.Detached) dbContext.Add(guard);
guard.Enabled = input.Enabled; guard.CreditLimit = input.Limit;
}
else
{
var product = input.GuardKey == "mapbox-permanent"
? PersonalProviderProduct.PermanentGeocoding : PersonalProviderProduct.Directions;
var meter = await dbContext.MapboxProductMeters.SingleOrDefaultAsync(
item => item.UserId == userId && item.Product == product, cancellationToken)
?? new MapboxProductMeter { UserId = userId, Product = product, CycleStart = new(1970, 1, 1) };
if (dbContext.Entry(meter).State == EntityState.Detached) dbContext.Add(meter);
meter.Enabled = input.Enabled; meter.Limit = input.Limit;
}
await dbContext.SaveChangesAsync(cancellationToken);
return RedirectToAction(nameof(Index));
}

private async Task<LocationProviderSettingsViewModel> BuildAsync(string userId, CancellationToken cancellationToken)
{
var profiles = await dbContext.PersonalLocationProviderProfiles.AsNoTracking()
.Where(item => item.UserId == userId).ToListAsync(cancellationToken);
var selection = await dbContext.PersonalLocationProviderSelections.AsNoTracking()
.SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken);
var geoGuard = await dbContext.GeoapifyUsageGuards.AsNoTracking().SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken);
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
var geoUsed = await dbContext.GeoapifyUsageAdmissions.AsNoTracking()
.Where(item => item.UserId == userId && item.AdmittedAt > cutoff).SumAsync(item => (int?)item.Credits, cancellationToken) ?? 0;
var meters = await dbContext.MapboxProductMeters.AsNoTracking().Where(item => item.UserId == userId).ToListAsync(cancellationToken);
var views = new[] { PersonalLocationProvider.Geoapify, PersonalLocationProvider.Mapbox }.Select(provider =>
BuildProfile(provider, profiles, geoGuard, geoUsed, meters)).ToArray();
return new()
{
Profiles = views, ActiveGeocodingProvider = selection?.GeocodingProviderKey,
ActiveRoutingProvider = selection?.RoutingProviderKey,
LegacyMigrationState = profiles.SingleOrDefault(item => item.ProviderKey == "mapbox")?.LegacyMigrationState ?? LegacyMapboxMigrationState.None
};
}

private static LocationProviderProfileViewModel BuildProfile(PersonalLocationProvider provider,
IReadOnlyCollection<PersonalLocationProviderProfile> profiles, GeoapifyUsageGuard? geoGuard, int geoUsed,
IReadOnlyCollection<MapboxProductMeter> meters)
{
var key = PersonalProviderKeys.Key(provider);
var profile = profiles.SingleOrDefault(item => item.ProviderKey == key);
if (provider == PersonalLocationProvider.Geoapify)
{
var limit = geoGuard?.CreditLimit ?? 2500;
return new(key, "Geoapify", profile?.ProtectedCredential != null && profile.RevokedAt == null, "••••••••••••••••",
profile?.GeocodingAuthorized == true, profile?.GeocodingVerification ?? 0,
profile?.RoutingAuthorized == true, profile?.RoutingVerification ?? 0,
geoGuard?.Enabled ?? true, limit, geoUsed, "credits",
"Wayfarer rolling 24-hour shared geocoding/routing window", (geoGuard?.Enabled ?? true) && geoUsed >= limit);
}
var permanent = meters.SingleOrDefault(item => item.Product == PersonalProviderProduct.PermanentGeocoding);
var directions = meters.SingleOrDefault(item => item.Product == PersonalProviderProduct.Directions);
return new(key, "Mapbox", profile?.ProtectedCredential != null && profile.RevokedAt == null, "••••••••••••••••",
profile?.GeocodingAuthorized == true, profile?.GeocodingVerification ?? 0,
profile?.RoutingAuthorized == true, profile?.RoutingVerification ?? 0,
permanent?.Enabled ?? true, permanent?.Limit ?? 1000, permanent?.AdmittedCount ?? 0, "Permanent Geocoding contacts",
"Wayfarer UTC calendar-month Permanent Geocoding safety cycle", permanent?.Enabled == true && permanent.AdmittedCount >= permanent.Limit,
directions?.Enabled ?? true, directions?.Limit ?? 1000, directions?.AdmittedCount ?? 0);
}

private static PersonalLocationProvider ParseProvider(string key) => key switch
{ "geoapify" => PersonalLocationProvider.Geoapify, "mapbox" => PersonalLocationProvider.Mapbox, _ => throw new ArgumentOutOfRangeException(nameof(key)) };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Wayfarer.Models.LocationProviders;

namespace Wayfarer.Areas.User.LocationProviderModels;

/// <summary>Contains only masked personal provider settings presentation.</summary>
public sealed class LocationProviderSettingsViewModel
{
public IReadOnlyList<LocationProviderProfileViewModel> Profiles { get; init; } = [];
public string? ActiveGeocodingProvider { get; init; }
public string? ActiveRoutingProvider { get; init; }
public LegacyMapboxMigrationState LegacyMigrationState { get; init; }
}

/// <summary>Presents bounded profile, capability, and provider-native usage state.</summary>
public sealed record LocationProviderProfileViewModel(
string ProviderKey, string DisplayName, bool CredentialConfigured, string Mask,
bool GeocodingAuthorized, PersonalProviderVerification GeocodingVerification,
bool RoutingAuthorized, PersonalProviderVerification RoutingVerification,
bool GuardEnabled, int Limit, int Used, string Unit, string WindowExplanation, bool Exhausted,
bool? DirectionsGuardEnabled = null, int? DirectionsLimit = null, int? DirectionsUsed = null);

/// <summary>Accepts explicit profile replacement/authorization and independent selection.</summary>
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; }
public bool GeocodingAuthorized { get; set; }
public bool RoutingAuthorized { get; set; }
public bool ActiveForGeocoding { get; set; }
public bool ActiveForRouting { get; set; }
}

/// <summary>Accepts one bounded provider-native guard setting.</summary>
public sealed class LocationProviderGuardInput
{
[Required, RegularExpression("geoapify|mapbox-permanent|mapbox-directions")] public string GuardKey { get; set; } = string.Empty;
public bool Enabled { get; set; }
[Range(0, 10_000_000)] public int Limit { get; set; }
}
10 changes: 5 additions & 5 deletions Areas/User/Views/ApiToken/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
<p class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<span class="text-break">
<strong>Token:</strong> <code id="wayfarer-token"
class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@(token.IsHashedToken ? token.DisplayToken : token.Token)</code>
class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@token.DisplayToken</code>
@if (token.IsHashedToken)
{
<small class="text-warning ms-2" title="Token is securely hashed. Regenerate to get a new token."><i class="bi bi-shield-lock"></i></small>
Expand Down Expand Up @@ -295,7 +295,7 @@
<p class="mb-2"><strong>Content-Type:</strong> <code>application/json</code>
</p>
<p class="mb-3"><strong>Authorization:</strong>
<code>Bearer @(token.IsHashedToken ? "<your-token>" : token.Token)</code></p>
<code>Bearer &lt;your-token&gt;</code></p>

<p class="mb-2">
<strong>Required parameters:</strong> <code>latitude, longitude,
Expand All @@ -314,7 +314,7 @@
<li>Add the following headers:</li>
<ul>
<li><code>Content-Type: application/json</code></li>
<li><code>Authorization: Bearer @(token.IsHashedToken ? "<your-token>" : token.Token)</code></li>
<li><code>Authorization: Bearer &lt;your-token&gt;</code></li>
</ul>
<li>Use the following JSON Template:</li>
</ol>
Expand Down Expand Up @@ -360,7 +360,7 @@
<p class="mb-1"><strong>Service:</strong> @token.Name</p>
<p class="mb-1"><strong>Created At:</strong> @token.CreatedAt</p>
<p class="mb-0"><strong>Token:</strong> <code
class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@(token.IsHashedToken ? token.DisplayToken : token.Token)</code></p>
class="api-token-value border p-2 rounded bg-body-tertiary user-select-all">@token.DisplayToken</code></p>
</div>
<div class="text-end">
<!-- NO REGENERATE BUTTON - only delete -->
Expand Down Expand Up @@ -623,7 +623,7 @@
@:var newTokenFromTempData = "@Html.Raw(newToken ?? "")";
@:var newTokenNameFromTempData = "@Html.Raw(newTokenName ?? "")";
@:var isHashedToken = @(token.IsHashedToken ? "true" : "false");
@:var plainToken = "@Html.Raw(token.Token ?? "")";
@:var plainToken = "";
@:var serverBaseUrl = "@serverBaseUrl";
@:var userName = "@Model.UserName";
@:var serverName = "@Context.Request.Host.Host";
Expand Down
Loading
Loading