90ac0b81d1
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
295 lines
13 KiB
C#
295 lines
13 KiB
C#
using FlatRender.IdentitySvc.Application.Services.Interfaces;
|
|
using FlatRender.IdentitySvc.Domain.Entities;
|
|
using FlatRender.IdentitySvc.Domain.Enums;
|
|
using FlatRender.IdentitySvc.Infrastructure.Data;
|
|
using FlatRender.IdentitySvc.Models.Requests;
|
|
using FlatRender.IdentitySvc.Models.Responses;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace FlatRender.IdentitySvc.Application.Services;
|
|
|
|
public class TenantService(IdentityDbContext db, ITokenService tokenService) : ITenantService
|
|
{
|
|
public async Task<PagedResponse<TenantResponse>> ListAsync(int page, int pageSize)
|
|
{
|
|
var total = await db.Tenants.LongCountAsync(t => t.DeletedAt == null);
|
|
var tenants = await db.Tenants
|
|
.Where(t => t.DeletedAt == null)
|
|
.OrderBy(t => t.CreatedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
return new PagedResponse<TenantResponse>(
|
|
tenants.Select(AuthService.MapTenantResponse).ToList(),
|
|
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
|
|
);
|
|
}
|
|
|
|
public async Task<TenantResponse> CreateAsync(CreateTenantRequest request)
|
|
{
|
|
var existing = await db.Tenants.AnyAsync(t => t.Slug == request.Slug && t.DeletedAt == null);
|
|
if (existing) throw new InvalidOperationException("Slug already taken");
|
|
|
|
var kind = Enum.TryParse<TenantKind>(request.Kind, true, out var k) ? k : TenantKind.Reseller;
|
|
var tenant = new Tenant
|
|
{
|
|
Slug = request.Slug.ToLower(),
|
|
Name = request.Name,
|
|
Kind = kind,
|
|
ContactName = request.ContactName,
|
|
ContactEmail = request.ContactEmail,
|
|
ContactPhone = request.ContactPhone,
|
|
};
|
|
db.Tenants.Add(tenant);
|
|
await db.SaveChangesAsync();
|
|
return AuthService.MapTenantResponse(tenant);
|
|
}
|
|
|
|
public async Task<TenantResponse> GetByIdAsync(Guid tenantId)
|
|
{
|
|
var tenant = await db.Tenants.FindAsync(tenantId)
|
|
?? throw new KeyNotFoundException("Tenant not found");
|
|
return AuthService.MapTenantResponse(tenant);
|
|
}
|
|
|
|
public async Task<TenantResponse> GetBySlugAsync(string slug)
|
|
{
|
|
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == slug && t.DeletedAt == null)
|
|
?? throw new KeyNotFoundException("Tenant not found");
|
|
return AuthService.MapTenantResponse(tenant);
|
|
}
|
|
|
|
public async Task<TenantResponse> UpdateAsync(Guid tenantId, UpdateTenantRequest request)
|
|
{
|
|
var tenant = await db.Tenants.FindAsync(tenantId)
|
|
?? throw new KeyNotFoundException("Tenant not found");
|
|
|
|
if (request.Name != null) tenant.Name = request.Name;
|
|
if (request.ContactName != null) tenant.ContactName = request.ContactName;
|
|
if (request.ContactEmail != null) tenant.ContactEmail = request.ContactEmail;
|
|
if (request.ContactPhone != null) tenant.ContactPhone = request.ContactPhone;
|
|
if (request.BillingEmail != null) tenant.BillingEmail = request.BillingEmail;
|
|
if (request.AllowedOrigins != null) tenant.AllowedOrigins = request.AllowedOrigins;
|
|
|
|
tenant.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return AuthService.MapTenantResponse(tenant);
|
|
}
|
|
|
|
public async Task<TenantBrandingResponse> GetBrandingAsync(Guid tenantId)
|
|
{
|
|
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId)
|
|
?? new TenantBranding { TenantId = tenantId };
|
|
return MapBrandingResponse(branding);
|
|
}
|
|
|
|
public async Task<TenantBrandingResponse> UpsertBrandingAsync(Guid tenantId, TenantBrandingRequest request)
|
|
{
|
|
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId);
|
|
if (branding == null)
|
|
{
|
|
branding = new TenantBranding { TenantId = tenantId };
|
|
db.TenantBrandings.Add(branding);
|
|
}
|
|
|
|
if (request.DisplayName != null) branding.DisplayName = request.DisplayName;
|
|
if (request.LogoUrl != null) branding.LogoUrl = request.LogoUrl;
|
|
if (request.LogoDarkUrl != null) branding.LogoDarkUrl = request.LogoDarkUrl;
|
|
if (request.FaviconUrl != null) branding.FaviconUrl = request.FaviconUrl;
|
|
if (request.OgImageUrl != null) branding.OgImageUrl = request.OgImageUrl;
|
|
if (request.PrimaryColor != null) branding.PrimaryColor = request.PrimaryColor;
|
|
if (request.SecondaryColor != null) branding.SecondaryColor = request.SecondaryColor;
|
|
if (request.AccentColor != null) branding.AccentColor = request.AccentColor;
|
|
if (request.BackgroundColor != null) branding.BackgroundColor = request.BackgroundColor;
|
|
if (request.FontFamily != null) branding.FontFamily = request.FontFamily;
|
|
if (request.EmailFromName != null) branding.EmailFromName = request.EmailFromName;
|
|
if (request.EmailFromAddress != null) branding.EmailFromAddress = request.EmailFromAddress;
|
|
if (request.EmailReplyTo != null) branding.EmailReplyTo = request.EmailReplyTo;
|
|
if (request.EmailFooterHtml != null) branding.EmailFooterHtml = request.EmailFooterHtml;
|
|
if (request.SupportUrl != null) branding.SupportUrl = request.SupportUrl;
|
|
if (request.TermsUrl != null) branding.TermsUrl = request.TermsUrl;
|
|
if (request.PrivacyUrl != null) branding.PrivacyUrl = request.PrivacyUrl;
|
|
if (request.EmbedEnabled.HasValue) branding.EmbedEnabled = request.EmbedEnabled.Value;
|
|
if (request.EmbedAllowedHosts != null) branding.EmbedAllowedHosts = request.EmbedAllowedHosts;
|
|
if (request.WatermarkText != null) branding.WatermarkText = request.WatermarkText;
|
|
if (request.WatermarkImageUrl != null) branding.WatermarkImageUrl = request.WatermarkImageUrl;
|
|
if (request.WatermarkEnabled.HasValue) branding.WatermarkEnabled = request.WatermarkEnabled.Value;
|
|
|
|
branding.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return MapBrandingResponse(branding);
|
|
}
|
|
|
|
public async Task<DomainVerificationResponse> StartDomainVerificationAsync(Guid tenantId, string domain, string method)
|
|
{
|
|
var tenant = await db.Tenants.FindAsync(tenantId)
|
|
?? throw new KeyNotFoundException("Tenant not found");
|
|
|
|
// Generate a unique verification challenge
|
|
var challenge = $"flatrender-verify={tokenService.HashToken(Guid.NewGuid().ToString())[..32]}";
|
|
|
|
// For now just return the challenge — actual DNS checking would be via a background job
|
|
return new DomainVerificationResponse(
|
|
Guid.NewGuid(),
|
|
challenge,
|
|
DateTime.UtcNow.AddDays(7)
|
|
);
|
|
}
|
|
|
|
public async Task<List<TenantUsageDayResponse>> GetUsageAsync(Guid tenantId, DateOnly from, DateOnly to)
|
|
{
|
|
var rows = await db.TenantUsageDailies
|
|
.Where(u => u.TenantId == tenantId && u.UsageDate >= from && u.UsageDate <= to)
|
|
.OrderBy(u => u.UsageDate)
|
|
.ToListAsync();
|
|
|
|
return rows.Select(r => new TenantUsageDayResponse(
|
|
r.UsageDate, r.RendersCompleted, r.RenderSeconds,
|
|
r.StorageBytes, r.ApiCalls, r.ActiveUsers,
|
|
r.AmountBilledMinor, r.BillingCurrency, r.BillingStatus
|
|
)).ToList();
|
|
}
|
|
|
|
// ── API Keys ─────────────────────────────────────────────────────────
|
|
|
|
public async Task<List<ApiKeyResponse>> GetApiKeysAsync(Guid tenantId)
|
|
{
|
|
var keys = await db.TenantApiKeys
|
|
.Where(k => k.TenantId == tenantId && k.RevokedAt == null)
|
|
.OrderByDescending(k => k.CreatedAt)
|
|
.ToListAsync();
|
|
|
|
return keys.Select(MapApiKeyResponse).ToList();
|
|
}
|
|
|
|
public async Task<ApiKeyCreatedResponse> CreateApiKeyAsync(Guid tenantId, Guid createdByUserId, CreateApiKeyRequest request)
|
|
{
|
|
var rawSecret = $"fr_{request.Environment.ToLower()[..4]}_{Guid.NewGuid():N}{Guid.NewGuid():N}";
|
|
var prefix = rawSecret[..16];
|
|
var last4 = rawSecret[^4..];
|
|
var hash = tokenService.HashToken(rawSecret);
|
|
|
|
var key = new TenantApiKey
|
|
{
|
|
TenantId = tenantId,
|
|
CreatedByUserId = createdByUserId,
|
|
Name = request.Name,
|
|
Environment = request.Environment,
|
|
KeyPrefix = prefix,
|
|
KeyHash = hash,
|
|
Last4 = last4,
|
|
Scopes = request.Scopes,
|
|
AllowedIps = request.AllowedIps ?? [],
|
|
RateLimitRpm = request.RateLimitRpm,
|
|
ExpiresAt = request.ExpiresAt,
|
|
};
|
|
db.TenantApiKeys.Add(key);
|
|
await db.SaveChangesAsync();
|
|
|
|
return new ApiKeyCreatedResponse(key.Id, tenantId, key.Name, key.Environment, prefix, last4, key.Scopes, rawSecret, key.CreatedAt);
|
|
}
|
|
|
|
public async Task RevokeApiKeyAsync(Guid tenantId, Guid apiKeyId, string? reason)
|
|
{
|
|
var key = await db.TenantApiKeys.FirstOrDefaultAsync(k => k.Id == apiKeyId && k.TenantId == tenantId)
|
|
?? throw new KeyNotFoundException("API key not found");
|
|
key.RevokedAt = DateTime.UtcNow;
|
|
key.RevokeReason = reason;
|
|
key.IsActive = false;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<ApiKeyValidateResponse> ValidateApiKeyAsync(string keyPrefix, string keyHash, string? ipAddress)
|
|
{
|
|
var key = await db.TenantApiKeys
|
|
.FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix && k.IsActive && k.RevokedAt == null &&
|
|
(k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
|
|
|
|
if (key == null || key.KeyHash != keyHash)
|
|
return new ApiKeyValidateResponse(false, null, null, null);
|
|
|
|
if (key.AllowedIps.Length > 0 && !string.IsNullOrEmpty(ipAddress) && !key.AllowedIps.Contains(ipAddress))
|
|
return new ApiKeyValidateResponse(false, null, null, null);
|
|
|
|
key.UsageCount++;
|
|
key.LastUsedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
|
|
return new ApiKeyValidateResponse(true, key.TenantId, key.Scopes, key.RateLimitRpm);
|
|
}
|
|
|
|
// ── Webhooks ──────────────────────────────────────────────────────────
|
|
|
|
public async Task<List<WebhookResponse>> GetWebhooksAsync(Guid tenantId)
|
|
{
|
|
var hooks = await db.TenantWebhooks
|
|
.Where(w => w.TenantId == tenantId)
|
|
.OrderByDescending(w => w.CreatedAt)
|
|
.ToListAsync();
|
|
|
|
return hooks.Select(w => new WebhookResponse(
|
|
w.Id, w.Name, w.Url, w.Events, w.IsActive,
|
|
w.LastTriggeredAt, w.LastStatusCode, w.ConsecutiveFailures, w.CreatedAt
|
|
)).ToList();
|
|
}
|
|
|
|
public async Task<WebhookResponse> CreateWebhookAsync(Guid tenantId, CreateWebhookRequest request)
|
|
{
|
|
_ = await db.Tenants.FindAsync(tenantId) ?? throw new KeyNotFoundException("Tenant not found");
|
|
|
|
var hook = new TenantWebhook
|
|
{
|
|
TenantId = tenantId,
|
|
Name = request.Name,
|
|
Url = request.Url,
|
|
Events = request.Events,
|
|
SecretHash = tokenService.HashToken(Guid.NewGuid().ToString()),
|
|
};
|
|
db.TenantWebhooks.Add(hook);
|
|
await db.SaveChangesAsync();
|
|
|
|
return new WebhookResponse(hook.Id, hook.Name, hook.Url, hook.Events, hook.IsActive,
|
|
hook.LastTriggeredAt, hook.LastStatusCode, hook.ConsecutiveFailures, hook.CreatedAt);
|
|
}
|
|
|
|
public async Task DeleteWebhookAsync(Guid tenantId, Guid webhookId)
|
|
{
|
|
var hook = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
|
|
?? throw new KeyNotFoundException("Webhook not found");
|
|
db.TenantWebhooks.Remove(hook);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<List<WebhookDeliveryResponse>> GetWebhookDeliveriesAsync(Guid tenantId, Guid webhookId)
|
|
{
|
|
_ = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
|
|
?? throw new KeyNotFoundException("Webhook not found");
|
|
|
|
var deliveries = await db.TenantWebhookDeliveries
|
|
.Where(d => d.WebhookId == webhookId)
|
|
.OrderByDescending(d => d.CreatedAt)
|
|
.Take(50)
|
|
.ToListAsync();
|
|
|
|
return deliveries.Select(d => new WebhookDeliveryResponse(
|
|
d.Id, d.EventType, d.RequestUrl, d.ResponseStatus, d.ResponseBody,
|
|
d.DurationMs, d.Attempt, d.Succeeded, d.ErrorMessage, d.DeliveredAt, d.CreatedAt
|
|
)).ToList();
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private static ApiKeyResponse MapApiKeyResponse(TenantApiKey k) => new(
|
|
k.Id, k.TenantId, k.Name, k.Environment, k.KeyPrefix, k.Last4,
|
|
k.Scopes, k.AllowedIps, k.RateLimitRpm, k.IsActive,
|
|
k.ExpiresAt, k.LastUsedAt, k.UsageCount, k.CreatedAt
|
|
);
|
|
|
|
private static TenantBrandingResponse MapBrandingResponse(TenantBranding b) => new(
|
|
b.TenantId, b.DisplayName, b.LogoUrl, b.LogoDarkUrl,
|
|
b.PrimaryColor, b.SecondaryColor, b.AccentColor,
|
|
b.EmbedEnabled, b.WatermarkEnabled
|
|
);
|
|
}
|