feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s

AI SEO content generator
- content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints
  (settings GET/PUT, seo-post) with SEO-expert prompt → structured article
- admin UI to configure token/base-url/model and generate + save as blog
- configurable base URL for restricted networks

Full data-driven admin panel
- generic /api/admin/resource proxy + reusable AdminResource component
- categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides
- AI content section; nav + i18n

i18n localization sweep
- localized 116 user-facing + studio/editor components to next-intl (fa+en)
  under the auto.* namespace; merge tooling in scripts/merge-i18n.js

Branding + assets
- Monoline F logo (LogoMark + favicon)
- offline SVG placeholder generator (/api/placeholder), dropped picsum.photos

Fixes
- JWT issuer mismatch on content/studio (flatrender → flatrender-identity)
- missing role claim → [Authorize(Roles="Admin")] now works (RBAC)
- Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE
- Radix RTL via DirectionProvider (right-aligned menus in fa)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 09:35:14 +03:30
parent bcc69f0a2e
commit 3fc7bf2b97
160 changed files with 4397 additions and 767 deletions
@@ -0,0 +1,177 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
/// <summary>Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller.</summary>
public class AiConfigException(string message) : Exception(message);
public class AiContentService(ContentDbContext db, IHttpClientFactory httpFactory)
{
public static readonly Guid DefaultTenant = Guid.Parse("00000000-0000-0000-0000-000000000001");
// ── Settings ──────────────────────────────────────────────────────────────
public async Task<AiSettings> GetRawAsync(Guid tenantId)
{
return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId)
?? new AiSettings { TenantId = tenantId };
}
public async Task<AiSettingsResponse> GetSettingsAsync(Guid tenantId)
{
var s = await GetRawAsync(tenantId);
var key = s.ApiKey;
var has = !string.IsNullOrWhiteSpace(key);
var masked = has ? $"••••••••{key![Math.Max(0, key.Length - 4)..]}" : null;
return new AiSettingsResponse(s.Provider, s.BaseUrl, s.Model, s.Enabled, has, masked,
s.UpdatedAt == default ? null : s.UpdatedAt);
}
public async Task<AiSettingsResponse> UpdateSettingsAsync(Guid tenantId, UpdateAiSettingsRequest req)
{
var s = await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId);
var isNew = s is null;
s ??= new AiSettings { TenantId = tenantId };
if (req.Provider is { } p) s.Provider = p;
if (req.BaseUrl is { } b && !string.IsNullOrWhiteSpace(b)) s.BaseUrl = b.TrimEnd('/');
if (req.Model is { } m && !string.IsNullOrWhiteSpace(m)) s.Model = m;
if (req.Enabled is { } e) s.Enabled = e;
// ApiKey: null = leave unchanged; non-null (incl. "") = set/clear.
if (req.ApiKey is not null) s.ApiKey = string.IsNullOrWhiteSpace(req.ApiKey) ? null : req.ApiKey.Trim();
s.UpdatedAt = DateTime.UtcNow;
if (isNew) db.AiSettings.Add(s);
await db.SaveChangesAsync();
return await GetSettingsAsync(tenantId);
}
// ── Generation ──────────────────────────────────────────────────────────────
public async Task<SeoPostResponse> GenerateSeoPostAsync(Guid tenantId, GenerateSeoPostRequest req, CancellationToken ct)
{
var s = await GetRawAsync(tenantId);
if (!s.Enabled) throw new AiConfigException("AI generation is disabled. Enable it in AI settings.");
if (string.IsNullOrWhiteSpace(s.ApiKey)) throw new AiConfigException("No OpenAI API key configured. Add one in AI settings.");
if (string.IsNullOrWhiteSpace(req.Description)) throw new AiConfigException("A description is required.");
var locale = (req.Locale ?? "fa").ToLowerInvariant();
var langName = locale == "en" ? "English" : "Persian (Farsi)";
var system =
"You are a senior SEO content strategist and copywriter. Given a product/page description and metadata, " +
"write an original, engaging, well-structured, SEO-optimized article. " +
"Return ONLY a single valid JSON object (no markdown, no code fences) with EXACTLY these keys: " +
"title, slug, meta_title, meta_description, keywords, short_description, content_html. " +
"Rules: " +
$"write all human-readable text in {langName}; " +
"slug must be a short lowercase ASCII (a-z, 0-9, hyphens) URL slug derived from the topic, even when the article is in Persian; " +
"meta_title <= 60 characters; meta_description <= 160 characters and compelling; " +
"keywords = array of 5-8 relevant search keywords; " +
"short_description = 1-2 sentence summary; " +
"content_html = semantic HTML using <h2>, <h3>, <p>, <ul><li>, <strong> (no <html>/<body>/<h1>), 500-900 words, " +
"naturally incorporating the keywords, with a short intro, scannable sections, and a closing call to action.";
var sb = new StringBuilder();
sb.AppendLine("Write an SEO article for the following:");
if (!string.IsNullOrWhiteSpace(req.Title)) sb.AppendLine($"Working title: {req.Title}");
if (!string.IsNullOrWhiteSpace(req.Type)) sb.AppendLine($"Content type: {req.Type}");
if (req.Tags is { Length: > 0 }) sb.AppendLine($"Tags: {string.Join(", ", req.Tags)}");
if (!string.IsNullOrWhiteSpace(req.Keyword)) sb.AppendLine($"Primary target keyword: {req.Keyword}");
if (!string.IsNullOrWhiteSpace(req.Audience)) sb.AppendLine($"Target audience: {req.Audience}");
sb.AppendLine($"Description / brief:\n{req.Description}");
var payload = new
{
model = s.Model,
messages = new object[]
{
new { role = "system", content = system },
new { role = "user", content = sb.ToString() },
},
temperature = 0.7,
response_format = new { type = "json_object" },
};
var http = httpFactory.CreateClient("openai");
http.Timeout = TimeSpan.FromSeconds(90);
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{s.BaseUrl.TrimEnd('/')}/chat/completions")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
};
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.ApiKey);
HttpResponseMessage resp;
try { resp = await http.SendAsync(msg, ct); }
catch (Exception ex) { throw new AiConfigException($"Could not reach the AI provider: {ex.Message}"); }
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new AiConfigException($"AI provider returned {(int)resp.StatusCode}: {Truncate(body, 300)}");
string contentJson;
try
{
using var doc = JsonDocument.Parse(body);
contentJson = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? "";
}
catch (Exception ex) { throw new AiConfigException($"Unexpected AI response shape: {ex.Message}"); }
return ParsePost(contentJson, req);
}
private static SeoPostResponse ParsePost(string contentJson, GenerateSeoPostRequest req)
{
try
{
using var doc = JsonDocument.Parse(contentJson);
var r = doc.RootElement;
string Str(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
string[] Keywords()
{
if (r.TryGetProperty("keywords", out var v))
{
if (v.ValueKind == JsonValueKind.Array)
return v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!).ToArray();
if (v.ValueKind == JsonValueKind.String)
return v.GetString()!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
return [];
}
var title = Str("title");
if (string.IsNullOrWhiteSpace(title)) title = req.Title ?? "Untitled";
var slug = Slugify(Str("slug"));
if (string.IsNullOrWhiteSpace(slug)) slug = Slugify(req.Keyword ?? title);
if (string.IsNullOrWhiteSpace(slug)) slug = "post";
return new SeoPostResponse(
title,
slug,
Str("meta_title") is { Length: > 0 } mt ? mt : title,
Str("meta_description"),
Keywords(),
Str("short_description"),
Str("content_html")
);
}
catch (Exception ex) { throw new AiConfigException($"Could not parse AI content as JSON: {ex.Message}"); }
}
private static string Slugify(string s)
{
if (string.IsNullOrWhiteSpace(s)) return "";
s = s.Trim().ToLowerInvariant();
s = Regex.Replace(s, @"[^a-z0-9]+", "-").Trim('-');
return s.Length > 80 ? s[..80].Trim('-') : s;
}
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";
}
@@ -0,0 +1,51 @@
using System.Security.Claims;
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/ai")]
[Authorize]
public class AiController(AiContentService svc) : ControllerBase
{
private Guid TenantId =>
Guid.TryParse(User.FindFirstValue("tenant_id"), out var t) ? t : AiContentService.DefaultTenant;
private bool IsAdmin =>
string.Equals(User.FindFirstValue("is_admin"), "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(User.FindFirstValue("is_tenant_admin"), "true", StringComparison.OrdinalIgnoreCase);
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
if (!IsAdmin) return Forbidden();
return Ok(await svc.GetSettingsAsync(TenantId));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateAiSettingsRequest req)
{
if (!IsAdmin) return Forbidden();
return Ok(await svc.UpdateSettingsAsync(TenantId, req));
}
[HttpPost("seo-post")]
public async Task<IActionResult> GenerateSeoPost([FromBody] GenerateSeoPostRequest req, CancellationToken ct)
{
if (!IsAdmin) return Forbidden();
try
{
return Ok(await svc.GenerateSeoPostAsync(TenantId, req, ct));
}
catch (AiConfigException ex)
{
return BadRequest(new { error = new { code = "ai_error", message = ex.Message } });
}
}
private IActionResult Forbidden() =>
StatusCode(403, new { error = new { code = "forbidden", message = "Admin access required." } });
}
@@ -0,0 +1,13 @@
namespace FlatRender.ContentSvc.Domain.Entities;
/// <summary>Per-tenant OpenAI (or OpenAI-compatible) configuration for the AI content generator.</summary>
public class AiSettings
{
public Guid TenantId { get; set; }
public string Provider { get; set; } = "openai";
public string? ApiKey { get; set; }
public string BaseUrl { get; set; } = "https://api.openai.com/v1";
public string Model { get; set; } = "gpt-4o-mini";
public bool Enabled { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -58,6 +58,9 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
public DbSet<FavoriteFolder> FavoriteFolders => Set<FavoriteFolder>();
public DbSet<FavoriteContainer> FavoriteContainers => Set<FavoriteContainer>();
// AI
public DbSet<AiSettings> AiSettings => Set<AiSettings>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("content");
@@ -70,6 +73,13 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
ConfigureScenes(mb);
ConfigureCharacters(mb);
ConfigureCms(mb);
// AI settings — snake_case convention maps columns (tenant_id, api_key, …).
mb.Entity<AiSettings>(e =>
{
e.ToTable("ai_settings");
e.HasKey(x => x.TenantId);
});
}
private static void ConfigureTaxonomy(ModelBuilder mb)
@@ -0,0 +1,44 @@
namespace FlatRender.ContentSvc.Models;
// ── AI settings ───────────────────────────────────────────────────────────────
/// <summary>Settings returned to the admin UI. The API key is never returned in full.</summary>
public record AiSettingsResponse(
string Provider,
string BaseUrl,
string Model,
bool Enabled,
bool HasApiKey,
string? ApiKeyMasked,
DateTime? UpdatedAt
);
public record UpdateAiSettingsRequest(
string? Provider,
string? ApiKey, // null = leave unchanged; "" = clear
string? BaseUrl,
string? Model,
bool? Enabled
);
// ── SEO post generation ─────────────────────────────────────────────────────
public record GenerateSeoPostRequest(
string Description,
string? Title,
string? Type, // e.g. "video template", "image template", "product"
string[]? Tags,
string? Locale, // "fa" (default) or "en"
string? Audience, // optional target-audience hint
string? Keyword // optional primary keyword to target
);
public record SeoPostResponse(
string Title,
string Slug,
string MetaTitle,
string MetaDescription,
string[] Keywords,
string ShortDescription,
string ContentHtml
);
@@ -50,7 +50,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
// The token's "role" claim is auto-mapped to ClaimTypes.Role by the default
// inbound claim mapping, which is what [Authorize(Roles = "Admin")] reads.
};
});
@@ -61,6 +63,10 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<TaxonomyService>();
builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
builder.Services.AddScoped<AiContentService>();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
builder.Services.AddHttpClient("openai");
// ── HTTP ──────────────────────────────────────────────────────────────────────
+1
View File
@@ -115,6 +115,7 @@ func main() {
v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/comments/*path", apiRL, auth, content.Handler())
v1.Any("/favorites/*path", apiRL, auth, content.Handler())
v1.Any("/ai/*path", apiRL, auth, content.Handler())
// ── File Service ─────────────────────────────────────────────────────────
v1.Any("/files/*path", apiRL, auth, file.Handler())
@@ -21,6 +21,9 @@ public class TokenService(IConfiguration config) : ITokenService
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// Role claim drives [Authorize(Roles = "...")] in the other services.
var role = user.IsAdmin ? "Admin" : user.IsTenantAdmin ? "TenantAdmin" : "User";
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
@@ -29,6 +32,7 @@ public class TokenService(IConfiguration config) : ITokenService
new("tenant_slug", tenant.Slug),
new("is_admin", user.IsAdmin.ToString().ToLower()),
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
new("role", role),
};
if (!string.IsNullOrEmpty(user.Email))
@@ -39,7 +39,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
ClockSkew = TimeSpan.FromSeconds(30),
};
});