mirror of
https://github.com/cyanfish/naps2.git
synced 2024-10-04 03:17:07 +03:00
366 lines
14 KiB
C#
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;
|
|
}
|
|
} |