Full rewrite of the portfolio site from Next.js 14 to .NET 10: - ASP.NET Core 10 Razor Pages, no Node.js dependency - EF Core 10 + SQLite (same schema as before — data survives upgrade) - Cookie authentication (same single-password model) - Resend contact form via HttpClient - Bilingual FA/EN via locale cookie + BasePageModel - All UI ported to Razor Pages with Tailwind CDN + custom CSS - Vanilla JS: particles, typewriter, cursor, animations, portfolio modal - Dockerfile: SDK 10.0-alpine → aspnet 10.0-alpine (no npm/Node needed) - CI/CD: dropped NPM_TOKEN, ADMIN_SESSION_SECRET — pure dotnet publish Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SoroushAsadi.Services;
|
||||
|
||||
/// <summary>Single-password authentication for the admin panel.</summary>
|
||||
public class AuthService(IConfiguration config, IWebHostEnvironment env)
|
||||
{
|
||||
private string? GetPassword()
|
||||
{
|
||||
var pw = config["ADMIN_PASSWORD"] ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD");
|
||||
if (!string.IsNullOrEmpty(pw)) return pw;
|
||||
// Allow "admin" in Development only
|
||||
return env.IsDevelopment() ? "admin" : null;
|
||||
}
|
||||
|
||||
/// <summary>True when the submitted password matches the configured one (constant-time).</summary>
|
||||
public bool VerifyPassword(string input)
|
||||
{
|
||||
var expected = GetPassword();
|
||||
if (expected is null) return false;
|
||||
|
||||
var a = SHA256Hash(input);
|
||||
var b = SHA256Hash(expected);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(a),
|
||||
Encoding.UTF8.GetBytes(b));
|
||||
}
|
||||
|
||||
public bool IsConfigured() => GetPassword() is not null;
|
||||
|
||||
private static string SHA256Hash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using SoroushAsadi.Data;
|
||||
|
||||
namespace SoroushAsadi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Merges hardcoded default content with admin overrides stored in SQLite.
|
||||
/// Sections are keyed by name ("hero", "services", etc.).
|
||||
/// The stored JSON is {"fa": {...}, "en": {...}} for bilingual sections,
|
||||
/// or a slug-keyed map for "posts".
|
||||
/// </summary>
|
||||
public class ContentService(AppDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _json =
|
||||
new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns the merged content for a section as a JsonNode.
|
||||
/// Callers get the locale-specific sub-object (e.g. node["en"]).</summary>
|
||||
public JsonNode? GetSection(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = db.ContentSections.Find(key);
|
||||
if (row is null) return null;
|
||||
return JsonNode.Parse(row.DataJson);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Returns merged bilingual content for the given section + locale.</summary>
|
||||
public JsonNode? GetSectionLocale(string key, string locale)
|
||||
{
|
||||
var node = GetSection(key);
|
||||
return node?[locale];
|
||||
}
|
||||
|
||||
/// <summary>Returns all section rows (for admin listing).</summary>
|
||||
public IReadOnlyList<string> GetSectionKeys() =>
|
||||
db.ContentSections.Select(s => s.Key).ToList();
|
||||
|
||||
/// <summary>Upserts a section's JSON data.</summary>
|
||||
public void SaveSection(string key, string json)
|
||||
{
|
||||
var row = db.ContentSections.Find(key);
|
||||
if (row is null)
|
||||
{
|
||||
row = new Models.ContentSection { Key = key };
|
||||
db.ContentSections.Add(row);
|
||||
}
|
||||
row.DataJson = json;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a section override (restores built-in default).</summary>
|
||||
public void DeleteSection(string key)
|
||||
{
|
||||
var row = db.ContentSections.Find(key);
|
||||
if (row is not null)
|
||||
{
|
||||
db.ContentSections.Remove(row);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Posts (stored under key "posts") ─────────────────────────────────
|
||||
|
||||
public const string PostsKey = "posts";
|
||||
|
||||
/// <summary>Returns the slug→PostContent map from DB, or empty.</summary>
|
||||
public Dictionary<string, JsonNode> GetPostOverrides()
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = db.ContentSections.Find(PostsKey);
|
||||
if (row is null) return [];
|
||||
var parsed = JsonNode.Parse(row.DataJson);
|
||||
if (parsed is not JsonObject obj) return [];
|
||||
return obj.ToDictionary(kv => kv.Key, kv => kv.Value!);
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/// <summary>Saves a single post override.</summary>
|
||||
public void SavePost(string slug, JsonNode content)
|
||||
{
|
||||
var row = db.ContentSections.Find(PostsKey);
|
||||
JsonObject obj;
|
||||
if (row is null)
|
||||
{
|
||||
obj = [];
|
||||
row = new Models.ContentSection { Key = PostsKey };
|
||||
db.ContentSections.Add(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
obj = JsonNode.Parse(row.DataJson) as JsonObject ?? [];
|
||||
}
|
||||
obj[slug] = content.DeepClone();
|
||||
row.DataJson = obj.ToJsonString();
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SoroushAsadi.Services;
|
||||
|
||||
public class EmailService(HttpClient http, IConfiguration config, ILogger<EmailService> logger)
|
||||
{
|
||||
private string? ApiKey => config["RESEND_API_KEY"] ?? Environment.GetEnvironmentVariable("RESEND_API_KEY");
|
||||
private string? Inbox => config["CONTACT_INBOX"] ?? Environment.GetEnvironmentVariable("CONTACT_INBOX");
|
||||
private string? From => config["CONTACT_FROM"] ?? Environment.GetEnvironmentVariable("CONTACT_FROM");
|
||||
|
||||
public record ContactForm(
|
||||
string Name, string Company, string Service,
|
||||
string Budget, string Message, string Locale);
|
||||
|
||||
/// <returns>null on success, error string on failure.</returns>
|
||||
public async Task<string?> SendContactAsync(ContactForm form)
|
||||
{
|
||||
if (ApiKey is null || Inbox is null || From is null)
|
||||
{
|
||||
logger.LogInformation("[contact] received (no Resend key — logging only): {Name}", form.Name);
|
||||
return null; // dev no-op
|
||||
}
|
||||
|
||||
var html = $"""
|
||||
<div style="font-family:ui-sans-serif,system-ui,sans-serif;line-height:1.55">
|
||||
<h2 style="margin:0 0 12px">New consultation request</h2>
|
||||
<table style="border-collapse:collapse">
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#475569">Name</td><td>{Esc(form.Name)}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#475569">Company</td><td>{Esc(form.Company)}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#475569">Service</td><td>{Esc(form.Service)}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#475569">Budget</td><td>{Esc(form.Budget)}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;color:#475569">Locale</td><td>{Esc(form.Locale)}</td></tr>
|
||||
</table>
|
||||
<h3 style="margin:20px 0 6px">Message</h3>
|
||||
<p style="white-space:pre-wrap;background:#f8fafc;padding:12px;border-radius:8px">{Esc(form.Message)}</p>
|
||||
</div>
|
||||
""";
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails");
|
||||
req.Headers.Add("Authorization", $"Bearer {ApiKey}");
|
||||
req.Content = JsonContent.Create(new
|
||||
{
|
||||
from = From,
|
||||
to = new[] { Inbox },
|
||||
subject = $"New consultation request — {form.Name}",
|
||||
html
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var res = await http.SendAsync(req);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
logger.LogError("[contact] Resend error {Status}: {Body}", res.StatusCode, body);
|
||||
return "Email service rejected the request.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[contact] Send failed");
|
||||
return "Email service unreachable.";
|
||||
}
|
||||
}
|
||||
|
||||
private static string Esc(string? s) => System.Web.HttpUtility.HtmlEncode(s ?? "");
|
||||
}
|
||||
Reference in New Issue
Block a user