Compare commits

..

1 commit

Author SHA1 Message Date
Laura Hausmann
7b615f1924
wip 2025-01-09 13:13:50 +01:00
367 changed files with 3058 additions and 12520 deletions

View file

@ -19,10 +19,10 @@ jobs:
- name: Print environment info - name: Print environment info
run: dotnet --info run: dotnet --info
- name: Build release artifacts - name: Build release artifacts
run: make release-artifacts "ARCHIVE_BASENAME=$REPO" "ARCHIVE_VERSION=$RELEASE_VERSION" VERBOSE=true DEP_VULN_WERROR=true run: make release-artifacts "ARCHIVE_BASENAME=$REPO" "ARCHIVE_VERSION=$VERSION" VERBOSE=true DEP_VULN_WERROR=true
env: env:
REPO: ${{ github.event.repository.name }} REPO: ${{ github.event.repository.name }}
RELEASE_VERSION: ${{ github.ref_name }} VERSION: ${{ github.ref_name }}
- name: Upload artifacts - name: Upload artifacts
uses: actions/release-action@main uses: actions/release-action@main
with: with:
@ -37,8 +37,8 @@ jobs:
# We always want to tag :{version} and :pre, but only tag :latest for stable releases, and (temporarily) v2024.1-beta releases # We always want to tag :{version} and :pre, but only tag :latest for stable releases, and (temporarily) v2024.1-beta releases
TAGS="-t $REPO:$GITHUB_REF_NAME -t $REPO:pre" TAGS="-t $REPO:$GITHUB_REF_NAME -t $REPO:pre"
# The first section below can be safely removed once v2025.1 hits stable # The first section below can be safely removed once v2024.1 hits stable
if [[ "$GITHUB_REF_NAME" == "v2025.1-beta"* ]]; then if [[ "$GITHUB_REF_NAME" == "v2024.1-beta"* ]]; then
TAGS="$TAGS -t $REPO:latest" TAGS="$TAGS -t $REPO:latest"
elif [[ "$GITHUB_REF_NAME" == *"-beta"* ]] || [[ "$GITHUB_REF_NAME" == *"-pre"* ]]; then elif [[ "$GITHUB_REF_NAME" == *"-beta"* ]] || [[ "$GITHUB_REF_NAME" == *"-pre"* ]]; then
: :

View file

@ -2,11 +2,6 @@
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <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" /> <inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile> </profile>
</component> </component>

View file

@ -1,289 +1,3 @@
## 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.
### Blazor Frontend
- The compose dialog send button no longer gets stuck on "Sent!"
- The compose dialog can no longer be closed while files are uploading or the composed note is being created
- Alt text no longer overflows the avatar box when the avatar fails to load
- Firefox no longer displays the avatar alt text while the image is loading
- If an avatar fails to load, the user's identicon is loaded as a fallback image
- Avatars now take up the same amount of space while they're loading
- The contrast of poll results has been improved
- An error dialog is displayed when poll voting fails
- Quotes no longer have extraneous whitespace above their content
- A frontend crash related to early JSInterop use has been fixed
- Profile fields now get rendered with an improved layout which no longer breaks on overflow
### Backend
- Link verification now works with `rel="me"` links in the `<head>` section, and works with MFM/aliased links
- Voting on a poll using the Web API no longer fails
- A bug related to LDLocalizedString serialization has been resolved
### Mastodon client API
- `/api/v2/instance` now returns the streaming API URL in the correct format
### Miscellaneous
- Release builds no longer use compiled EF models, fixing API errors & frontend page load issues
- Docker builds are getting tagged as :latest again
### Attribution
This release was made possible by project contributors: Kopper, Laura Hausmann, Lilian & pancakes
## v2025.1-beta5.patch1
This is a hotfix release. It's identical to v2025.1-beta5, except for a bunch of bugfixes. Upgrading is strongly recommended for all server operators running v2025.1-beta5.
### Blazor frontend
- Notifications no longer get randomly duplicated
- The moderation page now works correctly on mobile
- Style issues related to icons have been resolved
- The account dropdown now get positioned correctly when the page scroll position is not at the top
### Backend
- Pure renote replies now get rejected no matter which method of creating them is used
- Security configuration changes now apply in real-time everywhere, resolving an issue where the frontend would not offer invite registration despite it being enabled
### Attribution
This release was made possible by project contributors: Laura Hausmann, Lilian & pancakes
## v2025.1-beta5
This release contains lots of new features & bug fixes. Upgrading is recommended for all server operators.
### Release notes
This release contains a **breaking change** - we now require PostgreSQL version 15 or higher. If you need assistance upgrading, please reach out to the [support chat](https://chat.iceshrimp.dev).
### Highlights
- The MFM parser has been completely rewritten, improving frontend performance by several orders of magnitude, as well as fixing countless bugs, slowdowns & edge cases.
- TOTP 2FA is now supported and can be configured in the user settings
- Instance rules can now be configured and displayed
- Links in user profile fields are now verified
- Full drive file management has been added
- Federated user pronouns have been added
- Remote media is now proxied by default
- The project and all in-house libraries now target .NET 9.0
### Blazor frontend
- Custom emoji in user bios, user fields, user display names, & note content warnings are now rendered correctly
- Display names & fields now only render their respective first line
- The note display now only breaks words break when necessary
- The font used on the frontend is now downloaded if it's not available on the client system
- The Iceshrimp.NET frontend can now be installed as a PWA (Offline support is not enabled yet)
- The frontend will automatically check for and notify about new versions
- Better emoji picker with categories and search support
- Follow button will no longer show up for your own profile
- User profiles now have badges that indicate if a user is following you, as well as badges for moderators, administrators, and automated accounts
- Notes by automated accounts are tagged as such
- Improved rendering of notifications
- Accounts that require follow approval are tagged appropriately
- Notes in the profile view can now be opened correctly
- The emoji picker now works correctly when composing a note
- Composing a reply no longer adds a mention for yourself
- The host part of local mentions is now hidden
- Alt text can now easily be viewed for note attachments
- More notification types are now supported, and feature appropriate icons and emoji
- A registration page has been added
- The login page has been reworked and now features an account selector for existing sessions
- TOTP 2FA enrollment and authentication are now possible
- Buttons that have a state now reflect their state better
- Default note visiblity is respected when composing new notes
- MfM rendering now supports many many more functions and should render most MfM art correctly (flip, font, x2/3/4, blur, rotate, crop, position, scale, fg, bg, fn, jelly, tada, jump, bounce, spin, shake, twitch, rainbow, fade, ruby, unixtime, center, small)
- All popover menus are now improved
- The attachment viewer now supports keyboard navigation and displays alt text
- Single character profiles can now be opened correctly
- When composing a note, attached files are now listed and have a preview
- Improved display of note reaction details
- User profile now has a menu for contextual actions
- Look of all buttons has been improved
- Full profile customization is now possible, including changing banners, profile pictures, tags, etc.
- The follow back button now renders correctly
- The note composer now has a preview of what your note will look like
- Note composer now features character count
- Posts can be submitted with ctrl/cmd + enter
- Virtual scroller was completely rewritten to be more performant
- Fetched note data is now cached
- You can now create rules that will be displayed on the registration page and the instances about page
- Support for setting profile avatar and banner alt text
- New better looking dialog system for prompts, notices, etc.
- Button to open/close all content warnings in a thread
- The cw button now shows how long the post behind the CW is
- Removed overscroll in places where it looks bad
- Added status indicator for notification and timeline streaming
- Refetch profile option for the profile page
- Drive management has been added, including folder support, upload, and deletion, and modification
- Added a dedicated pronoun field on the profile page
- Menus take up more of the screen on the mobile UI and are easier to navigate
- Management page for local and remote emoji (Upload, modification, cloning)
- Completely reworked default theme
- Style improvements to go with the new default theme
- Support for poll rendering and voting
- Improved loading spinners
- Menu to change accounts or log out
- Settings pages no longer exceed screen height unless needed
- Notification content is limited to a reasonable size
- Improved rendering of cw and reactions in indented notes
- Admin cookie persists unless you log out the admin account
- Fixed a crash in the attachment viewer on chrome
- Content warnings now correctly hide quotes
- Added indicator when attachments are uploading
- Disabled posting note while attachments are uploading or note is empty
- Blurred images are now easier to deblur
- Many z-index issues have been fixed
- Page title now reflects instance name and current page
### Razor (public preview, admin panel, queue dashboard, etc.)
- The admin dashboard now has a responsive navigation bar
- Constructor dependency injection is now used where applicable
- Static assets are now collected, compressed & fingerprinted at build time
- The favicon is now correctly set to the project logo
- The index page now displays the Iceshrimp project wordmark
- The page footer is now more responsive
- Emoji now have their name set as alt text
- The queue dashboard index page now has a "top delayed" section
- The page footer now shows a registration link when registrations are open or invite-only
- The generate invite button on the admin panel is now accessible to screen-reader users
- The federation management page of the admin panel now has a search box
- The admin panel now supports remote user management & user search
- Polls are now displayed in public preview
### Backend
- Fork information in the version string is now handled correctly
- Version information is now only computed once
- Failed user resolutions no longer break the follow list import process
- Command line output referencing help pages now uses shortlinks, to prevent link rot
- Note backfilling now uses a stack instead of a queue
- MIME type & file extension are now being set correctly for converted images
- Locally originating create activities can now be fetched by their URI
- User responses now contain any emoji used in their display name or bio
- A DbContext race condition in UserRenderer has been fixed, resolving transient concurrency errors
- The search query parser now supports the has:media query
- User publickeys now have any extra whitespace removed before being added to the database
- Instance staff endpoints have been added
- User lookup error messages are now more specific
- User profile responses now include user roles, as well as the IsBot, IsCat and IsLocked fields
- Uploading files with long unicode names now works correctly
- Lock statements now use lock objects for improved performance
- GeneratedRegex partial methods have been converted to partial properties
- All params methods have been converted to take `IEnumerable<T>` as parameters
- The `dotnet ef database update` command now works as expected with multiplexing enabled
- An alternative OpenAPI UI - Scalar - has been added (accessible under `/scalar` & `/openapi`)
- Unauthenticated federation endpoints now cache their outputs for a short duration, easing database load during request bursts
- Release builds now use compiled EF models, reducing startup time by ~500ms
- The startup duration is now logged to console
- Entity model configuration has been moved into the respective entity classes
- The OpenAPI schema is now only generated once
- Usages of the `ConsumesHybrid` attribute have been replaced with `FromHybrid`
- `BlazorSsrHandoffMiddleware` now uses reflection instead of modifying the response
- A new exception verbosity option `Debug` has been added
- The error page title now contain the status code
- Middleware is now invoked conditionally, improving performance, simplifying stack traces and allowing plugins to add middleware to the stack
- Services are now runtime-discoverable, greatly improving readability
- Scoped services with request-specific properties have been converted to singletons using `AsyncLocal<T>`
- Unneeded compressed assets are no longer generated during build, improving build times
- The solution file now has virtual folders for build assets & project root files
- Version & web manifest endpoints have been added to support frontend PWA features
- Exceptions in StreamingConnectionAggregate no longer crash the backend
- Note creates & updates now get delivered to the author of note being replied to even if they're not mentioned
- Note recipients now get deduplicated
- Instance info endpoints have been added
- Support for note context collections has been added
- Reaction notifications now contain more information about the received reaction
- Note inline media is now supported using the `$[media <uri>]` MFM tag
- Session management endpoints have been added
- Line endings now get canonicalized during note/user ingest/update for improved frontend performance
- Empty & whitespace alt text now gets treated as no alt text
- User profile responses now contain the public URL of the user
- Endpoints related to user avatar, banner & display name have been added
- The user settings endpoints now allow for configuring the `isBot`, `isCat` and `speakAsCat` properties
- HTML markup tags are now deserialized to their corresponding MFM tag equivalent, instead of using symbol tags
- The note resolution lock now uses the fetched object `@id` property as its key
- Note lookups are now authenticated with the requesting user & don't attempt to redirect to inaccessible notes
- The batch emoji import endpoint is now excluded from the request size limit
- The emoji management endpoints now require moderator permissions instead of administrator ones
- Quotes without text no longer federate incorrectly to quote-aware implementations
- Notes from implementations sending HTML line breaks not followed by newline characters now get parsed correctly
- Quote blocks now aren't surrounded by extraneous line breaks
- The default renote visibility user setting can no longer be set to `specified`
- The user resolver now falls back to building the username/host tuple from the actor URI when it's not contained in the WebFinger response
- Reply backfill jobs now don't get scheduled for followers-only posts when authenticated user backfill is disabled
- The `w3id/identity-v1` JSON-LD context definition is now preloaded
- Outgoing unixtime MFM nodes now get converted to human-readable HTML
- Nodeinfo responses now return the configured instance name, description & admin contact email
- Support for backfilling user profiles has been added
- The exposed outbox collection is now functional
- Transient LD signature validation errors due to use of the wrong media type parser have been resolved
- Note refetches no longer wrongly mark notes as edited
- Fetching the relay actor now bypasses authorized fetch
- A startup error is now raised if the `ASPNETCORE_TEMP` is not writable
- Requests sent by suspended remote users are now rejected early during authorized fetch / inbox validation
- The unix socket permissions are now customizable
- The rewrite policy `CollapseWhitespace` was added
- Single emoji can now be given a name before uploading them
- The search query parser has been rewritten in C#, dropping the `FSharp.Core` dependency
- The UserResolver acct/uri mismatch message has been significantly improved
- Processed images now federate with the correct content type
- Negated search parameters now work with `match:words`
- The instance info response now contains the note length limit
- HTTP proxy configurations are now supported
- Hashtags are now handled more correctly, improving federation compatibility
- The home timeline heuristic now gets updated automatically for recently active users
- Hashtags now get the correct class set in when serialized to HTML
- Notes with `publishedAt`/`updatedAt` set to timestamps from the future will now get clamped to the current time
- The `Result<T>` helper type is now provided by `Iceshrimp.Utils.Common`
- User migration events now also transfer incoming and outgoing blocks to the new account
- The emoji table now correctly enforces unique names for local emoji (duplicates get fixed automatically, the newest entry is preserved)
- Like activities with `content` property now get correctly processed as reactions
- Deletion failures during media fixup are now ignored
- Avatar & banner alt text now federates bidirectionally, is returned in corresponding API responses & can be set
- `ExpressionExtensions` and `QueryableExtensions.AsChunkedAsyncEnumerable<T>` are now provided by `Iceshrimp.EntityFrameworkCore.Extensions`
- The license of assets included in the repository has been clarified to be `CC BY-SA 4.0`
- A refetch user endpoint has been added
- Remote emoji management endpoints have been added
- Polls can now be created, retrieved & voted on via the Web API
- Emoji media types now get populated & federated as appropriate
- Emoji entity names now get wrapped in colons for federation, resolving an interoperability issue with NodeBB
### Akkoma client API
- Local-only visibility is now respected
### Mastodon client API
- Admin scopes are now considered valid, allowing clients who request these to authenticate
- The confusing status context logic has been removed, matching -js & web api behavior
- The specified WebSocket protocol is now echoed back for streaming connections, fixing compatibility issues with some clients
- Attachment metadata is now returned when available
- Filter matches are now deduplicated, preventing duplicate filter match mesages
- The "reply inaccessible" marker now gets moved into the content warning (if any) and is more consistent
- Blockquotes now get rendered correctly when `supportsHtmlFormatting` is disabled
- Multiple accounts can now be fetched in one go via `/api/v1/accounts`
- Multiple statuses can now be fetched in one go via `/api/v1/statuses`
- The status response now correctly lists all hashtags
- The `/api/v1/accounts/{id}/statuses` endpoint no longer requires authentication, matching Mastodon's behavior
### Unit tests
- Tests now take less time to run due to higher parallelization
- The testing platform has been changed from `VSTest` to `Microsoft.Testing.Platform`
- The assertions library has been changed from `FluentAssertions` to `Iceshrimp.Assertions` due to a license change
### Build tasks
- Compressed razor class library assets now have corresponding static asset selector routes
- Pre-fingerprinted static assets collected from razor class libraries now get mapped correctly
### Miscellaneous
- The README has been updated
- The Dockerfile has been updated
- The security policy has been updated
- The OpenAPI documentation has been improved
### Attribution
This release was made possible by project contributors: blueb, Jeder, Kopper, Laura Hausmann, Lilian, notfire, pancakes & Tamara Schmitz
## v2024.1-beta4.security2 ## v2024.1-beta4.security2
This is a security hotfix release. It's identical to v2024.1-beta4.security1, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators. This is a security hotfix release. It's identical to v2024.1-beta4.security1, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.

View file

@ -26,8 +26,8 @@
<!-- Version metadata --> <!-- Version metadata -->
<PropertyGroup> <PropertyGroup>
<VersionPrefix>2025.1</VersionPrefix> <VersionPrefix>2024.1</VersionPrefix>
<VersionSuffix>beta5.patch2.security1</VersionSuffix> <VersionSuffix>beta4.security2</VersionSuffix>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -22,7 +22,7 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
+ See [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs#L16-L24) and [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts) to see all preloaded LD contexts we ship. + See [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs#L16-L24) and [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts) to see all preloaded LD contexts we ship.
- Outgoing activities are compacted against our well-known LD context ([iceshrimp.json](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts/iceshrimp.json)). - Outgoing activities are compacted against our well-known LD context ([iceshrimp.json](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts/iceshrimp.json)).
+ For compatibility with implementors that are not doing full LD processing, we force some attributes to be an array: + For compatibility with implementors that are not doing full LD processing, we force some attributes to be an array:
* `tag`, `attachment`, `to`, `cc`, `bcc`, `bto`, `alsoKnownAs` (all in the `https://www.w3.org/ns/activitystreams` namespace) * `tag`, `attachment`, `to`, `cc`, `bcc`, `bto` (all in the `https://www.w3.org/ns/activitystreams` namespace)
+ For the same reason, we forcibly keep `https://www.w3.org/ns/activitystreams#Public` as the full IRI, instead of compacting it to `as:Public`. + For the same reason, we forcibly keep `https://www.w3.org/ns/activitystreams#Public` as the full IRI, instead of compacting it to `as:Public`.
+ We trim unused inline properties from the context. For technical reasons, unused namespace aliases are currently not trimmed, but this is subject to change. + We trim unused inline properties from the context. For technical reasons, unused namespace aliases are currently not trimmed, but this is subject to change.
- [WebFinger](https://webfinger.net/) - [WebFinger](https://webfinger.net/)
@ -34,14 +34,12 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
- We support WebFinger over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing). - We support WebFinger over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing).
+ However, we do not ask for `xrd+xml` in our `Accept` header for outgoing WebFinger requests due to [compatibility issues](https://github.com/friendica/friendica/issues/14370) with Friendica. + However, we do not ask for `xrd+xml` in our `Accept` header for outgoing WebFinger requests due to [compatibility issues](https://github.com/friendica/friendica/issues/14370) with Friendica.
+ Responses **MUST** have their `Content-Type` set to `application/jrd+json`, `application/xrd+xml`, `application/json`, or `application/xml`. + Responses **MUST** have their `Content-Type` set to `application/jrd+json`, `application/xrd+xml`, `application/json`, or `application/xml`.
+ Responses **MUST** contain a link with the attributes `rel='self'` and `type='application/activity+json'`.
* `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` is treated interchangably with `application/activity+json`.
+ Responses **SHOULD** contain the `acct:` URI of the actor in the `subject` or `aliases` fields.
* If no such URI is found, we attempt to fetch the actor via ActivityPub and assemble the link from the actor's `preferredUsername` and `@id` host.
- We support host-meta over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing). - We support host-meta over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing).
+ The json representation is also accessible under `/.well-known/host-meta.json`. + The json representation is also accessible under `/.well-known/host-meta.json`.
+ Implementors **SHOULD** advertise the WebFinger `Content-Type` in the `type` attribute of the WebFinger template in the host-meta response. + Implementors **SHOULD** advertise the WebFinger `Content-Type` in the `type` attribute of the WebFinger template in the host-meta response.
* However, since major implementors either omit the attribute, or incorrectly advertise `jrd+json` as `xrd+xml`, we presently ignore this property. * However, since major implementors either omit the attribute, or incorrectly advertise `jrd+json` as `xrd+xml`, we presently ignore this property.
+ Implementors **MUST** return a link with the attributes `rel='self'` and `type='application/activity+json'` in the response.
* `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` is treated interchangably with `application/activity+json`.
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) - [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
- Incoming activities sent to the shared inbox or actor inbox **MUST** carry a valid HTTP signature, unless LD Signatures are explicitly enabled in the configuration, and the activity carries a valid LD signature. - Incoming activities sent to the shared inbox or actor inbox **MUST** carry a valid HTTP signature, unless LD Signatures are explicitly enabled in the configuration, and the activity carries a valid LD signature.
- Incoming federation requests **MUST** carry a valid HTTP signature, unless authorized fetch is explicitly disabled in the configuration. - Incoming federation requests **MUST** carry a valid HTTP signature, unless authorized fetch is explicitly disabled in the configuration.

View file

@ -10,11 +10,9 @@
[ [
new("/admin", "Overview", Icons.ChartLine), // spacer for alignment new("/admin", "Overview", Icons.ChartLine), // spacer for alignment
new("/admin/metadata", "Instance metadata", Icons.Info), new("/admin/metadata", "Instance metadata", Icons.Info),
new("/admin/rules", "Rules", Icons.Scales),
new("/admin/users", "User management", Icons.Users), new("/admin/users", "User management", Icons.Users),
new("/admin/federation", "Federation control", Icons.Graph), new("/admin/federation", "Federation control", Icons.Graph),
new("/admin/relays", "Relays", Icons.FastForward), new("/admin/relays", "Relays", Icons.FastForward),
new("/admin/tasks", "Cron tasks", Icons.Timer),
new("/admin/plugins", "Plugins", Icons.Plug) new("/admin/plugins", "Plugins", Icons.Plug)
]; ];

View file

@ -15,12 +15,13 @@
} }
@if (OverflowsLg) @if (OverflowsLg)
{ {
var offset = Links.Count - MaxItemsLg;
<li class="dropdown"> <li class="dropdown">
<a class="dropdown-button" tabindex="0"> <a class="dropdown-button" tabindex="0">
<Icon Name="Icons.DotsThree" Size="20pt"/> <Icon Name="Icons.DotsThree" Size="20pt"/>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@foreach (var link in Links[MaxItemsLg..]) @foreach (var link in Links[offset..])
{ {
<li> <li>
<NavBarLink Link="link"/> <NavBarLink Link="link"/>
@ -28,10 +29,7 @@
} }
@if (Right is { Count: > 0 }) @if (Right is { Count: > 0 })
{ {
if (MaxItemsLg != Links.Count) <li class="dropdown-spacer"></li>
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right) @foreach (var link in Right)
{ {
<li> <li>
@ -73,12 +71,13 @@
} }
@if (OverflowsMd) @if (OverflowsMd)
{ {
var offset = Links.Count - MaxItemsMd;
<li class="dropdown"> <li class="dropdown">
<a class="dropdown-button" tabindex="0"> <a class="dropdown-button" tabindex="0">
<Icon Name="Icons.DotsThree" Size="20pt"/> <Icon Name="Icons.DotsThree" Size="20pt"/>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@foreach (var link in Links[MaxItemsMd..]) @foreach (var link in Links[offset..])
{ {
<li> <li>
<NavBarLink Link="link"/> <NavBarLink Link="link"/>
@ -86,10 +85,7 @@
} }
@if (Right is { Count: > 0 }) @if (Right is { Count: > 0 })
{ {
if (MaxItemsMd != Links.Count) <li class="dropdown-spacer"></li>
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right) @foreach (var link in Right)
{ {
<li> <li>

View file

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

View file

@ -28,33 +28,29 @@ public class NoteRenderer(
var emoji = await GetEmojiAsync(allNotes); var emoji = await GetEmojiAsync(allNotes);
var users = await GetUsersAsync(allNotes); var users = await GetUsersAsync(allNotes);
var attachments = await GetAttachmentsAsync(allNotes); var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes);
return Render(note, users, mentions, emoji, attachments, polls); return await RenderAsync(note, users, mentions, emoji, attachments);
} }
private PreviewNote Render( private async Task<PreviewNote> RenderAsync(
Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions, Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions,
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments, Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments
Dictionary<string, PreviewPoll> polls
) )
{ {
var renderedText = mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span", attachments[note.Id]); var renderedText = await mfm.RenderAsync(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 inlineMediaUrls = renderedText?.InlineMedia.Select(m => m.Src).ToArray() ?? [];
var res = new PreviewNote var res = new PreviewNote
{ {
User = users.First(p => p.Id == note.User.Id), User = users.First(p => p.Id == note.User.Id),
Text = renderedText?.Html, Text = renderedText?.Html,
Cw = note.Cw, Cw = note.Cw,
RawText = note.Text, RawText = note.Text,
Uri = note.Uri ?? note.GetPublicUri(instance.Value), QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false, QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(), Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
Poll = polls.GetValueOrDefault(note.Id), CreatedAt = note.CreatedAt.ToDisplayStringTz(),
CreatedAt = note.CreatedAt.ToDisplayStringTz(), UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
}; };
return res; return res;
@ -116,24 +112,6 @@ public class NoteRenderer(
.ToList()); .ToList());
} }
private async Task<Dictionary<string, PreviewPoll>> GetPollsAsync(List<Note> notes)
{
if (notes is []) return new Dictionary<string, PreviewPoll>();
var ids = notes.Select(p => p.Id).ToList();
var polls = await db.Polls
.Where(p => ids.Contains(p.NoteId))
.ToListAsync();
return polls.ToDictionary(p => p.NoteId,
p => new PreviewPoll
{
ExpiresAt = p.ExpiresAt,
Multiple = p.Multiple,
Choices = p.Choices.Zip(p.Votes).Select(c => (c.First, c.Second)).ToList(),
VotersCount = p.VotersCount
});
}
public async Task<List<PreviewNote>> RenderManyAsync(List<Note> notes) public async Task<List<PreviewNote>> RenderManyAsync(List<Note> notes)
{ {
if (notes is []) return []; if (notes is []) return [];
@ -142,7 +120,8 @@ public class NoteRenderer(
var mentions = await GetMentionsAsync(allNotes); var mentions = await GetMentionsAsync(allNotes);
var emoji = await GetEmojiAsync(allNotes); var emoji = await GetEmojiAsync(allNotes);
var attachments = await GetAttachmentsAsync(allNotes); var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes); return await notes.Select(p => RenderAsync(p, users, mentions, emoji, attachments))
return notes.Select(p => Render(p, users, mentions, emoji, attachments, polls)).ToList(); .AwaitAllAsync()
.ToListAsync();
} }
} }

View file

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

View file

@ -8,11 +8,9 @@ public class PreviewNote
public required string? RawText; public required string? RawText;
public required MarkupString? Text; public required MarkupString? Text;
public required string? Cw; public required string? Cw;
public required string? Uri;
public required string? QuoteUrl; public required string? QuoteUrl;
public required bool QuoteInaccessible; public required bool QuoteInaccessible;
public required List<PreviewAttachment>? Attachments; public required List<PreviewAttachment>? Attachments;
public required PreviewPoll? Poll;
public required string CreatedAt; public required string CreatedAt;
public required string? UpdatedAt; public required string? UpdatedAt;
} }
@ -25,11 +23,3 @@ public class PreviewAttachment
public required string? Alt; public required string? Alt;
public required bool Sensitive; public required bool Sensitive;
} }
public class PreviewPoll
{
public required DateTime? ExpiresAt { get; set; }
public required bool Multiple { get; set; }
public required List<(string Value, int Votes)> Choices { get; set; }
public required int? VotersCount { get; set; }
}

View file

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

View file

@ -333,8 +333,8 @@ public class ActivityPubController(
var rendered = new ASEmoji var rendered = new ASEmoji
{ {
Id = emoji.GetPublicUri(config.Value), Id = emoji.GetPublicUri(config.Value),
Name = $":{emoji.Name}:", Name = emoji.Name,
Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl), MediaType = emoji.Type } Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl) }
}; };
return LdHelpers.Compact(rendered); return LdHelpers.Compact(rendered);

View file

@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
@ -13,7 +12,6 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@ -170,26 +168,6 @@ public class AccountController(
return await VerifyUserCredentials(); return await VerifyUserCredentials();
} }
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Forbidden)]
public async Task<IEnumerable<AccountEntity>> GetManyUsers(
[FromQuery(Name = "id")] [MaxLength(40)]
List<string> ids
)
{
var localUser = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var query = db.Users.IncludeCommonProperties().Where(p => ids.Contains(p.Id));
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && localUser == null)
query = query.Where(p => p.IsLocalUser);
return await userRenderer.RenderManyAsync(await query.ToArrayAsync(), localUser);
}
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
@ -199,8 +177,8 @@ public class AccountController(
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null) if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null) if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
@ -222,8 +200,8 @@ public class AccountController(
var followee = await db.Users.IncludeCommonProperties() var followee = await db.Users.IncludeCommonProperties()
.Where(p => p.Id == id) .Where(p => p.Id == id)
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true)) if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true))
throw GracefulException.Forbidden("This action is not allowed"); throw GracefulException.Forbidden("This action is not allowed");
@ -255,8 +233,8 @@ public class AccountController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnfollowUserAsync(user, followee); await userSvc.UnfollowUserAsync(user, followee);
return RenderRelationship(followee); return RenderRelationship(followee);
@ -276,8 +254,8 @@ public class AccountController(
.Where(p => p.FolloweeId == user.Id && p.FollowerId == id) .Where(p => p.FolloweeId == user.Id && p.FollowerId == id)
.Select(p => p.Follower) .Select(p => p.Follower)
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.RemoveFromFollowersAsync(user, follower); await userSvc.RemoveFromFollowersAsync(user, follower);
return RenderRelationship(follower); return RenderRelationship(follower);
@ -297,8 +275,8 @@ public class AccountController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
//TODO: handle notifications parameter //TODO: handle notifications parameter
DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration); DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration);
@ -320,8 +298,8 @@ public class AccountController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnmuteUserAsync(user, mutee); await userSvc.UnmuteUserAsync(user, mutee);
return RenderRelationship(mutee); return RenderRelationship(mutee);
@ -341,8 +319,8 @@ public class AccountController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.BlockUserAsync(user, blockee); await userSvc.BlockUserAsync(user, blockee);
return RenderRelationship(blockee); return RenderRelationship(blockee);
@ -362,8 +340,8 @@ public class AccountController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnblockUserAsync(user, blockee); await userSvc.UnblockUserAsync(user, blockee);
return RenderRelationship(blockee); return RenderRelationship(blockee);
@ -394,10 +372,7 @@ public class AccountController(
string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query
) )
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUserOrFail();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound(); var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound();
return await db.Notes return await db.Notes
@ -424,8 +399,8 @@ public class AccountController(
var account = await db.Users var account = await db.Users
.Include(p => p.UserProfile) .Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id) .FirstOrDefaultAsync(p => p.Id == id) ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null) if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
@ -460,8 +435,8 @@ public class AccountController(
var account = await db.Users var account = await db.Users
.Include(p => p.UserProfile) .Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id) .FirstOrDefaultAsync(p => p.Id == id) ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null) if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
@ -490,8 +465,8 @@ public class AccountController(
{ {
_ = await db.Users _ = await db.Users
.Include(p => p.UserProfile) .Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id) .FirstOrDefaultAsync(p => p.Id == id) ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return []; return [];
} }
@ -615,8 +590,8 @@ public class AccountController(
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u)) .Select(u => RenderRelationship(u))
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
} }
[HttpPost("/api/v1/follow_requests/{id}/reject")] [HttpPost("/api/v1/follow_requests/{id}/reject")]
@ -638,8 +613,8 @@ public class AccountController(
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u)) .Select(u => RenderRelationship(u))
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
} }
[HttpGet("lookup")] [HttpGet("lookup")]

View file

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

View file

@ -1,7 +1,6 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
@ -9,7 +8,6 @@ using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@ -159,44 +157,4 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
return new object(); 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

@ -46,8 +46,7 @@ public class InstanceController(
return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact) return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact)
{ {
Stats = new InstanceStats(userCount, noteCount, instanceCount), Stats = new InstanceStats(userCount, noteCount, instanceCount),
Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() }, Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() }
Rules = await GetRules()
}; };
} }
@ -67,8 +66,7 @@ public class InstanceController(
return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact) return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact)
{ {
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } }, Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } }
Rules = await GetRules()
}; };
} }
@ -89,17 +87,6 @@ public class InstanceController(
.ToListAsync(); .ToListAsync();
} }
[HttpGet("/api/v1/instance/rules")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<RuleEntity>> GetRules()
{
return await db.Rules
.OrderBy(p => p.Order)
.ThenBy(p => p.Id)
.Select(p => new RuleEntity { Id = p.Id, Text = p.Text, Hint = p.Description })
.ToListAsync();
}
[HttpGet("/api/v1/instance/translation_languages")] [HttpGet("/api/v1/instance/translation_languages")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
public Dictionary<string, IEnumerable<string>> GetTranslationLanguages() => new(); public Dictionary<string, IEnumerable<string>> GetTranslationLanguages() => new();

View file

@ -1,29 +0,0 @@
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,8 +20,7 @@ public class NoteRenderer(
MfmConverter mfmConverter, MfmConverter mfmConverter,
DatabaseContext db, DatabaseContext db,
EmojiService emojiSvc, EmojiService emojiSvc,
AttachmentRenderer attachmentRenderer, AttachmentRenderer attachmentRenderer
FlagService flags
) : IScopedService ) : IScopedService
{ {
private static readonly FilterResultEntity InaccessibleFilter = new() private static readonly FilterResultEntity InaccessibleFilter = new()
@ -90,13 +89,6 @@ public class NoteRenderer(
? await GetReactionsAsync([note], user) ? await GetReactionsAsync([note], user)
: [..data.Reactions.Where(p => p.NoteId == note.Id)]; : [..data.Reactions.Where(p => p.NoteId == note.Id)];
var tags = note.Tags.Select(tag => new StatusTags
{
Name = tag,
Url = $"https://{config.Value.WebDomain}/tags/{tag}"
})
.ToList();
var mentionedUsers = mentions.Select(p => new Note.MentionedUser var mentionedUsers = mentions.Select(p => new Note.MentionedUser
{ {
Host = p.Host ?? config.Value.AccountDomain, Host = p.Host ?? config.Value.AccountDomain,
@ -153,8 +145,8 @@ public class NoteRenderer(
{ {
if (text != null || quoteUri != null || quoteInaccessible || replyInaccessible) if (text != null || quoteUri != null || quoteInaccessible || replyInaccessible)
{ {
(content, inlineMedia) = mfmConverter.ToHtml(text ?? "", mentionedUsers, note.UserHost, quoteUri, (content, inlineMedia) = await mfmConverter.ToHtmlAsync(text ?? "", mentionedUsers, note.UserHost, quoteUri,
quoteInaccessible, replyInaccessible, media: inlineMedia); quoteInaccessible, replyInaccessible, media: inlineMedia);
attachments.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url))); attachments.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
} }
@ -171,20 +163,6 @@ public class NoteRenderer(
? (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.Id == note.Id) ? (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null; : 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 var res = new StatusEntity
{ {
Id = note.Id, Id = note.Id,
@ -209,7 +187,7 @@ public class NoteRenderer(
IsMuted = muted, IsMuted = muted,
IsSensitive = sensitive, IsSensitive = sensitive,
ContentWarning = cw ?? "", ContentWarning = cw ?? "",
Visibility = visibility, Visibility = StatusEntity.EncodeVisibility(note.Visibility),
Content = content, Content = content,
Text = text, Text = text,
Mentions = mentions, Mentions = mentions,
@ -218,9 +196,8 @@ public class NoteRenderer(
Emojis = noteEmoji, Emojis = noteEmoji,
Poll = poll, Poll = poll,
Reactions = reactions, Reactions = reactions,
Tags = tags,
Filtered = filterResult, Filtered = filterResult,
Pleroma = pleromaExtensions Pleroma = new PleromaStatusExtensions { Reactions = reactions, ConversationId = note.ThreadId }
}; };
return res; return res;
@ -258,7 +235,7 @@ public class NoteRenderer(
_ => MfmInlineMedia.MediaType.Other _ => MfmInlineMedia.MediaType.Other
}, p.RemoteUrl ?? p.Url, p.Description)).ToList(); }, p.RemoteUrl ?? p.Url, p.Description)).ToList();
(var content, inlineMedia) = mfmConverter.ToHtml(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia); (var content, inlineMedia) = await mfmConverter.ToHtmlAsync(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia);
files.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url))); files.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
var entry = new StatusEdit var entry = new StatusEdit

View file

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

View file

@ -1,11 +1,9 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -15,61 +13,38 @@ public class UserRenderer(
IOptions<Config.InstanceSection> config, IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security, IOptionsSnapshot<Config.SecuritySection> security,
MfmConverter mfmConverter, MfmConverter mfmConverter,
DatabaseContext db, DatabaseContext db
FlagService flags
) : IScopedService ) : IScopedService
{ {
private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png"; private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png";
public Task<AccountEntity> RenderAsync(User user, UserProfile? profile, User? localUser, bool source = false) public async Task<AccountEntity> RenderAsync(
=> RenderAsync(user, profile, localUser, null, source); User user, UserProfile? profile, User? localUser, IEnumerable<EmojiEntity>? emoji = null, bool source = false
private async Task<AccountEntity> RenderAsync(
User user, UserProfile? profile, User? localUser, UserRendererDto? data = null, bool source = false
) )
{ {
var acct = user.Username; var acct = user.Username;
if (user.IsRemoteUser) if (user.IsRemoteUser)
acct += $"@{user.Host}"; acct += $"@{user.Host}";
var profileEmoji = data?.Emoji.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([user]); var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([user]);
var mentions = profile?.Mentions ?? []; var mentions = profile?.Mentions ?? [];
var fields = profile?.Fields var fields = profile != null
.Select(p => new Field ? await profile.Fields
{ .Select(async p => new Field
Name = p.Name, {
Value = (mfmConverter.ToHtml(p.Value, mentions, user.Host)).Html, Name = p.Name,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value Value = (await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host)).Html,
? DateTime.Now.ToStringIso8601Like() VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
: null ? DateTime.Now.ToStringIso8601Like()
}); : null
})
.AwaitAllAsync()
: null;
var fieldsSource = source var fieldsSource = source
? profile?.Fields.Select(p => new Field { Name = p.Name, Value = p.Value }).ToList() ?? [] ? profile?.Fields.Select(p => new Field { Name = p.Name, Value = p.Value }).ToList() ?? []
: []; : [];
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 var res = new AccountEntity
{ {
Id = user.Id, Id = user.Id,
@ -80,46 +55,20 @@ public class UserRenderer(
FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}", FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}",
IsLocked = user.IsLocked, IsLocked = user.IsLocked,
CreatedAt = user.CreatedAt.ToStringIso8601Like(), CreatedAt = user.CreatedAt.ToStringIso8601Like(),
LastStatusAt = user.LastNoteAt?.ToStringIso8601Like(),
FollowersCount = user.FollowersCount, FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount, FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount, StatusesCount = user.NotesCount,
Note = mfmConverter.ToHtml(profile?.Description ?? "", mentions, user.Host).Html, Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html,
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value), Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value), Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO
AvatarDescription = avatarAlt ?? "",
HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent, HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent,
HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO
HeaderDescription = bannerAlt ?? "", MovedToAccount = null, //TODO
MovedToAccount = null, //TODO
IsBot = user.IsBot, IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable, IsDiscoverable = user.IsExplorable,
Fields = fields?.ToList() ?? [], 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 if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
@ -138,9 +87,8 @@ public class UserRenderer(
Fields = fieldsSource, Fields = fieldsSource,
Language = "", Language = "",
Note = profile?.Description ?? "", Note = profile?.Description ?? "",
Privacy = Privacy = StatusEntity.EncodeVisibility(user.UserSettings?.DefaultNoteVisibility ??
StatusEntity.EncodeVisibility(user.UserSettings?.DefaultNoteVisibility Note.NoteVisibility.Public),
?? Note.NoteVisibility.Public),
Sensitive = false, Sensitive = false,
FollowRequestCount = await db.FollowRequests.CountAsync(p => p.Followee == user) FollowRequestCount = await db.FollowRequests.CountAsync(p => p.Followee == user)
}; };
@ -168,55 +116,16 @@ public class UserRenderer(
.ToListAsync(); .ToListAsync();
} }
private async Task<Dictionary<string, string?>> GetAvatarAltAsync(IEnumerable<User> users)
{
var ids = users.Select(p => p.Id).ToList();
return await db.Users
.Where(p => ids.Contains(p.Id))
.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();
return await db.Users
.Where(p => ids.Contains(p.Id))
.Include(p => p.Banner)
.ToDictionaryAsync(p => p.Id, p => p.Banner?.Comment);
}
public async Task<AccountEntity> RenderAsync(User user, User? localUser, List<EmojiEntity>? emoji = null) public async Task<AccountEntity> RenderAsync(User user, User? localUser, List<EmojiEntity>? emoji = null)
{ {
var data = new UserRendererDto return await RenderAsync(user, user.UserProfile, localUser, emoji);
{
Emoji = emoji ?? await GetEmojiAsync([user]),
AvatarAlt = await GetAvatarAltAsync([user]),
BannerAlt = await GetBannerAltAsync([user])
};
return await RenderAsync(user, user.UserProfile, localUser, data);
} }
public async Task<IEnumerable<AccountEntity>> RenderManyAsync(IEnumerable<User> users, User? localUser) public async Task<IEnumerable<AccountEntity>> RenderManyAsync(IEnumerable<User> users, User? localUser)
{ {
var userList = users.ToList(); var userList = users.ToList();
if (userList.Count == 0) return []; if (userList.Count == 0) return [];
var emoji = await GetEmojiAsync(userList);
var data = new UserRendererDto return await userList.Select(p => RenderAsync(p, localUser, emoji)).AwaitAllAsync();
{
Emoji = await GetEmojiAsync(userList),
AvatarAlt = await GetAvatarAltAsync(userList),
BannerAlt = await GetBannerAltAsync(userList)
};
return await userList.Select(p => RenderAsync(p, p.UserProfile, localUser, data)).AwaitAllAsync();
}
private class UserRendererDto
{
public required List<EmojiEntity> Emoji;
public required Dictionary<string, string?> AvatarAlt;
public required Dictionary<string, string?> BannerAlt;
} }
} }

View file

@ -1,63 +0,0 @@
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,40 +1,33 @@
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class AccountEntity : IIdentifiable public class AccountEntity : IEntity
{ {
[J("username")] public required string Username { get; set; } [J("username")] public required string Username { get; set; }
[J("acct")] public required string Acct { get; set; } [J("acct")] public required string Acct { get; set; }
[J("fqn")] public required string FullyQualifiedName { get; set; } [J("fqn")] public required string FullyQualifiedName { get; set; }
[J("display_name")] public required string DisplayName { get; set; } [J("display_name")] public required string DisplayName { get; set; }
[J("locked")] public required bool IsLocked { get; set; } [J("locked")] public required bool IsLocked { get; set; }
[J("created_at")] public required string CreatedAt { get; set; } [J("created_at")] public required string CreatedAt { get; set; }
[J("followers_count")] public required long FollowersCount { get; set; } [J("followers_count")] public required long FollowersCount { get; set; }
[J("following_count")] public required long FollowingCount { get; set; } [J("following_count")] public required long FollowingCount { get; set; }
[J("statuses_count")] public required long StatusesCount { get; set; } [J("statuses_count")] public required long StatusesCount { get; set; }
[J("note")] public required string Note { get; set; } [J("note")] public required string Note { get; set; }
[J("url")] public required string Url { get; set; } [J("url")] public required string Url { get; set; }
[J("uri")] public required string Uri { get; set; } [J("uri")] public required string Uri { get; set; }
[J("avatar")] public required string AvatarUrl { get; set; } [J("avatar")] public required string AvatarUrl { get; set; }
[J("avatar_static")] public required string AvatarStaticUrl { get; set; } [J("avatar_static")] public required string AvatarStaticUrl { get; set; }
[J("header")] public required string HeaderUrl { get; set; } [J("header")] public required string HeaderUrl { get; set; }
[J("header_static")] public required string HeaderStaticUrl { get; set; } [J("header_static")] public required string HeaderStaticUrl { get; set; }
[J("moved")] public required AccountEntity? MovedToAccount { get; set; } [J("moved")] public required AccountEntity? MovedToAccount { get; set; }
[J("bot")] public required bool IsBot { get; set; } [J("bot")] public required bool IsBot { get; set; }
[J("discoverable")] public required bool IsDiscoverable { get; set; } [J("discoverable")] public required bool IsDiscoverable { get; set; }
[J("fields")] public required List<Field> Fields { get; set; } [J("fields")] public required List<Field> Fields { get; set; }
[J("source")] public AccountSource? Source { get; set; } [J("source")] public AccountSource? Source { get; set; }
[J("emojis")] public required List<EmojiEntity> Emoji { get; set; } [J("emojis")] public required List<EmojiEntity> Emoji { get; set; }
[J("id")] public required string Id { 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; }
} }
public class Field public class Field

View file

@ -1,9 +1,9 @@
using Iceshrimp.Shared.Helpers; using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class ConversationEntity : IIdentifiable public class ConversationEntity : IEntity
{ {
[J("unread")] public required bool Unread { get; set; } [J("unread")] public required bool Unread { get; set; }
[J("accounts")] public required List<AccountEntity> Accounts { get; set; } [J("accounts")] public required List<AccountEntity> Accounts { get; set; }

View file

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

View file

@ -1,9 +1,9 @@
using Iceshrimp.Shared.Helpers; using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class PollEntity : IIdentifiable public class PollEntity : IEntity
{ {
[J("expires_at")] public required string? ExpiresAt { get; set; } [J("expires_at")] public required string? ExpiresAt { get; set; }
[J("expired")] public required bool Expired { get; set; } [J("expired")] public required bool Expired { get; set; }

View file

@ -1,12 +0,0 @@
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

@ -1,11 +1,11 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Iceshrimp.Shared.Helpers; using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class RelationshipEntity : IIdentifiable public class RelationshipEntity : IEntity
{ {
[J("following")] public required bool Following { get; set; } [J("following")] public required bool Following { get; set; }
[J("followed_by")] public required bool FollowedBy { get; set; } [J("followed_by")] public required bool FollowedBy { get; set; }

View file

@ -1,17 +0,0 @@
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

@ -1,11 +0,0 @@
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class RuleEntity : IIdentifiable
{
[J("id")] public required string Id { get; set; }
[J("text")] public required string Text { get; set; }
[J("hint")] public required string? Hint { get; set; }
}

View file

@ -1,14 +1,14 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class StatusEntity : IIdentifiable, ICloneable public class StatusEntity : IEntity, ICloneable
{ {
[JI] public string? MastoReplyUserId; [JI] public string? MastoReplyUserId;
[J("text")] public required string? Text { get; set; } [J("text")] public required string? Text { get; set; }
@ -46,18 +46,17 @@ public class StatusEntity : IIdentifiable, ICloneable
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; } [J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; } [J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
[J("reactions")] public required List<ReactionEntity> Reactions { get; set; } [J("reactions")] public required List<ReactionEntity> Reactions { get; set; }
[J("tags")] public required List<StatusTags> Tags { get; set; }
[J("card")] public object? Card => null; //FIXME [J("tags")] public object[] Tags => []; //FIXME
[J("application")] public object? Application => null; //FIXME [J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("language")] public string? Language => null; //FIXME [J("language")] public string? Language => null; //FIXME
public object Clone() => MemberwiseClone(); public object Clone() => MemberwiseClone();
[J("id")] public required string Id { get; set; } [J("id")] public required string Id { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] [J("pleroma")] public required PleromaStatusExtensions Pleroma { get; set; }
public required PleromaStatusExtensions? Pleroma { get; set; }
public static string EncodeVisibility(Note.NoteVisibility visibility) public static string EncodeVisibility(Note.NoteVisibility visibility)
{ {
@ -108,9 +107,3 @@ public class StatusEdit
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; } [J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; } [J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
} }
public class StatusTags
{
[J("name")] public required string Name { get; set; }
[J("url")] public required string Url { get; set; }
}

View file

@ -1,4 +1,3 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities; using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -33,17 +32,15 @@ public class InstanceInfoV1Response(
[J("invites_enabled")] public bool RegsInvite => config.Security.Registrations == Enums.Registrations.Invite; [J("invites_enabled")] public bool RegsInvite => config.Security.Registrations == Enums.Registrations.Invite;
[J("approval_required")] public bool RegsClosed => config.Security.Registrations == Enums.Registrations.Closed; [J("approval_required")] public bool RegsClosed => config.Security.Registrations == Enums.Registrations.Closed;
[J("urls")] public InstanceUrlsV1 Urls => new(config.Instance); [J("urls")] public InstanceUrls Urls => new(config.Instance);
[J("configuration")] public InstanceConfigurationV1 Configuration => new(config.Instance); [J("configuration")] public InstanceConfigurationV1 Configuration => new(config.Instance);
[J("pleroma")] public required PleromaInstanceExtensions Pleroma { get; set; } [J("pleroma")] public required PleromaInstanceExtensions Pleroma { get; set; }
[J("rules")] public required List<RuleEntity> Rules { get; set; }
//TODO: add the rest //TODO: add the rest
} }
public class InstanceUrlsV1(Config.InstanceSection config) public class InstanceUrls(Config.InstanceSection config)
{ {
[J("streaming_api")] public string StreamingApi => $"wss://{config.WebDomain}"; [J("streaming_api")] public string StreamingApi => $"wss://{config.WebDomain}";
} }
@ -98,5 +95,5 @@ public class InstancePollConfiguration
public class InstanceReactionConfiguration public class InstanceReactionConfiguration
{ {
[J("max_reactions")] public int MaxOptions => 100; [J("max_reactions")] public int MaxOptions => 1;
} }

View file

@ -1,4 +1,3 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
@ -27,8 +26,6 @@ public class InstanceInfoV2Response(
[J("usage")] public required InstanceUsage Usage { get; set; } [J("usage")] public required InstanceUsage Usage { get; set; }
[J("rules")] public required List<RuleEntity> Rules { get; set; }
//TODO: add the rest //TODO: add the rest
} }
@ -39,12 +36,7 @@ public class InstanceConfigurationV2(Config.InstanceSection config)
[J("media_attachments")] public InstanceMediaConfiguration Media => new(); [J("media_attachments")] public InstanceMediaConfiguration Media => new();
[J("polls")] public InstancePollConfiguration Polls => new(); [J("polls")] public InstancePollConfiguration Polls => new();
[J("reactions")] public InstanceReactionConfiguration Reactions => new(); [J("reactions")] public InstanceReactionConfiguration Reactions => new();
[J("urls")] public InstanceUrlsV2 Urls => new(config); [J("urls")] public InstanceUrls Urls => new(config);
}
public class InstanceUrlsV2(Config.InstanceSection config)
{
[J("streaming")] public string StreamingApi => $"wss://{config.WebDomain}";
} }
public class InstanceRegistrations(Config.SecuritySection config) public class InstanceRegistrations(Config.SecuritySection config)

View file

@ -1,24 +0,0 @@
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

@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using AsyncKeyedLock; using AsyncKeyedLock;
@ -15,7 +14,6 @@ using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.MfmSharp; using Iceshrimp.MfmSharp;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@ -72,32 +70,6 @@ public class StatusController(
return await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user); return await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user);
} }
[HttpGet]
[Authenticate("read:statuses")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Forbidden)]
public async Task<IEnumerable<StatusEntity>> GetManyNotes([FromQuery(Name = "id")] [MaxLength(20)] List<string> ids)
{
var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var query = db.Notes.Where(p => ids.Contains(p.Id));
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && user == null)
query = query.Where(p => p.User.IsLocalUser);
var notes = await query.IncludeCommonProperties()
.FilterHidden(user, db, false, false,
filterMentions: false)
.EnsureVisibleFor(user)
.PrecomputeVisibilities(user)
.ToArrayAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user);
}
[HttpGet("{id}/context")] [HttpGet("{id}/context")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
@ -437,17 +409,6 @@ public class StatusController(
}; };
newText = quoteUri != null ? parsed.SkipLast(1).Serialize() : parsed.Serialize(); newText = quoteUri != null ? parsed.SkipLast(1).Serialize() : parsed.Serialize();
if (
newText.AsSpan().Trim().Length == 0
&& request.Cw?.AsSpan().Trim().Length is null or 0
&& request.Poll is null or { Options.Count: 0 }
&& attachments?.Count is null or 0
)
{
quoteUri = null;
newText = null;
}
} }
if (request is { Sensitive: true, MediaIds.Count: > 0 }) if (request is { Sensitive: true, MediaIds.Count: > 0 })

View file

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

View file

@ -1,106 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,11 +0,0 @@
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,6 +7,4 @@ public class PleromaStatusExtensions
{ {
[J("emoji_reactions")] public required List<ReactionEntity> Reactions { get; set; } [J("emoji_reactions")] public required List<ReactionEntity> Reactions { get; set; }
[J("conversation_id")] public required string ConversationId { 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

@ -1,12 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,6 +1,6 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
@ -36,7 +36,7 @@ public class LinkPaginationAttribute(
var entities = context.HttpContext.GetPaginationData(); var entities = context.HttpContext.GetPaginationData();
if (entities == null && context.Result is ObjectResult { StatusCode: null or >= 200 and <= 299 } result) if (entities == null && context.Result is ObjectResult { StatusCode: null or >= 200 and <= 299 } result)
entities = result.Value as IEnumerable<IIdentifiable>; entities = result.Value as IEnumerable<IEntity>;
if (entities is null) return; if (entities is null) return;
var ids = entities.Select(p => p.Id).ToList(); var ids = entities.Select(p => p.Id).ToList();
@ -89,15 +89,15 @@ public static class HttpContextExtensions
{ {
private const string Key = "link-pagination"; private const string Key = "link-pagination";
internal static void SetPaginationData(this HttpContext ctx, IEnumerable<IIdentifiable> entities) internal static void SetPaginationData(this HttpContext ctx, IEnumerable<IEntity> entities)
{ {
ctx.Items.Add(Key, entities); ctx.Items.Add(Key, entities);
} }
public static IEnumerable<IIdentifiable>? GetPaginationData(this HttpContext ctx) public static IEnumerable<IEntity>? GetPaginationData(this HttpContext ctx)
{ {
ctx.Items.TryGetValue(Key, out var entities); ctx.Items.TryGetValue(Key, out var entities);
return entities as IEnumerable<IIdentifiable>; return entities as IEnumerable<IEntity>;
} }
} }

View file

@ -14,7 +14,6 @@ using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Backend.Core.Tasks; using Iceshrimp.Backend.Core.Tasks;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Configuration; using Iceshrimp.Shared.Configuration;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -298,24 +297,6 @@ public class AdminController(
await new MediaCleanupTask().InvokeAsync(scope.ServiceProvider); 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")] [HttpGet("policy")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePoliciesAsync(); public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePoliciesAsync();

View file

@ -1,11 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Queues;
@ -36,7 +34,7 @@ public class DriveController(
[EnableCors("drive")] [EnableCors("drive")]
[EnableRateLimiting("proxy")] [EnableRateLimiting("proxy")]
[HttpGet("/files/{accessKey}/{version?}")] [HttpGet("/files/{accessKey}/{version?}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version) public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
{ {
@ -46,14 +44,14 @@ public class DriveController(
[EnableCors("drive")] [EnableCors("drive")]
[EnableRateLimiting("proxy")] [EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")] [HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmojiById(string id) public async Task<IActionResult> GetEmojiById(string id)
{ {
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Emoji not found"); ?? throw GracefulException.NotFound("Emoji not found");
if (!options.Value.ProxyRemoteMedia || emoji.Host == null) if (!options.Value.ProxyRemoteMedia)
return Redirect(emoji.RawPublicUrl); return Redirect(emoji.RawPublicUrl);
return await ProxyAsync(emoji.RawPublicUrl, null, null); return await ProxyAsync(emoji.RawPublicUrl, null, null);
@ -62,7 +60,7 @@ public class DriveController(
[EnableCors("drive")] [EnableCors("drive")]
[EnableRateLimiting("proxy")] [EnableRateLimiting("proxy")]
[HttpGet("/avatars/{userId}/{version}")] [HttpGet("/avatars/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAvatarByUserId(string userId, string? version) public async Task<IActionResult> GetAvatarByUserId(string userId, string? version)
{ {
@ -85,7 +83,7 @@ public class DriveController(
[EnableCors("drive")] [EnableCors("drive")]
[EnableRateLimiting("proxy")] [EnableRateLimiting("proxy")]
[HttpGet("/banners/{userId}/{version}")] [HttpGet("/banners/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Redirect)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetBannerByUserId(string userId, string? version) public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
{ {
@ -101,34 +99,20 @@ public class DriveController(
return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner); return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner);
} }
[EnableCors("drive")]
[HttpGet("/identicon/{userId}")]
[HttpGet("/identicon/{userId}.png")]
[Produces(MediaTypeNames.Image.Png)]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetIdenticonByUserId(string userId)
{
var stream = await IdenticonHelper.GetIdenticonAsync(userId);
Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{userId}.png", false);
}
[HttpPost] [HttpPost]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[MaxRequestSizeIsMaxUploadSize] [MaxRequestSizeIsMaxUploadSize]
public async Task<DriveFileResponse> UploadFile(IFormFile file, [FromQuery] string? folderId) public async Task<DriveFileResponse> UploadFile(IFormFile file)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = new DriveFileCreationRequest var request = new DriveFileCreationRequest
{ {
Filename = file.FileName, Filename = file.FileName,
MimeType = file.ContentType, MimeType = file.ContentType,
IsSensitive = false, IsSensitive = false
FolderId = folderId
}; };
var res = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, request); var res = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, request);
return await GetFileById(res.Id); return await GetFileById(res.Id);
@ -143,10 +127,7 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileById(string id) public async Task<DriveFileResponse> GetFileById(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse return new DriveFileResponse
@ -157,9 +138,7 @@ public class DriveController(
Filename = file.Name, Filename = file.Name,
ContentType = file.Type, ContentType = file.Type,
Description = file.Comment, Description = file.Comment,
Sensitive = file.IsSensitive, Sensitive = file.IsSensitive
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
}; };
} }
@ -172,10 +151,7 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileByHash(string sha256) public async Task<DriveFileResponse> GetFileByHash(string sha256)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256)
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256)
?? throw GracefulException.NotFound("File not found"); ?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse return new DriveFileResponse
@ -186,9 +162,7 @@ public class DriveController(
Filename = file.Name, Filename = file.Name,
ContentType = file.Type, ContentType = file.Type,
Description = file.Comment, Description = file.Comment,
Sensitive = file.IsSensitive, Sensitive = file.IsSensitive
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
}; };
} }
@ -237,220 +211,6 @@ public class DriveController(
return StatusCode(StatusCodes.Status202Accepted); return StatusCode(StatusCodes.Status202Accepted);
} }
[HttpPost("{id}/move")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> UpdateFileParent(string id, DriveMoveRequest request)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
if (request.FolderId != null)
{
var parent = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id);
if (parent == null)
throw GracefulException.NotFound("The new parent folder doesn't exist");
}
file.FolderId = request.FolderId;
await db.SaveChangesAsync();
return await GetFileById(file.Id);
}
[HttpGet("folder")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
public async Task<DriveFolderResponse> GetRootFolder()
{
return await GetFolder(null);
}
[HttpPost("folder")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> CreateFolder(DriveFolderRequest request)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(request.Name))
throw GracefulException.BadRequest("Folder name cannot be empty");
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == request.Name.Trim().ToLower()
&& p.ParentId == request.ParentId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists");
var driveFolder = await driveSvc.CreateFolderAsync(user, request.Name.Trim(), request.ParentId);
return new DriveFolderResponse
{
Id = driveFolder.Id,
Name = driveFolder.Name,
ParentId = driveFolder.ParentId
};
}
[HttpGet("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFolderResponse> GetFolder(string? id)
{
var user = HttpContext.GetUserOrFail();
var folder = id != null
? await db.DriveFolders.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound()
: null;
var driveFiles = await db.DriveFiles
.Where(p => p.FolderId == id && p.UserId == user.Id)
.OrderByDescending(p => p.CreatedAt)
.ThenBy(p => p.Id)
.Select(p => new DriveFileResponse
{
Id = p.Id,
Url = p.RawAccessUrl,
ThumbnailUrl = p.RawThumbnailAccessUrl,
Filename = p.Name,
ContentType = p.Type,
Sensitive = p.IsSensitive,
Description = p.Comment,
IsAvatar = p.UserAvatar != null,
IsBanner = p.UserBanner != null
})
.ToListAsync();
var driveFolders = await db.DriveFolders
.Where(p => p.ParentId == id && p.UserId == user.Id)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.Select(p => new DriveFolderResponse
{
Id = p.Id, Name = p.Name, ParentId = p.ParentId
})
.ToListAsync();
return new DriveFolderResponse
{
Id = folder?.Id,
Name = folder?.Name,
ParentId = folder?.ParentId,
Files = driveFiles,
Folders = driveFolders
};
}
[HttpPut("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> UpdateFolder(string id, [FromHybrid] string name)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(name))
throw GracefulException.BadRequest("Name must not be empty");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == name.Trim().ToLower()
&& p.ParentId == folder.ParentId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists");
folder.Name = name.Trim();
await db.SaveChangesAsync();
return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId };
}
[HttpDelete("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
public async Task DeleteFolder(string id)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(id))
throw GracefulException.BadRequest("Cannot delete root folder");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
var driveFiles = await db.DriveFiles
.CountAsync(p => p.FolderId == id);
var driveFolders = await db.DriveFolders
.CountAsync(p => p.ParentId == id);
if (driveFiles != 0 || driveFolders != 0)
throw GracefulException.Conflict("Cannot delete a non-empty folder");
db.Remove(folder);
await db.SaveChangesAsync();
}
[HttpPost("folder/{id}/move")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> UpdateFolderParent(string id, DriveMoveRequest request)
{
var user = HttpContext.GetUserOrFail();
if (request.FolderId == id)
throw GracefulException.BadRequest("Cannot move a folder into itself");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
if (request.FolderId != null)
{
var parent = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id);
if (parent == null)
throw GracefulException.NotFound("The new parent folder doesn't exist");
}
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == folder.Name.ToLower()
&& p.ParentId == request.FolderId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists in the new parent folder");
folder.ParentId = request.FolderId;
await db.SaveChangesAsync();
return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId, };
}
private async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version, DriveFile? file) private async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version, DriveFile? file)
{ {
file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey
@ -464,15 +224,15 @@ public class DriveController(
if (file.IsLink) if (file.IsLink)
{ {
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
if (!options.Value.ProxyRemoteMedia) if (!options.Value.ProxyRemoteMedia)
return Redirect(fetchUrl); return NoContent();
try try
{ {
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp") var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp")
? file.Name ? file.Name
: $"{file.Name}.webp"; : $"{file.Name}.webp";

View file

@ -1,13 +1,10 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Helpers;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@ -40,7 +37,7 @@ public class EmojiController(
Id = p.Id, Id = p.Id,
Name = p.Name, Name = p.Name,
Uri = p.Uri, Uri = p.Uri,
Tags = p.Tags, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.GetAccessUrl(instance.Value), PublicUrl = p.GetAccessUrl(instance.Value),
License = p.License, License = p.License,
@ -49,91 +46,20 @@ public class EmojiController(
.ToListAsync(); .ToListAsync();
} }
[HttpGet("remote")]
[Authorize("role:moderator")]
[RestPagination(100, 500)]
[ProducesResults(HttpStatusCode.OK)]
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
&& (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);
}
[HttpGet("remote/{host}")]
[Authorize("role:moderator")]
[RestPagination(100, 500)]
[ProducesResults(HttpStatusCode.OK)]
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,
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);
}
[HttpGet("remote/hosts")]
[Authorize("role:moderator")]
[LinkPagination(20, 250)]
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<EntityWrapper<string>>> GetEmojiHostsAsync(PaginationQuery pq)
{
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());
return res;
}
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<EmojiResponse> GetEmoji(string id) public async Task<EmojiResponse> GetEmoji(string id)
{ {
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == 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 return new EmojiResponse
{ {
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
Uri = emoji.Uri, Uri = emoji.Uri,
Tags = emoji.Tags, Aliases = emoji.Aliases,
Category = emoji.Category, Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value), PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License, License = emoji.License,
@ -155,7 +81,7 @@ public class EmojiController(
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
Uri = emoji.Uri, Uri = emoji.Uri,
Tags = [], Aliases = [],
Category = null, Category = null,
PublicUrl = emoji.GetAccessUrl(instance.Value), PublicUrl = emoji.GetAccessUrl(instance.Value),
License = null, License = null,
@ -181,7 +107,7 @@ public class EmojiController(
Id = cloned.Id, Id = cloned.Id,
Name = cloned.Name, Name = cloned.Name,
Uri = cloned.Uri, Uri = cloned.Uri,
Tags = [], Aliases = [],
Category = null, Category = null,
PublicUrl = cloned.GetAccessUrl(instance.Value), PublicUrl = cloned.GetAccessUrl(instance.Value),
License = null, License = null,
@ -191,7 +117,7 @@ public class EmojiController(
[HttpPost("import")] [HttpPost("import")]
[Authorize("role:moderator")] [Authorize("role:moderator")]
[NoRequestSizeLimit] [DisableRequestSizeLimit]
[ProducesResults(HttpStatusCode.Accepted)] [ProducesResults(HttpStatusCode.Accepted)]
public async Task<AcceptedResult> ImportEmoji(IFormFile file) public async Task<AcceptedResult> ImportEmoji(IFormFile file)
{ {
@ -204,19 +130,19 @@ public class EmojiController(
[Authorize("role:moderator")] [Authorize("role:moderator")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request) public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request)
{ {
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Tags, request.Category, var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Aliases, request.Category,
request.License, request.Sensitive) request.License, request.Sensitive) ??
?? throw GracefulException.NotFound("Emoji not found"); throw GracefulException.NotFound("Emoji not found");
return new EmojiResponse return new EmojiResponse
{ {
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
Uri = emoji.Uri, Uri = emoji.Uri,
Tags = emoji.Tags, Aliases = emoji.Aliases,
Category = emoji.Category, Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value), PublicUrl = emoji.GetAccessUrl(instance.Value),
License = emoji.License, License = emoji.License,

View file

@ -10,7 +10,6 @@ using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Controllers.Web;
@ -23,94 +22,23 @@ public class InstanceController(
DatabaseContext db, DatabaseContext db,
UserRenderer userRenderer, UserRenderer userRenderer,
IOptions<Config.InstanceSection> instanceConfig, IOptions<Config.InstanceSection> instanceConfig,
IOptionsSnapshot<Config.SecuritySection> securityConfig, IOptions<Config.SecuritySection> securityConfig,
MetaService meta, MetaService meta
InstanceService instanceSvc
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceResponse> GetInfo() public async Task<InstanceResponse> GetInfo()
{ {
var limits = new Limitations { NoteLength = instanceConfig.Value.CharacterLimit };
return new InstanceResponse return new InstanceResponse
{ {
AccountDomain = instanceConfig.Value.AccountDomain, AccountDomain = instanceConfig.Value.AccountDomain,
WebDomain = instanceConfig.Value.WebDomain, WebDomain = instanceConfig.Value.WebDomain,
Registration = (Registrations)securityConfig.Value.Registrations, Registration = (Registrations)securityConfig.Value.Registrations,
Name = await meta.GetAsync(MetaEntity.InstanceName), Name = await meta.GetAsync(MetaEntity.InstanceName)
Limits = limits
}; };
} }
[HttpGet("rules")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<RuleResponse>> GetRules()
{
return await db.Rules
.OrderBy(p => p.Order)
.ThenBy(p => p.Id)
.Select(p => new RuleResponse { Id = p.Id, Text = p.Text, Description = p.Description })
.ToListAsync();
}
[HttpPost("rules")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<RuleResponse> CreateRule(RuleCreateRequest request)
{
var rule = await instanceSvc.CreateRuleAsync(request.Text.Trim(), request.Description?.Trim());
return new RuleResponse { Id = rule.Id, Text = rule.Text, Description = rule.Description };
}
[HttpPatch("rules/{id}")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<RuleResponse> UpdateRule(string id, RuleUpdateRequest request)
{
var rule = await db.Rules.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
var order = request.Order ?? 0;
var text = request.Text?.Trim() ?? rule.Text;
var description = request.Description != null
? string.IsNullOrWhiteSpace(request.Description)
? null
: request.Description.Trim()
: rule.Description;
var res = await instanceSvc.UpdateRuleAsync(rule, order, text, description);
return new RuleResponse { Id = res.Id, Text = res.Text, Description = res.Description };
}
[HttpDelete("rules/{id}")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteRule(string id)
{
var rule = await db.Rules.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
var rules = await db.Rules
.Where(p => p.Order > rule.Order)
.ToListAsync();
db.Remove(rule);
foreach (var r in rules)
r.Order -= 1;
db.UpdateRange(rules);
await db.SaveChangesAsync();
}
[HttpGet("staff")] [HttpGet("staff")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]

View file

@ -1,12 +1,9 @@
using System.Net; using System.Net;
using Iceshrimp.Backend.Controllers.Shared.Attributes; 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.Database;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -16,21 +13,15 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Authorize("role:moderator")] [Authorize("role:moderator")]
[ApiController] [ApiController]
[Route("/api/iceshrimp/moderation")] [Route("/api/iceshrimp/moderation")]
public class ModerationController( public class ModerationController(DatabaseContext db, NoteService noteSvc, UserService userSvc) : ControllerBase
DatabaseContext db,
NoteService noteSvc,
UserService userSvc,
ReportRenderer reportRenderer,
ReportService reportSvc
) : ControllerBase
{ {
[HttpPost("notes/{id}/delete")] [HttpPost("notes/{id}/delete")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteNote(string id) public async Task DeleteNote(string id)
{ {
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
?? throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
await noteSvc.DeleteNoteAsync(note); await noteSvc.DeleteNoteAsync(note);
} }
@ -40,8 +31,8 @@ public class ModerationController(
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task SuspendUser(string id) public async Task SuspendUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
?? throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail()) if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot suspend yourself."); throw GracefulException.BadRequest("You cannot suspend yourself.");
@ -54,8 +45,8 @@ public class ModerationController(
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task UnsuspendUser(string id) public async Task UnsuspendUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
?? throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail()) if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot unsuspend yourself."); throw GracefulException.BadRequest("You cannot unsuspend yourself.");
@ -68,8 +59,8 @@ public class ModerationController(
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteUser(string id) public async Task DeleteUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
?? throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail()) if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot delete yourself."); throw GracefulException.BadRequest("You cannot delete yourself.");
@ -82,60 +73,9 @@ public class ModerationController(
[ProducesErrors(HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task PurgeUser(string id) public async Task PurgeUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
?? throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
await userSvc.PurgeUserAsync(user); 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

@ -29,9 +29,7 @@ public class NoteController(
NoteRenderer noteRenderer, NoteRenderer noteRenderer,
UserRenderer userRenderer, UserRenderer userRenderer,
CacheService cache, CacheService cache,
BiteService biteSvc, BiteService biteSvc
PollService pollSvc,
ReportService reportSvc
) : ControllerBase ) : ControllerBase
{ {
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o => private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
@ -159,10 +157,8 @@ public class NoteController(
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
name = name.Trim(':');
var users = await db.NoteReactions var users = await db.NoteReactions
.Where(p => p.Note == note && (p.Reaction == $":{name}:" || p.Reaction == name)) .Where(p => p.Note == note && p.Reaction == $":{name.Trim(':')}:")
.Include(p => p.User.UserProfile) .Include(p => p.User.UserProfile)
.Select(p => p.User) .Select(p => p.User)
.ToListAsync(); .ToListAsync();
@ -504,64 +500,6 @@ public class NoteController(
await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync(); await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync();
} }
[HttpPost("{id}/vote")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task<NotePollSchema> AddPollVote(string id, NotePollRequest request)
{
var user = HttpContext.GetUserOrFail();
request.Choices.RemoveAll(p => p < 0);
if (request.Choices.Count == 0)
throw GracefulException.BadRequest("At least one vote must be included");
var target = await db.Notes
.IncludeCommonProperties()
.Include(p => p.Poll)
.Where(p => p.Id == id && p.Poll != null)
.EnsureVisibleFor(user)
.FirstOrDefaultAsync()
?? throw GracefulException.NotFound("Poll not found");
if (target.Poll!.ExpiresAt != null && target.Poll.ExpiresAt < DateTime.UtcNow)
throw GracefulException.NotFound("Poll has expired");
var voted = await db.PollVotes
.AnyAsync(p => p.NoteId == id && p.UserId == user.Id);
if (voted)
throw GracefulException.BadRequest("You have already voted");
if (!target.Poll.Multiple)
request.Choices.RemoveRange(1, request.Choices.Count - 1);
List<PollVote> votes = [];
foreach (var choice in request.Choices)
{
var vote = new PollVote
{
Id = IdHelpers.GenerateSnowflakeId(),
CreatedAt = DateTime.UtcNow,
UserId = user.Id,
NoteId = id,
Choice = choice
};
await db.AddAsync(vote);
votes.Add(vote);
}
await db.SaveChangesAsync();
foreach (var vote in votes)
await pollSvc.RegisterPollVoteAsync(vote, target.Poll, target);
await db.ReloadEntityAsync(target.Poll);
var res = await GetNote(id);
return res.Poll!;
}
[HttpPost] [HttpPost]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
@ -617,18 +555,6 @@ public class NoteController(
? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync() ? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync()
: null; : null;
var minPollExpire = DateTime.UtcNow.AddMinutes(5);
var poll = request.Poll != null
? new Poll
{
ExpiresAt = request.Poll.ExpiresAt != null
? request.Poll.ExpiresAt <= minPollExpire ? minPollExpire : request.Poll.ExpiresAt
: null,
Multiple = request.Poll.Multiple,
Choices = request.Poll.Choices,
}
: null;
var note = await noteSvc.CreateNoteAsync(new NoteService.NoteCreationData var note = await noteSvc.CreateNoteAsync(new NoteService.NoteCreationData
{ {
User = user, User = user,
@ -637,8 +563,7 @@ public class NoteController(
Cw = request.Cw, Cw = request.Cw,
Reply = reply, Reply = reply,
Renote = renote, Renote = renote,
Attachments = attachments, Attachments = attachments
Poll = poll
}); });
if (request.IdempotencyKey != null) if (request.IdempotencyKey != null)
@ -646,18 +571,4 @@ public class NoteController(
return await noteRenderer.RenderOne(note, user); 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

@ -1,14 +1,14 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Controllers.Web;
@ -19,9 +19,9 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Route("/api/iceshrimp/profile")] [Route("/api/iceshrimp/profile")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
public class ProfileController( public class ProfileController(
IOptions<Config.InstanceSection> instance,
UserService userSvc, UserService userSvc,
DriveService driveSvc, DriveService driveSvc
DatabaseContext db
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
@ -43,8 +43,7 @@ public class ProfileController(
DisplayName = user.DisplayName ?? "", DisplayName = user.DisplayName ?? "",
IsBot = user.IsBot, IsBot = user.IsBot,
IsCat = user.IsCat, IsCat = user.IsCat,
SpeakAsCat = user.SpeakAsCat, SpeakAsCat = user.SpeakAsCat
Pronouns = profile.Pronouns ?? []
}; };
} }
@ -76,7 +75,6 @@ public class ProfileController(
profile.Birthday = birthday; profile.Birthday = birthday;
profile.Fields = fields.ToArray(); profile.Fields = fields.ToArray();
profile.FFVisibility = (UserProfile.UserProfileFFVisibility)newProfile.FFVisibility; profile.FFVisibility = (UserProfile.UserProfileFFVisibility)newProfile.FFVisibility;
profile.Pronouns = newProfile.Pronouns;
user.DisplayName = string.IsNullOrWhiteSpace(newProfile.DisplayName) ? null : newProfile.DisplayName.Trim(); user.DisplayName = string.IsNullOrWhiteSpace(newProfile.DisplayName) ? null : newProfile.DisplayName.Trim();
@ -84,28 +82,6 @@ public class ProfileController(
user.IsCat = newProfile.IsCat; user.IsCat = newProfile.IsCat;
user.SpeakAsCat = newProfile is { SpeakAsCat: true, IsCat: true }; user.SpeakAsCat = newProfile is { SpeakAsCat: true, IsCat: true };
if (newProfile.AvatarAlt != null)
{
var avatar = await db.DriveFiles
.FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserAvatar != null);
if (avatar != null)
{
user.Avatar = avatar;
user.Avatar.Comment = string.IsNullOrWhiteSpace(newProfile.AvatarAlt) ? null : newProfile.AvatarAlt.Trim();
}
}
if (newProfile.BannerAlt != null)
{
var banner = await db.DriveFiles
.FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserBanner != null);
if (banner != null)
{
user.Banner = banner;
user.Banner.Comment = string.IsNullOrWhiteSpace(newProfile.BannerAlt) ? null : newProfile.BannerAlt.Trim();
}
}
var prevAvatarId = user.AvatarId; var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId; var prevBannerId = user.BannerId;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -113,35 +89,16 @@ public class ProfileController(
[HttpGet("avatar")] [HttpGet("avatar")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)] public string GetAvatarUrl()
public async Task<DriveFileResponse> GetAvatar()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return user.AvatarId != null ? user.GetAvatarUrl(instance.Value) : "";
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserAvatar != null)
?? throw GracefulException.RecordNotFound();
return new DriveFileResponse
{
Id = file.Id,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Sensitive = file.IsSensitive,
Description = file.Comment,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
} }
[HttpPost("avatar")] [HttpPost("avatar")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)] [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task UpdateAvatar(IFormFile file, [FromQuery] string? altText) public async Task UpdateAvatar(IFormFile file)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -155,14 +112,12 @@ public class ProfileController(
{ {
Filename = file.FileName, Filename = file.FileName,
IsSensitive = false, IsSensitive = false,
MimeType = file.ContentType, MimeType = file.ContentType
Comment = altText
}; };
var avatar = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq); var avatar = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq);
user.Avatar = avatar; user.Avatar = avatar;
user.AvatarId = avatar.Id;
user.AvatarBlurhash = avatar.Blurhash; user.AvatarBlurhash = avatar.Blurhash;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -170,6 +125,7 @@ public class ProfileController(
[HttpDelete("avatar")] [HttpDelete("avatar")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteAvatar() public async Task DeleteAvatar()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -177,7 +133,8 @@ public class ProfileController(
var prevAvatarId = user.AvatarId; var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId; var prevBannerId = user.BannerId;
if (prevAvatarId == null) return; if (prevAvatarId == null)
throw GracefulException.NotFound("You do not have an avatar");
user.Avatar = null; user.Avatar = null;
user.AvatarBlurhash = null; user.AvatarBlurhash = null;
@ -187,35 +144,16 @@ public class ProfileController(
[HttpGet("banner")] [HttpGet("banner")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)] public string GetBannerUrl()
public async Task<DriveFileResponse> GetBanner()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return user.GetBannerUrl(instance.Value) ?? "";
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserBanner != null)
?? throw GracefulException.RecordNotFound();
return new DriveFileResponse
{
Id = file.Id,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Sensitive = file.IsSensitive,
Description = file.Comment,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
} }
[HttpPost("banner")] [HttpPost("banner")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)] [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task UpdateBanner(IFormFile file, [FromQuery] string? altText) public async Task UpdateBanner(IFormFile file)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -229,14 +167,12 @@ public class ProfileController(
{ {
Filename = file.FileName, Filename = file.FileName,
IsSensitive = false, IsSensitive = false,
MimeType = file.ContentType, MimeType = file.ContentType
Comment = altText
}; };
var banner = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq); var banner = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq);
user.Banner = banner; user.Banner = banner;
user.BannerId = banner.Id;
user.BannerBlurhash = banner.Blurhash; user.BannerBlurhash = banner.Blurhash;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -244,6 +180,7 @@ public class ProfileController(
[HttpDelete("banner")] [HttpDelete("banner")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteBanner() public async Task DeleteBanner()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -251,7 +188,8 @@ public class ProfileController(
var prevAvatarId = user.AvatarId; var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId; var prevBannerId = user.BannerId;
if (prevBannerId == null) return; if (prevBannerId == null)
throw GracefulException.NotFound("You do not have a banner");
user.Banner = null; user.Banner = null;
user.BannerBlurhash = null; user.BannerBlurhash = null;

View file

@ -38,7 +38,6 @@ public class NoteRenderer(
res.Filtered = new NoteFilteredSchema res.Filtered = new NoteFilteredSchema
{ {
Id = filtered.Value.filter.Id, Id = filtered.Value.filter.Id,
Name = filtered.Value.filter.Name,
Keyword = filtered.Value.keyword, Keyword = filtered.Value.keyword,
Hide = filtered.Value.filter.Action == Filter.FilterAction.Hide Hide = filtered.Value.filter.Action == Filter.FilterAction.Hide
}; };
@ -77,10 +76,9 @@ public class NoteRenderer(
var attachments = var attachments =
(data?.Attachments ?? await GetAttachmentsAsync([note])).Where(p => note.FileIds.Contains(p.Id)); (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 reactions = (data?.Reactions ?? await GetReactionsAsync([note], user)).Where(p => p.NoteId == note.Id);
var liked = data?.LikedNotes?.Contains(note.Id) var liked = data?.LikedNotes?.Contains(note.Id) ??
?? await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user); 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 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);
return new NoteResponse return new NoteResponse
{ {
@ -91,7 +89,6 @@ public class NoteRenderer(
Text = note.Text, Text = note.Text,
Cw = note.Cw, Cw = note.Cw,
Visibility = (NoteVisibility)note.Visibility, Visibility = (NoteVisibility)note.Visibility,
LocalOnly = note.LocalOnly,
User = noteUser, User = noteUser,
Attachments = attachments.ToList(), Attachments = attachments.ToList(),
Reactions = reactions.ToList(), Reactions = reactions.ToList(),
@ -99,8 +96,7 @@ public class NoteRenderer(
Renotes = note.RenoteCount, Renotes = note.RenoteCount,
Replies = note.RepliesCount, Replies = note.RepliesCount,
Liked = liked, Liked = liked,
Emoji = emoji, Emoji = emoji
Poll = poll
}; };
} }
@ -124,8 +120,7 @@ public class NoteRenderer(
ContentType = p.Type, ContentType = p.Type,
Blurhash = p.Blurhash, Blurhash = p.Blurhash,
AltText = p.Comment, AltText = p.Comment,
IsSensitive = p.IsSensitive, IsSensitive = p.IsSensitive
FileName = p.Name
}) })
.ToList(); .ToList();
} }
@ -141,12 +136,10 @@ public class NoteRenderer(
.Select(p => new NoteReactionSchema .Select(p => new NoteReactionSchema
{ {
NoteId = p.First().NoteId, NoteId = p.First().NoteId,
Count = Count = (int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
(int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1), Reacted = db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
Reacted = i.Reaction == p.First().Reaction &&
db.NoteReactions.Any(i => i.NoteId == p.First().NoteId i.User == user),
&& i.Reaction == p.First().Reaction
&& i.User == user),
Name = p.First().Reaction, Name = p.First().Reaction,
Url = null, Url = null,
Sensitive = false Sensitive = false
@ -199,7 +192,7 @@ public class NoteRenderer(
Id = p.Id, Id = p.Id,
Name = p.Name, Name = p.Name,
Uri = p.Uri, Uri = p.Uri,
Tags = p.Tags, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.GetAccessUrl(config.Value), PublicUrl = p.GetAccessUrl(config.Value),
License = p.License, License = p.License,
@ -208,38 +201,6 @@ public class NoteRenderer(
.ToListAsync(); .ToListAsync();
} }
private async Task<List<NotePollSchema>> GetPollsAsync(IEnumerable<Note> notes, User? user)
{
var polls = await db.Polls
.Where(p => notes.Contains(p.Note))
.ToListAsync();
var votes = user != null
? await db.PollVotes
.Where(p => notes.Contains(p.Note) && p.UserId == user.Id)
.ToListAsync()
: [];
return polls
.Select(p => new NotePollSchema
{
NoteId = p.NoteId,
ExpiresAt = p.ExpiresAt,
Multiple = p.Multiple,
Choices = p.Choices.Zip(p.Votes)
.Select((c, i) => new NotePollChoice
{
Value = c.First,
Votes = c.Second,
Voted = user != null
&& votes.Any(v => v.NoteId == p.NoteId && v.Choice == i)
})
.ToList(),
VotersCount = p.VotersCount
})
.ToList();
}
public async Task<IEnumerable<NoteResponse>> RenderManyAsync( public async Task<IEnumerable<NoteResponse>> RenderManyAsync(
IEnumerable<Note> notes, User? user, Filter.FilterContext? filterContext = null IEnumerable<Note> notes, User? user, Filter.FilterContext? filterContext = null
) )
@ -254,8 +215,7 @@ public class NoteRenderer(
Reactions = await GetReactionsAsync(allNotes, user), Reactions = await GetReactionsAsync(allNotes, user),
Filters = await GetFiltersAsync(user, filterContext), Filters = await GetFiltersAsync(user, filterContext),
LikedNotes = await GetLikedNotesAsync(allNotes, user), LikedNotes = await GetLikedNotesAsync(allNotes, user),
Emoji = await GetEmojiAsync(allNotes), Emoji = await GetEmojiAsync(allNotes)
Polls = await GetPollsAsync(allNotes, user)
}; };
return await notesList.Select(p => RenderOne(p, user, filterContext, data)).AwaitAllAsync(); return await notesList.Select(p => RenderOne(p, user, filterContext, data)).AwaitAllAsync();
@ -269,6 +229,5 @@ public class NoteRenderer(
public List<string>? LikedNotes; public List<string>? LikedNotes;
public List<NoteReactionSchema>? Reactions; public List<NoteReactionSchema>? Reactions;
public List<UserResponse>? Users; public List<UserResponse>? Users;
public List<NotePollSchema>? Polls;
} }
} }

View file

@ -1,11 +1,8 @@
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using static Iceshrimp.Shared.Schemas.Web.NotificationResponse; using static Iceshrimp.Shared.Schemas.Web.NotificationResponse;
@ -15,7 +12,7 @@ public class NotificationRenderer(
IOptions<Config.InstanceSection> instance, IOptions<Config.InstanceSection> instance,
UserRenderer userRenderer, UserRenderer userRenderer,
NoteRenderer noteRenderer, NoteRenderer noteRenderer,
DatabaseContext db EmojiService emojiSvc
) : IScopedService ) : IScopedService
{ {
private static NotificationResponse Render(Notification notification, NotificationRendererDto data) private static NotificationResponse Render(Notification notification, NotificationRendererDto data)
@ -117,44 +114,21 @@ public class NotificationRenderer(
Sensitive = false Sensitive = false
}) })
.ToList(); .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) foreach (var s in custom)
{ {
var name = s.host != null ? $":{s.name}@{s.host}:" : $":{s.name}:"; var emoji = await emojiSvc.ResolveEmojiAsync(s);
emojiUrls.TryGetValue(name, out var emoji);
var reaction = emoji != null var reaction = emoji != null
? new ReactionResponse ? new ReactionResponse
{ {
Name = name, Name = s,
Url = emoji.Url, Url = emoji.GetAccessUrl(instance.Value),
Sensitive = emoji.Sensitive Sensitive = emoji.Sensitive
} }
: new ReactionResponse : new ReactionResponse
{ {
Name = name, Name = s,
Url = null, Url = null,
Sensitive = false Sensitive = false
}; };

View file

@ -1,66 +0,0 @@
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

@ -55,7 +55,7 @@ public class UserProfileRenderer(DatabaseContext db, IOptions<Config.InstanceSec
? Role.Moderator ? Role.Moderator
: Role.None; : Role.None;
var url = user.Host != null ? user.UserProfile?.Url ?? user.Uri : user.GetPublicUrl(instance.Value); var url = user.Uri ?? user.GetPublicUrl(instance.Value.WebDomain);
return new UserProfileResponse return new UserProfileResponse
{ {
@ -69,9 +69,7 @@ public class UserProfileRenderer(DatabaseContext db, IOptions<Config.InstanceSec
Relations = relations, Relations = relations,
Role = role, Role = role,
IsLocked = user.IsLocked, IsLocked = user.IsLocked,
Url = url, Url = url
Lang = user.UserProfile?.Lang,
Pronouns = user.UserProfile?.Pronouns
}; };
} }

View file

@ -21,9 +21,6 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
if (!data.Emojis.TryGetValue(user.Id, out var emoji)) if (!data.Emojis.TryGetValue(user.Id, out var emoji))
throw new Exception("DTO didn't contain emoji for user"); throw new Exception("DTO didn't contain emoji for user");
var avatarAlt = data.AvatarAlt.GetValueOrDefault(user.Id);
var bannerAlt = data.BannerAlt.GetValueOrDefault(user.Id);
return new UserResponse return new UserResponse
{ {
Id = user.Id, Id = user.Id,
@ -31,9 +28,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Host = user.Host, Host = user.Host,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
AvatarUrl = user.GetAvatarUrl(config.Value), AvatarUrl = user.GetAvatarUrl(config.Value),
AvatarAlt = avatarAlt,
BannerUrl = user.GetBannerUrl(config.Value), BannerUrl = user.GetBannerUrl(config.Value),
BannerAlt = bannerAlt,
InstanceName = instanceName, InstanceName = instanceName,
InstanceIconUrl = instanceIcon, InstanceIconUrl = instanceIcon,
Emojis = emoji, Emojis = emoji,
@ -47,15 +42,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
{ {
var instanceData = await GetInstanceDataAsync([user]); var instanceData = await GetInstanceDataAsync([user]);
var emojis = await GetEmojisAsync([user]); var emojis = await GetEmojisAsync([user]);
var avatarAlt = await GetAvatarAltAsync([user]); var data = new UserRendererDto { Emojis = emojis, InstanceData = instanceData };
var bannerAlt = await GetBannerAltAsync([user]);
var data = new UserRendererDto
{
Emojis = emojis,
InstanceData = instanceData,
AvatarAlt = avatarAlt,
BannerAlt = bannerAlt
};
return Render(user, data); return Render(user, data);
} }
@ -66,33 +53,12 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
return await db.Instances.Where(p => hosts.Contains(p.Host)).ToListAsync(); return await db.Instances.Where(p => hosts.Contains(p.Host)).ToListAsync();
} }
private async Task<Dictionary<string, string?>> GetAvatarAltAsync(IEnumerable<User> users)
{
var ids = users.Select(p => p.Id).ToList();
return await db.Users
.Where(p => ids.Contains(p.Id))
.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();
return await db.Users
.Where(p => ids.Contains(p.Id))
.Include(p => p.Banner)
.ToDictionaryAsync(p => p.Id, p => p.Banner?.Comment);
}
public async Task<IEnumerable<UserResponse>> RenderManyAsync(IEnumerable<User> users) public async Task<IEnumerable<UserResponse>> RenderManyAsync(IEnumerable<User> users)
{ {
var userList = users.ToList(); var userList = users.ToList();
var data = new UserRendererDto var data = new UserRendererDto
{ {
InstanceData = await GetInstanceDataAsync(userList), InstanceData = await GetInstanceDataAsync(userList), Emojis = await GetEmojisAsync(userList)
Emojis = await GetEmojisAsync(userList),
AvatarAlt = await GetAvatarAltAsync(userList),
BannerAlt = await GetBannerAltAsync(userList)
}; };
return userList.Select(p => Render(p, data)); return userList.Select(p => Render(p, data));
@ -110,7 +76,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
Id = p.Id, Id = p.Id,
Name = p.Name, Name = p.Name,
Uri = p.Uri, Uri = p.Uri,
Tags = p.Tags, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.GetAccessUrl(config.Value), PublicUrl = p.GetAccessUrl(config.Value),
License = p.License, License = p.License,
@ -125,7 +91,5 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
{ {
public required List<Instance> InstanceData; public required List<Instance> InstanceData;
public required Dictionary<string, List<EmojiResponse>> Emojis; public required Dictionary<string, List<EmojiResponse>> Emojis;
public required Dictionary<string, string?> AvatarAlt;
public required Dictionary<string, string?> BannerAlt;
} }
} }

View file

@ -42,94 +42,4 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user, return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Home); 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,48 +117,6 @@ public class UserController(
await biteSvc.BiteAsync(user, target); 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")] [HttpPost("{id}/follow")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
@ -183,70 +141,6 @@ public class UserController(
await userSvc.FollowUserAsync(user, followee); 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]
[EnableRateLimiting("strict")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<UserResponse> RefetchUser(string id)
{
var user = await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id == id && p.Host != null && p.Uri != null)
?? throw GracefulException.NotFound("User not found");
await userSvc.UpdateUserAsync(user, force: true);
await db.ReloadEntityRecursivelyAsync(user);
return await userRenderer.RenderOne(user);
}
[HttpPost("{id}/remove_from_followers")] [HttpPost("{id}/remove_from_followers")]
[ProducesResults(HttpStatusCode.OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]

View file

@ -17,7 +17,6 @@ public sealed class Config
public required InstanceSection Instance { get; init; } = new(); public required InstanceSection Instance { get; init; } = new();
public required DatabaseSection Database { get; init; } = new(); public required DatabaseSection Database { get; init; } = new();
public required SecuritySection Security { get; init; } = new(); public required SecuritySection Security { get; init; } = new();
public required NetworkSection Network { get; init; } = new();
public required StorageSection Storage { get; init; } = new(); public required StorageSection Storage { get; init; } = new();
public required PerformanceSection Performance { get; init; } = new(); public required PerformanceSection Performance { get; init; } = new();
public required QueueSection Queue { get; init; } = new(); public required QueueSection Queue { get; init; } = new();
@ -54,26 +53,18 @@ public sealed class Config
public sealed class SecuritySection public sealed class SecuritySection
{ {
public bool AuthorizedFetch { get; init; } = true; public bool AuthorizedFetch { get; init; } = true;
public bool ValidateRequestSignatures { get; init; } = true; public bool AttachLdSignatures { get; init; } = false;
public bool AttachLdSignatures { get; init; } = false; public bool AcceptLdSignatures { get; init; } = false;
public bool AcceptLdSignatures { get; init; } = false; public bool AllowLoopback { get; init; } = false;
public bool AllowLoopback { get; init; } = false; public bool AllowLocalIPv6 { get; init; } = false;
public bool AllowLocalIPv6 { get; init; } = false; public bool AllowLocalIPv4 { get; init; } = false;
public bool AllowLocalIPv4 { get; init; } = false; public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic; public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed; public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList; public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered; public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered; public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
}
public sealed class NetworkSection
{
public string? HttpProxy { get; init; } = null;
public string? HttpProxyUser { get; init; } = null!;
public string? HttpProxyPass { get; init; } = null!;
} }
public sealed class DatabaseSection public sealed class DatabaseSection

View file

@ -16,7 +16,7 @@ namespace Iceshrimp.Backend.Core.Database;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) public class DatabaseContext(DbContextOptions<DatabaseContext> options)
: DbContext(options), IDataProtectionKeyContext : DbContext(options), IDataProtectionKeyContext
{ {
public virtual DbSet<Report> Reports { get; init; } = null!; public virtual DbSet<AbuseUserReport> AbuseUserReports { get; init; } = null!;
public virtual DbSet<Announcement> Announcements { get; init; } = null!; public virtual DbSet<Announcement> Announcements { get; init; } = null!;
public virtual DbSet<AnnouncementRead> AnnouncementReads { get; init; } = null!; public virtual DbSet<AnnouncementRead> AnnouncementReads { get; init; } = null!;
public virtual DbSet<Antenna> Antennas { get; init; } = null!; public virtual DbSet<Antenna> Antennas { get; init; } = null!;
@ -65,7 +65,6 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<RegistryItem> RegistryItems { get; init; } = null!; public virtual DbSet<RegistryItem> RegistryItems { get; init; } = null!;
public virtual DbSet<Relay> Relays { get; init; } = null!; public virtual DbSet<Relay> Relays { get; init; } = null!;
public virtual DbSet<RenoteMuting> RenoteMutings { get; init; } = null!; public virtual DbSet<RenoteMuting> RenoteMutings { get; init; } = null!;
public virtual DbSet<Rule> Rules { get; set; }
public virtual DbSet<Session> Sessions { get; init; } = null!; public virtual DbSet<Session> Sessions { get; init; } = null!;
public virtual DbSet<SwSubscription> SwSubscriptions { get; init; } = null!; public virtual DbSet<SwSubscription> SwSubscriptions { get; init; } = null!;
public virtual DbSet<PushSubscription> PushSubscriptions { get; init; } = null!; public virtual DbSet<PushSubscription> PushSubscriptions { get; init; } = null!;
@ -92,7 +91,6 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<Filter> Filters { get; init; } = null!; public virtual DbSet<Filter> Filters { get; init; } = null!;
public virtual DbSet<PluginStoreEntry> PluginStore { get; init; } = null!; public virtual DbSet<PluginStoreEntry> PluginStore { get; init; } = null!;
public virtual DbSet<PolicyConfiguration> PolicyConfiguration { 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 virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection config) public static NpgsqlDataSource GetDataSource(Config.DatabaseSection config)
@ -101,15 +99,14 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
{ {
ConnectionStringBuilder = ConnectionStringBuilder =
{ {
Host = config.Host, Host = config.Host,
Port = config.Port, Port = config.Port,
Username = config.Username, Username = config.Username,
Password = config.Password, Password = config.Password,
Database = config.Database, Database = config.Database,
MaxPoolSize = config.MaxConnections, MaxPoolSize = config.MaxConnections,
Multiplexing = config.Multiplexing, Multiplexing = config.Multiplexing,
Options = "-c jit=off", Options = "-c jit=off"
ApplicationName = "Iceshrimp.NET"
} }
}; };

View file

@ -0,0 +1,12 @@
namespace Iceshrimp.Backend.Core.Database;
public interface IEntity
{
public string Id { get; }
}
public class EntityWrapper<T> : IEntity
{
public required T Entity { get; init; }
public required string Id { get; init; }
}

View file

@ -19,7 +19,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.2") .HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
@ -36,6 +36,84 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 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 => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AllowedInstance", b =>
{ {
b.Property<string>("Host") b.Property<string>("Host")
@ -409,18 +487,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("blocking"); 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 => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.CacheEntry", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
@ -912,6 +978,13 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("id"); .HasColumnName("id");
b.PrimitiveCollection<List<string>>("Aliases")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("character varying(128)[]")
.HasColumnName("aliases")
.HasDefaultValueSql("'{}'::character varying[]");
b.Property<string>("Category") b.Property<string>("Category")
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
@ -944,7 +1017,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasColumnName("originalUrl"); .HasColumnName("originalUrl");
b.Property<string>("RawPublicUrl") b.Property<string>("PublicUrl")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasMaxLength(512) .HasMaxLength(512)
@ -956,13 +1029,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("sensitive"); .HasColumnName("sensitive");
b.PrimitiveCollection<List<string>>("Tags")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("character varying(128)[]")
.HasColumnName("tags")
.HasDefaultValueSql("'{}'::character varying[]");
b.Property<string>("Type") b.Property<string>("Type")
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
@ -991,18 +1057,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.HasIndex("Name", "Host") b.HasIndex("Name", "Host")
.IsUnique(); .IsUnique();
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"); b.ToTable("emoji");
}); });
@ -3814,111 +3868,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("renote_muting"); 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")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<string>("Description")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("description");
b.Property<int>("Order")
.HasColumnType("integer")
.HasColumnName("order");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("text");
b.HasKey("Id");
b.ToTable("rule");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Session", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Session", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -4202,10 +4151,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("lastFetchedAt"); .HasColumnName("lastFetchedAt");
b.Property<DateTime?>("LastNoteAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lastNoteAt");
b.Property<string>("MovedToUri") b.Property<string>("MovedToUri")
.HasMaxLength(512) .HasMaxLength(512)
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
@ -4682,10 +4627,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("pinnedPageId"); .HasColumnName("pinnedPageId");
b.Property<Dictionary<string, string>>("Pronouns")
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<string>("Url") b.Property<string>("Url")
.HasMaxLength(512) .HasMaxLength(512)
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
@ -4944,19 +4885,30 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("data_protection_keys", (string)null); b.ToTable("data_protection_keys", (string)null);
}); });
modelBuilder.Entity("reported_note", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AbuseUserReport", b =>
{ {
b.Property<string>("note_id") b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Assignee")
.HasColumnType("character varying(32)"); .WithMany("AbuseUserReportAssignees")
.HasForeignKey("AssigneeId")
.OnDelete(DeleteBehavior.SetNull);
b.Property<string>("report_id") b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "Reporter")
.HasColumnType("character varying(32)"); .WithMany("AbuseUserReportReporters")
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasKey("note_id", "report_id"); b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "TargetUser")
.WithMany("AbuseUserReportTargetUsers")
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasIndex("report_id"); b.Navigation("Assignee");
b.ToTable("reported_note"); b.Navigation("Reporter");
b.Navigation("TargetUser");
}); });
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AnnouncementRead", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AnnouncementRead", b =>
@ -5731,32 +5683,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("Muter"); 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 => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Session", b =>
{ {
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User") b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
@ -5967,21 +5893,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("User"); 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 => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Announcement", b =>
{ {
b.Navigation("AnnouncementReads"); b.Navigation("AnnouncementReads");

View file

@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250106074951_Rules")]
public partial class Rules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "rule",
columns: table => new
{
id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
order = table.Column<int>(type: "integer", nullable: false),
text = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_rule", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "rule");
}
}
}

View file

@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250121203942_FixupEmojiUniqueIndex")]
public partial class FixupEmojiUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_emoji_name_host",
table: "emoji");
// Clean up duplicates, preserving the newest one
migrationBuilder.Sql("""
DELETE FROM "emoji" e USING "emoji" e2 WHERE e."host" IS NULL AND e."name" = e2."name" AND e2."host" IS NULL AND e."id" < e2."id";
""");
migrationBuilder.CreateIndex(
name: "IX_emoji_name_host",
table: "emoji",
columns: new[] { "name", "host" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_emoji_name_host",
table: "emoji");
migrationBuilder.CreateIndex(
name: "IX_emoji_name_host",
table: "emoji",
columns: new[] { "name", "host" },
unique: true);
}
}
}

View file

@ -1,32 +0,0 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250127070810_Pronouns")]
public partial class Pronouns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<string, string>>(
name: "pronouns",
table: "user_profile",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pronouns",
table: "user_profile");
}
}
}

View file

@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250217182259_FixupEmojiAliases")]
public partial class FixupEmojiAliases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE "emoji" SET "aliases" = array_remove("aliases", '') WHERE '' = ANY("aliases");
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View file

@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250217192617_FixupEmojiType")]
public partial class FixupEmojiType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
UPDATE "emoji"
SET "type" =
(SELECT COALESCE("drive_file"."webpublicType", "drive_file"."type")
FROM "drive_file"
WHERE "drive_file"."userHost" IS NULL
AND ("drive_file"."webpublicUrl" = "emoji"."publicUrl"
OR "drive_file"."url" = "emoji"."publicUrl"
)
LIMIT 1
)
WHERE "host" IS NULL
AND EXISTS
(SELECT 1
FROM "drive_file"
WHERE "drive_file"."userHost" IS NULL
AND ("drive_file"."webpublicUrl" = "emoji"."publicUrl"
OR "drive_file"."url" = "emoji"."publicUrl"
)
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) { }
}
}

View file

@ -1,31 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,242 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,47 +0,0 @@
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

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

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
@ -21,7 +20,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(AccessKey))] [Index(nameof(AccessKey))]
[Index(nameof(Uri))] [Index(nameof(Uri))]
[Index(nameof(ThumbnailAccessKey))] [Index(nameof(ThumbnailAccessKey))]
public class DriveFile : IIdentifiable public class DriveFile : IEntity
{ {
/// <summary> /// <summary>
/// The created date of the DriveFile. /// The created date of the DriveFile.

View file

@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Iceshrimp.Backend.Core.Database.Tables; namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("emoji")] [Table("emoji")]
[Index(nameof(Name), nameof(Host), IsUnique = true)]
[Index(nameof(Host))] [Index(nameof(Host))]
[Index(nameof(Name))] [Index(nameof(Name))]
public class Emoji public class Emoji
@ -30,8 +31,8 @@ public class Emoji
[Column("type")] [StringLength(64)] public string? Type { get; set; } [Column("type")] [StringLength(64)] public string? Type { get; set; }
[Column("tags", TypeName = "character varying(128)[]")] [Column("aliases", TypeName = "character varying(128)[]")]
public List<string> Tags { get; set; } = []; public List<string> Aliases { get; set; } = [];
[Column("category")] [Column("category")]
[StringLength(128)] [StringLength(128)]
@ -75,20 +76,10 @@ public class Emoji
{ {
public void Configure(EntityTypeBuilder<Emoji> entity) public void Configure(EntityTypeBuilder<Emoji> entity)
{ {
entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Height).HasComment("Image height"); entity.Property(e => e.Height).HasComment("Image height");
entity.Property(e => e.RawPublicUrl).HasDefaultValueSql("''::character varying"); entity.Property(e => e.RawPublicUrl).HasDefaultValueSql("''::character varying");
entity.Property(e => e.Width).HasComment("Image width"); 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

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -10,7 +9,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(FolloweeId))] [Index(nameof(FolloweeId))]
[Index(nameof(FollowerId))] [Index(nameof(FollowerId))]
[Index(nameof(FollowerId), nameof(FolloweeId), IsUnique = true)] [Index(nameof(FollowerId), nameof(FolloweeId), IsUnique = true)]
public class FollowRequest : IIdentifiable public class FollowRequest : IEntity
{ {
/// <summary> /// <summary>
/// The created date of the FollowRequest. /// The created date of the FollowRequest.

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -8,7 +7,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("hashtag")] [Table("hashtag")]
[Index(nameof(Name), IsUnique = true)] [Index(nameof(Name), IsUnique = true)]
public class Hashtag : IIdentifiable public class Hashtag : IEntity
{ {
[Column("name")] [StringLength(128)] public string Name { get; set; } = null!; [Column("name")] [StringLength(128)] public string Name { get; set; } = null!;

View file

@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis;
using EntityFrameworkCore.Projectables; using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using NpgsqlTypes; using NpgsqlTypes;
@ -33,7 +32,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(Visibility))] [Index(nameof(Visibility))]
[Index(nameof(ReplyUri))] [Index(nameof(ReplyUri))]
[Index(nameof(RenoteUri))] [Index(nameof(RenoteUri))]
public class Note : IIdentifiable public class Note : IEntity
{ {
[PgName("note_visibility_enum")] [PgName("note_visibility_enum")]
public enum NoteVisibility public enum NoteVisibility

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -10,7 +9,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(UserId))] [Index(nameof(UserId))]
[Index(nameof(NoteId))] [Index(nameof(NoteId))]
[Index(nameof(UserId), nameof(NoteId), IsUnique = true)] [Index(nameof(UserId), nameof(NoteId), IsUnique = true)]
public class NoteLike : IIdentifiable public class NoteLike : IEntity
{ {
[Key] [Key]
[Column("id")] [Column("id")]

View file

@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using EntityFrameworkCore.Projectables; using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -11,7 +10,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("note_thread")] [Table("note_thread")]
[Index(nameof(Uri), IsUnique = true)] [Index(nameof(Uri), IsUnique = true)]
public class NoteThread : IIdentifiable public class NoteThread : IEntity
{ {
[Column("id")] [Column("id")]
[StringLength(256)] [StringLength(256)]

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using NpgsqlTypes; using NpgsqlTypes;
@ -16,7 +15,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(AppAccessTokenId))] [Index(nameof(AppAccessTokenId))]
[Index(nameof(MastoId))] [Index(nameof(MastoId))]
[Index(nameof(NoteId))] [Index(nameof(NoteId))]
public class Notification : IIdentifiable public class Notification : IEntity
{ {
[PgName("notification_type_enum")] [PgName("notification_type_enum")]
public enum NotificationType public enum NotificationType

View file

@ -1,13 +0,0 @@
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,24 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("rule")]
public class Rule
{
[Key]
[Column("id")]
[StringLength(32)]
public string Id { get; set; } = null!;
[Column("order")]
public int Order { get; set; }
[Column("text")]
[StringLength(128)]
public string Text { get; set; } = null!;
[Column("description")]
[StringLength(512)]
public string? Description { get; set; }
}

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -8,7 +7,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("session")] [Table("session")]
[Index(nameof(Token))] [Index(nameof(Token))]
public class Session : IIdentifiable public class Session : IEntity
{ {
[Key] [Key]
[Column("id")] [Column("id")]

View file

@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema;
using EntityFrameworkCore.Projectables; using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -24,7 +23,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(AvatarId), IsUnique = true)] [Index(nameof(AvatarId), IsUnique = true)]
[Index(nameof(BannerId), IsUnique = true)] [Index(nameof(BannerId), IsUnique = true)]
[Index(nameof(IsSuspended))] [Index(nameof(IsSuspended))]
public class User : IIdentifiable public class User : IEntity
{ {
/// <summary> /// <summary>
/// The created date of the User. /// The created date of the User.
@ -41,7 +40,6 @@ public class User : IIdentifiable
[Column("lastFetchedAt")] public DateTime? LastFetchedAt { get; set; } [Column("lastFetchedAt")] public DateTime? LastFetchedAt { get; set; }
[Column("outboxFetchedAt")] public DateTime? OutboxFetchedAt { get; set; } [Column("outboxFetchedAt")] public DateTime? OutboxFetchedAt { get; set; }
[Column("lastNoteAt")] public DateTime? LastNoteAt { get; set; }
[NotMapped] [NotMapped]
[Projectable] [Projectable]
@ -260,14 +258,14 @@ public class User : IIdentifiable
[Column("splitDomainResolved")] public bool SplitDomainResolved { get; set; } [Column("splitDomainResolved")] public bool SplitDomainResolved { get; set; }
[InverseProperty(nameof(Report.Assignee))] [InverseProperty(nameof(AbuseUserReport.Assignee))]
public virtual ICollection<Report> AbuseUserReportAssignees { get; set; } = new List<Report>(); public virtual ICollection<AbuseUserReport> AbuseUserReportAssignees { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(Report.Reporter))] [InverseProperty(nameof(AbuseUserReport.Reporter))]
public virtual ICollection<Report> AbuseUserReportReporters { get; set; } = new List<Report>(); public virtual ICollection<AbuseUserReport> AbuseUserReportReporters { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(Report.TargetUser))] [InverseProperty(nameof(AbuseUserReport.TargetUser))]
public virtual ICollection<Report> AbuseUserReportTargetUsers { get; set; } = new List<Report>(); public virtual ICollection<AbuseUserReport> AbuseUserReportTargetUsers { get; set; } = new List<AbuseUserReport>();
[InverseProperty(nameof(AnnouncementRead.User))] [InverseProperty(nameof(AnnouncementRead.User))]
public virtual ICollection<AnnouncementRead> AnnouncementReads { get; set; } = new List<AnnouncementRead>(); public virtual ICollection<AnnouncementRead> AnnouncementReads { get; set; } = new List<AnnouncementRead>();
@ -631,7 +629,7 @@ public class User : IIdentifiable
public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}"; public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}";
public string GetAvatarUrl(Config.InstanceSection config) public string GetAvatarUrl(Config.InstanceSection config)
=> $"https://{config.WebDomain}/avatars/{Id}/{AvatarId ?? "identicon"}"; => $"https://{config.WebDomain}/avatars/{Id}/{AvatarId ?? "identicon.png"}";
public string? GetBannerUrl(Config.InstanceSection config) public string? GetBannerUrl(Config.InstanceSection config)
=> BannerId != null ? $"https://{config.WebDomain}/banners/{Id}/{BannerId}" : null; => BannerId != null ? $"https://{config.WebDomain}/banners/{Id}/{BannerId}" : null;

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -10,7 +9,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(UserListId))] [Index(nameof(UserListId))]
[Index(nameof(UserId), nameof(UserListId), IsUnique = true)] [Index(nameof(UserId), nameof(UserListId), IsUnique = true)]
[Index(nameof(UserId))] [Index(nameof(UserId))]
public class UserListMember : IIdentifiable public class UserListMember : IEntity
{ {
/// <summary> /// <summary>
/// The created date of the UserListMember. /// The created date of the UserListMember.

View file

@ -71,9 +71,6 @@ public class UserProfile
[Column("mentions", TypeName = "jsonb")] [Column("mentions", TypeName = "jsonb")]
public List<Note.MentionedUser> Mentions { get; set; } = null!; public List<Note.MentionedUser> Mentions { get; set; } = null!;
[Column("pronouns", TypeName = "jsonb")]
public Dictionary<string, string>? Pronouns { get; set; }
[ForeignKey(nameof(PinnedPageId))] [ForeignKey(nameof(PinnedPageId))]
[InverseProperty(nameof(Page.UserProfile))] [InverseProperty(nameof(Page.UserProfile))]
public virtual Page? PinnedPage { get; set; } public virtual Page? PinnedPage { get; set; }

View file

@ -0,0 +1,51 @@
using System.Linq.Expressions;
namespace Iceshrimp.Backend.Core.Extensions;
public static class ExpressionExtensions
{
public static Expression<Func<T, bool>> True<T>() => f => true;
public static Expression<Func<T, bool>> False<T>() => f => false;
public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> expr1, Expression<Func<T, bool>> expr2
)
{
var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(Expression.OrElse(expr1.Body, invokedExpr), expr1.Parameters);
}
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1, Expression<Func<T, bool>> expr2
)
{
var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(expr1.Body, invokedExpr), expr1.Parameters);
}
public static Expression<Func<TFirstParam, TResult>> Compose<TFirstParam, TIntermediate, TResult>(
this Expression<Func<TFirstParam, TIntermediate>> first,
Expression<Func<TIntermediate, TResult>> second
)
{
var param = Expression.Parameter(typeof(TFirstParam), "param");
var newFirst = first.Body.Replace(first.Parameters[0], param);
var newSecond = second.Body.Replace(second.Parameters[0], newFirst);
return Expression.Lambda<Func<TFirstParam, TResult>>(newSecond, param);
}
private static Expression Replace(this Expression expression, Expression searchEx, Expression replaceEx)
{
return new ReplaceVisitor(searchEx, replaceEx).Visit(expression) ?? throw new NullReferenceException();
}
private class ReplaceVisitor(Expression from, Expression to) : ExpressionVisitor
{
public override Expression? Visit(Expression? node)
{
return node == from ? to : base.Visit(node);
}
}
}

View file

@ -1,7 +1,7 @@
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
@ -10,7 +10,7 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static partial class HttpContextExtensions public static partial class HttpContextExtensions
{ {
public static PaginationWrapper<TData> CreatePaginationWrapper<TData>( public static PaginationWrapper<TData> CreatePaginationWrapper<TData>(
this HttpContext ctx, PaginationQuery query, IEnumerable<IIdentifiable> paginationData, TData data this HttpContext ctx, PaginationQuery query, IEnumerable<IEntity> paginationData, TData data
) )
{ {
var attr = ctx.GetEndpoint()?.Metadata.GetMetadata<RestPaginationAttribute>(); var attr = ctx.GetEndpoint()?.Metadata.GetMetadata<RestPaginationAttribute>();
@ -37,7 +37,7 @@ public static partial class HttpContextExtensions
public static PaginationWrapper<TData> CreatePaginationWrapper<TData>( public static PaginationWrapper<TData> CreatePaginationWrapper<TData>(
this HttpContext ctx, PaginationQuery query, TData data this HttpContext ctx, PaginationQuery query, TData data
) where TData : IEnumerable<IIdentifiable> ) where TData : IEnumerable<IEntity>
{ {
return CreatePaginationWrapper(ctx, query, data, data); return CreatePaginationWrapper(ctx, query, data, data);
} }

View file

@ -9,8 +9,6 @@ using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -18,12 +16,118 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static class QueryableExtensions public static class QueryableExtensions
{ {
/// <summary>
/// This helper method allows consumers to obtain the performance &amp; memory footprint benefits of chunked DB transactions,
/// while not requiring them to work with chunks instead of a regular enumerator.
/// </summary>
/// <remarks>
/// Make sure to call .OrderBy() on the query, otherwise the results will be unpredictable.
/// Furthermore, this method is unsuitable for cases where the consumer removes elements from the original collection.
/// </remarks>
/// <returns>
/// The result set as an IAsyncEnumerable. Makes one DB roundtrip at the start of each chunk.
/// Successive items in the chunk are yielded instantaneously.
/// </returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static async IAsyncEnumerable<T> AsChunkedAsyncEnumerable<T>(this IQueryable<T> query, int chunkSize)
{
var offset = 0;
while (true)
{
var res = await query.Skip(offset).Take(chunkSize).ToArrayAsync();
if (res.Length == 0) break;
foreach (var item in res) yield return item;
if (res.Length < chunkSize) break;
offset += chunkSize;
}
}
/// <inheritdoc cref="AsChunkedAsyncEnumerable{T}(System.Linq.IQueryable{T},int)" select="summary|returns"/>
/// <remarks>
/// This overload requires you to pass a predicate to the identifier.
/// .OrderBy(<paramref name="idPredicate"/>) is appended to the query.
/// Set the <paramref name="hook"/> parameter to append things to the query after pagination, for cases where query translation would fail otherwise.
/// </remarks>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static async IAsyncEnumerable<TResult> AsChunkedAsyncEnumerable<TResult>(
this IQueryable<TResult> query, int chunkSize, Expression<Func<TResult, string>> idPredicate,
Func<IQueryable<TResult>, IQueryable<TResult>>? hook = null
)
{
var pred = idPredicate.Compile();
query = query.OrderBy(idPredicate);
string? last = null;
while (true)
{
// ReSharper disable once AccessToModifiedClosure
var final = last is not null ? query.Where(idPredicate.Compose(p => p.IsGreaterThan(last))) : query;
if (hook != null)
final = hook(final);
var res = await final.Take(chunkSize).ToArrayAsync();
if (res.Length == 0) break;
foreach (var item in res) yield return item;
if (res.Length < chunkSize) break;
last = pred.Invoke(res.Last());
}
}
/// <inheritdoc cref="AsChunkedAsyncEnumerable{TResult}(System.Linq.IQueryable{TResult},int,System.Linq.Expressions.Expression{System.Func{TResult,string}},System.Func{System.Linq.IQueryable{TResult},System.Linq.IQueryable{TResult}}?)"/>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static async IAsyncEnumerable<TResult> AsChunkedAsyncEnumerable<TResult>(
this IQueryable<TResult> query, int chunkSize, Expression<Func<TResult, Guid>> idPredicate,
Func<IQueryable<TResult>, IQueryable<TResult>>? hook = null
)
{
var pred = idPredicate.Compile();
query = query.OrderBy(idPredicate);
Guid? last = null;
while (true)
{
// ReSharper disable once AccessToModifiedClosure
var final = last is not null ? query.Where(idPredicate.Compose(p => p > last)) : query;
if (hook != null)
final = hook(final);
var res = await final.Take(chunkSize).ToArrayAsync();
if (res.Length == 0) break;
foreach (var item in res) yield return item;
if (res.Length < chunkSize) break;
last = pred.Invoke(res.Last());
}
}
/// <inheritdoc cref="AsChunkedAsyncEnumerable{TResult}(System.Linq.IQueryable{TResult},int,System.Linq.Expressions.Expression{System.Func{TResult,string}},System.Func{System.Linq.IQueryable{TResult},System.Linq.IQueryable{TResult}}?)"/>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static async IAsyncEnumerable<TResult> AsChunkedAsyncEnumerable<TResult>(
this IQueryable<TResult> query, int chunkSize, Expression<Func<TResult, int>> idPredicate,
Func<IQueryable<TResult>, IQueryable<TResult>>? hook = null
)
{
var pred = idPredicate.Compile();
query = query.OrderBy(idPredicate);
int? last = null;
while (true)
{
// ReSharper disable once AccessToModifiedClosure
var final = last is not null ? query.Where(idPredicate.Compose(p => p > last)) : query;
if (hook != null)
final = hook(final);
var res = await final.Take(chunkSize).ToArrayAsync();
if (res.Length == 0) break;
foreach (var item in res) yield return item;
if (res.Length < chunkSize) break;
last = pred.Invoke(res.Last());
}
}
public static IQueryable<T> Paginate<T>( public static IQueryable<T> Paginate<T>(
this IQueryable<T> query, this IQueryable<T> query,
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
int defaultLimit, int defaultLimit,
int maxLimit int maxLimit
) where T : IIdentifiable ) where T : IEntity
{ {
if (pq.Limit is < 1) if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1"); throw GracefulException.BadRequest("Limit cannot be less than 1");
@ -57,7 +161,7 @@ public static class QueryableExtensions
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
int defaultLimit, int defaultLimit,
int maxLimit int maxLimit
) where T : IIdentifiable ) where T : IEntity
{ {
if (pq.Limit is < 1) if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1"); throw GracefulException.BadRequest("Limit cannot be less than 1");
@ -91,7 +195,7 @@ public static class QueryableExtensions
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
int defaultLimit, int defaultLimit,
int maxLimit int maxLimit
) where T : IIdentifiable ) where T : IEntity
{ {
if (pq.Limit is < 1) if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1"); throw GracefulException.BadRequest("Limit cannot be less than 1");
@ -149,7 +253,7 @@ public static class QueryableExtensions
PaginationQuery pq, PaginationQuery pq,
int defaultLimit, int defaultLimit,
int maxLimit int maxLimit
) where T : IIdentifiable ) where T : IEntity
{ {
if (pq.Limit is < 1) if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1"); throw GracefulException.BadRequest("Limit cannot be less than 1");
@ -174,7 +278,7 @@ public static class QueryableExtensions
this IQueryable<T> query, this IQueryable<T> query,
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
ControllerContext context ControllerContext context
) where T : IIdentifiable ) where T : IEntity
{ {
var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>(); var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>();
if (attr == null) if (attr == null)
@ -188,7 +292,7 @@ public static class QueryableExtensions
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
int defaultLimit, int defaultLimit,
int maxLimit int maxLimit
) where T : IIdentifiable ) where T : IEntity
{ {
if (pq.Limit is < 1) if (pq.Limit is < 1)
throw GracefulException.BadRequest("Limit cannot be less than 1"); throw GracefulException.BadRequest("Limit cannot be less than 1");
@ -200,7 +304,7 @@ public static class QueryableExtensions
this IQueryable<T> query, this IQueryable<T> query,
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
ControllerContext context ControllerContext context
) where T : IIdentifiable ) where T : IEntity
{ {
var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>(); var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>();
if (attr == null) if (attr == null)
@ -214,7 +318,7 @@ public static class QueryableExtensions
Expression<Func<T, string>> predicate, Expression<Func<T, string>> predicate,
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
ControllerContext context ControllerContext context
) where T : IIdentifiable ) where T : IEntity
{ {
var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>(); var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>();
if (attr == null) if (attr == null)
@ -228,7 +332,7 @@ public static class QueryableExtensions
Expression<Func<T, long>> predicate, Expression<Func<T, long>> predicate,
MastodonPaginationQuery pq, MastodonPaginationQuery pq,
ControllerContext context ControllerContext context
) where T : IIdentifiable ) where T : IEntity
{ {
var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>(); var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>();
if (attr == null) if (attr == null)
@ -241,7 +345,7 @@ public static class QueryableExtensions
this IQueryable<T> query, this IQueryable<T> query,
PaginationQuery pq, PaginationQuery pq,
ControllerContext context ControllerContext context
) where T : IIdentifiable ) where T : IEntity
{ {
var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>(); var attr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IPaginationAttribute>();
if (attr == null) if (attr == null)
@ -252,7 +356,7 @@ public static class QueryableExtensions
public static IQueryable<EntityWrapper<TResult>> Wrap<TSource, TResult>( public static IQueryable<EntityWrapper<TResult>> Wrap<TSource, TResult>(
this IQueryable<TSource> query, Expression<Func<TSource, TResult>> predicate this IQueryable<TSource> query, Expression<Func<TSource, TResult>> predicate
) where TSource : IIdentifiable ) where TSource : IEntity
{ {
return query.Select(p => new EntityWrapper<TResult> { Id = p.Id, Entity = predicate.Compile().Invoke(p) }); return query.Select(p => new EntityWrapper<TResult> { Id = p.Id, Entity = predicate.Compile().Invoke(p) });
} }
@ -655,14 +759,5 @@ public static class QueryableExtensions
.Include(p => p.Bite); .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. #pragma warning restore CS8602 // Dereference of a possibly null reference.
} }

View file

@ -5,7 +5,6 @@ using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Parsing; using Iceshrimp.Parsing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -35,7 +34,6 @@ public static class QueryableFtsExtensions
InstanceFilter instanceFilter => current.ApplyInstanceFilter(instanceFilter, config), InstanceFilter instanceFilter => current.ApplyInstanceFilter(instanceFilter, config),
MentionFilter mentionFilter => current.ApplyMentionFilter(mentionFilter, config, db), MentionFilter mentionFilter => current.ApplyMentionFilter(mentionFilter, config, db),
MiscFilter miscFilter => current.ApplyMiscFilter(miscFilter, user), MiscFilter miscFilter => current.ApplyMiscFilter(miscFilter, user),
VisibilityFilter visibilityFilter => current.ApplyVisibilityFilter(visibilityFilter),
ReplyFilter replyFilter => current.ApplyReplyFilter(replyFilter, config, db), ReplyFilter replyFilter => current.ApplyReplyFilter(replyFilter, config, db),
CwFilter cwFilter => current.ApplyCwFilter(cwFilter, caseSensitivity, matchType), CwFilter cwFilter => current.ApplyCwFilter(cwFilter, caseSensitivity, matchType),
WordFilter wordFilter => current.ApplyWordFilter(wordFilter, caseSensitivity, matchType), WordFilter wordFilter => current.ApplyWordFilter(wordFilter, caseSensitivity, matchType),
@ -82,9 +80,7 @@ public static class QueryableFtsExtensions
private static IQueryable<Note> ApplyMultiWordFilter( private static IQueryable<Note> ApplyMultiWordFilter(
this IQueryable<Note> query, MultiWordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType this IQueryable<Note> query, MultiWordFilter filter, CaseFilterType caseSensitivity, MatchFilterType matchType
) => filter.Negated ) => query.Where(p => p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType));
? query.Where(p => !p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType))
: query.Where(p => p.FtsQueryOneOf(filter.Values, caseSensitivity, matchType));
private static IQueryable<Note> ApplyFromFilters( private static IQueryable<Note> ApplyFromFilters(
this IQueryable<Note> query, List<FromFilter> filters, Config.InstanceSection config, DatabaseContext db this IQueryable<Note> query, List<FromFilter> filters, Config.InstanceSection config, DatabaseContext db
@ -166,25 +162,6 @@ 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")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
private static IQueryable<Note> ApplyFollowersFilter(this IQueryable<Note> query, User user, bool negated) 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)); => query.Where(p => negated ? !p.User.IsFollowing(user) : p.User.IsFollowing(user));
@ -294,21 +271,37 @@ public static class QueryableFtsExtensions
Justification = "Projectable chain must have consistent visibility")] Justification = "Projectable chain must have consistent visibility")]
internal static bool FtsQueryPreEscaped( internal static bool FtsQueryPreEscaped(
this Note note, string query, bool negated, CaseFilterType caseSensitivity, MatchFilterType matchType this Note note, string query, bool negated, CaseFilterType caseSensitivity, MatchFilterType matchType
) => negated ) => matchType.Equals(MatchFilterType.Substring)
? matchType.Equals(MatchFilterType.Substring) ? caseSensitivity.Equals(CaseFilterType.Sensitive)
? FtsQueryMatchSubstringPreEscaped(note.Text, query, negated, caseSensitivity) ? negated
&& FtsQueryMatchSubstringPreEscaped(note.Cw, query, negated, caseSensitivity) ? !EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
&& FtsQueryMatchSubstringPreEscaped(note.CombinedAltText, query, negated, caseSensitivity) && !EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
: FtsQueryMatchWordPreEscaped(note.Text, query, negated, caseSensitivity) && !EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
&& FtsQueryMatchWordPreEscaped(note.Cw, query, negated, caseSensitivity) : EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
&& FtsQueryMatchWordPreEscaped(note.CombinedAltText, query, negated, caseSensitivity) || EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
: matchType.Equals(MatchFilterType.Substring) || EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
? FtsQueryMatchSubstringPreEscaped(note.Text, query, negated, caseSensitivity) : negated
|| FtsQueryMatchSubstringPreEscaped(note.Cw, query, negated, caseSensitivity) ? !EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
|| FtsQueryMatchSubstringPreEscaped(note.CombinedAltText, query, negated, caseSensitivity) && !EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
: FtsQueryMatchWordPreEscaped(note.Text, query, negated, caseSensitivity) && !EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
|| FtsQueryMatchWordPreEscaped(note.Cw, query, negated, caseSensitivity) : EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
|| FtsQueryMatchWordPreEscaped(note.CombinedAltText, query, negated, caseSensitivity); || EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
|| EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
: caseSensitivity.Equals(CaseFilterType.Sensitive)
? negated
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
: negated
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
[Projectable] [Projectable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global", [SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
@ -344,30 +337,4 @@ public static class QueryableFtsExtensions
this Note note, IEnumerable<string> words, CaseFilterType caseSensitivity, MatchFilterType matchType this Note note, IEnumerable<string> words, CaseFilterType caseSensitivity, MatchFilterType matchType
) => words.Select(p => PreEscapeFtsQuery(p, matchType)) ) => words.Select(p => PreEscapeFtsQuery(p, matchType))
.Any(p => note.FtsQueryPreEscaped(p, false, caseSensitivity, matchType)); .Any(p => note.FtsQueryPreEscaped(p, false, caseSensitivity, matchType));
[Projectable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
Justification = "Projectable chain must have consistent visibility")]
internal static bool FtsQueryMatchSubstringPreEscaped(
string? match, string query, bool negated, CaseFilterType caseSensitivity
) => caseSensitivity.Equals(CaseFilterType.Sensitive)
? negated
? !EF.Functions.Like(match!, "%" + query + "%", @"\")
: EF.Functions.Like(match!, "%" + query + "%", @"\")
: negated
? !EF.Functions.ILike(match!, "%" + query + "%", @"\")
: EF.Functions.ILike(match!, "%" + query + "%", @"\");
[Projectable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
Justification = "Projectable chain must have consistent visibility")]
internal static bool FtsQueryMatchWordPreEscaped(
string? match, string query, bool negated, CaseFilterType caseSensitivity
) => caseSensitivity.Equals(CaseFilterType.Sensitive)
? negated
? match == null || !Regex.IsMatch(match, "\\y" + query + "\\y")
: match != null && Regex.IsMatch(match, "\\y" + query + "\\y")
: negated
? match == null || !Regex.IsMatch(match, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
: match != null && Regex.IsMatch(match, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
} }

View file

@ -1,9 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Extensions; namespace Iceshrimp.Backend.Core.Extensions;
@ -20,58 +18,38 @@ public static class QueryableTimelineExtensions
) )
{ {
return heuristic < Cutoff return heuristic < Cutoff
? query.Where(FollowingAndOwnLowFreqExpr(user, db)) ? query.FollowingAndOwnLowFreq(user, db)
: query.Where(note => note.User == user || note.User.IsFollowedBy(user)); : query.Where(note => note.User == user || note.User.IsFollowedBy(user));
} }
public static IQueryable<Note> FilterByFollowingOwnAndLocal( private static IQueryable<Note> FollowingAndOwnLowFreq(this IQueryable<Note> query, User user, DatabaseContext db)
this IQueryable<Note> query, User user, DatabaseContext db, int heuristic => query.Where(note => db.Followings
) .Where(p => p.Follower == user)
{ .Select(p => p.FolloweeId)
return heuristic < Cutoff .Concat(new[] { user.Id })
? query.Where(FollowingAndOwnLowFreqExpr(user, db).Or(p => p.UserHost == null)) .Contains(note.UserId));
: 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
)
{
var cutoff = DateTime.UtcNow + maxRemainingTtl;
return query.Where(u => !db.CacheStore.Any(c => c.Key == Prefix + ':' + u.Id && c.Expiry > cutoff));
}
public static async Task ResetHeuristicAsync(User user, CacheService cache) public static async Task ResetHeuristicAsync(User user, CacheService cache)
{ {
await cache.ClearAsync($"{Prefix}:{user.Id}"); await cache.ClearAsync($"{Prefix}:{user.Id}");
} }
public static async Task<int> GetHeuristicAsync( public static async Task<int> GetHeuristicAsync(User user, DatabaseContext db, CacheService cache)
User user, DatabaseContext db, CacheService cache, bool forceUpdate = false
)
{ {
return await cache.FetchValueAsync($"{Prefix}:{user.Id}", TimeSpan.FromHours(24), FetchHeuristicAsync, return await cache.FetchValueAsync($"{Prefix}:{user.Id}", TimeSpan.FromHours(24), FetchHeuristicAsync);
forceUpdate: forceUpdate);
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall")]
async Task<int> FetchHeuristicAsync() async Task<int> FetchHeuristicAsync()
{ {
var latestNote = await db.Notes.OrderByDescending(p => p.Id) var latestNote = await db.Notes.OrderByDescending(p => p.Id)
.Select(p => new { p.CreatedAt }) .Select(p => new { p.CreatedAt })
.FirstOrDefaultAsync() .FirstOrDefaultAsync() ??
?? new { CreatedAt = DateTime.UtcNow }; new { CreatedAt = DateTime.UtcNow };
//TODO: maybe we should express this as a ratio between matching and non-matching posts //TODO: maybe we should express this as a ratio between matching and non-matching posts
return await db.Notes return await db.Notes
.Where(p => p.CreatedAt > latestNote.CreatedAt - TimeSpan.FromDays(7)) .Where(p => p.CreatedAt > latestNote.CreatedAt - TimeSpan.FromDays(7))
.Where(FollowingAndOwnLowFreqExpr(user, db)) .FollowingAndOwnLowFreq(user, db)
.OrderByDescending(p => p.Id) .OrderByDescending(p => p.Id)
.Take(Cutoff + 1) .Take(Cutoff + 1)
.CountAsync(); .CountAsync();

View file

@ -88,7 +88,6 @@ public static class ServiceExtensions
services.ConfigureWithValidation<Config>(configuration) services.ConfigureWithValidation<Config>(configuration)
.ConfigureWithValidation<Config.InstanceSection>(configuration, "Instance") .ConfigureWithValidation<Config.InstanceSection>(configuration, "Instance")
.ConfigureWithValidation<Config.SecuritySection>(configuration, "Security") .ConfigureWithValidation<Config.SecuritySection>(configuration, "Security")
.ConfigureWithValidation<Config.NetworkSection>(configuration, "Network")
.ConfigureWithValidation<Config.PerformanceSection>(configuration, "Performance") .ConfigureWithValidation<Config.PerformanceSection>(configuration, "Performance")
.ConfigureWithValidation<Config.QueueConcurrencySection>(configuration, "Performance:QueueConcurrency") .ConfigureWithValidation<Config.QueueConcurrencySection>(configuration, "Performance:QueueConcurrency")
.ConfigureWithValidation<Config.BackfillSection>(configuration, "Backfill") .ConfigureWithValidation<Config.BackfillSection>(configuration, "Backfill")

View file

@ -28,35 +28,4 @@ public static class StreamExtensions
ValueTask<int> DoReadAsync() => source.ReadAsync(new Memory<byte>(buffer), cancellationToken); 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,11 +61,6 @@ public static class TaskExtensions
return (await task).ToList(); 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) public static async Task ContinueWithResult(this Task task, Action continuation)
{ {
await task; await task;

View file

@ -2,33 +2,5 @@ namespace Iceshrimp.Backend.Core.Extensions;
public static class TimeSpanExtensions 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 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";
}
} }

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