[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.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<MfmInlineMedia> InlineMedia);
[UsedImplicitly]
public class MfmRenderer(MfmConverter converter) : ISingletonService
{
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.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<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> 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

View file

@ -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"),

View file

@ -151,7 +151,12 @@ public class ActivityPubController(
[ProducesErrors(HttpStatusCode.NotFound)]
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.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);

View file

@ -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);

View file

@ -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<Config.InstanceSection> 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<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> 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
})

View file

@ -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<AttachmentEntity> 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}")]

View file

@ -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,

View file

@ -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
})

View file

@ -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<Config.InstanceSection> 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)

View file

@ -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
})

View file

@ -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<Config.InstanceSection> instance, DatabaseContext db) : ControllerBase
{
[HttpGet("/api/v1/pleroma/emoji")]
[ProducesResults(HttpStatusCode.OK)]
@ -26,7 +28,7 @@ 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();

View file

@ -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<Config.StorageSection> options,
ILogger<DriveController> 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<IActionResult> GetFileByAccessKey(string accessKey)
public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
{
var 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");
return await GetFileByAccessKey(accessKey, version, null);
}
if (file.StoredInternal)
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmojiById(string id)
{
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 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);
}
var path = Path.Join(pathBase, accessKey);
var stream = System.IO.File.OpenRead(path);
[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");
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 (user.Avatar is null)
{
var stream = await IdenticonHelper.GetIdenticonAsync(user.Id);
Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false);
}
else
if (!options.Value.ProxyRemoteMedia)
return Redirect(user.Avatar.RawThumbnailAccessUrl);
return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar);
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/banners/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
{
if (file.IsLink)
{
//TODO: handle remove media proxying
var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId)
?? throw GracefulException.NotFound("User not found");
if (user.Banner is null)
return NoContent();
}
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 (!options.Value.ProxyRemoteMedia)
return Redirect(user.Banner.RawThumbnailAccessUrl);
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);
}
return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner);
}
[HttpPost]
@ -110,14 +127,14 @@ public class DriveController(
public async Task<DriveFileResponse> 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<DriveFileResponse> 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<DriveFileResponse> 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<IActionResult> 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);
}
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.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<Config.InstanceSection> 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
};

View file

@ -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<Config.InstanceSection> instance,
UserService userSvc,
DriveService driveSvc
) : ControllerBase
{
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
@ -86,7 +92,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
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,7 +138,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Avatar = null;
user.AvatarBlurhash = null;
user.AvatarUrl = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
@ -143,7 +147,7 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
public string GetBannerUrl()
{
var user = HttpContext.GetUserOrFail();
return user.BannerUrl ?? "";
return user.GetBannerUrl(instance.Value) ?? "";
}
[HttpPost("banner")]
@ -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,7 +193,6 @@ public class ProfileController(UserService userSvc, DriveService driveSvc) : Con
user.Banner = null;
user.BannerBlurhash = null;
user.BannerUrl = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}

View file

@ -14,6 +14,7 @@ public class NoteRenderer(
UserRenderer userRenderer,
DatabaseContext db,
EmojiService emojiSvc,
MediaProxyService mediaProxy,
IOptions<Config.InstanceSection> 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
})

View file

@ -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<Config.InstanceSection> 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
@ -101,15 +106,32 @@ public class NotificationRenderer(UserRenderer userRenderer, NoteRenderer noteRe
{
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))
.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);
}

View file

@ -27,8 +27,8 @@ public class UserRenderer(IOptions<Config.InstanceSection> 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.InstanceSection> 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
})

View file

@ -85,8 +85,9 @@ public sealed class Config
public readonly long? MaxUploadSizeBytes;
public readonly TimeSpan? MediaRetentionTimeSpan;
public bool CleanAvatars = false;
public bool CleanBanners = false;
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)]

View file

@ -747,6 +747,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("id");
b.Property<string>("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<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("avatarUrl")
.HasComment("The URL of the avatar DriveFile");
b.Property<string>("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<string>("BannerUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("bannerUrl")
.HasComment("The URL of the banner DriveFile");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.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")]
[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")]

View file

@ -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<Emoji>
{
public void Configure(EntityTypeBuilder<Emoji> 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");
}
}

View file

@ -242,13 +242,6 @@ public class User : IEntity
[Column("speakAsCat")]
public bool SpeakAsCat { get; set; }
/// <summary>
/// The URL of the avatar DriveFile
/// </summary>
[Column("avatarUrl")]
[StringLength(512)]
public string? AvatarUrl { get; set; }
/// <summary>
/// The blurhash of the avatar DriveFile
/// </summary>
@ -256,13 +249,6 @@ public class User : IEntity
[StringLength(128)]
public string? AvatarBlurhash { get; set; }
/// <summary>
/// The URL of the banner DriveFile
/// </summary>
[Column("bannerUrl")]
[StringLength(512)]
public string? BannerUrl { get; set; }
/// <summary>
/// The blurhash of the banner DriveFile
/// </summary>
@ -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);
@ -627,7 +613,6 @@ public class User : IEntity
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 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<User>
{
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.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[]");

View file

@ -248,11 +248,21 @@ public static class ServiceExtensions
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) =>

View file

@ -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];

View file

@ -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<ASAttachment>()
.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;

View file

@ -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

View file

@ -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<Stream> GetIdenticonAsync(string id)
{
using var image = new Image<Rgb24>(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

View file

@ -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<MfmInlineMedia> Inlin
public readonly record struct MfmHtmlData(string Html, List<MfmInlineMedia> InlineMedia);
public class MfmConverter(
IOptions<Config.InstanceSection> config
IOptions<Config.InstanceSection> config,
MediaProxyService mediaProxy
) : ISingletonService
{
public AsyncLocal<bool> 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");

View file

@ -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);
/// <summary>
/// This is intended for cases where no error occured, but the request needs to be aborted early (e.g. WebFinger
/// 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);
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);

View file

@ -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 (storedInternal)
{

View file

@ -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
};

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.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.StorageSection> 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)

View file

@ -92,7 +92,6 @@ public class StorageMaintenanceService(
// defer deletions in case an error occurs
List<string> 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)

View file

@ -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 () =>

View file

@ -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