From 113bd98b0e80d61b3d6e727964600f8497543d34 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Sun, 27 Oct 2024 18:42:46 +0100 Subject: [PATCH] [backend/drive] Proxy remote media by default --- .../PublicPreview/Renderers/MfmRenderer.cs | 4 +- .../PublicPreview/Renderers/NoteRenderer.cs | 6 +- .../PublicPreview/Renderers/UserRenderer.cs | 4 +- .../Federation/ActivityPubController.cs | 11 +- .../Controllers/Mastodon/AccountController.cs | 4 - .../Mastodon/InstanceController.cs | 19 +- .../Controllers/Mastodon/MediaController.cs | 22 +- .../Mastodon/Renderers/AttachmentRenderer.cs | 10 +- .../Mastodon/Renderers/NoteRenderer.cs | 17 +- .../Renderers/NotificationRenderer.cs | 7 +- .../Mastodon/Renderers/UserRenderer.cs | 16 +- .../Controllers/Pleroma/EmojiController.cs | 8 +- .../Controllers/Web/DriveController.cs | 231 +++++++++++++----- .../Controllers/Web/EmojiController.cs | 13 +- .../Controllers/Web/ProfileController.cs | 26 +- .../Controllers/Web/Renderers/NoteRenderer.cs | 13 +- .../Web/Renderers/NotificationRenderer.cs | 76 ++++-- .../Controllers/Web/Renderers/UserRenderer.cs | 6 +- .../Core/Configuration/Config.cs | 7 +- .../DatabaseContextModelSnapshot.cs | 13 +- ...24718_MakeDriveFileAccessKeyNonOptional.cs | 47 ++++ ...095535_RemoteUserAvatarBannerUrlColumns.cs | 50 ++++ .../Core/Database/Tables/DriveFile.cs | 8 +- .../Core/Database/Tables/Emoji.cs | 7 +- .../Core/Database/Tables/User.cs | 45 ++-- .../Core/Extensions/ServiceExtensions.cs | 10 + .../ActivityPub/ActivityRenderer.cs | 2 +- .../Federation/ActivityPub/NoteRenderer.cs | 6 +- .../Federation/ActivityPub/UserRenderer.cs | 25 +- .../Helpers/IdenticonHelper.cs} | 27 +- .../Helpers/LibMfm/Conversion/MfmConverter.cs | 6 +- .../Core/Middleware/ErrorHandlerMiddleware.cs | 3 + .../Core/Queues/BackgroundTaskQueue.cs | 3 +- .../Core/Services/DriveService.cs | 26 +- .../Core/Services/EmojiService.cs | 6 +- .../Core/Services/MediaProxyService.cs | 52 ++++ .../Core/Services/ObjectStorageService.cs | 7 +- .../Services/StorageMaintenanceService.cs | 9 - .../Core/Services/UserService.cs | 3 - Iceshrimp.Backend/configuration.ini | 4 + 40 files changed, 581 insertions(+), 278 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20241123224718_MakeDriveFileAccessKeyNonOptional.cs create mode 100644 Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20250109095535_RemoteUserAvatarBannerUrlColumns.cs rename Iceshrimp.Backend/{Controllers/Web/IdenticonController.cs => Core/Helpers/IdenticonHelper.cs} (88%) create mode 100644 Iceshrimp.Backend/Core/Services/MediaProxyService.cs diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs index aa8a6109..e730e71b 100644 --- a/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs +++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs @@ -3,12 +3,14 @@ using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.MfmSharp; +using JetBrains.Annotations; using Microsoft.AspNetCore.Components; namespace Iceshrimp.Backend.Components.PublicPreview.Renderers; public readonly record struct MfmRenderData(MarkupString Html, List InlineMedia); +[UsedImplicitly] public class MfmRenderer(MfmConverter converter) : ISingletonService { public async Task RenderAsync( @@ -34,4 +36,4 @@ public class MfmRenderer(MfmConverter converter) : ISingletonService var rendered = await RenderAsync(text, host, mentions, emoji, rootElement); return rendered?.Html; } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs index 9a535b50..06964374 100644 --- a/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs +++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs @@ -3,6 +3,7 @@ using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -12,6 +13,7 @@ public class NoteRenderer( DatabaseContext db, UserRenderer userRenderer, MfmRenderer mfm, + MediaProxyService mediaProxy, IOptions instance, IOptionsSnapshot security ) : IScopedService @@ -102,7 +104,7 @@ public class NoteRenderer( .Select(f => new PreviewAttachment { MimeType = f.Type, - Url = f.AccessUrl, + Url = mediaProxy.GetProxyUrl(f), Name = f.Name, Alt = f.Comment, Sensitive = f.IsSensitive @@ -122,4 +124,4 @@ public class NoteRenderer( .AwaitAllAsync() .ToListAsync(); } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs index 0ac51c68..37131731 100644 --- a/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs +++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs @@ -33,8 +33,8 @@ public class UserRenderer( Username = user.Username, Host = user.Host ?? instance.Value.AccountDomain, Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath, - AvatarUrl = user.AvatarUrl ?? user.IdenticonUrlPath, - BannerUrl = user.BannerUrl, + AvatarUrl = user.GetAvatarUrl(instance.Value), + BannerUrl = user.GetBannerUrl(instance.Value), RawDisplayName = user.DisplayName, DisplayName = await mfm.RenderSimpleAsync(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"), Bio = await mfm.RenderSimpleAsync(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"), diff --git a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs index 4e57e03b..30a31d37 100644 --- a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs @@ -151,7 +151,12 @@ public class ActivityPubController( [ProducesErrors(HttpStatusCode.NotFound)] public async Task> GetUser(string id) { - var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); + var user = await db.Users + .IncludeCommonProperties() + .Include(p => p.Avatar) + .Include(p => p.Banner) + .FirstOrDefaultAsync(p => p.Id == id); + if (user == null) throw GracefulException.NotFound("User not found"); if (user.IsRemoteUser) { @@ -282,6 +287,8 @@ public class ActivityPubController( var user = await db.Users .IncludeCommonProperties() + .Include(p => p.Avatar) + .Include(p => p.Banner) .FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser); if (user == null) throw GracefulException.NotFound("User not found"); @@ -327,7 +334,7 @@ public class ActivityPubController( { Id = emoji.GetPublicUri(config.Value), Name = emoji.Name, - Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } + Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl) } }; return LdHelpers.Compact(rendered); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs index d6217eb5..eb695707 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs @@ -107,7 +107,6 @@ public class AccountController( var avatar = await driveSvc.StoreFileAsync(request.Avatar.OpenReadStream(), user, rq); user.Avatar = avatar; user.AvatarBlurhash = avatar.Blurhash; - user.AvatarUrl = avatar.AccessUrl; } if (request.Banner != null) @@ -121,7 +120,6 @@ public class AccountController( var banner = await driveSvc.StoreFileAsync(request.Banner.OpenReadStream(), user, rq); user.Banner = banner; user.BannerBlurhash = banner.Blurhash; - user.BannerUrl = banner.AccessUrl; } user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); @@ -139,7 +137,6 @@ public class AccountController( var id = user.AvatarId; user.AvatarId = null; - user.AvatarUrl = null; user.AvatarBlurhash = null; db.Update(user); @@ -161,7 +158,6 @@ public class AccountController( var id = user.BannerId; user.BannerId = null; - user.BannerUrl = null; user.BannerBlurhash = null; db.Update(user); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs b/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs index 682d37ac..f15cc02c 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs @@ -21,7 +21,11 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableCors("mastodon")] [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] -public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase +public class InstanceController( + IOptions instance, + DatabaseContext db, + MetaService meta +) : ControllerBase { [HttpGet("/api/v1/instance")] [ProducesResults(HttpStatusCode.OK)] @@ -51,9 +55,10 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll public async Task GetInstanceInfoV2([FromServices] IOptionsSnapshot config) { var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30); - var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser && - !Constants.SystemUsers.Contains(p.UsernameLower) && - p.LastActiveDate > cutoff); + var activeMonth = + await db.Users.LongCountAsync(p => p.IsLocalUser + && !Constants.SystemUsers.Contains(p.UsernameLower) + && p.LastActiveDate > cutoff); var (instanceName, instanceDescription, adminContact) = await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription, @@ -74,8 +79,8 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll { Id = p.Id, Shortcode = p.Name, - Url = p.PublicUrl, - StaticUrl = p.PublicUrl, //TODO + Url = p.GetAccessUrl(instance.Value), + StaticUrl = p.GetAccessUrl(instance.Value), //TODO VisibleInPicker = true, Category = p.Category }) @@ -93,4 +98,4 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll var description = await meta.GetAsync(MetaEntity.InstanceDescription); return new InstanceExtendedDescription(description); } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs index 04b6a7e9..a0690d79 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs @@ -23,7 +23,11 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableCors("mastodon")] [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] -public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase +public class MediaController( + DriveService driveSvc, + DatabaseContext db, + AttachmentRenderer attachmentRenderer +) : ControllerBase { [MaxRequestSizeIsMaxUploadSize] [HttpPost("/api/v1/media")] @@ -40,7 +44,7 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro MimeType = request.File.ContentType }; var file = await driveSvc.StoreFileAsync(request.File.OpenReadStream(), user, rq); - return AttachmentRenderer.Render(file); + return attachmentRenderer.Render(file); } [HttpPut("/api/v1/media/{id}")] @@ -51,12 +55,12 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro ) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? - throw GracefulException.RecordNotFound(); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) + ?? throw GracefulException.RecordNotFound(); file.Comment = request.Description; await db.SaveChangesAsync(); - return AttachmentRenderer.Render(file); + return attachmentRenderer.Render(file); } [HttpGet("/api/v1/media/{id}")] @@ -65,10 +69,10 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro public async Task GetAttachment(string id) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? - throw GracefulException.RecordNotFound(); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) + ?? throw GracefulException.RecordNotFound(); - return AttachmentRenderer.Render(file); + return attachmentRenderer.Render(file); } [HttpPut("/api/v2/media/{id}")] @@ -76,4 +80,4 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro [ProducesErrors(HttpStatusCode.NotFound)] public IActionResult FallbackMediaRoute([SuppressMessage("ReSharper", "UnusedParameter.Global")] string id) => throw GracefulException.NotFound("This endpoint is not implemented, but some clients expect a 404 here."); -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/AttachmentRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/AttachmentRenderer.cs index cb6ec83b..83117ae4 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/AttachmentRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/AttachmentRenderer.cs @@ -1,17 +1,19 @@ using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Services; namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; -public static class AttachmentRenderer +public class AttachmentRenderer(MediaProxyService mediaProxy) : ISingletonService { - public static AttachmentEntity Render(DriveFile file) => new() + public AttachmentEntity Render(DriveFile file, bool proxy = true) => new() { Id = file.Id, Type = AttachmentEntity.GetType(file.Type), - Url = file.AccessUrl, + Url = proxy ? mediaProxy.GetProxyUrl(file) : file.RawAccessUrl, Blurhash = file.Blurhash, - PreviewUrl = file.ThumbnailAccessUrl, + PreviewUrl = proxy ? mediaProxy.GetThumbnailProxyUrl(file) : file.RawThumbnailAccessUrl, Description = file.Comment, RemoteUrl = file.Uri, Sensitive = file.IsSensitive, diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs index bdf4a098..15ea1dcd 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs @@ -19,7 +19,8 @@ public class NoteRenderer( PollRenderer pollRenderer, MfmConverter mfmConverter, DatabaseContext db, - EmojiService emojiSvc + EmojiService emojiSvc, + AttachmentRenderer attachmentRenderer ) : IScopedService { private static readonly FilterResultEntity InaccessibleFilter = new() @@ -296,7 +297,7 @@ public class NoteRenderer( if (notes.Count == 0) return []; var ids = notes.SelectMany(n => n.FileIds).Distinct(); return await db.DriveFiles.Where(p => ids.Contains(p.Id)) - .Select(f => AttachmentRenderer.Render(f)) + .Select(f => attachmentRenderer.Render(f, true)) .ToListAsync(); } @@ -305,7 +306,7 @@ public class NoteRenderer( var ids = fileIds.Distinct().ToList(); if (ids.Count == 0) return []; return await db.DriveFiles.Where(p => ids.Contains(p.Id)) - .Select(f => AttachmentRenderer.Render(f)) + .Select(f => attachmentRenderer.Render(f, true)) .ToListAsync(); } @@ -354,8 +355,8 @@ public class NoteRenderer( { var hit = await emojiSvc.ResolveEmojiAsync(item.Name); if (hit == null) continue; - item.Url = hit.PublicUrl; - item.StaticUrl = hit.PublicUrl; + item.Url = hit.GetAccessUrl(config.Value); + item.StaticUrl = hit.GetAccessUrl(config.Value); item.Name = item.Name.Trim(':'); } @@ -422,8 +423,8 @@ public class NoteRenderer( { Id = p.Id, Shortcode = p.Name.Trim(':'), - Url = p.PublicUrl, - StaticUrl = p.PublicUrl, //TODO + Url = p.GetAccessUrl(config.Value), + StaticUrl = p.GetAccessUrl(config.Value), //TODO VisibleInPicker = true, Category = p.Category }) @@ -486,4 +487,4 @@ public class NoteRenderer( public bool Source; } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NotificationRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NotificationRenderer.cs index 41938186..8f63152a 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NotificationRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/NotificationRenderer.cs @@ -1,14 +1,17 @@ using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; public class NotificationRenderer( + IOptions instance, DatabaseContext db, NoteRenderer noteRenderer, UserRenderer userRenderer @@ -45,7 +48,7 @@ public class NotificationRenderer( var parts = notification.Reaction.Trim(':').Split('@'); emojiUrl = await db.Emojis .Where(e => e.Name == parts[0] && e.Host == (parts.Length > 1 ? parts[1] : null)) - .Select(e => e.PublicUrl) + .Select(e => e.GetAccessUrl(instance.Value)) .FirstOrDefaultAsync(); } } @@ -107,7 +110,7 @@ public class NotificationRenderer( var emojiUrls = await urlQ.Select(e => new { Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:", - Url = e.PublicUrl + Url = e.GetAccessUrl(instance.Value) }) .ToArrayAsync() .ContinueWithResult(res => res.DistinctBy(e => e.Name) diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs index 9a49b7b9..8bf303d1 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs @@ -49,7 +49,7 @@ public class UserRenderer( { Id = user.Id, DisplayName = user.DisplayName ?? user.Username, - AvatarUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), + AvatarUrl = user.GetAvatarUrl(config.Value), Username = user.Username, Acct = acct, FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}", @@ -61,9 +61,9 @@ public class UserRenderer( Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html, Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value), Uri = user.Uri ?? user.GetPublicUri(config.Value), - AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO - HeaderUrl = user.BannerUrl ?? _transparent, - HeaderStaticUrl = user.BannerUrl ?? _transparent, //TODO + AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO + HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent, + HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO MovedToAccount = null, //TODO IsBot = user.IsBot, IsDiscoverable = user.IsExplorable, @@ -73,8 +73,8 @@ public class UserRenderer( if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO { - res.AvatarUrl = user.GetIdenticonUrlPng(config.Value); - res.AvatarStaticUrl = user.GetIdenticonUrlPng(config.Value); + res.AvatarUrl = user.GetIdenticonUrl(config.Value); + res.AvatarStaticUrl = user.GetIdenticonUrl(config.Value); res.HeaderUrl = _transparent; res.HeaderStaticUrl = _transparent; } @@ -108,8 +108,8 @@ public class UserRenderer( { Id = p.Id, Shortcode = p.Name, - Url = p.PublicUrl, - StaticUrl = p.PublicUrl, //TODO + Url = p.GetAccessUrl(config.Value), + StaticUrl = p.GetAccessUrl(config.Value), //TODO VisibleInPicker = true, Category = p.Category }) diff --git a/Iceshrimp.Backend/Controllers/Pleroma/EmojiController.cs b/Iceshrimp.Backend/Controllers/Pleroma/EmojiController.cs index e59d6a39..64460e74 100644 --- a/Iceshrimp.Backend/Controllers/Pleroma/EmojiController.cs +++ b/Iceshrimp.Backend/Controllers/Pleroma/EmojiController.cs @@ -3,11 +3,13 @@ using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Controllers.Shared.Attributes; +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Pleroma; @@ -15,7 +17,7 @@ namespace Iceshrimp.Backend.Controllers.Pleroma; [EnableCors("mastodon")] [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] -public class EmojiController(DatabaseContext db) : ControllerBase +public class EmojiController(IOptions instance, DatabaseContext db) : ControllerBase { [HttpGet("/api/v1/pleroma/emoji")] [ProducesResults(HttpStatusCode.OK)] @@ -26,11 +28,11 @@ public class EmojiController(DatabaseContext db) : ControllerBase .Select(p => KeyValuePair.Create(p.Name, new PleromaEmojiEntity { - ImageUrl = p.PublicUrl, + ImageUrl = p.GetAccessUrl(instance.Value), Tags = new[] { p.Category ?? "" } })) .ToArrayAsync(); return new Dictionary(emoji); } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Web/DriveController.cs b/Iceshrimp.Backend/Controllers/Web/DriveController.cs index a3da5db8..7f45c583 100644 --- a/Iceshrimp.Backend/Controllers/Web/DriveController.cs +++ b/Iceshrimp.Backend/Controllers/Web/DriveController.cs @@ -3,6 +3,7 @@ using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; @@ -10,6 +11,7 @@ using Iceshrimp.Backend.Core.Services; using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -23,63 +25,78 @@ public class DriveController( IOptionsSnapshot options, ILogger logger, DriveService driveSvc, - QueueService queueSvc + QueueService queueSvc, + HttpClient httpClient ) : ControllerBase { + private const string CacheControl = "max-age=31536000, immutable"; + [EnableCors("drive")] - [HttpGet("/files/{accessKey}")] + [EnableRateLimiting("proxy")] + [HttpGet("/files/{accessKey}/{version?}")] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] [ProducesErrors(HttpStatusCode.NotFound)] - public async Task GetFileByAccessKey(string accessKey) + public async Task GetFileByAccessKey(string accessKey, string? version) { - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey || - p.PublicAccessKey == accessKey || - p.ThumbnailAccessKey == accessKey); - if (file == null) + return await GetFileByAccessKey(accessKey, version, null); + } + + [EnableCors("drive")] + [EnableRateLimiting("proxy")] + [HttpGet("/media/emoji/{id}")] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetEmojiById(string id) + { + var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) + ?? throw GracefulException.NotFound("Emoji not found"); + + if (!options.Value.ProxyRemoteMedia) + return Redirect(emoji.RawPublicUrl); + + return await ProxyAsync(emoji.RawPublicUrl, null, null); + } + + [EnableCors("drive")] + [EnableRateLimiting("proxy")] + [HttpGet("/avatars/{userId}/{version}")] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetAvatarByUserId(string userId, string? version) + { + var user = await db.Users.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == userId) + ?? throw GracefulException.NotFound("User not found"); + + if (user.Avatar is null) { - Response.Headers.CacheControl = "max-age=86400"; - throw GracefulException.NotFound("File not found"); + var stream = await IdenticonHelper.GetIdenticonAsync(user.Id); + Response.Headers.CacheControl = CacheControl; + return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false); } - if (file.StoredInternal) - { - var pathBase = options.Value.Local?.Path; - if (string.IsNullOrWhiteSpace(pathBase)) - { - logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey); - throw GracefulException.NotFound("File not found"); - } + if (!options.Value.ProxyRemoteMedia) + return Redirect(user.Avatar.RawThumbnailAccessUrl); - var path = Path.Join(pathBase, accessKey); - var stream = System.IO.File.OpenRead(path); + return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar); + } - Response.Headers.CacheControl = "max-age=31536000, immutable"; - Response.Headers.XContentTypeOptions = "nosniff"; - return Constants.BrowserSafeMimeTypes.Contains(file.Type) - ? new InlineFileStreamResult(stream, file.Type, file.Name, true) - : File(stream, file.Type, file.Name, true); - } - else - { - if (file.IsLink) - { - //TODO: handle remove media proxying - return NoContent(); - } + [EnableCors("drive")] + [EnableRateLimiting("proxy")] + [HttpGet("/banners/{userId}/{version}")] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetBannerByUserId(string userId, string? version) + { + var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId) + ?? throw GracefulException.NotFound("User not found"); - var stream = await objectStorage.GetFileAsync(accessKey); - if (stream == null) - { - logger.LogError("Failed to get file {accessKey} from object storage", accessKey); - throw GracefulException.NotFound("File not found"); - } + if (user.Banner is null) + return NoContent(); - Response.Headers.CacheControl = "max-age=31536000, immutable"; - Response.Headers.XContentTypeOptions = "nosniff"; - return Constants.BrowserSafeMimeTypes.Contains(file.Type) - ? new InlineFileStreamResult(stream, file.Type, file.Name, true) - : File(stream, file.Type, file.Name, true); - } + if (!options.Value.ProxyRemoteMedia) + return Redirect(user.Banner.RawThumbnailAccessUrl); + + return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner); } [HttpPost] @@ -110,14 +127,14 @@ public class DriveController( public async Task GetFileById(string id) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? - throw GracefulException.NotFound("File not found"); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) + ?? throw GracefulException.NotFound("File not found"); return new DriveFileResponse { Id = file.Id, - Url = file.AccessUrl, - ThumbnailUrl = file.ThumbnailAccessUrl, + Url = file.RawAccessUrl, + ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Description = file.Comment, @@ -134,14 +151,14 @@ public class DriveController( public async Task GetFileByHash(string sha256) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) ?? - throw GracefulException.NotFound("File not found"); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) + ?? throw GracefulException.NotFound("File not found"); return new DriveFileResponse { Id = file.Id, - Url = file.AccessUrl, - ThumbnailUrl = file.ThumbnailAccessUrl, + Url = file.RawAccessUrl, + ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Description = file.Comment, @@ -159,8 +176,8 @@ public class DriveController( public async Task UpdateFile(string id, UpdateDriveFileRequest request) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? - throw GracefulException.NotFound("File not found"); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) + ?? throw GracefulException.NotFound("File not found"); file.Name = request.Filename ?? file.Name; file.IsSensitive = request.Sensitive ?? file.IsSensitive; @@ -178,8 +195,8 @@ public class DriveController( public async Task DeleteFile(string id) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? - throw GracefulException.NotFound("File not found"); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) + ?? throw GracefulException.NotFound("File not found"); if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file)) throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar"); @@ -193,4 +210,104 @@ public class DriveController( return StatusCode(StatusCodes.Status202Accepted); } -} \ No newline at end of file + + private async Task GetFileByAccessKey(string accessKey, string? version, DriveFile? file) + { + file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey + || p.PublicAccessKey == accessKey + || p.ThumbnailAccessKey == accessKey); + if (file == null) + { + Response.Headers.CacheControl = "max-age=86400"; + throw GracefulException.NotFound("File not found"); + } + + if (file.IsLink) + { + if (!options.Value.ProxyRemoteMedia) + return NoContent(); + + try + { + var fetchUrl = version is "thumbnail" + ? file.RawThumbnailAccessUrl + : file.RawAccessUrl; + + var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp") + ? file.Name + : $"{file.Name}.webp"; + + return await ProxyAsync(fetchUrl, file.Type, filename); + } + catch (Exception e) when (e is not GracefulException) + { + throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true); + } + } + + if (file.StoredInternal) + { + var pathBase = options.Value.Local?.Path; + if (string.IsNullOrWhiteSpace(pathBase)) + { + logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey); + throw GracefulException.NotFound("File not found"); + } + + var path = Path.Join(pathBase, accessKey); + var stream = System.IO.File.OpenRead(path); + + Response.Headers.CacheControl = CacheControl; + Response.Headers.XContentTypeOptions = "nosniff"; + + return Constants.BrowserSafeMimeTypes.Contains(file.Type) + ? new InlineFileStreamResult(stream, file.Type, file.Name, true) + : File(stream, file.Type, file.Name, true); + } + else + { + var stream = await objectStorage.GetFileAsync(accessKey); + if (stream == null) + { + logger.LogError("Failed to get file {accessKey} from object storage", accessKey); + throw GracefulException.NotFound("File not found"); + } + + Response.Headers.CacheControl = CacheControl; + Response.Headers.XContentTypeOptions = "nosniff"; + + return Constants.BrowserSafeMimeTypes.Contains(file.Type) + ? new InlineFileStreamResult(stream, file.Type, file.Name, true) + : File(stream, file.Type, file.Name, true); + } + } + + private async Task ProxyAsync(string url, string? expectedMediaType, string? filename) + { + try + { + // @formatter:off + var res = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + if (!res.IsSuccessStatusCode) + throw GracefulException.BadGateway($"Failed to proxy request: response status was {res.StatusCode}", suppressLog: true); + if (res.Content.Headers.ContentType?.MediaType is not { } mediaType) + throw GracefulException.BadGateway("Failed to proxy request: remote didn't return Content-Type"); + if (expectedMediaType != null && mediaType != expectedMediaType && !Constants.BrowserSafeMimeTypes.Contains(mediaType)) + throw GracefulException.BadGateway("Failed to proxy request: content type mismatch", suppressLog: true); + // @formatter:on + + Response.Headers.CacheControl = CacheControl; + Response.Headers.XContentTypeOptions = "nosniff"; + + var stream = await res.Content.ReadAsStreamAsync(); + + return Constants.BrowserSafeMimeTypes.Contains(mediaType) + ? new InlineFileStreamResult(stream, mediaType, filename, true) + : File(stream, mediaType, filename, true); + } + catch (Exception e) when (e is not GracefulException) + { + throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true); + } + } +} diff --git a/Iceshrimp.Backend/Controllers/Web/EmojiController.cs b/Iceshrimp.Backend/Controllers/Web/EmojiController.cs index d7f84494..fe24dcc9 100644 --- a/Iceshrimp.Backend/Controllers/Web/EmojiController.cs +++ b/Iceshrimp.Backend/Controllers/Web/EmojiController.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; @@ -8,6 +9,7 @@ using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Web; @@ -18,6 +20,7 @@ namespace Iceshrimp.Backend.Controllers.Web; [Route("/api/iceshrimp/emoji")] [Produces(MediaTypeNames.Application.Json)] public class EmojiController( + IOptions instance, DatabaseContext db, EmojiService emojiSvc, EmojiImportService emojiImportSvc @@ -36,7 +39,7 @@ public class EmojiController( Uri = p.Uri, Aliases = p.Aliases, Category = p.Category, - PublicUrl = p.PublicUrl, + PublicUrl = p.GetAccessUrl(instance.Value), License = p.License, Sensitive = p.Sensitive }) @@ -58,7 +61,7 @@ public class EmojiController( Uri = emoji.Uri, Aliases = emoji.Aliases, Category = emoji.Category, - PublicUrl = emoji.PublicUrl, + PublicUrl = emoji.GetAccessUrl(instance.Value), License = emoji.License, Sensitive = emoji.Sensitive }; @@ -80,7 +83,7 @@ public class EmojiController( Uri = emoji.Uri, Aliases = [], Category = null, - PublicUrl = emoji.PublicUrl, + PublicUrl = emoji.GetAccessUrl(instance.Value), License = null, Sensitive = false }; @@ -106,7 +109,7 @@ public class EmojiController( Uri = cloned.Uri, Aliases = [], Category = null, - PublicUrl = cloned.PublicUrl, + PublicUrl = cloned.GetAccessUrl(instance.Value), License = null, Sensitive = cloned.Sensitive }; @@ -141,7 +144,7 @@ public class EmojiController( Uri = emoji.Uri, Aliases = emoji.Aliases, Category = emoji.Category, - PublicUrl = emoji.PublicUrl, + PublicUrl = emoji.GetAccessUrl(instance.Value), License = emoji.License, Sensitive = emoji.Sensitive }; diff --git a/Iceshrimp.Backend/Controllers/Web/ProfileController.cs b/Iceshrimp.Backend/Controllers/Web/ProfileController.cs index 61fd51e0..261b2f40 100644 --- a/Iceshrimp.Backend/Controllers/Web/ProfileController.cs +++ b/Iceshrimp.Backend/Controllers/Web/ProfileController.cs @@ -1,12 +1,14 @@ using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Web; @@ -16,7 +18,11 @@ namespace Iceshrimp.Backend.Controllers.Web; [EnableRateLimiting("sliding")] [Route("/api/iceshrimp/profile")] [Produces(MediaTypeNames.Application.Json)] -public class ProfileController(UserService userSvc, DriveService driveSvc) : ControllerBase +public class ProfileController( + IOptions instance, + UserService userSvc, + DriveService driveSvc +) : ControllerBase { [HttpGet] [ProducesResults(HttpStatusCode.OK)] @@ -34,7 +40,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con Birthday = profile.Birthday, FFVisibility = ffVisibility, Fields = fields.ToList(), - DisplayName = user.DisplayName ?? "", + DisplayName = user.DisplayName ?? "", IsBot = user.IsBot, IsCat = user.IsCat, SpeakAsCat = user.SpeakAsCat @@ -80,13 +86,13 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con var prevBannerId = user.BannerId; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } - + [HttpGet("avatar")] [ProducesResults(HttpStatusCode.OK)] public string GetAvatarUrl() { var user = HttpContext.GetUserOrFail(); - return user.AvatarUrl ?? ""; + return user.AvatarId != null ? user.GetAvatarUrl(instance.Value) : ""; } [HttpPost("avatar")] @@ -113,7 +119,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con user.Avatar = avatar; user.AvatarBlurhash = avatar.Blurhash; - user.AvatarUrl = avatar.AccessUrl; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } @@ -133,19 +138,18 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con user.Avatar = null; user.AvatarBlurhash = null; - user.AvatarUrl = null; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } - + [HttpGet("banner")] [ProducesResults(HttpStatusCode.OK)] public string GetBannerUrl() { var user = HttpContext.GetUserOrFail(); - return user.BannerUrl ?? ""; + return user.GetBannerUrl(instance.Value) ?? ""; } - + [HttpPost("banner")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest)] @@ -170,7 +174,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con user.Banner = banner; user.BannerBlurhash = banner.Blurhash; - user.BannerUrl = banner.AccessUrl; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } @@ -190,8 +193,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con user.Banner = null; user.BannerBlurhash = null; - user.BannerUrl = null; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Web/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Controllers/Web/Renderers/NoteRenderer.cs index 17099fd2..e71706c0 100644 --- a/Iceshrimp.Backend/Controllers/Web/Renderers/NoteRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Web/Renderers/NoteRenderer.cs @@ -14,6 +14,7 @@ public class NoteRenderer( UserRenderer userRenderer, DatabaseContext db, EmojiService emojiSvc, + MediaProxyService mediaProxy, IOptions config ) : IScopedService { @@ -114,8 +115,8 @@ public class NoteRenderer( return files.Select(p => new NoteAttachment { Id = p.Id, - Url = p.AccessUrl, - ThumbnailUrl = p.ThumbnailAccessUrl, + Url = mediaProxy.GetProxyUrl(p), + ThumbnailUrl = mediaProxy.GetThumbnailProxyUrl(p), ContentType = p.Type, Blurhash = p.Blurhash, AltText = p.Comment, @@ -141,7 +142,7 @@ public class NoteRenderer( i.User == user), Name = p.First().Reaction, Url = null, - Sensitive = false, + Sensitive = false }) .ToListAsync(); @@ -149,7 +150,7 @@ public class NoteRenderer( { var hit = await emojiSvc.ResolveEmojiAsync(item.Name); if (hit == null) continue; - item.Url = hit.PublicUrl; + item.Url = hit.GetAccessUrl(config.Value); item.Sensitive = hit.Sensitive; } @@ -193,7 +194,7 @@ public class NoteRenderer( Uri = p.Uri, Aliases = p.Aliases, Category = p.Category, - PublicUrl = p.PublicUrl, + PublicUrl = p.GetAccessUrl(config.Value), License = p.License, Sensitive = p.Sensitive }) @@ -229,4 +230,4 @@ public class NoteRenderer( public List? Reactions; public List? Users; } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Web/Renderers/NotificationRenderer.cs b/Iceshrimp.Backend/Controllers/Web/Renderers/NotificationRenderer.cs index c50c356d..dbd8f08c 100644 --- a/Iceshrimp.Backend/Controllers/Web/Renderers/NotificationRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Web/Renderers/NotificationRenderer.cs @@ -1,33 +1,38 @@ +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Services; using Iceshrimp.Shared.Schemas.Web; +using Microsoft.Extensions.Options; using static Iceshrimp.Shared.Schemas.Web.NotificationResponse; namespace Iceshrimp.Backend.Controllers.Web.Renderers; -public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRenderer, EmojiService emojiSvc) : IScopedService +public class NotificationRenderer( + IOptions instance, + UserRenderer userRenderer, + NoteRenderer noteRenderer, + EmojiService emojiSvc +) : IScopedService { private static NotificationResponse Render(Notification notification, NotificationRendererDto data) { var user = notification.Notifier != null - ? data.Users?.First(p => p.Id == notification.Notifier.Id) ?? - throw new Exception("DTO didn't contain the notifier") + ? data.Users?.First(p => p.Id == notification.Notifier.Id) + ?? throw new Exception("DTO didn't contain the notifier") : null; var note = notification.Note != null - ? data.Notes?.First(p => p.Id == notification.Note.Id) ?? - throw new Exception("DTO didn't contain the note") + ? data.Notes?.First(p => p.Id == notification.Note.Id) ?? throw new Exception("DTO didn't contain the note") : null; var bite = notification.Bite != null - ? data.Bites?.First(p => p.Id == notification.Bite.Id) ?? - throw new Exception("DTO didn't contain the bite") + ? data.Bites?.First(p => p.Id == notification.Bite.Id) ?? throw new Exception("DTO didn't contain the bite") : null; var reaction = notification.Reaction != null - ? data.Reactions?.First(p => p.Name == notification.Reaction) ?? - throw new Exception("DTO didn't contain the reaction") + ? data.Reactions?.First(p => p.Name == notification.Reaction) + ?? throw new Exception("DTO didn't contain the reaction") : null; return new NotificationResponse @@ -38,7 +43,7 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe User = user, Note = note, Bite = bite, - Reaction = reaction, + Reaction = reaction, Type = RenderType(notification.Type) }; } @@ -49,9 +54,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe { var data = new NotificationRendererDto { - Users = await GetUsersAsync([notification]), - Notes = await GetNotesAsync([notification], localUser), - Bites = GetBites([notification]), + Users = await GetUsersAsync([notification]), + Notes = await GetNotesAsync([notification], localUser), + Bites = GetBites([notification]), Reactions = await GetReactionsAsync([notification]) }; @@ -96,24 +101,41 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe var bites = notifications.Select(p => p.Bite).NotNull().DistinctBy(p => p.Id); return bites.Select(p => new BiteResponse { Id = p.Id, BiteBack = p.TargetBiteId != null }).ToList(); } - + private async Task> GetReactionsAsync(IEnumerable notifications) { var reactions = notifications.Select(p => p.Reaction).NotNull().ToList(); - var emojis = reactions.Where(p => !EmojiService.IsCustomEmoji(p)).Select(p => new ReactionResponse { Name = p, Url = null, Sensitive = false }).ToList(); - var custom = reactions.Where(EmojiService.IsCustomEmoji).ToArray(); + var emojis = reactions.Where(p => !EmojiService.IsCustomEmoji(p)) + .Select(p => new ReactionResponse + { + Name = p, + Url = null, + Sensitive = false + }) + .ToList(); + var custom = reactions.Where(EmojiService.IsCustomEmoji).ToArray(); foreach (var s in custom) { var emoji = await emojiSvc.ResolveEmojiAsync(s); var reaction = emoji != null - ? new ReactionResponse { Name = s, Url = emoji.PublicUrl, Sensitive = emoji.Sensitive } - : new ReactionResponse { Name = s, Url = null, Sensitive = false }; - + ? new ReactionResponse + { + Name = s, + Url = emoji.GetAccessUrl(instance.Value), + Sensitive = emoji.Sensitive + } + : new ReactionResponse + { + Name = s, + Url = null, + Sensitive = false + }; + emojis.Add(reaction); } - + return emojis; } @@ -124,9 +146,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe var notificationsList = notifications.ToList(); var data = new NotificationRendererDto { - Users = await GetUsersAsync(notificationsList), - Notes = await GetNotesAsync(notificationsList, user), - Bites = GetBites(notificationsList), + Users = await GetUsersAsync(notificationsList), + Notes = await GetNotesAsync(notificationsList, user), + Bites = GetBites(notificationsList), Reactions = await GetReactionsAsync(notificationsList) }; @@ -135,9 +157,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe private class NotificationRendererDto { - public List? Notes; - public List? Users; - public List? Bites; + public List? Notes; + public List? Users; + public List? Bites; public List? Reactions; } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Web/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Controllers/Web/Renderers/UserRenderer.cs index 7dd18840..12509419 100644 --- a/Iceshrimp.Backend/Controllers/Web/Renderers/UserRenderer.cs +++ b/Iceshrimp.Backend/Controllers/Web/Renderers/UserRenderer.cs @@ -27,8 +27,8 @@ public class UserRenderer(IOptions config, DatabaseConte Username = user.Username, Host = user.Host, DisplayName = user.DisplayName, - AvatarUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), - BannerUrl = user.BannerUrl, + AvatarUrl = user.GetAvatarUrl(config.Value), + BannerUrl = user.GetBannerUrl(config.Value), InstanceName = instanceName, InstanceIconUrl = instanceIcon, Emojis = emoji, @@ -78,7 +78,7 @@ public class UserRenderer(IOptions config, DatabaseConte Uri = p.Uri, Aliases = p.Aliases, Category = p.Category, - PublicUrl = p.PublicUrl, + PublicUrl = p.GetAccessUrl(config.Value), License = p.License, Sensitive = p.Sensitive }) diff --git a/Iceshrimp.Backend/Core/Configuration/Config.cs b/Iceshrimp.Backend/Core/Configuration/Config.cs index 199f7b15..9635ea15 100644 --- a/Iceshrimp.Backend/Core/Configuration/Config.cs +++ b/Iceshrimp.Backend/Core/Configuration/Config.cs @@ -85,9 +85,10 @@ public sealed class Config public readonly long? MaxUploadSizeBytes; public readonly TimeSpan? MediaRetentionTimeSpan; - public bool CleanAvatars = false; - public bool CleanBanners = false; - public Enums.FileStorage Provider { get; init; } = Enums.FileStorage.Local; + public bool CleanAvatars { get; init; } = false; + public bool CleanBanners { get; init; } = false; + public bool ProxyRemoteMedia { get; init; } = true; + public Enums.FileStorage Provider { get; init; } = Enums.FileStorage.Local; [Obsolete("This property is for backwards compatibility only, use StorageSection.Provider instead", true)] [SuppressMessage("ReSharper", "UnusedMember.Global")] diff --git a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs index ad51afb9..cdaf6c35 100644 --- a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -747,6 +747,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("id"); b.Property("AccessKey") + .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("accessKey"); @@ -3998,12 +3999,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("avatarId") .HasComment("The ID of avatar DriveFile."); - b.Property("AvatarUrl") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("avatarUrl") - .HasComment("The URL of the avatar DriveFile"); - b.Property("BannerBlurhash") .HasMaxLength(128) .HasColumnType("character varying(128)") @@ -4016,12 +4011,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("bannerId") .HasComment("The ID of banner DriveFile."); - b.Property("BannerUrl") - .HasMaxLength(512) - .HasColumnType("character varying(512)") - .HasColumnName("bannerUrl") - .HasComment("The URL of the banner DriveFile"); - b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("createdAt") diff --git a/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20241123224718_MakeDriveFileAccessKeyNonOptional.cs b/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20241123224718_MakeDriveFileAccessKeyNonOptional.cs new file mode 100644 index 00000000..baee5b0f --- /dev/null +++ b/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20241123224718_MakeDriveFileAccessKeyNonOptional.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace Iceshrimp.Backend.Core.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241123224718_MakeDriveFileAccessKeyNonOptional")] + public partial class MakeDriveFileAccessKeyNonOptional : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + UPDATE "drive_file" SET "accessKey" = gen_random_uuid() WHERE "accessKey" IS NULL; + """); + + migrationBuilder.AlterColumn( + name: "accessKey", + table: "drive_file", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "accessKey", + table: "drive_file", + type: "character varying(256)", + maxLength: 256, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(256)", + oldMaxLength: 256); + } + } +} diff --git a/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20250109095535_RemoteUserAvatarBannerUrlColumns.cs b/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20250109095535_RemoteUserAvatarBannerUrlColumns.cs new file mode 100644 index 00000000..fc9b786f --- /dev/null +++ b/Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta5/20250109095535_RemoteUserAvatarBannerUrlColumns.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace Iceshrimp.Backend.Core.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20250109095535_RemoteUserAvatarBannerUrlColumns")] + public partial class RemoteUserAvatarBannerUrlColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "avatarUrl", + table: "user"); + + migrationBuilder.DropColumn( + name: "bannerUrl", + table: "user"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "avatarUrl", + table: "user", + type: "character varying(512)", + maxLength: 512, + nullable: true, + comment: "The URL of the avatar DriveFile"); + + migrationBuilder.AddColumn( + name: "bannerUrl", + table: "user", + type: "character varying(512)", + maxLength: 512, + nullable: true, + comment: "The URL of the banner DriveFile"); + + migrationBuilder.Sql(""" + UPDATE "user" SET "avatarUrl" = (SELECT COALESCE("webpublicUrl", "url") FROM "drive_file" WHERE "id" = "user"."avatarId"); + UPDATE "user" SET "bannerUrl" = (SELECT COALESCE("webpublicUrl", "url") FROM "drive_file" WHERE "id" = "user"."bannerId"); + """); + } + } +} diff --git a/Iceshrimp.Backend/Core/Database/Tables/DriveFile.cs b/Iceshrimp.Backend/Core/Database/Tables/DriveFile.cs index 4c563404..afafb5ec 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/DriveFile.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/DriveFile.cs @@ -106,7 +106,7 @@ public class DriveFile : IEntity [Column("accessKey")] [StringLength(256)] - public string? AccessKey { get; set; } + public string AccessKey { get; set; } = null!; [Column("thumbnailAccessKey")] [StringLength(256)] @@ -189,8 +189,8 @@ public class DriveFile : IEntity [InverseProperty(nameof(Tables.User.Banner))] public virtual User? UserBanner { get; set; } - [NotMapped] public string AccessUrl => PublicUrl ?? Url; - [NotMapped] public string ThumbnailAccessUrl => ThumbnailUrl ?? PublicUrl ?? Url; + [NotMapped] public string RawAccessUrl => PublicUrl ?? Url; + [NotMapped] public string RawThumbnailAccessUrl => ThumbnailUrl ?? PublicUrl ?? Url; [Key] [Column("id")] @@ -243,4 +243,4 @@ public class DriveFile : IEntity .OnDelete(DeleteBehavior.SetNull); } } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Core/Database/Tables/Emoji.cs b/Iceshrimp.Backend/Core/Database/Tables/Emoji.cs index 8f0d0a74..e8b38c3c 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/Emoji.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/Emoji.cs @@ -40,7 +40,7 @@ public class Emoji [Column("publicUrl")] [StringLength(512)] - public string PublicUrl { get; set; } = null!; + public string RawPublicUrl { get; set; } = null!; [Column("license")] [StringLength(1024)] @@ -69,13 +69,16 @@ public class Emoji ? $"https://{config.WebDomain}/emoji/{Name}" : null; + public string GetAccessUrl(Config.InstanceSection config) + => $"https://{config.WebDomain}/media/emoji/{Id}"; + private class EntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder entity) { entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Height).HasComment("Image height"); - entity.Property(e => e.PublicUrl).HasDefaultValueSql("''::character varying"); + entity.Property(e => e.RawPublicUrl).HasDefaultValueSql("''::character varying"); entity.Property(e => e.Width).HasComment("Image width"); } } diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs index 762d22c5..f27ea28d 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/User.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs @@ -242,13 +242,6 @@ public class User : IEntity [Column("speakAsCat")] public bool SpeakAsCat { get; set; } - /// - /// The URL of the avatar DriveFile - /// - [Column("avatarUrl")] - [StringLength(512)] - public string? AvatarUrl { get; set; } - /// /// The blurhash of the avatar DriveFile /// @@ -256,13 +249,6 @@ public class User : IEntity [StringLength(128)] public string? AvatarBlurhash { get; set; } - /// - /// The URL of the banner DriveFile - /// - [Column("bannerUrl")] - [StringLength(512)] - public string? BannerUrl { get; set; } - /// /// The blurhash of the banner DriveFile /// @@ -587,12 +573,12 @@ public class User : IEntity [Projectable] public bool HasInteractedWith(Note note) => - HasLiked(note) || - HasReacted(note) || - HasBookmarked(note) || - HasReplied(note) || - HasRenoted(note) || - HasVoted(note); + HasLiked(note) + || HasReacted(note) + || HasBookmarked(note) + || HasReplied(note) + || HasRenoted(note) + || HasVoted(note); [Projectable] public bool ProhibitInteractionWith(User user) => IsBlocking(user) || IsBlockedBy(user); @@ -623,11 +609,10 @@ public class User : IEntity return this; } - public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain); - public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain); - public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain); - public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain); - public string GetIdenticonUrlPng(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain) + ".png"; + public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain); + public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain); + public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain); + public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain); public string GetPublicUri(string webDomain) => Host == null ? $"https://{webDomain}/users/{Id}" @@ -643,6 +628,12 @@ public class User : IEntity public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}"; + public string GetAvatarUrl(Config.InstanceSection config) + => $"https://{config.WebDomain}/avatars/{Id}/{AvatarId ?? "identicon"}"; + + public string? GetBannerUrl(Config.InstanceSection config) + => BannerId != null ? $"https://{config.WebDomain}/banners/{Id}/{BannerId}" : null; + private class EntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder entity) @@ -650,10 +641,8 @@ public class User : IEntity entity.Property(e => e.AlsoKnownAs).HasComment("URIs the user is known as too"); entity.Property(e => e.AvatarBlurhash).HasComment("The blurhash of the avatar DriveFile"); entity.Property(e => e.AvatarId).HasComment("The ID of avatar DriveFile."); - entity.Property(e => e.AvatarUrl).HasComment("The URL of the avatar DriveFile"); entity.Property(e => e.BannerBlurhash).HasComment("The blurhash of the banner DriveFile"); entity.Property(e => e.BannerId).HasComment("The ID of banner DriveFile."); - entity.Property(e => e.BannerUrl).HasComment("The URL of the banner DriveFile"); entity.Property(e => e.CreatedAt).HasComment("The created date of the User."); entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit"); entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]"); @@ -725,4 +714,4 @@ public class User : IEntity .OnDelete(DeleteBehavior.SetNull); } } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index 94aa9067..5481188c 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -247,12 +247,22 @@ public static class ServiceExtensions QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 }; + + var proxy = new SlidingWindowRateLimiterOptions + { + PermitLimit = 10, + SegmentsPerWindow = 10, + Window = TimeSpan.FromSeconds(10), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }; // @formatter:off options.AddPolicy("sliding", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false),_ => sliding)); options.AddPolicy("auth", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false), _ => auth)); options.AddPolicy("strict", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => strict)); options.AddPolicy("imports", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => imports)); + options.AddPolicy("proxy", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => proxy)); // @formatter:on options.OnRejected = async (context, token) => diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index 8c49bf58..5fbf855a 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -92,7 +92,7 @@ public class ActivityRenderer( { Id = emoji.GetPublicUriOrNull(config.Value), Name = name, - Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } + Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl) } }; res.Tags = [e]; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs index b1e48387..f6415da5 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs @@ -105,7 +105,7 @@ public class NoteRenderer( { Id = e.GetPublicUri(config.Value), Name = e.Name, - Image = new ASImage { Url = new ASLink(e.PublicUrl) } + Image = new ASImage { Url = new ASLink(e.RawPublicUrl) } })) .ToList(); @@ -119,14 +119,14 @@ public class NoteRenderer( var attachments = driveFiles?.Select(p => new ASDocument { Sensitive = p.IsSensitive, - Url = new ASLink(p.AccessUrl), + Url = new ASLink(p.RawAccessUrl), MediaType = p.Type, Description = p.Comment }) .Cast() .ToList(); - var inlineMedia = driveFiles?.Select(p => new MfmInlineMedia(MfmInlineMedia.GetType(p.Type), p.AccessUrl, p.Comment)) + var inlineMedia = driveFiles?.Select(p => new MfmInlineMedia(MfmInlineMedia.GetType(p.Type), p.RawAccessUrl, p.Comment)) .ToList(); var quoteUri = note.IsQuote ? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(config.Value) : null; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs index e909d2b9..0237bea9 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs @@ -40,6 +40,19 @@ public class UserRenderer( if (keypair == null) throw new Exception("User has no keypair"); + // Fetch avatar/banner relations if missing + if ((user.Avatar == null && user.AvatarId != null) || (user.Banner == null && user.BannerId != null)) + { + var newUser = await db.Users + .IncludeCommonProperties() + .Include(p => p.Avatar) + .Include(p => p.Banner) + .FirstOrDefaultAsync(p => p.Id == user.Id); + + if (newUser != null) + user = newUser; + } + var id = user.GetPublicUri(config.Value); var type = Constants.SystemUsers.Contains(user.UsernameLower) ? ASActor.Types.Application @@ -61,7 +74,7 @@ public class UserRenderer( { Id = e.GetPublicUri(config.Value), Name = e.Name, - Image = new ASImage { Url = new ASLink(e.PublicUrl) } + Image = new ASImage { Url = new ASLink(e.RawPublicUrl) } })) .ToList(); @@ -97,11 +110,11 @@ public class UserRenderer( AlsoKnownAs = user.AlsoKnownAs?.Select(p => new ASLink(p)).ToList(), MovedTo = user.MovedToUri is not null ? new ASLink(user.MovedToUri) : null, Featured = new ASOrderedCollection($"{id}/collections/featured"), - Avatar = user.AvatarUrl != null - ? new ASImage { Url = new ASLink(user.AvatarUrl) } + Avatar = user.Avatar != null + ? new ASImage { Url = new ASLink(user.Avatar.RawAccessUrl) } : null, - Banner = user.BannerUrl != null - ? new ASImage { Url = new ASLink(user.BannerUrl) } + Banner = user.Banner != null + ? new ASImage { Url = new ASLink(user.Banner.RawAccessUrl) } : null, Endpoints = new ASEndpoints { SharedInbox = new ASObjectBase($"https://{config.Value.WebDomain}/inbox") }, PublicKey = new ASPublicKey @@ -124,4 +137,4 @@ public class UserRenderer( var displayUri = uri.Host + uri.PathAndQuery + uri.Fragment; return $"{displayUri}"; } -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs b/Iceshrimp.Backend/Core/Helpers/IdenticonHelper.cs similarity index 88% rename from Iceshrimp.Backend/Controllers/Web/IdenticonController.cs rename to Iceshrimp.Backend/Core/Helpers/IdenticonHelper.cs index 76d167d5..0478a89e 100644 --- a/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs +++ b/Iceshrimp.Backend/Core/Helpers/IdenticonHelper.cs @@ -1,27 +1,15 @@ using System.IO.Hashing; -using System.Net; -using System.Net.Mime; using System.Text; -using Iceshrimp.Backend.Controllers.Shared.Attributes; -using Microsoft.AspNetCore.Cors; -using Microsoft.AspNetCore.Mvc; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -namespace Iceshrimp.Backend.Controllers.Web; +namespace Iceshrimp.Backend.Core.Helpers; -[ApiController] -[EnableCors("drive")] -[Route("/identicon/{id}")] -[Route("/identicon/{id}.png")] -[Produces(MediaTypeNames.Image.Png)] -public class IdenticonController : ControllerBase +public static class IdenticonHelper { - [HttpGet] - [ProducesResults(HttpStatusCode.OK)] - public async Task GetIdenticon(string id) + public static async Task GetIdenticonAsync(string id) { using var image = new Image(Size, Size); @@ -74,9 +62,10 @@ public class IdenticonController : ControllerBase } } - Response.Headers.CacheControl = "max-age=31536000, immutable"; - Response.Headers.ContentType = "image/png"; - await image.SaveAsPngAsync(Response.Body); + var stream = new MemoryStream(); + await image.SaveAsPngAsync(stream); + stream.Seek(0, SeekOrigin.Begin); + return stream; } #region Color definitions & Constants @@ -128,4 +117,4 @@ public class IdenticonController : ControllerBase ]; #endregion -} \ No newline at end of file +} diff --git a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs index e22e161c..973ef106 100644 --- a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs +++ b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs @@ -8,6 +8,7 @@ using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.MfmSharp; +using Iceshrimp.Backend.Core.Services; using Microsoft.Extensions.Options; using MfmHtmlParser = Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser; using HtmlParser = AngleSharp.Html.Parser.HtmlParser; @@ -41,7 +42,8 @@ public readonly record struct HtmlMfmData(string Mfm, List Inlin public readonly record struct MfmHtmlData(string Html, List InlineMedia); public class MfmConverter( - IOptions config + IOptions config, + MediaProxyService mediaProxy ) : ISingletonService { public AsyncLocal SupportsHtmlFormatting { get; } = new(); @@ -310,7 +312,7 @@ public class MfmConverter( { var el = document.CreateElement("span"); var inner = document.CreateElement("img"); - inner.SetAttribute("src", hit.PublicUrl); + inner.SetAttribute("src", mediaProxy.GetProxyUrl(hit)); inner.SetAttribute("alt", hit.Name); el.AppendChild(inner); el.ClassList.Add("emoji"); diff --git a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs index cddf844c..af3d56b0 100644 --- a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs @@ -241,6 +241,9 @@ public class GracefulException( new(HttpStatusCode.MisdirectedRequest, HttpStatusCode.MisdirectedRequest.ToString(), "This server is not configured to respond to this request.", null, true, true); + public static GracefulException BadGateway(string message, string? details = null, bool suppressLog = false) => + new(HttpStatusCode.BadGateway, HttpStatusCode.BadGateway.ToString(), message, details, suppressLog); + /// /// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger /// returning 410 Gone) diff --git a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs index b276148b..0f7a020f 100644 --- a/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs +++ b/Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs @@ -69,8 +69,7 @@ public class BackgroundTaskQueue(int parallelism) var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == jobData.DriveFileId, token); if (file == null) return; - var deduplicated = file.AccessKey != null && - await db.DriveFiles.AnyAsync(p => p.Id != file.Id && + var deduplicated = await db.DriveFiles.AnyAsync(p => p.Id != file.Id && p.AccessKey == file.AccessKey && !p.IsLink, token); diff --git a/Iceshrimp.Backend/Core/Services/DriveService.cs b/Iceshrimp.Backend/Core/Services/DriveService.cs index 6dd24506..611dccdc 100644 --- a/Iceshrimp.Backend/Core/Services/DriveService.cs +++ b/Iceshrimp.Backend/Core/Services/DriveService.cs @@ -110,7 +110,7 @@ public class DriveService( Filename = filename, IsSensitive = sensitive, Comment = description, - MimeType = CleanMimeType(mimeType ?? res.Content.Headers.ContentType?.MediaType) + MimeType = CleanMimeType(res.Content.Headers.ContentType?.MediaType ?? mimeType) }; var input = await res.Content.ReadAsStreamAsync(); @@ -148,7 +148,8 @@ public class DriveService( Url = uri, Name = new Uri(uri).AbsolutePath.Split('/').LastOrDefault() ?? "", Comment = description, - Type = CleanMimeType(mimeType ?? "application/octet-stream") + Type = CleanMimeType(mimeType ?? "application/octet-stream"), + AccessKey = Guid.NewGuid().ToStringLower() }; db.Add(file); @@ -191,7 +192,8 @@ public class DriveService( Comment = request.Comment, Type = CleanMimeType(request.MimeType), RequestHeaders = request.RequestHeaders, - RequestIp = request.RequestIp + RequestIp = request.RequestIp, + AccessKey = Guid.NewGuid().ToStringLower() + Path.GetExtension(request.Filename) }; db.Add(file); @@ -331,7 +333,7 @@ public class DriveService( Sha256 = digest, Size = buf.Length, IsLink = !shouldStore, - AccessKey = original?.accessKey, + AccessKey = original?.accessKey ?? Guid.NewGuid().ToStringLower(), IsSensitive = request.IsSensitive, StoredInternal = storedInternal, Src = request.Source, @@ -461,21 +463,13 @@ public class DriveService( file.PublicMimeType = null; file.StoredInternal = false; - await db.Users.Where(p => p.AvatarId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.AvatarUrl, file.Uri), token); - await db.Users.Where(p => p.BannerId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.BannerUrl, file.Uri), token); await db.SaveChangesAsync(token); - if (file.AccessKey != null) - { - var deduplicated = await db.DriveFiles - .AnyAsync(p => p.Id != file.Id && p.AccessKey == file.AccessKey && !p.IsLink, - token); + var deduplicated = + await db.DriveFiles.AnyAsync(p => p.Id != file.Id && p.AccessKey == file.AccessKey && !p.IsLink, token); - if (deduplicated) - return; - } + if (deduplicated) + return; if (storedInternal) { diff --git a/Iceshrimp.Backend/Core/Services/EmojiService.cs b/Iceshrimp.Backend/Core/Services/EmojiService.cs index 75570e9a..f04aea6a 100644 --- a/Iceshrimp.Backend/Core/Services/EmojiService.cs +++ b/Iceshrimp.Backend/Core/Services/EmojiService.cs @@ -55,7 +55,7 @@ public partial class EmojiService( Category = category, UpdatedAt = DateTime.UtcNow, OriginalUrl = driveFile.Url, - PublicUrl = driveFile.AccessUrl, + RawPublicUrl = driveFile.RawAccessUrl, Width = driveFile.Properties.Width, Height = driveFile.Properties.Height, Sensitive = false @@ -81,7 +81,7 @@ public partial class EmojiService( Name = existing.Name, UpdatedAt = DateTime.UtcNow, OriginalUrl = driveFile.Url, - PublicUrl = driveFile.AccessUrl, + RawPublicUrl = driveFile.RawAccessUrl, Width = driveFile.Properties.Width, Height = driveFile.Properties.Height, Sensitive = existing.Sensitive @@ -132,7 +132,7 @@ public partial class EmojiService( Name = emojo.Name ?? throw new Exception("emojo.Name must not be null at this stage"), UpdatedAt = DateTime.UtcNow, OriginalUrl = emojo.Image?.Url?.Link ?? throw new Exception("Emoji.Image has no url"), - PublicUrl = emojo.Image.Url.Link, + RawPublicUrl = emojo.Image.Url.Link, Uri = emojo.Id, Sensitive = false }; diff --git a/Iceshrimp.Backend/Core/Services/MediaProxyService.cs b/Iceshrimp.Backend/Core/Services/MediaProxyService.cs new file mode 100644 index 00000000..4a3c6793 --- /dev/null +++ b/Iceshrimp.Backend/Core/Services/MediaProxyService.cs @@ -0,0 +1,52 @@ +using Iceshrimp.Backend.Core.Configuration; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Configuration.Enums; + +namespace Iceshrimp.Backend.Core.Services; + +[UsedImplicitly] +public class MediaProxyService( + IOptions instance, + IOptionsMonitor storage +) : ISingletonService +{ + public string? GetProxyUrl(string? url, string? accessKey, bool thumbnail = false, string route = "files") + { + if (!storage.CurrentValue.ProxyRemoteMedia || url is null || accessKey is null) return url; + + // Don't proxy local / object storage urls + if ( + storage.CurrentValue.Provider is FileStorage.ObjectStorage + && storage.CurrentValue.ObjectStorage?.AccessUrl is { } accessUrl + && (url.StartsWith(accessUrl) || url.StartsWith($"https://{instance.Value.WebDomain}/")) + ) + { + return url; + } + + return GetProxyUrlInternal($"{route}/{accessKey}", thumbnail); + } + + public string GetProxyUrl(DriveFile file, bool thumbnail) + { + var url = thumbnail ? file.RawThumbnailAccessUrl : file.RawAccessUrl; + if (file.UserHost is null || !file.IsLink) + return url; + + return GetProxyUrlInternal($"files/{file.AccessKey}", thumbnail); + } + + public string GetProxyUrl(Emoji emoji) => emoji.Host is null + ? emoji.RawPublicUrl + : GetProxyUrlInternal($"emoji/{emoji.Id}", thumbnail: false); + + public string GetProxyUrl(DriveFile file) => GetProxyUrl(file, thumbnail: false); + public string GetThumbnailProxyUrl(DriveFile file) => GetProxyUrl(file, thumbnail: true); + + private string GetProxyUrlInternal(string route, bool thumbnail) => thumbnail + ? $"https://{instance.Value.WebDomain}/{route}/thumbnail" + : $"https://{instance.Value.WebDomain}/{route}"; +} diff --git a/Iceshrimp.Backend/Core/Services/ObjectStorageService.cs b/Iceshrimp.Backend/Core/Services/ObjectStorageService.cs index 01d3ff7f..44bea7dd 100644 --- a/Iceshrimp.Backend/Core/Services/ObjectStorageService.cs +++ b/Iceshrimp.Backend/Core/Services/ObjectStorageService.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; +using Microsoft.Net.Http.Headers; using System.Text; using Carbon.Storage; using Iceshrimp.Backend.Core.Configuration; @@ -89,10 +89,11 @@ public class ObjectStorageService(IOptions config, HttpCl if (_bucket == null) throw new Exception("Refusing to upload to object storage with invalid configuration"); var properties = (_acl ?? BlobProperties.Empty).ToDictionary(); - var contentDisposition = new ContentDispositionHeaderValue("inline") { FileName = filename }.ToString(); + var contentDisposition = new ContentDispositionHeaderValue("inline"); + contentDisposition.SetHttpFileName(filename); properties.Add("Content-Type", contentType); - properties.Add("Content-Disposition", contentDisposition); + properties.Add("Content-Disposition", contentDisposition.ToString()); IBlob blob = data.Length > 0 ? new Blob(GetKeyWithPrefix(key), data, properties) diff --git a/Iceshrimp.Backend/Core/Services/StorageMaintenanceService.cs b/Iceshrimp.Backend/Core/Services/StorageMaintenanceService.cs index f9d978d4..28087fcd 100644 --- a/Iceshrimp.Backend/Core/Services/StorageMaintenanceService.cs +++ b/Iceshrimp.Backend/Core/Services/StorageMaintenanceService.cs @@ -92,7 +92,6 @@ public class StorageMaintenanceService( // defer deletions in case an error occurs List deletionQueue = []; - if (file.AccessKey != null) { var path = Path.Join(pathBase, file.AccessKey); var stream = File.OpenRead(path); @@ -179,10 +178,6 @@ public class StorageMaintenanceService( } await driveSvc.ExpireFileAsync(file); - await db.Users.Where(p => p.AvatarId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.AvatarUrl, file.Uri)); - await db.Users.Where(p => p.BannerId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.BannerUrl, file.Uri)); continue; } @@ -218,10 +213,6 @@ public class StorageMaintenanceService( if (dryRun) continue; await db.SaveChangesAsync(); - await db.Users.Where(p => p.AvatarId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.AvatarUrl, file.AccessUrl)); - await db.Users.Where(p => p.BannerId == file.Id) - .ExecuteUpdateAsync(p => p.SetProperty(u => u.BannerUrl, file.AccessUrl)); } if (dryRun) diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index de042d97..4899c6af 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -464,9 +464,6 @@ public class UserService( user.AvatarBlurhash = avatar?.Blurhash; user.BannerBlurhash = banner?.Blurhash; - user.AvatarUrl = avatar?.Url; - user.BannerUrl = banner?.Url; - await db.SaveChangesAsync(); return async () => diff --git a/Iceshrimp.Backend/configuration.ini b/Iceshrimp.Backend/configuration.ini index 8659c0df..d6008cca 100644 --- a/Iceshrimp.Backend/configuration.ini +++ b/Iceshrimp.Backend/configuration.ini @@ -167,6 +167,10 @@ MediaRetention = 30d CleanAvatars = false CleanBanners = false +;; Whether to proxy remote media. This can prevent leaking the IP address of users, at the cost of higher bandwidth use. +;; It is recommended to disable this for instances hosted on residential connections. +ProxyRemoteMedia = true + [Storage:Local] ;; Path where media is stored at. Must be writable for the service user. Path = /path/to/media/location