Add token-authenticated catalog import API endpoint

POST /PowderCatalog/ImportApi accepts the JSON scrape format in the request
body, authenticated by a shared secret in the X-Import-Token header (matched
constant-time against CatalogImport:Token), with the vendor in X-Vendor-Name.
Runs through the same ImportJsonAsync -> shared upsert as the manual upload, so
the offline PrismaticSync tool can push unattended.

ImportJsonAsync refactored to take a Stream (the form upload now passes
file.OpenReadStream()). Endpoint is AllowAnonymous + IgnoreAntiforgeryToken
(it's token-gated, not cookie-auth) and returns 401 until a token is configured,
so it's inert by default. README updated with the route + token wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:35:30 -04:00
parent c59d55529f
commit 843d1c3c51
3 changed files with 80 additions and 9 deletions
+10 -6
View File
@@ -50,13 +50,17 @@ these conservative — getting blocked is worse than being slow, and Prismatic i
## Pushing into the app ## Pushing into the app
Set `Sync.Import.EndpointUrl` + `Sync.Import.Token` in `appsettings.json`. The tool POSTs the JSON Set in `appsettings.json`:
with an `X-Import-Token` header to the app's token-authenticated import endpoint, which runs it - `Sync.Import.EndpointUrl``https://<your-app>/PowderCatalog/ImportApi`
through the same upsert as the Columbia sync. If the endpoint isn't configured, `push` is skipped and - `Sync.Import.Token` the same secret as the app's `CatalogImport:Token` config
you upload `prismatic_powders.json` manually via the Powder Catalog admin page.
> **App-side dependency:** the token-authenticated import endpoint must exist in the web app for The tool POSTs the JSON with an `X-Import-Token` header (and `X-Vendor-Name: Prismatic Powders`) to
> unattended push to work. Until then, use the manual upload. that endpoint, which authenticates the token and runs the records through the same upsert as the
Columbia sync. If the endpoint/token isn't configured here, `push` is skipped and you upload
`prismatic_powders.json` manually via the Powder Catalog admin page instead.
> **App side:** set `CatalogImport:Token` in the web app's config (Azure App Setting in prod). The
> endpoint returns 401 until a token is set, so it's inert by default.
## Scheduling (Windows Task Scheduler) ## Scheduling (Windows Task Scheduler)
@@ -25,6 +25,7 @@ public class PowderCatalogController : Controller
private readonly IColumbiaCatalogSyncService _columbiaSyncService; private readonly IColumbiaCatalogSyncService _columbiaSyncService;
private readonly IPowderCatalogUpsertService _upsertService; private readonly IPowderCatalogUpsertService _upsertService;
private readonly IPlatformSettingsService _platformSettings; private readonly IPlatformSettingsService _platformSettings;
private readonly IConfiguration _config;
private readonly ILogger<PowderCatalogController> _logger; private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController( public PowderCatalogController(
@@ -33,6 +34,7 @@ public class PowderCatalogController : Controller
IColumbiaCatalogSyncService columbiaSyncService, IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService, IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings, IPlatformSettingsService platformSettings,
IConfiguration config,
ILogger<PowderCatalogController> logger) ILogger<PowderCatalogController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
@@ -40,6 +42,7 @@ public class PowderCatalogController : Controller
_columbiaSyncService = columbiaSyncService; _columbiaSyncService = columbiaSyncService;
_upsertService = upsertService; _upsertService = upsertService;
_platformSettings = platformSettings; _platformSettings = platformSettings;
_config = config;
_logger = logger; _logger = logger;
} }
@@ -372,7 +375,8 @@ public class PowderCatalogController : Controller
PowderCatalogImportResult result; PowderCatalogImportResult result;
try try
{ {
result = await ImportJsonAsync(file, vendorName); using var stream = file.OpenReadStream();
result = await ImportJsonAsync(stream, vendorName);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -393,6 +397,67 @@ public class PowderCatalogController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
/// <summary>
/// Unattended catalog import for the offline scraper tool (e.g. PrismaticSync). Accepts the same
/// JSON scrape format in the request body, authenticated by a shared secret in the
/// <c>X-Import-Token</c> header (matched against <c>CatalogImport:Token</c>). The vendor name
/// comes from the <c>X-Vendor-Name</c> header. Runs through the same upsert as the manual upload.
/// Inert (401) until a token is configured.
/// </summary>
[HttpPost]
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
public async Task<IActionResult> ImportApi()
{
var configuredToken = _config["CatalogImport:Token"];
if (string.IsNullOrWhiteSpace(configuredToken))
{
_logger.LogWarning("ImportApi called but no CatalogImport:Token is configured — rejecting.");
return Unauthorized(new { success = false, errorMessage = "Import API is not enabled." });
}
var providedToken = Request.Headers["X-Import-Token"].ToString();
if (!FixedTimeEquals(providedToken, configuredToken))
return Unauthorized(new { success = false, errorMessage = "Invalid import token." });
var vendorName = Request.Headers["X-Vendor-Name"].ToString();
if (string.IsNullOrWhiteSpace(vendorName))
vendorName = "Prismatic Powders";
try
{
var result = await ImportJsonAsync(Request.Body, vendorName);
_logger.LogInformation(
"ImportApi ({Vendor}): {Inserted} inserted, {Updated} updated, {Skipped} skipped, {Errors} errors.",
vendorName, result.Inserted, result.Updated, result.Skipped, result.Errors);
return Json(new
{
success = result.Success,
vendorName,
result.Inserted,
result.Updated,
result.Skipped,
result.Errors,
result.ErrorMessage
});
}
catch (Exception ex)
{
_logger.LogError(ex, "ImportApi failed for vendor {Vendor}", vendorName);
return StatusCode(500, new { success = false, errorMessage = "Import failed." });
}
}
/// <summary>Constant-time string comparison so token checks don't leak length/contents via timing.</summary>
private static bool FixedTimeEquals(string a, string b)
{
var ba = System.Text.Encoding.UTF8.GetBytes(a ?? string.Empty);
var bb = System.Text.Encoding.UTF8.GetBytes(b ?? string.Empty);
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb);
}
/// <summary> /// <summary>
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name. /// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
/// SKU exact matches are ranked first; color name substring matches follow. /// SKU exact matches are ranked first; color name substring matches follow.
@@ -527,9 +592,8 @@ public class PowderCatalogController : Controller
} }
} }
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName) private async Task<PowderCatalogImportResult> ImportJsonAsync(Stream stream, string vendorName)
{ {
using var stream = file.OpenReadStream();
using var doc = await JsonDocument.ParseAsync(stream); using var doc = await JsonDocument.ParseAsync(stream);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) || if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
+3
View File
@@ -47,6 +47,9 @@
"BaseUrl": "https://columbiacoatings.com", "BaseUrl": "https://columbiacoatings.com",
"ApiBasePath": "/wp-json/cca/v1" "ApiBasePath": "/wp-json/cca/v1"
}, },
"CatalogImport": {
"Token": ""
},
"SendGrid": { "SendGrid": {
"ApiKey": "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", "ApiKey": "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",
"FromEmail": "spouliot@scppowdercoating.com", "FromEmail": "spouliot@scppowdercoating.com",