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
@@ -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<PowderCatalogController> _logger;
public PowderCatalogController(
@@ -33,6 +34,7 @@ public class PowderCatalogController : Controller
IColumbiaCatalogSyncService columbiaSyncService,
IPowderCatalogUpsertService upsertService,
IPlatformSettingsService platformSettings,
IConfiguration config,
ILogger<PowderCatalogController> 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));
}
/// <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>
/// 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<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);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
+3
View File
@@ -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",