This article contains companion code for our video on C# file upload security. This ASP.NET Core portal uses Cloudmersive Advanced Scan to check for malware, embedded scripts, macros, and invalid file structures before approval. This implementation ensures rejected uploads and failed scans never reach approved storage.
Note that this requires the .NET 10 SDK and a Cloudmersive API key. The project uses the official Cloudmersive .NET client, version 4.0.0.
Scan uploaded files
CloudmersiveScanner.cs
using Cloudmersive.APIClient.NETCore.VirusScan.Api;
using Cloudmersive.APIClient.NETCore.VirusScan.Client;
using System.Text.Json;
namespace UploadSecurityDemo;
public interface IFileScanner
{
Task<ScanResult?> ScanAsync(byte[] bytes, string filename, CancellationToken cancellation);
}
public sealed record ScanResult(bool? CleanResult, IReadOnlyList<string> PolicyFindings, int VirusCount, JsonElement Diagnostics);
public sealed class MissingApiKeyException : Exception { }
public sealed class InvalidScanResponseException : Exception { }
public sealed class CloudmersiveScanner(Configuration configuration) : IFileScanner
{
public static readonly string[] BlockingHeaders = [
"allowExecutables", "allowInvalidFiles", "allowScripts", "allowPasswordProtectedFiles",
"allowMacros", "allowXmlExternalEntities", "allowInsecureDeserialization", "allowHtml",
"allowUnsafeArchives", "allowOleEmbeddedObject", "allowUnwantedAction"
];
private static readonly Dictionary<string, string> PolicyFields = new() {
["ContainsExecutable"] = "executable content",
["ContainsInvalidFile"] = "invalid file",
["ContainsScript"] = "scripts",
["ContainsPasswordProtectedFile"] = "password-protected content",
["ContainsRestrictedFileFormat"] = "restricted file format",
["ContainsMacros"] = "macros",
["ContainsXmlExternalEntities"] = "XML external entities",
["ContainsInsecureDeserialization"] = "insecure deserialization",
["ContainsHtml"] = "HTML",
["ContainsUnsafeArchive"] = "unsafe archive",
["ContainsOleEmbeddedObject"] = "OLE embedded objects",
["ContainsUnwantedAction"] = "unwanted actions"
};
public async Task<ScanResult?> ScanAsync(byte[] bytes, string filename, CancellationToken cancellation)
{
configuration.ApiKey.TryGetValue("Apikey", out var key);
if (string.IsNullOrWhiteSpace(key)) throw new MissingApiKeyException();
if (key.Any(char.IsControl)) throw new InvalidScanResponseException();
cancellation.ThrowIfCancellationRequested();
using var input = new MemoryStream(bytes, writable: false);
var api = new ScanApi(configuration);
try
{
var response = await api.ScanFileAdvancedWithHttpInfoAsync(
inputFile: input,
fileName: ApprovalService.SafeFilename(filename),
allowExecutables: false,
allowInvalidFiles: false,
allowScripts: false,
allowPasswordProtectedFiles: false,
allowMacros: false,
allowXmlExternalEntities: false,
allowInsecureDeserialization: false,
allowHtml: false,
allowUnsafeArchives: false,
allowOleEmbeddedObject: false,
allowUnwantedAction: false,
options: "blockOfficeXmlOleEmbeddedFile,blockInvalidUris",
cancellationToken: cancellation);
cancellation.ThrowIfCancellationRequested();
if ((int)response.StatusCode is < 200 or >= 300 ||
!string.IsNullOrEmpty(response.ErrorText) || string.IsNullOrWhiteSpace(response.RawContent))
throw new InvalidScanResponseException();
// Validate the original JSON: SDK model coercion/defaults must not authorize uploads.
// The SDK buffers responses internally; this checks size after receipt.
if (System.Text.Encoding.UTF8.GetByteCount(response.RawContent) > 1024 * 1024)
throw new InvalidScanResponseException();
return Parse(response.RawContent);
}
catch (ApiException)
{
// SDK exceptions can contain remote payloads. Keep them out of logs/UI.
throw new InvalidScanResponseException();
}
catch (Newtonsoft.Json.JsonException)
{
throw new InvalidScanResponseException();
}
}
public static ScanResult Parse(string json)
{
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 });
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object) throw new InvalidScanResponseException();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in root.EnumerateObject())
if (!seen.Add(property.Name)) throw new InvalidScanResponseException();
if (!root.TryGetProperty("CleanResult", out var clean) ||
clean.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
throw new InvalidScanResponseException();
var findings = new List<string>();
foreach (var (name, label) in PolicyFields)
{
if (!root.TryGetProperty(name, out var field)) continue;
if (field.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
throw new InvalidScanResponseException();
if (field.GetBoolean()) findings.Add(label);
}
var virusCount = 0;
if (root.TryGetProperty("FoundViruses", out var viruses) && viruses.ValueKind != JsonValueKind.Null)
{
if (viruses.ValueKind != JsonValueKind.Array) throw new InvalidScanResponseException();
foreach (var virus in viruses.EnumerateArray())
{
if (virus.ValueKind != JsonValueKind.Object ||
!virus.TryGetProperty("VirusName", out var name) || name.ValueKind != JsonValueKind.String ||
!virus.TryGetProperty("FileName", out var file) || file.ValueKind != JsonValueKind.String)
throw new InvalidScanResponseException();
virusCount++;
}
}
if (root.TryGetProperty("VerifiedFileFormat", out var format) &&
format.ValueKind is not (JsonValueKind.String or JsonValueKind.Null))
throw new InvalidScanResponseException();
if (root.TryGetProperty("ContentInformation", out var info) &&
info.ValueKind is not (JsonValueKind.Object or JsonValueKind.Null))
throw new InvalidScanResponseException();
// Clone preserves documented diagnostics (including ContentInformation and virus names)
// for server-side debugger inspection. Never return the payload to the browser.
return new ScanResult(clean.GetBoolean(), findings, virusCount, root.Clone());
}
catch (JsonException) { throw new InvalidScanResponseException(); }
}
}
Approve or reject uploads
ApprovalService.cs
namespace UploadSecurityDemo;
public sealed record UploadSettings(long MaxBytes, string ApprovedDirectory);
public sealed record UploadResult(string Status, string Title, string Message, string? Detail = null)
{
public static UploadResult Approved() => new("approved", "Upload approved",
"This file passed the configured security checks and was saved.");
public static UploadResult Rejected(string? detail = null) => new("rejected", "File rejected",
"This file did not pass the configured security checks and was not saved to approved storage.", detail);
public static UploadResult Unavailable(string? detail = null) => new("unavailable", "Scan unavailable",
"We could not complete the security checks. This file was not approved. Please try again.", detail);
}
public sealed class ApprovalService(IFileScanner scanner, UploadSettings settings, ILogger<ApprovalService> logger)
{
public async Task<UploadResult> ProcessAsync(byte[] bytes, string filename, CancellationToken cancellation = default)
{
if (bytes.LongLength == 0) return UploadResult.Rejected("Choose a non-empty file.");
if (bytes.LongLength > settings.MaxBytes) return UploadResult.Rejected("The file exceeds the size limit.");
string? pending = null;
bool ownsPending = false;
try
{
cancellation.ThrowIfCancellationRequested();
var scan = await scanner.ScanAsync(bytes, SafeFilename(filename), cancellation);
// Approval depends on an explicit boolean AND a valid, consistent response.
if (scan?.CleanResult is null) return UploadResult.Unavailable();
logger.LogInformation("Completed scan. CleanResult={CleanResult}; PolicyFindings={PolicyFindings}; VirusCount={VirusCount}",
scan.CleanResult, string.Join(", ", scan.PolicyFindings), scan.VirusCount);
if (scan.CleanResult == false)
return UploadResult.Rejected(scan.PolicyFindings.Count > 0
? "Content policy: " + string.Join(", ", scan.PolicyFindings) + "."
: scan.VirusCount > 0 ? "The scanner reported a malware finding."
: "The scanner reported a security or content-policy finding.");
if (scan.PolicyFindings.Count > 0 || scan.VirusCount > 0)
return UploadResult.Unavailable(); // Contradictory responses cannot approve a file.
cancellation.ThrowIfCancellationRequested();
// Stage only AFTER approval, outside approved storage; publish atomically after writing.
var parent = Path.GetDirectoryName(settings.ApprovedDirectory)!;
var staging = Path.Combine(parent, "pending");
Directory.CreateDirectory(staging);
Directory.CreateDirectory(settings.ApprovedDirectory);
var id = Guid.NewGuid().ToString("N");
pending = Path.Combine(staging, id + ".tmp");
await using (var output = new FileStream(pending, FileMode.CreateNew, FileAccess.Write, FileShare.None,
81920, FileOptions.Asynchronous))
{
ownsPending = true;
await output.WriteAsync(bytes, cancellation);
await output.FlushAsync(cancellation);
}
cancellation.ThrowIfCancellationRequested();
// Client filenames and extensions are never used for storage. Move cannot overwrite.
File.Move(pending, Path.Combine(settings.ApprovedDirectory, id + ".upload"), overwrite: false);
pending = null;
return UploadResult.Approved();
}
catch (MissingApiKeyException)
{
logger.LogWarning("Cloudmersive API key is not configured.");
return UploadResult.Unavailable("Server configuration required: the Cloudmersive API key is missing.");
}
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or InvalidScanResponseException
or IOException or UnauthorizedAccessException)
{
// Do not log exception messages, remote payloads, filenames, or request headers.
logger.LogWarning("Upload not approved ({FailureType}).", ex.GetType().Name);
return UploadResult.Unavailable();
}
finally
{
if (pending != null && ownsPending)
{
try { File.Delete(pending); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{ logger.LogWarning("Could not remove a staged file. It remains outside approved storage."); }
}
}
}
public static string SafeFilename(string filename)
{
var leaf = filename.Replace('\\', '/').Split('/').Last();
leaf = new string(leaf.Where(c => !char.IsControl(c) && c != '"' && c != ';').Take(180).ToArray());
return string.IsNullOrWhiteSpace(leaf) ? "document" : leaf;
}
}
Receive file uploads
UploadEndpoint.cs
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
namespace UploadSecurityDemo;
public static class UploadEndpoint
{
// MultipartReader avoids IFormFile's automatic disk buffering.
public static async Task<IResult> HandleAsync(HttpContext context, IAntiforgery antiforgery,
UploadSettings settings, ApprovalService approval, ILoggerFactory logs)
{
try
{
await antiforgery.ValidateRequestAsync(context);
if (!MediaTypeHeaderValue.TryParse(context.Request.ContentType, out var type) ||
!string.Equals(type.MediaType.Value, "multipart/form-data", StringComparison.OrdinalIgnoreCase))
return Results.Json(UploadResult.Rejected("Choose one file to upload."), statusCode: 400);
var boundary = HeaderUtilities.RemoveQuotes(type.Boundary).Value;
if (string.IsNullOrWhiteSpace(boundary) || boundary.Length > 128)
return Results.Json(UploadResult.Rejected("The upload could not be read."), statusCode: 400);
if (context.Request.ContentLength > settings.MaxBytes + 64 * 1024)
return Results.Json(UploadResult.Rejected("The upload exceeds the size limit."), statusCode: 413);
var reader = new MultipartReader(boundary, context.Request.Body) {
HeadersCountLimit = 16, HeadersLengthLimit = 16384
};
var section = await reader.ReadNextSectionAsync(context.RequestAborted);
if (section == null ||
!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var disposition) ||
disposition.DispositionType != "form-data" ||
HeaderUtilities.RemoveQuotes(disposition.Name).Value != "file" ||
(!disposition.FileName.HasValue && !disposition.FileNameStar.HasValue))
return Results.Json(UploadResult.Rejected("Choose one file to upload."), statusCode: 400);
var filename = HeaderUtilities.RemoveQuotes(
disposition.FileNameStar.HasValue ? disposition.FileNameStar : disposition.FileName).Value ?? "document";
using var buffer = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await section.Body.ReadAsync(chunk, context.RequestAborted)) != 0)
{
if (buffer.Length + read > settings.MaxBytes)
return Results.Json(UploadResult.Rejected("The file exceeds the size limit."), statusCode: 413);
await buffer.WriteAsync(chunk.AsMemory(0, read), context.RequestAborted);
}
// Exactly one file per request; reject extra sections before scanning or saving.
if (await reader.ReadNextSectionAsync(context.RequestAborted) != null)
return Results.Json(UploadResult.Rejected("Submit one file at a time."), statusCode: 400);
var result = await approval.ProcessAsync(buffer.ToArray(), filename, context.RequestAborted);
return Results.Json(result, statusCode: result.Status == "unavailable" ? 503 : 200);
}
catch (AntiforgeryValidationException)
{
return Results.Json(UploadResult.Unavailable("Refresh the page and try again."), statusCode: 400);
}
catch (BadHttpRequestException ex) when (ex.StatusCode == 413)
{
return Results.Json(UploadResult.Rejected("The upload exceeds the size limit."), statusCode: 413);
}
catch (Exception ex) when (ex is InvalidDataException or IOException or OperationCanceledException or BadHttpRequestException)
{
logs.CreateLogger("Upload").LogWarning("Upload interrupted or malformed ({FailureType}).", ex.GetType().Name);
return Results.Json(UploadResult.Unavailable(), statusCode: 400);
}
}
}
Application setup
Project file
UploadSecurityDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>UploadSecurityDemo-local-development</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cloudmersive.APIClient.NETCore.VirusScan" Version="4.0.0" />
</ItemGroup>
</Project>
Register services and routes
Program.cs
using UploadSecurityDemo;
using Microsoft.AspNetCore.Http.Features;
var builder = WebApplication.CreateBuilder(args);
var maxBytes = builder.Configuration.GetValue<long?>("Upload:MaxBytes") ?? 5 * 1024 * 1024;
if (maxBytes < 1 || maxBytes > 20 * 1024 * 1024)
throw new InvalidOperationException("Upload:MaxBytes must be between 1 and 20971520 for this in-memory demo.");
var timeout = builder.Configuration.GetValue<int?>("Cloudmersive:TimeoutSeconds") ?? 60;
if (timeout < 1 || timeout > 300)
throw new InvalidOperationException("Cloudmersive:TimeoutSeconds must be between 1 and 300.");
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = maxBytes + 64 * 1024);
builder.Services.Configure<FormOptions>(options => options.MultipartBodyLengthLimit = maxBytes);
builder.Services.AddSingleton(new UploadSettings(maxBytes,
Path.Combine(builder.Environment.ContentRootPath, "App_Data", "approved")));
builder.Services.AddRazorPages();
builder.Services.AddAntiforgery(options => {
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.HttpOnly = true;
});
builder.Services.AddScoped(_ => {
var config = new Cloudmersive.APIClient.NETCore.VirusScan.Client.Configuration {
BasePath = "https://api.cloudmersive.com",
Timeout = TimeSpan.FromSeconds(timeout)
};
// Use an instance configuration, never the SDK's global API key dictionary.
config.ApiKey["Apikey"] = builder.Configuration["Cloudmersive:ApiKey"] ?? "";
return config;
});
builder.Services.AddScoped<IFileScanner, CloudmersiveScanner>();
builder.Services.AddScoped<ApprovalService>();
var app = builder.Build();
// No exception detail or remote response is returned to the browser, including in Development.
app.UseExceptionHandler(error => error.Run(async context => {
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(UploadResult.Unavailable());
}));
app.Use(async (context, next) => {
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers.CacheControl = "no-store";
await next();
});
app.UseStaticFiles(); // Only authored assets in wwwroot. App_Data is never served.
app.MapRazorPages();
app.MapPost("/upload", UploadEndpoint.HandleAsync);
app.Run();
public partial class Program { }
Configure the upload limit and scan timeout
appsettings.json
{
"Upload": { "MaxBytes": 5242880 },
"Cloudmersive": { "TimeoutSeconds": 60 },
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "System.Net.Http.HttpClient": "Warning" } },
"AllowedHosts": "localhost;127.0.0.1"
}
Portal interface
Upload page
Pages/Index.cshtml
@page
@using Microsoft.AspNetCore.Antiforgery
@using UploadSecurityDemo
@inject IAntiforgery Antiforgery
@inject UploadSettings Settings
@inject IConfiguration Configuration
@{
Layout = null;
var token = Antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
var limitMiB = Settings.MaxBytes / 1048576.0;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="@token" />
<title>Document Upload Portal</title>
<link rel="stylesheet" href="/portal.css" />
<script src="/portal.js" defer></script>
</head>
<body>
<header class="topbar">
<div class="brand"><span class="brand-mark" aria-hidden="true">D<span>↗</span></span><span>Document Upload Portal</span></div>
<span class="environment"><span></span> LOCAL DEMO</span>
</header>
<main>
<div class="eyebrow">CUSTOMER DOCUMENTS <span>/</span> NEW SUBMISSION</div>
<div class="intro">
<div><h1>Submit a document</h1><p>Upload your file for security checks before approval.</p></div>
<span class="private-badge"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6Z"/><path d="m8 12 3 3 5-6"/></svg>Checked before storage</span>
</div>
@if (string.IsNullOrWhiteSpace(Configuration["Cloudmersive:ApiKey"]))
{
<div class="configuration" role="alert"><strong>Scanner setup required.</strong> The Cloudmersive API key is not configured on the server. Uploads cannot be approved until setup is complete.</div>
}
<div class="workspace">
<section class="upload-card" aria-labelledby="upload-heading">
<div class="card-heading"><div><h2 id="upload-heading">Your document</h2><p>Choose a file to submit for review.</p></div><span class="step-label">STEP 01</span></div>
<form id="upload-form" data-max-bytes="@Settings.MaxBytes">
<div id="drop-zone" class="drop-zone">
<div class="upload-icon" aria-hidden="true"><svg viewBox="0 0 32 32"><path d="M16 21V6m-6 6 6-6 6 6M6 21v5h20v-5"/></svg></div>
<h3>Drag and drop your document here</h3>
<p>or select a file from your device</p>
<label class="browse" for="file-input">Browse files</label>
<input class="accessible-input" id="file-input" type="file" name="file" aria-describedby="file-limit" />
<span id="file-limit" class="limit">One file at a time · Maximum @limitMiB.ToString("0.##") MiB</span>
</div>
<div id="selected-file" class="selected-file" hidden>
<span class="document-icon" aria-hidden="true">▤</span>
<div><strong id="selected-name"></strong><span id="selected-size"></span></div>
<button type="button" id="remove-file" aria-label="Remove selected file">×</button>
</div>
<p id="selection-error" class="selection-error" role="alert" hidden></p>
<div class="submit-row">
<p><span aria-hidden="true">↳</span> Files are saved only after approval.</p>
<button type="submit" id="submit-button" class="primary" disabled>Upload document <span aria-hidden="true">→</span></button>
</div>
</form>
<div id="progress" class="progress" role="status" hidden><span class="spinner" aria-hidden="true"></span><div><strong>Uploading & checking your file</strong><p>Please keep this page open while the security checks complete.</p></div></div>
<section id="result" class="result" tabindex="-1" aria-live="polite" aria-atomic="true" hidden>
<span id="result-category" class="result-category"></span><h3 id="result-title"></h3><p id="result-message"></p><p id="result-detail"></p>
</section>
<div class="card-footer"><span class="small-shield" aria-hidden="true">✓</span><span>Security checks powered by <strong>Cloudmersive</strong></span><span class="advanced">ADVANCED SCAN</span></div>
</section>
<aside>
<section class="steps-card"><span class="eyebrow">WHAT TO EXPECT</span><h2>From upload to approval.</h2>
<ol>
<li><span class="step-number">1</span><div><h3>Submit your file</h3><p>Select the document you want to add to your application.</p></div></li>
<li><span class="step-number">2</span><div><h3>Wait for the security checks</h3><p>Your document is checked for malware and blocked content.</p></div></li>
<li><span class="step-number">3</span><div><h3>Review the result</h3><p>See whether your file was approved and saved, rejected, or could not be checked.</p></div></li>
</ol>
</section>
<section class="note-card"><span aria-hidden="true">ⓘ</span><div><h3>More than a malware check</h3><p>Checks also cover embedded scripts, macros, unsafe archives, and other restricted content.</p></div></section>
</aside>
</div>
<footer><span class="footer-line"></span>File scanning is one layer of upload protection.<span class="footer-line"></span></footer>
</main>
</body>
</html>
Browser upload handling
wwwroot/portal.js
"use strict";
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const zone = document.querySelector("#drop-zone");
const button = document.querySelector("#submit-button");
const remove = document.querySelector("#remove-file");
const error = document.querySelector("#selection-error");
const result = document.querySelector("#result");
const progress = document.querySelector("#progress");
const maxBytes = Number(form.dataset.maxBytes);
let selected = null;
let busy = false;
function selectFile(file) {
if (busy) return;
error.hidden = true;
selected = file || null;
if (selected && (selected.size > maxBytes || selected.size === 0)) {
error.textContent = selected.size === 0 ? "Choose a non-empty file." :
"This file exceeds the " + (maxBytes / 1048576).toLocaleString() + " MiB limit.";
error.hidden = false;
selected = null;
input.value = "";
}
document.querySelector("#selected-file").hidden = !selected;
if (selected) {
document.querySelector("#selected-name").textContent = selected.name;
document.querySelector("#selected-size").textContent =
selected.size < 1048576 ? (selected.size / 1024).toFixed(1) + " KiB" :
(selected.size / 1048576).toFixed(2) + " MiB";
}
button.disabled = !selected;
}
input.addEventListener("change", () => selectFile(input.files[0]));
remove.addEventListener("click", () => { input.value = ""; selectFile(null); input.focus(); });
["dragenter", "dragover"].forEach(event => zone.addEventListener(event, e => {
e.preventDefault(); if (!busy) zone.classList.add("dragging");
}));
["dragleave", "drop"].forEach(event => zone.addEventListener(event, e => {
e.preventDefault(); zone.classList.remove("dragging");
}));
zone.addEventListener("drop", e => {
if (busy) return;
if (e.dataTransfer.files.length !== 1) {
error.textContent = "Submit one file at a time."; error.hidden = false; return;
}
input.value = ""; selectFile(e.dataTransfer.files[0]);
});
// Prevent dropped documents from navigating away from the portal.
window.addEventListener("dragover", e => e.preventDefault());
window.addEventListener("drop", e => e.preventDefault());
function display(data) {
result.className = "result " + data.status;
document.querySelector("#result-category").textContent = data.status.toUpperCase();
document.querySelector("#result-title").textContent = data.title;
document.querySelector("#result-message").textContent = data.message;
document.querySelector("#result-detail").textContent = data.detail || "";
result.hidden = false;
result.focus({ preventScroll: true });
result.scrollIntoView({ block: "nearest" });
}
form.addEventListener("submit", async e => {
e.preventDefault();
if (busy || !selected) return;
busy = true;
button.disabled = input.disabled = remove.disabled = true;
form.setAttribute("aria-busy", "true");
result.hidden = true;
error.hidden = true;
progress.hidden = false;
const body = new FormData();
body.append("file", selected);
try {
const response = await fetch("/upload", {
method: "POST",
headers: { "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content },
body
});
const data = await response.json();
if (!["approved", "rejected", "unavailable"].includes(data.status) ||
typeof data.title !== "string" || typeof data.message !== "string" ||
(!response.ok && data.status === "approved")) throw new Error("Unexpected response");
display(data);
if (data.status === "approved") { selected = null; input.value = ""; document.querySelector("#selected-file").hidden = true; }
} catch {
display({ status: "unavailable", title: "Scan unavailable",
message: "We could not complete the security checks. This file was not approved. Please try again.",
detail: "The connection was interrupted or the server response could not be read." });
} finally {
busy = false;
input.disabled = remove.disabled = false;
button.disabled = !selected;
progress.hidden = true;
form.setAttribute("aria-busy", "false");
}
});
Portal styles
wwwroot/portal.css
:root{font-family:Inter,"Segoe UI",Arial,sans-serif;color:#172b4d;background:#f5f7fb;font-synthesis:none}
*{box-sizing:border-box}
body{margin:0}
.topbar{height:88px;background:white;border-bottom:1px solid #e3e9f1;display:flex;align-items:center;justify-content:space-between;padding:0 5.2%}
.brand{display:flex;align-items:center;gap:14px;font-size:18px;font-weight:650;letter-spacing:-.4px}
.brand-mark{width:37px;height:40px;border-radius:9px;background:#235bd6;color:white;display:flex;align-items:center;justify-content:center;font-size:25px;font-weight:750}
.brand-mark span{font-size:15px;margin-left:-2px}
.environment{display:flex;align-items:center;gap:8px;font-size:10px;letter-spacing:1.4px;color:#60708a;font-weight:700}
.environment>span{width:6px;height:6px;background:#5d89c5;border-radius:50%}
main{max-width:1280px;margin:0 auto;padding:57px 40px 32px}
.eyebrow{font-size:10px;letter-spacing:1.7px;font-weight:750;color:#75849c}
.eyebrow>span{padding:0 13px;color:#b4bfce}
.intro{display:flex;justify-content:space-between;align-items:center;margin:23px 0 33px;gap:24px}
h1{font-size:40px;line-height:1.2;letter-spacing:-1.5px;margin:0 0 12px;font-weight:650}
.intro p{margin:0;font-size:16px;color:#63738c}
.private-badge{display:flex;align-items:center;gap:8px;color:#466080;font-size:12px;white-space:nowrap}
.private-badge svg{width:21px;height:21px;fill:none;stroke:#456ca4;stroke-width:1.5}
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:26px;align-items:start}
.upload-card{background:white;border:1px solid #e1e7ef;border-radius:15px;box-shadow:0 6px 24px #213c6705;overflow:hidden}
.card-heading{padding:29px 32px 25px;display:flex;justify-content:space-between;align-items:start}
h2{margin:0;font-size:18px;font-weight:650;letter-spacing:-.35px}
.card-heading p{margin:8px 0 0;color:#60708a;font-size:13px}
.step-label{background:#f2f5fa;border:1px solid #e8edf5;border-radius:5px;color:#7b8aa0;font-size:9px;letter-spacing:1px;padding:6px 8px;font-weight:700}
form{padding:0 32px}
.drop-zone{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:294px;border:1.5px dashed #becde3;border-radius:10px;background:#fafcff;padding:30px 20px;text-align:center;transition:background .15s,border-color .15s}
.drop-zone.dragging{background:#edf3ff;border-color:#235bd6}
.upload-icon{width:57px;height:57px;background:#ebf1fd;border:1px solid #e0e9fc;border-radius:14px;display:grid;place-items:center;margin-bottom:21px}
.upload-icon svg{width:30px;height:30px;fill:none;stroke:#3769cd;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
h3{margin:0;font-size:15px;font-weight:650}
.drop-zone>p{font-size:13px;color:#60708a;margin:9px 0 19px}
.browse{position:relative;z-index:1;background:white;border:1px solid #cfd9e8;box-shadow:0 2px 3px #12284805;border-radius:6px;color:#2b4d7f;padding:10px 18px;font-size:12px;font-weight:650;cursor:pointer}
.browse:hover{background:#eef4ff}
.accessible-input{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
.drop-zone:focus-within{outline:3px solid #85abf6;outline-offset:3px}
.limit{font-size:11px;color:#65758d;margin-top:18px}
.submit-row{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:26px 0 29px}
.submit-row p{font-size:11px;color:#7c899b;line-height:1.6;margin:0}
.submit-row p span{margin-right:5px;color:#6983aa}
.primary{border:0;border-radius:7px;background:#245bd6;color:white;font-size:12px;font-weight:600;padding:14px 18px;cursor:pointer;white-space:nowrap;box-shadow:0 3px 7px #245bd621}
.primary span{margin-left:13px}
.primary:hover{background:#194bb9}
.primary:disabled{opacity:.48;cursor:not-allowed}
.card-footer{padding:19px 32px;border-top:1px solid #edf0f5;display:flex;align-items:center;gap:9px;font-size:10px;color:#8290a4;background:#fdfefe}
.card-footer strong{color:#60738e;font-weight:600}
.small-shield{color:#6f8bad;border:1px solid #cad6e5;border-radius:4px;padding:0 3px}
.advanced{margin-left:auto;letter-spacing:1.1px;font-size:8px;color:#8b98ad}
.steps-card{padding:30px 26px 26px;background:#eff3fa;border:1px solid #e1e8f2;border-radius:13px}
.steps-card h2{margin:13px 0 26px;font-size:21px;line-height:1.45;max-width:230px}
.steps-card ol{list-style:none;padding:0;margin:0;display:grid;gap:25px}
.steps-card li{display:flex;gap:14px}
.step-number{flex-shrink:0;display:grid;place-items:center;background:white;border:1px solid #dce5f2;color:#537ab4;border-radius:50%;width:28px;height:28px;font-size:11px;font-weight:650}
.steps-card h3{font-size:13px;line-height:1.7}
.steps-card p{font-size:13px;line-height:1.8;margin:6px 0 0;color:#586d89}
.note-card{display:flex;gap:11px;padding:25px 10px}
.note-card>span{color:#7c95b9;font-size:17px}
.note-card h3{font-size:12px;line-height:1.7}
.note-card p{font-size:12px;line-height:1.85;color:#60708a;margin:6px 0 0}
footer{display:flex;align-items:center;justify-content:center;gap:13px;margin-top:41px;color:#91a0b4;font-size:11px}
.footer-line{width:32px;height:1px;background:#d9e1ec}
.configuration{margin-bottom:22px;padding:14px 18px;border:1px solid #ead6a8;border-radius:8px;background:#fffaf0;color:#80602d;font-size:12px;line-height:1.7}
.selected-file{display:flex;align-items:center;gap:12px;padding:15px;margin-top:17px;border:1px solid #dae4f3;border-radius:8px;background:#f9fbff}
.selected-file>div{min-width:0;flex:1}
.selected-file strong{display:block;overflow-wrap:anywhere;font-size:12px}
.selected-file div span{display:block;color:#7c8ca4;font-size:11px;margin-top:5px}
.document-icon{color:#527dc3;font-size:25px}
.selected-file button{border:0;background:transparent;color:#6b7d98;font-size:24px;cursor:pointer;padding:4px 8px}
.selection-error{font-size:12px;color:#ae3544}
.progress,.result{margin:0 32px 25px;border-radius:8px;padding:20px;font-size:13px;line-height:1.65}
.progress{display:flex;gap:14px;align-items:center;background:#f0f5ff;color:#345c96}
.progress p{margin:4px 0 0;font-size:12px}
.spinner{width:22px;height:22px;border:2px solid #c3d4f3;border-top-color:#245bd6;border-radius:50%;animation:spin 1s linear infinite;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}
}
.result{border:1px solid #c5dfd5;background:#f1faf6;color:#256449;outline-offset:3px}
.result.rejected{background:#fff6f6;border-color:#f0ced1;color:#993b46}
.result.unavailable{background:#fffaf0;border-color:#ebd6a6;color:#80602d}
.result-category{font-size:9px;letter-spacing:1.3px;font-weight:750}
.result h3{font-size:17px;margin:6px 0}
.result p{margin:5px 0 0;font-size:12px}
#result-detail:empty{display:none}
[hidden]{display:none!important}
button:focus-visible,a:focus-visible{outline:3px solid #85abf6;outline-offset:4px}
@media(min-width:1600px){main{padding-top:75px}
.drop-zone{min-height:325px}
.intro{margin-bottom:40px}
.workspace{gap:30px}
}
@media(max-width:900px){main{padding:35px 24px}
.workspace{grid-template-columns:1fr}
.steps-card ol{grid-template-columns:repeat(3,1fr);gap:18px}
.steps-card h2{max-width:none}
.private-badge{display:none}
.note-card{padding-bottom:0}
}
@media(max-width:600px){.topbar{height:74px;padding:0 20px}
.brand{font-size:14px;gap:9px}
.environment{font-size:8px;letter-spacing:.8px}
main{padding:28px 16px}
h1{font-size:32px}
.intro p{font-size:14px;line-height:1.6}
.eyebrow{font-size:8px}
.card-heading{padding:22px}
form{padding:0 20px}
.drop-zone{padding:25px 15px}
.drop-zone h3{font-size:13px}
.submit-row{align-items:stretch;flex-direction:column}
.primary{font-size:13px}
.card-footer{padding:18px 20px;flex-wrap:wrap}
.advanced{display:none}
.steps-card ol{grid-template-columns:1fr}
.progress,.result{margin-left:20px;margin-right:20px}
footer{font-size:10px;gap:8px}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}
}
Run locally
From the directory containing UploadSecurityDemo/, run in PowerShell:
$env:Cloudmersive__ApiKey = [System.Net.NetworkCredential]::new('', (Read-Host 'Cloudmersive API key' -AsSecureString)).Password
dotnet run --project ./UploadSecurityDemo/UploadSecurityDemo.csproj --no-launch-profile -- --urls http://localhost:5080
Open http://localhost:5080. Approved files are saved in UploadSecurityDemo/App_Data/approved.