using AngleSharp.Html.Parser; using AsyncKeyedLock; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Helpers; using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Core.Services; public class InstanceService( DatabaseContext db, HttpClient httpClient, ILogger logger, MetaService meta ) : IScopedService { private static readonly AsyncKeyedLocker KeyedLocker = new(o => { o.PoolSize = 100; o.PoolInitialFill = 5; }); private async Task GetUpdatedInstanceMetadataAsync(string host, string webDomain) { if (host == webDomain) logger.LogDebug("Updating instance metadata for {host}", host); else logger.LogDebug("Updating instance metadata for {host} ({domain})", host, webDomain); host = host.ToPunycodeLower(); var instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host); if (instance == null) { if (!KeyedLocker.IsInUse(host)) { using (await KeyedLocker.LockAsync(host)) { instance = new Instance { Id = IdHelpers.GenerateSnowflakeId(), Host = host, CaughtAt = DateTime.UtcNow, LastCommunicatedAt = DateTime.UtcNow }; await db.AddAsync(instance); await db.SaveChangesAsync(); } } else { using (await KeyedLocker.LockAsync(host)) { instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host); } if (instance == null) throw new Exception("Failed to get instance metadata for {host}"); } } if (instance.NeedsUpdate && !KeyedLocker.IsInUse(host)) { using (await KeyedLocker.LockAsync(host)) { instance.InfoUpdatedAt = DateTime.UtcNow; var nodeinfo = await GetNodeInfoAsync(webDomain); var icons = await GetIconsAsync(webDomain); if (nodeinfo != null) { instance.Name = nodeinfo.Metadata?.NodeName; instance.Description = nodeinfo.Metadata?.NodeDescription; instance.FaviconUrl = icons.FaviconUrl; //instance.FollowersCount = TODO, //instance.FollowingCount = TODO, instance.IconUrl = icons.IconUrl; instance.MaintainerName = nodeinfo.Metadata?.Maintainer?.Name; instance.MaintainerEmail = nodeinfo.Metadata?.Maintainer?.Email; instance.OpenRegistrations = nodeinfo.OpenRegistrations; instance.SoftwareName = nodeinfo.Software?.Name; instance.SoftwareVersion = nodeinfo.Software?.Version; instance.ThemeColor = nodeinfo.Metadata?.ThemeColor; } await db.SaveChangesAsync(); } } return instance; } public async Task GetUpdatedInstanceMetadataAsync(User user) { if (user.Host == null || user.Uri == null) throw new Exception("Can't fetch instance metadata for local user"); return await GetUpdatedInstanceMetadataAsync(user.Host, new Uri(user.Uri).Host); } private async Task<(string? FaviconUrl, string? IconUrl)> GetIconsAsync(string webDomain) { try { const int maxLength = 1_000_000; var res = await httpClient.GetAsync($"https://{webDomain}", HttpCompletionOption.ResponseHeadersRead); if (res is not { IsSuccessStatusCode: true, Content.Headers: { ContentType.MediaType: "text/html", ContentLength: null or <= maxLength } }) return (null, null); var contentLength = res.Content.Headers.ContentLength; var stream = await res.Content.ReadAsStreamAsync() .ContinueWithResult(p => p.GetSafeStreamOrNullAsync(maxLength, contentLength)); if (stream == Stream.Null) return (null, null); var document = await new HtmlParser().ParseDocumentAsync(stream); var faviconUrl = document.Head?.Children.Last(e => e.NodeName.ToLower() == "link" && (e.GetAttribute("rel") ?.Contains("icon") ?? false)) .GetAttribute("href"); if (faviconUrl == null) { var favicon = await httpClient.GetAsync($"https://{webDomain}/favicon.ico"); if (favicon.IsSuccessStatusCode) faviconUrl = $"https://{webDomain}/favicon.ico"; } var iconUrl = document.Head?.Children.Last(e => e.NodeName.ToLower() == "link" && (e.GetAttribute("rel") ?.Contains("apple-touch-icon-precomposed") ?? false) || (e.GetAttribute("rel") ?.Contains("apple-touch-icon") ?? false) || (e.GetAttribute("rel") ?.Contains("icon") ?? false)) .GetAttribute("href"); var baseUri = new Uri($"https://{webDomain}"); return ( faviconUrl != null ? new Uri(baseUri, faviconUrl).ToString() : null, iconUrl != null ? new Uri(baseUri, iconUrl).ToString() : null ); } catch { return (null, null); } } private async Task GetNodeInfoAsync(string webDomain) { try { var res = await httpClient.GetFromJsonAsync($"https://{webDomain}/.well-known/nodeinfo"); var url = res?.Links.FirstOrDefault(p => p.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.1") ?? res?.Links.FirstOrDefault(p => p.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"); if (url == null) return null; return await httpClient.GetFromJsonAsync(url.Href); } catch { return null; } } public async Task UpdateInstanceStatusAsync(string host, string webDomain) { var instance = await GetUpdatedInstanceMetadataAsync(host, webDomain); instance.LastCommunicatedAt = DateTime.UtcNow; instance.LatestRequestReceivedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public async Task UpdateInstanceStatusAsync(string host, string webDomain, int statusCode, bool notResponding) { var instance = await GetUpdatedInstanceMetadataAsync(host, webDomain); instance.LatestStatus = statusCode; instance.LatestRequestSentAt = DateTime.UtcNow; if (notResponding) { instance.IsNotResponding = true; } else { instance.IsNotResponding = false; instance.LastCommunicatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(); } public async Task MarkInstanceAsUnresponsiveAsync(string host, string webDomain) { var instance = await GetUpdatedInstanceMetadataAsync(host, webDomain); instance.LatestRequestSentAt = DateTime.UtcNow; instance.IsNotResponding = true; await db.SaveChangesAsync(); } public async Task> GetRulesAsync() { return await db.Rules.OrderBy(p => p.Order).ThenBy(p => p.Id).ToListAsync(); } public async Task CreateRuleAsync(string text, string? description) { var count = await db.Rules.CountAsync(); var rule = new Rule { Id = IdHelpers.GenerateSnowflakeId(), Order = count + 1, Text = text, Description = description }; db.Add(rule); await db.SaveChangesAsync(); return rule; } public async Task UpdateRuleAsync(Rule rule, int order, string text, string? description) { var count = await db.Rules.CountAsync(); if (order > 0 && order != rule.Order && count != 1) { order = Math.Min(order, count); if (order > rule.Order) { var rules = await db.Rules .Where(p => rule.Order < p.Order && p.Order <= order) .ToListAsync(); foreach (var r in rules) r.Order -= 1; db.UpdateRange(rules); } else { var rules = await db.Rules .Where(p => order <= p.Order && p.Order < rule.Order) .ToListAsync(); foreach (var r in rules) r.Order += 1; db.UpdateRange(rules); } rule.Order = order; } rule.Text = text; rule.Description = description; await db.SaveChangesAsync(); return rule; } public async Task<(string?, string?)> GetInstanceImageAsync() { var (iconId, bannerId) = await meta.GetManyAsync(MetaEntity.IconFileId, MetaEntity.BannerFileId); return (await db.DriveFiles.Where(p => p.Id == iconId).Select(p => p.RawAccessUrl).FirstOrDefaultAsync(), await db.DriveFiles.Where(p => p.Id == bannerId).Select(p => p.RawAccessUrl).FirstOrDefaultAsync()); } }