naps2/NAPS2.Escl.Server/EsclApiController.cs
2024-08-17 14:28:12 -07:00

366 lines
14 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Xml.Linq;
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;
using Microsoft.Extensions.Logging;
namespace NAPS2.Escl.Server;
internal class EsclApiController : WebApiController
{
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
private readonly EsclDeviceConfig _deviceConfig;
private readonly EsclServerState _serverState;
private readonly EsclSecurityPolicy _securityPolicy;
private readonly ILogger _logger;
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState,
EsclSecurityPolicy securityPolicy, ILogger logger)
{
_deviceConfig = deviceConfig;
_serverState = serverState;
_securityPolicy = securityPolicy;
_logger = logger;
}
protected override void OnBeforeHandler()
{
base.OnBeforeHandler();
if (_securityPolicy.HasFlag(EsclSecurityPolicy.ServerAllowAnyOrigin))
{
Response.Headers.Add("Access-Control-Allow-Origin", "*");
}
}
[Route(HttpVerbs.Get, "/ScannerCapabilities")]
public async Task GetScannerCapabilities()
{
var caps = _deviceConfig.Capabilities;
var protocol = _securityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) ? "https" : "http";
var iconUri = caps.IconPng != null ? $"{protocol}://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : "";
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerCapabilities",
new XElement(PwgNs + "Version", caps.Version),
new XElement(PwgNs + "MakeAndModel", caps.MakeAndModel),
new XElement(PwgNs + "SerialNumber", caps.SerialNumber),
new XElement(ScanNs + "Manufacturer", caps.Manufacturer),
new XElement(ScanNs + "UUID", caps.Uuid),
new XElement(ScanNs + "AdminURI", ""),
new XElement(ScanNs + "IconURI", iconUri),
new XElement(ScanNs + "Naps2Extensions", "Progress;ErrorDetails;ShortTimeout"),
new XElement(ScanNs + "Platen",
new XElement(ScanNs + "PlatenInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "Adf",
new XElement(ScanNs + "AdfSimplexInputCaps", GetCommonInputCaps()),
new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "CompressionFactorSupport",
new XElement(ScanNs + "Min", 0),
new XElement(ScanNs + "Max", 100),
new XElement(ScanNs + "Normal", 75),
new XElement(ScanNs + "Step", 1))));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
}
private object[] GetCommonInputCaps()
{
// TODO: After implementing scanner capabilities this should be scanner-specific
return
[
new XElement(ScanNs + "MinWidth", "1"),
new XElement(ScanNs + "MaxWidth", EsclInputCaps.DEFAULT_MAX_WIDTH),
new XElement(ScanNs + "MinHeight", "1"),
new XElement(ScanNs + "MaxHeight", EsclInputCaps.DEFAULT_MAX_HEIGHT),
new XElement(ScanNs + "MaxScanRegions", "1"),
new XElement(ScanNs + "SettingProfiles",
new XElement(ScanNs + "SettingProfile",
new XElement(ScanNs + "ColorModes",
new XElement(ScanNs + "ColorMode", "BlackAndWhite1"),
new XElement(ScanNs + "ColorMode", "Grayscale8"),
new XElement(ScanNs + "ColorMode", "RGB24")),
new XElement(ScanNs + "DocumentFormats",
new XElement(PwgNs + "DocumentFormat", "application/pdf"),
new XElement(PwgNs + "DocumentFormat", "image/jpeg"),
new XElement(PwgNs + "DocumentFormat", "image/png"),
new XElement(ScanNs + "DocumentFormatExt", "application/pdf"),
new XElement(ScanNs + "DocumentFormatExt", "image/jpeg"),
new XElement(ScanNs + "DocumentFormatExt", "image/png")
),
new XElement(ScanNs + "SupportedResolutions",
new XElement(ScanNs + "DiscreteResolutions",
CreateResolution(100),
CreateResolution(150),
CreateResolution(200),
CreateResolution(300),
CreateResolution(400),
CreateResolution(600),
CreateResolution(800),
CreateResolution(1200),
CreateResolution(2400),
CreateResolution(4800)
))))
];
}
private XElement CreateResolution(int res) =>
new(ScanNs + "DiscreteResolution",
new XElement(ScanNs + "XResolution", res.ToString()),
new XElement(ScanNs + "YResolution", res.ToString()));
[Route(HttpVerbs.Get, "/icon.png")]
public async Task GetIcon()
{
if (_deviceConfig.Capabilities.IconPng != null)
{
Response.ContentType = "image/png";
using var stream = Response.OutputStream;
var buffer = _deviceConfig.Capabilities.IconPng;
await stream.WriteAsync(buffer, 0, buffer.Length);
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScannerStatus")]
public async Task GetScannerStatus()
{
var jobsElement = new XElement(ScanNs + "Jobs");
foreach (var jobInfo in _serverState.Jobs.OrderBy(x => x.LastUpdated.ElapsedMilliseconds))
{
jobsElement.Add(new XElement(ScanNs + "JobInfo",
new XElement(PwgNs + "JobUri", $"/eSCL/ScanJobs/{jobInfo.Id}"),
new XElement(PwgNs + "JobUuid", jobInfo.Id),
new XElement(ScanNs + "Age", Math.Ceiling(jobInfo.LastUpdated.Elapsed.TotalSeconds)),
new XElement(PwgNs + "ImagesCompleted", jobInfo.ImagesCompleted),
new XElement(PwgNs + "ImagesToTransfer", jobInfo.ImagesToTransfer),
new XElement(PwgNs + "JobState", jobInfo.State.ToString()),
new XElement(PwgNs + "JobStateReasons",
new XElement(PwgNs + "JobStateReason",
jobInfo.State == EsclJobState.Processing ? "JobScanning" : "JobCompletedSuccessfully"))));
}
var scannerState = _serverState.IsProcessing ? EsclScannerState.Processing : EsclScannerState.Idle;
var adfState = _serverState.IsProcessing ? EsclAdfState.ScannerAdfProcessing : EsclAdfState.ScannedAdfLoaded;
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerStatus",
new XElement(PwgNs + "Version", "2.6"),
new XElement(PwgNs + "State", scannerState),
new XElement(ScanNs + "AdfState", adfState),
jobsElement
));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
}
[Route(HttpVerbs.Post, "/ScanJobs")]
public void CreateScanJob()
{
// TODO: Actually use job input for scan options
EsclScanSettings settings;
try
{
var doc = XDocument.Load(Request.InputStream);
settings = SettingsParser.Parse(doc);
}
catch (Exception)
{
Response.StatusCode = 400; // Bad request
return;
}
if (_serverState.IsProcessing)
{
Response.StatusCode = 503; // Service unavailable
return;
}
_serverState.IsProcessing = true;
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
_serverState.AddJob(jobInfo);
var uri = Request.Url;
if (Request.IsSecureConnection)
{
// Fix https://github.com/unosquare/embedio/issues/593
uri = new UriBuilder(uri) { Scheme = "https" }.Uri;
}
Response.Headers.Add("Access-Control-Expose-Headers", "Location");
Response.Headers.Add("Location", $"{uri}/{jobInfo.Id}");
Response.StatusCode = 201; // Created
}
[Route(HttpVerbs.Delete, "/ScanJobs/{jobId}")]
public void CancelScanJob(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState) &&
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
{
jobState.Job.Cancel();
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ScanImageInfo")]
public void GetImageinfo(string jobId)
{
}
// This endpoint is a NAPS2-specific extension to the ESCL API.
// It gives a chunked response where each line is a double between 0 and 1 representing the current page progress.
// This endpoint should be called once before each call to NextDocument.
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/Progress")]
public async Task Progress(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState) &&
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
{
SetChunkedResponse();
using var stream = Response.OutputStream;
await jobState.Job.WriteProgressTo(stream);
}
else
{
Response.StatusCode = 404;
}
}
// This endpoint is a NAPS2-specific extension to the ESCL API.
// The ESCL status-based model (where errors like "no paper in feeder" are encompassed by ScannerStatus that can be
// polled every few seconds) is a good match to a physical scanner but a poor match to NAPS2's model.
// Instead, we have a this ErrorDetails endpoint that we call when NextDocument returns a 500 error which gives us
// XML-based details for the exception that occurred.
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ErrorDetails")]
public async Task ErrorDetails(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState))
{
Response.ContentType = "text/xml";
using var stream = Response.OutputStream;
await jobState.Job.WriteErrorDetailsTo(stream);
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/NextDocument")]
public async Task NextDocument(string jobId)
{
if (!CheckJobState(jobId, out var jobInfo))
{
return;
}
await jobInfo.NextDocumentLock.Take();
try
{
// Recheck job state in case it's been changed while we were waiting on the lock
if (!CheckJobState(jobId, out _))
{
return;
}
await WaitForAndWriteNextDocument(jobInfo);
}
finally
{
jobInfo.NextDocumentLock.Release();
}
}
private bool CheckJobState(string jobId, [NotNullWhen(true)] out JobInfo? jobInfo)
{
if (!_serverState.TryGetJob(jobId, out jobInfo))
{
Response.StatusCode = 404;
return false;
}
if (jobInfo.State == EsclJobState.Aborted)
{
Response.StatusCode = 500;
return false;
}
if (jobInfo.State is not (EsclJobState.Pending or EsclJobState.Processing))
{
Response.StatusCode = 404;
return false;
}
return true;
}
private async Task WaitForAndWriteNextDocument(JobInfo jobInfo)
{
try
{
// If we already have a document (i.e. if a connection error occured during the previous NextDocument
// request), we stay at that same document and don't advance
var cts = new CancellationTokenSource();
cts.CancelAfter(1000);
jobInfo.NextDocumentReady = jobInfo.NextDocumentReady || await jobInfo.Job.WaitForNextDocument(cts.Token);
}
catch (TaskCanceledException)
{
_logger.LogDebug("Waiting for document timed out, returning 503");
// Tell the client to retry after 2s
Response.Headers.Add("Retry-After", "2");
Response.StatusCode = 503;
return;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ESCL server error waiting for document");
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
Response.StatusCode = 500;
return;
}
// At this point either we have a document and can respond with it, or we have no documents left and should 404
if (jobInfo.NextDocumentReady)
{
try
{
Response.Headers.Add("Content-Location", $"/eSCL/ScanJobs/{jobInfo.Id}/1");
SetChunkedResponse();
Response.ContentType = jobInfo.Job.ContentType;
Response.ContentEncoding = null;
using var stream = Response.OutputStream;
await jobInfo.Job.WriteDocumentTo(stream);
jobInfo.NextDocumentReady = false;
jobInfo.TransferredDocument();
}
catch (Exception ex)
{
_logger.LogError(ex, "ESCL server error writing document");
// We don't transition state here, as the assumption is that the problem was network-related and the
// client will retry
Response.StatusCode = 500;
}
}
else
{
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Completed);
Response.StatusCode = 404;
}
}
private void SetChunkedResponse()
{
// Bypass https://github.com/unosquare/embedio/issues/510
var field = Response.GetType().GetField("<ProtocolVersion>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field != null)
{
field.SetValue(Response, new Version(1, 1));
}
Response.SendChunked = true;
}
}