[backend/drive] Proxy remote media by default
This commit is contained in:
parent
241486a778
commit
113bd98b0e
40 changed files with 581 additions and 278 deletions
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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}")]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")]
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[]");
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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");
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
52
Iceshrimp.Backend/Core/Services/MediaProxyService.cs
Normal file
52
Iceshrimp.Backend/Core/Services/MediaProxyService.cs
Normal 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}";
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 () =>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue