From 843d1c3c51de47dcafc091c6915a1c00356dcaef Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Thu, 18 Jun 2026 11:35:30 -0400 Subject: [PATCH] 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 --- scripts/Prismatic Data Scraper/README.md | 16 +++-- .../Controllers/PowderCatalogController.cs | 70 ++++++++++++++++++- src/PowderCoating.Web/appsettings.json | 3 + 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/scripts/Prismatic Data Scraper/README.md b/scripts/Prismatic Data Scraper/README.md index 52d9d38..71d44eb 100644 --- a/scripts/Prismatic Data Scraper/README.md +++ b/scripts/Prismatic Data Scraper/README.md @@ -50,13 +50,17 @@ these conservative — getting blocked is worse than being slow, and Prismatic i ## Pushing into the app -Set `Sync.Import.EndpointUrl` + `Sync.Import.Token` in `appsettings.json`. The tool POSTs the JSON -with an `X-Import-Token` header to the app's token-authenticated import endpoint, which runs it -through the same upsert as the Columbia sync. If the endpoint isn't configured, `push` is skipped and -you upload `prismatic_powders.json` manually via the Powder Catalog admin page. +Set in `appsettings.json`: +- `Sync.Import.EndpointUrl` → `https:///PowderCatalog/ImportApi` +- `Sync.Import.Token` → the same secret as the app's `CatalogImport:Token` config -> **App-side dependency:** the token-authenticated import endpoint must exist in the web app for -> unattended push to work. Until then, use the manual upload. +The tool POSTs the JSON with an `X-Import-Token` header (and `X-Vendor-Name: Prismatic Powders`) to +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) diff --git a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs index 47f2e38..289bd6d 100644 --- a/src/PowderCoating.Web/Controllers/PowderCatalogController.cs +++ b/src/PowderCoating.Web/Controllers/PowderCatalogController.cs @@ -25,6 +25,7 @@ public class PowderCatalogController : Controller private readonly IColumbiaCatalogSyncService _columbiaSyncService; private readonly IPowderCatalogUpsertService _upsertService; private readonly IPlatformSettingsService _platformSettings; + private readonly IConfiguration _config; private readonly ILogger _logger; public PowderCatalogController( @@ -33,6 +34,7 @@ public class PowderCatalogController : Controller IColumbiaCatalogSyncService columbiaSyncService, IPowderCatalogUpsertService upsertService, IPlatformSettingsService platformSettings, + IConfiguration config, ILogger logger) { _unitOfWork = unitOfWork; @@ -40,6 +42,7 @@ public class PowderCatalogController : Controller _columbiaSyncService = columbiaSyncService; _upsertService = upsertService; _platformSettings = platformSettings; + _config = config; _logger = logger; } @@ -372,7 +375,8 @@ public class PowderCatalogController : Controller PowderCatalogImportResult result; try { - result = await ImportJsonAsync(file, vendorName); + using var stream = file.OpenReadStream(); + result = await ImportJsonAsync(stream, vendorName); } catch (Exception ex) { @@ -393,6 +397,67 @@ public class PowderCatalogController : Controller return RedirectToAction(nameof(Index)); } + /// + /// 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 + /// X-Import-Token header (matched against CatalogImport:Token). The vendor name + /// comes from the X-Vendor-Name header. Runs through the same upsert as the manual upload. + /// Inert (401) until a token is configured. + /// + [HttpPost] + [AllowAnonymous] + [IgnoreAntiforgeryToken] + [RequestSizeLimit(50 * 1024 * 1024)] // 50 MB + public async Task 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." }); + } + } + + /// Constant-time string comparison so token checks don't leak length/contents via timing. + 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); + } + /// /// 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. @@ -527,9 +592,8 @@ public class PowderCatalogController : Controller } } - private async Task ImportJsonAsync(IFormFile file, string vendorName) + private async Task ImportJsonAsync(Stream stream, string vendorName) { - using var stream = file.OpenReadStream(); using var doc = await JsonDocument.ParseAsync(stream); if (!doc.RootElement.TryGetProperty("results", out var resultsEl) || diff --git a/src/PowderCoating.Web/appsettings.json b/src/PowderCoating.Web/appsettings.json index 35b29f0..a870f83 100644 --- a/src/PowderCoating.Web/appsettings.json +++ b/src/PowderCoating.Web/appsettings.json @@ -47,6 +47,9 @@ "BaseUrl": "https://columbiacoatings.com", "ApiBasePath": "/wp-json/cca/v1" }, + "CatalogImport": { + "Token": "" + }, "SendGrid": { "ApiKey": "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", "FromEmail": "spouliot@scppowdercoating.com",