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
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:
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user