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
|
## 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) ||
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user