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:
@@ -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://<your-app>/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)
|
||||
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user