[backend/drive] Proxy remote media by default

This commit is contained in:
Laura Hausmann 2024-10-27 18:42:46 +01:00
parent 241486a778
commit 113bd98b0e
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
40 changed files with 581 additions and 278 deletions

View file

@ -3,12 +3,14 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.MfmSharp; using Iceshrimp.MfmSharp;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers; namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public readonly record struct MfmRenderData(MarkupString Html, List<MfmInlineMedia> InlineMedia); public readonly record struct MfmRenderData(MarkupString Html, List<MfmInlineMedia> InlineMedia);
[UsedImplicitly]
public class MfmRenderer(MfmConverter converter) : ISingletonService public class MfmRenderer(MfmConverter converter) : ISingletonService
{ {
public async Task<MfmRenderData?> RenderAsync( public async Task<MfmRenderData?> RenderAsync(

View file

@ -3,6 +3,7 @@ using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -12,6 +13,7 @@ public class NoteRenderer(
DatabaseContext db, DatabaseContext db,
UserRenderer userRenderer, UserRenderer userRenderer,
MfmRenderer mfm, MfmRenderer mfm,
MediaProxyService mediaProxy,
IOptions<Config.InstanceSection> instance, IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> security IOptionsSnapshot<Config.SecuritySection> security
) : IScopedService ) : IScopedService
@ -102,7 +104,7 @@ public class NoteRenderer(
.Select(f => new PreviewAttachment .Select(f => new PreviewAttachment
{ {
MimeType = f.Type, MimeType = f.Type,
Url = f.AccessUrl, Url = mediaProxy.GetProxyUrl(f),
Name = f.Name, Name = f.Name,
Alt = f.Comment, Alt = f.Comment,
Sensitive = f.IsSensitive Sensitive = f.IsSensitive

View file

@ -33,8 +33,8 @@ public class UserRenderer(
Username = user.Username, Username = user.Username,
Host = user.Host ?? instance.Value.AccountDomain, Host = user.Host ?? instance.Value.AccountDomain,
Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath, Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath,
AvatarUrl = user.AvatarUrl ?? user.IdenticonUrlPath, AvatarUrl = user.GetAvatarUrl(instance.Value),
BannerUrl = user.BannerUrl, BannerUrl = user.GetBannerUrl(instance.Value),
RawDisplayName = user.DisplayName, RawDisplayName = user.DisplayName,
DisplayName = await mfm.RenderSimpleAsync(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"), 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"), Bio = await mfm.RenderSimpleAsync(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),

View file

@ -151,7 +151,12 @@ public class ActivityPubController(
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ActionResult<JObject>> GetUser(string id) public async Task<ActionResult<JObject>> 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 == null) throw GracefulException.NotFound("User not found");
if (user.IsRemoteUser) if (user.IsRemoteUser)
{ {
@ -282,6 +287,8 @@ public class ActivityPubController(
var user = await db.Users var user = await db.Users
.IncludeCommonProperties() .IncludeCommonProperties()
.Include(p => p.Avatar)
.Include(p => p.Banner)
.FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser); .FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser);
if (user == null) throw GracefulException.NotFound("User not found"); if (user == null) throw GracefulException.NotFound("User not found");
@ -327,7 +334,7 @@ public class ActivityPubController(
{ {
Id = emoji.GetPublicUri(config.Value), Id = emoji.GetPublicUri(config.Value),
Name = emoji.Name, Name = emoji.Name,
Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl) }
}; };
return LdHelpers.Compact(rendered); return LdHelpers.Compact(rendered);

View file

@ -107,7 +107,6 @@ public class AccountController(
var avatar = await driveSvc.StoreFileAsync(request.Avatar.OpenReadStream(), user, rq); var avatar = await driveSvc.StoreFileAsync(request.Avatar.OpenReadStream(), user, rq);
user.Avatar = avatar; user.Avatar = avatar;
user.AvatarBlurhash = avatar.Blurhash; user.AvatarBlurhash = avatar.Blurhash;
user.AvatarUrl = avatar.AccessUrl;
} }
if (request.Banner != null) if (request.Banner != null)
@ -121,7 +120,6 @@ public class AccountController(
var banner = await driveSvc.StoreFileAsync(request.Banner.OpenReadStream(), user, rq); var banner = await driveSvc.StoreFileAsync(request.Banner.OpenReadStream(), user, rq);
user.Banner = banner; user.Banner = banner;
user.BannerBlurhash = banner.Blurhash; user.BannerBlurhash = banner.Blurhash;
user.BannerUrl = banner.AccessUrl;
} }
user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -139,7 +137,6 @@ public class AccountController(
var id = user.AvatarId; var id = user.AvatarId;
user.AvatarId = null; user.AvatarId = null;
user.AvatarUrl = null;
user.AvatarBlurhash = null; user.AvatarBlurhash = null;
db.Update(user); db.Update(user);
@ -161,7 +158,6 @@ public class AccountController(
var id = user.BannerId; var id = user.BannerId;
user.BannerId = null; user.BannerId = null;
user.BannerUrl = null;
user.BannerBlurhash = null; user.BannerBlurhash = null;
db.Update(user); db.Update(user);

View file

@ -21,7 +21,11 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")] [EnableCors("mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase public class InstanceController(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
MetaService meta
) : ControllerBase
{ {
[HttpGet("/api/v1/instance")] [HttpGet("/api/v1/instance")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
@ -51,9 +55,10 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
public async Task<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config) public async Task<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config)
{ {
var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30); var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30);
var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser && var activeMonth =
!Constants.SystemUsers.Contains(p.UsernameLower) && await db.Users.LongCountAsync(p => p.IsLocalUser
p.LastActiveDate > cutoff); && !Constants.SystemUsers.Contains(p.UsernameLower)
&& p.LastActiveDate > cutoff);
var (instanceName, instanceDescription, adminContact) = var (instanceName, instanceDescription, adminContact) =
await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription, await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription,
@ -74,8 +79,8 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
{ {
Id = p.Id, Id = p.Id,
Shortcode = p.Name, Shortcode = p.Name,
Url = p.PublicUrl, Url = p.GetAccessUrl(instance.Value),
StaticUrl = p.PublicUrl, //TODO StaticUrl = p.GetAccessUrl(instance.Value), //TODO
VisibleInPicker = true, VisibleInPicker = true,
Category = p.Category Category = p.Category
}) })

View file

@ -23,7 +23,11 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")] [EnableCors("mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase public class MediaController(
DriveService driveSvc,
DatabaseContext db,
AttachmentRenderer attachmentRenderer
) : ControllerBase
{ {
[MaxRequestSizeIsMaxUploadSize] [MaxRequestSizeIsMaxUploadSize]
[HttpPost("/api/v1/media")] [HttpPost("/api/v1/media")]
@ -40,7 +44,7 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
MimeType = request.File.ContentType MimeType = request.File.ContentType
}; };
var file = await driveSvc.StoreFileAsync(request.File.OpenReadStream(), user, rq); var file = await driveSvc.StoreFileAsync(request.File.OpenReadStream(), user, rq);
return AttachmentRenderer.Render(file); return attachmentRenderer.Render(file);
} }
[HttpPut("/api/v1/media/{id}")] [HttpPut("/api/v1/media/{id}")]
@ -51,12 +55,12 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
) )
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user)
throw GracefulException.RecordNotFound(); ?? throw GracefulException.RecordNotFound();
file.Comment = request.Description; file.Comment = request.Description;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return AttachmentRenderer.Render(file); return attachmentRenderer.Render(file);
} }
[HttpGet("/api/v1/media/{id}")] [HttpGet("/api/v1/media/{id}")]
@ -65,10 +69,10 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
public async Task<AttachmentEntity> GetAttachment(string id) public async Task<AttachmentEntity> GetAttachment(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user)
throw GracefulException.RecordNotFound(); ?? throw GracefulException.RecordNotFound();
return AttachmentRenderer.Render(file); return attachmentRenderer.Render(file);
} }
[HttpPut("/api/v2/media/{id}")] [HttpPut("/api/v2/media/{id}")]

View file

@ -1,17 +1,19 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; 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, Id = file.Id,
Type = AttachmentEntity.GetType(file.Type), Type = AttachmentEntity.GetType(file.Type),
Url = file.AccessUrl, Url = proxy ? mediaProxy.GetProxyUrl(file) : file.RawAccessUrl,
Blurhash = file.Blurhash, Blurhash = file.Blurhash,
PreviewUrl = file.ThumbnailAccessUrl, PreviewUrl = proxy ? mediaProxy.GetThumbnailProxyUrl(file) : file.RawThumbnailAccessUrl,
Description = file.Comment, Description = file.Comment,
RemoteUrl = file.Uri, RemoteUrl = file.Uri,
Sensitive = file.IsSensitive, Sensitive = file.IsSensitive,

View file

@ -19,7 +19,8 @@ public class NoteRenderer(
PollRenderer pollRenderer, PollRenderer pollRenderer,
MfmConverter mfmConverter, MfmConverter mfmConverter,
DatabaseContext db, DatabaseContext db,
EmojiService emojiSvc EmojiService emojiSvc,
AttachmentRenderer attachmentRenderer
) : IScopedService ) : IScopedService
{ {
private static readonly FilterResultEntity InaccessibleFilter = new() private static readonly FilterResultEntity InaccessibleFilter = new()
@ -296,7 +297,7 @@ public class NoteRenderer(
if (notes.Count == 0) return []; if (notes.Count == 0) return [];
var ids = notes.SelectMany(n => n.FileIds).Distinct(); var ids = notes.SelectMany(n => n.FileIds).Distinct();
return await db.DriveFiles.Where(p => ids.Contains(p.Id)) return await db.DriveFiles.Where(p => ids.Contains(p.Id))
.Select(f => AttachmentRenderer.Render(f)) .Select(f => attachmentRenderer.Render(f, true))
.ToListAsync(); .ToListAsync();
} }
@ -305,7 +306,7 @@ public class NoteRenderer(
var ids = fileIds.Distinct().ToList(); var ids = fileIds.Distinct().ToList();
if (ids.Count == 0) return []; if (ids.Count == 0) return [];
return await db.DriveFiles.Where(p => ids.Contains(p.Id)) return await db.DriveFiles.Where(p => ids.Contains(p.Id))
.Select(f => AttachmentRenderer.Render(f)) .Select(f => attachmentRenderer.Render(f, true))
.ToListAsync(); .ToListAsync();
} }
@ -354,8 +355,8 @@ public class NoteRenderer(
{ {
var hit = await emojiSvc.ResolveEmojiAsync(item.Name); var hit = await emojiSvc.ResolveEmojiAsync(item.Name);
if (hit == null) continue; if (hit == null) continue;
item.Url = hit.PublicUrl; item.Url = hit.GetAccessUrl(config.Value);
item.StaticUrl = hit.PublicUrl; item.StaticUrl = hit.GetAccessUrl(config.Value);
item.Name = item.Name.Trim(':'); item.Name = item.Name.Trim(':');
} }
@ -422,8 +423,8 @@ public class NoteRenderer(
{ {
Id = p.Id, Id = p.Id,
Shortcode = p.Name.Trim(':'), Shortcode = p.Name.Trim(':'),
Url = p.PublicUrl, Url = p.GetAccessUrl(config.Value),
StaticUrl = p.PublicUrl, //TODO StaticUrl = p.GetAccessUrl(config.Value), //TODO
VisibleInPicker = true, VisibleInPicker = true,
Category = p.Category Category = p.Category
}) })

View file

@ -1,14 +1,17 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers; namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class NotificationRenderer( public class NotificationRenderer(
IOptions<Config.InstanceSection> instance,
DatabaseContext db, DatabaseContext db,
NoteRenderer noteRenderer, NoteRenderer noteRenderer,
UserRenderer userRenderer UserRenderer userRenderer
@ -45,7 +48,7 @@ public class NotificationRenderer(
var parts = notification.Reaction.Trim(':').Split('@'); var parts = notification.Reaction.Trim(':').Split('@');
emojiUrl = await db.Emojis emojiUrl = await db.Emojis
.Where(e => e.Name == parts[0] && e.Host == (parts.Length > 1 ? parts[1] : null)) .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(); .FirstOrDefaultAsync();
} }
} }
@ -107,7 +110,7 @@ public class NotificationRenderer(
var emojiUrls = await urlQ.Select(e => new var emojiUrls = await urlQ.Select(e => new
{ {
Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:", Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:",
Url = e.PublicUrl Url = e.GetAccessUrl(instance.Value)
}) })
.ToArrayAsync() .ToArrayAsync()
.ContinueWithResult(res => res.DistinctBy(e => e.Name) .ContinueWithResult(res => res.DistinctBy(e => e.Name)

View file

@ -49,7 +49,7 @@ public class UserRenderer(
{ {
Id = user.Id, Id = user.Id,
DisplayName = user.DisplayName ?? user.Username, DisplayName = user.DisplayName ?? user.Username,
AvatarUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), AvatarUrl = user.GetAvatarUrl(config.Value),
Username = user.Username, Username = user.Username,
Acct = acct, Acct = acct,
FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}", 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, Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html,
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value), Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value), Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO
HeaderUrl = user.BannerUrl ?? _transparent, HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent,
HeaderStaticUrl = user.BannerUrl ?? _transparent, //TODO HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO
MovedToAccount = null, //TODO MovedToAccount = null, //TODO
IsBot = user.IsBot, IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable, IsDiscoverable = user.IsExplorable,
@ -73,8 +73,8 @@ public class UserRenderer(
if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
{ {
res.AvatarUrl = user.GetIdenticonUrlPng(config.Value); res.AvatarUrl = user.GetIdenticonUrl(config.Value);
res.AvatarStaticUrl = user.GetIdenticonUrlPng(config.Value); res.AvatarStaticUrl = user.GetIdenticonUrl(config.Value);
res.HeaderUrl = _transparent; res.HeaderUrl = _transparent;
res.HeaderStaticUrl = _transparent; res.HeaderStaticUrl = _transparent;
} }
@ -108,8 +108,8 @@ public class UserRenderer(
{ {
Id = p.Id, Id = p.Id,
Shortcode = p.Name, Shortcode = p.Name,
Url = p.PublicUrl, Url = p.GetAccessUrl(config.Value),
StaticUrl = p.PublicUrl, //TODO StaticUrl = p.GetAccessUrl(config.Value), //TODO
VisibleInPicker = true, VisibleInPicker = true,
Category = p.Category Category = p.Category
}) })

View file

@ -3,11 +3,13 @@ using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Pleroma; namespace Iceshrimp.Backend.Controllers.Pleroma;
@ -15,7 +17,7 @@ namespace Iceshrimp.Backend.Controllers.Pleroma;
[EnableCors("mastodon")] [EnableCors("mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class EmojiController(DatabaseContext db) : ControllerBase public class EmojiController(IOptions<Config.InstanceSection> instance, DatabaseContext db) : ControllerBase
{ {
[HttpGet("/api/v1/pleroma/emoji")] [HttpGet("/api/v1/pleroma/emoji")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
@ -26,7 +28,7 @@ public class EmojiController(DatabaseContext db) : ControllerBase
.Select(p => KeyValuePair.Create(p.Name, .Select(p => KeyValuePair.Create(p.Name,
new PleromaEmojiEntity new PleromaEmojiEntity
{ {
ImageUrl = p.PublicUrl, ImageUrl = p.GetAccessUrl(instance.Value),
Tags = new[] { p.Category ?? "" } Tags = new[] { p.Category ?? "" }
})) }))
.ToArrayAsync(); .ToArrayAsync();

View file

@ -3,6 +3,7 @@ using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Queues;
@ -10,6 +11,7 @@ using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -23,63 +25,78 @@ public class DriveController(
IOptionsSnapshot<Config.StorageSection> options, IOptionsSnapshot<Config.StorageSection> options,
ILogger<DriveController> logger, ILogger<DriveController> logger,
DriveService driveSvc, DriveService driveSvc,
QueueService queueSvc QueueService queueSvc,
HttpClient httpClient
) : ControllerBase ) : ControllerBase
{ {
private const string CacheControl = "max-age=31536000, immutable";
[EnableCors("drive")] [EnableCors("drive")]
[HttpGet("/files/{accessKey}")] [EnableRateLimiting("proxy")]
[HttpGet("/files/{accessKey}/{version?}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey) public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
{ {
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey || return await GetFileByAccessKey(accessKey, version, null);
p.PublicAccessKey == accessKey || }
p.ThumbnailAccessKey == accessKey);
if (file == null) [EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> 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<IActionResult> 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"; var stream = await IdenticonHelper.GetIdenticonAsync(user.Id);
throw GracefulException.NotFound("File not found"); Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false);
} }
if (file.StoredInternal) if (!options.Value.ProxyRemoteMedia)
{ return Redirect(user.Avatar.RawThumbnailAccessUrl);
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); return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar);
var stream = System.IO.File.OpenRead(path); }
Response.Headers.CacheControl = "max-age=31536000, immutable"; [EnableCors("drive")]
Response.Headers.XContentTypeOptions = "nosniff"; [EnableRateLimiting("proxy")]
return Constants.BrowserSafeMimeTypes.Contains(file.Type) [HttpGet("/banners/{userId}/{version}")]
? new InlineFileStreamResult(stream, file.Type, file.Name, true) [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
: File(stream, file.Type, file.Name, true); [ProducesErrors(HttpStatusCode.NotFound)]
} public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
else {
{ var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId)
if (file.IsLink) ?? throw GracefulException.NotFound("User not found");
{
//TODO: handle remove media proxying
return NoContent();
}
var stream = await objectStorage.GetFileAsync(accessKey); if (user.Banner is null)
if (stream == null) return NoContent();
{
logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
throw GracefulException.NotFound("File not found");
}
Response.Headers.CacheControl = "max-age=31536000, immutable"; if (!options.Value.ProxyRemoteMedia)
Response.Headers.XContentTypeOptions = "nosniff"; return Redirect(user.Banner.RawThumbnailAccessUrl);
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true) return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner);
: File(stream, file.Type, file.Name, true);
}
} }
[HttpPost] [HttpPost]
@ -110,14 +127,14 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileById(string id) public async Task<DriveFileResponse> GetFileById(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse return new DriveFileResponse
{ {
Id = file.Id, Id = file.Id,
Url = file.AccessUrl, Url = file.RawAccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name, Filename = file.Name,
ContentType = file.Type, ContentType = file.Type,
Description = file.Comment, Description = file.Comment,
@ -134,14 +151,14 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileByHash(string sha256) public async Task<DriveFileResponse> GetFileByHash(string sha256)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256)
throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse return new DriveFileResponse
{ {
Id = file.Id, Id = file.Id,
Url = file.AccessUrl, Url = file.RawAccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name, Filename = file.Name,
ContentType = file.Type, ContentType = file.Type,
Description = file.Comment, Description = file.Comment,
@ -159,8 +176,8 @@ public class DriveController(
public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request) public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
file.Name = request.Filename ?? file.Name; file.Name = request.Filename ?? file.Name;
file.IsSensitive = request.Sensitive ?? file.IsSensitive; file.IsSensitive = request.Sensitive ?? file.IsSensitive;
@ -178,8 +195,8 @@ public class DriveController(
public async Task<IActionResult> DeleteFile(string id) public async Task<IActionResult> DeleteFile(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file)) if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file))
throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar"); throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar");
@ -193,4 +210,104 @@ public class DriveController(
return StatusCode(StatusCodes.Status202Accepted); return StatusCode(StatusCodes.Status202Accepted);
} }
private async Task<IActionResult> 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<IActionResult> 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);
}
}
} }

View file

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
@ -8,6 +9,7 @@ using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Controllers.Web;
@ -18,6 +20,7 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Route("/api/iceshrimp/emoji")] [Route("/api/iceshrimp/emoji")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class EmojiController( public class EmojiController(
IOptions<Config.InstanceSection> instance,
DatabaseContext db, DatabaseContext db,
EmojiService emojiSvc, EmojiService emojiSvc,
EmojiImportService emojiImportSvc EmojiImportService emojiImportSvc
@ -36,7 +39,7 @@ public class EmojiController(
Uri = p.Uri, Uri = p.Uri,
Aliases = p.Aliases, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.PublicUrl, PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License, License = p.License,
Sensitive = p.Sensitive Sensitive = p.Sensitive
}) })
@ -58,7 +61,7 @@ public class EmojiController(
Uri = emoji.Uri, Uri = emoji.Uri,
Aliases = emoji.Aliases, Aliases = emoji.Aliases,
Category = emoji.Category, Category = emoji.Category,
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License, License = emoji.License,
Sensitive = emoji.Sensitive Sensitive = emoji.Sensitive
}; };
@ -80,7 +83,7 @@ public class EmojiController(
Uri = emoji.Uri, Uri = emoji.Uri,
Aliases = [], Aliases = [],
Category = null, Category = null,
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.GetAccessUrl(instance.Value),
License = null, License = null,
Sensitive = false Sensitive = false
}; };
@ -106,7 +109,7 @@ public class EmojiController(
Uri = cloned.Uri, Uri = cloned.Uri,
Aliases = [], Aliases = [],
Category = null, Category = null,
PublicUrl = cloned.PublicUrl, PublicUrl = cloned.GetAccessUrl(instance.Value),
License = null, License = null,
Sensitive = cloned.Sensitive Sensitive = cloned.Sensitive
}; };
@ -141,7 +144,7 @@ public class EmojiController(
Uri = emoji.Uri, Uri = emoji.Uri,
Aliases = emoji.Aliases, Aliases = emoji.Aliases,
Category = emoji.Category, Category = emoji.Category,
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License, License = emoji.License,
Sensitive = emoji.Sensitive Sensitive = emoji.Sensitive
}; };

View file

@ -1,12 +1,14 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Controllers.Web;
@ -16,7 +18,11 @@ namespace Iceshrimp.Backend.Controllers.Web;
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Route("/api/iceshrimp/profile")] [Route("/api/iceshrimp/profile")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class ProfileController(UserService userSvc, DriveService driveSvc) : ControllerBase public class ProfileController(
IOptions<Config.InstanceSection> instance,
UserService userSvc,
DriveService driveSvc
) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
@ -86,7 +92,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
public string GetAvatarUrl() public string GetAvatarUrl()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return user.AvatarUrl ?? ""; return user.AvatarId != null ? user.GetAvatarUrl(instance.Value) : "";
} }
[HttpPost("avatar")] [HttpPost("avatar")]
@ -113,7 +119,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Avatar = avatar; user.Avatar = avatar;
user.AvatarBlurhash = avatar.Blurhash; user.AvatarBlurhash = avatar.Blurhash;
user.AvatarUrl = avatar.AccessUrl;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
} }
@ -133,7 +138,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Avatar = null; user.Avatar = null;
user.AvatarBlurhash = null; user.AvatarBlurhash = null;
user.AvatarUrl = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
} }
@ -143,7 +147,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
public string GetBannerUrl() public string GetBannerUrl()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return user.BannerUrl ?? ""; return user.GetBannerUrl(instance.Value) ?? "";
} }
[HttpPost("banner")] [HttpPost("banner")]
@ -170,7 +174,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Banner = banner; user.Banner = banner;
user.BannerBlurhash = banner.Blurhash; user.BannerBlurhash = banner.Blurhash;
user.BannerUrl = banner.AccessUrl;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
} }
@ -190,7 +193,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Banner = null; user.Banner = null;
user.BannerBlurhash = null; user.BannerBlurhash = null;
user.BannerUrl = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
} }

View file

@ -14,6 +14,7 @@ public class NoteRenderer(
UserRenderer userRenderer, UserRenderer userRenderer,
DatabaseContext db, DatabaseContext db,
EmojiService emojiSvc, EmojiService emojiSvc,
MediaProxyService mediaProxy,
IOptions<Config.InstanceSection> config IOptions<Config.InstanceSection> config
) : IScopedService ) : IScopedService
{ {
@ -114,8 +115,8 @@ public class NoteRenderer(
return files.Select(p => new NoteAttachment return files.Select(p => new NoteAttachment
{ {
Id = p.Id, Id = p.Id,
Url = p.AccessUrl, Url = mediaProxy.GetProxyUrl(p),
ThumbnailUrl = p.ThumbnailAccessUrl, ThumbnailUrl = mediaProxy.GetThumbnailProxyUrl(p),
ContentType = p.Type, ContentType = p.Type,
Blurhash = p.Blurhash, Blurhash = p.Blurhash,
AltText = p.Comment, AltText = p.Comment,
@ -141,7 +142,7 @@ public class NoteRenderer(
i.User == user), i.User == user),
Name = p.First().Reaction, Name = p.First().Reaction,
Url = null, Url = null,
Sensitive = false, Sensitive = false
}) })
.ToListAsync(); .ToListAsync();
@ -149,7 +150,7 @@ public class NoteRenderer(
{ {
var hit = await emojiSvc.ResolveEmojiAsync(item.Name); var hit = await emojiSvc.ResolveEmojiAsync(item.Name);
if (hit == null) continue; if (hit == null) continue;
item.Url = hit.PublicUrl; item.Url = hit.GetAccessUrl(config.Value);
item.Sensitive = hit.Sensitive; item.Sensitive = hit.Sensitive;
} }
@ -193,7 +194,7 @@ public class NoteRenderer(
Uri = p.Uri, Uri = p.Uri,
Aliases = p.Aliases, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.PublicUrl, PublicUrl = p.GetAccessUrl(config.Value),
License = p.License, License = p.License,
Sensitive = p.Sensitive Sensitive = p.Sensitive
}) })

View file

@ -1,33 +1,38 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.Extensions.Options;
using static Iceshrimp.Shared.Schemas.Web.NotificationResponse; using static Iceshrimp.Shared.Schemas.Web.NotificationResponse;
namespace Iceshrimp.Backend.Controllers.Web.Renderers; namespace Iceshrimp.Backend.Controllers.Web.Renderers;
public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRenderer, EmojiService emojiSvc) : IScopedService public class NotificationRenderer(
IOptions<Config.InstanceSection> instance,
UserRenderer userRenderer,
NoteRenderer noteRenderer,
EmojiService emojiSvc
) : IScopedService
{ {
private static NotificationResponse Render(Notification notification, NotificationRendererDto data) private static NotificationResponse Render(Notification notification, NotificationRendererDto data)
{ {
var user = notification.Notifier != null var user = notification.Notifier != null
? data.Users?.First(p => p.Id == notification.Notifier.Id) ?? ? data.Users?.First(p => p.Id == notification.Notifier.Id)
throw new Exception("DTO didn't contain the notifier") ?? throw new Exception("DTO didn't contain the notifier")
: null; : null;
var note = notification.Note != null var note = notification.Note != null
? data.Notes?.First(p => p.Id == notification.Note.Id) ?? ? data.Notes?.First(p => p.Id == notification.Note.Id) ?? throw new Exception("DTO didn't contain the note")
throw new Exception("DTO didn't contain the note")
: null; : null;
var bite = notification.Bite != null var bite = notification.Bite != null
? data.Bites?.First(p => p.Id == notification.Bite.Id) ?? ? data.Bites?.First(p => p.Id == notification.Bite.Id) ?? throw new Exception("DTO didn't contain the bite")
throw new Exception("DTO didn't contain the bite")
: null; : null;
var reaction = notification.Reaction != null var reaction = notification.Reaction != null
? data.Reactions?.First(p => p.Name == notification.Reaction) ?? ? data.Reactions?.First(p => p.Name == notification.Reaction)
throw new Exception("DTO didn't contain the reaction") ?? throw new Exception("DTO didn't contain the reaction")
: null; : null;
return new NotificationResponse return new NotificationResponse
@ -49,9 +54,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe
{ {
var data = new NotificationRendererDto var data = new NotificationRendererDto
{ {
Users = await GetUsersAsync([notification]), Users = await GetUsersAsync([notification]),
Notes = await GetNotesAsync([notification], localUser), Notes = await GetNotesAsync([notification], localUser),
Bites = GetBites([notification]), Bites = GetBites([notification]),
Reactions = await GetReactionsAsync([notification]) Reactions = await GetReactionsAsync([notification])
}; };
@ -101,15 +106,32 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe
{ {
var reactions = notifications.Select(p => p.Reaction).NotNull().ToList(); 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 emojis = reactions.Where(p => !EmojiService.IsCustomEmoji(p))
var custom = reactions.Where(EmojiService.IsCustomEmoji).ToArray(); .Select(p => new ReactionResponse
{
Name = p,
Url = null,
Sensitive = false
})
.ToList();
var custom = reactions.Where(EmojiService.IsCustomEmoji).ToArray();
foreach (var s in custom) foreach (var s in custom)
{ {
var emoji = await emojiSvc.ResolveEmojiAsync(s); var emoji = await emojiSvc.ResolveEmojiAsync(s);
var reaction = emoji != null var reaction = emoji != null
? new ReactionResponse { Name = s, Url = emoji.PublicUrl, Sensitive = emoji.Sensitive } ? new ReactionResponse
: new ReactionResponse { Name = s, Url = null, Sensitive = false }; {
Name = s,
Url = emoji.GetAccessUrl(instance.Value),
Sensitive = emoji.Sensitive
}
: new ReactionResponse
{
Name = s,
Url = null,
Sensitive = false
};
emojis.Add(reaction); emojis.Add(reaction);
} }
@ -124,9 +146,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe
var notificationsList = notifications.ToList(); var notificationsList = notifications.ToList();
var data = new NotificationRendererDto var data = new NotificationRendererDto
{ {
Users = await GetUsersAsync(notificationsList), Users = await GetUsersAsync(notificationsList),
Notes = await GetNotesAsync(notificationsList, user), Notes = await GetNotesAsync(notificationsList, user),
Bites = GetBites(notificationsList), Bites = GetBites(notificationsList),
Reactions = await GetReactionsAsync(notificationsList) Reactions = await GetReactionsAsync(notificationsList)
}; };
@ -135,9 +157,9 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe
private class NotificationRendererDto private class NotificationRendererDto
{ {
public List<NoteResponse>? Notes; public List<NoteResponse>? Notes;
public List<UserResponse>? Users; public List<UserResponse>? Users;
public List<BiteResponse>? Bites; public List<BiteResponse>? Bites;
public List<ReactionResponse>? Reactions; public List<ReactionResponse>? Reactions;
} }
} }

View file

@ -27,8 +27,8 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Username = user.Username, Username = user.Username,
Host = user.Host, Host = user.Host,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
AvatarUrl = user.AvatarUrl ?? user.GetIdenticonUrl(config.Value), AvatarUrl = user.GetAvatarUrl(config.Value),
BannerUrl = user.BannerUrl, BannerUrl = user.GetBannerUrl(config.Value),
InstanceName = instanceName, InstanceName = instanceName,
InstanceIconUrl = instanceIcon, InstanceIconUrl = instanceIcon,
Emojis = emoji, Emojis = emoji,
@ -78,7 +78,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Uri = p.Uri, Uri = p.Uri,
Aliases = p.Aliases, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.PublicUrl, PublicUrl = p.GetAccessUrl(config.Value),
License = p.License, License = p.License,
Sensitive = p.Sensitive Sensitive = p.Sensitive
}) })

View file

@ -85,9 +85,10 @@ public sealed class Config
public readonly long? MaxUploadSizeBytes; public readonly long? MaxUploadSizeBytes;
public readonly TimeSpan? MediaRetentionTimeSpan; public readonly TimeSpan? MediaRetentionTimeSpan;
public bool CleanAvatars = false; public bool CleanAvatars { get; init; } = false;
public bool CleanBanners = false; public bool CleanBanners { get; init; } = false;
public Enums.FileStorage Provider { get; init; } = Enums.FileStorage.Local; 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)] [Obsolete("This property is for backwards compatibility only, use StorageSection.Provider instead", true)]
[SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "UnusedMember.Global")]

View file

@ -747,6 +747,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("AccessKey") b.Property<string>("AccessKey")
.IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("accessKey"); .HasColumnName("accessKey");
@ -3998,12 +3999,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("avatarId") .HasColumnName("avatarId")
.HasComment("The ID of avatar DriveFile."); .HasComment("The ID of avatar DriveFile.");
b.Property<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("avatarUrl")
.HasComment("The URL of the avatar DriveFile");
b.Property<string>("BannerBlurhash") b.Property<string>("BannerBlurhash")
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
@ -4016,12 +4011,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("bannerId") .HasColumnName("bannerId")
.HasComment("The ID of banner DriveFile."); .HasComment("The ID of banner DriveFile.");
b.Property<string>("BannerUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("bannerUrl")
.HasComment("The URL of the banner DriveFile");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("createdAt") .HasColumnName("createdAt")

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241123224718_MakeDriveFileAccessKeyNonOptional")]
public partial class MakeDriveFileAccessKeyNonOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE "drive_file" SET "accessKey" = gen_random_uuid() WHERE "accessKey" IS NULL;
""");
migrationBuilder.AlterColumn<string>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "accessKey",
table: "drive_file",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
}
}
}

View file

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250109095535_RemoteUserAvatarBannerUrlColumns")]
public partial class RemoteUserAvatarBannerUrlColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "avatarUrl",
table: "user");
migrationBuilder.DropColumn(
name: "bannerUrl",
table: "user");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "avatarUrl",
table: "user",
type: "character varying(512)",
maxLength: 512,
nullable: true,
comment: "The URL of the avatar DriveFile");
migrationBuilder.AddColumn<string>(
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");
""");
}
}
}

View file

@ -106,7 +106,7 @@ public class DriveFile : IEntity
[Column("accessKey")] [Column("accessKey")]
[StringLength(256)] [StringLength(256)]
public string? AccessKey { get; set; } public string AccessKey { get; set; } = null!;
[Column("thumbnailAccessKey")] [Column("thumbnailAccessKey")]
[StringLength(256)] [StringLength(256)]
@ -189,8 +189,8 @@ public class DriveFile : IEntity
[InverseProperty(nameof(Tables.User.Banner))] [InverseProperty(nameof(Tables.User.Banner))]
public virtual User? UserBanner { get; set; } public virtual User? UserBanner { get; set; }
[NotMapped] public string AccessUrl => PublicUrl ?? Url; [NotMapped] public string RawAccessUrl => PublicUrl ?? Url;
[NotMapped] public string ThumbnailAccessUrl => ThumbnailUrl ?? PublicUrl ?? Url; [NotMapped] public string RawThumbnailAccessUrl => ThumbnailUrl ?? PublicUrl ?? Url;
[Key] [Key]
[Column("id")] [Column("id")]

View file

@ -40,7 +40,7 @@ public class Emoji
[Column("publicUrl")] [Column("publicUrl")]
[StringLength(512)] [StringLength(512)]
public string PublicUrl { get; set; } = null!; public string RawPublicUrl { get; set; } = null!;
[Column("license")] [Column("license")]
[StringLength(1024)] [StringLength(1024)]
@ -69,13 +69,16 @@ public class Emoji
? $"https://{config.WebDomain}/emoji/{Name}" ? $"https://{config.WebDomain}/emoji/{Name}"
: null; : null;
public string GetAccessUrl(Config.InstanceSection config)
=> $"https://{config.WebDomain}/media/emoji/{Id}";
private class EntityTypeConfiguration : IEntityTypeConfiguration<Emoji> private class EntityTypeConfiguration : IEntityTypeConfiguration<Emoji>
{ {
public void Configure(EntityTypeBuilder<Emoji> entity) public void Configure(EntityTypeBuilder<Emoji> entity)
{ {
entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Height).HasComment("Image height"); 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"); entity.Property(e => e.Width).HasComment("Image width");
} }
} }

View file

@ -242,13 +242,6 @@ public class User : IEntity
[Column("speakAsCat")] [Column("speakAsCat")]
public bool SpeakAsCat { get; set; } public bool SpeakAsCat { get; set; }
/// <summary>
/// The URL of the avatar DriveFile
/// </summary>
[Column("avatarUrl")]
[StringLength(512)]
public string? AvatarUrl { get; set; }
/// <summary> /// <summary>
/// The blurhash of the avatar DriveFile /// The blurhash of the avatar DriveFile
/// </summary> /// </summary>
@ -256,13 +249,6 @@ public class User : IEntity
[StringLength(128)] [StringLength(128)]
public string? AvatarBlurhash { get; set; } public string? AvatarBlurhash { get; set; }
/// <summary>
/// The URL of the banner DriveFile
/// </summary>
[Column("bannerUrl")]
[StringLength(512)]
public string? BannerUrl { get; set; }
/// <summary> /// <summary>
/// The blurhash of the banner DriveFile /// The blurhash of the banner DriveFile
/// </summary> /// </summary>
@ -587,12 +573,12 @@ public class User : IEntity
[Projectable] [Projectable]
public bool HasInteractedWith(Note note) => public bool HasInteractedWith(Note note) =>
HasLiked(note) || HasLiked(note)
HasReacted(note) || || HasReacted(note)
HasBookmarked(note) || || HasBookmarked(note)
HasReplied(note) || || HasReplied(note)
HasRenoted(note) || || HasRenoted(note)
HasVoted(note); || HasVoted(note);
[Projectable] [Projectable]
public bool ProhibitInteractionWith(User user) => IsBlocking(user) || IsBlockedBy(user); public bool ProhibitInteractionWith(User user) => IsBlocking(user) || IsBlockedBy(user);
@ -623,11 +609,10 @@ public class User : IEntity
return this; return this;
} }
public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain); public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain);
public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain); public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain);
public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain); public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain);
public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain); public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain);
public string GetIdenticonUrlPng(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain) + ".png";
public string GetPublicUri(string webDomain) => Host == null public string GetPublicUri(string webDomain) => Host == null
? $"https://{webDomain}/users/{Id}" ? $"https://{webDomain}/users/{Id}"
@ -643,6 +628,12 @@ public class User : IEntity
public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}"; 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<User> private class EntityTypeConfiguration : IEntityTypeConfiguration<User>
{ {
public void Configure(EntityTypeBuilder<User> entity) public void Configure(EntityTypeBuilder<User> 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.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.AvatarBlurhash).HasComment("The blurhash of the avatar DriveFile");
entity.Property(e => e.AvatarId).HasComment("The ID of 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.BannerBlurhash).HasComment("The blurhash of the banner DriveFile");
entity.Property(e => e.BannerId).HasComment("The ID of 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.CreatedAt).HasComment("The created date of the User.");
entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit"); entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit");
entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]");

View file

@ -248,11 +248,21 @@ public static class ServiceExtensions
QueueLimit = 0 QueueLimit = 0
}; };
var proxy = new SlidingWindowRateLimiterOptions
{
PermitLimit = 10,
SegmentsPerWindow = 10,
Window = TimeSpan.FromSeconds(10),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
};
// @formatter:off // @formatter:off
options.AddPolicy("sliding", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false),_ => sliding)); options.AddPolicy("sliding", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false),_ => sliding));
options.AddPolicy("auth", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false), _ => auth)); options.AddPolicy("auth", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(false), _ => auth));
options.AddPolicy("strict", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => strict)); options.AddPolicy("strict", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => strict));
options.AddPolicy("imports", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => imports)); options.AddPolicy("imports", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => imports));
options.AddPolicy("proxy", ctx => RateLimitPartition.GetSlidingWindowLimiter(ctx.GetRateLimitPartition(true), _ => proxy));
// @formatter:on // @formatter:on
options.OnRejected = async (context, token) => options.OnRejected = async (context, token) =>

View file

@ -92,7 +92,7 @@ public class ActivityRenderer(
{ {
Id = emoji.GetPublicUriOrNull(config.Value), Id = emoji.GetPublicUriOrNull(config.Value),
Name = name, Name = name,
Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl) }
}; };
res.Tags = [e]; res.Tags = [e];

View file

@ -105,7 +105,7 @@ public class NoteRenderer(
{ {
Id = e.GetPublicUri(config.Value), Id = e.GetPublicUri(config.Value),
Name = e.Name, Name = e.Name,
Image = new ASImage { Url = new ASLink(e.PublicUrl) } Image = new ASImage { Url = new ASLink(e.RawPublicUrl) }
})) }))
.ToList(); .ToList();
@ -119,14 +119,14 @@ public class NoteRenderer(
var attachments = driveFiles?.Select(p => new ASDocument var attachments = driveFiles?.Select(p => new ASDocument
{ {
Sensitive = p.IsSensitive, Sensitive = p.IsSensitive,
Url = new ASLink(p.AccessUrl), Url = new ASLink(p.RawAccessUrl),
MediaType = p.Type, MediaType = p.Type,
Description = p.Comment Description = p.Comment
}) })
.Cast<ASAttachment>() .Cast<ASAttachment>()
.ToList(); .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(); .ToList();
var quoteUri = note.IsQuote ? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(config.Value) : null; var quoteUri = note.IsQuote ? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(config.Value) : null;

View file

@ -40,6 +40,19 @@ public class UserRenderer(
if (keypair == null) throw new Exception("User has no keypair"); 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 id = user.GetPublicUri(config.Value);
var type = Constants.SystemUsers.Contains(user.UsernameLower) var type = Constants.SystemUsers.Contains(user.UsernameLower)
? ASActor.Types.Application ? ASActor.Types.Application
@ -61,7 +74,7 @@ public class UserRenderer(
{ {
Id = e.GetPublicUri(config.Value), Id = e.GetPublicUri(config.Value),
Name = e.Name, Name = e.Name,
Image = new ASImage { Url = new ASLink(e.PublicUrl) } Image = new ASImage { Url = new ASLink(e.RawPublicUrl) }
})) }))
.ToList(); .ToList();
@ -97,11 +110,11 @@ public class UserRenderer(
AlsoKnownAs = user.AlsoKnownAs?.Select(p => new ASLink(p)).ToList(), AlsoKnownAs = user.AlsoKnownAs?.Select(p => new ASLink(p)).ToList(),
MovedTo = user.MovedToUri is not null ? new ASLink(user.MovedToUri) : null, MovedTo = user.MovedToUri is not null ? new ASLink(user.MovedToUri) : null,
Featured = new ASOrderedCollection($"{id}/collections/featured"), Featured = new ASOrderedCollection($"{id}/collections/featured"),
Avatar = user.AvatarUrl != null Avatar = user.Avatar != null
? new ASImage { Url = new ASLink(user.AvatarUrl) } ? new ASImage { Url = new ASLink(user.Avatar.RawAccessUrl) }
: null, : null,
Banner = user.BannerUrl != null Banner = user.Banner != null
? new ASImage { Url = new ASLink(user.BannerUrl) } ? new ASImage { Url = new ASLink(user.Banner.RawAccessUrl) }
: null, : null,
Endpoints = new ASEndpoints { SharedInbox = new ASObjectBase($"https://{config.Value.WebDomain}/inbox") }, Endpoints = new ASEndpoints { SharedInbox = new ASObjectBase($"https://{config.Value.WebDomain}/inbox") },
PublicKey = new ASPublicKey PublicKey = new ASPublicKey

View file

@ -1,27 +1,15 @@
using System.IO.Hashing; using System.IO.Hashing;
using System.Net;
using System.Net.Mime;
using System.Text; using System.Text;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Core.Helpers;
[ApiController] public static class IdenticonHelper
[EnableCors("drive")]
[Route("/identicon/{id}")]
[Route("/identicon/{id}.png")]
[Produces(MediaTypeNames.Image.Png)]
public class IdenticonController : ControllerBase
{ {
[HttpGet] public static async Task<Stream> GetIdenticonAsync(string id)
[ProducesResults(HttpStatusCode.OK)]
public async Task GetIdenticon(string id)
{ {
using var image = new Image<Rgb24>(Size, Size); using var image = new Image<Rgb24>(Size, Size);
@ -74,9 +62,10 @@ public class IdenticonController : ControllerBase
} }
} }
Response.Headers.CacheControl = "max-age=31536000, immutable"; var stream = new MemoryStream();
Response.Headers.ContentType = "image/png"; await image.SaveAsPngAsync(stream);
await image.SaveAsPngAsync(Response.Body); stream.Seek(0, SeekOrigin.Begin);
return stream;
} }
#region Color definitions & Constants #region Color definitions & Constants

View file

@ -8,6 +8,7 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.MfmSharp; using Iceshrimp.MfmSharp;
using Iceshrimp.Backend.Core.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MfmHtmlParser = Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser; using MfmHtmlParser = Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser;
using HtmlParser = AngleSharp.Html.Parser.HtmlParser; using HtmlParser = AngleSharp.Html.Parser.HtmlParser;
@ -41,7 +42,8 @@ public readonly record struct HtmlMfmData(string Mfm, List<MfmInlineMedia> Inlin
public readonly record struct MfmHtmlData(string Html, List<MfmInlineMedia> InlineMedia); public readonly record struct MfmHtmlData(string Html, List<MfmInlineMedia> InlineMedia);
public class MfmConverter( public class MfmConverter(
IOptions<Config.InstanceSection> config IOptions<Config.InstanceSection> config,
MediaProxyService mediaProxy
) : ISingletonService ) : ISingletonService
{ {
public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new(); public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new();
@ -310,7 +312,7 @@ public class MfmConverter(
{ {
var el = document.CreateElement("span"); var el = document.CreateElement("span");
var inner = document.CreateElement("img"); var inner = document.CreateElement("img");
inner.SetAttribute("src", hit.PublicUrl); inner.SetAttribute("src", mediaProxy.GetProxyUrl(hit));
inner.SetAttribute("alt", hit.Name); inner.SetAttribute("alt", hit.Name);
el.AppendChild(inner); el.AppendChild(inner);
el.ClassList.Add("emoji"); el.ClassList.Add("emoji");

View file

@ -241,6 +241,9 @@ public class GracefulException(
new(HttpStatusCode.MisdirectedRequest, HttpStatusCode.MisdirectedRequest.ToString(), new(HttpStatusCode.MisdirectedRequest, HttpStatusCode.MisdirectedRequest.ToString(),
"This server is not configured to respond to this request.", null, true, true); "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);
/// <summary> /// <summary>
/// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger /// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger
/// returning 410 Gone) /// returning 410 Gone)

View file

@ -69,8 +69,7 @@ public class BackgroundTaskQueue(int parallelism)
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == jobData.DriveFileId, token); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == jobData.DriveFileId, token);
if (file == null) return; if (file == null) return;
var deduplicated = file.AccessKey != null && var deduplicated = await db.DriveFiles.AnyAsync(p => p.Id != file.Id &&
await db.DriveFiles.AnyAsync(p => p.Id != file.Id &&
p.AccessKey == file.AccessKey && p.AccessKey == file.AccessKey &&
!p.IsLink, !p.IsLink,
token); token);

View file

@ -110,7 +110,7 @@ public class DriveService(
Filename = filename, Filename = filename,
IsSensitive = sensitive, IsSensitive = sensitive,
Comment = description, Comment = description,
MimeType = CleanMimeType(mimeType ?? res.Content.Headers.ContentType?.MediaType) MimeType = CleanMimeType(res.Content.Headers.ContentType?.MediaType ?? mimeType)
}; };
var input = await res.Content.ReadAsStreamAsync(); var input = await res.Content.ReadAsStreamAsync();
@ -148,7 +148,8 @@ public class DriveService(
Url = uri, Url = uri,
Name = new Uri(uri).AbsolutePath.Split('/').LastOrDefault() ?? "", Name = new Uri(uri).AbsolutePath.Split('/').LastOrDefault() ?? "",
Comment = description, Comment = description,
Type = CleanMimeType(mimeType ?? "application/octet-stream") Type = CleanMimeType(mimeType ?? "application/octet-stream"),
AccessKey = Guid.NewGuid().ToStringLower()
}; };
db.Add(file); db.Add(file);
@ -191,7 +192,8 @@ public class DriveService(
Comment = request.Comment, Comment = request.Comment,
Type = CleanMimeType(request.MimeType), Type = CleanMimeType(request.MimeType),
RequestHeaders = request.RequestHeaders, RequestHeaders = request.RequestHeaders,
RequestIp = request.RequestIp RequestIp = request.RequestIp,
AccessKey = Guid.NewGuid().ToStringLower() + Path.GetExtension(request.Filename)
}; };
db.Add(file); db.Add(file);
@ -331,7 +333,7 @@ public class DriveService(
Sha256 = digest, Sha256 = digest,
Size = buf.Length, Size = buf.Length,
IsLink = !shouldStore, IsLink = !shouldStore,
AccessKey = original?.accessKey, AccessKey = original?.accessKey ?? Guid.NewGuid().ToStringLower(),
IsSensitive = request.IsSensitive, IsSensitive = request.IsSensitive,
StoredInternal = storedInternal, StoredInternal = storedInternal,
Src = request.Source, Src = request.Source,
@ -461,21 +463,13 @@ public class DriveService(
file.PublicMimeType = null; file.PublicMimeType = null;
file.StoredInternal = false; 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); 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) if (deduplicated)
return; return;
}
if (storedInternal) if (storedInternal)
{ {

View file

@ -55,7 +55,7 @@ public partial class EmojiService(
Category = category, Category = category,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
OriginalUrl = driveFile.Url, OriginalUrl = driveFile.Url,
PublicUrl = driveFile.AccessUrl, RawPublicUrl = driveFile.RawAccessUrl,
Width = driveFile.Properties.Width, Width = driveFile.Properties.Width,
Height = driveFile.Properties.Height, Height = driveFile.Properties.Height,
Sensitive = false Sensitive = false
@ -81,7 +81,7 @@ public partial class EmojiService(
Name = existing.Name, Name = existing.Name,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
OriginalUrl = driveFile.Url, OriginalUrl = driveFile.Url,
PublicUrl = driveFile.AccessUrl, RawPublicUrl = driveFile.RawAccessUrl,
Width = driveFile.Properties.Width, Width = driveFile.Properties.Width,
Height = driveFile.Properties.Height, Height = driveFile.Properties.Height,
Sensitive = existing.Sensitive 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"), Name = emojo.Name ?? throw new Exception("emojo.Name must not be null at this stage"),
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
OriginalUrl = emojo.Image?.Url?.Link ?? throw new Exception("Emoji.Image has no url"), 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, Uri = emojo.Id,
Sensitive = false Sensitive = false
}; };

View file

@ -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<Config.InstanceSection> instance,
IOptionsMonitor<Config.StorageSection> 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}";
}

View file

@ -1,6 +1,6 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using System.Text; using System.Text;
using Carbon.Storage; using Carbon.Storage;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
@ -89,10 +89,11 @@ public class ObjectStorageService(IOptions<Config.StorageSection> config, HttpCl
if (_bucket == null) throw new Exception("Refusing to upload to object storage with invalid configuration"); if (_bucket == null) throw new Exception("Refusing to upload to object storage with invalid configuration");
var properties = (_acl ?? BlobProperties.Empty).ToDictionary(); 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-Type", contentType);
properties.Add("Content-Disposition", contentDisposition); properties.Add("Content-Disposition", contentDisposition.ToString());
IBlob blob = data.Length > 0 IBlob blob = data.Length > 0
? new Blob(GetKeyWithPrefix(key), data, properties) ? new Blob(GetKeyWithPrefix(key), data, properties)

View file

@ -92,7 +92,6 @@ public class StorageMaintenanceService(
// defer deletions in case an error occurs // defer deletions in case an error occurs
List<string> deletionQueue = []; List<string> deletionQueue = [];
if (file.AccessKey != null)
{ {
var path = Path.Join(pathBase, file.AccessKey); var path = Path.Join(pathBase, file.AccessKey);
var stream = File.OpenRead(path); var stream = File.OpenRead(path);
@ -179,10 +178,6 @@ public class StorageMaintenanceService(
} }
await driveSvc.ExpireFileAsync(file); 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; continue;
} }
@ -218,10 +213,6 @@ public class StorageMaintenanceService(
if (dryRun) continue; if (dryRun) continue;
await db.SaveChangesAsync(); 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) if (dryRun)

View file

@ -464,9 +464,6 @@ public class UserService(
user.AvatarBlurhash = avatar?.Blurhash; user.AvatarBlurhash = avatar?.Blurhash;
user.BannerBlurhash = banner?.Blurhash; user.BannerBlurhash = banner?.Blurhash;
user.AvatarUrl = avatar?.Url;
user.BannerUrl = banner?.Url;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return async () => return async () =>

View file

@ -167,6 +167,10 @@ MediaRetention = 30d
CleanAvatars = false CleanAvatars = false
CleanBanners = 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] [Storage:Local]
;; Path where media is stored at. Must be writable for the service user. ;; Path where media is stored at. Must be writable for the service user.
Path = /path/to/media/location Path = /path/to/media/location