using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Sozsoft.Platform.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.Settings; using SeoSettings = Sozsoft.Platform.PlatformConsts.AbpSettings.SiteManagement.Seo; namespace Sozsoft.Platform.Public; /// /// Public sitenin arama motoru yuzeyi: sayfa metasinin sozlesmesi, sitemap ve robots metni. /// Tamami anonim erisime aciktir. /// [AllowAnonymous] public class SeoAppService : PlatformAppService, ISeoAppService { /// Sitemap'e yalnizca ziyaretciye acik yollar girer; yonetim yuzeyi disaridadir. private const string AdminPathPrefix = "/admin"; private const string DefaultChangeFrequency = "weekly"; /// Route tablosunda ziyaretciye acik satirlarin tipi. private const string PublicRouteType = "public"; private readonly ISettingProvider _settingProvider; private readonly IRepository _menuRepository; private readonly IRepository _customComponentRepository; private readonly IRepository _postRepository; private readonly IRepository _routeRepository; public SeoAppService( ISettingProvider settingProvider, IRepository menuRepository, IRepository customComponentRepository, IRepository postRepository, IRepository routeRepository) { _settingProvider = settingProvider; _menuRepository = menuRepository; _customComponentRepository = customComponentRepository; _postRepository = postRepository; _routeRepository = routeRepository; } public async Task GetConfigAsync() { var config = new SeoConfigDto { SiteUrl = NormalizeBaseUrl(await GetSettingAsync(SeoSettings.SiteUrl)), SiteName = await GetSettingAsync(SeoSettings.SiteName), DefaultTitle = await GetSettingAsync(SeoSettings.DefaultTitle), DefaultDescription = await GetSettingAsync(SeoSettings.DefaultDescription), DefaultKeywords = await GetSettingAsync(SeoSettings.DefaultKeywords), DefaultImage = await GetSettingAsync(SeoSettings.DefaultImage), TwitterSite = await GetSettingAsync(SeoSettings.TwitterSite), Robots = await GetSettingAsync(SeoSettings.Robots), GoogleSiteVerification = await GetSettingAsync(SeoSettings.GoogleSiteVerification), Locales = SplitList(await GetSettingAsync(SeoSettings.Locales)), SameAs = SplitList(await GetSettingAsync(SeoSettings.OrganizationSameAs)), }; config.Routes = [.. (await CollectSeoRoutesAsync()) .Select(entry => entry.ToDto()) .OrderBy(route => route.RoutePath, StringComparer.Ordinal)]; return config; } public async Task GetSitemapAsync(string baseUrl) { if (!await GetBoolSettingAsync(SeoSettings.SitemapEnabled)) { return string.Empty; } var siteUrl = await ResolveSiteUrlAsync(baseUrl); var excluded = SplitLines(await GetSettingAsync(SeoSettings.SitemapExcludePaths)); var entries = await CollectSitemapEntriesAsync(excluded); var builder = new StringBuilder(); builder.AppendLine(""""""); builder.AppendLine(""""""); foreach (var entry in entries) { builder.AppendLine(" "); builder.AppendLine($" {Escape(siteUrl + entry.Path)}"); if (entry.LastModified.HasValue) { builder.AppendLine($" {entry.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}"); } builder.AppendLine($" {Escape(entry.ChangeFrequency)}"); builder.AppendLine($" {entry.Priority.ToString("0.0", CultureInfo.InvariantCulture)}"); builder.AppendLine(" "); } builder.AppendLine(""); return builder.ToString(); } public async Task GetRobotsAsync(string baseUrl) { var robots = await GetSettingAsync(SeoSettings.Robots); var siteUrl = await ResolveSiteUrlAsync(baseUrl); var builder = new StringBuilder(); builder.AppendLine("User-agent: *"); // Ayar "dizine ekleme" derse butun site kapatilir; aksi halde yalnizca ziyaretciye // acik olmayan yuzeyler disarida birakilir. if (robots.Contains("noindex", StringComparison.OrdinalIgnoreCase)) { builder.AppendLine("Disallow: /"); return builder.ToString(); } builder.AppendLine("Disallow: /admin/"); builder.AppendLine("Disallow: /auth/"); builder.AppendLine("Disallow: /checkout"); builder.AppendLine("Disallow: /payment"); builder.AppendLine("Disallow: /success"); foreach (var path in SplitLines(await GetSettingAsync(SeoSettings.SitemapExcludePaths))) { builder.AppendLine($"Disallow: {path.TrimEnd('*')}"); } if (await GetBoolSettingAsync(SeoSettings.SitemapEnabled)) { builder.AppendLine(); builder.AppendLine($"Sitemap: {siteUrl}/sitemap.xml"); } return builder.ToString(); } private async Task> CollectSitemapEntriesAsync(List excluded) { var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); void Add(string? path, DateTime? lastModified, string changeFrequency, decimal priority) { var normalized = NormalizePath(path); if (normalized == null || IsExcluded(normalized, excluded) || entries.ContainsKey(normalized)) { return; } entries[normalized] = new SitemapEntry(normalized, lastModified, changeFrequency, priority); } // Ana sayfa menude bir kayit olmayabilir; site kokunun her zaman listede olmasi gerekir. Add("/", null, "daily", 1.0m); var menus = await _menuRepository.GetListAsync( menu => menu.ModuleId == PlatformConsts.PublicSite.ModuleId && !menu.IsDisabled); foreach (var menu in menus.OrderBy(menu => menu.Order)) { Add(menu.Url, null, DefaultChangeFrequency, 0.8m); } var components = await _customComponentRepository.GetListAsync(component => component.IsActive); foreach (var component in components) { Add(component.RoutePath, component.LastModificationTime ?? component.CreationTime, DefaultChangeFrequency, 0.7m); } var posts = await _postRepository.GetListAsync(post => post.IsPublished); foreach (var post in posts) { Add($"/blog/{post.Slug}", post.LastModificationTime ?? post.PublishedAt, "monthly", 0.6m); } // Yolu tanimlayan kayitlarin kendi SEO alanlari hem yeni yol ekler hem de // yukaridakilerin sirasini ve sikligini ezer. foreach (var seo in await CollectSeoRoutesAsync()) { if (seo.RoutePath.Contains('*')) { continue; } if (seo.NoIndex || !seo.IncludeInSitemap) { entries.Remove(seo.RoutePath); continue; } entries[seo.RoutePath] = new SitemapEntry( seo.RoutePath, seo.LastModified, seo.ChangeFrequency.IsNullOrWhiteSpace() ? DefaultChangeFrequency : seo.ChangeFrequency!, seo.Priority ?? 0.5m); } return [.. entries.Values.OrderBy(entry => entry.Path, StringComparer.Ordinal)]; } /// Uygulama ici yollari tek bicime indirger; disa donuk ve yonetim adresleri elenir. private static string? NormalizePath(string? path) { if (path.IsNullOrWhiteSpace()) { return null; } var value = path!.Trim(); if (value.Contains("://", StringComparison.Ordinal)) { return null; } if (!value.StartsWith('/')) { value = "/" + value; } if (value.StartsWith(AdminPathPrefix + "/", StringComparison.OrdinalIgnoreCase) || value.Equals(AdminPathPrefix, StringComparison.OrdinalIgnoreCase)) { return null; } // Parametreli rota (/blog/:id) bir adres degil, bir kaliptir. if (value.Contains(':', StringComparison.Ordinal)) { return null; } return value.Length > 1 ? value.TrimEnd('/') : value; } private static bool IsExcluded(string path, List excluded) { return excluded.Exists(rule => rule.EndsWith('*') ? path.StartsWith(rule.TrimEnd('*'), StringComparison.OrdinalIgnoreCase) : path.Equals(rule, StringComparison.OrdinalIgnoreCase)); } private async Task ResolveSiteUrlAsync(string baseUrl) { var configured = NormalizeBaseUrl(await GetSettingAsync(SeoSettings.SiteUrl)); return configured.IsNullOrWhiteSpace() ? NormalizeBaseUrl(baseUrl) : configured; } private static string NormalizeBaseUrl(string value) { return value.IsNullOrWhiteSpace() ? string.Empty : value.Trim().TrimEnd('/'); } private async Task GetSettingAsync(string name) { return await _settingProvider.GetOrNullAsync(name) ?? string.Empty; } private async Task GetBoolSettingAsync(string name) { var value = await GetSettingAsync(name); return value.IsNullOrWhiteSpace() || value.Equals("true", StringComparison.OrdinalIgnoreCase); } private static List SplitList(string value) { return [.. value .Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; } private static List SplitLines(string value) { return [.. value .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; } private static string Escape(string value) { return value .Replace("&", "&", StringComparison.Ordinal) .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); } /// /// SEO kayitlarini yolu tanimlayan iki kaynaktan toplar: fiziksel sayfalarin Route /// satirlari ve rota tasiyan Custom Component kayitlari. Ayni yolu ikisi de tanimliyorsa /// bilesen kazanir; calisma zamaninda da bilesen fiziksel rotayi golgeler. /// private async Task> CollectSeoRoutesAsync() { var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); void Add(SeoEntry? entry) { if (entry != null) { entries[entry.RoutePath] = entry; } } var routes = await _routeRepository.GetListAsync(route => route.RouteType == PublicRouteType); foreach (var route in routes) { Add(SeoEntry.From( route.Path, route.SeoTitle, route.SeoDescription, route.SeoKeywords, route.SeoOgImage, route.SeoCanonicalUrl, route.SeoNoIndex, route.SeoIncludeInSitemap, route.SeoChangeFrequency, route.SeoPriority, route.SeoStructuredData, route.LastModificationTime ?? route.CreationTime)); } var components = await _customComponentRepository.GetListAsync(component => component.IsActive); foreach (var component in components) { Add(SeoEntry.From( component.RoutePath, component.SeoTitle, component.SeoDescription, component.SeoKeywords, component.SeoOgImage, component.SeoCanonicalUrl, component.SeoNoIndex, component.SeoIncludeInSitemap, component.SeoChangeFrequency, component.SeoPriority, component.SeoStructuredData, component.LastModificationTime ?? component.CreationTime)); } return [.. entries.Values]; } /// Iki kaynagin SEO alanlarini tek bicime indirger. private sealed record SeoEntry( string RoutePath, string? Title, string? Description, string? Keywords, string? OgImage, string? CanonicalUrl, bool NoIndex, bool IncludeInSitemap, string? ChangeFrequency, decimal? Priority, string? StructuredData, DateTime? LastModified) { /// Hicbir SEO alani doldurulmamis kayit sozlesmeye girmez; genel varsayilanlar zaten uygulanir. public static SeoEntry? From( string? routePath, string? title, string? description, string? keywords, string? ogImage, string? canonicalUrl, bool noIndex, bool includeInSitemap, string? changeFrequency, decimal? priority, string? structuredData, DateTime? lastModified) { var normalized = NormalizePath(routePath); if (normalized == null) { return null; } var hasValue = !title.IsNullOrWhiteSpace() || !description.IsNullOrWhiteSpace() || !keywords.IsNullOrWhiteSpace() || !ogImage.IsNullOrWhiteSpace() || !canonicalUrl.IsNullOrWhiteSpace() || !structuredData.IsNullOrWhiteSpace() || !changeFrequency.IsNullOrWhiteSpace() || priority.HasValue || noIndex || !includeInSitemap; return hasValue ? new SeoEntry(normalized, title, description, keywords, ogImage, canonicalUrl, noIndex, includeInSitemap, changeFrequency, priority, structuredData, lastModified) : null; } public SeoRouteDto ToDto() { return new SeoRouteDto { RoutePath = RoutePath, Title = Title, Description = Description, Keywords = Keywords, OgImage = OgImage, CanonicalUrl = CanonicalUrl, NoIndex = NoIndex, StructuredData = StructuredData, }; } } private sealed record SitemapEntry(string Path, DateTime? LastModified, string ChangeFrequency, decimal Priority); }