404 lines
15 KiB
C#
404 lines
15 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Public sitenin arama motoru yuzeyi: sayfa metasinin sozlesmesi, sitemap ve robots metni.
|
||
|
|
/// Tamami anonim erisime aciktir.
|
||
|
|
/// </summary>
|
||
|
|
[AllowAnonymous]
|
||
|
|
public class SeoAppService : PlatformAppService, ISeoAppService
|
||
|
|
{
|
||
|
|
/// <summary>Sitemap'e yalnizca ziyaretciye acik yollar girer; yonetim yuzeyi disaridadir.</summary>
|
||
|
|
private const string AdminPathPrefix = "/admin";
|
||
|
|
|
||
|
|
private const string DefaultChangeFrequency = "weekly";
|
||
|
|
|
||
|
|
/// <summary>Route tablosunda ziyaretciye acik satirlarin tipi.</summary>
|
||
|
|
private const string PublicRouteType = "public";
|
||
|
|
|
||
|
|
private readonly ISettingProvider _settingProvider;
|
||
|
|
private readonly IRepository<Menu, Guid> _menuRepository;
|
||
|
|
private readonly IRepository<CustomComponent, Guid> _customComponentRepository;
|
||
|
|
private readonly IRepository<BlogPost, Guid> _postRepository;
|
||
|
|
private readonly IRepository<Route, Guid> _routeRepository;
|
||
|
|
|
||
|
|
public SeoAppService(
|
||
|
|
ISettingProvider settingProvider,
|
||
|
|
IRepository<Menu, Guid> menuRepository,
|
||
|
|
IRepository<CustomComponent, Guid> customComponentRepository,
|
||
|
|
IRepository<BlogPost, Guid> postRepository,
|
||
|
|
IRepository<Route, Guid> routeRepository)
|
||
|
|
{
|
||
|
|
_settingProvider = settingProvider;
|
||
|
|
_menuRepository = menuRepository;
|
||
|
|
_customComponentRepository = customComponentRepository;
|
||
|
|
_postRepository = postRepository;
|
||
|
|
_routeRepository = routeRepository;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<SeoConfigDto> 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<string> 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("""<?xml version="1.0" encoding="UTF-8"?>""");
|
||
|
|
builder.AppendLine("""<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">""");
|
||
|
|
|
||
|
|
foreach (var entry in entries)
|
||
|
|
{
|
||
|
|
builder.AppendLine(" <url>");
|
||
|
|
builder.AppendLine($" <loc>{Escape(siteUrl + entry.Path)}</loc>");
|
||
|
|
|
||
|
|
if (entry.LastModified.HasValue)
|
||
|
|
{
|
||
|
|
builder.AppendLine($" <lastmod>{entry.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}</lastmod>");
|
||
|
|
}
|
||
|
|
|
||
|
|
builder.AppendLine($" <changefreq>{Escape(entry.ChangeFrequency)}</changefreq>");
|
||
|
|
builder.AppendLine($" <priority>{entry.Priority.ToString("0.0", CultureInfo.InvariantCulture)}</priority>");
|
||
|
|
builder.AppendLine(" </url>");
|
||
|
|
}
|
||
|
|
|
||
|
|
builder.AppendLine("</urlset>");
|
||
|
|
|
||
|
|
return builder.ToString();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<string> 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<List<SitemapEntry>> CollectSitemapEntriesAsync(List<string> excluded)
|
||
|
|
{
|
||
|
|
var entries = new Dictionary<string, SitemapEntry>(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)];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Uygulama ici yollari tek bicime indirger; disa donuk ve yonetim adresleri elenir.</summary>
|
||
|
|
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<string> excluded)
|
||
|
|
{
|
||
|
|
return excluded.Exists(rule => rule.EndsWith('*')
|
||
|
|
? path.StartsWith(rule.TrimEnd('*'), StringComparison.OrdinalIgnoreCase)
|
||
|
|
: path.Equals(rule, StringComparison.OrdinalIgnoreCase));
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<string> 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<string> GetSettingAsync(string name)
|
||
|
|
{
|
||
|
|
return await _settingProvider.GetOrNullAsync(name) ?? string.Empty;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<bool> GetBoolSettingAsync(string name)
|
||
|
|
{
|
||
|
|
var value = await GetSettingAsync(name);
|
||
|
|
|
||
|
|
return value.IsNullOrWhiteSpace() || value.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static List<string> SplitList(string value)
|
||
|
|
{
|
||
|
|
return [.. value
|
||
|
|
.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||
|
|
}
|
||
|
|
|
||
|
|
private static List<string> 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// SEO kayitlarini yolu tanimlayan iki kaynaktan toplar: fiziksel sayfalarin <c>Route</c>
|
||
|
|
/// satirlari ve rota tasiyan Custom Component kayitlari. Ayni yolu ikisi de tanimliyorsa
|
||
|
|
/// bilesen kazanir; calisma zamaninda da bilesen fiziksel rotayi golgeler.
|
||
|
|
/// </summary>
|
||
|
|
private async Task<List<SeoEntry>> CollectSeoRoutesAsync()
|
||
|
|
{
|
||
|
|
var entries = new Dictionary<string, SeoEntry>(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];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Iki kaynagin SEO alanlarini tek bicime indirger.</summary>
|
||
|
|
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)
|
||
|
|
{
|
||
|
|
/// <summary>Hicbir SEO alani doldurulmamis kayit sozlesmeye girmez; genel varsayilanlar zaten uygulanir.</summary>
|
||
|
|
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);
|
||
|
|
}
|