[backend/razor] Move public preview to Blazor SSR (razor components)
This commit is contained in:
parent
7662c28745
commit
9d1a21e2d9
35 changed files with 834 additions and 536 deletions
34
Iceshrimp.Backend/Components/Helpers/AsyncComponentBase.cs
Normal file
34
Iceshrimp.Backend/Components/Helpers/AsyncComponentBase.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
38
Iceshrimp.Backend/Components/RootComponent.razor
Normal file
38
Iceshrimp.Backend/Components/RootComponent.razor
Normal 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; }
|
||||||
|
}
|
3
Iceshrimp.Backend/Components/RootComponent.razor.css
Normal file
3
Iceshrimp.Backend/Components/RootComponent.razor.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.float-right {
|
||||||
|
float: right;
|
||||||
|
}
|
|
@ -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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
111
Iceshrimp.Backend/Pages/NotePreview.razor
Normal file
111
Iceshrimp.Backend/Pages/NotePreview.razor
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
69
Iceshrimp.Backend/Pages/NotePreview.razor.cs
Normal file
69
Iceshrimp.Backend/Pages/NotePreview.razor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
41
Iceshrimp.Backend/Pages/UserPreview.razor
Normal file
41
Iceshrimp.Backend/Pages/UserPreview.razor
Normal 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>
|
||||||
|
}
|
65
Iceshrimp.Backend/Pages/UserPreview.razor.cs
Normal file
65
Iceshrimp.Backend/Pages/UserPreview.razor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
15
Iceshrimp.Backend/Pages/UserPreview.razor.css
Normal file
15
Iceshrimp.Backend/Pages/UserPreview.razor.css
Normal 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;
|
||||||
|
}
|
4
Iceshrimp.Backend/Pages/_Imports.razor
Normal file
4
Iceshrimp.Backend/Pages/_Imports.razor
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
@using Iceshrimp.Backend.Components
|
||||||
|
@using Iceshrimp.Backend.Components.Helpers
|
||||||
|
@using Iceshrimp.Backend.Components.PublicPreview
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
|
@ -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);
|
||||||
|
|
Loading…
Add table
Reference in a new issue