Removed the captcha. (#5816)
This commit removes the Captcha support, and replaces with a copy-paste-able safeguard in the UI.
This commit is contained in:
@@ -79,7 +79,6 @@
|
||||
<script type="text/javascript" src="scripts/services/DialogService.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/services/LogService.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/services/EditBackupService.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/services/CaptchaService.js?v=2.0.0.7"></script>
|
||||
|
||||
<script type="text/javascript" src="scripts/controllers/AppController.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/controllers/AboutController.js?v=2.0.0.7"></script>
|
||||
@@ -104,6 +103,7 @@
|
||||
<script type="text/javascript" src="scripts/controllers/CommandlineController.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/controllers/ThrottleController.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/controllers/ChangePasswordController.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/controllers/ConfirmDeleteController.js?v=2.0.0.7"></script>
|
||||
|
||||
<script type="text/javascript" src="scripts/filters/timeremaining.js?v=2.0.0.7"></script>
|
||||
<script type="text/javascript" src="scripts/filters/highlight.js?v=2.0.0.7"></script>
|
||||
|
||||
@@ -2222,6 +2222,29 @@ div.modal-dialog {
|
||||
}
|
||||
}
|
||||
|
||||
.confirmdelete {
|
||||
div.overview {
|
||||
width: 420px;
|
||||
}
|
||||
div.word {
|
||||
font-weight: bold;
|
||||
padding: 12px;
|
||||
border: 1px solid black;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
background-color: #e0e0e0;
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
span.backupname {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input.typedword {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress bar styles from Bootstrap */
|
||||
.progress-bar-striped {
|
||||
background-image: linear-gradient(45deg, rgb(255 255 255 / 15%) 25%, transparent 25%, transparent 50%, rgb(255 255 255 / 15%) 50%, rgb(255 255 255 / 15%) 75%, transparent 75%, transparent);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
backupApp.controller('CaptchaController', function($scope, CaptchaService, DialogService, AppService, AppUtils) {
|
||||
var entry = $scope.entry = CaptchaService.active;
|
||||
|
||||
function refreshChallenge() {
|
||||
entry.imageurl = null;
|
||||
|
||||
AppService.postJson('/captcha', { 'target': entry.target}).then(function(resp) {
|
||||
entry.token = resp.data.Token;
|
||||
entry.expectedAnswer = resp.data.Answer;
|
||||
entry.noVisualChallenge = resp.data.NoVisualChallenge;
|
||||
entry.imageurl = AppService.apiurl + '/captcha/' + entry.token;
|
||||
|
||||
}, function(err) {
|
||||
DialogService.dismissCurrent();
|
||||
AppUtils.connectionError(err);
|
||||
});
|
||||
};
|
||||
|
||||
if (entry.token == null)
|
||||
refreshChallenge();
|
||||
|
||||
$scope.reload = refreshChallenge;
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
backupApp.controller('ConfirmDeleteController', function($scope, $location, gettextCatalog) {
|
||||
$scope.selection = $scope.$parent.state.CurrentItem;
|
||||
|
||||
if ($scope.selection.requiredword == '')
|
||||
$scope.selection.requiredword = 'delete all files';
|
||||
$scope.selection.typedword = '';
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
backupApp.controller('DeleteController', function($scope, $routeParams, $location, gettextCatalog, CaptchaService, DialogService, ServerStatus, SystemInfo, BackupList, AppService, AppUtils) {
|
||||
backupApp.controller('DeleteController', function($scope, $routeParams, $location, gettextCatalog, DialogService, ServerStatus, SystemInfo, BackupList, AppService, AppUtils) {
|
||||
$scope.BackupID = $routeParams.backupid;
|
||||
$scope.DeleteLocalDatabase = true;
|
||||
$scope.DeleteRemoteFiles = false;
|
||||
@@ -61,16 +61,38 @@ backupApp.controller('DeleteController', function($scope, $routeParams, $locatio
|
||||
$scope.doDelete = function() {
|
||||
if ($scope.DeleteRemoteFiles)
|
||||
{
|
||||
CaptchaService.Authorize(
|
||||
const dlg = DialogService.htmlDialog(
|
||||
gettextCatalog.getString('Confirm delete'),
|
||||
gettextCatalog.getString('To confirm you want to delete all remote files for "{{name}}", please enter the word you see below', {name: $scope.Backup.Backup.Name}),
|
||||
'DELETE /backup/' + $scope.BackupID,
|
||||
function(token, answer) {
|
||||
AppService.delete('/backup/' + $scope.BackupID + '?delete-local-db=' + $scope.DeleteLocalDatabase + '&delete-remote-files=' + $scope.DeleteRemoteFiles + '&captcha-token=' + token + '&captcha-answer=' + answer).then(function() {
|
||||
$location.path('/');
|
||||
}, AppUtils.connectionError);
|
||||
'templates/confirmdelete.html',
|
||||
[gettextCatalog.getString('Cancel'), gettextCatalog.getString('Delete')],
|
||||
function(ix) {
|
||||
if (ix == 1) {
|
||||
AppService.delete('/backup/' + $scope.BackupID + '?delete-local-db=' + $scope.DeleteLocalDatabase + '&delete-remote-files=' + $scope.DeleteRemoteFiles).then(function() {
|
||||
$location.path('/');
|
||||
}, AppUtils.connectionError);
|
||||
}
|
||||
},
|
||||
null,
|
||||
function(index, text, cur) {
|
||||
// Allow the user to cancel
|
||||
if (index != 1)
|
||||
return true;
|
||||
|
||||
// Compare case insensitive
|
||||
if (cur.requiredword?.toLowerCase() != cur.typedword?.toLowerCase() || cur.typedword == '')
|
||||
{
|
||||
alert("Please enter the required phrase to confirm the deletion");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Set the dialog data
|
||||
dlg.backupId = $scope.BackupID;
|
||||
dlg.backupname = $scope.Backup.Backup.Name;
|
||||
dlg.requiredword = ('delete ' + $scope.Backup.Backup.Name).toLowerCase();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
backupApp.service('CaptchaService', function(DialogService, AppService, AppUtils, gettextCatalog) {
|
||||
this.active = null;
|
||||
var self = this;
|
||||
|
||||
this.Authorize = function(title, message, target, callback) {
|
||||
|
||||
var cb = self.active = {
|
||||
'message': message,
|
||||
'target': target,
|
||||
'callback': callback,
|
||||
'attempts': 0,
|
||||
'hasfailed': false,
|
||||
'verifying': false
|
||||
};
|
||||
|
||||
self.attemptSolve = function() {
|
||||
if (cb.attempts >= 3) {
|
||||
cb.attempts = 0;
|
||||
cb.token = null;
|
||||
}
|
||||
|
||||
DialogService.htmlDialog(title, 'templates/captcha.html', [gettextCatalog.getString('Cancel'), gettextCatalog.getString('OK')], function(btn) {
|
||||
if (btn != 1) {
|
||||
self.active = null;
|
||||
return;
|
||||
}
|
||||
|
||||
cb.attempts += 1;
|
||||
cb.verifying = true;
|
||||
|
||||
|
||||
DialogService.dialog(gettextCatalog.getString('Verifying answer'), gettextCatalog.getString('Verifying …'), [], function() {}, function() {
|
||||
|
||||
AppService.postJson('/captcha/' + encodeURIComponent(cb.token), {'answer': cb.answer, 'target': cb.target}).then(function(resp) {
|
||||
DialogService.dismissCurrent();
|
||||
self.active = null;
|
||||
cb.callback(cb.token, cb.answer);
|
||||
}, function(err) {
|
||||
|
||||
DialogService.dismissCurrent();
|
||||
cb.verifying = false;
|
||||
cb.hasfailed = true;
|
||||
if (err.status == 400)
|
||||
self.attemptSolve();
|
||||
else
|
||||
AppUtils.connectionError(err);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
self.attemptSolve();
|
||||
};
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
||||
<div class="captcha" ng-controller="CaptchaController">
|
||||
<div ng-show="entry.imageurl">
|
||||
<div>{{entry.message}}</div>
|
||||
<div class="details">
|
||||
<img ng-hide="entry.noVisualChallenge" src="{{entry.imageurl}}" />
|
||||
<code class="code" ng-show="entry.noVisualChallenge">{{entry.expectedAnswer}}</code>
|
||||
<input class="answer" type="text" ng-model="entry.answer" />
|
||||
<input type="button" value="{{'Reload' | translate}}" ng-click="reload()" />
|
||||
</div>
|
||||
|
||||
<div class="centered-text" ng-show="entry.verifying" translate>Checking …</div>
|
||||
<div class="centered-text warning" ng-show="!entry.verifying && entry.hasfailed" translate>Incorrect answer, try again</div>
|
||||
|
||||
</div>
|
||||
<div ng-hide="entry.imageurl" translate>Loading …</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div ng-controller="ConfirmDeleteController" class="confirmdelete">
|
||||
<form class="styled">
|
||||
<div class="overview" translate>
|
||||
To confirm you want to delete all remote files for
|
||||
<span class="backupname">"{{selection.backupname}}"</span>, please enter
|
||||
this phrase:
|
||||
</div>
|
||||
|
||||
<div class="word">{{selection.requiredword}}</div>
|
||||
|
||||
<div>
|
||||
<input type="text" class="typedword" ng-model="selection.typedword" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace Duplicati.WebserverCore.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// A captcha provider
|
||||
/// </summary>
|
||||
public interface ICaptchaProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if the captcha is solved
|
||||
/// </summary>
|
||||
/// <param name="token">The captcha token</param>
|
||||
/// <param name="target">The captcha target</param>
|
||||
/// <param name="answer">The captcha answer</param>
|
||||
bool SolvedCaptcha(string token, string target, string answer);
|
||||
/// <summary>
|
||||
/// Create a captcha
|
||||
/// </summary>
|
||||
/// <param name="target">The captcha target</param>
|
||||
/// <returns>The captcha token and the answer</returns>
|
||||
(string Token, string? Answer) CreateCaptcha(string target);
|
||||
/// <summary>
|
||||
/// Get the captcha image
|
||||
/// </summary>
|
||||
/// <param name="token">The captcha token</param>
|
||||
byte[] GetCaptchaImage(string token);
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the visual captcha is disabled
|
||||
/// </summary>
|
||||
bool VisualCaptchaDisabled { get; }
|
||||
}
|
||||
@@ -18,9 +18,9 @@ public class BackupPutDelete : IEndpointV1
|
||||
=> ExecutePut(GetBackup(connection, id), connection, input))
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapDelete("/backup/{id}", ([FromServices] Connection connection, [FromServices] IWorkerThreadsManager workerThreadsManager, [FromServices] ICaptchaProvider captchaProvider, [FromServices] LiveControls liveControls, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string id, [FromQuery(Name = "delete-remote-files")] bool? delete_remote_files, [FromQuery(Name = "delete-local-db")] bool? delete_local_db, [FromQuery(Name = "captcha-token")] string? captcha_token, [FromQuery(Name = "captcha-answer")] string? captcha_answer, [FromQuery] bool? force) =>
|
||||
group.MapDelete("/backup/{id}", ([FromServices] Connection connection, [FromServices] IWorkerThreadsManager workerThreadsManager, [FromServices] LiveControls liveControls, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string id, [FromQuery(Name = "delete-remote-files")] bool? delete_remote_files, [FromQuery(Name = "delete-local-db")] bool? delete_local_db, [FromQuery] bool? force) =>
|
||||
{
|
||||
var res = ExecuteDelete(GetBackup(connection, id), workerThreadsManager, captchaProvider, liveControls, delete_remote_files ?? false, delete_local_db, captcha_token, captcha_answer, force ?? false);
|
||||
var res = ExecuteDelete(GetBackup(connection, id), workerThreadsManager, liveControls, delete_remote_files ?? false, delete_local_db, force ?? false);
|
||||
if (res.Status != "OK" && httpContextAccessor.HttpContext != null)
|
||||
httpContextAccessor.HttpContext.Response.StatusCode = 500;
|
||||
return res;
|
||||
@@ -112,17 +112,8 @@ public class BackupPutDelete : IEndpointV1
|
||||
}
|
||||
}
|
||||
|
||||
private static Dto.DeleteBackupOutputDto ExecuteDelete(IBackup backup, IWorkerThreadsManager workerThreadsManager, ICaptchaProvider captchaProvider, LiveControls liveControls, bool delete_remote_files, bool? delete_local_db, string? captcha_token, string? captcha_answer, bool force)
|
||||
private static Dto.DeleteBackupOutputDto ExecuteDelete(IBackup backup, IWorkerThreadsManager workerThreadsManager, LiveControls liveControls, bool delete_remote_files, bool? delete_local_db, bool force)
|
||||
{
|
||||
if (delete_remote_files)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(captcha_token) || string.IsNullOrWhiteSpace(captcha_answer))
|
||||
throw new UnauthorizedException("Missing captcha");
|
||||
|
||||
if (!captchaProvider.SolvedCaptcha(captcha_token, "DELETE /backup/" + backup.ID, captcha_answer))
|
||||
throw new ForbiddenException("Invalid captcha");
|
||||
}
|
||||
|
||||
if (workerThreadsManager.WorkerThread!.Active)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using Duplicati.WebserverCore.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Duplicati.WebserverCore.Endpoints.V1;
|
||||
|
||||
public class Captcha : IEndpointV1
|
||||
{
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/captcha/{token}", ([FromServices] ICaptchaProvider captchaProvider, [FromServices] IHttpContextAccessor httpContextAccessor, [FromRoute] string token, CancellationToken ct) =>
|
||||
{
|
||||
var jpeg = captchaProvider.GetCaptchaImage(token);
|
||||
var response = httpContextAccessor.HttpContext!.Response;
|
||||
response.ContentLength = jpeg.Length;
|
||||
response.ContentType = "image/jpeg";
|
||||
response.Body.WriteAsync(jpeg, ct);
|
||||
});
|
||||
|
||||
group.MapPost("/captcha", ([FromServices] ICaptchaProvider captchaProvider, [FromBody] Dto.SolveCaptchaInputDto input) =>
|
||||
{
|
||||
var (token, answer) = captchaProvider.CreateCaptcha(input.target);
|
||||
return new Dto.GenerateCaptchaOutput(
|
||||
token,
|
||||
answer,
|
||||
captchaProvider.VisualCaptchaDisabled
|
||||
);
|
||||
})
|
||||
.RequireAuthorization();
|
||||
|
||||
|
||||
group.MapPost("/captcha/{token}", ([FromServices] ICaptchaProvider captchaProvider, [FromRoute] string token, [FromBody] Dto.SolveCaptchaInputDto input) =>
|
||||
{
|
||||
if (captchaProvider.SolvedCaptcha(token, input.target, input.answer ?? ""))
|
||||
return new { success = true };
|
||||
return new { success = false };
|
||||
})
|
||||
.RequireAuthorization();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ public static class ServiceCollectionsExtensions
|
||||
.AddSingleton<IScheduler, SchedulerService>()
|
||||
.AddSingleton<IWebsocketAccessor, WebsocketAccessor>()
|
||||
.AddTransient<ILanguageService, LanguageService>()
|
||||
.AddSingleton<ICaptchaProvider, CaptchaService>()
|
||||
.AddSingleton<ICommandlineRunService, CommandlineRunService>()
|
||||
.AddTransient<IJWTTokenProvider, JWTTokenProvider>()
|
||||
.AddTransient<ITokenFamilyStore, TokenFamilyStore>()
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
using Duplicati.WebserverCore.Abstractions;
|
||||
using Duplicati.WebserverCore.Exceptions;
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
|
||||
namespace Duplicati.WebserverCore.Services;
|
||||
|
||||
public class CaptchaService : ICaptchaProvider
|
||||
{
|
||||
private readonly object m_lock = new();
|
||||
private readonly Dictionary<string, CaptchaEntry> m_captchas = [];
|
||||
private readonly bool m_disableVisualCaptcha;
|
||||
|
||||
public CaptchaService(ISettingsService settings)
|
||||
{
|
||||
m_disableVisualCaptcha = settings.GetSettings().DisableVisualCaptcha;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of possible system fonts, ordered by preference
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> FontNamePreference = new string[] {
|
||||
"Arial", "Verdana", "FreeSans", "Tahoma", "Helvetica", "Times New Roman", "Courier New", "Andale Mono"
|
||||
}
|
||||
.Select((x, i) => new { Key = x, Value = i })
|
||||
.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private class CaptchaEntry
|
||||
{
|
||||
public readonly string Answer;
|
||||
public readonly string Target;
|
||||
public int Attempts;
|
||||
public readonly DateTime Expires;
|
||||
|
||||
public CaptchaEntry(string answer, string target)
|
||||
{
|
||||
Answer = answer;
|
||||
Target = target;
|
||||
Attempts = 4;
|
||||
Expires = DateTime.Now.AddMinutes(2);
|
||||
}
|
||||
}
|
||||
|
||||
public (string Token, string? Answer) CreateCaptcha(string target)
|
||||
{
|
||||
var answer = CaptchaUtil.CreateRandomAnswer(minlength: 6, maxlength: 6);
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
|
||||
string token;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(answer + nonce);
|
||||
ms.Write(bytes, 0, bytes.Length);
|
||||
ms.Position = 0;
|
||||
using (var hasher = Library.Utility.HashFactory.CreateHasher(Library.Utility.HashFactory.SHA256))
|
||||
token = Library.Utility.Utility.Base64PlainToBase64Url(Convert.ToBase64String(hasher.ComputeHash(ms)));
|
||||
}
|
||||
|
||||
lock (m_lock)
|
||||
{
|
||||
var expired = m_captchas.Where(x => x.Value.Expires < DateTime.Now).Select(x => x.Key).ToArray();
|
||||
foreach (var x in expired)
|
||||
m_captchas.Remove(x);
|
||||
|
||||
if (m_captchas.Count > 3)
|
||||
throw new ServiceUnavailableException("Too many captchas, wait 2 minutes and try again");
|
||||
|
||||
m_captchas[token] = new CaptchaEntry(answer, target);
|
||||
}
|
||||
|
||||
return (token, m_disableVisualCaptcha ? answer : null);
|
||||
}
|
||||
|
||||
public byte[] GetCaptchaImage(string token)
|
||||
{
|
||||
if (m_disableVisualCaptcha)
|
||||
throw new NotFoundException("No such entry");
|
||||
|
||||
string? answer = null;
|
||||
lock (m_lock)
|
||||
{
|
||||
m_captchas.TryGetValue(token, out var tp);
|
||||
if (tp != null && tp.Expires > DateTime.Now)
|
||||
answer = tp.Answer;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(answer))
|
||||
throw new NotFoundException("No such entry");
|
||||
|
||||
using var image = CaptchaUtil.CreateCaptcha(answer);
|
||||
using var ms = new MemoryStream();
|
||||
image.SaveAsJpeg(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
public bool SolvedCaptcha(string token, string target, string answer)
|
||||
{
|
||||
lock (m_lock)
|
||||
{
|
||||
m_captchas.TryGetValue(token ?? string.Empty, out var tp);
|
||||
if (tp == null)
|
||||
return false;
|
||||
|
||||
if (tp.Attempts > 0)
|
||||
tp.Attempts--;
|
||||
|
||||
return tp.Attempts >= 0 && string.Equals(tp.Answer, answer, StringComparison.OrdinalIgnoreCase) && tp.Target == target && tp.Expires >= DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
public bool VisualCaptchaDisabled => m_disableVisualCaptcha;
|
||||
|
||||
|
||||
public static class CaptchaUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// A lookup string with characters to use
|
||||
/// </summary>
|
||||
private static readonly string DEFAULT_CHARS = "ACDEFGHJKLMNPQRTUVWXY34679";
|
||||
|
||||
/// <summary>
|
||||
/// Approximate the size in pixels of text drawn at the given fontsize
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure</param>
|
||||
/// <param name="font">The font to use</param>
|
||||
private static int ApproxTextWidth(string text, Font font)
|
||||
=> (int)TextMeasurer.MeasureSize(text, new TextOptions(font) { KerningMode = KerningMode.Standard }).Width;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a random answer.
|
||||
/// </summary>
|
||||
/// <returns>The random answer.</returns>
|
||||
/// <param name="allowedchars">The list of allowed chars, supply a character multiple times to change frequency.</param>
|
||||
/// <param name="minlength">The minimum answer length.</param>
|
||||
/// <param name="maxlength">The maximum answer length.</param>
|
||||
public static string CreateRandomAnswer(string? allowedchars = null, int minlength = 10, int maxlength = 12)
|
||||
{
|
||||
allowedchars = allowedchars ?? DEFAULT_CHARS;
|
||||
var rnd = new Random();
|
||||
var len = rnd.Next(Math.Min(minlength, maxlength), Math.Max(minlength, maxlength) + 1);
|
||||
if (len <= 0)
|
||||
throw new ArgumentException($"The values {minlength} and {maxlength} gave a final length of {len} and it must be greater than 0");
|
||||
|
||||
return new string(Enumerable.Range(0, len).Select(x => allowedchars[rnd.Next(0, allowedchars.Length)]).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a captcha image.
|
||||
/// </summary>
|
||||
/// <returns>The captcha image.</returns>
|
||||
/// <param name="answer">The captcha solution string.</param>
|
||||
/// <param name="size">The size of the image, omit to get a size based on the string.</param>
|
||||
/// <param name="fontsize">The size of the font used to create the captcha, in pixels.</param>
|
||||
public static Image<Rgba32> CreateCaptcha(string answer, Size size = default(Size), float fontsize = 40)
|
||||
{
|
||||
var fontFamily = SystemFonts.Collection.Families.OrderBy(x =>
|
||||
{
|
||||
if (FontNamePreference.TryGetValue(x.Name, out var val))
|
||||
return val;
|
||||
return int.MaxValue;
|
||||
}).FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fontFamily.Name))
|
||||
throw new Exception("No usable font found");
|
||||
|
||||
var font = fontFamily.CreateFont(fontsize);
|
||||
var text_width = ApproxTextWidth(answer, font);
|
||||
if (size.Width == 0 || size.Height == 0)
|
||||
size = new Size((int)(text_width * 1.2), (int)(fontsize * 1.2));
|
||||
|
||||
var image = new Image<Rgba32>(size.Width, size.Height);
|
||||
var rnd = new Random();
|
||||
var stray_x = (int)fontsize / 2;
|
||||
var stray_y = size.Height / 4;
|
||||
var ans_stray_x = (int)fontsize / 3;
|
||||
var ans_stray_y = size.Height / 6;
|
||||
|
||||
image.Mutate(ctx =>
|
||||
{
|
||||
ctx.Fill(Color.White);
|
||||
|
||||
// Apply a background string to make it hard to do OCR
|
||||
foreach (var color in new[] { Color.Yellow, Color.LightGreen, Color.GreenYellow })
|
||||
{
|
||||
var backgroundFont = font;
|
||||
ctx.DrawText(CreateRandomAnswer(minlength: answer.Length, maxlength: answer.Length), backgroundFont, color, new PointF(rnd.Next(-stray_x, stray_x), rnd.Next(-stray_y, stray_y)));
|
||||
}
|
||||
|
||||
var spacing = (size.Width / (int)fontsize) + rnd.Next(0, stray_x);
|
||||
|
||||
// Create vertical background lines
|
||||
for (var i = rnd.Next(0, stray_x); i < size.Width; i += spacing)
|
||||
{
|
||||
var color = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
|
||||
ctx.DrawLine(color, 1, new PointF(i + rnd.Next(-stray_x, stray_x), rnd.Next(0, stray_y)), new PointF(i + rnd.Next(-stray_x, stray_x), size.Height - rnd.Next(0, stray_y)));
|
||||
}
|
||||
|
||||
spacing = (size.Height / (int)fontsize) + rnd.Next(0, stray_y);
|
||||
|
||||
// Create horizontal background lines
|
||||
for (var i = rnd.Next(0, stray_y); i < size.Height; i += spacing)
|
||||
{
|
||||
var color = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
|
||||
ctx.DrawLine(color, 1, new PointF(rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y)), new PointF(size.Width - rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y)));
|
||||
}
|
||||
|
||||
// Draw the actual answer
|
||||
var answerColor = Color.FromRgb((byte)rnd.Next(256), (byte)rnd.Next(256), (byte)rnd.Next(256));
|
||||
ctx.DrawText(answer, font, answerColor, new PointF(((size.Width - text_width) / 2) + rnd.Next(-ans_stray_x, ans_stray_x), ((size.Height - fontsize) / 2) + rnd.Next(-ans_stray_y, ans_stray_y)));
|
||||
});
|
||||
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user