upload
This commit is contained in:
41
Services/EmailService.cs
Normal file
41
Services/EmailService.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using SimpleWiki.Settings;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class EmailService
|
||||
{
|
||||
public static string SendPasswordResetCode(string toEmail)
|
||||
{
|
||||
if (!SmtpSettings.IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException("SMTP не настроен. Заполни Settings/SmtpSettings.cs.");
|
||||
}
|
||||
|
||||
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
|
||||
|
||||
using var client = new SmtpClient(SmtpSettings.Host, SmtpSettings.Port)
|
||||
{
|
||||
EnableSsl = SmtpSettings.EnableSsl,
|
||||
Credentials = new NetworkCredential(SmtpSettings.Email, SmtpSettings.Password)
|
||||
};
|
||||
|
||||
using var message = new MailMessage
|
||||
{
|
||||
From = new MailAddress(SmtpSettings.Email, SmtpSettings.DisplayName),
|
||||
Subject = "Код подтверждения для смены пароля",
|
||||
Body = $"<h1 style='font-family:Segoe UI'>Код подтверждения: {code}</h1><p>Если это были не вы, просто проигнорируйте письмо.</p>",
|
||||
IsBodyHtml = true,
|
||||
BodyEncoding = Encoding.UTF8,
|
||||
SubjectEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
message.To.Add(toEmail);
|
||||
client.Send(message);
|
||||
|
||||
return code;
|
||||
}
|
||||
}
|
||||
66
Services/InputValidator.cs
Normal file
66
Services/InputValidator.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class InputValidator
|
||||
{
|
||||
private static readonly Regex EmailRegex = new(@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$", RegexOptions.Compiled);
|
||||
private static readonly Regex FullNameRegex = new(@"^[A-Za-zА-Яа-яЁё\-\s]{2,100}$", RegexOptions.Compiled);
|
||||
private static readonly Regex PasswordRegex = new(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,64}$", RegexOptions.Compiled);
|
||||
private static readonly Regex CodeRegex = new(@"^\d{6}$", RegexOptions.Compiled);
|
||||
|
||||
public static string? ValidateFullName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "ФИО обязательно.";
|
||||
if (!FullNameRegex.IsMatch(value.Trim())) return "ФИО: только буквы, пробелы и дефис, от 2 до 100 символов.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Email обязателен.";
|
||||
if (value.Length > 256) return "Email слишком длинный.";
|
||||
if (!EmailRegex.IsMatch(value.Trim())) return "Некорректный email.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidatePassword(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Пароль обязателен.";
|
||||
if (!PasswordRegex.IsMatch(value)) return "Пароль: 8-64 символа, минимум 1 строчная, 1 заглавная, 1 цифра и 1 спецсимвол.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateResetCode(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Код подтверждения обязателен.";
|
||||
if (!CodeRegex.IsMatch(value.Trim())) return "Код должен состоять из 6 цифр.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateArticleTitle(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Заголовок обязателен.";
|
||||
if (value.Trim().Length < 3 || value.Trim().Length > 150) return "Заголовок: от 3 до 150 символов.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateArticleSummary(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Краткое описание обязательно.";
|
||||
if (value.Trim().Length < 10 || value.Trim().Length > 400) return "Краткое описание: от 10 до 400 символов.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateArticleMarkdown(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "Текст статьи обязателен.";
|
||||
if (value.Trim().Length < 20 || value.Trim().Length > 20000) return "Текст статьи: от 20 до 20000 символов.";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string JoinErrors(params string?[] errors)
|
||||
{
|
||||
return string.Join(Environment.NewLine, errors.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
}
|
||||
}
|
||||
67
Services/MarkdownService.cs
Normal file
67
Services/MarkdownService.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Markdig;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class MarkdownService
|
||||
{
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
|
||||
public static string ToHtmlDocument(string title, string markdown)
|
||||
{
|
||||
var htmlBody = Markdown.ToHtml(markdown ?? string.Empty, Pipeline);
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html style='background:#1E1E2E; color:#CDD6F4;'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='color-scheme' content='dark' />
|
||||
<style>
|
||||
html, body {{
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 100%;
|
||||
background: #1E1E2E !important;
|
||||
background-color: #1E1E2E !important;
|
||||
color: #CDD6F4 !important;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}}
|
||||
body {{
|
||||
box-sizing: border-box;
|
||||
padding: 0 !important;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
.page {{
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #1E1E2E !important;
|
||||
color: #CDD6F4 !important;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
h1, h2, h3, h4, h5, h6 {{ color: #B4BEFE !important; margin-top: 0; }}
|
||||
p, ul, ol, blockquote, table, pre {{ margin-top: 0; margin-bottom: 16px; color: #CDD6F4; }}
|
||||
li, span, div {{ color: inherit; }}
|
||||
a {{ color: #89B4FA !important; }}
|
||||
a:visited {{ color: #B4BEFE !important; }}
|
||||
code {{ background: #11111B; color: #F5E0DC; padding: 3px 6px; border-radius: 8px; }}
|
||||
pre {{ background: #11111B; color: #F5E0DC; padding: 14px 16px; border-radius: 12px; overflow-x: auto; }}
|
||||
pre code {{ background: transparent; padding: 0; }}
|
||||
blockquote {{ border-left: 4px solid #89B4FA; padding-left: 12px; color: #A6ADC8 !important; }}
|
||||
table {{ border-collapse: collapse; width: 100%; background: #181825; border-radius: 12px; overflow: hidden; }}
|
||||
th, td {{ border: 1px solid #45475A; padding: 10px 12px; text-align: left; color: #CDD6F4; }}
|
||||
th {{ background: #313244; color: #CDD6F4; }}
|
||||
hr {{ border: none; border-top: 1px solid #45475A; margin: 24px 0; }}
|
||||
img {{ max-width: 100%; height: auto; border-radius: 10px; }}
|
||||
</style>
|
||||
<title>{System.Net.WebUtility.HtmlEncode(title)}</title>
|
||||
</head>
|
||||
<body bgcolor='#1E1E2E' text='#CDD6F4'>
|
||||
<div class='page'>
|
||||
{htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
77
Services/PasswordHasher.cs
Normal file
77
Services/PasswordHasher.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
private const int Iterations = 3;
|
||||
private const int MemorySizeKb = 65536;
|
||||
private const int Parallelism = 1;
|
||||
private const int SaltLength = 16;
|
||||
private const int HashLength = 32;
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
throw new ArgumentException("Пароль пустой.");
|
||||
}
|
||||
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltLength);
|
||||
|
||||
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
Iterations = Iterations,
|
||||
DegreeOfParallelism = Parallelism,
|
||||
MemorySize = MemorySizeKb
|
||||
};
|
||||
|
||||
var hash = argon2.GetBytes(HashLength);
|
||||
var combined = new byte[SaltLength + HashLength];
|
||||
|
||||
Buffer.BlockCopy(salt, 0, combined, 0, SaltLength);
|
||||
Buffer.BlockCopy(hash, 0, combined, SaltLength, HashLength);
|
||||
|
||||
return Convert.ToBase64String(combined);
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string password, string hashPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(hashPassword))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] combined;
|
||||
try
|
||||
{
|
||||
combined = Convert.FromBase64String(hashPassword);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (combined.Length < SaltLength + HashLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var salt = combined.Take(SaltLength).ToArray();
|
||||
var originalHash = combined.Skip(SaltLength).Take(HashLength).ToArray();
|
||||
|
||||
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
Iterations = Iterations,
|
||||
DegreeOfParallelism = Parallelism,
|
||||
MemorySize = MemorySizeKb
|
||||
};
|
||||
|
||||
var newHash = argon2.GetBytes(HashLength);
|
||||
return CryptographicOperations.FixedTimeEquals(originalHash, newHash);
|
||||
}
|
||||
}
|
||||
8
Services/SessionService.cs
Normal file
8
Services/SessionService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using SimpleWiki.Models;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class SessionService
|
||||
{
|
||||
public static User? CurrentUser { get; set; }
|
||||
}
|
||||
34
Services/SlugHelper.cs
Normal file
34
Services/SlugHelper.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SimpleWiki.Services;
|
||||
|
||||
public static class SlugHelper
|
||||
{
|
||||
public static string Generate(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = title.Trim().ToLowerInvariant();
|
||||
normalized = normalized.Replace('ё', 'е');
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var ch in normalized)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
else if (char.IsWhiteSpace(ch) || ch == '-' || ch == '_')
|
||||
{
|
||||
sb.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
var slug = Regex.Replace(sb.ToString(), "-+", "-").Trim('-');
|
||||
return string.IsNullOrWhiteSpace(slug) ? $"article-{DateTime.UtcNow:yyyyMMddHHmmss}" : slug;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user