Compare commits

...

139 commits

Author SHA1 Message Date
30bf18a539
e
Some checks failed
/ test-build-and-push (push) Has been cancelled
2025-04-11 18:38:55 -04:00
Lilian
b79de70345
[frontend/core] Add debug tripwire in StateSynchronizer 2025-03-26 00:08:27 +01:00
Lilian
9ce293b8ba
[frontend/core] Fix potential null reference exceptions 2025-03-26 00:08:27 +01:00
Laura Hausmann
49bd10bc68
[frontend/mfm] Improve performance of AngleSharp calls for MFM-HTML conversion
This makes sure the AngleSharp owner document is only created once per application lifecycle, and replaces all async calls with their synchronous counterparts (since the input is already loaded in memory, using async for this just creates overhead)
2025-03-24 18:06:13 +01:00
Laura Hausmann
9ff79c92e0
[backend/libmfm] Improve performance of AngleSharp calls for MFM-HTML conversion, improve UrlNode HTML representation
This makes sure the AngleSharp owner document is only created once per application lifecycle, and replaces all async calls with their synchronous counterparts (since the input is already loaded in memory, using async for this just creates overhead)
2025-03-24 18:05:21 +01:00
Laura Hausmann
b950cb716f
[frontend/components] Revert: "Fix note z-indexes"
This reverts commit b9b19b44af.
2025-03-24 13:17:34 +01:00
Lilian
cd0149cfeb
[frontend/components] Fix note timestamp not updating 2025-03-24 01:06:31 +01:00
Lilian
5a54da365c
[frontend/core] Fix UpdateService returning unexpected value 2025-03-23 16:25:52 +01:00
0b24eadf4c
[backend/akko-client] Add ThreadMuted to PleromaStatusExtensions so posts properly get set as muted on Pleroma clients 2025-03-23 09:36:40 -04:00
Laura Hausmann
ccf93a06aa
[backend/api] Improve notifications endpoint performance when rendering multiple custom emojis 2025-03-23 14:02:47 +01:00
Laura Hausmann
1af21062fb
[backend/masto-client] Fix slow notifications endpoint response time 2025-03-23 13:41:27 +01:00
Laura Hausmann
f1a1ed9039
[backend/api] Rename recommended timeline to bubble timeline for improved clarity 2025-03-23 12:24:53 +01:00
Laura Hausmann
2c3a9d49b1
[backend/akko-api] Fix IsPleroma flag not being used correctly 2025-03-23 00:05:35 +01:00
Laura Hausmann
2e911805de
[backend/api] Add all missing timeline endpoints 2025-03-22 23:35:51 +01:00
Laura Hausmann
d2f1048dcc
[backend/api] Update trigger timestamp after manual cron task runs (ISH-760) 2025-03-22 23:13:32 +01:00
Laura Hausmann
01cc7b08d9
[backend/cron] Add cron task statistics & drastically improve cron logic (ISH-760) 2025-03-22 23:09:03 +01:00
Laura Hausmann
2f2ec826bc
[backend/core] Handle emoji updates gracefully (ISH-727) 2025-03-22 22:03:14 +01:00
Laura Hausmann
cefbf4f898
[backend/core] Move AsyncLocals to FlagService, fix IsPleroma flag for streaming connections 2025-03-22 20:56:36 +01:00
pancakes
ca8f28c1fd
[frontend/components] Improve mobile emoji picker and menu positioning and padding 2025-03-21 15:56:36 +10:00
pancakes
b9b19b44af
[frontend/components] Fix note z-indexes 2025-03-21 15:21:21 +10:00
pancakes
bde4e5aada
Revert "[frontend/components] Stop note footer getting stuck under mfm nodes"
This reverts commit b1ef1b978a.
2025-03-21 14:14:47 +10:00
pancakes
e1e54029cd
[frontend/pages] Rearrange profile menu items, apply danger style, and change temp mute icon 2025-03-21 13:52:47 +10:00
pancakes
316c25e036
[frontend/components] Add refetch note action to note footer menu 2025-03-21 13:34:16 +10:00
pancakes
7ebcb98dd5
[frontend/components] Fix z-index and add a slight margin to separators 2025-03-21 13:34:16 +10:00
pancakes
47d417b6d5
[frontend/components] Improve emoji picker search 2025-03-21 13:34:16 +10:00
pancakes
2ebc90d418
[frontend/components] Simplify menu and emoji picker borders on mobile 2025-03-21 13:34:16 +10:00
pancakes
879247bc7c
[frontend/components] Improve menu spacing 2025-03-21 13:34:16 +10:00
pancakes
066e60a1ec
[frontend/components] Add danger style to account dropdown logout button 2025-03-21 13:34:16 +10:00
pancakes
e922b5dd04
[frontend/components] Remove bite notice from bite profile action to match bite note action 2025-03-21 13:34:16 +10:00
pancakes
435ad656c7
[frontend/components] Add separator and danger style to emoji management entry menu 2025-03-21 13:34:16 +10:00
pancakes
54d8d71d78
[frontend/components] Add separators and danger style to drive entry menus and fix styling 2025-03-21 13:34:16 +10:00
pancakes
406f36dea0
[frontend/components] Make drive entry styling more specific to avoid styling menus 2025-03-21 13:34:16 +10:00
pancakes
da066cca6a
[frontend/pages] Rearrange profile menu elements 2025-03-21 13:34:16 +10:00
pancakes
ae62ead358
[frontend/components] Improve menu styling 2025-03-21 13:30:56 +10:00
pancakes
ba784683d4
[frontend/components] Add copy contents button to note footer menu 2025-03-21 13:30:56 +10:00
pancakes
039d7b2931
[frontend/components] Split note share button into local and remote share 2025-03-21 13:30:56 +10:00
pancakes
8439ba6b88
[frontend/components] Rename and rearrange note footer menu elements 2025-03-21 13:30:56 +10:00
pancakes
586db6ff99
[frontend/components] Improve menu styling 2025-03-21 13:30:56 +10:00
pancakes
100908c1ee
[frontend/components] Add danger style to menu element 2025-03-21 13:30:56 +10:00
pancakes
dcc6fd3a19
[backend/database] Add gin_trgm index for Emoji Name and Host 2025-03-21 00:35:46 +01:00
pancakes
026cfa0e82
[backend/api] Improve remote emoji query 2025-03-21 00:35:46 +01:00
pancakes
805812110a
[frontend/pages] Add labels to top bar actions 2025-03-21 00:35:46 +01:00
pancakes
be16fe8e5d
[frontend/pages] Improve state handling for no emojis 2025-03-21 00:35:46 +01:00
pancakes
af580eeed1
[backend/api] Allow updating remote emojis but limited to sensitive status 2025-03-21 00:35:46 +01:00
pancakes
d160d7337e
[frontend/components] Use localization for other category name in emoji picker 2025-03-21 00:35:46 +01:00
pancakes
28610192fe
[frontend/pages] Use localization for other category name 2025-03-21 00:35:46 +01:00
pancakes
6875a1e9e2
[frontend/components] Ask for confirmation before cloning emojis 2025-03-21 00:35:45 +01:00
pancakes
7c0ed5fed4
[frontend/pages] Make emoji management layout flex 2025-03-21 00:35:45 +01:00
pancakes
c43394075a
[frontend/components] Improve layout styling 2025-03-21 00:35:45 +01:00
pancakes
2e73fb05bd
[frontend/pages] Make emoji management search consistent 2025-03-21 00:35:45 +01:00
pancakes
71f897435e
[frontend/components] Improve emoji management entry for remote emojis 2025-03-21 00:35:45 +01:00
pancakes
6586f8d631
[frontend/pages] Remove remote emoji code from local emoji management page 2025-03-21 00:35:45 +01:00
pancakes
7e86daa685
[frontend/pages] Rename Aliases to Tags 2025-03-21 00:35:45 +01:00
pancakes
6c1131357e
Revert "[frontend/components] Rework custom emoji management"
This reverts commit fe0848e08c.
2025-03-21 00:35:45 +01:00
pancakes
01d49ca011
[frontend/pages] Separate remote emoji management into its own page 2025-03-21 00:35:45 +01:00
pancakes
5eec67fe70
[backend/api] Add name and host queries to remote emoji endpoint 2025-03-21 00:35:44 +01:00
pancakes
d0e034adcd
[frontend/components] Make poll duration input smaller 2025-03-21 00:33:42 +01:00
pancakes
596b06a962
[frontend/components] Don't allow empty poll choices 2025-03-21 00:33:42 +01:00
pancakes
718a26516a
[frontend/components] Add poll creation to compose 2025-03-21 00:33:42 +01:00
pancakes
65bd4a3a15
[frontend/components] Display API error notices when composing fails 2025-03-21 00:33:00 +01:00
pancakes
da5f065757
[frontend/components] Display file name for compose attachments that aren't images 2025-03-21 00:28:56 +01:00
pancakes
e1ddc5c8f6
[frontend/components] Fallback to detecting compose attachment type by filename extension 2025-03-21 00:28:56 +01:00
pancakes
9530e1dafc
[frontend/components] Fallback to detecting drive file type by filename extension 2025-03-21 00:28:55 +01:00
pancakes
5976735af2
[frontend/components] Fallback to detecting attachment type by filename extension 2025-03-21 00:28:55 +01:00
pancakes
0ea4122ec7
[frontend/components] Use actual filename for file attachments 2025-03-21 00:28:55 +01:00
pancakes
fe08c4515e
[backend/api] Include DriveFile name in NoteAttachment 2025-03-21 00:28:55 +01:00
pancakes
2dee0cb91a
[frontend/pages] Fix button text in mute and unmute actions 2025-03-21 00:27:58 +01:00
pancakes
030d3bbbae
[frontend/pages] Add temporary mute action to profile page 2025-03-21 00:27:58 +01:00
pancakes
97679c275b
[frontend/pages] Add mute and unmute actions to profile page 2025-03-21 00:27:58 +01:00
pancakes
00e0a958b5
[backend/api] Add mute and unmute user endpoints 2025-03-21 00:27:58 +01:00
pancakes
17bfc5ff54
[frontend/components] Add blocking state to FollowButton 2025-03-21 00:27:58 +01:00
pancakes
f16836ca31
[frontend/pages] Add block and unblock actions to profile page 2025-03-21 00:27:58 +01:00
pancakes
158fe10696
[backend/api] Add block and unblock user endpoints 2025-03-21 00:27:58 +01:00
pancakes
b3a190c431
[frontend/components] Add button to remove attached quote in compose 2025-03-21 00:26:38 +01:00
pancakes
bacad0bd5a
[frontend/components] Add button to insert a quote in compose 2025-03-21 00:26:38 +01:00
pancakes
f50a975825
[frontend/components] Display filter name and blur it instead of filter keyword 2025-03-21 00:25:08 +01:00
pancakes
732d7b8134
[backend/api] Render note filter name 2025-03-21 00:25:08 +01:00
pancakes
6c1a4b5119
[frontend/components] Make host part of usernames dim like in reactions 2025-03-21 00:25:08 +01:00
pancakes
f0511fb95a
[frontend/components] Truncate the right side of instance names instead of the left side 2025-03-21 00:25:08 +01:00
pancakes
457283c7b6
[frontend/components] Show the sum of all the note's reactions as a count next to the react button 2025-03-21 00:25:08 +01:00
pancakes
b1ef1b978a
[frontend/components] Stop note footer getting stuck under mfm nodes 2025-03-21 00:25:08 +01:00
pancakes
5ffbafce16
[frontend/components] Increase single note timestamp margin 2025-03-21 00:25:08 +01:00
pancakes
062fc1724b
[frontend/components] Display the note created date above the footer for the selected note in single note view 2025-03-21 00:25:08 +01:00
pancakes
b08f7b0ed3
[frontend/components] Improve visibility of usernames on mobile and in notifications 2025-03-21 00:25:08 +01:00
pancakes
353b665080
[frontend/components] Fix note overflow clipping 2025-03-21 00:25:07 +01:00
pancakes
1644a7c35c
[frontend/components] Only show note body text if there is actual text in the body 2025-03-21 00:25:07 +01:00
pancakes
44646558d8
[frontend/components] Only open notes when clicking on the content warning text, note text or timestamp in contexts where you can open notes 2025-03-21 00:25:07 +01:00
pancakes
8dbaea3868
[frontend/pages] Add remote user notice banner to profile and single note page 2025-03-21 00:19:43 +01:00
Lilian
f995810d19
[frontend/components] Add button to clear local storage on error UI 2025-03-20 23:43:59 +01:00
Lilian
6814488d30
[frontend] Force frontend update once new version is available 2025-03-20 23:43:59 +01:00
Laura Hausmann
e772f45a35
[backend/masto-client] Increase advertised reaction limit to 100 2025-03-20 22:41:28 +01:00
Laura Hausmann
c5650fe3ae
[backend/openapi] Switch to native Scalar support for multiple OpenAPI documents 2025-03-19 15:33:14 +01:00
Laura Hausmann
eb65294219
[bakend/akko-api] Don't send note/notification entity pleroma extensions unless isPleroma is enabled for the auth token 2025-03-18 09:25:37 +01:00
Laura Hausmann
11faef2152
[backend/razor] Fix OpenGraph meta tags 2025-03-15 17:32:42 +01:00
Laura Hausmann
10b74ff0d9
[backend/razor] Add cron tasks page to admin dashboard (ISH-719) 2025-03-15 03:55:09 +01:00
Laura Hausmann
94c07d3d06
[backend/masto-client] Add endpoints for filing reports (ISH-749) 2025-03-15 02:44:43 +01:00
Laura Hausmann
05caa02abc
[backend/api] Mark local-only notes as such (ISH-658) 2025-03-15 02:16:47 +01:00
Laura Hausmann
ffcc8d0582
[backend/akko-client] Set status visibility to local when client is marked as isPleroma and note is local only (ISH-658) 2025-03-15 02:13:07 +01:00
Laura Hausmann
caa5263af6
[backend/akko-client] Add LocalOnly to PleromaStatusExtensions (ISH-658) 2025-03-15 02:06:47 +01:00
Laura Hausmann
0a6b097d4c
[backend/razor] Fix public preview redirects
Beyond actually working, we don't actually want to send permanent redirects here, as an auth state change might influence the response.
2025-03-15 01:48:00 +01:00
Laura Hausmann
ca2b64a7ad
[backend/razor] Add rel=alternate links to UserPreview and NotePreview 2025-03-15 01:21:13 +01:00
Laura Hausmann
9cad98f9e8
[docs] Update README.md 2025-03-15 01:01:32 +01:00
Laura Hausmann
2da465307e
[backend/startup] Add user management commands (ISH-504) 2025-03-13 20:14:02 +01:00
Laura Hausmann
4fc7ed1ac0
[sln] Update MfmSharp version (ISH-745) 2025-03-13 19:11:36 +01:00
Laura Hausmann
46462a7736
[backend/database] Set database connection application name 2025-03-13 18:39:53 +01:00
Laura Hausmann
b7446eceeb
[backend/api] Set report assignee when marking report as resolved (ISH-116) 2025-03-11 20:42:30 +01:00
Laura Hausmann
f3999f80be
[backend/api] Add support for creating, forwarding & resolving reports (ISH-116) 2025-03-11 20:40:23 +01:00
Laura Hausmann
7594c652f5
[backend/api] Add report endpoints (ISH-116) 2025-03-11 20:40:23 +01:00
afaea61fa0
[backend/razor] Fix navigation between user pages when using search 2025-03-11 01:28:34 +01:00
Laura Hausmann
b7b2c0bd42
[sln] Update version to be in line with security hotfix release
This avoids "am I vulnerable to this?" confusion if running a :dev image or a -git package.
2025-03-11 01:08:33 +01:00
Laura Hausmann
880ef39db6
[docs] Update CHANGELOG.md to be in line with the security update that was published 2025-03-11 01:07:04 +01:00
Laura Hausmann
8bbb5c1f98
[backend/federation] Handle incoming ASFlag activities (ISH-116) 2025-03-11 00:51:10 +01:00
Laura Hausmann
14147a5924
[sln] Revert LangVersion to latest due to EF Core breakages 2025-03-11 00:50:31 +01:00
Laura Hausmann
ae5cc2477f
[backend/core] Fix build with LangVersion=preview 2025-03-11 00:47:31 +01:00
Laura Hausmann
1bd69467af
[sln] Set LangVersion to preview
This allows us to use the field keyword when targeting net90.
2025-03-11 00:42:33 +01:00
Laura Hausmann
78bd70e2ab
[backend/federation] Apply code style in ActivityHandlerService.cs 2025-03-11 00:18:23 +01:00
Tamara Schmitz
caef749fa0
hint to TaskScheduler that queue main tasks and cron tasks are long running
The default TaskScheduler may also mark tasks as long running after a
while and then spawn an extra thread regardless. But this way we make
sure to not block the threadpool by spawning the tasks in separate
threads instead in the normal pool.

Also keep the denychildattach which Run would have added. See:
https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/
2025-03-10 22:46:21 +01:00
Laura Hausmann
72c21ce51c
[backend/database] Move the remaining migrations into their respective versioned folders 2025-03-09 17:21:12 +01:00
Laura Hausmann
65d6edf799
[backend/database] Fix slow AddUserLastNoteAdd migration
This change ensures that the migration doesn't have to read every note in the database, but only up to one per known user.
2025-03-09 02:56:00 +01:00
Laura Hausmann
4b5a9386c7
[backend/masto-client] Add /api/v1/preferences endpoint (ISH-746) 2025-03-07 21:56:35 +01:00
pancakes
f639124881
[backend/api] Check reaction name with and without colons 2025-03-07 21:23:57 +01:00
pancakes
091d0e0c3f
[backend/api] Fix endpoint for getting specific reactions on notes not working with Unicode emojis 2025-03-07 21:23:57 +01:00
Kopper
5d27233d08
[backend/masto-client] Expose AccountEntity.last_status_at
For performance reasons (it's set on the same query as the one that
increments the user notes counter) this does not take renotes into
account
2025-03-07 21:22:48 +01:00
Laura Hausmann
ec7056f11a
[backend/csproj] Update ImageSharp version 2025-03-06 18:02:11 +01:00
Laura Hausmann
864148129b
[docs] Add Ivory to supported Mastodon clients 2025-03-06 16:16:12 +01:00
Laura Hausmann
a4717da8ab
[backend/core] Fix link verification for sites served with Transfer-Encoding: chunked 2025-03-06 15:52:19 +01:00
pancakes
107160c690
[frontend/core] Reconstruct stored users to minimal state if they are malformed 2025-03-06 15:04:48 +10:00
Laura Hausmann
57488e5641
[backend/configuration] Allow disabling authorized fetch signature validation 2025-03-05 15:25:10 +01:00
Laura Hausmann
befe550f37
[backend/middleware] Authenticate requests with signature headers when authorized fetch is disabled
This fixes a bug that prevented fetching follower-only posts from remote instances when the local instance has authorized fetch disabled.
2025-03-05 15:25:10 +01:00
Laura Hausmann
0442f676e1
[backend/database] [frontend] [shared] Rename emoji aliases to tags (ISH-717) 2025-03-04 23:28:59 +01:00
Laura Hausmann
6391e5f185
[tests] Improve SearchQueryTests 2025-03-04 23:18:08 +01:00
Laura Hausmann
220c4b776d
[parsing] Add visibility:local search query filter (ISH-707) 2025-03-04 23:18:00 +01:00
Laura Hausmann
822d5f90d3
[backend/core] Fix negated multi word search query filters (ISH-739) 2025-03-04 23:11:00 +01:00
Laura Hausmann
4a4c674776
[parsing] Add note visibility search query type (ISH-707) 2025-03-04 23:06:09 +01:00
Laura Hausmann
fe24a11ca8
[backend/core] Move mutes on account migration (ISH-721) 2025-03-04 22:54:52 +01:00
Laura Hausmann
4aa548dab4
[backend/razor] Fix details element styles for Chrome >= 131 & future Firefox versions 2025-03-03 18:52:33 +01:00
Laura Hausmann
bcc8377bec
[backend/api] Fix emoji import request size limiter not being disabled 2025-02-27 01:56:18 +01:00
Laura Hausmann
ed1623b572
[backend/core] Fix path-style object storage access URLs 2025-02-26 05:06:05 +01:00
Laura Hausmann
bf5f1e927c
[backend/core] Verify object storage credentials & access URL before starting the object storage migration task 2025-02-26 03:07:00 +01:00
182 changed files with 4472 additions and 1387 deletions

View file

@ -2,6 +2,11 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

View file

@ -1,3 +1,12 @@
## v2025.1-beta5.patch2.security1
This is a security hotfix release. It's identical to v2025.1-beta5.patch2, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
### Backend
- Updated SixLabors.ImageSharp to 3.1.7 (addressing [GHSA-2cmq-823j-5qj8](https://github.com/advisories/GHSA-2cmq-823j-5qj8))
### Attribution
This release was made possible by project contributors: Laura Hausmann
## v2025.1-beta5.patch2
This is a hotfix release. It's identical to v2025.1-beta5.patch1, except for a bunch of bugfixes. Upgrading is strongly recommended for all server operators running v2025.1-beta5 or v2025.1-beta5.patch1.

View file

@ -27,7 +27,7 @@
<!-- Version metadata -->
<PropertyGroup>
<VersionPrefix>2025.1</VersionPrefix>
<VersionSuffix>beta5.patch2</VersionSuffix>
<VersionSuffix>beta5.patch2.security1</VersionSuffix>
</PropertyGroup>
<ItemGroup>

View file

@ -14,6 +14,7 @@
new("/admin/users", "User management", Icons.Users),
new("/admin/federation", "Federation control", Icons.Graph),
new("/admin/relays", "Relays", Icons.FastForward),
new("/admin/tasks", "Cron tasks", Icons.Timer),
new("/admin/plugins", "Plugins", Icons.Plug)
];

View file

@ -2,6 +2,7 @@ using Iceshrimp.Backend.Components.PublicPreview.Schemas;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.MfmSharp;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Components;
@ -11,9 +12,9 @@ namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public readonly record struct MfmRenderData(MarkupString Html, List<MfmInlineMedia> InlineMedia);
[UsedImplicitly]
public class MfmRenderer(MfmConverter converter) : ISingletonService
public class MfmRenderer(MfmConverter converter, FlagService flags) : ISingletonService
{
public async Task<MfmRenderData?> RenderAsync(
public MfmRenderData? Render(
string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement,
List<PreviewAttachment>? media = null
)
@ -22,18 +23,18 @@ public class MfmRenderer(MfmConverter converter) : ISingletonService
var parsed = MfmParser.Parse(text);
// Ensure we are rendering HTML markup (AsyncLocal)
converter.SupportsHtmlFormatting.Value = true;
converter.SupportsInlineMedia.Value = true;
flags.SupportsHtmlFormatting.Value = true;
flags.SupportsInlineMedia.Value = true;
var mfmInlineMedia = media?.Select(m => new MfmInlineMedia(MfmInlineMedia.GetType(m.MimeType), m.Url, m.Alt)).ToList();
var serialized = await converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement, media: mfmInlineMedia);
var serialized = converter.ToHtml(parsed, mentions, host, emoji: emoji, rootElement: rootElement, media: mfmInlineMedia);
return new MfmRenderData(new MarkupString(serialized.Html), serialized.InlineMedia);
}
public async Task<MarkupString?> RenderSimpleAsync(string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement)
public MarkupString? RenderSimple(string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement)
{
var rendered = await RenderAsync(text, host, mentions, emoji, rootElement);
var rendered = Render(text, host, mentions, emoji, rootElement);
return rendered?.Html;
}
}

View file

@ -30,30 +30,31 @@ public class NoteRenderer(
var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes);
return await RenderAsync(note, users, mentions, emoji, attachments, polls);
return Render(note, users, mentions, emoji, attachments, polls);
}
private async Task<PreviewNote> RenderAsync(
private PreviewNote Render(
Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions,
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments,
Dictionary<string, PreviewPoll> polls
)
{
var renderedText = await mfm.RenderAsync(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span", attachments[note.Id]);
var renderedText = mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span", attachments[note.Id]);
var inlineMediaUrls = renderedText?.InlineMedia.Select(m => m.Src).ToArray() ?? [];
var res = new PreviewNote
{
User = users.First(p => p.Id == note.User.Id),
Text = renderedText?.Html,
Cw = note.Cw,
RawText = note.Text,
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
User = users.First(p => p.Id == note.User.Id),
Text = renderedText?.Html,
Cw = note.Cw,
RawText = note.Text,
Uri = note.Uri ?? note.GetPublicUri(instance.Value),
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
Poll = polls.GetValueOrDefault(note.Id),
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
Poll = polls.GetValueOrDefault(note.Id),
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
};
return res;
@ -142,8 +143,6 @@ public class NoteRenderer(
var emoji = await GetEmojiAsync(allNotes);
var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes);
return await notes.Select(p => RenderAsync(p, users, mentions, emoji, attachments, polls))
.AwaitAllAsync()
.ToListAsync();
return notes.Select(p => Render(p, users, mentions, emoji, attachments, polls)).ToList();
}
}

View file

@ -19,10 +19,10 @@ public class UserRenderer(
{
if (user == null) return null;
var emoji = await GetEmojiAsync([user]);
return await RenderAsync(user, emoji);
return Render(user, emoji);
}
private async Task<PreviewUser> RenderAsync(User user, Dictionary<string, List<Emoji>> emoji)
private PreviewUser Render(User user, Dictionary<string, List<Emoji>> emoji)
{
var mentions = user.UserProfile?.Mentions ?? [];
@ -32,12 +32,13 @@ public class UserRenderer(
Id = user.Id,
Username = user.Username,
Host = user.Host ?? instance.Value.AccountDomain,
Uri = user.GetUriOrPublicUri(instance.Value),
Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath,
AvatarUrl = user.GetAvatarUrl(instance.Value),
BannerUrl = user.GetBannerUrl(instance.Value),
RawDisplayName = user.DisplayName,
DisplayName = await mfm.RenderSimpleAsync(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
Bio = await mfm.RenderSimpleAsync(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
DisplayName = mfm.RenderSimple(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
Bio = mfm.RenderSimple(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
MovedToUri = user.MovedToUri
};
// @formatter:on
@ -63,6 +64,6 @@ public class UserRenderer(
public async Task<List<PreviewUser>> RenderManyAsync(List<User> users)
{
var emoji = await GetEmojiAsync(users);
return await users.Select(p => RenderAsync(p, emoji)).AwaitAllAsync().ToListAsync();
return users.Select(p => Render(p, emoji)).ToList();
}
}

View file

@ -8,6 +8,7 @@ public class PreviewNote
public required string? RawText;
public required MarkupString? Text;
public required string? Cw;
public required string? Uri;
public required string? QuoteUrl;
public required bool QuoteInaccessible;
public required List<PreviewAttachment>? Attachments;

View file

@ -11,6 +11,7 @@ public class PreviewUser
public required string Username;
public required string Host;
public required string Url;
public required string Uri;
public required string AvatarUrl;
public required string? BannerUrl;
public required string? MovedToUri;

View file

@ -55,7 +55,7 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
})
.ToListAsync();
await res.Select(async p => p.Content = (await mfmConverter.ToHtmlAsync(p.Content, [], null)).Html).AwaitAllAsync();
res.ForEach(p => p.Content = mfmConverter.ToHtml(p.Content, [], null).Html);
return res;
}

View file

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -8,6 +9,7 @@ using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@ -157,4 +159,44 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
return new object();
}
[Authenticate]
[HttpGet("/api/oauth_tokens.json")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<PleromaOauthTokenEntity>> GetOauthTokens()
{
var user = HttpContext.GetUserOrFail();
var oauthTokens = await db.OauthTokens
.Where(p => p.User == user)
.Include(oauthToken => oauthToken.App)
.ToListAsync();
List<PleromaOauthTokenEntity> result = [];
foreach (var token in oauthTokens)
{
result.Add(new PleromaOauthTokenEntity()
{
Id = token.Id,
AppName = token.App.Name,
ValidUntil = token.CreatedAt + TimeSpan.FromDays(365 * 100)
});
}
return result;
}
[Authenticate]
[HttpDelete("/api/oauth_tokens/{id}")]
[ProducesResults(HttpStatusCode.Created)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
public async Task RevokeOauthTokenPleroma(string id)
{
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.Forbidden("You are not authorized to revoke this token");
db.Remove(token);
await db.SaveChangesAsync();
Response.StatusCode = 201;
}
}

View file

@ -0,0 +1,29 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Mvc;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[Route("/api/v1/preferences")]
[Authenticate]
[MastodonApiController]
public class PreferencesController : ControllerBase
{
[Authorize("read:accounts")]
[HttpGet]
public PreferencesEntity GetPreferences()
{
var settings = HttpContext.GetUserOrFail().UserSettings;
var visibility = StatusEntity.EncodeVisibility(settings?.DefaultNoteVisibility ?? Note.NoteVisibility.Public);
return new PreferencesEntity
{
PostingDefaultVisibility = visibility,
PostingDefaultSensitive = settings?.AlwaysMarkSensitive ?? false,
ReadingExpandMedia = "default",
ReadingExpandSpoilers = false
};
}
}

View file

@ -20,7 +20,8 @@ public class NoteRenderer(
MfmConverter mfmConverter,
DatabaseContext db,
EmojiService emojiSvc,
AttachmentRenderer attachmentRenderer
AttachmentRenderer attachmentRenderer,
FlagService flags
) : IScopedService
{
private static readonly FilterResultEntity InaccessibleFilter = new()
@ -152,8 +153,8 @@ public class NoteRenderer(
{
if (text != null || quoteUri != null || quoteInaccessible || replyInaccessible)
{
(content, inlineMedia) = await mfmConverter.ToHtmlAsync(text ?? "", mentionedUsers, note.UserHost, quoteUri,
quoteInaccessible, replyInaccessible, media: inlineMedia);
(content, inlineMedia) = mfmConverter.ToHtml(text ?? "", mentionedUsers, note.UserHost, quoteUri,
quoteInaccessible, replyInaccessible, media: inlineMedia);
attachments.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
}
@ -170,6 +171,20 @@ public class NoteRenderer(
? (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null;
var visibility = flags.IsPleroma.Value && note.LocalOnly
? "local"
: StatusEntity.EncodeVisibility(note.Visibility);
var pleromaExtensions = flags.IsPleroma.Value
? new PleromaStatusExtensions
{
LocalOnly = note.LocalOnly,
Reactions = reactions,
ConversationId = note.ThreadId,
ThreadMuted = muted
}
: null;
var res = new StatusEntity
{
Id = note.Id,
@ -194,7 +209,7 @@ public class NoteRenderer(
IsMuted = muted,
IsSensitive = sensitive,
ContentWarning = cw ?? "",
Visibility = StatusEntity.EncodeVisibility(note.Visibility),
Visibility = visibility,
Content = content,
Text = text,
Mentions = mentions,
@ -205,7 +220,7 @@ public class NoteRenderer(
Reactions = reactions,
Tags = tags,
Filtered = filterResult,
Pleroma = new PleromaStatusExtensions { Reactions = reactions, ConversationId = note.ThreadId }
Pleroma = pleromaExtensions
};
return res;
@ -243,7 +258,7 @@ public class NoteRenderer(
_ => MfmInlineMedia.MediaType.Other
}, p.RemoteUrl ?? p.Url, p.Description)).ToList();
(var content, inlineMedia) = await mfmConverter.ToHtmlAsync(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia);
(var content, inlineMedia) = mfmConverter.ToHtml(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia);
files.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
var entry = new StatusEdit

View file

@ -5,6 +5,7 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -14,7 +15,8 @@ public class NotificationRenderer(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
NoteRenderer noteRenderer,
UserRenderer userRenderer
UserRenderer userRenderer,
FlagService flags
) : IScopedService
{
public async Task<NotificationEntity> RenderAsync(
@ -27,13 +29,13 @@ public class NotificationRenderer(
var targetNote = notification.Note;
var note = targetNote != null
? statuses?.FirstOrDefault(p => p.Id == targetNote.Id) ??
await noteRenderer.RenderAsync(targetNote, user, Filter.FilterContext.Notifications,
new NoteRenderer.NoteRendererDto { Accounts = accounts })
? statuses?.FirstOrDefault(p => p.Id == targetNote.Id)
?? await noteRenderer.RenderAsync(targetNote, user, Filter.FilterContext.Notifications,
new NoteRenderer.NoteRendererDto { Accounts = accounts })
: null;
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
await userRenderer.RenderAsync(dbNotifier, user);
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id)
?? await userRenderer.RenderAsync(dbNotifier, user);
string? emojiUrl = null;
if (notification.Reaction != null)
@ -62,7 +64,9 @@ public class NotificationRenderer(
CreatedAt = notification.CreatedAt.ToStringIso8601Like(),
Emoji = notification.Reaction,
EmojiUrl = emojiUrl,
Pleroma = new PleromaNotificationExtensions { IsSeen = notification.IsRead }
Pleroma = flags.IsPleroma.Value
? new PleromaNotificationExtensions { IsSeen = notification.IsRead }
: null
};
return res;
@ -97,27 +101,28 @@ public class NotificationRenderer(
.Select(p =>
{
var parts = p.Reaction!.Trim(':').Split('@');
return new { Name = parts[0], Host = parts.Length > 1 ? parts[1] : null };
});
return (name: parts[0], host: parts.Length > 1 ? parts[1] : null);
})
.Distinct()
.ToArray();
var expr = ExpressionExtensions.False<Emoji>();
expr = parts.Aggregate(expr, (current, part) => current.Or(p => p.Name == part.name && p.Host == part.host));
// https://github.com/dotnet/efcore/issues/31492
//TODO: is there a better way of expressing this using LINQ?
IQueryable<Emoji> urlQ = db.Emojis;
foreach (var part in parts)
urlQ = urlQ.Concat(db.Emojis.Where(e => e.Name == part.Name && e.Host == part.Host));
var emojiUrls = await db.Emojis
.Where(expr)
.Select(e => new
{
Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:",
Url = e.GetAccessUrl(instance.Value)
})
.ToDictionaryAsync(e => e.Name, e => e.Url);
//TODO: can we somehow optimize this to do the dedupe database side?
var emojiUrls = await urlQ.Select(e => new
{
Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:",
Url = e.GetAccessUrl(instance.Value)
})
.ToArrayAsync()
.ContinueWithResult(res => res.DistinctBy(e => e.Name)
.ToDictionary(e => e.Name, e => e.Url));
var res = await notificationList
.Select(p => RenderAsync(p, user, isPleroma, accounts, notes, emojiUrls))
.AwaitAllAsync();
return await notificationList
.Select(p => RenderAsync(p, user, isPleroma, accounts, notes, emojiUrls))
.AwaitAllAsync();
return res;
}
}
}

View file

@ -1,9 +1,11 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -13,7 +15,8 @@ public class UserRenderer(
IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security,
MfmConverter mfmConverter,
DatabaseContext db
DatabaseContext db,
FlagService flags
) : IScopedService
{
private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png";
@ -31,18 +34,15 @@ public class UserRenderer(
var profileEmoji = data?.Emoji.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([user]);
var mentions = profile?.Mentions ?? [];
var fields = profile != null
? await profile.Fields
.Select(async p => new Field
{
Name = p.Name,
Value = (await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
})
.AwaitAllAsync()
: null;
var fields = profile?.Fields
.Select(p => new Field
{
Name = p.Name,
Value = (mfmConverter.ToHtml(p.Value, mentions, user.Host)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
});
var fieldsSource = source
? profile?.Fields.Select(p => new Field { Name = p.Name, Value = p.Value }).ToList() ?? []
@ -51,6 +51,25 @@ public class UserRenderer(
var avatarAlt = data?.AvatarAlt.GetValueOrDefault(user.Id);
var bannerAlt = data?.BannerAlt.GetValueOrDefault(user.Id);
string? favicon;
string? softwareName;
string? softwareVersion;
if (user.IsRemoteUser)
{
var instInfo = await db.Instances
.Where(p => p.Host == user.Host)
.FirstOrDefaultAsync();
favicon = instInfo?.FaviconUrl;
softwareName = instInfo?.SoftwareName;
softwareVersion = instInfo?.SoftwareVersion;
}
else
{
favicon = config.Value.WebDomain + "/_content/Iceshrimp.Assets.Branding/favicon.png";
softwareName = "iceshrimp";
softwareVersion = config.Value.Version;
}
var res = new AccountEntity
{
Id = user.Id,
@ -61,10 +80,11 @@ public class UserRenderer(
FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}",
IsLocked = user.IsLocked,
CreatedAt = user.CreatedAt.ToStringIso8601Like(),
LastStatusAt = user.LastNoteAt?.ToStringIso8601Like(),
FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount,
Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html,
Note = mfmConverter.ToHtml(profile?.Description ?? "", mentions, user.Host).Html,
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO
@ -76,7 +96,30 @@ public class UserRenderer(
IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable,
Fields = fields?.ToList() ?? [],
Emoji = profileEmoji
Emoji = profileEmoji,
Pleroma = flags?.IsPleroma.Value == true
? new PleromaUserExtensions
{
IsAdmin = user.IsAdmin,
IsModerator = user.IsModerator,
Favicon = favicon!
} : null,
Akkoma = flags?.IsPleroma.Value == true
? new AkkomaUserExtensions
{
Instance = new AkkomaInstanceEntity
{
Name = user.Host ?? config.Value.AccountDomain,
NodeInfo = new AkkomaNodeInfoEntity
{
Software = new AkkomaNodeInfoSoftwareEntity
{
Name = softwareName,
Version = softwareVersion
}
}
}
} : null
};
if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO

View file

@ -0,0 +1,63 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[Route("/api/v1/reports")]
[Authenticate]
[MastodonApiController]
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class ReportController(ReportService reportSvc, DatabaseContext db, UserRenderer userRenderer) : ControllerBase
{
[Authorize("write:reports")]
[HttpPost]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task<ReportEntity> FileReport([FromHybrid] ReportSchemas.FileReportRequest request)
{
var user = HttpContext.GetUserOrFail();
if (request.Comment.Length > 2048)
throw GracefulException.BadRequest("Comment length must not exceed 2048 characters");
if (request.AccountId == user.Id)
throw GracefulException.BadRequest("You cannot report yourself");
var target = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == request.AccountId)
?? throw GracefulException.NotFound("Target user not found");
var notes = await db.Notes.Where(p => request.StatusIds.Contains(p.Id)).ToListAsync();
if (notes.Any(p => p.UserId != target.Id))
throw GracefulException.BadRequest("Note author does not match target user");
var report = await reportSvc.CreateReportAsync(user, target, notes, request.Comment);
var targetAccount = await userRenderer.RenderAsync(report.TargetUser, user);
return new ReportEntity
{
Id = report.Id,
Category = "other",
Comment = report.Comment,
Forwarded = report.Forwarded,
ActionTaken = report.Resolved,
CreatedAt = report.CreatedAt.ToStringIso8601Like(),
TargetAccount = targetAccount,
RuleIds = null,
StatusIds = request.StatusIds,
ActionTakenAt = null
};
}
}

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
@ -5,29 +6,32 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class AccountEntity : IIdentifiable
{
[J("username")] public required string Username { get; set; }
[J("acct")] public required string Acct { get; set; }
[J("fqn")] public required string FullyQualifiedName { get; set; }
[J("display_name")] public required string DisplayName { get; set; }
[J("locked")] public required bool IsLocked { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("followers_count")] public required long FollowersCount { get; set; }
[J("following_count")] public required long FollowingCount { get; set; }
[J("statuses_count")] public required long StatusesCount { get; set; }
[J("note")] public required string Note { get; set; }
[J("url")] public required string Url { get; set; }
[J("uri")] public required string Uri { get; set; }
[J("avatar")] public required string AvatarUrl { get; set; }
[J("avatar_static")] public required string AvatarStaticUrl { get; set; }
[J("header")] public required string HeaderUrl { get; set; }
[J("header_static")] public required string HeaderStaticUrl { get; set; }
[J("moved")] public required AccountEntity? MovedToAccount { get; set; }
[J("bot")] public required bool IsBot { get; set; }
[J("discoverable")] public required bool IsDiscoverable { get; set; }
[J("fields")] public required List<Field> Fields { get; set; }
[J("source")] public AccountSource? Source { get; set; }
[J("emojis")] public required List<EmojiEntity> Emoji { get; set; }
[J("id")] public required string Id { get; set; }
[J("username")] public required string Username { get; set; }
[J("acct")] public required string Acct { get; set; }
[J("fqn")] public required string FullyQualifiedName { get; set; }
[J("display_name")] public required string DisplayName { get; set; }
[J("locked")] public required bool IsLocked { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("followers_count")] public required long FollowersCount { get; set; }
[J("following_count")] public required long FollowingCount { get; set; }
[J("statuses_count")] public required long StatusesCount { get; set; }
[J("note")] public required string Note { get; set; }
[J("url")] public required string Url { get; set; }
[J("uri")] public required string Uri { get; set; }
[J("avatar")] public required string AvatarUrl { get; set; }
[J("avatar_static")] public required string AvatarStaticUrl { get; set; }
[J("header")] public required string HeaderUrl { get; set; }
[J("header_static")] public required string HeaderStaticUrl { get; set; }
[J("moved")] public required AccountEntity? MovedToAccount { get; set; }
[J("bot")] public required bool IsBot { get; set; }
[J("discoverable")] public required bool IsDiscoverable { get; set; }
[J("fields")] public required List<Field> Fields { get; set; }
[J("source")] public AccountSource? Source { get; set; }
[J("emojis")] public required List<EmojiEntity> Emoji { get; set; }
[J("id")] public required string Id { get; set; }
[J("last_status_at")] public string? LastStatusAt { get; set; }
[J("pleroma")] public required PleromaUserExtensions? Pleroma { get; set; }
[J("akkoma")] public required AkkomaUserExtensions? Akkoma { get; set; }
[J("avatar_description")] public required string AvatarDescription { get; set; }
[J("header_description")] public required string HeaderDescription { get; set; }

View file

@ -1,21 +1,25 @@
using System.Text.Json.Serialization;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
using static Iceshrimp.Backend.Core.Database.Tables.Notification;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class NotificationEntity : IIdentifiable
{
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("pleroma")] public required PleromaNotificationExtensions Pleroma { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required PleromaNotificationExtensions? Pleroma { get; set; }
public static string EncodeType(NotificationType type, bool isPleroma)
{

View file

@ -0,0 +1,12 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class PreferencesEntity
{
[J("posting:default:visibility")] public required string PostingDefaultVisibility { get; set; }
[J("posting:default:sensitive")] public required bool PostingDefaultSensitive { get; set; }
[J("posting:default:language")] public string? PostingDefaultLanguage => null;
[J("reading:expand:media")] public required string ReadingExpandMedia { get; set; }
[J("reading:expand:spoilers")] public required bool ReadingExpandSpoilers { get; set; }
}

View file

@ -0,0 +1,17 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class ReportEntity
{
[J("id")] public required string Id { get; set; }
[J("action_taken")] public required bool ActionTaken { get; set; }
[J("action_taken_at")] public string? ActionTakenAt { get; set; }
[J("category")] public required string Category { get; set; }
[J("comment")] public required string Comment { get; set; }
[J("forwarded")] public required bool Forwarded { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("status_ids")] public string[]? StatusIds { get; set; }
[J("rule_ids")] public string[]? RuleIds { get; set; }
[J("target_account")] public required AccountEntity TargetAccount { get; set; }
}

View file

@ -56,7 +56,8 @@ public class StatusEntity : IIdentifiable, ICloneable
public object Clone() => MemberwiseClone();
[J("id")] public required string Id { get; set; }
[J("pleroma")] public required PleromaStatusExtensions Pleroma { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required PleromaStatusExtensions? Pleroma { get; set; }
public static string EncodeVisibility(Note.NoteVisibility visibility)
{

View file

@ -98,5 +98,5 @@ public class InstancePollConfiguration
public class InstanceReactionConfiguration
{
[J("max_reactions")] public int MaxOptions => 1;
[J("max_reactions")] public int MaxOptions => 100;
}

View file

@ -0,0 +1,24 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JR = System.Text.Json.Serialization.JsonRequiredAttribute;
using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas;
public abstract class ReportSchemas
{
public class FileReportRequest
{
[B(Name = "account_id")]
[J("account_id")]
[JR]
public string AccountId { get; set; } = null!;
[B(Name = "status_ids")]
[J("status_ids")]
public string[] StatusIds { get; set; } = [];
[B(Name = "comment")]
[J("comment")]
public string Comment { get; set; } = "";
}
}

View file

@ -8,7 +8,6 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Events;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
@ -394,9 +393,10 @@ public sealed class WebSocketConnection(
private void InitializeScopeLocalParameters(IServiceScope scope)
{
var mfmConverter = scope.ServiceProvider.GetRequiredService<MfmConverter>();
mfmConverter.SupportsHtmlFormatting.Value = Token.SupportsHtmlFormatting;
mfmConverter.SupportsInlineMedia.Value = Token.SupportsInlineMedia;
var flags = scope.ServiceProvider.GetRequiredService<FlagService>();
flags.SupportsHtmlFormatting.Value = Token.SupportsHtmlFormatting;
flags.SupportsInlineMedia.Value = Token.SupportsInlineMedia;
flags.IsPleroma.Value = Token.IsPleroma;
}
public async Task CloseAsync(WebSocketCloseStatus status)

View file

@ -0,0 +1,106 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using NoteRenderer = Iceshrimp.Backend.Controllers.Mastodon.Renderers.NoteRenderer;
using UserRenderer = Iceshrimp.Backend.Controllers.Mastodon.Renderers.UserRenderer;
namespace Iceshrimp.Backend.Controllers.Pleroma;
[MastodonApiController]
[Authenticate]
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class AdminController(
DatabaseContext db,
ReportRenderer reportRenderer,
NoteRenderer noteRenderer,
UserRenderer userRenderer
) : ControllerBase
{
[HttpGet("/api/v1/pleroma/admin/reports")]
[Authenticate("admin:read:reports")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<ReportsQuery> GetReports()
{
var user = HttpContext.GetUserOrFail();
var reports = await db.Reports
.IncludeCommonProperties()
.ToListAsync();
var rendered = await reportRenderer.RenderManyAsync(reports);
var reportsList = new List<Reports>();
foreach (var r in rendered)
{
var reActor = await db.Users
.IncludeCommonProperties()
.Where(p => p.Id == r.Reporter.Id)
.RenderAllForMastodonAsync(userRenderer, user);
var reTarget = await db.Users
.IncludeCommonProperties()
.Where(p => p.Id == r.TargetUser.Id)
.RenderAllForMastodonAsync(userRenderer, user);
foreach (var n in r.Notes)
{
var note = await db.Notes
.IncludeCommonProperties()
.Where(p => p.Id == n.Id)
.RenderAllForMastodonAsync(noteRenderer, user);
reportsList.Add(new Reports()
{
Account = reTarget.FirstOrDefault()!,
Actor = reActor.FirstOrDefault()!,
Id = r.Id,
CreatedAt = r.CreatedAt,
State = r.Resolved ? "resolved" : "open",
Content = r.Comment,
Statuses = note,
Notes = [] // unsupported
});
}
}
var resps = new ReportsQuery()
{
Total = reportsList.Count,
Reports = reportsList
};
return resps;
}
[HttpPatch("/api/v1/pleroma/admin/reports")]
[Authenticate("admin:read:reports")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
// ReSharper disable once AsyncVoidMethod
public async Task<ReportsQuery>? SetReportState(ReportsQuery query)
{
foreach (var list in query.Reports)
{
var report = await db.Reports.Where(p => p.Id == list.Id).FirstOrDefaultAsync()
?? throw GracefulException.NotFound("Report not found");
report.Resolved = list.State is "resolved" or "closed";
await db.SaveChangesAsync();
}
return query;
}
}

View file

@ -0,0 +1,9 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
public class AkkomaInstanceEntity
{
[J("name")] public required string Name { get; set; }
[J("nodeinfo")] public required AkkomaNodeInfoEntity NodeInfo { get; set; }
}

View file

@ -0,0 +1,8 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
public class AkkomaNodeInfoEntity
{
[J("software")] public required AkkomaNodeInfoSoftwareEntity Software { get; set; }
}

View file

@ -0,0 +1,9 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
public class AkkomaNodeInfoSoftwareEntity
{
[J("name")] public required string? Name { get; set; }
[J("version")] public required string? Version { get; set; }
}

View file

@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
[Keyless]
public class AkkomaUserExtensions
{
[J("instance")] public required AkkomaInstanceEntity Instance { get; set; }
}

View file

@ -0,0 +1,11 @@
using System.Runtime.InteropServices.JavaScript;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
public class PleromaOauthTokenEntity
{
[J("id")] public required string Id { get; set; }
[J("valid_until")] public required DateTime ValidUntil { get; set; }
[J("app_name")] public required string? AppName { get; set; }
}

View file

@ -7,4 +7,6 @@ public class PleromaStatusExtensions
{
[J("emoji_reactions")] public required List<ReactionEntity> Reactions { get; set; }
[J("conversation_id")] public required string ConversationId { get; set; }
[J("local")] public required bool LocalOnly { get; set; }
[J("thread_muted")] public required bool ThreadMuted { get; set; }
}

View file

@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
[Keyless]
public class PleromaUserExtensions
{
[J("is_admin")] public required bool IsAdmin { get; set; }
[J("is_moderator")] public required bool IsModerator { get; set; }
[J("favicon")] public required string Favicon { get; set; }
}

View file

@ -0,0 +1,22 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas;
public class ReportsQuery
{
[J("total")] public int Total { get; set; }
[J("reports")] public required List<Reports> Reports { get; set; }
}
public class Reports
{
[J("account")] public AccountEntity? Account { get; set; }
[J("actor")] public AccountEntity? Actor { get; set; }
[J("id")] public required string Id { get; set; }
[J("created_at")] public DateTime? CreatedAt { get; set; }
[J("state")] public required string State { get; set; }
[J("content")] public string? Content { get; set; }
[J("statuses")] public IEnumerable<StatusEntity>? Statuses { get; set; }
[J("notes")] public string[]? Notes { get; set; }
}

View file

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Iceshrimp.Backend.Controllers.Shared.Attributes;
public class NoRequestSizeLimitAttribute : Attribute, IFormOptionsMetadata, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
var feature = context.HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>() ??
throw new Exception("Failed to get IHttpMaxRequestBodySizeFeature");
feature.MaxRequestBodySize = long.MaxValue;
}
public void OnResourceExecuted(ResourceExecutedContext context) { }
public bool? BufferBody => null;
public int? MemoryBufferThreshold => null;
public long? BufferBodyLengthLimit => long.MaxValue;
public int? ValueCountLimit => null;
public int? KeyLengthLimit => null;
public int? ValueLengthLimit => null;
public int? MultipartBoundaryLengthLimit => null;
public int? MultipartHeadersCountLimit => null;
public int? MultipartHeadersLengthLimit => null;
public long? MultipartBodyLengthLimit => long.MaxValue;
}

View file

@ -298,6 +298,24 @@ public class AdminController(
await new MediaCleanupTask().InvokeAsync(scope.ServiceProvider);
}
[HttpPost("tasks/{id}/run")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public void RunCronTask([FromServices] CronService cronSvc, string id)
{
var task = cronSvc.Tasks.FirstOrDefault(p => p.Task.GetType().FullName == id)
?? throw GracefulException.NotFound("Task not found");
Task.Factory.StartNew(async () =>
{
await cronSvc.RunCronTaskAsync(task.Task, task.Trigger);
task.Trigger.UpdateNextTrigger();
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
[HttpGet("policy")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePoliciesAsync();

View file

@ -34,42 +34,46 @@ public class EmojiController(
public async Task<IEnumerable<EmojiResponse>> GetAllEmoji()
{
return await db.Emojis
.Where(p => p.Host == null)
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Category = p.Category,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.ToListAsync();
.Where(p => p.Host == null)
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Tags = p.Tags,
Category = p.Category,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.ToListAsync();
}
[HttpGet("remote")]
[Authorize("role:moderator")]
[RestPagination(100, 500)]
[ProducesResults(HttpStatusCode.OK)]
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmoji(PaginationQuery pq)
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmoji(
[FromQuery] string? name, [FromQuery] string? host, PaginationQuery pq
)
{
var res = await db.Emojis
.Where(p => p.Host != null)
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Category = p.Host,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.Paginate(pq, ControllerContext)
.ToListAsync();
.Where(p => p.Host != null
&& (string.IsNullOrWhiteSpace(host) || p.Host.ToLower().Contains(host.ToLower()))
&& (string.IsNullOrWhiteSpace(name) || p.Name.ToLower().Contains(name.ToLower())))
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Tags = p.Tags,
Category = p.Host,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.Paginate(pq, ControllerContext)
.ToListAsync();
return HttpContext.CreatePaginationWrapper(pq, res);
}
@ -81,20 +85,20 @@ public class EmojiController(
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmojiByHost(string host, PaginationQuery pq)
{
var res = await db.Emojis
.Where(p => p.Host == host)
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Category = p.Host,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.Paginate(pq, ControllerContext)
.ToListAsync();
.Where(p => p.Host == host)
.Select(p => new EmojiResponse
{
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Tags = p.Tags,
Category = p.Host,
PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License,
Sensitive = p.Sensitive
})
.Paginate(pq, ControllerContext)
.ToListAsync();
return HttpContext.CreatePaginationWrapper(pq, res);
}
@ -107,11 +111,11 @@ public class EmojiController(
{
pq.MinId ??= "";
var res = await db.Emojis.Where(p => p.Host != null)
.Select(p => new EntityWrapper<string> { Entity = p.Host!, Id = p.Host! })
.Distinct()
.Paginate(pq, ControllerContext)
.ToListAsync()
.ContinueWithResult(p => p.NotNull());
.Select(p => new EntityWrapper<string> { Entity = p.Host!, Id = p.Host! })
.Distinct()
.Paginate(pq, ControllerContext)
.ToListAsync()
.ContinueWithResult(p => p.NotNull());
return res;
}
@ -122,14 +126,14 @@ public class EmojiController(
public async Task<EmojiResponse> GetEmoji(string id)
{
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Emoji not found");
?? throw GracefulException.NotFound("Emoji not found");
return new EmojiResponse
{
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Aliases = emoji.Aliases,
Tags = emoji.Tags,
Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License,
@ -151,7 +155,7 @@ public class EmojiController(
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Aliases = [],
Tags = [],
Category = null,
PublicUrl = emoji.GetAccessUrl(instance.Value),
License = null,
@ -177,7 +181,7 @@ public class EmojiController(
Id = cloned.Id,
Name = cloned.Name,
Uri = cloned.Uri,
Aliases = [],
Tags = [],
Category = null,
PublicUrl = cloned.GetAccessUrl(instance.Value),
License = null,
@ -187,7 +191,7 @@ public class EmojiController(
[HttpPost("import")]
[Authorize("role:moderator")]
[DisableRequestSizeLimit]
[NoRequestSizeLimit]
[ProducesResults(HttpStatusCode.Accepted)]
public async Task<AcceptedResult> ImportEmoji(IFormFile file)
{
@ -200,19 +204,19 @@ public class EmojiController(
[Authorize("role:moderator")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request)
{
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Aliases, request.Category,
request.License, request.Sensitive)
?? throw GracefulException.NotFound("Emoji not found");
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Tags, request.Category,
request.License, request.Sensitive)
?? throw GracefulException.NotFound("Emoji not found");
return new EmojiResponse
{
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Aliases = emoji.Aliases,
Tags = emoji.Tags,
Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License,

View file

@ -1,9 +1,12 @@
using System.Net;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -13,15 +16,21 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Authorize("role:moderator")]
[ApiController]
[Route("/api/iceshrimp/moderation")]
public class ModerationController(DatabaseContext db, NoteService noteSvc, UserService userSvc) : ControllerBase
public class ModerationController(
DatabaseContext db,
NoteService noteSvc,
UserService userSvc,
ReportRenderer reportRenderer,
ReportService reportSvc
) : ControllerBase
{
[HttpPost("notes/{id}/delete")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteNote(string id)
{
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Note not found");
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Note not found");
await noteSvc.DeleteNoteAsync(note);
}
@ -31,8 +40,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task SuspendUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot suspend yourself.");
@ -45,8 +54,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task UnsuspendUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot unsuspend yourself.");
@ -59,8 +68,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot delete yourself.");
@ -73,9 +82,60 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task PurgeUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
await userSvc.PurgeUserAsync(user);
}
}
[HttpGet("reports")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
[LinkPagination(20, 40)]
public async Task<IEnumerable<ReportResponse>> GetReports(PaginationQuery pq, bool resolved = false)
{
var reports = await db.Reports
.IncludeCommonProperties()
.Where(p => p.Resolved == resolved)
.Paginate(pq, ControllerContext)
.ToListAsync();
return await reportRenderer.RenderManyAsync(reports);
}
[HttpPost("reports/{id}/resolve")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task ResolveReport(string id)
{
var user = HttpContext.GetUserOrFail();
var report = await db.Reports.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Report not found");
report.Assignee = user;
report.Resolved = true;
await db.SaveChangesAsync();
}
[HttpPost("reports/{id}/forward")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task ForwardReport(string id, [FromBody] NoteReportRequest? request)
{
var report = await db.Reports
.Include(p => p.TargetUser)
.Include(p => p.Notes)
.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Report not found");
if (report.TargetUserHost == null)
throw GracefulException.BadRequest("Cannot forward report to local instance");
if (report.Forwarded)
return;
await reportSvc.ForwardReportAsync(report, request?.Comment);
report.Forwarded = true;
await db.SaveChangesAsync();
}
}

View file

@ -30,7 +30,8 @@ public class NoteController(
UserRenderer userRenderer,
CacheService cache,
BiteService biteSvc,
PollService pollSvc
PollService pollSvc,
ReportService reportSvc
) : ControllerBase
{
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
@ -158,8 +159,10 @@ public class NoteController(
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
name = name.Trim(':');
var users = await db.NoteReactions
.Where(p => p.Note == note && p.Reaction == $":{name.Trim(':')}:")
.Where(p => p.Note == note && (p.Reaction == $":{name}:" || p.Reaction == name))
.Include(p => p.User.UserProfile)
.Select(p => p.User)
.ToListAsync();
@ -643,4 +646,18 @@ public class NoteController(
return await noteRenderer.RenderOne(note, user);
}
[HttpPost("{id}/report")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task ReportNote(string id, [FromBody] NoteReportRequest request)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Include(p => p.User).EnsureVisibleFor(user).FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Note not found");
await reportSvc.CreateReportAsync(user, note.User, [note], request.Comment);
}
}

View file

@ -38,6 +38,7 @@ public class NoteRenderer(
res.Filtered = new NoteFilteredSchema
{
Id = filtered.Value.filter.Id,
Name = filtered.Value.filter.Name,
Keyword = filtered.Value.keyword,
Hide = filtered.Value.filter.Action == Filter.FilterAction.Hide
};
@ -76,8 +77,8 @@ public class NoteRenderer(
var attachments =
(data?.Attachments ?? await GetAttachmentsAsync([note])).Where(p => note.FileIds.Contains(p.Id));
var reactions = (data?.Reactions ?? await GetReactionsAsync([note], user)).Where(p => p.NoteId == note.Id);
var liked = data?.LikedNotes?.Contains(note.Id) ??
await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
var liked = data?.LikedNotes?.Contains(note.Id)
?? await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
var emoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([note]);
var poll = (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.NoteId == note.Id);
@ -90,6 +91,7 @@ public class NoteRenderer(
Text = note.Text,
Cw = note.Cw,
Visibility = (NoteVisibility)note.Visibility,
LocalOnly = note.LocalOnly,
User = noteUser,
Attachments = attachments.ToList(),
Reactions = reactions.ToList(),
@ -122,7 +124,8 @@ public class NoteRenderer(
ContentType = p.Type,
Blurhash = p.Blurhash,
AltText = p.Comment,
IsSensitive = p.IsSensitive
IsSensitive = p.IsSensitive,
FileName = p.Name
})
.ToList();
}
@ -138,10 +141,12 @@ public class NoteRenderer(
.Select(p => new NoteReactionSchema
{
NoteId = p.First().NoteId,
Count = (int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
Reacted = db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
i.Reaction == p.First().Reaction &&
i.User == user),
Count =
(int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
Reacted =
db.NoteReactions.Any(i => i.NoteId == p.First().NoteId
&& i.Reaction == p.First().Reaction
&& i.User == user),
Name = p.First().Reaction,
Url = null,
Sensitive = false
@ -194,7 +199,7 @@ public class NoteRenderer(
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Tags = p.Tags,
Category = p.Category,
PublicUrl = p.GetAccessUrl(config.Value),
License = p.License,
@ -206,8 +211,8 @@ public class NoteRenderer(
private async Task<List<NotePollSchema>> GetPollsAsync(IEnumerable<Note> notes, User? user)
{
var polls = await db.Polls
.Where(p => notes.Contains(p.Note))
.ToListAsync();
.Where(p => notes.Contains(p.Note))
.ToListAsync();
var votes = user != null
? await db.PollVotes

View file

@ -1,8 +1,11 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using static Iceshrimp.Shared.Schemas.Web.NotificationResponse;
@ -12,7 +15,7 @@ public class NotificationRenderer(
IOptions<Config.InstanceSection> instance,
UserRenderer userRenderer,
NoteRenderer noteRenderer,
EmojiService emojiSvc
DatabaseContext db
) : IScopedService
{
private static NotificationResponse Render(Notification notification, NotificationRendererDto data)
@ -114,21 +117,44 @@ public class NotificationRenderer(
Sensitive = false
})
.ToList();
var custom = reactions.Where(EmojiService.IsCustomEmoji).ToArray();
var custom = reactions.Where(EmojiService.IsCustomEmoji)
.Select(p =>
{
var parts = p.Trim(':').Split('@');
return (name: parts[0], host: parts.Length > 1 ? parts[1] : null);
})
.Distinct()
.ToArray();
var expr = ExpressionExtensions.False<Emoji>();
expr = custom.Aggregate(expr, (current, part) => current.Or(p => p.Name == part.name && p.Host == part.host));
// https://github.com/dotnet/efcore/issues/31492
var emojiUrls = await db.Emojis
.Where(expr)
.Select(e => new
{
Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:",
Url = e.GetAccessUrl(instance.Value),
e.Sensitive
})
.ToDictionaryAsync(e => e.Name, e => new { e.Url, e.Sensitive });
foreach (var s in custom)
{
var emoji = await emojiSvc.ResolveEmojiAsync(s);
var name = s.host != null ? $":{s.name}@{s.host}:" : $":{s.name}:";
emojiUrls.TryGetValue(name, out var emoji);
var reaction = emoji != null
? new ReactionResponse
{
Name = s,
Url = emoji.GetAccessUrl(instance.Value),
Name = name,
Url = emoji.Url,
Sensitive = emoji.Sensitive
}
: new ReactionResponse
{
Name = s,
Name = name,
Url = null,
Sensitive = false
};

View file

@ -0,0 +1,66 @@
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Backend.Controllers.Web.Renderers;
public class ReportRenderer(UserRenderer userRenderer, NoteRenderer noteRenderer) : IScopedService
{
private static ReportResponse Render(Report report, ReportRendererDto data)
{
var noteIds = report.Notes.Select(i => i.Id).ToArray();
return new ReportResponse
{
Id = report.Id,
CreatedAt = report.CreatedAt,
Comment = report.Comment,
Forwarded = report.Forwarded,
Resolved = report.Resolved,
Assignee = data.Users.FirstOrDefault(p => p.Id == report.AssigneeId),
TargetUser = data.Users.First(p => p.Id == report.TargetUserId),
Reporter = data.Users.First(p => p.Id == report.ReporterId),
Notes = data.Notes.Where(p => noteIds.Contains(p.Id)).ToArray()
};
}
public async Task<ReportResponse> RenderOneAsync(Report report)
{
return Render(report, await BuildDtoAsync(report));
}
public async Task<IEnumerable<ReportResponse>> RenderManyAsync(IEnumerable<Report> reports)
{
var arr = reports.ToArray();
var data = await BuildDtoAsync(arr);
return arr.Select(p => Render(p, data));
}
private async Task<UserResponse[]> GetUsersAsync(IEnumerable<User> users)
=> await userRenderer.RenderManyAsync(users).ToArrayAsync();
private async Task<NoteResponse[]> GetNotesAsync(IEnumerable<Note> notes)
=> await noteRenderer.RenderManyAsync(notes, null).ToArrayAsync();
private async Task<ReportRendererDto> BuildDtoAsync(params Report[] reports)
{
var notes = await GetNotesAsync(reports.SelectMany(p => p.Notes));
var users = notes.Select(p => p.User).DistinctBy(p => p.Id).ToList();
var missingUsers = reports.Select(p => p.TargetUser)
.Concat(reports.Select(p => p.Assignee))
.Concat(reports.Select(p => p.Reporter))
.NotNull()
.DistinctBy(p => p.Id)
.ExceptBy(users.Select(p => p.Id), p => p.Id);
users.AddRange(await GetUsersAsync(missingUsers));
return new ReportRendererDto { Users = users.ToArray(), Notes = notes };
}
private class ReportRendererDto
{
public required UserResponse[] Users;
public required NoteResponse[] Notes;
}
}

View file

@ -51,7 +51,10 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
var bannerAlt = await GetBannerAltAsync([user]);
var data = new UserRendererDto
{
Emojis = emojis, InstanceData = instanceData, AvatarAlt = avatarAlt, BannerAlt = bannerAlt
Emojis = emojis,
InstanceData = instanceData,
AvatarAlt = avatarAlt,
BannerAlt = bannerAlt
};
return Render(user, data);
@ -71,7 +74,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
.Include(p => p.Avatar)
.ToDictionaryAsync(p => p.Id, p => p.Avatar?.Comment);
}
private async Task<Dictionary<string, string?>> GetBannerAltAsync(IEnumerable<User> users)
{
var ids = users.Select(p => p.Id).ToList();
@ -107,7 +110,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Tags = p.Tags,
Category = p.Category,
PublicUrl = p.GetAccessUrl(config.Value),
License = p.License,

View file

@ -42,4 +42,94 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Home);
}
[HttpGet("local")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetLocalTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => p.UserHost == null)
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("social")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetSocialTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var heuristic = await QueryableTimelineExtensions.GetHeuristicAsync(user, db, cache);
var notes = await db.Notes.IncludeCommonProperties()
.FilterByFollowingOwnAndLocal(user, db, heuristic)
.EnsureVisibleFor(user)
.FilterHidden(user, db, filterHiddenListMembers: true)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("bubble")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetBubbleTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => db.BubbleInstances.Any(i => i.Host == p.UserHost))
.EnsureVisibleFor(user)
.FilterHidden(user, db, filterHiddenListMembers: true)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("global")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetGlobalTimeline(PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
[HttpGet("remote/{instance}")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> GetRemoteTimeline(string instance, PaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes.IncludeCommonProperties()
.Where(p => p.UserHost == instance)
.EnsureVisibleFor(user)
.FilterHidden(user, db)
.FilterMutedThreads(user, db)
.Paginate(pq, ControllerContext)
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Public);
}
}

View file

@ -117,6 +117,48 @@ public class UserController(
await biteSvc.BiteAsync(user, target);
}
[HttpPost("{id}/block")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task BlockUser(string id)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot block yourself");
var blockee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.BlockUserAsync(user, blockee);
}
[HttpPost("{id}/unblock")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task UnblockUser(string id)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot unblock yourself");
var blockee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.UnblockUserAsync(user, blockee);
}
[HttpPost("{id}/follow")]
[Authenticate]
[Authorize]
@ -141,6 +183,51 @@ public class UserController(
await userSvc.FollowUserAsync(user, followee);
}
[HttpPost("{id}/mute")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task MuteUser(string id, [FromQuery] DateTime? expires)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot mute yourself");
if (expires?.ToUniversalTime() <= DateTime.UtcNow.AddMinutes(1))
throw GracefulException.BadRequest("Mute expiration must be in the future");
var mutee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.MuteUserAsync(user, mutee, expires?.ToUniversalTime());
}
[HttpPost("{id}/unmute")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task UnmuteUser(string id)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot unmute yourself");
var mutee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.UnmuteUserAsync(user, mutee);
}
[HttpPost("{id}/refetch")]
[Authenticate]
[Authorize]

View file

@ -54,18 +54,19 @@ public sealed class Config
public sealed class SecuritySection
{
public bool AuthorizedFetch { get; init; } = true;
public bool AttachLdSignatures { get; init; } = false;
public bool AcceptLdSignatures { get; init; } = false;
public bool AllowLoopback { get; init; } = false;
public bool AllowLocalIPv6 { get; init; } = false;
public bool AllowLocalIPv4 { get; init; } = false;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
public bool AuthorizedFetch { get; init; } = true;
public bool ValidateRequestSignatures { get; init; } = true;
public bool AttachLdSignatures { get; init; } = false;
public bool AcceptLdSignatures { get; init; } = false;
public bool AllowLoopback { get; init; } = false;
public bool AllowLocalIPv6 { get; init; } = false;
public bool AllowLocalIPv4 { get; init; } = false;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
}
public sealed class NetworkSection

View file

@ -16,7 +16,7 @@ namespace Iceshrimp.Backend.Core.Database;
public class DatabaseContext(DbContextOptions<DatabaseContext> options)
: DbContext(options), IDataProtectionKeyContext
{
public virtual DbSet<AbuseUserReport> AbuseUserReports { get; init; } = null!;
public virtual DbSet<Report> Reports { get; init; } = null!;
public virtual DbSet<Announcement> Announcements { get; init; } = null!;
public virtual DbSet<AnnouncementRead> AnnouncementReads { get; init; } = null!;
public virtual DbSet<Antenna> Antennas { get; init; } = null!;
@ -92,6 +92,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<Filter> Filters { get; init; } = null!;
public virtual DbSet<PluginStoreEntry> PluginStore { get; init; } = null!;
public virtual DbSet<PolicyConfiguration> PolicyConfiguration { get; init; } = null!;
public virtual DbSet<BubbleInstance> BubbleInstances { get; init; } = null!;
public virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection config)
@ -100,14 +101,15 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
{
ConnectionStringBuilder =
{
Host = config.Host,
Port = config.Port,
Username = config.Username,
Password = config.Password,
Database = config.Database,
MaxPoolSize = config.MaxConnections,
Multiplexing = config.Multiplexing,
Options = "-c jit=off"
Host = config.Host,
Port = config.Port,
Username = config.Username,
Password = config.Password,
Database = config.Database,
MaxPoolSize = config.MaxConnections,
Multiplexing = config.Multiplexing,
Options = "-c jit=off",
ApplicationName = "Iceshrimp.NET"
}
};

View file

@ -19,7 +19,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.1")
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
@ -36,84 +36,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AbuseUserReport", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<string>("AssigneeId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("assigneeId");
b.Property<string>("Comment")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("comment");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("createdAt")
.HasComment("The created date of the AbuseUserReport.");
b.Property<bool>("Forwarded")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("forwarded");
b.Property<string>("ReporterHost")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("reporterHost")
.HasComment("[Denormalized]");
b.Property<string>("ReporterId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("reporterId");
b.Property<bool>("Resolved")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("resolved");
b.Property<string>("TargetUserHost")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("targetUserHost")
.HasComment("[Denormalized]");
b.Property<string>("TargetUserId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("targetUserId");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("ReporterHost");
b.HasIndex("ReporterId");
b.HasIndex("Resolved");
b.HasIndex("TargetUserHost");
b.HasIndex("TargetUserId");
b.ToTable("abuse_user_report");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AllowedInstance", b =>
{
b.Property<string>("Host")
@ -487,6 +409,18 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("blocking");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.BubbleInstance", b =>
{
b.Property<string>("Host")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("host");
b.HasKey("Host");
b.ToTable("bubble_instance");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.CacheEntry", b =>
{
b.Property<string>("Key")
@ -978,13 +912,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.PrimitiveCollection<List<string>>("Aliases")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("character varying(128)[]")
.HasColumnName("aliases")
.HasDefaultValueSql("'{}'::character varying[]");
b.Property<string>("Category")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
@ -1029,6 +956,13 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("boolean")
.HasColumnName("sensitive");
b.PrimitiveCollection<List<string>>("Tags")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("character varying(128)[]")
.HasColumnName("tags")
.HasDefaultValueSql("'{}'::character varying[]");
b.Property<string>("Type")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
@ -1059,6 +993,16 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Name", "Host"), false);
b.HasIndex(new[] { "Host" }, "GIN_TRGM_emoji_host");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex(new[] { "Host" }, "GIN_TRGM_emoji_host"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex(new[] { "Host" }, "GIN_TRGM_emoji_host"), new[] { "gin_trgm_ops" });
b.HasIndex(new[] { "Name" }, "GIN_TRGM_emoji_name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex(new[] { "Name" }, "GIN_TRGM_emoji_name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex(new[] { "Name" }, "GIN_TRGM_emoji_name"), new[] { "gin_trgm_ops" });
b.ToTable("emoji");
});
@ -3870,6 +3814,84 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("renote_muting");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Report", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<string>("AssigneeId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("assigneeId");
b.Property<string>("Comment")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("comment");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("createdAt")
.HasComment("The created date of the Report.");
b.Property<bool>("Forwarded")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("forwarded");
b.Property<string>("ReporterHost")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("reporterHost")
.HasComment("[Denormalized]");
b.Property<string>("ReporterId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("reporterId");
b.Property<bool>("Resolved")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("resolved");
b.Property<string>("TargetUserHost")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("targetUserHost")
.HasComment("[Denormalized]");
b.Property<string>("TargetUserId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("targetUserId");
b.HasKey("Id");
b.HasIndex("AssigneeId");
b.HasIndex("CreatedAt");
b.HasIndex("ReporterHost");
b.HasIndex("ReporterId");
b.HasIndex("Resolved");
b.HasIndex("TargetUserHost");
b.HasIndex("TargetUserId");
b.ToTable("report");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Rule", b =>
{
b.Property<string>("Id")
@ -4180,6 +4202,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("lastFetchedAt");
b.Property<DateTime?>("LastNoteAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lastNoteAt");
b.Property<string>("MovedToUri")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
@ -4918,30 +4944,19 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("data_protection_keys", (string)null);
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AbuseUserReport", b =>
modelBuilder.Entity("reported_note", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Assignee")
.WithMany("AbuseUserReportAssignees")
.HasForeignKey("AssigneeId")
.OnDelete(DeleteBehavior.SetNull);
b.Property<string>("note_id")
.HasColumnType("character varying(32)");
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Reporter")
.WithMany("AbuseUserReportReporters")
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Property<string>("report_id")
.HasColumnType("character varying(32)");
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "TargetUser")
.WithMany("AbuseUserReportTargetUsers")
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasKey("note_id", "report_id");
b.Navigation("Assignee");
b.HasIndex("report_id");
b.Navigation("Reporter");
b.Navigation("TargetUser");
b.ToTable("reported_note");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AnnouncementRead", b =>
@ -5716,6 +5731,32 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("Muter");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Report", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Assignee")
.WithMany("AbuseUserReportAssignees")
.HasForeignKey("AssigneeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Reporter")
.WithMany("AbuseUserReportReporters")
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "TargetUser")
.WithMany("AbuseUserReportTargetUsers")
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Assignee");
b.Navigation("Reporter");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Session", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
@ -5926,6 +5967,21 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("User");
});
modelBuilder.Entity("reported_note", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.Note", null)
.WithMany()
.HasForeignKey("note_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.Report", null)
.WithMany()
.HasForeignKey("report_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Announcement", b =>
{
b.Navigation("AnnouncementReads");

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250304222123_RenameEmojiTagsColumn")]
public partial class RenameEmojiTagsColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "aliases",
table: "emoji",
newName: "tags");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "tags",
table: "emoji",
newName: "aliases");
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250223092435_AddUserLastNoteAt")]
public partial class AddUserLastNoteAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "lastNoteAt",
table: "user",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.Sql("""UPDATE "user" SET "lastNoteAt" = (SELECT note."createdAt" FROM "note" WHERE "note"."userId" = "user"."id" ORDER BY "note"."id" DESC LIMIT 1);""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "lastNoteAt",
table: "user");
}
}
}

View file

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250307145204_EmojiNameHostIndex")]
public partial class EmojiNameHostIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "GIN_TRGM_emoji_host",
table: "emoji",
column: "host")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
migrationBuilder.CreateIndex(
name: "GIN_TRGM_emoji_name",
table: "emoji",
column: "name")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "GIN_TRGM_emoji_host",
table: "emoji");
migrationBuilder.DropIndex(
name: "GIN_TRGM_emoji_name",
table: "emoji");
}
}
}

View file

@ -0,0 +1,242 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250310231413_RefactorReportsSchema")]
public partial class RefactorReportsSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_abuse_user_report_user_assigneeId",
table: "abuse_user_report");
migrationBuilder.DropForeignKey(
name: "FK_abuse_user_report_user_reporterId",
table: "abuse_user_report");
migrationBuilder.DropForeignKey(
name: "FK_abuse_user_report_user_targetUserId",
table: "abuse_user_report");
migrationBuilder.DropPrimaryKey(
name: "PK_abuse_user_report",
table: "abuse_user_report");
migrationBuilder.RenameTable(
name: "abuse_user_report",
newName: "report");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_targetUserId",
table: "report",
newName: "IX_report_targetUserId");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_targetUserHost",
table: "report",
newName: "IX_report_targetUserHost");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_resolved",
table: "report",
newName: "IX_report_resolved");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_reporterId",
table: "report",
newName: "IX_report_reporterId");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_reporterHost",
table: "report",
newName: "IX_report_reporterHost");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_createdAt",
table: "report",
newName: "IX_report_createdAt");
migrationBuilder.RenameIndex(
name: "IX_abuse_user_report_assigneeId",
table: "report",
newName: "IX_report_assigneeId");
migrationBuilder.AlterColumn<DateTime>(
name: "createdAt",
table: "report",
type: "timestamp with time zone",
nullable: false,
comment: "The created date of the Report.",
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldComment: "The created date of the AbuseUserReport.");
migrationBuilder.AddPrimaryKey(
name: "PK_report",
table: "report",
column: "id");
migrationBuilder.CreateTable(
name: "reported_note",
columns: table => new
{
note_id = table.Column<string>(type: "character varying(32)", nullable: false),
report_id = table.Column<string>(type: "character varying(32)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_reported_note", x => new { x.note_id, x.report_id });
table.ForeignKey(
name: "FK_reported_note_note_note_id",
column: x => x.note_id,
principalTable: "note",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_reported_note_report_report_id",
column: x => x.report_id,
principalTable: "report",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_reported_note_report_id",
table: "reported_note",
column: "report_id");
migrationBuilder.AddForeignKey(
name: "FK_report_user_assigneeId",
table: "report",
column: "assigneeId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_report_user_reporterId",
table: "report",
column: "reporterId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_report_user_targetUserId",
table: "report",
column: "targetUserId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_report_user_assigneeId",
table: "report");
migrationBuilder.DropForeignKey(
name: "FK_report_user_reporterId",
table: "report");
migrationBuilder.DropForeignKey(
name: "FK_report_user_targetUserId",
table: "report");
migrationBuilder.DropTable(
name: "reported_note");
migrationBuilder.DropPrimaryKey(
name: "PK_report",
table: "report");
migrationBuilder.RenameTable(
name: "report",
newName: "abuse_user_report");
migrationBuilder.RenameIndex(
name: "IX_report_targetUserId",
table: "abuse_user_report",
newName: "IX_abuse_user_report_targetUserId");
migrationBuilder.RenameIndex(
name: "IX_report_targetUserHost",
table: "abuse_user_report",
newName: "IX_abuse_user_report_targetUserHost");
migrationBuilder.RenameIndex(
name: "IX_report_resolved",
table: "abuse_user_report",
newName: "IX_abuse_user_report_resolved");
migrationBuilder.RenameIndex(
name: "IX_report_reporterId",
table: "abuse_user_report",
newName: "IX_abuse_user_report_reporterId");
migrationBuilder.RenameIndex(
name: "IX_report_reporterHost",
table: "abuse_user_report",
newName: "IX_abuse_user_report_reporterHost");
migrationBuilder.RenameIndex(
name: "IX_report_createdAt",
table: "abuse_user_report",
newName: "IX_abuse_user_report_createdAt");
migrationBuilder.RenameIndex(
name: "IX_report_assigneeId",
table: "abuse_user_report",
newName: "IX_abuse_user_report_assigneeId");
migrationBuilder.AlterColumn<DateTime>(
name: "createdAt",
table: "abuse_user_report",
type: "timestamp with time zone",
nullable: false,
comment: "The created date of the AbuseUserReport.",
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldComment: "The created date of the Report.");
migrationBuilder.AddPrimaryKey(
name: "PK_abuse_user_report",
table: "abuse_user_report",
column: "id");
migrationBuilder.AddForeignKey(
name: "FK_abuse_user_report_user_assigneeId",
table: "abuse_user_report",
column: "assigneeId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_abuse_user_report_user_reporterId",
table: "abuse_user_report",
column: "reporterId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_abuse_user_report_user_targetUserId",
table: "abuse_user_report",
column: "targetUserId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250322222922_AddRecommendedInstanceTable")]
public partial class AddRecommendedInstanceTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "recommended_instance",
columns: table => new
{
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_recommended_instance", x => x.host);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "recommended_instance");
}
}
}

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250323112015_RenameBubbleInstanceTable")]
public partial class RenameBubbleInstanceTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_recommended_instance",
table: "recommended_instance");
migrationBuilder.RenameTable(
name: "recommended_instance",
newName: "bubble_instance");
migrationBuilder.AddPrimaryKey(
name: "PK_bubble_instance",
table: "bubble_instance",
column: "host");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_bubble_instance",
table: "bubble_instance");
migrationBuilder.RenameTable(
name: "bubble_instance",
newName: "recommended_instance");
migrationBuilder.AddPrimaryKey(
name: "PK_recommended_instance",
table: "recommended_instance",
column: "host");
}
}
}

View file

@ -30,8 +30,8 @@ public class Emoji
[Column("type")] [StringLength(64)] public string? Type { get; set; }
[Column("aliases", TypeName = "character varying(128)[]")]
public List<string> Aliases { get; set; } = [];
[Column("tags", TypeName = "character varying(128)[]")]
public List<string> Tags { get; set; } = [];
[Column("category")]
[StringLength(128)]
@ -75,11 +75,18 @@ public class Emoji
{
public void Configure(EntityTypeBuilder<Emoji> entity)
{
entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Height).HasComment("Image height");
entity.Property(e => e.RawPublicUrl).HasDefaultValueSql("''::character varying");
entity.Property(e => e.Width).HasComment("Image width");
entity.HasIndex(e => e.Name, "GIN_TRGM_emoji_name")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
entity.HasIndex(e => e.Host, "GIN_TRGM_emoji_host")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// This index must be NULLS NOT DISTINCT to make having multiple local emoji with the same name cause a constraint failure
entity.HasIndex(nameof(Name), nameof(Host)).IsUnique().AreNullsDistinct(false);
}

View file

@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("bubble_instance")]
public class BubbleInstance
{
[Key]
[Column("host")]
[StringLength(256)]
public string Host { get; set; } = null!;
}

View file

@ -1,18 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("abuse_user_report")]
[Table("report")]
[Index(nameof(ReporterId))]
[Index(nameof(Resolved))]
[Index(nameof(TargetUserHost))]
[Index(nameof(TargetUserId))]
[Index(nameof(CreatedAt))]
[Index(nameof(ReporterHost))]
public class AbuseUserReport
public class Report : IIdentifiable
{
[Key]
[Column("id")]
@ -20,7 +22,7 @@ public class AbuseUserReport
public string Id { get; set; } = null!;
/// <summary>
/// The created date of the AbuseUserReport.
/// The created date of the Report.
/// </summary>
[Column("createdAt")]
public DateTime CreatedAt { get; set; }
@ -70,12 +72,14 @@ public class AbuseUserReport
[ForeignKey(nameof(TargetUserId))]
[InverseProperty(nameof(User.AbuseUserReportTargetUsers))]
public virtual User TargetUser { get; set; } = null!;
private class EntityTypeConfiguration : IEntityTypeConfiguration<AbuseUserReport>
public virtual ICollection<Note> Notes { get; set; } = new List<Note>();
private class EntityTypeConfiguration : IEntityTypeConfiguration<Report>
{
public void Configure(EntityTypeBuilder<AbuseUserReport> entity)
public void Configure(EntityTypeBuilder<Report> entity)
{
entity.Property(e => e.CreatedAt).HasComment("The created date of the AbuseUserReport.");
entity.Property(e => e.CreatedAt).HasComment("The created date of the Report.");
entity.Property(e => e.Forwarded).HasDefaultValue(false);
entity.Property(e => e.ReporterHost).HasComment("[Denormalized]");
entity.Property(e => e.Resolved).HasDefaultValue(false);
@ -92,6 +96,10 @@ public class AbuseUserReport
entity.HasOne(d => d.TargetUser)
.WithMany(p => p.AbuseUserReportTargetUsers)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Notes)
.WithMany()
.UsingEntity("reported_note", "report_id", "note_id", DeleteBehavior.Cascade);
}
}
}
}

View file

@ -41,6 +41,7 @@ public class User : IIdentifiable
[Column("lastFetchedAt")] public DateTime? LastFetchedAt { get; set; }
[Column("outboxFetchedAt")] public DateTime? OutboxFetchedAt { get; set; }
[Column("lastNoteAt")] public DateTime? LastNoteAt { get; set; }
[NotMapped]
[Projectable]
@ -259,14 +260,14 @@ public class User : IIdentifiable
[Column("splitDomainResolved")] public bool SplitDomainResolved { get; set; }
[InverseProperty(nameof(AbuseUserReport.Assignee))]
public virtual ICollection<AbuseUserReport> AbuseUserReportAssignees { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(Report.Assignee))]
public virtual ICollection<Report> AbuseUserReportAssignees { get; set; } = new List<Report>();
[InverseProperty(nameof(AbuseUserReport.Reporter))]
public virtual ICollection<AbuseUserReport> AbuseUserReportReporters { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(Report.Reporter))]
public virtual ICollection<Report> AbuseUserReportReporters { get; set; } = new List<Report>();
[InverseProperty(nameof(AbuseUserReport.TargetUser))]
public virtual ICollection<AbuseUserReport> AbuseUserReportTargetUsers { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(Report.TargetUser))]
public virtual ICollection<Report> AbuseUserReportTargetUsers { get; set; } = new List<Report>();
[InverseProperty(nameof(AnnouncementRead.User))]
public virtual ICollection<AnnouncementRead> AnnouncementReads { get; set; } = new List<AnnouncementRead>();

View file

@ -655,5 +655,14 @@ public static class QueryableExtensions
.Include(p => p.Bite);
}
public static IQueryable<Report> IncludeCommonProperties(this IQueryable<Report> query)
{
return query.Include(p => p.Reporter.UserProfile)
.Include(p => p.TargetUser.UserProfile)
.Include(p => p.Assignee.UserProfile)
.Include(p => p.Notes)
.ThenInclude(p => p.User.UserProfile);
}
#pragma warning restore CS8602 // Dereference of a possibly null reference.
}

View file

@ -35,6 +35,7 @@ public static class QueryableFtsExtensions
InstanceFilter instanceFilter => current.ApplyInstanceFilter(instanceFilter, config),
MentionFilter mentionFilter => current.ApplyMentionFilter(mentionFilter, config, db),
MiscFilter miscFilter => current.ApplyMiscFilter(miscFilter, user),
VisibilityFilter visibilityFilter => current.ApplyVisibilityFilter(visibilityFilter),
ReplyFilter replyFilter => current.ApplyReplyFilter(replyFilter, config, db),
CwFilter cwFilter => current.ApplyCwFilter(cwFilter, caseSensitivity, matchType),
WordFilter wordFilter => current.ApplyWordFilter(wordFilter, caseSensitivity, matchType),
@ -81,7 +82,9 @@ public static class QueryableFtsExtensions
private static IQueryable<Note> ApplyMultiWordFilter(
this IQueryable<Note> query, MultiWordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType
) => query.Where(p => p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType));
) => filter.Negated
? query.Where(p => !p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType))
: query.Where(p => p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType));
private static IQueryable<Note> ApplyFromFilters(
this IQueryable<Note> query, List<FromFilter> filters, Config.InstanceSection config, DatabaseContext db
@ -163,6 +166,25 @@ public static class QueryableFtsExtensions
};
}
private static IQueryable<Note> ApplyVisibilityFilter(this IQueryable<Note> query, VisibilityFilter filter)
{
if (filter.Value is VisibilityFilterType.Local)
return query.Where(p => p.LocalOnly == !filter.Negated);
var visibility = filter.Value switch
{
VisibilityFilterType.Public => Note.NoteVisibility.Public,
VisibilityFilterType.Home => Note.NoteVisibility.Home,
VisibilityFilterType.Followers => Note.NoteVisibility.Followers,
VisibilityFilterType.Specified => Note.NoteVisibility.Specified,
_ => throw new ArgumentOutOfRangeException()
};
return filter.Negated
? query.Where(p => p.Visibility != visibility)
: query.Where(p => p.Visibility == visibility);
}
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
private static IQueryable<Note> ApplyFollowersFilter(this IQueryable<Note> query, User user, bool negated)
=> query.Where(p => negated ? !p.User.IsFollowing(user) : p.User.IsFollowing(user));

View file

@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Extensions;
@ -18,16 +20,25 @@ public static class QueryableTimelineExtensions
)
{
return heuristic < Cutoff
? query.FollowingAndOwnLowFreq(user, db)
? query.Where(FollowingAndOwnLowFreqExpr(user, db))
: query.Where(note => note.User == user || note.User.IsFollowedBy(user));
}
private static IQueryable<Note> FollowingAndOwnLowFreq(this IQueryable<Note> query, User user, DatabaseContext db)
=> query.Where(note => db.Followings
.Where(p => p.Follower == user)
.Select(p => p.FolloweeId)
.Concat(new[] { user.Id })
.Contains(note.UserId));
public static IQueryable<Note> FilterByFollowingOwnAndLocal(
this IQueryable<Note> query, User user, DatabaseContext db, int heuristic
)
{
return heuristic < Cutoff
? query.Where(FollowingAndOwnLowFreqExpr(user, db).Or(p => p.UserHost == null))
: query.Where(note => note.User == user || note.User.IsFollowedBy(user));
}
private static Expression<Func<Note,bool>> FollowingAndOwnLowFreqExpr(User user, DatabaseContext db)
=> note => db.Followings
.Where(p => p.Follower == user)
.Select(p => p.FolloweeId)
.Concat(new[] { user.Id })
.Contains(note.UserId);
public static IQueryable<User> NeedsTimelineHeuristicUpdate(
this IQueryable<User> query, DatabaseContext db, TimeSpan maxRemainingTtl
@ -60,7 +71,7 @@ public static class QueryableTimelineExtensions
//TODO: maybe we should express this as a ratio between matching and non-matching posts
return await db.Notes
.Where(p => p.CreatedAt > latestNote.CreatedAt - TimeSpan.FromDays(7))
.FollowingAndOwnLowFreq(user, db)
.Where(FollowingAndOwnLowFreqExpr(user, db))
.OrderByDescending(p => p.Id)
.Take(Cutoff + 1)
.CountAsync();

View file

@ -28,4 +28,35 @@ public static class StreamExtensions
ValueTask<int> DoReadAsync() => source.ReadAsync(new Memory<byte>(buffer), cancellationToken);
}
/// <summary>
/// We can't trust the Content-Length header, and it might be null.
/// This makes sure that we only ever read up to maxLength into memory.
/// </summary>
/// <param name="stream">The response content stream</param>
/// <param name="maxLength">The maximum length to buffer (null = unlimited)</param>
/// <param name="contentLength">The content length, if known</param>
/// <param name="token">A CancellationToken, if applicable</param>
/// <returns>Either a buffered MemoryStream, or Stream.Null</returns>
public static async Task<Stream> GetSafeStreamOrNullAsync(
this Stream stream, long? maxLength, long? contentLength, CancellationToken token = default
)
{
if (maxLength is 0) return Stream.Null;
if (contentLength > maxLength) return Stream.Null;
MemoryStream buf = new();
if (contentLength < maxLength)
maxLength = contentLength.Value;
await stream.CopyToAsync(buf, maxLength, token);
if (maxLength == null || buf.Length <= maxLength)
{
buf.Seek(0, SeekOrigin.Begin);
return buf;
}
await buf.DisposeAsync();
return Stream.Null;
}
}

View file

@ -61,6 +61,11 @@ public static class TaskExtensions
return (await task).ToList();
}
public static async Task<T[]> ToArrayAsync<T>(this Task<IEnumerable<T>> task)
{
return (await task).ToArray();
}
public static async Task ContinueWithResult(this Task task, Action continuation)
{
await task;

View file

@ -2,5 +2,33 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static class TimeSpanExtensions
{
private static readonly long Seconds = TimeSpan.FromMinutes(1).Ticks;
private static readonly long Minutes = TimeSpan.FromHours(1).Ticks;
private static readonly long Hours = TimeSpan.FromDays(1).Ticks;
public static long GetTotalMilliseconds(this TimeSpan timeSpan) => Convert.ToInt64(timeSpan.TotalMilliseconds);
}
public static string ToDisplayString(this TimeSpan timeSpan, bool singleNumber = true)
{
if (timeSpan.Ticks < Seconds)
{
var seconds = (int)timeSpan.TotalSeconds;
return seconds == 1 ? singleNumber ? "1 second" : "second" : $"{seconds} seconds";
}
if (timeSpan.Ticks < Minutes)
{
var minutes = (int)timeSpan.TotalMinutes;
return minutes == 1 ? singleNumber ? "1 minute" : "minute" : $"{minutes} minutes";
}
if (timeSpan.Ticks < Hours)
{
var hours = (int)timeSpan.TotalHours;
return hours == 1 ? singleNumber ? "1 hour" : "hour" : $"{hours} hours";
}
var days = (int)timeSpan.TotalDays;
return days == 1 ? singleNumber ? "1 day" : "day" : $"{days} days";
}
}

View file

@ -4,6 +4,7 @@ using System.Runtime.InteropServices;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Migrations;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Backend.Core.Services.ImageProcessing;
@ -61,6 +62,9 @@ public static class WebApplicationExtensions
app.MapScalarApiReference("/scalar", options =>
{
options.WithTitle("Iceshrimp API documentation")
.AddDocument("iceshrimp", "Iceshrimp.NET")
.AddDocument("federation", "Federation")
.AddDocument("mastodon", "Mastodon")
.WithOpenApiRoutePattern("/openapi/{documentName}.json")
.WithModels(false)
.WithCustomCss("""
@ -228,13 +232,106 @@ public static class WebApplicationExtensions
Environment.Exit(0);
}
string[] userMgmtCommands =
[
"--create-user", "--create-admin-user", "--reset-password", "--grant-admin", "--revoke-admin"
];
if (args.FirstOrDefault(userMgmtCommands.Contains) is { } cmd)
{
if (args is not [not null, var username])
{
app.Logger.LogError("Invalid syntax. Usage: {cmd} <username>", cmd);
Environment.Exit(1);
return null!;
}
if (cmd is "--create-user" or "--create-admin-user")
{
var password = CryptographyHelpers.GenerateRandomString(16);
app.Logger.LogInformation("Creating user {username}...", username);
var userSvc = provider.GetRequiredService<UserService>();
await userSvc.CreateLocalUserAsync(username, password, null, force: true);
if (args[0] is "--create-admin-user")
{
await db.Users.Where(p => p.Username == username)
.ExecuteUpdateAsync(p => p.SetProperty(i => i.IsAdmin, true));
app.Logger.LogInformation("Successfully created admin user.");
}
else
{
app.Logger.LogInformation("Successfully created user.");
}
app.Logger.LogInformation("Username: {username}", username);
app.Logger.LogInformation("Password: {password}", password);
Environment.Exit(0);
}
if (cmd is "--reset-password")
{
var settings = await db.UserSettings
.FirstOrDefaultAsync(p => p.User.UsernameLower == username.ToLowerInvariant());
if (settings == null)
{
app.Logger.LogError("User {username} not found.", username);
Environment.Exit(1);
}
app.Logger.LogInformation("Resetting password for user {username}...", username);
var password = CryptographyHelpers.GenerateRandomString(16);
settings.Password = AuthHelpers.HashPassword(password);
await db.SaveChangesAsync();
app.Logger.LogInformation("Password for user {username} was reset to: {password}", username, password);
Environment.Exit(0);
}
if (cmd is "--grant-admin")
{
var user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username.ToLowerInvariant());
if (user == null)
{
app.Logger.LogError("User {username} not found.", username);
Environment.Exit(1);
}
else
{
user.IsAdmin = true;
await db.SaveChangesAsync();
app.Logger.LogInformation("Granted admin privileges to user {username}.", username);
Environment.Exit(0);
}
}
if (cmd is "--revoke-admin")
{
var user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username.ToLowerInvariant());
if (user == null)
{
app.Logger.LogError("User {username} not found.", username);
Environment.Exit(1);
}
else
{
user.IsAdmin = false;
await db.SaveChangesAsync();
app.Logger.LogInformation("Revoked admin privileges of user {username}.", username);
Environment.Exit(0);
}
}
}
var storageConfig = app.Configuration.GetSection("Storage").Get<Config.StorageSection>() ??
throw new Exception("Failed to read Storage config section");
if (storageConfig.Provider == Enums.FileStorage.Local)
{
if (string.IsNullOrWhiteSpace(storageConfig.Local?.Path) ||
!Directory.Exists(storageConfig.Local.Path))
if (string.IsNullOrWhiteSpace(storageConfig.Local?.Path) || !Directory.Exists(storageConfig.Local.Path))
{
app.Logger.LogCritical("Local storage path does not exist");
Environment.Exit(1);
@ -368,4 +465,4 @@ public class ConditionalMiddleware<T> : IConditionalMiddleware where T : Attribu
protected static T GetAttributeOrFail(HttpContext ctx)
=> GetAttribute(ctx) ?? throw new Exception("Failed to get middleware filter attribute");
}
}

View file

@ -28,7 +28,8 @@ public class ActivityHandlerService(
FollowupTaskService followupTaskSvc,
EmojiService emojiSvc,
EventService eventSvc,
RelayService relaySvc
RelayService relaySvc,
ReportService reportSvc
) : IScopedService
{
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId)
@ -86,6 +87,7 @@ public class ActivityHandlerService(
ASUndo undo => HandleUndoAsync(undo, resolvedActor),
ASUnfollow unfollow => HandleUnfollowAsync(unfollow, resolvedActor),
ASUpdate update => HandleUpdateAsync(update, resolvedActor),
ASFlag flag => HandleFlagAsync(flag, resolvedActor),
// Separated for readability
_ => throw GracefulException.UnprocessableEntity($"Activity type {activity.Type} is unknown")
@ -108,8 +110,8 @@ public class ActivityHandlerService(
if (activity.Object == null)
throw GracefulException.UnprocessableEntity("Create activity object was null");
activity.Object = await objectResolver.ResolveObjectAsync(activity.Object, actor.Uri) as ASNote ??
throw GracefulException.UnprocessableEntity("Failed to resolve create object");
activity.Object = await objectResolver.ResolveObjectAsync(activity.Object, actor.Uri) as ASNote
?? throw GracefulException.UnprocessableEntity("Failed to resolve create object");
using (await NoteService.GetNoteProcessLockAsync(activity.Object.Id))
await noteSvc.ProcessNoteAsync(activity.Object, actor, inboxUser);
@ -221,8 +223,8 @@ public class ActivityHandlerService(
if (await db.Followings.AnyAsync(p => p.Followee == actor && p.FollowerId == ids[0]))
return;
throw GracefulException.UnprocessableEntity($"No follow or follow request matching follower '{ids[0]}'" +
$"and followee '{actor.Id}' found");
throw GracefulException.UnprocessableEntity($"No follow or follow request matching follower '{ids[0]}'"
+ $"and followee '{actor.Id}' found");
}
await userSvc.AcceptFollowRequestAsync(request);
@ -272,8 +274,7 @@ public class ActivityHandlerService(
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.FollowRequestAccepted)
.Where(p => p.Notifiee == resolvedFollower &&
p.Notifier == resolvedActor)
.Where(p => p.Notifiee == resolvedFollower && p.Notifier == resolvedActor)
.ExecuteDeleteAsync();
await db.UserListMembers
@ -401,8 +402,8 @@ public class ActivityHandlerService(
User = resolvedActor,
UserHost = resolvedActor.Host,
TargetBite =
await db.Bites.FirstAsync(p => p.UserHost == null &&
p.Id == Bite.GetIdFromPublicUri(targetBite.Id, config.Value))
await db.Bites.FirstAsync(p => p.UserHost == null
&& p.Id == Bite.GetIdFromPublicUri(targetBite.Id, config.Value))
},
null => throw GracefulException.UnprocessableEntity($"Failed to resolve bite target {activity.Target.Id}"),
_ when activity.To?.Id != null => new Bite
@ -419,9 +420,9 @@ public class ActivityHandlerService(
//TODO: more fallback
};
if ((dbBite.TargetUser?.IsRemoteUser ?? false) ||
(dbBite.TargetNote?.User.IsRemoteUser ?? false) ||
(dbBite.TargetBite?.User.IsRemoteUser ?? false))
if ((dbBite.TargetUser?.IsRemoteUser ?? false)
|| (dbBite.TargetNote?.User.IsRemoteUser ?? false)
|| (dbBite.TargetBite?.User.IsRemoteUser ?? false))
throw GracefulException.Accepted("Ignoring bite for remote user");
var finalTarget = dbBite.TargetUser ?? dbBite.TargetNote?.User ?? dbBite.TargetBite?.User;
@ -520,14 +521,45 @@ public class ActivityHandlerService(
var targetUri = target.Uri ?? target.GetPublicUri(config.Value.WebDomain);
var aliases = target.AlsoKnownAs ?? [];
if (!aliases.Contains(sourceUri))
throw GracefulException.UnprocessableEntity("Refusing to process move activity:" +
"source uri not listed in target aliases");
throw GracefulException.UnprocessableEntity("Refusing to process move activity:"
+ "source uri not listed in target aliases");
source.MovedToUri = targetUri;
await db.SaveChangesAsync();
await userSvc.MoveRelationshipsAsync(source, target, sourceUri, targetUri);
}
private async Task HandleFlagAsync(ASFlag flag, User resolvedActor)
{
if (resolvedActor.IsLocalUser)
throw GracefulException.UnprocessableEntity("Refusing to process locally originating report via AP");
if (flag.Object is not { Length: > 0 })
throw GracefulException.UnprocessableEntity("ASFlag activity does not reference any objects");
var candidates = flag.Object.Take(25)
.Select(p => p.Id)
.NotNull()
.Where(p => p.StartsWith($"https://{config.Value.WebDomain}/users/")
|| p.StartsWith($"https://{config.Value.WebDomain}/notes/"))
.Select(p => p[$"https://{config.Value.WebDomain}/notes/".Length..])
.ToArray();
var userMatch = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser && candidates.Contains(p.Id));
var noteMatches = await db.Notes.Where(p => p.UserHost == null && candidates.Contains(p.Id))
.Include(note => note.User)
.ToListAsync();
if (userMatch == null && noteMatches.Count == 0)
throw GracefulException.UnprocessableEntity("ASFlag activity object resolution yielded zero results");
userMatch ??= noteMatches[0].User;
if (noteMatches.Count != 0 && noteMatches.Any(p => p.User != userMatch))
throw GracefulException.UnprocessableEntity("Refusing to process ASFlag: note author mismatch");
await reportSvc.CreateReportAsync(resolvedActor, userMatch, noteMatches, flag.Content ?? "");
}
private async Task UnfollowAsync(ASActor followeeActor, User follower)
{
//TODO: send reject? or do we not want to copy that part of the old ap core
@ -558,9 +590,9 @@ public class ActivityHandlerService(
});
await db.Notifications
.Where(p => p.Type == Notification.NotificationType.Follow &&
p.Notifiee == followee &&
p.Notifier == follower)
.Where(p => p.Type == Notification.NotificationType.Follow
&& p.Notifiee == followee
&& p.Notifier == follower)
.ExecuteDeleteAsync();
eventSvc.RaiseUserUnfollowed(this, follower, followee);
@ -577,4 +609,4 @@ public class ActivityHandlerService(
.UnprocessableEntity("Refusing to process unblock between two remote users");
await userSvc.UnblockUserAsync(blocker, resolvedBlockee);
}
}
}

View file

@ -239,4 +239,12 @@ public class ActivityRenderer(
PublishedAt = bite.CreatedAt,
To = userRenderer.RenderLite(fallbackTo)
};
public ASFlag RenderFlag(User actor, User user, IEnumerable<Note> notes, string comment) => new()
{
Id = GenerateActivityId(),
Actor = userRenderer.RenderLite(actor),
Object = notes.Select(noteRenderer.RenderLite).Prepend<ASObject>(userRenderer.RenderLite(user)).ToArray(),
Content = comment
};
}

View file

@ -182,7 +182,7 @@ public class NoteRenderer(
To = to,
Tags = tags,
Attachments = attachments,
Content = text != null ? (await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost, media: inlineMedia)).Html : null,
Content = text != null ? mfmConverter.ToHtml(text, mentions, note.UserHost, media: inlineMedia).Html : null,
Summary = note.Cw,
Source = rawText != null
? new ASNoteSource { Content = rawText, MediaType = "text/x.misskeymarkdown" }
@ -214,7 +214,7 @@ public class NoteRenderer(
To = to,
Tags = tags,
Attachments = attachments,
Content = text != null ? (await mfmConverter.ToHtmlAsync(text, mentions, note.UserHost, media: inlineMedia)).Html : null,
Content = text != null ? mfmConverter.ToHtml(text, mentions, note.UserHost, media: inlineMedia).Html : null,
Summary = note.Cw,
Source = rawText != null
? new ASNoteSource { Content = rawText, MediaType = "text/x.misskeymarkdown" }

View file

@ -5,6 +5,7 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.MfmSharp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -84,7 +85,7 @@ public class UserRenderer(
.ToList();
var summary = profile?.Description != null
? (await mfmConverter.ToHtmlAsync(profile.Description, profile.Mentions, user.Host)).Html
? mfmConverter.ToHtml(profile.Description, profile.Mentions, user.Host).Html
: null;
var pronouns = profile?.Pronouns != null ? new LDLocalizedString { Values = profile.Pronouns! } : null;

View file

@ -35,6 +35,7 @@ public class ASActivity : ASObjectWithId
public const string Like = $"{Ns}#Like";
public const string Block = $"{Ns}#Block";
public const string Move = $"{Ns}#Move";
public const string Flag = $"{Ns}#Flag";
// Extensions
public const string Bite = "https://ns.mia.jetzt/as#Bite";
@ -42,6 +43,23 @@ public class ASActivity : ASObjectWithId
}
}
public class ASActivityWithObjectArray : ASActivity
{
private ASObject[]? _object;
[J($"{Constants.ActivityStreamsNs}#object")]
[JC(typeof(ASObjectArrayConverter))]
public new ASObject[]? Object
{
get => _object;
set
{
_object = value;
base.Object = value?.FirstOrDefault();
}
}
}
public class ASCreate : ASActivity
{
public ASCreate() => Type = Types.Create;
@ -128,6 +146,15 @@ public class ASBlock : ASActivity
public ASBlock() => Type = Types.Block;
}
public class ASFlag : ASActivityWithObjectArray
{
public ASFlag() => Type = Types.Flag;
[J($"{Constants.ActivityStreamsNs}#content")]
[JC(typeof(VC))]
public string? Content { get; set; }
}
public class ASLike : ASActivity
{
public ASLike() => Type = Types.Like;
@ -210,4 +237,4 @@ public class ASMove : ASActivity
[J($"{Constants.ActivityStreamsNs}#target")]
[JC(typeof(ASLinkConverter))]
public required ASLink Target { get; set; }
}
}

View file

@ -1,4 +1,5 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using J = Newtonsoft.Json.JsonPropertyAttribute;
@ -54,6 +55,7 @@ public class ASObject : ASObjectBase
ASActivity.Types.EmojiReact => token.ToObject<ASEmojiReact>(),
ASActivity.Types.Block => token.ToObject<ASBlock>(),
ASActivity.Types.Move => token.ToObject<ASMove>(),
ASActivity.Types.Flag => token.ToObject<ASFlag>(),
_ => token.ToObject<ASObject>()
};
case JTokenType.Array:
@ -126,6 +128,43 @@ internal sealed class ASObjectConverter : JsonConverter
throw new Exception("this shouldn't happen");
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
}
internal sealed class ASObjectArrayConverter : JsonConverter
{
public override bool CanWrite => false;
public override bool CanConvert(Type objectType)
{
return true;
}
public override object? ReadJson(
JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer
)
{
if (reader.TokenType == JsonToken.StartArray)
{
var obj = JArray.Load(reader);
var arr = obj.Select(ASObject.Deserialize).NotNull().ToArray();
return arr.Length > 0 ? arr : null;
}
if (reader.TokenType == JsonToken.StartObject)
{
var obj = JObject.Load(reader);
var deserialized = ASObject.Deserialize(obj);
return deserialized != null ? new[]{ deserialized } : null;
}
throw new Exception("this shouldn't happen");
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new NotImplementedException();

View file

@ -16,7 +16,7 @@ public static class BlurhashHelper
/// <param name="componentsX">The number of components used on the X-Axis for the DCT</param>
/// <param name="componentsY">The number of components used on the Y-Axis for the DCT</param>
/// <returns>The resulting Blurhash string</returns>
public static string Encode(Span2D<Rgb24> pixels, int componentsX, int componentsY)
public static string Encode(ReadOnlySpan2D<Rgb24> pixels, int componentsX, int componentsY)
{
if (componentsX < 1) throw new ArgumentException("componentsX needs to be at least 1");
if (componentsX > 9) throw new ArgumentException("componentsX needs to be at most 9");

View file

@ -2,13 +2,14 @@ using System.Text;
using System.Text.RegularExpressions;
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using AngleSharp.Html.Dom;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.MfmSharp;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.MfmSharp.Helpers;
using Microsoft.Extensions.Options;
using MfmHtmlParser = Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing.HtmlParser;
using HtmlParser = AngleSharp.Html.Parser.HtmlParser;
@ -43,13 +44,19 @@ public readonly record struct MfmHtmlData(string Html, List<MfmInlineMedia> Inli
public class MfmConverter(
IOptions<Config.InstanceSection> config,
MediaProxyService mediaProxy
MediaProxyService mediaProxy,
FlagService flags
) : ISingletonService
{
public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new();
public AsyncLocal<bool> SupportsInlineMedia { get; } = new();
private static readonly HtmlParser Parser = new();
public static async Task<HtmlMfmData> FromHtmlAsync(
private static readonly Lazy<IHtmlDocument> OwnerDocument =
new(() => Parser.ParseDocument(ReadOnlyMemory<char>.Empty));
private static IElement CreateElement(string name) => OwnerDocument.Value.CreateElement(name);
private static IText CreateTextNode(string data) => OwnerDocument.Value.CreateTextNode(data);
public static HtmlMfmData FromHtml(
string? html, List<Note.MentionedUser>? mentions = null, List<string>? hashtags = null
)
{
@ -66,7 +73,7 @@ public class MfmConverter(
// Ensure compatibility with AP servers that send CRLF or CR instead of LF-style newlines
html = html.ReplaceLineEndings("\n");
var dom = await new HtmlParser().ParseDocumentAsync(html);
var dom = Parser.ParseDocument(html);
if (dom.Body == null) return new HtmlMfmData("", media);
var sb = new StringBuilder();
@ -75,7 +82,7 @@ public class MfmConverter(
return new HtmlMfmData(sb.ToString().Trim(), media);
}
public static async Task<List<string>> ExtractMentionsFromHtmlAsync(string? html)
public static List<string> ExtractMentionsFromHtml(string? html)
{
if (html == null) return [];
@ -83,7 +90,7 @@ public class MfmConverter(
var regex = new Regex(@"<br\s?\/?>\r?\n", RegexOptions.IgnoreCase);
html = regex.Replace(html, "\n");
var dom = await new HtmlParser().ParseDocumentAsync(html);
var dom = Parser.ParseDocument(html);
if (dom.Body == null) return [];
var parser = new HtmlMentionsExtractor();
@ -93,28 +100,26 @@ public class MfmConverter(
return parser.Mentions;
}
public async Task<MfmHtmlData> ToHtmlAsync(
public MfmHtmlData ToHtml(
IMfmNode[] nodes, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
)
{
var context = BrowsingContext.New();
var document = await context.OpenNewAsync();
var element = document.CreateElement(rootElement);
var element = CreateElement(rootElement);
var hasContent = nodes.Length > 0;
if (replyInaccessible)
{
var wrapper = document.CreateElement("span");
var re = document.CreateElement("span");
var wrapper = CreateElement("span");
var re = CreateElement("span");
re.TextContent = "RE: \ud83d\udd12"; // lock emoji
wrapper.AppendChild(re);
if (hasContent)
{
wrapper.AppendChild(document.CreateElement("br"));
wrapper.AppendChild(document.CreateElement("br"));
wrapper.AppendChild(CreateElement("br"));
wrapper.AppendChild(CreateElement("br"));
}
element.AppendChild(wrapper);
@ -122,23 +127,23 @@ public class MfmConverter(
var usedMedia = new List<MfmInlineMedia>();
foreach (var node in nodes)
element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
element.AppendNodes(FromMfmNode(node, mentions, host, usedMedia, emoji, media));
if (quoteUri != null)
{
var a = document.CreateElement("a");
var a = CreateElement("a");
a.SetAttribute("href", quoteUri);
a.TextContent = quoteUri.StartsWith("https://") ? quoteUri[8..] : quoteUri[7..];
var quote = document.CreateElement("span");
var quote = CreateElement("span");
quote.ClassList.Add("quote-inline");
if (hasContent)
{
quote.AppendChild(document.CreateElement("br"));
quote.AppendChild(document.CreateElement("br"));
quote.AppendChild(CreateElement("br"));
quote.AppendChild(CreateElement("br"));
}
var re = document.CreateElement("span");
var re = CreateElement("span");
re.TextContent = "RE: ";
quote.AppendChild(re);
quote.AppendChild(a);
@ -146,39 +151,47 @@ public class MfmConverter(
}
else if (quoteInaccessible)
{
var wrapper = document.CreateElement("span");
var re = document.CreateElement("span");
var wrapper = CreateElement("span");
var re = CreateElement("span");
re.TextContent = "RE: \ud83d\udd12"; // lock emoji
if (hasContent)
{
wrapper.AppendChild(document.CreateElement("br"));
wrapper.AppendChild(document.CreateElement("br"));
wrapper.AppendChild(CreateElement("br"));
wrapper.AppendChild(CreateElement("br"));
}
wrapper.AppendChild(re);
element.AppendChild(wrapper);
}
await using var sw = new StringWriter();
await element.ToHtmlAsync(sw);
return new MfmHtmlData(sw.ToString(), usedMedia);
return new MfmHtmlData(element.ToHtml(), usedMedia);
}
public async Task<MfmHtmlData> ToHtmlAsync(
public MfmHtmlData ToHtml(
string mfm, List<Note.MentionedUser> mentions, string? host, string? quoteUri = null,
bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
)
{
var nodes = MfmParser.Parse(mfm);
return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible,
replyInaccessible, rootElement, emoji, media);
return ToHtml(nodes, mentions, host, quoteUri, quoteInaccessible, replyInaccessible, rootElement, emoji, media);
}
public string ProfileFieldToHtml(MfmUrlNode node)
{
var parsed = FromMfmNode(node, [], null, []);
if (parsed is not IHtmlAnchorElement el)
return parsed.ToHtml();
el.SetAttribute("rel", "me nofollow noopener");
el.SetAttribute("target", "_blank");
return el.ToHtml();
}
private INode FromMfmNode(
IDocument document, IMfmNode node, List<Note.MentionedUser> mentions, string? host,
List<MfmInlineMedia> usedMedia,
IMfmNode node, List<Note.MentionedUser> mentions, string? host, List<MfmInlineMedia> usedMedia,
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
)
{
@ -194,9 +207,9 @@ public class MfmConverter(
{
usedMedia.Add(current);
if (!SupportsInlineMedia.Value || current.Type == MfmInlineMedia.MediaType.Other)
if (!flags.SupportsInlineMedia.Value || current.Type == MfmInlineMedia.MediaType.Other)
{
var el = document.CreateElement("a");
var el = CreateElement("a");
el.SetAttribute("href", current.Src);
if (current.Type == MfmInlineMedia.MediaType.Other)
@ -223,7 +236,7 @@ public class MfmConverter(
_ => throw new ArgumentOutOfRangeException()
};
var el = document.CreateElement(nodeName);
var el = CreateElement(nodeName);
el.SetAttribute("src", current.Src);
el.SetAttribute("alt", current.Alt);
return el;
@ -232,16 +245,16 @@ public class MfmConverter(
}
{
var el = CreateInlineFormattingElement(document, "i");
AddHtmlMarkup(document, el, "*");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkup(document, el, "*");
var el = CreateInlineFormattingElement("i");
AddHtmlMarkup(el, "*");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkup(el, "*");
return el;
}
}
case MfmFnNode { Name: "unixtime" } fn:
{
var el = CreateInlineFormattingElement(document, "i");
var el = CreateInlineFormattingElement("i");
if (fn.Children.Length != 1 || fn.Children.FirstOrDefault() is not MfmTextNode textNode)
return Fallback();
@ -256,55 +269,55 @@ public class MfmConverter(
IElement Fallback()
{
AddHtmlMarkup(document, el, "*");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkup(document, el, "*");
AddHtmlMarkup(el, "*");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkup(el, "*");
return el;
}
}
case MfmBoldNode:
{
var el = CreateInlineFormattingElement(document, "b");
AddHtmlMarkup(document, el, "**");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkup(document, el, "**");
var el = CreateInlineFormattingElement("b");
AddHtmlMarkup(el, "**");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkup(el, "**");
return el;
}
case MfmSmallNode:
{
var el = document.CreateElement("small");
AppendChildren(el, document, node, mentions, host, usedMedia);
var el = CreateElement("small");
AppendChildren(el, node, mentions, host, usedMedia);
return el;
}
case MfmStrikeNode:
{
var el = CreateInlineFormattingElement(document, "del");
AddHtmlMarkup(document, el, "~~");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkup(document, el, "~~");
var el = CreateInlineFormattingElement("del");
AddHtmlMarkup(el, "~~");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkup(el, "~~");
return el;
}
case MfmItalicNode:
case MfmFnNode:
{
var el = CreateInlineFormattingElement(document, "i");
AddHtmlMarkup(document, el, "*");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkup(document, el, "*");
var el = CreateInlineFormattingElement("i");
AddHtmlMarkup(el, "*");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkup(el, "*");
return el;
}
case MfmCodeBlockNode codeBlockNode:
{
var el = CreateInlineFormattingElement(document, "pre");
var inner = CreateInlineFormattingElement(document, "code");
var el = CreateInlineFormattingElement("pre");
var inner = CreateInlineFormattingElement("code");
inner.TextContent = codeBlockNode.Code;
el.AppendNodes(inner);
return el;
}
case MfmCenterNode:
{
var el = document.CreateElement("div");
AppendChildren(el, document, node, mentions, host, usedMedia);
var el = CreateElement("div");
AppendChildren(el, node, mentions, host, usedMedia);
return el;
}
case MfmEmojiCodeNode emojiCodeNode:
@ -312,8 +325,8 @@ public class MfmConverter(
var punyHost = host?.ToPunycodeLower();
if (emoji?.FirstOrDefault(p => p.Name == emojiCodeNode.Name && p.Host == punyHost) is { } hit)
{
var el = document.CreateElement("span");
var inner = document.CreateElement("img");
var el = CreateElement("span");
var inner = CreateElement("img");
inner.SetAttribute("src", mediaProxy.GetProxyUrl(hit));
inner.SetAttribute("alt", hit.Name);
el.AppendChild(inner);
@ -321,11 +334,11 @@ public class MfmConverter(
return el;
}
return document.CreateTextNode($"\u200B:{emojiCodeNode.Name}:\u200B");
return CreateTextNode($"\u200B:{emojiCodeNode.Name}:\u200B");
}
case MfmHashtagNode hashtagNode:
{
var el = document.CreateElement("a");
var el = CreateElement("a");
el.SetAttribute("href", $"https://{config.Value.WebDomain}/tags/{hashtagNode.Hashtag}");
el.TextContent = $"#{hashtagNode.Hashtag}";
el.SetAttribute("rel", "tag");
@ -334,32 +347,32 @@ public class MfmConverter(
}
case MfmInlineCodeNode inlineCodeNode:
{
var el = CreateInlineFormattingElement(document, "code");
var el = CreateInlineFormattingElement("code");
el.TextContent = inlineCodeNode.Code;
return el;
}
case MfmInlineMathNode inlineMathNode:
{
var el = CreateInlineFormattingElement(document, "code");
var el = CreateInlineFormattingElement("code");
el.TextContent = inlineMathNode.Formula;
return el;
}
case MfmMathBlockNode mathBlockNode:
{
var el = CreateInlineFormattingElement(document, "code");
var el = CreateInlineFormattingElement("code");
el.TextContent = mathBlockNode.Formula;
return el;
}
case MfmLinkNode linkNode:
{
var el = document.CreateElement("a");
var el = CreateElement("a");
el.SetAttribute("href", linkNode.Url);
el.TextContent = linkNode.Text;
return el;
}
case MfmMentionNode mentionNode:
{
var el = document.CreateElement("span");
var el = CreateElement("span");
// Fall back to object host, as localpart-only mentions are relative to the instance the note originated from
var finalHost = mentionNode.Host ?? host ?? config.Value.AccountDomain;
@ -380,10 +393,10 @@ public class MfmConverter(
{
el.ClassList.Add("h-card");
el.SetAttribute("translate", "no");
var a = document.CreateElement("a");
var a = CreateElement("a");
a.ClassList.Add("u-url", "mention");
a.SetAttribute("href", mention.Url ?? mention.Uri);
var span = document.CreateElement("span");
var span = CreateElement("span");
span.TextContent = $"@{mention.Username}";
a.AppendChild(span);
el.AppendChild(a);
@ -393,25 +406,25 @@ public class MfmConverter(
}
case MfmQuoteNode:
{
var el = CreateInlineFormattingElement(document, "blockquote");
AddHtmlMarkup(document, el, "> ");
AppendChildren(el, document, node, mentions, host, usedMedia);
AddHtmlMarkupTag(document, el, "br");
AddHtmlMarkupTag(document, el, "br");
var el = CreateInlineFormattingElement("blockquote");
AddHtmlMarkup(el, "> ");
AppendChildren(el, node, mentions, host, usedMedia);
AddHtmlMarkupTag(el, "br");
AddHtmlMarkupTag(el, "br");
return el;
}
case MfmTextNode textNode:
{
var el = document.CreateElement("span");
var el = CreateElement("span");
var nodes = textNode.Text.Split("\r\n")
.SelectMany(p => p.Split('\r'))
.SelectMany(p => p.Split('\n'))
.Select(document.CreateTextNode);
.Select(CreateTextNode);
foreach (var htmlNode in nodes)
{
el.AppendNodes(htmlNode);
el.AppendNodes(document.CreateElement("br"));
el.AppendNodes(CreateElement("br"));
}
if (el.LastChild != null)
@ -420,17 +433,25 @@ public class MfmConverter(
}
case MfmUrlNode urlNode:
{
var el = document.CreateElement("a");
if (
!Uri.TryCreate(urlNode.Url, UriKind.Absolute, out var uri)
|| uri is not { Scheme: "http" or "https" }
)
{
var fallbackEl = CreateElement("span");
fallbackEl.TextContent = urlNode.Url;
return fallbackEl;
}
var el = CreateElement("a");
el.SetAttribute("href", urlNode.Url);
var prefix = urlNode.Url.StartsWith("https://") ? "https://" : "http://";
var length = prefix.Length;
el.TextContent = urlNode.Url[length..];
el.TextContent = uri.ToMfmDisplayString();
return el;
}
case MfmPlainNode:
{
var el = document.CreateElement("span");
AppendChildren(el, document, node, mentions, host, usedMedia);
var el = CreateElement("span");
AppendChildren(el, node, mentions, host, usedMedia);
return el;
}
default:
@ -441,32 +462,32 @@ public class MfmConverter(
}
private void AppendChildren(
INode element, IDocument document, IMfmNode parent,
INode element, IMfmNode parent,
List<Note.MentionedUser> mentions, string? host, List<MfmInlineMedia> usedMedia,
List<Emoji>? emoji = null, List<MfmInlineMedia>? media = null
)
{
foreach (var node in parent.Children)
element.AppendNodes(FromMfmNode(document, node, mentions, host, usedMedia, emoji, media));
element.AppendNodes(FromMfmNode(node, mentions, host, usedMedia, emoji, media));
}
private IElement CreateInlineFormattingElement(IDocument document, string name)
private IElement CreateInlineFormattingElement(string name)
{
return document.CreateElement(SupportsHtmlFormatting.Value ? name : "span");
return CreateElement(flags.SupportsHtmlFormatting.Value ? name : "span");
}
private void AddHtmlMarkup(IDocument document, IElement node, string chars)
private void AddHtmlMarkup(IElement node, string chars)
{
if (SupportsHtmlFormatting.Value) return;
var el = document.CreateElement("span");
el.AppendChild(document.CreateTextNode(chars));
if (flags.SupportsHtmlFormatting.Value) return;
var el = CreateElement("span");
el.AppendChild(CreateTextNode(chars));
node.AppendChild(el);
}
private void AddHtmlMarkupTag(IDocument document, IElement node, string tag)
private void AddHtmlMarkupTag(IElement node, string tag)
{
if (SupportsHtmlFormatting.Value) return;
var el = document.CreateElement(tag);
if (flags.SupportsHtmlFormatting.Value) return;
var el = CreateElement(tag);
node.AppendChild(el);
}
}

View file

@ -10,6 +10,8 @@ public static class StartupHelpers
{
Console.WriteLine($"""
Usage: ./{typeof(Program).Assembly.GetName().Name} [options...]
General options & commands:
-h, -?, --help Prints information on available command line arguments.
--migrate Applies pending migrations.
--migrate-and-start Applies pending migrations, then starts the application.
@ -26,6 +28,15 @@ public static class StartupHelpers
instead of http on the specified port.
--environment <env> Specifies the ASP.NET Core environment. Available options
are 'Development' and 'Production'.
User management commands:
--create-user <username> Creates a new user with the specified username
and a randomly generated password.
--create-admin-user <username> Creates a new admin user with the specified
username and a randomly generated password.
--reset-password <username> Resets the password of the specified user.
--grant-admin <username> Grants admin privileges to the specified user.
--revoke-admin <username> Revokes admin privileges of the specified user.
""");
Environment.Exit(0);
}

View file

@ -3,7 +3,6 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.EntityFrameworkCore;
@ -13,7 +12,7 @@ namespace Iceshrimp.Backend.Core.Middleware;
public class AuthenticationMiddleware(
DatabaseContext db,
UserService userSvc,
MfmConverter mfmConverter
FlagService flags
) : ConditionalMiddleware<AuthenticateAttribute>, IMiddlewareService
{
public static ServiceLifetime Lifetime => ServiceLifetime.Scoped;
@ -79,8 +78,9 @@ public class AuthenticationMiddleware(
userSvc.UpdateOauthTokenMetadata(oauthToken);
ctx.SetOauthToken(oauthToken);
mfmConverter.SupportsHtmlFormatting.Value = oauthToken.SupportsHtmlFormatting;
mfmConverter.SupportsInlineMedia.Value = oauthToken.SupportsInlineMedia;
flags.SupportsHtmlFormatting.Value = oauthToken.SupportsHtmlFormatting;
flags.SupportsInlineMedia.Value = oauthToken.SupportsInlineMedia;
flags.IsPleroma.Value = oauthToken.IsPleroma;
}
else
{

View file

@ -5,10 +5,11 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Utils.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver;
namespace Iceshrimp.Backend.Core.Middleware;
@ -23,7 +24,7 @@ public class AuthorizedFetchMiddleware(
ActivityPub.FederationControlService fedCtrlSvc,
ILogger<AuthorizedFetchMiddleware> logger,
IHostApplicationLifetime appLifetime,
MfmConverter mfmConverter
FlagService flags
) : ConditionalMiddleware<AuthorizedFetchAttribute>, IMiddlewareService
{
public static ServiceLifetime Lifetime => ServiceLifetime.Scoped;
@ -34,28 +35,56 @@ public class AuthorizedFetchMiddleware(
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
// Ensure we're rendering HTML markup (AsyncLocal)
mfmConverter.SupportsHtmlFormatting.Value = true;
mfmConverter.SupportsInlineMedia.Value = true;
flags.SupportsHtmlFormatting.Value = true;
flags.SupportsInlineMedia.Value = true;
if (!config.Value.AuthorizedFetch)
// Short-circuit fetches when signature validation is disabled
if (config.Value is { AuthorizedFetch: false, ValidateRequestSignatures: false })
{
await next(ctx);
return;
}
ctx.Response.Headers.CacheControl = "private, no-store";
var request = ctx.Request;
var ct = appLifetime.ApplicationStopping;
// Short-circuit instance & relay actor fetches
_instanceActorUri ??= $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}";
_relayActorUri ??= $"/users/{(await systemUserSvc.GetRelayActorAsync()).Id}";
if (request.Path.Value == _instanceActorUri || request.Path.Value == _relayActorUri)
if (ctx.Request.Path.Value == _instanceActorUri || ctx.Request.Path.Value == _relayActorUri)
{
await next(ctx);
return;
}
// Prevent authenticated responses from ending up in caches, and prevent unauthenticated responses
// from being returned for authenticated requests
if (ctx.Request.Headers.ContainsKey("Signature"))
ctx.Response.Headers.CacheControl = "private, no-store";
else
ctx.Response.Headers.Append(HeaderNames.Vary, "Signature");
var res = await ValidateSignatureAsync(ctx);
// We want to reject blocked instances even if authorized fetch is disabled
if (res.TryGetError(out var error) && error is InstanceBlockedException)
throw error;
if (res.TryGetResult(out var user) && user.Value != null)
{
ctx.SetActor(user.Value);
}
else if (config.Value.AuthorizedFetch)
{
if (error != null) throw error;
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
}
await next(ctx);
}
private async Task<Result<Optional<User>>> ValidateSignatureAsync(HttpContext ctx)
{
var request = ctx.Request;
var ct = appLifetime.ApplicationStopping;
UserPublickey? key = null;
var verified = false;
@ -64,17 +93,15 @@ public class AuthorizedFetchMiddleware(
try
{
if (!request.Headers.TryGetValue("signature", out var sigHeader))
throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header");
return new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header");
var sig = HttpSignature.Parse(sigHeader.ToString());
if (await fedCtrlSvc.ShouldBlockAsync(sig.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
return new InstanceBlockedException();
// First, we check if we already have the key
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
// If we don't, we need to try to fetch it
if (key == null)
@ -82,31 +109,26 @@ public class AuthorizedFetchMiddleware(
try
{
var user = await userResolver.ResolveAsync(sig.KeyId, ResolveFlags.Uri).WaitAsync(ct);
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.User == user, ct);
// If the key is still null here, we have a data consistency issue and need to update the key manually
key ??= await userSvc.UpdateUserPublicKeyAsync(user).WaitAsync(ct);
}
catch (Exception e)
{
if (e is GracefulException) throw;
throw new Exception($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
if (e is GracefulException) return e;
return new Exception($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
}
}
// If we still don't have the key, something went wrong and we need to throw an exception
if (key == null) throw new Exception($"Failed to fetch key of signature user ({sig.KeyId})");
if (key.User.IsSuspended)
throw GracefulException.Forbidden("User is suspended");
return GracefulException.Forbidden("User is suspended");
if (key.User.IsLocalUser)
throw new Exception("Remote user must have a host");
return new Exception("Remote user must have a host");
// We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
return new InstanceBlockedException();
List<string> headers = ["(request-target)", "host"];
@ -128,18 +150,19 @@ public class AuthorizedFetchMiddleware(
}
catch (Exception e)
{
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw;
if (e is AuthFetchException afe) return GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) return e;
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
}
if (!verified || key == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
return new Optional<User>(null);
ctx.SetActor(key.User);
await next(ctx);
return new Optional<User>(key.User);
}
private class InstanceBlockedException() : GracefulException(HttpStatusCode.Forbidden, "Forbidden",
"Instance is blocked", suppressLog: true);
}
public class AuthorizedFetchAttribute : Attribute;

View file

@ -6,14 +6,41 @@ namespace Iceshrimp.Backend.Core.Services;
public class CronService(IServiceScopeFactory serviceScopeFactory) : BackgroundService
{
public CronTaskState[] Tasks { get; private set; } = [];
public async Task RunCronTaskAsync(ICronTask task, ICronTrigger trigger)
{
if (trigger.IsRunning) return;
trigger.IsRunning = true;
trigger.Error = null;
try
{
await using var scope = serviceScopeFactory.CreateAsyncScope();
await task.InvokeAsync(scope.ServiceProvider);
}
catch (Exception e)
{
trigger.Error = e;
}
trigger.LastRun = DateTime.UtcNow;
trigger.IsRunning = false;
}
protected override Task ExecuteAsync(CancellationToken token)
{
var tasks = PluginLoader
.Assemblies.Prepend(Assembly.GetExecutingAssembly())
.SelectMany(AssemblyLoader.GetImplementationsOfInterface<ICronTask>)
.Select(p => Activator.CreateInstance(p) as ICronTask)
.Where(p => p != null)
.Cast<ICronTask>();
var tasks = PluginLoader.Assemblies
.Prepend(Assembly.GetExecutingAssembly())
.SelectMany(AssemblyLoader.GetImplementationsOfInterface<ICronTask>)
.OrderBy(p => p.AssemblyQualifiedName)
.ThenBy(p => p.Name)
.Select(p => Activator.CreateInstance(p) as ICronTask)
.Where(p => p != null)
.Cast<ICronTask>()
.ToArray();
List<CronTaskState> stateObjs = [];
foreach (var task in tasks)
{
@ -24,24 +51,33 @@ public class CronService(IServiceScopeFactory serviceScopeFactory) : BackgroundS
_ => throw new ArgumentOutOfRangeException()
};
trigger.OnTrigger += async void () =>
trigger.OnTrigger += async void (state) =>
{
try
{
await using var scope = serviceScopeFactory.CreateAsyncScope();
await task.InvokeAsync(scope.ServiceProvider);
await RunCronTaskAsync(task, state);
}
catch
{
// ignored (errors in the event handler crash the host process)
}
};
stateObjs.Add(new CronTaskState { Task = task, Trigger = trigger });
}
Tasks = stateObjs.ToArray();
return Task.CompletedTask;
}
}
public class CronTaskState
{
public required ICronTask Task;
public required ICronTrigger Trigger;
}
public interface ICronTask
{
public TimeSpan Trigger { get; }
@ -55,36 +91,59 @@ public enum CronTaskType
Interval
}
file interface ICronTrigger
public interface ICronTrigger
{
public event Action? OnTrigger;
public event Action<ICronTrigger>? OnTrigger;
public DateTime NextTrigger { get; }
public DateTime? LastRun { get; set; }
public bool IsRunning { get; set; }
public Exception? Error { get; set; }
public TimeSpan UpdateNextTrigger();
}
file class DailyTrigger : ICronTrigger, IDisposable
public class DailyTrigger : ICronTrigger, IDisposable
{
public DailyTrigger(TimeSpan triggerTime, CancellationToken cancellationToken)
{
TriggerTime = triggerTime;
CancellationToken = cancellationToken;
NextTrigger = DateTime.UtcNow;
RunningTask = Task.Run(async () =>
RunningTask = Task.Factory.StartNew(async () =>
{
while (!CancellationToken.IsCancellationRequested)
{
var nextTrigger = DateTime.Today + TriggerTime - DateTime.Now;
if (nextTrigger < TimeSpan.Zero)
nextTrigger = nextTrigger.Add(new TimeSpan(24, 0, 0));
var nextTrigger = UpdateNextTrigger();
await Task.Delay(nextTrigger, CancellationToken);
OnTrigger?.Invoke();
OnTrigger?.Invoke(this);
}
}, CancellationToken);
}, CancellationToken, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
public TimeSpan UpdateNextTrigger()
{
var nextTrigger = DateTime.Today + TriggerTime - DateTime.Now;
if (nextTrigger < TimeSpan.Zero)
nextTrigger = nextTrigger.Add(new TimeSpan(24, 0, 0));
NextTrigger = DateTime.UtcNow + nextTrigger;
return nextTrigger;
}
private TimeSpan TriggerTime { get; }
private CancellationToken CancellationToken { get; }
private Task RunningTask { get; set; }
public event Action? OnTrigger;
public event Action<ICronTrigger>? OnTrigger;
public DateTime NextTrigger { get; set; }
public DateTime? LastRun { get; set; }
public bool IsRunning { get; set; }
public Exception? Error { get; set; } = null;
public void Dispose()
{
@ -96,28 +155,49 @@ file class DailyTrigger : ICronTrigger, IDisposable
~DailyTrigger() => Dispose();
}
file class IntervalTrigger : ICronTrigger, IDisposable
public class IntervalTrigger : ICronTrigger, IDisposable
{
public IntervalTrigger(TimeSpan triggerInterval, CancellationToken cancellationToken)
{
TriggerInterval = triggerInterval;
CancellationToken = cancellationToken;
NextTrigger = DateTime.UtcNow + TriggerInterval;
RunningTask = Task.Run(async () =>
RunningTask = Task.Factory.StartNew(async () =>
{
while (!CancellationToken.IsCancellationRequested)
{
UpdateNextTrigger();
await Task.Delay(TriggerInterval, CancellationToken);
OnTrigger?.Invoke();
while (LastRun != null && LastRun + TriggerInterval + TimeSpan.FromMinutes(5) < DateTime.UtcNow)
{
NextTrigger = LastRun.Value + TriggerInterval;
await Task.Delay(NextTrigger - DateTime.UtcNow);
}
OnTrigger?.Invoke(this);
}
}, CancellationToken);
}, CancellationToken, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
public TimeSpan UpdateNextTrigger()
{
NextTrigger = DateTime.UtcNow + TriggerInterval;
return TriggerInterval;
}
private TimeSpan TriggerInterval { get; }
private CancellationToken CancellationToken { get; }
private Task RunningTask { get; set; }
public event Action? OnTrigger;
public event Action<ICronTrigger>? OnTrigger;
public DateTime NextTrigger { get; set; }
public DateTime? LastRun { get; set; }
public bool IsRunning { get; set; }
public Exception? Error { get; set; } = null;
public void Dispose()
{
@ -127,4 +207,4 @@ file class IntervalTrigger : ICronTrigger, IDisposable
}
~IntervalTrigger() => Dispose();
}
}

View file

@ -120,7 +120,7 @@ public class DriveService(
? storageConfig.Value.MaxCacheSizeBytes
: 0;
var stream = await GetSafeStreamOrNullAsync(input, maxLength, res.Content.Headers.ContentLength);
var stream = await input.GetSafeStreamOrNullAsync(maxLength, res.Content.Headers.ContentLength);
try
{
return await StoreFileAsync(stream, user, request, skipImageProcessing);
@ -629,37 +629,6 @@ public class DriveService(
int GetTargetRes() => config.TargetRes ?? throw new Exception("TargetRes is required to encode images");
// @formatter:on
}
/// <summary>
/// We can't trust the Content-Length header, and it might be null.
/// This makes sure that we only ever read up to maxLength into memory.
/// </summary>
/// <param name="stream">The response content stream</param>
/// <param name="maxLength">The maximum length to buffer (null = unlimited)</param>
/// <param name="contentLength">The content length, if known</param>
/// <param name="token">A CancellationToken, if applicable</param>
/// <returns>Either a buffered MemoryStream, or Stream.Null</returns>
private static async Task<Stream> GetSafeStreamOrNullAsync(
Stream stream, long? maxLength, long? contentLength, CancellationToken token = default
)
{
if (maxLength is 0) return Stream.Null;
if (contentLength > maxLength) return Stream.Null;
MemoryStream buf = new();
if (contentLength < maxLength)
maxLength = contentLength.Value;
await stream.CopyToAsync(buf, maxLength, token);
if (maxLength == null || buf.Length <= maxLength)
{
buf.Seek(0, SeekOrigin.Begin);
return buf;
}
await buf.DisposeAsync();
return Stream.Null;
}
}
public class DriveFileCreationRequest

View file

@ -28,7 +28,7 @@ public partial class EmojiService(
});
public async Task<Emoji> CreateEmojiFromStreamAsync(
Stream input, string fileName, string mimeType, List<string>? aliases = null,
Stream input, string fileName, string mimeType, List<string>? tags = null,
string? category = null
)
{
@ -51,7 +51,7 @@ public partial class EmojiService(
{
Id = id,
Name = name,
Aliases = aliases ?? [],
Tags = tags ?? [],
Category = category,
UpdatedAt = DateTime.UtcNow,
OriginalUrl = driveFile.Url,
@ -118,10 +118,39 @@ public partial class EmojiService(
var resolved = await db.Emojis.Where(p => p.Host == host && emoji.Select(e => e.Name).Contains(p.Name))
.ToListAsync();
var existing = emoji.Where(p => resolved.Any(i => i.Name == p.Name))
.ToDictionary(p => p, p => resolved.First(i => i.Name == p.Name));
//TODO: handle updated emoji
foreach (var emojo in emoji.Where(emojo => resolved.All(p => p.Name != emojo.Name)))
foreach (var emojo in emoji)
{
// Update emoji entry if modified
if (existing.TryGetValue(emojo, out var hit))
{
var url = emojo.Image?.Url?.Link;
if (emojo.Image == null || string.IsNullOrWhiteSpace(url))
continue;
if (
hit.OriginalUrl != url
|| hit.RawPublicUrl != url
|| hit.Type != emojo.Image.MediaType
|| hit.Uri != emojo.Id
)
{
using (await KeyedLocker.LockAsync($"emoji:{host}:{emojo.Name}"))
{
await db.ReloadEntityAsync(hit);
hit.OriginalUrl = url;
hit.RawPublicUrl = url;
hit.Type = emojo.Image.MediaType;
hit.Uri = emojo.Id;
await db.SaveChangesAsync();
}
}
continue;
}
using (await KeyedLocker.LockAsync($"emoji:{host}:{emojo.Name}"))
{
var dbEmojo = await db.Emojis.FirstOrDefaultAsync(p => p.Host == host && p.Name == emojo.Name);
@ -224,12 +253,24 @@ public partial class EmojiService(
}
public async Task<Emoji?> UpdateLocalEmojiAsync(
string id, string? name, List<string>? aliases, string? category, string? license, bool? sensitive
string id, string? name, List<string>? tags, string? category, string? license, bool? sensitive
)
{
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id);
if (emoji == null) return null;
if (emoji.Host != null) return null;
if (emoji.Host != null)
{
// Only allow changing remote emoji sensitive status
if (!sensitive.HasValue)
throw GracefulException.BadRequest("Only sensitive can be updated on remote emojis");
emoji.Sensitive = sensitive.Value;
emoji.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return emoji;
}
emoji.UpdatedAt = DateTime.UtcNow;
@ -241,8 +282,8 @@ public partial class EmojiService(
emoji.Uri = emoji.GetPublicUri(config.Value);
}
if (aliases != null)
emoji.Aliases = aliases.Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
if (tags != null)
emoji.Tags = tags.Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
// If category is provided but empty reset to null
if (category != null) emoji.Category = string.IsNullOrEmpty(category) ? null : category;

View file

@ -0,0 +1,12 @@
using Iceshrimp.Backend.Core.Extensions;
using JetBrains.Annotations;
namespace Iceshrimp.Backend.Core.Services;
[UsedImplicitly]
public class FlagService : ISingletonService
{
public AsyncLocal<bool> SupportsHtmlFormatting { get; } = new();
public AsyncLocal<bool> SupportsInlineMedia { get; } = new();
public AsyncLocal<bool> IsPleroma { get; } = new();
}

View file

@ -480,8 +480,12 @@ public class NoteService(
}
else
{
var updateLastNoteTimestamp = create && (note.User.LastNoteAt == null || note.CreatedAt > note.User.LastNoteAt);
await db.Users.Where(p => p.Id == note.User.Id)
.ExecuteUpdateAsync(p => p.SetProperty(u => u.NotesCount, u => u.NotesCount + diff));
.ExecuteUpdateAsync(p => p
.SetProperty(u => u.NotesCount, u => u.NotesCount + diff)
.SetProperty(u => u.LastNoteAt, u => updateLastNoteTimestamp ? note.CreatedAt : u.LastNoteAt));
}
if (note.Reply != null)
@ -1024,7 +1028,7 @@ public class NoteService(
.ToList()
?? [];
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions, hashtags);
(text, htmlInlineMedia) = MfmConverter.FromHtml(note.Content, mentionData.Mentions, hashtags);
}
var cw = note.Summary;
@ -1124,7 +1128,7 @@ public class NoteService(
.ToList()
?? [];
(text, htmlInlineMedia) = await MfmConverter.FromHtmlAsync(note.Content, mentionData.Mentions, hashtags);
(text, htmlInlineMedia) = MfmConverter.FromHtml(note.Content, mentionData.Mentions, hashtags);
}
var cw = note.Summary;

View file

@ -104,7 +104,10 @@ public class ObjectStorageService(IOptions<Config.StorageSection> config, HttpCl
public Uri GetFilePublicUrl(string filename)
{
var baseUri = new Uri(_accessUrl ?? throw new Exception("Invalid object storage access url"));
var accessUrl = _accessUrl ?? throw new Exception("Invalid object storage access url");
if (!accessUrl.EndsWith('/'))
accessUrl += '/';
var baseUri = new Uri(accessUrl);
return new Uri(baseUri, GetKeyWithPrefix(filename));
}

View file

@ -57,8 +57,8 @@ public class QueueService(
logger.LogInformation("Queue shutdown complete.");
});
_ = Task.Run(ExecuteHealthchecksWorkerAsync, token);
await Task.Run(ExecuteBackgroundWorkersAsync, tokenSource.Token);
_ = Task.Factory.StartNew(ExecuteHealthchecksWorkerAsync, token, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
await Task.Factory.StartNew(ExecuteBackgroundWorkersAsync, tokenSource.Token, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
return;
@ -563,4 +563,4 @@ public abstract class PostgresJobQueue<T>(
await db.Jobs.Upsert(job).On(j => j.Mutex!).NoUpdate().RunAsync();
RaiseJobDelayedEvent();
}
}
}

View file

@ -0,0 +1,49 @@
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
namespace Iceshrimp.Backend.Core.Services;
public class ReportService(
ActivityPub.ActivityDeliverService deliverSvc,
ActivityPub.ActivityRenderer activityRenderer,
SystemUserService sysUserSvc,
DatabaseContext db
) : IScopedService
{
public async Task ForwardReportAsync(Report report, string? comment)
{
if (report.TargetUser.IsLocalUser)
throw new Exception("Refusing to forward report to local instance");
var actor = await sysUserSvc.GetInstanceActorAsync();
var activity = activityRenderer.RenderFlag(actor, report.TargetUser, report.Notes, comment ?? report.Comment);
var inbox = report.TargetUser.SharedInbox
?? report.TargetUser.Inbox
?? throw new Exception("Target user does not have inbox");
await deliverSvc.DeliverToAsync(activity, actor, inbox);
}
public async Task<Report> CreateReportAsync(User reporter, User target, IEnumerable<Note> notes, string comment)
{
var report = new Report
{
Id = IdHelpers.GenerateSnowflakeId(),
CreatedAt = DateTime.UtcNow,
TargetUser = target,
TargetUserHost = target.Host,
Reporter = reporter,
ReporterHost = reporter.Host,
Notes = notes.ToArray(),
Comment = comment
};
db.Add(report);
await db.SaveChangesAsync();
await db.ReloadEntityRecursivelyAsync(report);
return report;
}
}

View file

@ -21,6 +21,17 @@ public class StorageMaintenanceService(
{
public async Task MigrateLocalFilesAsync(bool purge)
{
try
{
await objectStorageSvc.VerifyCredentialsAsync();
}
catch (Exception e)
{
logger.LogCritical("Failed to initialize object storage: {message}", e.Message);
Environment.Exit(1);
return;
}
var pathBase = options.Value.Local?.Path;
var pathsToDelete = new ConcurrentBag<string>();
var failed = new ConcurrentBag<string>();

View file

@ -27,11 +27,10 @@ public class UserProfileMentionsResolver(
?? [];
if (fields is not { Count: > 0 } && (actor.MkSummary ?? actor.Summary) == null) return ([], []);
var parsedFields = await fields.SelectMany<ASField, string?>(p => [p.Name, p.Value])
.Select(async p => await MfmConverter.ExtractMentionsFromHtmlAsync(p))
.AwaitAllAsync();
var parsedFields = fields.SelectMany<ASField, string?>(p => [p.Name, p.Value])
.Select(MfmConverter.ExtractMentionsFromHtml);
var parsedBio = actor.MkSummary == null ? await MfmConverter.ExtractMentionsFromHtmlAsync(actor.Summary) : [];
var parsedBio = actor.MkSummary == null ? MfmConverter.ExtractMentionsFromHtml(actor.Summary) : [];
var userUris = parsedFields.Prepend(parsedBio).SelectMany(p => p).ToList();
var mentionNodes = new List<MfmMentionNode>();

View file

@ -148,16 +148,12 @@ public class UserService(
var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(), host);
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(async p => new UserProfile.Field
{
Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
})
.AwaitAllAsync()
: null;
var fields = actor.Attachments?.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(p => new UserProfile.Field
{
Name = p.Name!, Value = MfmConverter.FromHtml(p.Value).Mfm
});
var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");
@ -170,7 +166,7 @@ public class UserService(
.ToList()
?? [];
bio = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
bio = MfmConverter.FromHtml(actor.Summary, hashtags: asHashtags).Mfm;
}
var tags = ResolveHashtags(MfmParser.Parse(bio), actor);
@ -318,16 +314,12 @@ public class UserService(
?? throw new
Exception("User host must not be null at this stage"));
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(async p => new UserProfile.Field
{
Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
})
.AwaitAllAsync()
: null;
var fields = actor.Attachments?.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(p => new UserProfile.Field
{
Name = p.Name!, Value = MfmConverter.FromHtml(p.Value).Mfm
});
var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");
@ -348,7 +340,7 @@ public class UserService(
.ToList()
?? [];
user.UserProfile.Description = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
user.UserProfile.Description = MfmConverter.FromHtml(actor.Summary, hashtags: asHashtags).Mfm;
}
//user.UserProfile.Birthday = TODO;
@ -429,15 +421,16 @@ public class UserService(
return user;
}
public async Task<User> CreateLocalUserAsync(string username, string password, string? invite)
public async Task<User> CreateLocalUserAsync(string username, string password, string? invite, bool force = false)
{
//TODO: invite system should allow multi-use invites & time limited invites
if (security.Value.Registrations == Enums.Registrations.Closed)
if (security.Value.Registrations == Enums.Registrations.Closed && !force)
throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null)
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null && !force)
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code");
if (security.Value.Registrations == Enums.Registrations.Invite
&& !await db.RegistrationInvites.AnyAsync(p => p.Code == invite))
&& !await db.RegistrationInvites.AnyAsync(p => p.Code == invite)
&& !force)
throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
if (!Regex.IsMatch(username, @"^\w+$"))
throw new GracefulException(HttpStatusCode.BadRequest, "Username must only contain letters and numbers");
@ -472,7 +465,7 @@ public class UserService(
var usedUsername = new UsedUsername { CreatedAt = DateTime.UtcNow, Username = username.ToLowerInvariant() };
if (security.Value.Registrations == Enums.Registrations.Invite)
if (security.Value.Registrations == Enums.Registrations.Invite && !force)
{
var ticket = await db.RegistrationInvites.FirstOrDefaultAsync(p => p.Code == invite);
if (ticket == null)
@ -1131,21 +1124,17 @@ public class UserService(
{
var (mentions, splitDomainMapping) =
await bgMentionsResolver.ResolveMentionsAsync(actor, bgUser.Host);
var fields = actor.Attachments != null
? await actor.Attachments
.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(async p => new UserProfile.Field
{
Name = p.Name!,
Value = (await MfmConverter.FromHtmlAsync(p.Value, mentions)).Mfm
})
.AwaitAllAsync()
: null;
var fields = actor.Attachments?.OfType<ASField>()
.Where(p => p is { Name: not null, Value: not null })
.Select(p => new UserProfile.Field
{
Name = p.Name!,
Value = MfmConverter.FromHtml(p.Value, mentions).Mfm
});
var description = actor.MkSummary != null
? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping)
: (await MfmConverter.FromHtmlAsync(actor.Summary, mentions)).Mfm;
: MfmConverter.FromHtml(actor.Summary, mentions).Mfm;
bgUser.UserProfile.Mentions = mentions;
bgUser.UserProfile.Fields = fields?.ToArray() ?? [];
@ -1203,13 +1192,14 @@ public class UserService(
try
{
var res = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
const int maxLength = 1_000_000;
var res = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (
res is not
{
IsSuccessStatusCode: true,
Content.Headers: { ContentType.MediaType: "text/html", ContentLength: <= 1_000_000 }
Content.Headers: { ContentType.MediaType: "text/html", ContentLength: null or <= maxLength }
}
)
{
@ -1220,9 +1210,13 @@ public class UserService(
continue;
}
var html = await res.Content.ReadAsStringAsync();
var document = await new HtmlParser().ParseDocumentAsync(html);
var contentLength = res.Content.Headers.ContentLength;
var stream = await res.Content.ReadAsStreamAsync()
.ContinueWithResult(p => p.GetSafeStreamOrNullAsync(maxLength, contentLength));
if (stream == Stream.Null) throw new Exception("Response size limit exceeded");
var document = await new HtmlParser().ParseDocumentAsync(stream);
var headLinks = document.Head?.Children.Where(el => el.NodeName.ToLower() == "link").ToList() ?? [];
userProfileField.IsVerified =
@ -1564,6 +1558,28 @@ public class UserService(
}
}
var mutes = db.Mutings
.Where(p => p.Mutee == source && p.ExpiresAt == null)
.Select(p => p.Muter)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var muter in mutes)
{
try
{
if (muter.Id == target.Id) continue;
// We need to transfer the precomputed properties to the target user for each muter so that the block method works correctly
target.PrecomputedIsMutedBy = muter.PrecomputedIsMuting;
await MuteUserAsync(muter, target, null);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for muter {id}: {error}",
sourceUri, targetUri, muter.Id, e);
}
}
if (source.IsRemoteUser || target.IsRemoteUser) return;
var following = db.Followings
@ -1603,6 +1619,25 @@ public class UserService(
sourceUri, targetUri, blockee.Id, e);
}
}
mutes = db.Mutings
.Where(p => p.Muter == source && p.ExpiresAt == null)
.Select(p => p.Mutee)
.AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));
await foreach (var mutee in mutes)
{
try
{
if (mutee.Id == target.Id) continue;
await MuteUserAsync(mutee, target, null);
}
catch (Exception e)
{
logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for mutee {id}: {error}",
sourceUri, targetUri, mutee.Id, e);
}
}
}
public async Task SuspendUserAsync(User user)

View file

@ -28,7 +28,7 @@
<PackageReference Include="EntityFrameworkCore.Projectables" Version="4.0.0" />
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.1.2" />
<PackageReference Include="Iceshrimp.Assets.Fonts" Version="1.0.0" />
<PackageReference Include="Iceshrimp.EntityFrameworkCore.Extensions" Version="1.0.1" />
<PackageReference Include="Iceshrimp.EntityFrameworkCore.Extensions" Version="1.0.2" />
<PackageReference Include="Iceshrimp.ObjectStorage.S3" Version="0.34.3" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
@ -45,9 +45,9 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.0.15" />
<PackageReference Include="Scalar.AspNetCore" Version="2.1.0" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7-iceshrimp" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8-iceshrimp" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="System.IO.Hashing" Version="9.0.2" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
@ -56,7 +56,7 @@
<PackageReference Include="Ulid" Version="1.3.4" />
<PackageReference Include="Iceshrimp.Assets.Branding" Version="1.0.1" />
<PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.3" />
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.17" />
<PackageReference Include="Iceshrimp.MfmSharp" Version="1.2.20" />
<PackageReference Include="Iceshrimp.Utils.Common" Version="1.2.1" />
<PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.1" />
<PackageReference Include="Iceshrimp.WebPush" Version="2.1.0" />

View file

@ -0,0 +1,63 @@
@page "/admin/tasks"
@using Iceshrimp.Backend.Components.Admin
@using Iceshrimp.Backend.Core.Extensions
@using Iceshrimp.Backend.Core.Services
@inherits AdminComponentBase
@inject CronService CronSvc
<AdminPageHeader Title="Cron tasks"/>
<table class="auto-table">
<thead>
<th>Name</th>
<th>Assembly</th>
<th>Schedule</th>
<th>Last run</th>
<th>Next run</th>
<th>Actions</th>
</thead>
<tbody>
@foreach (var task in CronSvc.Tasks)
{
var type = task.Task.GetType();
var schedule = task.Task.Type switch
{
CronTaskType.Daily when task.Task.Trigger == TimeSpan.Zero => "daily at midnight",
//
CronTaskType.Daily => $"daily at {((int)task.Task.Trigger.TotalHours).ToString().PadLeft(2, '0')}:{((int)task.Task.Trigger.TotalMinutes).ToString().PadLeft(2, '0')}",
CronTaskType.Interval => $"every {task.Task.Trigger.ToDisplayString(singleNumber: false)}",
_ => throw new ArgumentOutOfRangeException()
};
var last = task.Trigger.IsRunning
? "now"
: task.Trigger.LastRun.HasValue
? (DateTime.UtcNow - task.Trigger.LastRun.Value).ToDisplayString() + " ago"
: "never";
if (task.Trigger.LastRun.HasValue)
{
last += task.Trigger.Error is null ? " (OK)" : " (Error)";
}
var next = task.Trigger.IsRunning
? "now"
: "in " + (task.Trigger.NextTrigger - DateTime.UtcNow).ToDisplayString();
<tr>
<td>@type.Name</td>
<td>@type.Assembly.GetName().Name</td>
<td>@schedule</td>
@if (task.Trigger is { LastRun: not null, Error: { } e })
{
<td title="@e.ToString()">@last</td>
}
else
{
<td>@last</td>
}
<td>@next</td>
<td><a class="fake-link" onclick="runCronTask('@type.FullName', event.target)">Run now</a></td>
</tr>
}
</tbody>
</table>

View file

@ -102,7 +102,7 @@ else
<div class="flex">
@if (Offset is > 0)
{
<button role="link" data-target="/admin/users?@(Remote ? "remote=true&" : "")offset=@(Math.Max(0, Offset.Value - 50))" onclick="navigate(event)">
<button role="link" data-target="/admin/users?@(_prevPageParams)" onclick="navigate(event)">
Previous page
</button>
}
@ -113,7 +113,7 @@ else
@if (_users.Length == 50)
{
<button role="link" data-target="/admin/users?@(Remote ? "remote=true&" : "")offset=@((Offset ?? 0) + 50)" onclick="navigate(event)">
<button role="link" data-target="/admin/users?@(_nextPageParams)" onclick="navigate(event)">
Next page
</button>
}
@ -141,18 +141,27 @@ else
private User[] _users = [];
private int _count;
private string _prevPageParams = "";
private string _nextPageParams = "";
protected override async Task OnGet()
{
var query = Remote
? Database.Users.Where(p => p.IsRemoteUser)
.Where(p => ("@" + p.Username + "@" + p.Host).ToLower().Contains((Query ?? "").ToLower()))
.OrderBy(p => p.Username + "@" + p.Host)
: Database.Users.Where(p => p.IsLocalUser && !p.IsSystemUser)
.Where(p => ("@" + p.Username.ToLower()).Contains((Query ?? "").ToLower()))
.OrderBy(p => p.Username);
? Database.Users.Where(p => p.IsRemoteUser)
.Where(p => ("@" + p.Username + "@" + p.Host).ToLower().Contains((Query ?? "").ToLower()))
.OrderBy(p => p.Username + "@" + p.Host)
: Database.Users.Where(p => p.IsLocalUser && !p.IsSystemUser)
.Where(p => ("@" + p.Username.ToLower()).Contains((Query ?? "").ToLower()))
.OrderBy(p => p.Username);
_users = await query.Skip(Offset ?? 0).Take(50).ToArrayAsync();
_count = await query.CountAsync();
if (Offset > 0)
_prevPageParams = (Query is { Length: > 0 } ? $"q={Query}&" : "") + (Remote ? "remote=true&" : "") + "offset=" + (Math.Max(0, Offset.Value - 50));
_users = await query.Skip(Offset ?? 0).Take(50).ToArrayAsync();
_count = await query.CountAsync();
if (_users.Length == 50)
_nextPageParams = (Query is { Length: > 0 } ? $"q={Query}&" : "") + (Remote ? "remote=true&" : "") + "offset=" + ((Offset ?? 0) + 50);
}
}

View file

@ -10,9 +10,9 @@
@section head
{
<meta name="twitter:card" content="summary">
<meta name="og:title" content="Home - @Model.InstanceName">
<meta name="og:description" content="@Model.InstanceDescription">
<meta name="og:site_name" content="@Model.InstanceName">
<meta property="og:title" content="Home - @Model.InstanceName">
<meta property="og:description" content="@Model.InstanceDescription">
<meta property="og:site_name" content="@Model.InstanceName">
}
<div class="header">

View file

@ -123,10 +123,11 @@ else
}
<meta name="twitter:card" content="@cardType">
<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">
<meta property="og:site_name" content="@_instanceName">
<meta property="og:title" content="@title">
<meta property="og:image" content="@previewImageUrl">
<meta property="og:description" content="@description">
<link rel="alternate" type="application/activity+json" href="@_note.Uri">
<VersionedLink rel="stylesheet" href="/css/public-preview.css"/>
</HeadContent>

View file

@ -51,14 +51,14 @@ public partial class NotePreview(
note.Renote?.GetPublicUriOrNull(config.Value) ??
throw new Exception("Note is remote but has no uri");
Context.Response.Redirect(target, permanent: true);
Redirect(target);
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);
Redirect(target);
return;
}

View file

@ -1,5 +1,4 @@
@page "/openapi"
@attribute [Route("/scalar")]
@using Microsoft.Extensions.Options
@using Swashbuckle.AspNetCore.SwaggerGen
@inject IOptions<SwaggerGenOptions> Options;
@ -18,7 +17,7 @@
<td>
<a href="/openapi/@(doc.Key).json">JSON</a> -
<a href="/swagger/index.html?urls.primaryName=@(doc.Value.Title)">SwaggerUI</a> -
<a href="/scalar/@(doc.Key)">ScalarUI</a>
<a href="/scalar/?api=@(doc.Key)">ScalarUI</a>
</td>
</tr>
}

View file

@ -35,13 +35,14 @@ else
<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">
<meta property="og:site_name" content="@_instanceName">
<meta property="og:title" content="@@@_user.Username">
@if (_user.Bio is { } bio)
{
<meta name="og:description" content="@bio">
<meta property="og:description" content="@bio">
}
<meta name="og:image" content="@_user.AvatarUrl">
<meta property="og:image" content="@_user.AvatarUrl">
<link rel="alternate" type="application/activity+json" href="@_user.Uri">
<VersionedLink rel="stylesheet" href="/css/public-preview.css"/>
</HeadContent>
}

View file

@ -54,7 +54,7 @@ public partial class UserPreview(
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);
Redirect(target);
return;
}

View file

@ -6,6 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": false,
"externalUrlConfiguration": true,
"commandLineArgs": "--migrate-and-start",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -7,8 +7,8 @@ ListenHost = localhost
;;ListenSocketPerms = 660
;; Caution: changing these settings after initial setup *will* break federation
WebDomain = shrimp.example.org
AccountDomain = example.org
WebDomain = localhost:3000
AccountDomain = localhost:3000
;; End of problematic settings block
;; Additional domains this instance allows API access from, separated by commas.
@ -28,6 +28,11 @@ CharacterLimit = 8192
;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation
AuthorizedFetch = true
;; Whether to validate incoming ActivityPub requests. Always enabled when AuthorizedFetch is enabled.
;; Disabling this improves performance during large request bursts, but prevents remote instances from fetching follower-only notes.
;; AP inbox delivery & AP inbox signature validation are unaffected by this option.
ValidateRequestSignatures = true
;; Whether to attach LD signatures to outgoing activities. Outgoing relayed activities get signed regardless of this option.
AttachLdSignatures = false
@ -179,7 +184,7 @@ ProxyRemoteMedia = true
[Storage:Local]
;; Path where media is stored at. Must be writable for the service user.
Path = /path/to/media/location
Path = /home/luke/Documents/shrimp
[Storage:ObjectStorage]
;;Endpoint = endpoint.example.org

View file

@ -757,6 +757,10 @@ details > :last-child {
margin-bottom: 0;
}
details summary {
width: 100%;
}
details[open] summary {
margin-bottom: 10px;
}

View file

@ -54,6 +54,10 @@ async function purgeUser(id, target) {
await confirm(target, () => callApiMethod(`/api/iceshrimp/moderation/users/${id}/purge`));
}
async function runCronTask(id, target) {
await confirm(target, () => callApiMethod(`/api/iceshrimp/admin/tasks/${id}/run`));
}
async function generateInvite() {
const res = await callApiMethod(`/api/iceshrimp/admin/invites/generate`);
const json = await res.json();

Some files were not shown because too many files have changed in this diff Show more