1
0
This commit is contained in:
Debug_pro
2026-06-07 18:19:53 +03:00
commit fb13e5a20a
33 changed files with 2382 additions and 0 deletions

41
Services/EmailService.cs Normal file
View 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;
}
}

View 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)));
}
}

View 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>";
}
}

View 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);
}
}

View 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
View 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;
}
}