[backend/razor] Move public preview to Blazor SSR (razor components)

This commit is contained in:
Laura Hausmann 2024-09-26 06:09:14 +02:00
parent 7662c28745
commit 9d1a21e2d9
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
35 changed files with 834 additions and 536 deletions

View file

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Components;
namespace Iceshrimp.Backend.Components.Helpers;
/// <summary>
/// Overrides to allow for asynchronous actions to be performed in fully SSR pages before the page gets rendered
/// </summary>
public class AsyncComponentBase : ComponentBase
{
private bool _initialized;
public override Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (_initialized) return CallOnParametersSetAsync();
_initialized = true;
return RunInitAndSetParametersAsync();
}
private async Task RunInitAndSetParametersAsync()
{
OnInitialized();
await OnInitializedAsync();
await CallOnParametersSetAsync();
}
private async Task CallOnParametersSetAsync()
{
OnParametersSet();
await OnParametersSetAsync();
StateHasChanged();
}
}

View file

@ -0,0 +1,42 @@
using AngleSharp.Io;
using Microsoft.AspNetCore.Routing.Matching;
namespace Iceshrimp.Backend.Components.PublicPreview.Attributes;
public class PublicPreviewRouteMatcher : MatcherPolicy, IEndpointSelectorPolicy
{
public override int Order => 99999; // That's ActionConstraintMatcherPolicy - 1
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
{
return endpoints.Any(p => p.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null);
}
public Task ApplyAsync(HttpContext ctx, CandidateSet candidates)
{
var applies = Enumerate(candidates)
.Any(p => p.Score >= 0 && p.Endpoint.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null);
if (!applies) return Task.CompletedTask;
// Add Vary: Accept to the response headers to prevent caches serving the wrong response
ctx.Response.Headers.Append(HeaderNames.Vary, HeaderNames.Cookie);
var hasCookie = ctx.Request.Cookies.ContainsKey("sessions");
for (var i = 0; i < candidates.Count; i++)
{
var candidate = candidates[i];
var hasAttr = candidate.Endpoint.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null;
candidates.SetValidity(i, !hasCookie || !hasAttr);
}
return Task.CompletedTask;
}
private static IEnumerable<CandidateState> Enumerate(CandidateSet candidates)
{
for (var i = 0; i < candidates.Count; i++) yield return candidates[i];
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class PublicPreviewRouteFilterAttribute : Attribute;

View file

@ -0,0 +1,34 @@
@using Iceshrimp.Backend.Components.PublicPreview.Schemas
<UserComponent User="Note.User" Link="true"/>
<!-- TODO: figure out a better place to put this
<small>Published at: @Note.CreatedAt</small>
@if (Note.UpdatedAt != null)
{
<br/>
<small>Edited at: @Note.UpdatedAt</small>
}
-->
<div class="content">
@if (Note.Text != null)
{
if (Note.Cw != null)
{
<details>
<summary>@Note.Cw</summary>
@Note.Text
</details>
}
else
{
@Note.Text
}
}
</div>
@code {
[Parameter, EditorRequired] public required PreviewNote Note { get; set; }
}

View file

@ -0,0 +1,11 @@
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
background-color: #f7f7f7;
background-color: var(--background-alt);
padding: 10px;
margin: 1em 0;
border-radius: 6px;
overflow: hidden;
}

View file

@ -0,0 +1,23 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Parsing;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public class MfmRenderer(IOptions<Config.InstanceSection> config)
{
private readonly MfmConverter _converter = new(config);
public async Task<MarkupString?> Render(
string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement
)
{
if (text is null) return null;
var parsed = Mfm.parse(text);
var serialized = await _converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement);
return new MarkupString(serialized);
}
}

View file

@ -0,0 +1,118 @@
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public class NoteRenderer(
DatabaseContext db,
UserRenderer userRenderer,
MfmRenderer mfm,
IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> security
)
{
public async Task<PreviewNote?> RenderOne(Note? note)
{
if (note == null) return null;
var allNotes = ((Note?[]) [note, note.Reply, note.Renote]).NotNull().ToList();
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var users = await GetUsers(allNotes);
var attachments = await GetAttachments(allNotes);
return await Render(note, users, mentions, emoji, attachments);
}
private async Task<PreviewNote> Render(
Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions,
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments
)
{
var res = new PreviewNote
{
User = users.First(p => p.Id == note.User.Id),
Text = await mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span"),
Cw = note.Cw,
RawText = note.Text,
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
Attachments = attachments[note.Id],
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
};
return res;
}
private async Task<Dictionary<string, List<Note.MentionedUser>>> GetMentions(List<Note> notes)
{
var mentions = notes.SelectMany(n => n.Mentions).Distinct().ToList();
if (mentions.Count == 0) return notes.ToDictionary<Note, string, List<Note.MentionedUser>>(p => p.Id, _ => []);
var users = await db.Users.Where(p => mentions.Contains(p.Id))
.ToDictionaryAsync(p => p.Id,
p => new Note.MentionedUser
{
Host = p.Host,
Uri = p.Uri ?? p.GetPublicUri(instance.Value),
Url = p.UserProfile?.Url,
Username = p.Username
});
return notes.ToDictionary(p => p.Id,
p => users.Where(u => p.Mentions.Contains(u.Key)).Select(u => u.Value).ToList());
}
private async Task<Dictionary<string, List<Emoji>>> GetEmoji(List<Note> notes)
{
var ids = notes.SelectMany(n => n.Emojis).Distinct().ToList();
if (ids.Count == 0) return notes.ToDictionary<Note, string, List<Emoji>>(p => p.Id, _ => []);
var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
return notes.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
}
private async Task<List<PreviewUser>> GetUsers(List<Note> notes)
{
if (notes is []) return [];
return await userRenderer.RenderMany(notes.Select(p => p.User).Distinct().ToList());
}
private async Task<Dictionary<string, List<PreviewAttachment>?>> GetAttachments(List<Note> notes)
{
if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => p.FileIds is [] ? null : []);
var ids = notes.SelectMany(p => p.FileIds).ToList();
var files = await db.DriveFiles.Where(p => ids.Contains(p.Id)).ToListAsync();
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => files
.Where(f => p.FileIds.Contains(f.Id))
.Select(f => new PreviewAttachment
{
MimeType = f.Type,
Url = f.AccessUrl,
Name = f.Name,
Alt = f.Comment
})
.ToList());
}
public async Task<List<PreviewNote>> RenderMany(List<Note> notes)
{
if (notes is []) return [];
var allNotes = notes.SelectMany<Note, Note?>(p => [p, p.Renote, p.Reply]).NotNull().Distinct().ToList();
var users = await GetUsers(allNotes);
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var attachments = await GetAttachments(allNotes);
return await notes.Select(p => Render(p, users, mentions, emoji, attachments)).AwaitAllAsync().ToListAsync();
}
}

View file

@ -0,0 +1,68 @@
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public class UserRenderer(
DatabaseContext db,
MfmRenderer mfm,
IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> security
)
{
public async Task<PreviewUser?> RenderOne(User? user)
{
if (user == null) return null;
var emoji = await GetEmoji([user]);
return await Render(user, emoji);
}
private async Task<PreviewUser> Render(User user, Dictionary<string, List<Emoji>> emoji)
{
var mentions = user.UserProfile?.Mentions ?? [];
// @formatter:off
var res = new PreviewUser
{
Id = user.Id,
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,
RawDisplayName = user.DisplayName,
DisplayName = await mfm.Render(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
Bio = await mfm.Render(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
MovedToUri = user.MovedToUri
};
// @formatter:on
if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
{
res.AvatarUrl = user.IdenticonUrlPath;
res.BannerUrl = null;
}
return res;
}
private async Task<Dictionary<string, List<Emoji>>> GetEmoji(List<User> users)
{
var ids = users.SelectMany(n => n.Emojis).Distinct().ToList();
if (ids.Count == 0) return users.ToDictionary<User, string, List<Emoji>>(p => p.Id, _ => []);
var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
return users.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
}
public async Task<List<PreviewUser>> RenderMany(List<User> users)
{
var emoji = await GetEmoji(users);
return await users.Select(p => Render(p, emoji)).AwaitAllAsync().ToListAsync();
}
}

View file

@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Components;
namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
public class PreviewNote
{
public required PreviewUser User;
public required string? RawText;
public required MarkupString? Text;
public required string? Cw;
public required string? QuoteUrl;
public required bool QuoteInaccessible;
public required List<PreviewAttachment>? Attachments;
public required string CreatedAt;
public required string? UpdatedAt;
}
public class PreviewAttachment
{
public required string MimeType;
public required string Url;
public required string Name;
public required string? Alt;
}

View file

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Components;
namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
public class PreviewUser
{
public required string Id;
public required string? RawDisplayName;
public required MarkupString? DisplayName;
public required MarkupString? Bio;
public required string Username;
public required string Host;
public required string Url;
public required string AvatarUrl;
public required string? BannerUrl;
public required string? MovedToUri;
}

View file

@ -0,0 +1,32 @@
@using Iceshrimp.Backend.Components.PublicPreview.Schemas
@if (Link)
{
<div class="user">
<a href="@User.Url">
<img src="@User.AvatarUrl" class="avatar" alt="User avatar"/>
</a>
<div class="title">
<a class="display-name" href="@User.Url">
@if (User.DisplayName != null)
{
@User.DisplayName
}
else
{
@User.Username
}
</a>
</div>
<span class="acct">@@@User.Username<span class="host">@@@User.Host</span></span>
</div>
}
else
{
<img src="@User.AvatarUrl" class="avatar" alt="User avatar"/>
}
@code {
[Parameter, EditorRequired] public required PreviewUser User { get; set; }
[Parameter] public bool Link { get; set; } = true;
}

View file

@ -1,15 +1,3 @@
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
background-color: #f7f7f7;
background-color: var(--background-alt);
padding: 10px;
margin: 1em 0;
border-radius: 6px;
overflow: hidden;
}
.user { .user {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(3em, 3em)); grid-template-columns: repeat(auto-fill, minmax(3em, 3em));
@ -45,4 +33,8 @@
.display-name { .display-name {
color: var(--text-bright); color: var(--text-bright);
}
.host {
color: var(--text-dim);
} }

View file

@ -0,0 +1,38 @@
@using System.Text.Encodings.Web
@using Iceshrimp.Backend.Core.Configuration
@using Iceshrimp.Backend.Core.Middleware
@using Microsoft.Extensions.Options
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@inject IOptions<Config.InstanceSection> Instance
@preservewhitespace true
@attribute [RazorSsr]
<!DOCTYPE html>
<html lang="en">
<!--suppress HtmlRequiredTitleElement, Justification: HeadOutlet -->
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="/Iceshrimp.Backend.styles.css"/>
<link rel="stylesheet" href="/css/default.css"/>
<link rel="icon" type="image/png" href="/favicon.png"/>
<HeadOutlet/>
<PageTitle>Iceshrimp.NET</PageTitle>
</head>
<body>
<Router AppAssembly="@typeof(RootComponent).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData"/>
</Found>
</Router>
<footer>
<strong>Iceshrimp.NET</strong> v@(Instance.Value.Version)
<span class="float-right">
<a href="/login?rd=@UrlEncoder.Default.Encode(Context.Request.Path)">Login</a>
</span>
</footer>
</body>
</html>
@code {
[CascadingParameter] public required HttpContext Context { get; set; }
}

View file

@ -0,0 +1,3 @@
.float-right {
float: right;
}

View file

@ -624,9 +624,11 @@ public class User : IEntity
: throw new Exception("Cannot access PublicUri for remote user"); : throw new Exception("Cannot access PublicUri for remote user");
public string GetPublicUrl(string webDomain) => Host == null public string GetPublicUrl(string webDomain) => Host == null
? $"https://{webDomain}/@{Username}" ? $"https://{webDomain}{PublicUrlPath}"
: throw new Exception("Cannot access PublicUrl for remote user"); : throw new Exception("Cannot access PublicUrl for remote user");
[Projectable] public string PublicUrlPath => $"/@{Username}";
public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}"; public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}";
} }

View file

@ -101,11 +101,6 @@ public static class MvcBuilderExtensions
return builder; return builder;
} }
public static IMvcBuilder AddRouteOverrides(this IMvcBuilder builder)
{
return builder.AddRazorPagesOptions(o => { o.Conventions.AddPageRoute("/User", "@{user}@{host}"); });
}
} }
/// <summary> /// <summary>

View file

@ -1,6 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using System.Xml.Linq; using System.Xml.Linq;
using Iceshrimp.Backend.Components.PublicPreview.Attributes;
using Iceshrimp.Backend.Components.PublicPreview.Renderers;
using Iceshrimp.Backend.Controllers.Federation; using Iceshrimp.Backend.Controllers.Federation;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Web.Renderers; using Iceshrimp.Backend.Controllers.Web.Renderers;
@ -83,7 +85,9 @@ public static class ServiceExtensions
.AddScoped<CacheService>() .AddScoped<CacheService>()
.AddScoped<MetaService>() .AddScoped<MetaService>()
.AddScoped<StorageMaintenanceService>() .AddScoped<StorageMaintenanceService>()
.AddScoped<RelayService>(); .AddScoped<RelayService>()
.AddScoped<Components.PublicPreview.Renderers.UserRenderer>()
.AddScoped<Components.PublicPreview.Renderers.NoteRenderer>();
// Singleton = instantiated once across application lifetime // Singleton = instantiated once across application lifetime
services services
@ -101,7 +105,10 @@ public static class ServiceExtensions
.AddSingleton<PushService>() .AddSingleton<PushService>()
.AddSingleton<StreamingService>() .AddSingleton<StreamingService>()
.AddSingleton<ImageProcessor>() .AddSingleton<ImageProcessor>()
.AddSingleton<RazorViewRenderService>(); .AddSingleton<RazorViewRenderService>()
.AddSingleton<StripRazorJsInitMiddleware>()
.AddSingleton<MfmRenderer>()
.AddSingleton<MatcherPolicy, PublicPreviewRouteMatcher>();
var config = configuration.GetSection("Storage").Get<Config.StorageSection>() ?? var config = configuration.GetSection("Storage").Get<Config.StorageSection>() ??
throw new Exception("Failed to read storage config section"); throw new Exception("Failed to read storage config section");

View file

@ -19,6 +19,12 @@ public static class StringExtensions
{ {
return target[..Math.Min(target.Length, maxLength)]; return target[..Math.Min(target.Length, maxLength)];
} }
public static string TruncateEllipsis(this string target, int maxLength)
{
if (target.Length <= maxLength) return target;
return target[..(maxLength-3)] + "...";
}
private static string ToPunycode(this string target) private static string ToPunycode(this string target)
{ {

View file

@ -27,7 +27,8 @@ public static class WebApplicationExtensions
.UseMiddleware<AuthorizationMiddleware>() .UseMiddleware<AuthorizationMiddleware>()
.UseMiddleware<FederationSemaphoreMiddleware>() .UseMiddleware<FederationSemaphoreMiddleware>()
.UseMiddleware<AuthorizedFetchMiddleware>() .UseMiddleware<AuthorizedFetchMiddleware>()
.UseMiddleware<InboxValidationMiddleware>(); .UseMiddleware<InboxValidationMiddleware>()
.UseMiddleware<StripRazorJsInitMiddleware>();
} }
public static IApplicationBuilder UseSwaggerWithOptions(this WebApplication app) public static IApplicationBuilder UseSwaggerWithOptions(this WebApplication app)

View file

@ -60,13 +60,13 @@ public class MfmConverter(IOptions<Config.InstanceSection> config)
public async Task<string> ToHtmlAsync( public async Task<string> ToHtmlAsync(
IEnumerable<MfmNode> nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null, IEnumerable<MfmNode> nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
bool quoteInaccessible = false, bool replyInaccessible = false, bool divAsRoot = false, bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List<Emoji>? emoji = null List<Emoji>? emoji = null
) )
{ {
var context = BrowsingContext.New(); var context = BrowsingContext.New();
var document = await context.OpenNewAsync(); var document = await context.OpenNewAsync();
var element = document.CreateElement(divAsRoot ? "div" : "p"); var element = document.CreateElement(rootElement);
var nodeList = nodes.ToList(); var nodeList = nodes.ToList();
var hasContent = nodeList.Count > 0; var hasContent = nodeList.Count > 0;
@ -131,13 +131,13 @@ public class MfmConverter(IOptions<Config.InstanceSection> config)
public async Task<string> ToHtmlAsync( public async Task<string> ToHtmlAsync(
string mfm, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null, string mfm, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
bool quoteInaccessible = false, bool replyInaccessible = false, bool divAsRoot = false, bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List<Emoji>? emoji = null List<Emoji>? emoji = null
) )
{ {
var nodes = MfmParser.Parse(mfm); var nodes = MfmParser.Parse(mfm);
return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible, return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible,
replyInaccessible, divAsRoot, emoji); replyInaccessible, rootElement, emoji);
} }
private INode FromMfmNode( private INode FromMfmNode(

View file

@ -131,7 +131,6 @@ public class ErrorHandlerMiddleware(
}; };
await WriteResponse(error); await WriteResponse(error);
//TODO: use the overload that takes an exception instead of printing it ourselves
logger.LogError("Request encountered an unexpected error: {exception}", e); logger.LogError("Request encountered an unexpected error: {exception}", e);
} }
@ -167,7 +166,7 @@ public class ErrorHandlerMiddleware(
{ {
var model = new ErrorPageModel(payload); var model = new ErrorPageModel(payload);
ctx.Response.ContentType = "text/html; charset=utf8"; ctx.Response.ContentType = "text/html; charset=utf8";
var stream = ctx.Response.BodyWriter.AsStream(); var stream = ctx.Response.Body;
await razor.RenderToStreamAsync("Shared/ErrorPage.cshtml", model, stream); await razor.RenderToStreamAsync("Shared/ErrorPage.cshtml", model, stream);
return; return;
} }

View file

@ -0,0 +1,50 @@
using System.Reflection;
using Microsoft.AspNetCore.Components.Endpoints;
namespace Iceshrimp.Backend.Core.Middleware;
public class StripRazorJsInitMiddleware : IMiddleware
{
private static readonly byte[] Magic = "<!--Blazor-Web-Initializers"u8.ToArray();
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var attribute = context.GetEndpoint()
?.Metadata.GetMetadata<RootComponentMetadata>()
?.Type.GetCustomAttributes<RazorSsrAttribute>()
.FirstOrDefault();
if (attribute == null)
{
await next(context);
return;
}
var body = context.Response.Body;
var stream = new MemoryStream();
context.Response.Body = stream;
try
{
await next(context);
stream.Seek(0, SeekOrigin.Begin);
if (stream.TryGetBuffer(out var buffer))
{
var index = buffer.AsSpan().IndexOf(Magic);
if (index != -1)
stream.SetLength(index);
}
context.Response.Headers.ContentLength = stream.Length;
await stream.CopyToAsync(body);
}
finally
{
// Revert body stream
context.Response.Body = body;
}
}
}
public class RazorSsrAttribute : Attribute;

View file

@ -15,7 +15,7 @@ public class IndexModel(MetaService meta, IOptionsSnapshot<Config.InstanceSectio
public async Task<IActionResult> OnGet() public async Task<IActionResult> OnGet()
{ {
if (Request.Cookies.ContainsKey("session") || Request.Cookies.ContainsKey("sessions")) if (Request.Cookies.ContainsKey("sessions"))
return Partial("Shared/FrontendSPA"); return Partial("Shared/FrontendSPA");
if (config.Value.RedirectIndexTo is { } dest) if (config.Value.RedirectIndexTo is { } dest)

View file

@ -1,171 +0,0 @@
@page "/notes/{id}"
@using AngleSharp
@using Iceshrimp.Backend.Core.Database.Tables
@using Iceshrimp.Backend.Core.Extensions
@model NoteModel
@section styles
{
<link rel="stylesheet" href="~/css/public-preview.css"/>
}
@if (Model.Note == null)
{
Response.StatusCode = 404;
<div>
<h2>Not found</h2>
<p>This note either doesn't exist or is only accessible to authenticated users</p>
</div>
}
else
{
async Task RenderNote(string name, Note note)
{
Model.TextContent.TryGetValue(note.Id, out var textContent);
var acct = "@" + note.User.Username + (note.User.IsLocalUser ? "" : "@" + note.User.Host);
var displayName = await RenderDisplayName(note.User.DisplayName ?? note.User.Username);
var url = note.User.UserProfile?.Url ?? note.User.Uri ?? note.User.GetPublicUrl(Model.WebDomain);
var avatarUrl = note.User.AvatarUrl == null || !Model.ShowMedia ? note.User.IdenticonUrlPath : note.User.AvatarUrl;
<div class="user">
<a href="@url">
<img src="@avatarUrl" class="avatar" alt="User avatar"/>
</a>
<div class="title">@name by <a class="display-name" href="@url">@Html.Raw(displayName)</a></div>
<span class="acct">@acct</span>
</div>
<small>Published at: @note.CreatedAt.ToDisplayString()</small>
@if (note.UpdatedAt != null)
{
<small>Edited at: @note.UpdatedAt.Value.ToDisplayString()</small>
}
if (textContent != null)
{
<div class="content-wrapper">
@if (note.Cw != null)
{
<details>
<summary>@note.Cw</summary>
@Html.Raw(textContent)
</details>
}
else
{
<div class="content">@Html.Raw(textContent)</div>
}
</div>
}
if (!Model.MediaAttachments.TryGetValue(note.Id, out var files)) return;
if (!Model.ShowMedia)
{
<p>
<i>This post has attachments, but this server's configuration prevents them from being displayed here.</i>
</p>
return;
}
@foreach (var file in files)
{
if (file.Type.StartsWith("image/"))
{
<img src="@file.Url" max-width="200px" alt="@file.Comment"/>
}
else if (file.Type.StartsWith("video/"))
{
<video controls max-width="200px">
<source src="@file.Url" type="@file.Type"/>
<p>@(file.Comment ?? "No alt text.")</p>
</video>
}
else
{
<div>Attachment: <a href="@file.Url">@file.Name</a> (@file.Type)</div>
}
}
return;
async Task<string> RenderDisplayName(string input)
{
var res = Html.Encode(input);
var emojis = Model.UserEmoji[note.User.Id];
if (emojis is []) return res;
var context = BrowsingContext.New();
var document = await context.OpenNewAsync();
foreach (var emoji in emojis)
{
var el = document.CreateElement("span");
var inner = document.CreateElement("img");
inner.SetAttribute("src", emoji.PublicUrl);
el.AppendChild(inner);
el.ClassList.Add("emoji");
res = res.Replace($":{emoji.Name.Trim(':')}:", el.OuterHtml);
}
return res;
}
}
async Task RenderQuoteUrl(string url)
{
var displayUrl = url.StartsWith("https://") ? url[8..] : url[7..];
<p>
<i>This post is quoting <a href="@url">@displayUrl</a>, but that post either has been deleted or is not publicly visible.</i>
</p>
}
ViewData["title"] = $"Note by @{Model.Note.User.Username} - {Model.InstanceName}";
@section head
{
@{
Model.MediaAttachments.TryGetValue(Model.Note.Id, out var attachments);
}
<meta name="twitter:card" content="summary">
<meta name="og:title" content="Note by @@@Model.Note.User.Username">
@if (Model.Note.Cw != null)
{
<meta name="og:description" content="Content warning: @Model.Note.Cw)">
}
else
{
var text = Model.Note.Text;
if (attachments is { Count: > 0 })
{
var attachmentText = $"({attachments.Count} attachments)";
if (string.IsNullOrWhiteSpace(text))
text = attachmentText;
else
text += $"\n{attachmentText}";
}
<meta name="og:description" content="@(text + (Model.QuoteUrl != null ? $"\n\nRE: {Model.QuoteUrl}" : ""))">
}
<meta name="og:site_name" content="@Model.InstanceName">
@if (Model.ShowMedia && attachments != null)
{
if (attachments.Any(p => p.Type.StartsWith("image/")))
{
<meta name="og:image" content="@attachments.First(p => p.Type.StartsWith("image/")).Url">
}
}
}
<div class="note">
@{
await RenderNote("Note", Model.Note);
if (Model.Note.Renote != null)
await RenderNote("Quote", Model.Note.Renote);
else if (Model.QuoteUrl != null)
await RenderQuoteUrl(Model.QuoteUrl);
}
</div>
if (!Model.ShowRemoteReplies)
{
<p>
<i>This server's configuration is preventing remotely originating content from being shown. This view may therefore be incomplete.</i>
</p>
}
}

View file

@ -1,133 +0,0 @@
using System.Diagnostics.CodeAnalysis;
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.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Pages;
public class NoteModel(
DatabaseContext db,
IOptions<Config.InstanceSection> config,
IOptions<Config.SecuritySection> security,
MetaService meta,
MfmConverter mfmConverter
) : PageModel
{
public Dictionary<string, List<DriveFile>> MediaAttachments = new();
public Note? Note;
public string? QuoteUrl;
public bool ShowMedia = security.Value.PublicPreview > Enums.PublicPreview.RestrictedNoMedia;
public bool ShowRemoteReplies = security.Value.PublicPreview > Enums.PublicPreview.Restricted;
public string InstanceName = "Iceshrimp.NET";
public string WebDomain = config.Value.WebDomain;
public Dictionary<string, string> TextContent = new();
public Dictionary<string, List<Emoji>> UserEmoji = new();
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery",
Justification = "IncludeCommonProperties")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
public async Task<IActionResult> OnGet(string id)
{
if (Request.Cookies.ContainsKey("session") || Request.Cookies.ContainsKey("sessions"))
return Partial("Shared/FrontendSPA");
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown)
throw new PublicPreviewDisabledException();
InstanceName = await meta.Get(MetaEntity.InstanceName) ?? InstanceName;
//TODO: thread view (respect public preview settings - don't show remote replies if set to restricted or lower)
Note = await db.Notes
.IncludeCommonProperties()
.Where(p => p.Id == id && p.VisibilityIsPublicOrHome)
.PrecomputeVisibilities(null)
.FirstOrDefaultAsync();
QuoteUrl = Note?.Renote?.Url ?? Note?.Renote?.Uri ?? Note?.Renote?.GetPublicUriOrNull(config.Value);
Note = Note?.EnforceRenoteReplyVisibility();
if (Note != null)
{
MediaAttachments[Note.Id] = await GetAttachments(Note);
UserEmoji[Note.User.Id] = await GetEmoji(Note.User);
}
if (Note?.Renote != null)
{
MediaAttachments[Note.Renote.Id] = await GetAttachments(Note.Renote);
UserEmoji[Note.Renote.User.Id] = await GetEmoji(Note.User);
}
if (Note is { IsPureRenote: true })
return RedirectPermanent(Note.Renote?.Url ??
Note.Renote?.Uri ??
Note.Renote?.GetPublicUriOrNull(config.Value) ??
throw new Exception("Note is remote but has no uri"));
if (Note is { UserHost: not null })
return RedirectPermanent(Note.Url ?? Note.Uri ?? throw new Exception("Note is remote but has no uri"));
if (Note is { Text: not null })
{
TextContent[Note.Id] = await mfmConverter.ToHtmlAsync(Note.Text, await GetMentions(Note), Note.UserHost,
divAsRoot: true, emoji: await GetEmoji(Note));
}
if (Note?.Renote is { Text: not null })
{
TextContent[Note.Renote.Id] = await mfmConverter.ToHtmlAsync(Note.Renote.Text!,
await GetMentions(Note.Renote),
Note.Renote.UserHost,
divAsRoot: true,
emoji: await GetEmoji(Note));
}
return Page();
}
private async Task<List<DriveFile>> GetAttachments(Note? note)
{
if (note == null || note.FileIds.Count == 0) return [];
return await db.DriveFiles.Where(p => note.FileIds.Contains(p.Id)).ToListAsync();
}
private async Task<List<Note.MentionedUser>> GetMentions(Note note)
{
return await db.Users.IncludeCommonProperties()
.Where(p => note.Mentions.Contains(p.Id))
.Select(u => new Note.MentionedUser
{
Host = u.Host ?? config.Value.AccountDomain,
Uri = u.Uri ?? u.GetPublicUri(config.Value),
Username = u.Username,
Url = u.UserProfile != null ? u.UserProfile.Url : null
})
.ToListAsync();
}
private async Task<List<Emoji>> GetEmoji(Note note)
{
var ids = note.Emojis;
if (ids.Count == 0) return [];
return await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
}
private async Task<List<Emoji>> GetEmoji(User user)
{
var ids = user.Emojis;
if (ids.Count == 0) return [];
return await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
}
}

View file

@ -0,0 +1,111 @@
@page "/notes/{id}"
@using Iceshrimp.Backend.Components.PublicPreview.Attributes
@using Iceshrimp.Backend.Core.Extensions
@attribute [PublicPreviewRouteFilter]
@inherits AsyncComponentBase
@if (_note is null)
{
Context.Response.StatusCode = 404;
<div>
<h2>Not found</h2>
<p>This note either doesn't exist or is only accessible to authenticated users</p>
</div>
}
else
{
<NoteComponent Note="_note"/>
if (_note.QuoteUrl != null)
{
var displayUrl = _note.QuoteUrl.StartsWith("https://") ? _note.QuoteUrl[8..] : _note.QuoteUrl[7..];
<p>
@if (_note.QuoteInaccessible)
{
<i>This note is quoting <a href="@_note.QuoteUrl">@displayUrl</a>, which has either been deleted or is not publicly visible.</i>
}
else
{
<i>This note is quoting <a href="@_note.QuoteUrl">@displayUrl</a></i>
}
</p>
}
if (!ShowMedia && _note.Attachments != null)
{
<p>
<i>This post has attachments, but this server's configuration prevents them from being displayed here.</i>
</p>
}
else if (ShowMedia && _note.Attachments is { Count: > 0 })
{
foreach (var file in _note.Attachments)
{
if (file.MimeType.StartsWith("image/"))
{
<img src="@file.Url" max-width="200px" max-height="200px" alt="@(file.Alt ?? "")"/>
}
else if (file.MimeType.StartsWith("video/"))
{
<video controls max-width="200px" max-height="200px">
<source src="@file.Url" type="@file.MimeType"/>
<p>@(file.Alt ?? "No alt text.")</p>
</video>
}
else
{
<div>Attachment: <a href="@file.Url">@file.Name</a> (@file.MimeType)</div>
}
}
}
<PageTitle>Note by @@@_note.User.Username - @_instanceName</PageTitle>
<HeadContent>
@{
var previewImageUrl = _note.User.AvatarUrl;
if (ShowMedia && _note.Attachments != null)
{
if (_note.Attachments.FirstOrDefault(p => p.MimeType.StartsWith("image/")) is { } img)
{
previewImageUrl = img.Url;
}
}
string description;
if (_note.Cw is { } cw)
{
description = $"Content warning: {cw}";
}
else
{
var text = _note.RawText?.TruncateEllipsis(280);
if (_note.Attachments is { Count: > 0 })
{
var attachmentText = $"({_note.Attachments.Count} attachments)";
text = text is null
? attachmentText
: text + $"\n{attachmentText}";
}
description = text + (_note.QuoteUrl != null ? $"\n\nRE: {_note.QuoteUrl}" : "");
}
var username = _note.User.Username;
var title = _note.User.RawDisplayName is { } name ? $"{name} (@{username})" : $"@{username}";
}
<meta name="twitter:card" content="summary">
<meta name="og:site_name" content="@_instanceName">
<meta name="og:title" content="@title">
<meta name="og:image" content="@previewImageUrl">
<meta name="og:description" content="@description">
<link rel="stylesheet" href="/css/public-preview.css"/>
</HeadContent>
if (!ShowRemoteReplies)
{
<p>
<i>This server's configuration is preventing remotely originating content from being shown. This view may therefore be incomplete.</i>
</p>
}
}

View file

@ -0,0 +1,69 @@
using Iceshrimp.Backend.Components.Helpers;
using Iceshrimp.Backend.Components.PublicPreview.Renderers;
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Pages;
public partial class NotePreview : AsyncComponentBase
{
[Inject] public required IOptions<Config.InstanceSection> Config { get; set; }
[Inject] public required IOptionsSnapshot<Config.SecuritySection> Security { get; set; }
[Inject] public required MetaService Meta { get; set; }
[Inject] public required DatabaseContext Database { get; set; }
[Inject] public required NoteRenderer Renderer { get; set; }
[Parameter] public required string Id { get; set; }
[CascadingParameter] public required HttpContext Context { get; set; }
private PreviewNote? _note;
private string _instanceName = "Iceshrimp.NET";
private bool ShowMedia => Security.Value.PublicPreview > Enums.PublicPreview.RestrictedNoMedia;
private bool ShowRemoteReplies => Security.Value.PublicPreview > Enums.PublicPreview.Restricted;
protected override async Task OnInitializedAsync()
{
if (Security.Value.PublicPreview == Enums.PublicPreview.Lockdown)
throw new PublicPreviewDisabledException();
_instanceName = await Meta.Get(MetaEntity.InstanceName) ?? _instanceName;
//TODO: show publish & edit timestamps
//TODO: show quotes inline (enforce visibility by checking VisibilityIsPublicOrHome)
//TODO: show parent post inline (enforce visibility by checking VisibilityIsPublicOrHome)
//TODO: show avatar instead of image as fallback? can we do both?
//TODO: thread view (respect public preview settings - don't show remote replies if set to restricted or lower)
var note = await Database.Notes
.IncludeCommonProperties()
.EnsureVisibleFor(null)
.FirstOrDefaultAsync(p => p.Id == Id);
if (note is { IsPureRenote: true })
{
var target = note.Renote?.Url ??
note.Renote?.Uri ??
note.Renote?.GetPublicUriOrNull(Config.Value) ??
throw new Exception("Note is remote but has no uri");
Context.Response.Redirect(target, permanent: true);
return;
}
if (note is { User.Host: not null })
{
var target = note.Url ?? note.Uri ?? throw new Exception("Note is remote but has no uri");
Context.Response.Redirect(target, permanent: true);
return;
}
_note = await Renderer.RenderOne(note);
}
}

View file

@ -9,7 +9,7 @@ public class TagModel : PageModel
public IActionResult OnGet(string tag) public IActionResult OnGet(string tag)
{ {
if (Request.Cookies.ContainsKey("session") || Request.Cookies.ContainsKey("sessions")) if (Request.Cookies.ContainsKey("sessions"))
return Partial("Shared/FrontendSPA"); return Partial("Shared/FrontendSPA");
Tag = tag; Tag = tag;

View file

@ -1,52 +0,0 @@
@page
@attribute [RazorCompiledItemMetadata("RouteTemplate", "/@{user}")]
@using Microsoft.AspNetCore.Razor.Hosting
@model UserModel
@section styles
{
<link rel="stylesheet" href="~/css/public-preview.css"/>
}
@if (Model.User == null)
{
Response.StatusCode = 404;
<div>
<h2>Not found</h2>
<p>This user doesn't appear to exist on this server</p>
</div>
}
else
{
ViewData["title"] = $"@{Model.User.Username} - {Model.InstanceName}";
@section head
{
<meta name="twitter:card" content="summary">
<meta name="og:site_name" content="@Model.InstanceName">
<meta name="og:title" content="@@@Model.User.Username">
@if (Model.User.UserProfile?.Description is { } bio)
{
<meta name="og:description" content="@bio">
}
<meta name="og:image" content="@(Model.User.AvatarUrl ?? Model.User.IdenticonUrlPath)">
}
<div class="user">
<div class="header">
<img src="@(Model.User.AvatarUrl)" class="avatar" alt="User avatar"/>
<div class="title">
<span>@Html.Raw(Model.DisplayName)</span>
</div>
<span class="acct">@@@Model.User.Username<span class="host">@@@Model.User.Host</span></span>
</div>
<div class="bio">
@Html.Raw(Model.Bio ?? "<i>This user hasn't added a bio yet.</i>")
</div>
@if (Model.User.MovedToUri is { } target)
{
<div class="bio">
<i>This user has migrated to a <a href="@target">different account</a>.</i>
</div>
}
</div>
}

View file

@ -1,102 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using AngleSharp;
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.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Pages;
public class UserModel(
DatabaseContext db,
IOptions<Config.SecuritySection> security,
IOptions<Config.InstanceSection> instance,
MetaService meta,
MfmConverter mfm
) : PageModel
{
public new User? User;
public string? Bio;
public List<Emoji>? Emoji;
public bool ShowMedia = security.Value.PublicPreview > Enums.PublicPreview.RestrictedNoMedia;
public string InstanceName = "Iceshrimp.NET";
public string? DisplayName;
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery",
Justification = "IncludeCommonProperties")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
public async Task<IActionResult> OnGet(string user, string? host)
{
if (Request.Cookies.ContainsKey("session") || Request.Cookies.ContainsKey("sessions"))
return Partial("Shared/FrontendSPA");
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown)
throw new PublicPreviewDisabledException();
InstanceName = await meta.Get(MetaEntity.InstanceName) ?? InstanceName;
//TODO: user note view (respect public preview settings - don't show renotes of remote notes if set to restricted or lower)
user = user.ToLowerInvariant();
host = host?.ToLowerInvariant();
if (host == instance.Value.AccountDomain || host == instance.Value.WebDomain)
host = null;
User = await db.Users.IncludeCommonProperties()
.Where(p => p.UsernameLower == user && p.Host == host)
.FirstOrDefaultAsync();
if (User is { IsRemoteUser: true })
return RedirectPermanent(User.UserProfile?.Url ??
User.Uri ??
throw new Exception("User is remote but has no uri"));
if (User != null)
{
Emoji = await GetEmoji(User);
DisplayName = HtmlEncoder.Default.Encode(User.DisplayName ?? User.Username);
if (Emoji is { Count: > 0 })
{
var context = BrowsingContext.New();
var document = await context.OpenNewAsync();
foreach (var emoji in Emoji)
{
var el = document.CreateElement("span");
var inner = document.CreateElement("img");
inner.SetAttribute("src", emoji.PublicUrl);
el.AppendChild(inner);
el.ClassList.Add("emoji");
DisplayName = DisplayName.Replace($":{emoji.Name.Trim(':')}:", el.OuterHtml);
}
}
}
if (User?.UserProfile?.Description is { } bio)
Bio = await mfm.ToHtmlAsync(bio, User.UserProfile.Mentions, User.Host, divAsRoot: true, emoji: Emoji);
if (User is { AvatarUrl: null } || (User is not null && !ShowMedia))
User.AvatarUrl = User.GetIdenticonUrl(instance.Value);
if (User is { Host: null })
User.Host = instance.Value.AccountDomain;
return Page();
}
private async Task<List<Emoji>> GetEmoji(User user)
{
var ids = user.Emojis;
if (ids.Count == 0) return [];
return await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
}
}

View file

@ -1,48 +0,0 @@
.bio {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: inherit;
}
.user {
background-color: #f7f7f7;
background-color: var(--background-alt);
padding: 12px;
margin: 1em 0;
border-radius: 6px;
overflow: hidden;
}
.header {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(3em, 3em));
grid-template-rows: repeat(2, 1.5em);
}
.avatar {
grid-column: 1;
grid-row: 1/3;
border-radius: 5px;
object-fit: cover;
width: 3em;
max-height: 3em;
}
.title {
grid-column: 2/-1;
grid-row: 1;
padding-left: 10px;
color: var(--text-bright);
font-weight: 600;
}
.acct {
grid-column: 2/-1;
grid-row: 2;
padding-left: 10px;
}
.host {
color: var(--text-dim);
}

View file

@ -0,0 +1,41 @@
@page "/User"
@using Iceshrimp.Backend.Components.PublicPreview.Attributes
@attribute [Route("/@{Acct}")]
@attribute [PublicPreviewRouteFilter]
@inherits AsyncComponentBase
@if (_user is null)
{
Context.Response.StatusCode = 404;
<div>
<h2>Not found</h2>
<p>This user doesn't appear to exist on this server</p>
</div>
}
else
{
<div class="user">
<UserComponent User="_user"/>
<div class="bio">
@(_user.Bio ?? new MarkupString("<i>This user hasn't added a bio yet.</i>"))
</div>
@if (_user.MovedToUri != null)
{
<div class="bio">
<i>This user has migrated to a <a href="@_user.MovedToUri">different account</a>.</i>
</div>
}
</div>
<PageTitle>@@@_user.Username - @_instanceName</PageTitle>
<HeadContent>
<meta name="twitter:card" content="summary">
<meta name="og:site_name" content="@_instanceName">
<meta name="og:title" content="@@@_user.Username">
@if (_user.Bio is { } bio)
{
<meta name="og:description" content="@bio">
}
<meta name="og:image" content="@_user.AvatarUrl">
<link rel="stylesheet" href="/css/public-preview.css"/>
</HeadContent>
}

View file

@ -0,0 +1,65 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Components.Helpers;
using Iceshrimp.Backend.Components.PublicPreview.Renderers;
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Pages;
public partial class UserPreview : AsyncComponentBase
{
[Inject] public required DatabaseContext Database { get; set; }
[Inject] public required UserRenderer Renderer { get; set; }
[Inject] public required MetaService Meta { get; set; }
[Inject] public required IOptions<Config.InstanceSection> Instance { get; set; }
[Inject] public required IOptionsSnapshot<Config.SecuritySection> Security { get; set; }
[Parameter] public required string Id { get; set; }
[Parameter] public required string Acct { get; set; }
[CascadingParameter] public required HttpContext Context { get; set; }
private PreviewUser? _user;
private string _instanceName = "Iceshrimp.NET";
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")]
protected override async Task OnInitializedAsync()
{
if (Security.Value.PublicPreview == Enums.PublicPreview.Lockdown)
throw new PublicPreviewDisabledException();
_instanceName = await Meta.Get(MetaEntity.InstanceName) ?? _instanceName;
//TODO: user banner
//TODO: user note view (respect public preview settings - don't show renotes of remote notes if set to restricted or lower)
var split = Acct.Split("@");
if (split.Length > 2) throw GracefulException.BadRequest("Invalid acct");
var username = split[0].ToLowerInvariant();
var host = split.Length == 2 ? split[1].ToPunycodeLower() : null;
if (host == Instance.Value.AccountDomain || host == Instance.Value.WebDomain)
host = null;
var user = await Database.Users
.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.UsernameLower == username &&
p.Host == host &&
!p.IsSystemUser);
if (user is { IsRemoteUser: true })
{
var target = user.UserProfile?.Url ?? user.Uri ?? throw new Exception("User is remote but has no uri");
Context.Response.Redirect(target, permanent: true);
return;
}
_user = await Renderer.RenderOne(user);
}
}

View file

@ -0,0 +1,15 @@
.user {
background-color: #f7f7f7;
background-color: var(--background-alt);
padding: 12px;
margin: 1em 0;
border-radius: 6px;
overflow: hidden;
}
.bio {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: inherit;
}

View file

@ -0,0 +1,4 @@
@using Iceshrimp.Backend.Components
@using Iceshrimp.Backend.Components.Helpers
@using Iceshrimp.Backend.Components.PublicPreview
@using Microsoft.AspNetCore.Components.Web

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Components;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.SignalR; using Iceshrimp.Backend.SignalR;
@ -30,7 +31,8 @@ builder.Services.AddAuthorizationPolicies();
builder.Services.AddAuthenticationServices(); builder.Services.AddAuthenticationServices();
builder.Services.AddSignalR().AddMessagePackProtocol(); builder.Services.AddSignalR().AddMessagePackProtocol();
builder.Services.AddResponseCompression(); builder.Services.AddResponseCompression();
builder.Services.AddRazorPages().AddRouteOverrides(); builder.Services.AddRazorPages(); //.AddRouteOverrides();
builder.Services.AddRazorComponents();
builder.Services.AddServices(builder.Configuration); builder.Services.AddServices(builder.Configuration);
builder.Services.ConfigureServices(builder.Configuration); builder.Services.ConfigureServices(builder.Configuration);
@ -65,6 +67,7 @@ app.MapControllers();
app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback").WithOrder(int.MaxValue - 3); app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback").WithOrder(int.MaxValue - 3);
app.MapHub<StreamingHub>("/hubs/streaming"); app.MapHub<StreamingHub>("/hubs/streaming");
app.MapRazorPages(); app.MapRazorPages();
app.MapRazorComponents<RootComponent>().DisableAntiforgery();
app.MapFrontendRoutes("/Shared/FrontendSPA"); app.MapFrontendRoutes("/Shared/FrontendSPA");
PluginLoader.RunAppHooks(app); PluginLoader.RunAppHooks(app);