diff --git a/Marechai.Server/Services/OldDosPromotionService.cs b/Marechai.Server/Services/OldDosPromotionService.cs index fb0cf5e7..c0fab74f 100644 --- a/Marechai.Server/Services/OldDosPromotionService.cs +++ b/Marechai.Server/Services/OldDosPromotionService.cs @@ -144,6 +144,7 @@ public sealed class OldDosPromotionService try { ulong targetSoftwareId; + string softwareName; int insertedDescriptions = 0; int insertedGenres = 0; int insertedVersions = 0; @@ -159,6 +160,7 @@ public sealed class OldDosPromotionService return new AcceptOldDosImportResultDto { Success = false, Error = "Target software not found." }; targetSoftwareId = target.Id; + softwareName = target.Name; } else { @@ -170,6 +172,7 @@ public sealed class OldDosPromotionService _db.Softwares.Add(fresh); await _db.SaveChangesAsync(); targetSoftwareId = fresh.Id; + softwareName = fresh.Name; } // Per-version attach (Version + Release + DEV company role). @@ -275,6 +278,17 @@ public sealed class OldDosPromotionService staging.PromotedSoftwareId = targetSoftwareId; staging.ReviewedBy = adminUserId; staging.ReviewedOn = DateTime.UtcNow; + + _db.News.Add(new News + { + AddedId = (long)targetSoftwareId, + Date = DateTime.UtcNow, + Type = dto.Mode == OldDosAcceptMode.MergeIntoExisting + ? NewsType.UpdatedSoftwareInDb + : NewsType.NewSoftwareInDb, + Name = softwareName + }); + await _db.SaveChangesAsync(); await tx.CommitAsync(); diff --git a/Marechai.Server/Services/WwpcPromotionService.cs b/Marechai.Server/Services/WwpcPromotionService.cs index 42a35169..d28b43ce 100644 --- a/Marechai.Server/Services/WwpcPromotionService.cs +++ b/Marechai.Server/Services/WwpcPromotionService.cs @@ -223,6 +223,7 @@ public sealed class WwpcPromotionService int insertedGenres = 0; ulong targetSoftwareId; + string softwareName; if(dto.Mode == WwpcAcceptMode.MergeIntoExisting) { if(dto.TargetSoftwareId is not { } tid) @@ -230,6 +231,7 @@ public sealed class WwpcPromotionService Software target = await _db.Softwares.FindAsync(tid); if(target == null) return Fail("Target software not found."); targetSoftwareId = target.Id; + softwareName = target.Name; } else { @@ -241,6 +243,7 @@ public sealed class WwpcPromotionService _db.Softwares.Add(fresh); await _db.SaveChangesAsync(); targetSoftwareId = fresh.Id; + softwareName = fresh.Name; } // ---- Company → Software link ---- @@ -420,6 +423,17 @@ public sealed class WwpcPromotionService staging.PromotedSoftwareId = targetSoftwareId; staging.ReviewedBy = adminUserId; staging.ReviewedOn = DateTime.UtcNow; + + _db.News.Add(new News + { + AddedId = (long)targetSoftwareId, + Date = DateTime.UtcNow, + Type = dto.Mode == WwpcAcceptMode.MergeIntoExisting + ? NewsType.UpdatedSoftwareInDb + : NewsType.NewSoftwareInDb, + Name = softwareName + }); + await _db.SaveChangesAsync(); await tx.CommitAsync(); diff --git a/Marechai/Services/BooksService.cs b/Marechai/Services/BooksService.cs index 1c72bac0..1fb0b8d8 100644 --- a/Marechai/Services/BooksService.cs +++ b/Marechai/Services/BooksService.cs @@ -33,7 +33,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData) +public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData, IndexNowService indexNow) { public async Task GetBooksCountAsync(IReadOnlyList filters = null, CancellationToken cancellationToken = default) @@ -352,6 +352,8 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r { long? id = await client.Books.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/book/{id}"); + return (id, null); } catch(ApiException ex) @@ -370,6 +372,8 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r { await client.Books[id].PutAsync(dto); + indexNow.EnqueueUrl($"/book/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/CompaniesService.cs b/Marechai/Services/CompaniesService.cs index 69de6f4c..84457f68 100644 --- a/Marechai/Services/CompaniesService.cs +++ b/Marechai/Services/CompaniesService.cs @@ -31,7 +31,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class CompaniesService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData) +public class CompaniesService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData, IndexNowService indexNow) { public async Task> GetAsync() { @@ -104,6 +104,8 @@ public class CompaniesService(Marechai.ApiClient.Client client, ReferenceDataCac { int? id = await client.Companies.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/company/{id}"); + return (id, null); } catch(ApiException ex) @@ -122,6 +124,8 @@ public class CompaniesService(Marechai.ApiClient.Client client, ReferenceDataCac { await client.Companies[id].PutAsync(dto); + indexNow.EnqueueUrl($"/company/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/DocumentsService.cs b/Marechai/Services/DocumentsService.cs index 3f38e1ab..c73609a5 100644 --- a/Marechai/Services/DocumentsService.cs +++ b/Marechai/Services/DocumentsService.cs @@ -33,7 +33,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData) +public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData, IndexNowService indexNow) { public async Task GetDocumentsCountAsync(IReadOnlyList filters = null, CancellationToken cancellationToken = default) @@ -333,6 +333,8 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac { long? id = await client.Documents.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/document/{id}"); + return (id, null); } catch(ApiException ex) @@ -351,6 +353,8 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac { await client.Documents[id].PutAsync(dto); + indexNow.EnqueueUrl($"/document/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/GpusService.cs b/Marechai/Services/GpusService.cs index 225e6e39..1c97f8bf 100644 --- a/Marechai/Services/GpusService.cs +++ b/Marechai/Services/GpusService.cs @@ -33,7 +33,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class GpusService(Marechai.ApiClient.Client client) +public class GpusService(Marechai.ApiClient.Client client, IndexNowService indexNow) { public async Task> GetAllAsync(int? skip = null, int? take = null, CancellationToken cancellationToken = default) @@ -142,6 +142,8 @@ public class GpusService(Marechai.ApiClient.Client client) { long? id = await client.Gpus.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/gpu/{id}"); + return (id, null); } catch(ApiException ex) @@ -160,6 +162,8 @@ public class GpusService(Marechai.ApiClient.Client client) { await client.Gpus[id].PutAsync(dto); + indexNow.EnqueueUrl($"/gpu/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/IndexNowBackgroundService.cs b/Marechai/Services/IndexNowBackgroundService.cs new file mode 100644 index 00000000..ae4c8556 --- /dev/null +++ b/Marechai/Services/IndexNowBackgroundService.cs @@ -0,0 +1,166 @@ +/****************************************************************************** +// MARECHAI: Master repository of computing history artifacts information +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// --[ License ] -------------------------------------------------------------- +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// ---------------------------------------------------------------------------- +// Copyright © 2003-2026 Natalia Portillo +*******************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Marechai.Helpers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marechai.Services; + +/// +/// Background worker that periodically drains the queue +/// and POSTs batches of changed URLs to every IndexNow-participating search engine +/// endpoint in parallel. +/// +public sealed class IndexNowBackgroundService( + IndexNowService indexNowService, + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) : BackgroundService +{ + // How often we drain the queue and submit. + static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + + // IndexNow caps a single POST at 10 000 URLs. + const int MaxBatchSize = 10_000; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + IndexNowOptions opts = options.Value; + + if(!opts.Enabled || string.IsNullOrWhiteSpace(opts.Key)) + { + logger.LogInformation("IndexNow is disabled or no key configured — background worker will not run"); + + return; + } + + logger.LogInformation("IndexNow background worker started, submitting to {Count} endpoints every {Seconds}s", + opts.Endpoints.Length, Interval.TotalSeconds); + + while(!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(Interval, stoppingToken); + } + catch(TaskCanceledException) + { + break; + } + + List urls = indexNowService.DrainQueue(); + + if(urls.Count == 0) + continue; + + // Split into IndexNow-legal batches of 10 000. + for(int i = 0; i < urls.Count; i += MaxBatchSize) + { + List batch = urls.GetRange(i, Math.Min(MaxBatchSize, urls.Count - i)); + + await SubmitBatchAsync(batch, opts, stoppingToken); + } + } + } + + async Task SubmitBatchAsync(List urls, IndexNowOptions opts, CancellationToken ct) + { + // Parse the canonical host to extract just the hostname for the payload. + string host = new Uri(SeoMeta.CanonicalHost).Host; + + string keyLocation = $"{SeoMeta.CanonicalHost}/{opts.Key}.txt"; + + var payload = new IndexNowPayload + { + Host = host, + Key = opts.Key, + KeyLocation = keyLocation, + UrlList = urls + }; + + logger.LogInformation("IndexNow: submitting {Count} URL(s) to {Endpoints} endpoints", + urls.Count, opts.Endpoints.Length); + + // Fire requests to all endpoints in parallel. + Task[] tasks = opts.Endpoints.Select(endpoint => SubmitToEndpointAsync(endpoint, payload, ct)).ToArray(); + + await Task.WhenAll(tasks); + } + + async Task SubmitToEndpointAsync(string endpoint, IndexNowPayload payload, CancellationToken ct) + { + try + { + HttpClient client = httpClientFactory.CreateClient("IndexNow"); + + string json = JsonSerializer.Serialize(payload); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.PostAsync(endpoint, content, ct); + + if(response.IsSuccessStatusCode) + { + logger.LogInformation("IndexNow: {Endpoint} accepted {Count} URL(s) (HTTP {Status})", + endpoint, payload.UrlList.Count, (int)response.StatusCode); + } + else + { + logger.LogWarning("IndexNow: {Endpoint} returned HTTP {Status} for {Count} URL(s)", + endpoint, (int)response.StatusCode, payload.UrlList.Count); + } + } + catch(Exception ex) + { + logger.LogWarning(ex, "IndexNow: failed to submit to {Endpoint}", endpoint); + } + } + + /// JSON payload shape for the IndexNow POST API. + sealed class IndexNowPayload + { + [JsonPropertyName("host")] + public string Host { get; init; } + + [JsonPropertyName("key")] + public string Key { get; init; } + + [JsonPropertyName("keyLocation")] + public string KeyLocation { get; init; } + + [JsonPropertyName("urlList")] + public List UrlList { get; init; } + } +} diff --git a/Marechai/Services/IndexNowOptions.cs b/Marechai/Services/IndexNowOptions.cs new file mode 100644 index 00000000..0d8aa4e3 --- /dev/null +++ b/Marechai/Services/IndexNowOptions.cs @@ -0,0 +1,42 @@ +/****************************************************************************** +// MARECHAI: Master repository of computing history artifacts information +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// --[ License ] -------------------------------------------------------------- +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// ---------------------------------------------------------------------------- +// Copyright © 2003-2026 Natalia Portillo +*******************************************************************************/ + +namespace Marechai.Services; + +public sealed class IndexNowOptions +{ + public bool Enabled { get; set; } + public string Key { get; set; } + public string[] Endpoints { get; set; } = + [ + "https://www.bing.com/indexnow", + "https://yandex.com/indexnow", + "https://search.seznam.cz/indexnow", + "https://searchadvisor.naver.com/indexnow", + "https://indexnow.yep.com/indexnow", + "https://internetarchive.indexnow.org/indexnow", + "https://indexnow.amazonbot.amazon/indexnow" + ]; +} diff --git a/Marechai/Services/IndexNowService.cs b/Marechai/Services/IndexNowService.cs new file mode 100644 index 00000000..db6134e2 --- /dev/null +++ b/Marechai/Services/IndexNowService.cs @@ -0,0 +1,69 @@ +/****************************************************************************** +// MARECHAI: Master repository of computing history artifacts information +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// --[ License ] -------------------------------------------------------------- +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// ---------------------------------------------------------------------------- +// Copyright © 2003-2026 Natalia Portillo +*******************************************************************************/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Marechai.Helpers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Marechai.Services; + +/// +/// Thread-safe in-memory queue for IndexNow URL submissions. Singleton service that +/// accepts relative URLs from admin CRUD operations and exposes them for the background +/// worker to drain and POST to each participating search engine. +/// +public sealed class IndexNowService(IOptions options, ILogger logger) +{ + readonly ConcurrentQueue _queue = new(); + + /// Enqueue a relative URL (e.g. /machine/42) for submission to IndexNow. + public void EnqueueUrl(string relativeUrl) + { + IndexNowOptions opts = options.Value; + + if(!opts.Enabled || string.IsNullOrWhiteSpace(opts.Key)) + return; + + string absoluteUrl = $"{SeoMeta.CanonicalHost}{relativeUrl}"; + + _queue.Enqueue(absoluteUrl); + + logger.LogDebug("IndexNow: enqueued {Url}", absoluteUrl); + } + + /// Drain all queued URLs into a deduplicated list. Called by the background worker. + internal List DrainQueue() + { + var urls = new HashSet(StringComparer.Ordinal); + + while(_queue.TryDequeue(out string url)) + urls.Add(url); + + return [.. urls]; + } +} diff --git a/Marechai/Services/MachinesService.cs b/Marechai/Services/MachinesService.cs index 89a6e792..f6a964dd 100644 --- a/Marechai/Services/MachinesService.cs +++ b/Marechai/Services/MachinesService.cs @@ -33,7 +33,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class MachinesService(Marechai.ApiClient.Client client) +public class MachinesService(Marechai.ApiClient.Client client, IndexNowService indexNow) { static string ExtractErrorMessage(ApiException ex) { @@ -134,6 +134,8 @@ public class MachinesService(Marechai.ApiClient.Client client) { long? id = await client.Machines.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/machine/{id}"); + return (id, null); } catch(ApiException ex) @@ -152,6 +154,8 @@ public class MachinesService(Marechai.ApiClient.Client client) { await client.Machines[id].PutAsync(dto); + indexNow.EnqueueUrl($"/machine/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/MagazinesService.cs b/Marechai/Services/MagazinesService.cs index 529711a7..7268f194 100644 --- a/Marechai/Services/MagazinesService.cs +++ b/Marechai/Services/MagazinesService.cs @@ -35,7 +35,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData) +public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCache referenceData, IndexNowService indexNow) { public async Task GetMagazinesCountAsync(IReadOnlyList filters = null, CancellationToken cancellationToken = default) @@ -350,6 +350,8 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac { long? id = await client.Magazines.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/magazine/{id}"); + return (id, null); } catch(ApiException ex) @@ -368,6 +370,8 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac { await client.Magazines[id].PutAsync(dto); + indexNow.EnqueueUrl($"/magazine/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/OldDosImportsService.cs b/Marechai/Services/OldDosImportsService.cs index 37708b4a..2a415dac 100644 --- a/Marechai/Services/OldDosImportsService.cs +++ b/Marechai/Services/OldDosImportsService.cs @@ -15,7 +15,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; /// Client wrapper around the /old-dos admin endpoints. -public sealed class OldDosImportsService(Client client, ILogger logger) +public sealed class OldDosImportsService(Client client, ILogger logger, IndexNowService indexNow) { public async Task> GetPendingAsync(int skip, int take, OldDosSoftwareStatus? status, @@ -114,6 +114,10 @@ public sealed class OldDosImportsService(Client client, ILogger> GetAllAsync(int? skip = null, int? take = null, CancellationToken cancellationToken = default) @@ -142,6 +142,8 @@ public class ProcessorsService(Marechai.ApiClient.Client client) { long? id = await client.Processors.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/processor/{id}"); + return (id, null); } catch(ApiException ex) @@ -160,6 +162,8 @@ public class ProcessorsService(Marechai.ApiClient.Client client) { await client.Processors[id].PutAsync(dto); + indexNow.EnqueueUrl($"/processor/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/Register.cs b/Marechai/Services/Register.cs index 544b86cd..739c8d0f 100644 --- a/Marechai/Services/Register.cs +++ b/Marechai/Services/Register.cs @@ -91,5 +91,8 @@ public static class Register services.AddScoped(); services.AddSingleton(); + + services.AddSingleton(); + services.AddHostedService(); } } \ No newline at end of file diff --git a/Marechai/Services/SoftwareService.cs b/Marechai/Services/SoftwareService.cs index 4dc052a3..5b2a7c9f 100644 --- a/Marechai/Services/SoftwareService.cs +++ b/Marechai/Services/SoftwareService.cs @@ -51,7 +51,7 @@ public enum AddonPrefixMode StartsWith } -public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter requestAdapter, ReferenceDataCache referenceData) +public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter requestAdapter, ReferenceDataCache referenceData, IndexNowService indexNow) { static string ExtractErrorMessage(ApiException ex) { @@ -69,6 +69,8 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r { int? id = await client.Software.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/software/{id}"); + return (id, null); } catch(ApiException ex) @@ -87,6 +89,8 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r { await client.Software[id].PutAsync(dto); + indexNow.EnqueueUrl($"/software/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/SoundSynthsService.cs b/Marechai/Services/SoundSynthsService.cs index b7b69b83..279ff81b 100644 --- a/Marechai/Services/SoundSynthsService.cs +++ b/Marechai/Services/SoundSynthsService.cs @@ -33,7 +33,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; -public class SoundSynthsService(Marechai.ApiClient.Client client) +public class SoundSynthsService(Marechai.ApiClient.Client client, IndexNowService indexNow) { static string ExtractErrorMessage(ApiException ex) { @@ -151,6 +151,8 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) { long? id = await client.SoundSynths.PostAsync(dto); + if(id.HasValue) indexNow.EnqueueUrl($"/soundsynth/{id}"); + return (id, null); } catch(ApiException ex) @@ -169,6 +171,8 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) { await client.SoundSynths[id].PutAsync(dto); + indexNow.EnqueueUrl($"/soundsynth/{id}"); + return (true, null); } catch(ApiException ex) diff --git a/Marechai/Services/WwpcImportsService.cs b/Marechai/Services/WwpcImportsService.cs index 50a35d71..4a30a7ec 100644 --- a/Marechai/Services/WwpcImportsService.cs +++ b/Marechai/Services/WwpcImportsService.cs @@ -15,7 +15,7 @@ using Microsoft.Kiota.Abstractions; namespace Marechai.Services; /// Client wrapper around the /wwpc admin endpoints. -public sealed class WwpcImportsService(Client client, ILogger logger) +public sealed class WwpcImportsService(Client client, ILogger logger, IndexNowService indexNow) { public async Task> GetPendingAsync(int skip, int take, WwpcSoftwareStatus? status, @@ -125,6 +125,10 @@ public sealed class WwpcImportsService(Client client, ILogger(Configuration.GetSection("IndexNow")); + services.AddHttpClient("IndexNow"); + // OpenAI + NLLB HttpClients (each registered only when its Url is configured) plus the // shared TranslationService singleton. Lives in Marechai.Translation so the API server // (Marechai.Server) can reuse the same provider for the genre translation worker. @@ -344,6 +348,27 @@ public class Startup(IConfiguration configuration) $"User-agent: *\nAllow: /\n\nSitemap: {Helpers.SeoMeta.CanonicalHost}/sitemap.xml\n"); }); + // IndexNow key verification file — search engines crawl /{key}.txt to prove + // we own the domain. Served dynamically so the key lives only in appsettings. + endpoints.MapGet("/{filename}.txt", context => + { + string filename = (string)context.Request.RouteValues["filename"]; + string configKey = Configuration["IndexNow:Key"]; + + if(string.IsNullOrWhiteSpace(configKey) || + !string.Equals(filename, configKey, StringComparison.Ordinal)) + { + context.Response.StatusCode = 404; + + return Task.CompletedTask; + } + + context.Response.ContentType = "text/plain; charset=utf-8"; + context.Response.Headers["Cache-Control"] = "public, max-age=86400"; + + return context.Response.WriteAsync(configKey); + }); + endpoints.MapGet("/sitemap.xml", async context => { var sitemapService = context.RequestServices.GetRequiredService(); diff --git a/Marechai/appsettings.json b/Marechai/appsettings.json index 9c4b88c0..49a5a731 100644 --- a/Marechai/appsettings.json +++ b/Marechai/appsettings.json @@ -70,6 +70,10 @@ "Description": "A normal user role." } ], + "IndexNow": { + "Enabled": true, + "Key": null + }, "Smtp": { "Host": "", "Port": 587,