Add support page, admin features, and auth hardening.

Rename public affiliate route to /support with localized nav labels, and include affiliate/social/wallpaper admin, 2FA login, and related site updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Atakan Doğan Özban
2026-07-29 20:25:55 +02:00
co-authored by Cursor
parent 202dc16a52
commit fc45c35dbe
149 changed files with 3496 additions and 11263 deletions
+41
View File
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.Web;
namespace atakanozbancom.Models.classes
{
public class AdminAffiliateLinkVM
{
public int id { get; set; }
[Display(Name = "Title (EN)")]
[Required(ErrorMessage = "Title is required.")]
[StringLength(200)]
public string TitleEN { get; set; }
[Display(Name = "Description (EN)")]
[StringLength(500)]
public string DescEN { get; set; }
[Display(Name = "Title (TR)")]
[StringLength(200)]
public string TitleTR { get; set; }
[Display(Name = "Description (TR)")]
[StringLength(500)]
public string DescTR { get; set; }
[Display(Name = "URL")]
[Required(ErrorMessage = "URL is required.")]
[StringLength(1000)]
public string url { get; set; }
[Display(Name = "Sort Order")]
public int sort_order { get; set; }
// Path of the already-uploaded logo (used when editing)
public string existingImage { get; set; }
// New logo file (required on create, optional on edit to replace it)
public HttpPostedFileBase logoFile { get; set; }
}
}
+9 -9
View File
@@ -1,16 +1,16 @@
namespace atakanozbancom.Models.classes
using System.Collections.Generic;
namespace atakanozbancom.Models.classes
{
public class AdminMyProjectsEditVM
{
public int id { get; set; }
public string image { get; set; }
public string iframe { get; set; }
public string title { get; set; }
public string description { get; set; }
public string TitleEN { get; set; }
public string DescEN { get; set; }
public string TitleTR { get; set; }
public string TitleEN { get; set; }
public string DescTR { get; set; }
public string DescEN { get; set; }
// NEW:
public List<projectmedia> medias { get; set; } = new List<projectmedia>();
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace atakanozbancom.Models.classes
{
public class AdminSocialIconVM
{
public int id { get; set; }
[Display(Name = "Icon (Font Awesome class)")]
[Required(ErrorMessage = "Icon class is required.")]
[StringLength(100)]
public string icon { get; set; }
[Display(Name = "URL")]
[Required(ErrorMessage = "URL is required.")]
[StringLength(500)]
public string url { get; set; }
[Display(Name = "Sort Order")]
public int sort_order { get; set; }
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace atakanozbancom.Models.classes
{
public class AffiliateLinkVM
{
public int id { get; set; }
public string title { get; set; }
public string description { get; set; }
public string url { get; set; }
public string image { get; set; }
}
}
+5 -1
View File
@@ -13,7 +13,11 @@ namespace atakanozbancom.Models.classes
public DbSet<admin> admins { get; set; }
public DbSet<myprojects> MyProjects { get; set; }
public DbSet<myprojectstranslation> myprojectstranslations { get; set; }
public DbSet<projectmedia> projectmedias { get; set; }
public DbSet<projectmedia> projectmedia { get; set; }
public DbSet<affiliatelink> AffiliateLinks { get; set; }
public DbSet<affiliatelinktranslation> affiliatelinktranslations { get; set; }
public DbSet<socialicon> SocialIcons { get; set; }
public DbSet<sitesetting> SiteSettings { get; set; }
}
}
+47
View File
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
using System.Web;
namespace atakanozbancom.Models.classes
{
/// <summary>
/// Renders user-entered project/affiliate descriptions as safe HTML.
/// The whole input is HTML-encoded first (so no raw tags/scripts can ever
/// survive), then a very small Markdown-style link syntax is turned into
/// real anchor tags: [Link text](https://example.com)
/// Only http:// and https:// URLs are accepted; anything else is left as
/// plain (encoded) text.
/// </summary>
public static class DescriptionFormatter
{
// [label](https://url) - label can't contain "]", url must be http(s) and can't contain whitespace or ")"
private static readonly Regex LinkPattern = new Regex(
@"\[([^\]\r\n]{1,200})\]\((https?://[^\s)]{1,2000})\)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static string ToSafeHtml(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
// 1) Neutralize everything (script/tags/attributes) up front.
var encoded = HttpUtility.HtmlEncode(raw);
// 2) Re-introduce ONLY safe <a> links for the [text](url) syntax.
// Both groups are already HTML-encoded, so this can't break out
// of the href attribute or inject new tags/attributes.
var withLinks = LinkPattern.Replace(encoded, m =>
{
var label = m.Groups[1].Value;
var url = m.Groups[2].Value;
return $"<a href=\"{url}\" class=\"text-secondary text-decoration-none\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">{label}</a>";
});
// 3) Preserve line breaks the admin typed in the textarea.
var withBreaks = withLinks
.Replace("\r\n", "\n")
.Replace("\n", "<br />");
return withBreaks;
}
}
}
+17 -2
View File
@@ -1,12 +1,27 @@
namespace atakanozbancom.Models.classes
using System.Collections.Generic;
using System.Web.Mvc;
namespace atakanozbancom.Models.classes
{
public class MyProjectsVM
{
public int id { get; set; }
public string title { get; set; }
[AllowHtml]
public string description { get; set; }
// legacy (şimdilik kalsın)
public string image { get; set; }
public string iframe { get; set; }
// NEW
public List<ProjectMediaVM> medias { get; set; } = new List<ProjectMediaVM>();
}
}
public class ProjectMediaVM
{
public string type { get; set; }
public string url { get; set; }
public int order { get; set; }
}
}
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace atakanozbancom.Models.classes
{
public static class PasswordHasher
{
private const string Prefix = "pbkdf2";
private const int DefaultIterations = 100000;
private const int SaltSize = 16;
private const int HashSize = 32;
public static string Hash(string password)
{
if (password == null)
throw new ArgumentNullException("password");
var salt = new byte[SaltSize];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(salt);
}
var hash = Pbkdf2(password, salt, DefaultIterations, HashSize);
return string.Format(
"{0}${1}${2}${3}",
Prefix,
DefaultIterations,
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public static bool IsHashed(string stored)
{
return !string.IsNullOrWhiteSpace(stored)
&& stored.StartsWith(Prefix + "$", StringComparison.Ordinal);
}
public static bool Verify(string password, string stored)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(stored))
return false;
if (!IsHashed(stored))
return FixedTimeEquals(password, stored);
var parts = stored.Split('$');
if (parts.Length != 4 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal))
return false;
int iterations;
if (!int.TryParse(parts[1], out iterations) || iterations < 1000)
return false;
byte[] salt;
byte[] expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch
{
return false;
}
var actual = Pbkdf2(password, salt, iterations, expected.Length);
return FixedTimeEquals(actual, expected);
}
private static byte[] Pbkdf2(string password, byte[] salt, int iterations, int length)
{
using (var derive = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256))
{
return derive.GetBytes(length);
}
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
private static bool FixedTimeEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace atakanozbancom.Models.classes
{
public class ProjectMediaVM
{
public int id { get; set; }
public int projectid { get; set; }
public string mediatype { get; set; } // "image" or "iframe"
public string mediaurl { get; set; }
public int sortorder { get; set; }
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Text;
using System.Web.Security;
namespace atakanozbancom.Models.classes
{
public static class SecretProtector
{
private static readonly string[] Purposes = { "atakanozbancom.TotpSecret.v1" };
public static string Protect(string plaintext)
{
if (string.IsNullOrEmpty(plaintext))
return plaintext;
var bytes = Encoding.UTF8.GetBytes(plaintext);
var protectedBytes = MachineKey.Protect(bytes, Purposes);
return Convert.ToBase64String(protectedBytes);
}
public static string Unprotect(string protectedValue)
{
if (string.IsNullOrEmpty(protectedValue))
return protectedValue;
try
{
var protectedBytes = Convert.FromBase64String(protectedValue);
var bytes = MachineKey.Unprotect(protectedBytes, Purposes);
return bytes == null ? null : Encoding.UTF8.GetString(bytes);
}
catch
{
return null;
}
}
public static bool LooksProtected(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
var bytes = Convert.FromBase64String(value);
return bytes.Length > 16;
}
catch
{
return false;
}
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Web;
namespace atakanozbancom.Models.classes
{
public static class TotpHelper
{
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static string GenerateSecret(int byteLength = 20)
{
var bytes = new byte[byteLength];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
return ToBase32(bytes);
}
public static bool VerifyCode(string base32Secret, string code, int window = 1)
{
if (string.IsNullOrWhiteSpace(base32Secret) || string.IsNullOrWhiteSpace(code))
return false;
code = code.Trim().Replace(" ", "");
if (code.Length != 6)
return false;
long timestep;
try
{
timestep = GetCurrentTimeStep();
}
catch
{
return false;
}
for (var i = -window; i <= window; i++)
{
var expected = ComputeTotp(base32Secret, timestep + i);
if (FixedTimeEquals(expected, code))
return true;
}
return false;
}
public static string BuildOtpAuthUri(string issuer, string accountName, string base32Secret)
{
var label = HttpUtility.UrlEncode(issuer + ":" + accountName);
var issuerParam = HttpUtility.UrlEncode(issuer);
return string.Format(
CultureInfo.InvariantCulture,
"otpauth://totp/{0}?secret={1}&issuer={2}&digits=6&period=30",
label,
base32Secret,
issuerParam);
}
private static long GetCurrentTimeStep()
{
return (long)Math.Floor((DateTime.UtcNow - UnixEpoch).TotalSeconds / 30.0);
}
private static string ComputeTotp(string base32Secret, long timestep)
{
var key = FromBase32(base32Secret);
var counter = BitConverter.GetBytes(timestep);
if (BitConverter.IsLittleEndian)
Array.Reverse(counter);
byte[] hash;
using (var hmac = new HMACSHA1(key))
{
hash = hmac.ComputeHash(counter);
}
var offset = hash[hash.Length - 1] & 0x0F;
var binary =
((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
var otp = binary % 1000000;
return otp.ToString("D6", CultureInfo.InvariantCulture);
}
private static string ToBase32(byte[] data)
{
if (data == null || data.Length == 0)
return string.Empty;
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = data[0];
var next = 1;
var bitsLeft = 8;
while (bitsLeft > 0 || next < data.Length)
{
if (bitsLeft < 5)
{
if (next < data.Length)
{
buffer <<= 8;
buffer |= data[next++] & 0xFF;
bitsLeft += 8;
}
else
{
var pad = 5 - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
var index = (buffer >> (bitsLeft - 5)) & 0x1F;
bitsLeft -= 5;
sb.Append(Base32Alphabet[index]);
}
return sb.ToString();
}
private static byte[] FromBase32(string input)
{
var cleaned = input.Trim().Replace(" ", "").Replace("=", "").ToUpperInvariant();
var output = new byte[cleaned.Length * 5 / 8];
var bitBuffer = 0;
var bitsLeft = 0;
var index = 0;
foreach (var c in cleaned)
{
var val = Base32Alphabet.IndexOf(c);
if (val < 0)
throw new FormatException("Invalid Base32 character.");
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8)
{
output[index++] = (byte)((bitBuffer >> (bitsLeft - 8)) & 0xFF);
bitsLeft -= 8;
}
}
if (index != output.Length)
{
var trimmed = new byte[index];
Array.Copy(output, trimmed, index);
return trimmed;
}
return output;
}
private static bool FixedTimeEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}
}
+24 -2
View File
@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
@@ -9,6 +11,26 @@ namespace atakanozbancom.Models.classes
public int id { get; set; }
public string username { get; set; }
public string password { get; set; }
[Column("two_factor_enabled")]
public bool two_factor_enabled { get; set; }
[Column("two_factor_secret")]
[StringLength(512)]
public string two_factor_secret { get; set; }
[Column("login_failed_count")]
public int login_failed_count { get; set; }
[Column("login_lock_until")]
public DateTime? login_lock_until { get; set; }
[Column("totp_failed_count")]
public int totp_failed_count { get; set; }
[Column("totp_lock_until")]
public DateTime? totp_lock_until { get; set; }
public List<myprojects> MyProjects { get; set; }
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
[Table("affiliatelinks")]
public class affiliatelink
{
[Key]
public int id { get; set; }
[Required, StringLength(500)]
[Column("image")]
public string image { get; set; }
[Required, StringLength(200)]
[Column("title")]
public string title { get; set; }
[StringLength(500)]
[Column("description")]
public string description { get; set; }
[Required, StringLength(1000)]
[Column("url")]
public string url { get; set; }
[Column("sortorder")]
public int sort_order { get; set; } = 0;
public virtual ICollection<affiliatelinktranslation> Translations { get; set; }
}
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
[Table("affiliatelinktranslations")]
public class affiliatelinktranslation
{
public int id { get; set; }
[Required]
public int affiliatelink_id { get; set; }
[Required, StringLength(10)]
public string culture { get; set; }
[StringLength(255)]
public string title { get; set; }
public string description { get; set; }
[ForeignKey("affiliatelink_id")]
public virtual affiliatelink AffiliateLink { get; set; }
}
}
-4
View File
@@ -13,10 +13,6 @@ namespace atakanozbancom.Models.classes
public string title { get; set; }
[AllowHtml]
public string description { get; set; }
public string TitleEN { get; set; }
public string DescEN { get; set; }
public string TitleTR { get; set; }
public string DescTR { get; set; }
public object admins { get; internal set; }
public virtual ICollection<myprojectstranslation> Translations { get; set; }
+39
View File
@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
public enum MediaType
{
Image = 1,
Iframe = 2,
// Video = 0,
//Document = 3
}
[Table("projectmedia")]
public class projectmedia
{
[Key]
public int id { get; set; }
[Column("projectid")]
public int project_id { get; set; }
[ForeignKey(nameof(project_id))]
public virtual myprojects Project { get; set; }
[Column("mediatype")]
public MediaType media_type { get; set; }
[Required, StringLength(1000)]
[Column("mediaurl")]
public string url { get; set; }
[Column("sortorder")]
public int sort_order { get; set; } = 0;
// DB'de bu kolon yok → modelden kaldırıyoruz
// public string title { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
[Table("sitesettings")]
public class sitesetting
{
[Key]
public int id { get; set; }
// Public URL of the current wallpaper. Null/empty falls back to the bundled default.
[StringLength(500)]
[Column("wallpaper")]
public string wallpaper { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace atakanozbancom.Models.classes
{
[Table("socialicons")]
public class socialicon
{
[Key]
public int id { get; set; }
// Font Awesome class(es), e.g. "fa-brands fa-youtube"
[Required, StringLength(100)]
[Column("icon")]
public string icon { get; set; }
[Required, StringLength(500)]
[Column("url")]
public string url { get; set; }
[Column("sortorder")]
public int sort_order { get; set; } = 0;
}
}