Compare commits

..

8 commits

Author SHA1 Message Date
Lilian
c433286cab
eep 2024-11-06 01:56:00 +01:00
Lilian
d359897bcd
. 2024-11-06 01:48:37 +01:00
Lilian
bf59339a4c
test 2024-11-06 01:43:21 +01:00
Lilian
f3c82e3f05
test 2024-11-06 01:07:54 +01:00
Lilian
fa1590512f
[frontend/core] Add version controller model 2024-11-06 01:07:23 +01:00
Lilian
69f0727138
[backend/web] Add version endpoint 2024-11-06 01:06:41 +01:00
Lilian
3ad40f8d2e
test
test
2024-11-04 22:21:55 +01:00
Lilian
d6717890ec
[frontend/components] Disable interacting with inline-replies 2024-11-03 19:39:55 +01:00
638 changed files with 8764 additions and 23874 deletions

View file

@ -9,9 +9,7 @@ build dotnet-sdk-8.0-wasm dotnet-sdk:8.0-wasm
build dotnet-sdk-9.0-alpine dotnet-sdk:9.0-alpine
build dotnet-sdk-9.0-alpine-wasm dotnet-sdk:9.0-alpine-wasm
build ci-env-dotnet8 ci-env:dotnet8
build ci-env-dotnet8-wasm ci-env:dotnet8-wasm
build ci-env-dotnet9 ci-env:dotnet9
build ci-env-dotnet9-wasm ci-env:dotnet9-wasm
build ci-env ci-env:dotnet
build ci-env-wasm ci-env:dotnet-wasm
docker buildx prune -a -f --keep-storage 10G

View file

@ -1,3 +0,0 @@
FROM iceshrimp.dev/iceshrimp/dotnet-sdk:9.0-alpine-wasm
RUN apk add --no-cache --no-progress git docker-cli docker-cli-buildx python3 curl go nodejs-current tar zstd make
CMD ["/bin/bash"]

View file

@ -1,3 +0,0 @@
FROM iceshrimp.dev/iceshrimp/dotnet-sdk:9.0-alpine
RUN apk add --no-cache --no-progress git docker-cli docker-cli-buildx python3 curl go nodejs-current tar zstd make
CMD ["/bin/bash"]

View file

@ -1,6 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine-composite
ARG TARGETARCH
WORKDIR /app
COPY linux-musl-$TARGETARCH/ .
USER app
ENTRYPOINT ["./Iceshrimp.Backend", "--environment", "Production", "--migrate-and-start"]

View file

@ -1,13 +1,5 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-alpine
RUN dotnet workload install wasm-tools
RUN apk add --no-cache --no-progress bash python3
# Workaround for https://github.com/dotnet/sdk/issues/44933
RUN apk add --no-cache --no-progress go
RUN go install github.com/yaegashi/muslstack@latest
RUN find /usr/share/dotnet/packs -name wasm-opt -type f | xargs ~/go/bin/muslstack -s 0x800000
RUN rm -rf ~/go
RUN apk del --no-cache --no-progress go
RUN apk add --no-cache --no-progress bash
RUN ln -sf /bin/bash /bin/sh
CMD ["/bin/bash"]

View file

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-alpine
RUN apk add --no-cache --no-progress bash
RUN ln -sf /bin/bash /bin/sh
CMD ["/bin/bash"]

View file

@ -1,14 +0,0 @@
# Top-most EditorConfig file
root = true
[{Iceshrimp.Backend/Controllers/*/*.cs,Iceshrimp.Tests/**.cs,**.razor,**.razor.cs}]
# Override for controller actions. Main rule is configured in ReSharper.
dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods
dotnet_naming_rule.async_methods_end_in_async.style = camel
dotnet_naming_rule.async_methods_end_in_async.severity = none
dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_style.camel.capitalization = camel_case

View file

@ -6,16 +6,13 @@ jobs:
test-build-and-push:
runs-on: docker
container:
image: iceshrimp.dev/iceshrimp/ci-env:dotnet9
image: iceshrimp.dev/iceshrimp/ci-env:dotnet
options: |
--volume /opt/iceshrimp-cache/nuget:/root/.nuget
--volume /root/.docker:/root/.docker
steps:
- name: Clone repository
run: git clone "$REPO" --branch="$BRANCH" --depth=1 .
env:
REPO: ${{ github.event.repository.clone_url }}
BRANCH: ${{ github.ref_name }}
run: git clone ${{ github.event.repository.clone_url }} --branch=${{ github.ref_name }} --depth=1 .
- name: Print environment info
run: dotnet --info
- name: Run unit tests
@ -24,10 +21,7 @@ jobs:
shell: bash
run: |
make cleanall >/dev/null
docker login iceshrimp.dev -u "$USER" -p "$TOKEN"
docker login iceshrimp.dev -u ${{ github.actor }} -p ${{ secrets.REGISTRY_TOKEN }}
docker buildx create --name iceshrimp-ci 2>&1 &>/dev/null || true
docker buildx build -t "iceshrimp.dev/${GITHUB_REPOSITORY@L}:$GITHUB_REF_NAME" --provenance=false --platform=linux/amd64,linux/arm64 --push --builder iceshrimp-ci .
docker buildx build -t iceshrimp.dev/${GITHUB_REPOSITORY@L}:$GITHUB_REF_NAME --provenance=false --platform=linux/amd64,linux/arm64 --push --builder iceshrimp-ci .
docker buildx prune --keep-storage 20G --builder iceshrimp-ci
env:
USER: ${{ github.actor }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}

View file

@ -6,23 +6,17 @@ jobs:
build-artifacts:
runs-on: docker
container:
image: iceshrimp.dev/iceshrimp/ci-env:dotnet9-wasm
image: iceshrimp.dev/iceshrimp/ci-env:dotnet-wasm
options: |
--volume /opt/iceshrimp-cache/nuget:/root/.nuget
--volume /root/.docker:/root/.docker
steps:
- name: Clone repository
run: git clone "$REPO" --branch="$BRANCH" --depth=1 .
env:
REPO: ${{ github.event.repository.clone_url }}
BRANCH: ${{ github.ref_name }}
run: git clone ${{ github.event.repository.clone_url }} --branch=${{ github.ref_name }} --depth=1 .
- name: Print environment info
run: dotnet --info
- name: Build release artifacts
run: make release-artifacts "ARCHIVE_BASENAME=$REPO" "ARCHIVE_VERSION=$RELEASE_VERSION" VERBOSE=true DEP_VULN_WERROR=true
env:
REPO: ${{ github.event.repository.name }}
RELEASE_VERSION: ${{ github.ref_name }}
run: make release-artifacts ARCHIVE_BASENAME=${{ github.event.repository.name }} ARCHIVE_VERSION=${{ github.ref_name }} VERBOSE=true DEP_VULN_WERROR=true
- name: Upload artifacts
uses: actions/release-action@main
with:
@ -37,8 +31,8 @@ jobs:
# 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"
# The first section below can be safely removed once v2025.1 hits stable
if [[ "$GITHUB_REF_NAME" == "v2025.1-beta"* ]]; then
# The first section below can be safely removed once v2024.1 hits stable
if [[ "$GITHUB_REF_NAME" == "v2024.1-beta"* ]]; then
TAGS="$TAGS -t $REPO:latest"
elif [[ "$GITHUB_REF_NAME" == *"-beta"* ]] || [[ "$GITHUB_REF_NAME" == *"-pre"* ]]; then
:
@ -50,10 +44,7 @@ jobs:
echo "TAGS=$TAGS" >> "${GITHUB_ENV}"
- name: Build docker image
run: |
docker login iceshrimp.dev -u "$USER" -p "$TOKEN"
docker login iceshrimp.dev -u ${{ github.actor }} -p ${{ secrets.REGISTRY_TOKEN }}
docker buildx create --name iceshrimp-ci 2>&1 &>/dev/null || true
docker buildx build ${{ env.TAGS }} --provenance=false --platform=linux/amd64,linux/arm64 --push --builder iceshrimp-ci -f ./.docker/dotnet-runner-9.0.Dockerfile ./release
docker buildx build ${{ env.TAGS }} --provenance=false --platform=linux/amd64,linux/arm64 --push --builder iceshrimp-ci -f ./.docker/dotnet-runner-8.0.Dockerfile ./release
docker buildx prune --keep-storage 20G --builder iceshrimp-ci
env:
USER: ${{ github.actor }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}

View file

@ -7,18 +7,15 @@ jobs:
test-build:
runs-on: docker
container:
image: iceshrimp.dev/iceshrimp/ci-env:dotnet9
image: iceshrimp.dev/iceshrimp/ci-env:dotnet
options: --volume /opt/iceshrimp-cache/nuget:/root/.nuget
steps:
- name: Clone repository
run: |
git init -b test-build
git remote add origin "$REPO"
git fetch origin "$REF" --depth=1
git remote add origin ${{ github.event.repository.clone_url }}
git fetch origin ${{ github.ref }} --depth=1
git checkout --detach FETCH_HEAD
env:
REPO: ${{ github.event.repository.clone_url }}
REF: ${{ github.ref }}
- name: Print environment info
run: dotnet --info
- name: Run unit tests

View file

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

View file

@ -1,475 +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
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.
### Backend
- Several DoS & stack overflow vulnerabilities in the MFM parser were resolved
### Miscellaneous
- Performance of the MFM parser (and by extension, the frontend) should be significantly improved, as the backport of the security fixes also contains all other performance-related changes since v2024.1-beta4.
### Attribution
This release was made possible by project contributors: Laura Hausmann
Furthermore, I want to give special thanks to Natty for helping with investigating this vulnerability.
## v2024.1-beta4.security1
This is a security hotfix release. It's identical to v2024.1-beta4, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
### Backend
- ActivityPub actor and note validation has been improved & now protects against cross-origin identifiers in more places, resolving a database pollution vulnerability
- Cross-origin `url` properties on actor & note objects now get set to null before ingestion, resolving a clickjacking vulnerability
- User resolution when processing incoming notes is now limited
### Attribution
This release was made possible by project contributors: Laura Hausmann
Furthermore, I want to give special thanks to Hazel Koehler for the vulnerability disclosure.
## v2024.1-beta4
This release contains lots of new features & bug fixes, including security fixes. Upgrading is strongly recommended for all server operators.
### Release notes
This release contains a **breaking change** regarding the configuration file. If you have configured a *natural duration* using the units for \[w\]eeks, \[m\]onths, or \[y\]ears, please update your configuration file to use one of \[s\]econds, \[m\]inutes, \[h\]ours, and \[d\]ays. This change was necessary to accommodate the newly added minute unit.
Furthermore, this release contains a migration that may take a while, as it goes through every note in the database in order to migrate to a new thread schema required for reply backfilling.
### Highlights
- Akkoma clients are now supported, including Akko-FE
- Note reply backfilling is now available as an opt-in experimental feature
- Index redirects for unauthenticated users are now configurable
- Incoming, outgoing, local-local & remote-remote account migrations are now supported
- Inbox jobs are now retried with exponential backoff
- Connecting to relays is now supported
- Reject & rewrite policies are now supported & can be arbitrarily extended via plugins
- Full text search now also searches for alt text matches
- Basic moderation actions are now supported
- A basic admin dashboard has been added
- Commands for fixing up media & pruning unreferenced files have been added
- The frontend now shows significantly more note details
- The frontend layout & stylesheet have been significantly refined
- The follow list can now be imported & exported
### Blazor frontend
- Version information is now displayed correctly
- The .NET Runtime version is now shown on the about page
- Note footer buttons now have correct accessibility labeling
- Notifications have received a visual overhaul
- Unsupported notification details are now displayed
- Bites notifications are now rendered correctly
- Buttons to bite users and notes have been added
- Note search now supports state reconstruction
- Links now open in a new tab
- Initial loads for the single note view have been reworked
- Reply count is now shown next to the reply button
- Replies are now shown inline on the timeline
- Replies to inaccessible notes are now marked with a lock
- The login page now redirects to the previous page after successful authentication
- Erroneous "note not found" messages in the single note view have been resolved
- Long notes now get truncated correctly
- Accessibility issues with the compose dialog have been resolved
- The main layout now carries accessibility landmarks
- Profile images on notes are now indicated as being links
- Various bits of missing alt text have been added
- User profiles now show the profile banner, if set
- Verified, birthday & location fields now have appropriate icons
### Backend
- The content root path is now set to the assembly directory instead of the working directory
- Additional domains to permit can now be added in the configuration file
- Reply notifications are no longer generated for remote users
- User creations with database conflicts now fail early and with a better error message
- Paginated collections are now handled correctly
- Raw JSON-LD value types are now deserialized correctly
- Dead instances are no longer erroneously marked as responsive
- The program now exits when started with --migrate & no pending migrations
- Several long-running tasks now consume less memory due to improved database abstractions
- Files larger than 128MB can now be uploaded
- Non-image attachments no longer have leading dashes erroneously added to their filenames
- Drive files can now be deleted
- Links converted from HTML now get shortened if the url and text components match
- The local-only flag is now enforced for renotes & replies of local-only notes
- Invalid accept activities now have improved error logging
- Mention in parentheses are now parsed correctly
- The media cleanup task no longer causes database query warnings
- Newlines surrounding code blocks are now handled correctly
- Code blocks are now serialized correctly
- Erroneous job timeouts are no longer logged
- Job timeouts now log improved error messages
- Queue exceptions are no longer logged twice
- The prune-designer-cs-files helper script has been relicensed under MIT
- Inbox queue logs have been improved
- Creating local follow relationships no longer cause errors related to instance stats
- Delayed jobs can now be abandoned in the queue dashboard
- Renotes & quotes mentioning muted/blocked users now get filtered
- System users can no longer be followed
- Reply/renote accessibility is now indicated correctly in Web API responses
- Zero-durations in the configuration file now get treated the same regardless of their suffix
- Media cleanup can now be triggered manually
- Punycode hosts are now represented in lowercase everywhere
- Deep threads no longer cause API errors
- Emoji can now be marked as sensitive
- Erroneous inbox job failures for activities referencing deleted notes have been resolved
- System users can no longer log in or create notes
- Avatar & banner updates now set the denormalized URLs to the AccessUrl instead of the regular Url
- Files served by /files are now returned as inline attachments
- Endpoints to get all blocked/allowed instances have been added
- Log messages related to jobs that were queued for more than 10 seconds have been improved
- The background-task queue timeout has been increased to accommodate longer-running tasks
- The inbox queue timeout has been increased to accommodate longer-running jobs
- Erroneous voter counts for polls from instances that don't return a voter count value have been resolved
- Drive file expiry no longer leaves orphaned file versions in the storage backend
- MFM fn nodes now get parsed correctly
- Content warnings can now be searched for explicitly using the cw: search filter
- The replies collection is now exposed for local notes
- A bug in the drive file cleanup job related to locally stored files has been resolved
- The job queue now supports a mutex field to prevent the same job from being queued by multiple threads
- Negative voter counts are now rejected
- It's now possible to bite users, posts & other bites
- InboxValidationMiddleware error handling has been improved
- A typo causing confusing log messages in ActivityHandlerService has been fixed
- UserResolver has been fully reworked, deduplicating significant amounts of code & greatly limiting attack surface, as well as improving consistency & performance
- Endpoints for listing note likes, renotes & quotes have been added
- Web API responses now use RestPagination instead of LinkPagination
- Stripped reply data is now returned for the note ascendants & descendants Web API endpoints
- The request trace identifier is now returned as a header even when no errors have occurred
- The WebFinger JSON-LD context definition is now preloaded
- The natural duration configuration parser has been reworked to support seconds & minutes. Support for weeks, months & years has been removed.
- Lists using stars as item indicators no longer get mis-parsed by libmfm
- HTTP/2 is now preferred for outgoing connections
- The StreamingService render-only-once mutex implementation has been fixed
- DriveController is no longer serving files with possibly invalid extensions
- The thread mute endpoints no longer have incorrect rate limits
- A bug causing some followers-only renote activities to be registered as specified has been fixed
- Stricter guard clauses have been added to some federation-related methods
- ActivityPub URIs are now enforced to be https everywhere
- More efficient time & duration is now being used where applicable
- An edge case related to local mentions in profile fields & bios has been resolved
- Followers can now be removed via a new endpoint
### Razor (public preview, admin panel, queue dashboard, etc.)
- Basic user page public preview has been added
- Razor pages now carry a footer with login, instance & version information
- The RestrictedNoMedia public preview mode is now enforced
- Avatars are now replaced with identicons when public preview mode is set to RestrictedNoMedia
- Public hashtag preview now displays a placeholder instead of loading the blazor frontend
- When public preview is disabled, a better error message is now shown
- The instance name is now shown in the title of queue dashboard pages
- You can now click on avatars & display names of users on public note preview pages
- The queue dashboard now allows for batch retries of failed jobs
- Custom emoji are now displayed on public preview pages
- The error page for disabled public preview now has a login button
- Public preview pages have been rebuilt using Blazor SSR (Razor components)
- Sensitive media is now skipped for public preview embeds
- Public preview embeds with images now use the correct card type
- Delayed jobs with a retry count of zero are now marked as scheduled on the queue dashboard
- The entries in the queue dashboard overview table are now clickable
- Abandoning or descheduling jobs in the queue dashboard now requires confirmation
- CSS & JS files are now versioned on razor pages & blazor SSR
### Mastodon client API
- Blockquotes are now handled better for some clients
- Reaction notification are now shown in supported clients
- The git revision is no longer reported in the backend version string
- The bite extension is now supported, allowing bites to originate from compatible clients
### Akkoma client API
- Akkoma-specific endpoints have been implemented, adding support for Akkoma clients, including Akko-FE
### Miscellaneous
- The frontend is no longer unnecessarily rebuilt during CI runs
- SECURITY.md has been added to the repository root
- FEDERATION.md has been updated to reflect support for FEP-9fde
- Vulnerable dependency checks no longer cause build failures by default. To opt back in to the previous behavior, add the `DependencyVulnsAsError=true` build flag, or the `DEP_VULN_WERROR=true` make flag.
### Attribution
This release was made possible by project contributors: Jeder, Laura Hausmann, Lilian, Samuel Proulx, kopper, notfire & pancakes
## v2024.1-beta3.patch1
This is a hotfix release. It's identical to v2024.1-beta3, except for a bunch of fixed frontend crashes. Upgrading is strongly recommended for all server operators.

View file

@ -1,7 +1,7 @@
<Project>
<!-- Target framework & language version -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
@ -14,6 +14,11 @@
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
</PropertyGroup>
<!-- Analyze dependencies for all dependencies, not just direct ones. Remove when upgrading to net90-->
<PropertyGroup>
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
<!-- Except low and medium severity vulnerable dependency warnings from TreatWarningsAsErrors, leaving high and critical severity ones intact -->
<PropertyGroup>
<WarningsNotAsErrors>$(WarningsNotAsErrors),NU1901,NU1902</WarningsNotAsErrors>
@ -26,8 +31,8 @@
<!-- Version metadata -->
<PropertyGroup>
<VersionPrefix>2025.1</VersionPrefix>
<VersionSuffix>beta5.patch2.security1</VersionSuffix>
<VersionPrefix>2024.1</VersionPrefix>
<VersionSuffix>beta3.patch1</VersionSuffix>
</PropertyGroup>
<ItemGroup>
@ -49,8 +54,5 @@
<!-- Enable Blazor AOT compilation when EnableAOT build flag is set -->
<PropertyGroup Condition="'$(EnableAOT)' == 'true'">
<RunAOTCompilation>true</RunAOTCompilation>
<EmccCompileOptimizationFlag>-O3</EmccCompileOptimizationFlag>
<EmccLinkOptimizationFlag>-O3</EmccLinkOptimizationFlag>
<WasmBitcodeCompileOptimizationFlag>-O3</WasmBitcodeCompileOptimizationFlag>
</PropertyGroup>
</Project>

View file

@ -1,25 +1,28 @@
# syntax=docker/dockerfile-upstream:master
# To build with ILLink & AOT enabled, run docker build --build-arg="AOT=true"
# To build with AOT enabled, run docker build --build-arg="AOT=true"
# To build without VIPS support, run docker build --build-arg="VIPS=false"
# We have to build AOT images on linux-glibc, at least until .NET 9.0 (See https://github.com/dotnet/sdk/issues/32327 for details)
ARG AOT=false
ARG IMAGE=${AOT/true/alpine-wasm}
ARG IMAGE=${AOT/true/wasm}
ARG IMAGE=${IMAGE/false/alpine}
FROM --platform=$BUILDPLATFORM iceshrimp.dev/iceshrimp/dotnet-sdk:9.0-$IMAGE AS builder
ARG RUNNER=${AOT/true/noble-chiseled}
ARG RUNNER=${RUNNER/false/alpine}
FROM --platform=$BUILDPLATFORM iceshrimp.dev/iceshrimp/dotnet-sdk:8.0-$IMAGE AS builder
WORKDIR /src
ARG BUILDPLATFORM
ARG AOT=false
# copy csproj files & nuget config, then restore as distinct layers
# copy csproj/fsproj & nuget config, then restore as distinct layers
COPY NuGet.Config /src
COPY Iceshrimp.Backend/*.csproj /src/Iceshrimp.Backend/
COPY Iceshrimp.Parsing/*.csproj /src/Iceshrimp.Parsing/
COPY Iceshrimp.Parsing/*.fsproj /src/Iceshrimp.Parsing/
COPY Iceshrimp.Frontend/*.csproj /src/Iceshrimp.Frontend/
COPY Iceshrimp.Shared/*.csproj /src/Iceshrimp.Shared/
COPY Iceshrimp.Build/*.csproj /src/Iceshrimp.Build/
COPY Iceshrimp.Build/*.props /src/Iceshrimp.Build/
COPY Directory.Build.props /src/Directory.Build.props
WORKDIR /src/Iceshrimp.Backend
@ -32,7 +35,6 @@ COPY Iceshrimp.Backend/ /src/Iceshrimp.Backend/
COPY Iceshrimp.Parsing/ /src/Iceshrimp.Parsing/
COPY Iceshrimp.Frontend/ /src/Iceshrimp.Frontend/
COPY Iceshrimp.Shared/ /src/Iceshrimp.Shared/
COPY Iceshrimp.Build/ /src/Iceshrimp.Build/
# copy files required for sourcelink
COPY .git/HEAD /src/.git/HEAD
@ -58,7 +60,7 @@ RUN --mount=type=cache,target=/root/.nuget \
# Enable globalization and time zones:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md
# final stage/image
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine-composite AS image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-$RUNNER-composite AS image
WORKDIR /app
COPY --from=builder /app .
USER app

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.
- 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:
* `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`.
+ 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/)
@ -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).
+ 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** 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).
+ 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.
* 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)
- 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.
@ -68,10 +66,6 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
- [FEP-9fde: Mechanism for servers to expose supported operations](https://codeberg.org/fediverse/fep/src/branch/main/fep/9fde/fep-9fde.md)
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
+ Specifically, we use it in a "conversational context" sense, where each note has an attached context, which maps to an internal "thread".
+ We currently do not use the context for anything other than grouping.
+ Our context collections contain objects, not activities.
## FEPs we intend to support in the future
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)

View file

@ -2,6 +2,5 @@
@using Iceshrimp.Backend.Components.Helpers
<HeadContent>
<VersionedLink rel="stylesheet" href="/css/admin.css"/>
<VersionedLink rel="stylesheet" href="/_content/Iceshrimp.Assets.PhosphorIcons/css/ph-regular.css"/>
<VersionedScript src="/js/admin.js"/>
</HeadContent>

View file

@ -1,22 +0,0 @@
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Backend.Components.Generic
<NavBar Brand="_brand" Links="_links" Right="_right" MaxItemsLg="7" MaxItemsMd="3"/>
@code
{
private NavBar.NavLink _brand = new("/admin", "Admin Dashboard");
private List<NavBar.NavLink> _links =
[
new("/admin", "Overview", Icons.ChartLine), // spacer for alignment
new("/admin/metadata", "Instance metadata", Icons.Info),
new("/admin/rules", "Rules", Icons.Scales),
new("/admin/users", "User management", Icons.Users),
new("/admin/federation", "Federation control", Icons.Graph),
new("/admin/relays", "Relays", Icons.FastForward),
new("/admin/tasks", "Cron tasks", Icons.Timer),
new("/admin/plugins", "Plugins", Icons.Plug)
];
private List<NavBar.NavLink> _right = [new("/queue", "Queue dashboard", IconRight: Icons.ArrowSquareOut, NewTab: true)];
}

View file

@ -1,7 +1,7 @@
@using Microsoft.AspNetCore.Components.Web
<PageTitle>@Title - Admin - @(InstanceName ?? "Iceshrimp.NET")</PageTitle>
<AdminHead/>
<AdminNav/>
<h1>Admin Dashboard</h1>
<button role="link" data-target="/admin" onclick="navigate(event)">Return to overview</button>
<h2>@Title</h2>
@code {

View file

@ -1,152 +0,0 @@
@using Iceshrimp.Assets.PhosphorIcons
@using Iceshrimp.Backend.Components.Helpers
<nav class="navbar navbar-lg">
<ul>
<li>
<a href="@Brand.Href" class="brand">@Brand.Name</a>
</li>
</ul>
<ul>
@foreach (var link in Links[..Math.Min(MaxItemsLg, Links.Count)])
{
<li>
<NavBarLink Link="link"/>
</li>
}
@if (OverflowsLg)
{
<li class="dropdown">
<a class="dropdown-button" tabindex="0">
<Icon Name="Icons.DotsThree" Size="20pt"/>
</a>
<ul class="dropdown-menu">
@foreach (var link in Links[MaxItemsLg..])
{
<li>
<NavBarLink Link="link"/>
</li>
}
@if (Right is { Count: > 0 })
{
if (MaxItemsLg != Links.Count)
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right)
{
<li>
<NavBarLink Link="link"/>
</li>
}
}
</ul>
</li>
}
</ul>
@if (!OverflowsLg)
{
<ul class="nav-right">
@if (Right is { Count: > 0 })
{
foreach (var link in Right)
{
<li>
<NavBarLink Link="link"/>
</li>
}
}
</ul>
}
</nav>
<nav class="navbar navbar-md">
<ul>
<li>
<a href="@Brand.Href" class="brand">@Brand.Name</a>
</li>
</ul>
<ul>
@foreach (var link in Links[..Math.Min(MaxItemsMd, Links.Count)])
{
<li>
<NavBarLink Link="link"/>
</li>
}
@if (OverflowsMd)
{
<li class="dropdown">
<a class="dropdown-button" tabindex="0">
<Icon Name="Icons.DotsThree" Size="20pt"/>
</a>
<ul class="dropdown-menu">
@foreach (var link in Links[MaxItemsMd..])
{
<li>
<NavBarLink Link="link"/>
</li>
}
@if (Right is { Count: > 0 })
{
if (MaxItemsMd != Links.Count)
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right)
{
<li>
<NavBarLink Link="link"/>
</li>
}
}
</ul>
</li>
}
</ul>
</nav>
<nav class="navbar navbar-sm">
<ul>
<li>
<a href="@Brand.Href" class="brand">@Brand.Name</a>
</li>
</ul>
<ul class="nav-right">
<li>
<a class="hamburger-button" href="#" onclick="toggleHamburger(event)">
<Icon Name="Icons.List" Size="20pt"/>
</a>
<ul class="hamburger-menu hidden">
@foreach (var link in Links)
{
<li>
<NavBarLink Link="link"/>
</li>
}
@if (Right is { Count: > 0 })
{
<li class="hamburger-spacer"></li>
@foreach (var link in Right)
{
<li>
<NavBarLink Link="link"/>
</li>
}
}
</ul>
</li>
</ul>
</nav>
<div class="navbar-placeholder"></div>
<VersionedScript src="/Components/Generic/NavBar.razor.js"/>
@code {
public record struct NavLink(string Href, string Name, IconName? Icon = null, IconName? IconRight = null, bool NewTab = false);
[Parameter, EditorRequired] public required int MaxItemsLg { get; set; }
[Parameter, EditorRequired] public required int MaxItemsMd { get; set; }
[Parameter, EditorRequired] public required NavLink Brand { get; set; }
[Parameter, EditorRequired] public required List<NavLink> Links { get; set; }
[Parameter] public List<NavLink>? Right { get; set; }
private bool OverflowsLg => Links.Count + (Right?.Count ?? 0) > MaxItemsLg;
private bool OverflowsMd => Links.Count + (Right?.Count ?? 0) > MaxItemsMd;
}

View file

@ -1,151 +0,0 @@
.navbar-placeholder {
margin-top: 60px;
}
.navbar {
position: absolute;
left: 0;
top: 0;
right: 0;
height: 50px;
padding: 0;
background-color: var(--button-base);
display: flex;
align-items: center;
white-space: nowrap;
vertical-align: middle;
}
a {
text-decoration: none;
}
.navbar ::deep a {
color: var(--text-main);
}
.navbar ul {
margin: 0;
padding: 0;
height: 100%;
list-style-type: none;
display: flex;
align-items: center;
}
.navbar .brand {
color: var(--text-bright);
padding: 0 15px;
font-weight: bold;
}
.navbar ul.nav-right:last-of-type li:last-of-type ::deep a {
padding: 0 15px;
}
.navbar ul li {
height: 100%;
}
.navbar ul li ::deep a {
color: var(--text-main);
padding: 0 12px;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4pt;
}
.navbar ::deep a.active,
.navbar ul ::deep a:hover,
.navbar ul ::deep a:focus,
.navbar .brand:hover,
.navbar .brand:focus {
background-color: var(--button-hover);
text-decoration: none;
}
.navbar ul.nav-right {
margin-left: auto;
}
.dropdown-button {
cursor: pointer;
}
.dropdown:hover > .dropdown-menu,
.dropdown:focus-within > .dropdown-menu,
.dropdown-menu:hover,
.dropdown-menu:focus {
visibility: visible;
opacity: 1;
display: block !important;
}
.dropdown {
position: relative;
}
.dropdown-menu {
opacity: 0;
min-width: 5rem;
position: absolute;
margin-top: 1rem;
left: 0;
z-index: +1;
display: none !important;
}
ul.dropdown-menu li {
background-color: var(--button-base);
}
li.dropdown-spacer,
li.hamburger-spacer {
height: 1px !important;
border-top: 1px solid var(--border);
filter: brightness(65%);
}
.hamburger-button {
cursor: pointer;
}
.hamburger-menu.hidden {
display: none !important;
}
.hamburger-menu {
position: absolute;
right: 0;
width: 100%;
display: inline-block !important;
z-index: +1;
}
ul.hamburger-menu li {
display: block !important;
background-color: var(--button-base);
}
.navbar-lg {
display: none;
@media screen and (min-width: 1200px) {
display: flex;
}
}
.navbar-md {
display: none;
@media screen and (min-width: 800px) and (max-width: 1199px) {
display: flex;
}
}
.navbar-sm {
display: none;
@media screen and (max-width: 799px) {
display: flex;
}
}

View file

@ -1,11 +0,0 @@
function toggleHamburger(e) {
e.preventDefault();
for (let el of document.getElementsByClassName("hamburger-menu")) {
if (el.classList.contains("hidden")) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
}

View file

@ -1,17 +0,0 @@
@using Iceshrimp.Assets.PhosphorIcons
<NavLink href="@Link.Href" class="nav-link" Match="NavLinkMatch.AllExcludingQuery" target="@Target">
@if (Link.Icon != null)
{
<Icon Name="Link.Icon"/>
}
@Link.Name
@if (Link.IconRight != null)
{
<Icon Name="Link.IconRight"/>
}
</NavLink>
@code {
[Parameter, EditorRequired] public required NavBar.NavLink Link { get; set; }
private string Target => Link.NewTab ? "_blank" : "_self";
}

View file

@ -1,244 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
namespace Iceshrimp.Backend.Components.Generic;
/// <summary>
/// A component that renders an anchor tag, automatically toggling its 'active'
/// class based on whether its 'href' matches the current URI.
/// </summary>
public class NavLink : ComponentBase, IDisposable
{
private const string DefaultActiveClass = "active";
private bool _isActive;
private string? _hrefAbsolute;
private string? _class;
/// <summary>
/// Gets or sets the CSS class name applied to the NavLink when the
/// current route matches the NavLink href.
/// </summary>
[Parameter]
public string? ActiveClass { get; set; }
/// <summary>
/// Gets or sets a collection of additional attributes that will be added to the generated
/// <c>a</c> element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
/// <summary>
/// Gets or sets the computed CSS class based on whether or not the link is active.
/// </summary>
protected string? CssClass { get; set; }
/// <summary>
/// Gets or sets the child content of the component.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Gets or sets a value representing the URL matching behavior.
/// </summary>
[Parameter]
public NavLinkMatch Match { get; set; }
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
/// <inheritdoc />
protected override void OnInitialized()
{
// We'll consider re-rendering on each location change
NavigationManager.LocationChanged += OnLocationChanged;
}
/// <inheritdoc />
protected override void OnParametersSet()
{
// Update computed state
string? href = null;
if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("href", out var obj))
{
href = Convert.ToString(obj, CultureInfo.InvariantCulture);
}
_hrefAbsolute = href == null ? null : NavigationManager.ToAbsoluteUri(href).AbsoluteUri;
_isActive = ShouldMatch(NavigationManager.Uri);
_class = null;
if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("class", out obj))
{
_class = Convert.ToString(obj, CultureInfo.InvariantCulture);
}
UpdateCssClass();
}
/// <inheritdoc />
public void Dispose()
{
// To avoid leaking memory, it's important to detach any event handlers in Dispose()
NavigationManager.LocationChanged -= OnLocationChanged;
}
private void UpdateCssClass()
{
CssClass = _isActive ? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass) : _class;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
// We could just re-render always, but for this component we know the
// only relevant state change is to the _isActive property.
var shouldBeActiveNow = ShouldMatch(args.Location);
if (shouldBeActiveNow != _isActive)
{
_isActive = shouldBeActiveNow;
UpdateCssClass();
StateHasChanged();
}
}
private bool ShouldMatch(string currentUriAbsolute)
{
if (_hrefAbsolute == null)
return false;
if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
return true;
if (Match == NavLinkMatch.AllExcludingQuery && EqualsHrefExcludingQuery(currentUriAbsolute))
return true;
return Match == NavLinkMatch.Prefix && IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute);
}
private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
{
if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (currentUriAbsolute.Length == _hrefAbsolute!.Length - 1)
{
// Special case: highlight links to http://host/path/ even if you're
// at http://host/path (with no trailing slash)
//
// This is because the router accepts an absolute URI value of "same
// as base URI but without trailing slash" as equivalent to "base URI",
// which in turn is because it's common for servers to return the same page
// for http://host/vdir as they do for host://host/vdir/ as it's no
// good to display a blank page in that case.
if (_hrefAbsolute[^1] == '/'
&& _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private bool EqualsHrefExcludingQuery(string currentUriAbsolute)
{
Debug.Assert(_hrefAbsolute != null);
currentUriAbsolute = currentUriAbsolute.Split('?')[0];
if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
{
// Special case: highlight links to http://host/path/ even if you're
// at http://host/path (with no trailing slash)
//
// This is because the router accepts an absolute URI value of "same
// as base URI but without trailing slash" as equivalent to "base URI",
// which in turn is because it's common for servers to return the same page
// for http://host/vdir as they do for host://host/vdir/ as it's no
// good to display a blank page in that case.
if (_hrefAbsolute[^1] == '/'
&& _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <inheritdoc/>
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "a");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
if (_isActive)
{
builder.AddAttribute(3, "aria-current", "page");
}
builder.AddContent(4, ChildContent);
builder.CloseElement();
}
private static string CombineWithSpace(string? str1, string str2) => str1 == null ? str2 : $"{str1} {str2}";
private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
{
var prefixLength = prefix.Length;
if (value.Length > prefixLength)
{
return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& (
// Only match when there's a separator character either at the end of the
// prefix or right after it.
// Example: "/abc" is treated as a prefix of "/abc/def" but not "/abcdef"
// Example: "/abc/" is treated as a prefix of "/abc/def" but not "/abcdef"
prefixLength == 0
|| !IsUnreservedCharacter(prefix[prefixLength - 1])
|| !IsUnreservedCharacter(value[prefixLength])
);
}
return false;
}
private static bool IsUnreservedCharacter(char c)
{
// Checks whether it is an unreserved character according to
// https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
// Those are characters that are allowed in a URI but do not have a reserved
// purpose (e.g. they do not separate the components of the URI)
return char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~';
}
}
/// <summary>
/// Modifies the URL matching behavior for a <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" />.
/// </summary>
public enum NavLinkMatch
{
/// <summary>
/// Specifies that the <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" /> should be active when it matches any prefix
/// of the current URL.
/// </summary>
Prefix,
/// <summary>
/// Specifies that the <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" /> should be active when it matches the entire
/// current URL.
/// </summary>
All,
AllExcludingQuery
}

View file

@ -35,14 +35,14 @@ public class AsyncComponentBase : ComponentBase
{
OnParametersSet();
await OnParametersSetAsync();
await RunMethodHandlerAsync();
await RunMethodHandler();
StateHasChanged();
}
protected virtual Task OnPost() => Task.CompletedTask;
protected virtual Task OnGet() => Task.CompletedTask;
private async Task RunMethodHandlerAsync()
private async Task RunMethodHandler()
{
if (string.Equals(Context.Request.Method, "GET", StringComparison.InvariantCultureIgnoreCase))
await OnGet();

View file

@ -1,10 +1,15 @@
<link @attributes="AdditionalAttributes" href="/@Assets[href.TrimStart('/')]"/>
@using Microsoft.AspNetCore.Mvc.ViewFeatures
<link @attributes="AdditionalAttributes" href="@VersionedHref"/>
@code {
[Inject] public required IFileVersionProvider FileVersionProvider { get; set; }
@* ReSharper disable InconsistentNaming *@
[Parameter, EditorRequired] public required string href { get; set; }
@* ReSharper restore InconsistentNaming *@
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string VersionedHref => FileVersionProvider.AddFileVersionToPath("", href);
}

View file

@ -1,10 +1,15 @@
<script src="/@Assets[src.TrimStart('/')]" @attributes="AdditionalAttributes"></script>
@using Microsoft.AspNetCore.Mvc.ViewFeatures
<script src="@VersionedSrc" @attributes="AdditionalAttributes"></script>
@code {
[Inject] public required IFileVersionProvider FileVersionProvider { get; set; }
@* ReSharper disable InconsistentNaming *@
[Parameter, EditorRequired] public required string src { get; set; }
@* ReSharper restore InconsistentNaming *@
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string VersionedSrc => FileVersionProvider.AddFileVersionToPath("", src);
}

View file

@ -1,11 +1,9 @@
using AngleSharp.Io;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.AspNetCore.Routing.Matching;
namespace Iceshrimp.Backend.Components.PublicPreview.Attributes;
public class PublicPreviewRouteMatcher : MatcherPolicy, IEndpointSelectorPolicy, ISingletonService,
IService<MatcherPolicy>
public class PublicPreviewRouteMatcher : MatcherPolicy, IEndpointSelectorPolicy
{
public override int Order => 99999; // That's ActionConstraintMatcherPolicy - 1

View file

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

View file

@ -3,7 +3,6 @@ using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -13,10 +12,9 @@ public class NoteRenderer(
DatabaseContext db,
UserRenderer userRenderer,
MfmRenderer mfm,
MediaProxyService mediaProxy,
IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> security
) : IScopedService
)
{
public async Task<PreviewNote?> RenderOne(Note? note)
{
@ -24,35 +22,28 @@ public class NoteRenderer(
var allNotes = ((Note?[]) [note, note.Reply, note.Renote]).NotNull().ToList();
var mentions = await GetMentionsAsync(allNotes);
var emoji = await GetEmojiAsync(allNotes);
var users = await GetUsersAsync(allNotes);
var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes);
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var users = await GetUsers(allNotes);
var attachments = await GetAttachments(allNotes);
return Render(note, users, mentions, emoji, attachments, polls);
return await Render(note, users, mentions, emoji, attachments);
}
private PreviewNote Render(
private async Task<PreviewNote> Render(
Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions,
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments,
Dictionary<string, PreviewPoll> polls
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments
)
{
var renderedText = mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span", attachments[note.Id]);
var inlineMediaUrls = renderedText?.InlineMedia.Select(m => m.Src).ToArray() ?? [];
var res = new PreviewNote
{
User = users.First(p => p.Id == note.User.Id),
Text = renderedText?.Html,
Text = await mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span"),
Cw = note.Cw,
RawText = note.Text,
Uri = note.Uri ?? note.GetPublicUri(instance.Value),
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
Poll = polls.GetValueOrDefault(note.Id),
Attachments = attachments[note.Id],
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
};
@ -60,7 +51,7 @@ public class NoteRenderer(
return res;
}
private async Task<Dictionary<string, List<Note.MentionedUser>>> GetMentionsAsync(List<Note> notes)
private async Task<Dictionary<string, List<Note.MentionedUser>>> GetMentions(List<Note> notes)
{
var mentions = notes.SelectMany(n => n.Mentions).Distinct().ToList();
if (mentions.Count == 0) return notes.ToDictionary<Note, string, List<Note.MentionedUser>>(p => p.Id, _ => []);
@ -78,7 +69,7 @@ public class NoteRenderer(
p => users.Where(u => p.Mentions.Contains(u.Key)).Select(u => u.Value).ToList());
}
private async Task<Dictionary<string, List<Emoji>>> GetEmojiAsync(List<Note> notes)
private async Task<Dictionary<string, List<Emoji>>> GetEmoji(List<Note> notes)
{
var ids = notes.SelectMany(n => n.Emojis).Distinct().ToList();
if (ids.Count == 0) return notes.ToDictionary<Note, string, List<Emoji>>(p => p.Id, _ => []);
@ -87,13 +78,13 @@ public class NoteRenderer(
return notes.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
}
private async Task<List<PreviewUser>> GetUsersAsync(List<Note> notes)
private async Task<List<PreviewUser>> GetUsers(List<Note> notes)
{
if (notes is []) return [];
return await userRenderer.RenderManyAsync(notes.Select(p => p.User).Distinct().ToList());
return await userRenderer.RenderMany(notes.Select(p => p.User).Distinct().ToList());
}
private async Task<Dictionary<string, List<PreviewAttachment>?>> GetAttachmentsAsync(List<Note> notes)
private async Task<Dictionary<string, List<PreviewAttachment>?>> GetAttachments(List<Note> notes)
{
if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
@ -101,48 +92,28 @@ public class NoteRenderer(
var ids = notes.SelectMany(p => p.FileIds).ToList();
var files = await db.DriveFiles.Where(p => ids.Contains(p.Id)).ToListAsync();
return notes
.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => files
.Where(f => p.FileIds.Contains(f.Id))
.Select(f => new PreviewAttachment
{
MimeType = f.Type,
Url = mediaProxy.GetProxyUrl(f),
Name = f.Name,
Alt = f.Comment,
Sensitive = f.IsSensitive
})
.ToList());
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => files
.Where(f => p.FileIds.Contains(f.Id))
.Select(f => new PreviewAttachment
{
MimeType = f.Type,
Url = f.AccessUrl,
Name = f.Name,
Alt = f.Comment,
Sensitive = f.IsSensitive
})
.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>> RenderMany(List<Note> notes)
{
if (notes is []) return [];
var allNotes = notes.SelectMany<Note, Note?>(p => [p, p.Renote, p.Reply]).NotNull().Distinct().ToList();
var users = await GetUsersAsync(allNotes);
var mentions = await GetMentionsAsync(allNotes);
var emoji = await GetEmojiAsync(allNotes);
var attachments = await GetAttachmentsAsync(allNotes);
var polls = await GetPollsAsync(allNotes);
return notes.Select(p => Render(p, users, mentions, emoji, attachments, polls)).ToList();
var users = await GetUsers(allNotes);
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var attachments = await GetAttachments(allNotes);
return await notes.Select(p => Render(p, users, mentions, emoji, attachments)).AwaitAllAsync().ToListAsync();
}
}
}

View file

@ -13,16 +13,16 @@ public class UserRenderer(
MfmRenderer mfm,
IOptions<Config.InstanceSection> instance,
IOptionsSnapshot<Config.SecuritySection> security
) : IScopedService
)
{
public async Task<PreviewUser?> RenderOne(User? user)
{
if (user == null) return null;
var emoji = await GetEmojiAsync([user]);
return Render(user, emoji);
var emoji = await GetEmoji([user]);
return await Render(user, emoji);
}
private PreviewUser Render(User user, Dictionary<string, List<Emoji>> emoji)
private async Task<PreviewUser> Render(User user, Dictionary<string, List<Emoji>> emoji)
{
var mentions = user.UserProfile?.Mentions ?? [];
@ -32,13 +32,12 @@ public class UserRenderer(
Id = user.Id,
Username = user.Username,
Host = user.Host ?? instance.Value.AccountDomain,
Uri = user.GetUriOrPublicUri(instance.Value),
Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath,
AvatarUrl = user.GetAvatarUrl(instance.Value),
BannerUrl = user.GetBannerUrl(instance.Value),
AvatarUrl = user.AvatarUrl ?? user.IdenticonUrlPath,
BannerUrl = user.BannerUrl,
RawDisplayName = user.DisplayName,
DisplayName = mfm.RenderSimple(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
Bio = mfm.RenderSimple(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
DisplayName = await mfm.Render(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
Bio = await mfm.Render(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
MovedToUri = user.MovedToUri
};
// @formatter:on
@ -52,7 +51,7 @@ public class UserRenderer(
return res;
}
private async Task<Dictionary<string, List<Emoji>>> GetEmojiAsync(List<User> users)
private async Task<Dictionary<string, List<Emoji>>> GetEmoji(List<User> users)
{
var ids = users.SelectMany(n => n.Emojis).Distinct().ToList();
if (ids.Count == 0) return users.ToDictionary<User, string, List<Emoji>>(p => p.Id, _ => []);
@ -61,9 +60,9 @@ public class UserRenderer(
return users.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
}
public async Task<List<PreviewUser>> RenderManyAsync(List<User> users)
public async Task<List<PreviewUser>> RenderMany(List<User> users)
{
var emoji = await GetEmojiAsync(users);
return users.Select(p => Render(p, emoji)).ToList();
var emoji = await GetEmoji(users);
return await users.Select(p => Render(p, emoji)).AwaitAllAsync().ToListAsync();
}
}

View file

@ -8,11 +8,9 @@ public class PreviewNote
public required string? RawText;
public required MarkupString? Text;
public required string? Cw;
public required string? Uri;
public required string? QuoteUrl;
public required bool QuoteInaccessible;
public required List<PreviewAttachment>? Attachments;
public required PreviewPoll? Poll;
public required string CreatedAt;
public required string? UpdatedAt;
}
@ -24,12 +22,4 @@ public class PreviewAttachment
public required string Name;
public required string? Alt;
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 Host;
public required string Url;
public required string Uri;
public required string AvatarUrl;
public required string? BannerUrl;
public required string? MovedToUri;

View file

@ -2,7 +2,6 @@ using System.Net;
using System.Text;
using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -13,7 +12,6 @@ using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
@ -29,9 +27,8 @@ public class ActivityPubController(
QueueService queues,
ActivityPub.NoteRenderer noteRenderer,
ActivityPub.UserRenderer userRenderer,
IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security
) : ControllerBase, IScopedService
IOptions<Config.InstanceSection> config
) : ControllerBase
{
[HttpGet("/notes/{id}")]
[AuthorizedFetch]
@ -55,28 +52,27 @@ public class ActivityPubController(
[HttpGet("/notes/{id}/activity")]
[AuthorizedFetch]
[OverrideResultType<ASActivity>]
[OverrideResultType<ASAnnounce>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetNoteActivity(string id)
public async Task<JObject> GetRenote(string id)
{
var actor = HttpContext.GetActor();
var note = await db.Notes
.IncludeCommonProperties()
.EnsureVisibleFor(actor)
.Where(p => p.Id == id && p.UserHost == null)
.Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null)
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
var noteActor = userRenderer.RenderLite(note.User);
ASActivity activity = note is { IsPureRenote: true, Renote: not null }
? ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote),
note.GetPublicUri(config.Value), noteActor, note.Visibility,
note.User.GetPublicUri(config.Value) + "/followers")
: ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note), noteActor);
return activity.Compact();
return ActivityPub.ActivityRenderer
.RenderAnnounce(noteRenderer.RenderLite(note.Renote!),
note.GetPublicUri(config.Value),
userRenderer.RenderLite(note.User),
note.Visibility,
note.User.GetPublicUri(config.Value) + "/followers")
.Compact();
}
[HttpGet("/notes/{id}/replies")]
@ -94,7 +90,6 @@ public class ActivityPubController(
var replies = await db.Notes
.Where(p => p.ReplyId == id)
.EnsureVisibleFor(actor)
.OrderByDescending(p => p.Id)
.Select(p => new Note { Id = p.Id, Uri = p.Uri })
.ToListAsync();
@ -110,53 +105,15 @@ public class ActivityPubController(
return res.Compact();
}
[HttpGet("/threads/{id}")]
[AuthorizedFetch]
[OverrideResultType<ASOrderedCollection>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetThread(string id)
{
var actor = HttpContext.GetActor();
var thread = await db.NoteThreads
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == id && p.User != null && p.User.IsLocalUser) ??
throw GracefulException.NotFound("Thread not found");
var notes = await db.Notes
.Where(p => p.ThreadId == id)
.EnsureVisibleFor(actor)
.OrderByDescending(p => p.Id)
.Select(p => new Note { Id = p.Id, Uri = p.Uri })
.ToListAsync();
var rendered = notes.Select(noteRenderer.RenderLite).Cast<ASObject>().ToList();
var res = new ASOrderedCollection
{
Id = thread.GetPublicUri(config.Value),
AttributedTo = [new ASObjectBase(thread.User!.GetPublicUri(config.Value))],
TotalItems = (ulong)rendered.Count,
Items = rendered
};
return res.Compact();
}
[HttpGet("/users/{id}")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[OverrideResultType<ASActor>]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ActionResult<JObject>> GetUser(string id)
{
var user = await db.Users
.IncludeCommonProperties()
.Include(p => p.Avatar)
.Include(p => p.Banner)
.FirstOrDefaultAsync(p => p.Id == id);
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id);
if (user == null) throw GracefulException.NotFound("User not found");
if (user.IsRemoteUser)
{
@ -171,7 +128,6 @@ public class ActivityPubController(
[HttpGet("/users/{id}/collections/featured")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[OverrideResultType<ASOrderedCollection>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
@ -200,71 +156,8 @@ public class ActivityPubController(
return res.Compact();
}
[HttpGet("/users/{id}/outbox")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[OverrideResultType<ASOrderedCollectionPage>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetUserOutbox(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser);
if (user == null) throw GracefulException.NotFound("User not found");
var res = new ASOrderedCollection
{
Id = $"{user.GetPublicUri(config.Value)}/outbox",
First = new ASOrderedCollectionPage($"{user.GetPublicUri(config.Value)}/outbox/page"),
};
return res.Compact();
}
[HttpGet("/users/{id}/outbox/page")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[OverrideResultType<ASOrderedCollectionPage>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetUserOutboxPage(string id, [FromQuery] string? maxId)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser);
if (user == null) throw GracefulException.NotFound("User not found");
var actor = HttpContext.GetActor();
if (actor == null && security.Value.PublicPreview == Enums.PublicPreview.Lockdown)
throw new PublicPreviewDisabledException();
var notes = await db.Notes.Where(p => p.UserId == id)
.Include(p => p.User)
.Include(p => p.Renote)
.EnsureVisibleFor(actor)
.Paginate(new PaginationQuery { MaxId = maxId }, 20, 20)
.ToArrayAsync();
var noteActor = userRenderer.RenderLite(user);
var rendered = notes.Select(note => note is { IsPureRenote: true, Renote: not null }
? (ASObject)ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote),
note.GetPublicUri(config.Value), noteActor,
note.Visibility,
note.User.GetPublicUri(config.Value) + "/followers")
: ActivityPub.ActivityRenderer.RenderCreate(noteRenderer.RenderLite(note), noteActor))
.ToList();
var last = notes.LastOrDefault();
var res = new ASOrderedCollectionPage
{
Id = $"{user.GetPublicUri(config.Value)}/outbox/page{(maxId != null ? $"?maxId={maxId}" : "")}",
Next = last != null ? new ASOrderedCollectionPage($"{user.GetPublicUri(config.Value)}/outbox/page?maxId={last.Id}") : null,
Items = rendered
};
return res.Compact();
}
[HttpGet("/@{acct}")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[OverrideResultType<ASActor>]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
@ -287,8 +180,6 @@ public class ActivityPubController(
var user = await db.Users
.IncludeCommonProperties()
.Include(p => p.Avatar)
.Include(p => p.Banner)
.FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser);
if (user == null) throw GracefulException.NotFound("User not found");
@ -320,7 +211,6 @@ public class ActivityPubController(
[HttpGet("/emoji/{name}")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[OverrideResultType<ASEmoji>]
[ProducesResults(HttpStatusCode.OK)]
@ -333,10 +223,10 @@ public class ActivityPubController(
var rendered = new ASEmoji
{
Id = emoji.GetPublicUri(config.Value),
Name = $":{emoji.Name}:",
Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl), MediaType = emoji.Type }
Name = emoji.Name,
Image = new ASImage { Url = new ASLink(emoji.PublicUrl) }
};
return LdHelpers.Compact(rendered);
}
}
}

View file

@ -4,9 +4,7 @@ using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -21,8 +19,7 @@ namespace Iceshrimp.Backend.Controllers.Federation;
public class NodeInfoController(
IOptions<Config.InstanceSection> instanceConfig,
IOptions<Config.StorageSection> storageConfig,
DatabaseContext db,
MetaService meta
DatabaseContext db
) : ControllerBase
{
[HttpGet("2.1")]
@ -46,10 +43,6 @@ public class NodeInfoController(
var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null);
var maxUploadSize = storageConfig.Value.MaxUploadSizeBytes;
var (instanceName, instanceDescription, adminContact) =
await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription,
MetaEntity.AdminContactEmail);
return new NodeInfoResponse
{
Version = Request.Path.Value?.EndsWith("2.1") ?? false ? "2.1" : "2.0",
@ -81,9 +74,9 @@ public class NodeInfoController(
Metadata = new NodeInfoResponse.NodeInfoMetadata
{
//FIXME Implement members
NodeName = instanceName,
NodeDescription = instanceDescription,
Maintainer = new NodeInfoResponse.Maintainer { Name = "todo", Email = adminContact },
NodeName = "Iceshrimp.NET",
NodeDescription = "An Iceshrimp.NET instance",
Maintainer = new NodeInfoResponse.Maintainer { Name = "todo", Email = "todo" },
Languages = [],
TosUrl = "todo",
RepositoryUrl = new Uri(Constants.RepositoryUrl),

View file

@ -9,7 +9,6 @@ using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -18,7 +17,6 @@ namespace Iceshrimp.Backend.Controllers.Federation;
[FederationApiController]
[Route("/.well-known")]
[EnableCors("well-known")]
[OutputCache(PolicyName = "federation")]
public class WellKnownController(IOptions<Config.InstanceSection> config, DatabaseContext db) : ControllerBase
{
[HttpGet("webfinger")]

View file

@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
@ -13,7 +12,6 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@ -106,9 +104,10 @@ public class AccountController(
IsSensitive = false,
MimeType = request.Avatar.ContentType
};
var avatar = await driveSvc.StoreFileAsync(request.Avatar.OpenReadStream(), user, rq);
var avatar = await driveSvc.StoreFile(request.Avatar.OpenReadStream(), user, rq);
user.Avatar = avatar;
user.AvatarBlurhash = avatar.Blurhash;
user.AvatarUrl = avatar.AccessUrl;
}
if (request.Banner != null)
@ -119,9 +118,10 @@ public class AccountController(
IsSensitive = false,
MimeType = request.Banner.ContentType
};
var banner = await driveSvc.StoreFileAsync(request.Banner.OpenReadStream(), user, rq);
var banner = await driveSvc.StoreFile(request.Banner.OpenReadStream(), user, rq);
user.Banner = banner;
user.BannerBlurhash = banner.Blurhash;
user.BannerUrl = banner.AccessUrl;
}
user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -139,11 +139,12 @@ public class AccountController(
var id = user.AvatarId;
user.AvatarId = null;
user.AvatarUrl = null;
user.AvatarBlurhash = null;
db.Update(user);
await db.SaveChangesAsync();
await driveSvc.RemoveFileAsync(id);
await driveSvc.RemoveFile(id);
}
return await VerifyUserCredentials();
@ -160,36 +161,17 @@ public class AccountController(
var id = user.BannerId;
user.BannerId = null;
user.BannerUrl = null;
user.BannerBlurhash = null;
db.Update(user);
await db.SaveChangesAsync();
await driveSvc.RemoveFileAsync(id);
await driveSvc.RemoveFile(id);
}
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}")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
@ -199,13 +181,13 @@ public class AccountController(
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
return await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(user), localUser);
return await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(user), localUser);
}
[HttpPost("{id}/follow")]
@ -222,8 +204,8 @@ public class AccountController(
var followee = await db.Users.IncludeCommonProperties()
.Where(p => p.Id == id)
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true))
throw GracefulException.Forbidden("This action is not allowed");
@ -255,8 +237,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.UnfollowUserAsync(user, followee);
return RenderRelationship(followee);
@ -271,14 +253,14 @@ public class AccountController(
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot unfollow yourself");
var follower = await db.Followings
.Where(p => p.FolloweeId == user.Id && p.FollowerId == id)
.Select(p => p.Follower)
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.RemoveFromFollowersAsync(user, follower);
return RenderRelationship(follower);
}
@ -297,8 +279,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
//TODO: handle notifications parameter
DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration);
@ -320,8 +302,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.UnmuteUserAsync(user, mutee);
return RenderRelationship(mutee);
@ -341,8 +323,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.BlockUserAsync(user, blockee);
return RenderRelationship(blockee);
@ -362,8 +344,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.UnblockUserAsync(user, blockee);
return RenderRelationship(blockee);
@ -394,10 +376,7 @@ public class AccountController(
string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query
)
{
var user = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var user = HttpContext.GetUserOrFail();
var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound();
return await db.Notes
@ -424,8 +403,8 @@ public class AccountController(
var account = await db.Users
.Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
@ -460,8 +439,8 @@ public class AccountController(
var account = await db.Users
.Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound();
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && account.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
@ -490,8 +469,8 @@ public class AccountController(
{
_ = await db.Users
.Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound();
return [];
}
@ -615,8 +594,8 @@ public class AccountController(
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
}
[HttpPost("/api/v1/follow_requests/{id}/reject")]
@ -638,8 +617,8 @@ public class AccountController(
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
}
[HttpGet("lookup")]
@ -652,7 +631,7 @@ public class AccountController(
var localUser = HttpContext.GetUser();
var user = await userResolver.ResolveOrNullAsync(acct, flags) ?? throw GracefulException.RecordNotFound();
user = await userResolver.GetUpdatedUserAsync(user);
user = await userResolver.GetUpdatedUser(user);
return await userRenderer.RenderAsync(user, localUser);
}
@ -676,4 +655,4 @@ public class AccountController(
ShowingReblogs = true //FIXME
};
}
}
}

View file

@ -55,7 +55,7 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
})
.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)).AwaitAllAsync();
return res;
}

View file

@ -1,7 +1,6 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -9,7 +8,6 @@ using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@ -34,12 +32,13 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
return new VerifyAppCredentialsResponse
{
App = token.App, VapidKey = await meta.GetAsync(MetaEntity.VapidPublicKey)
App = token.App, VapidKey = await meta.Get(MetaEntity.VapidPublicKey)
};
}
[HttpPost("/api/v1/apps")]
[EnableRateLimiting("auth")]
[ConsumesHybrid]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RegisterAppResponse> RegisterApp([FromHybrid] RegisterAppRequest request)
@ -79,10 +78,11 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
await db.AddAsync(app);
await db.SaveChangesAsync();
return new RegisterAppResponse { App = app, VapidKey = await meta.GetAsync(MetaEntity.VapidPublicKey) };
return new RegisterAppResponse { App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) };
}
[HttpPost("/oauth/token")]
[ConsumesHybrid]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<OauthTokenResponse> GetOauthToken([FromHybrid] OauthTokenRequest request)
@ -144,6 +144,7 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
}
[HttpPost("/oauth/revoke")]
[ConsumesHybrid]
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
@ -159,44 +160,4 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
return new object();
}
[Authenticate]
[HttpGet("/api/oauth_tokens.json")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<PleromaOauthTokenEntity>> GetOauthTokens()
{
var user = HttpContext.GetUserOrFail();
var oauthTokens = await db.OauthTokens
.Where(p => p.User == user)
.Include(oauthToken => oauthToken.App)
.ToListAsync();
List<PleromaOauthTokenEntity> result = [];
foreach (var token in oauthTokens)
{
result.Add(new PleromaOauthTokenEntity()
{
Id = token.Id,
AppName = token.App.Name,
ValidUntil = token.CreatedAt + TimeSpan.FromDays(365 * 100)
});
}
return result;
}
[Authenticate]
[HttpDelete("/api/oauth_tokens/{id}")]
[ProducesResults(HttpStatusCode.Created)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
public async Task RevokeOauthTokenPleroma(string id)
{
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.Forbidden("You are not authorized to revoke this token");
db.Remove(token);
await db.SaveChangesAsync();
Response.StatusCode = 201;
}
}

View file

@ -21,11 +21,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class InstanceController(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
MetaService meta
) : ControllerBase
public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase
{
[HttpGet("/api/v1/instance")]
[ProducesResults(HttpStatusCode.OK)]
@ -37,17 +33,15 @@ public class InstanceController(
var instanceCount = await db.Instances.LongCountAsync();
var (instanceName, instanceDescription, adminContact) =
await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription,
MetaEntity.AdminContactEmail);
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
// can't merge with above call since they're all nullable and this is not.
var vapidKey = await meta.GetAsync(MetaEntity.VapidPublicKey);
var vapidKey = await meta.Get(MetaEntity.VapidPublicKey);
return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact)
{
Stats = new InstanceStats(userCount, noteCount, instanceCount),
Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() },
Rules = await GetRules()
Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() }
};
}
@ -56,19 +50,16 @@ public class InstanceController(
public async Task<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config)
{
var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30);
var activeMonth =
await db.Users.LongCountAsync(p => p.IsLocalUser
&& !Constants.SystemUsers.Contains(p.UsernameLower)
&& p.LastActiveDate > cutoff);
var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser &&
!Constants.SystemUsers.Contains(p.UsernameLower) &&
p.LastActiveDate > cutoff);
var (instanceName, instanceDescription, adminContact) =
await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription,
MetaEntity.AdminContactEmail);
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact)
{
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } },
Rules = await GetRules()
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } }
};
}
@ -81,25 +72,14 @@ public class InstanceController(
{
Id = p.Id,
Shortcode = p.Name,
Url = p.GetAccessUrl(instance.Value),
StaticUrl = p.GetAccessUrl(instance.Value), //TODO
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true,
Category = p.Category
})
.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")]
[ProducesResults(HttpStatusCode.OK)]
public Dictionary<string, IEnumerable<string>> GetTranslationLanguages() => new();
@ -108,7 +88,7 @@ public class InstanceController(
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceExtendedDescription> GetExtendedDescription()
{
var description = await meta.GetAsync(MetaEntity.InstanceDescription);
var description = await meta.Get(MetaEntity.InstanceDescription);
return new InstanceExtendedDescription(description);
}
}
}

View file

@ -2,11 +2,11 @@ using System.Diagnostics.CodeAnalysis;
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.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
@ -23,11 +23,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class MediaController(
DriveService driveSvc,
DatabaseContext db,
AttachmentRenderer attachmentRenderer
) : ControllerBase
public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase
{
[MaxRequestSizeIsMaxUploadSize]
[HttpPost("/api/v1/media")]
@ -43,8 +39,8 @@ public class MediaController(
Comment = request.Description,
MimeType = request.File.ContentType
};
var file = await driveSvc.StoreFileAsync(request.File.OpenReadStream(), user, rq);
return attachmentRenderer.Render(file);
var file = await driveSvc.StoreFile(request.File.OpenReadStream(), user, rq);
return RenderAttachment(file);
}
[HttpPut("/api/v1/media/{id}")]
@ -55,12 +51,12 @@ public class MediaController(
)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user)
?? throw GracefulException.RecordNotFound();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.RecordNotFound();
file.Comment = request.Description;
await db.SaveChangesAsync();
return attachmentRenderer.Render(file);
return RenderAttachment(file);
}
[HttpGet("/api/v1/media/{id}")]
@ -69,10 +65,10 @@ public class MediaController(
public async Task<AttachmentEntity> GetAttachment(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user)
?? throw GracefulException.RecordNotFound();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.RecordNotFound();
return attachmentRenderer.Render(file);
return RenderAttachment(file);
}
[HttpPut("/api/v2/media/{id}")]
@ -80,4 +76,20 @@ public class MediaController(
[ProducesErrors(HttpStatusCode.NotFound)]
public IActionResult FallbackMediaRoute([SuppressMessage("ReSharper", "UnusedParameter.Global")] string id) =>
throw GracefulException.NotFound("This endpoint is not implemented, but some clients expect a 404 here.");
}
private static AttachmentEntity RenderAttachment(DriveFile file)
{
return new AttachmentEntity
{
Id = file.Id,
Type = AttachmentEntity.GetType(file.Type),
Url = file.AccessUrl,
Blurhash = file.Blurhash,
Description = file.Comment,
PreviewUrl = file.ThumbnailAccessUrl,
RemoteUrl = file.Uri,
Sensitive = file.IsSensitive
//Metadata = TODO,
};
}
}

View file

@ -115,7 +115,7 @@ public class PollController(
await db.SaveChangesAsync();
foreach (var vote in votes)
await pollSvc.RegisterPollVoteAsync(vote, poll, note, votes.IndexOf(vote) == 0);
await pollSvc.RegisterPollVote(vote, poll, note, votes.IndexOf(vote) == 0);
await db.ReloadEntityAsync(poll);
return await pollRenderer.RenderAsync(poll, user);

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

@ -150,7 +150,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa
{
Id = sub.Id,
Endpoint = sub.Endpoint,
ServerKey = await meta.GetAsync(MetaEntity.VapidPublicKey),
ServerKey = await meta.Get(MetaEntity.VapidPublicKey),
Policy = GetPolicyString(sub.Policy),
Alerts = new Alerts
{

View file

@ -1,25 +0,0 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class AttachmentRenderer(MediaProxyService mediaProxy) : ISingletonService
{
public AttachmentEntity Render(DriveFile file, bool proxy = true) => new()
{
Id = file.Id,
Type = AttachmentEntity.GetType(file.Type),
Url = proxy ? mediaProxy.GetProxyUrl(file) : file.RawAccessUrl,
Blurhash = file.Blurhash,
PreviewUrl = proxy ? mediaProxy.GetThumbnailProxyUrl(file) : file.RawThumbnailAccessUrl,
Description = file.Comment,
RemoteUrl = file.Uri,
Sensitive = file.IsSensitive,
//
Metadata = file.Properties is { Height: { } height, Width: { } width }
? new AttachmentMetadata(width, height)
: null
};
}

View file

@ -19,10 +19,8 @@ public class NoteRenderer(
PollRenderer pollRenderer,
MfmConverter mfmConverter,
DatabaseContext db,
EmojiService emojiSvc,
AttachmentRenderer attachmentRenderer,
FlagService flags
) : IScopedService
EmojiService emojiSvc
)
{
private static readonly FilterResultEntity InaccessibleFilter = new()
{
@ -73,30 +71,20 @@ public class NoteRenderer(
var renoted = data?.Renotes?.Contains(note.Id) ??
await db.Notes.AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote);
var noteEmoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([note]);
var noteEmoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([note]);
var mentions = data?.Mentions == null
? await GetMentionsAsync([note])
? await GetMentions([note])
: [..data.Mentions.Where(p => note.Mentions.Contains(p.Id))];
var attachments = data?.Attachments == null
? await GetAttachmentsAsync([note])
? await GetAttachments([note])
: [..data.Attachments.Where(p => note.FileIds.Contains(p.Id))];
if (user == null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia)
attachments = [];
var reactions = data?.Reactions == null
? await GetReactionsAsync([note], user)
? await GetReactions([note], user)
: [..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
{
Host = p.Host ?? config.Value.AccountDomain,
@ -111,17 +99,21 @@ public class NoteRenderer(
var quoteInaccessible =
note.Renote == null && ((note.RenoteId != null && recurse > 0) || note.RenoteUri != null);
var sensitive = note.Cw != null || attachments.Any(p => p.Sensitive);
var content = data?.Source != true
? text != null || quoteUri != null || quoteInaccessible || replyInaccessible
? await mfmConverter.ToHtmlAsync(text ?? "", mentionedUsers, note.UserHost, quoteUri,
quoteInaccessible, replyInaccessible)
: ""
: null;
var inlineMedia = attachments.Select(p => new MfmInlineMedia(p.Type switch
{
AttachmentType.Audio => MfmInlineMedia.MediaType.Audio,
AttachmentType.Video => MfmInlineMedia.MediaType.Video,
AttachmentType.Image or AttachmentType.Gif => MfmInlineMedia.MediaType.Image,
_ => MfmInlineMedia.MediaType.Other
}, p.RemoteUrl ?? p.Url, p.Description)).ToList();
var filters = data?.Filters ?? await GetFiltersAsync(user, filterContext);
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
await userRenderer.RenderAsync(note.User, user);
var poll = note.HasPoll
? (data?.Polls ?? await GetPolls([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null;
var filters = data?.Filters ?? await GetFilters(user, filterContext);
List<FilterResultEntity> filterResult;
if (filters.Count > 0 && filterContext == null)
@ -137,53 +129,9 @@ public class NoteRenderer(
if ((user?.UserSettings?.FilterInaccessible ?? false) && (replyInaccessible || quoteInaccessible))
filterResult.Insert(0, InaccessibleFilter);
var cw = note.Cw;
if (replyInaccessible && !string.IsNullOrEmpty(cw))
{
// prefix with lock emoji
cw = "RE: \ud83d\udd12, " + cw;
// prevent duplicating inaccessible marker in the body
replyInaccessible = false;
}
string? content = null;
if (data?.Source != true)
{
if (text != null || quoteUri != null || quoteInaccessible || replyInaccessible)
{
(content, inlineMedia) = mfmConverter.ToHtml(text ?? "", mentionedUsers, note.UserHost, quoteUri,
quoteInaccessible, replyInaccessible, media: inlineMedia);
attachments.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
}
else
{
content = "";
}
}
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
await userRenderer.RenderAsync(note.User, user);
var poll = note.HasPoll
? (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null;
var visibility = flags.IsPleroma.Value && note.LocalOnly
? "local"
: StatusEntity.EncodeVisibility(note.Visibility);
var pleromaExtensions = flags.IsPleroma.Value
? new PleromaStatusExtensions
{
LocalOnly = note.LocalOnly,
Reactions = reactions,
ConversationId = note.ThreadId,
ThreadMuted = muted
}
: null;
if (user == null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
attachments = [];
var res = new StatusEntity
{
@ -207,9 +155,9 @@ public class NoteRenderer(
IsRenoted = renoted,
IsBookmarked = bookmarked,
IsMuted = muted,
IsSensitive = sensitive,
ContentWarning = cw ?? "",
Visibility = visibility,
IsSensitive = note.Cw != null || attachments.Any(p => p.Sensitive),
ContentWarning = note.Cw ?? "",
Visibility = StatusEntity.EncodeVisibility(note.Visibility),
Content = content,
Text = text,
Mentions = mentions,
@ -218,9 +166,8 @@ public class NoteRenderer(
Emojis = noteEmoji,
Poll = poll,
Reactions = reactions,
Tags = tags,
Filtered = filterResult,
Pleroma = pleromaExtensions
Pleroma = new PleromaStatusExtensions { Reactions = reactions, ConversationId = note.ThreadId }
};
return res;
@ -231,8 +178,8 @@ public class NoteRenderer(
var edits = await db.NoteEdits.Where(p => p.Note == note).OrderBy(p => p.Id).ToListAsync();
edits.Add(RenderEdit(note));
var attachments = await GetAttachmentsAsync(note.FileIds.Concat(edits.SelectMany(p => p.FileIds)));
var mentions = await GetMentionsAsync([note]);
var attachments = await GetAttachments(note.FileIds.Concat(edits.SelectMany(p => p.FileIds)));
var mentions = await GetMentions([note]);
var mentionedUsers = mentions.Select(p => new Note.MentionedUser
{
Host = p.Host ?? config.Value.AccountDomain,
@ -248,23 +195,11 @@ public class NoteRenderer(
List<StatusEdit> history = [];
foreach (var edit in edits)
{
var files = attachments.Where(p => edit.FileIds.Contains(p.Id)).ToList();
var inlineMedia = files.Select(p => new MfmInlineMedia(p.Type switch
{
AttachmentType.Audio => MfmInlineMedia.MediaType.Audio,
AttachmentType.Video => MfmInlineMedia.MediaType.Video,
AttachmentType.Image or AttachmentType.Gif => MfmInlineMedia.MediaType.Image,
_ => MfmInlineMedia.MediaType.Other
}, p.RemoteUrl ?? p.Url, p.Description)).ToList();
(var content, inlineMedia) = mfmConverter.ToHtml(edit.Text ?? "", mentionedUsers, note.UserHost, media: inlineMedia);
files.RemoveAll(attachment => inlineMedia.Any(inline => inline.Src == (attachment.RemoteUrl ?? attachment.Url)));
var files = attachments.Where(p => edit.FileIds.Contains(p.Id)).ToList();
var entry = new StatusEdit
{
Account = account,
Content = content,
Content = await mfmConverter.ToHtmlAsync(edit.Text ?? "", mentionedUsers, note.UserHost),
CreatedAt = lastDate.ToStringIso8601Like(),
Emojis = [],
IsSensitive = files.Any(p => p.Sensitive),
@ -296,7 +231,7 @@ public class NoteRenderer(
{
var res = new List<FilterResultEntity>();
foreach (var entry in filtered.DistinctBy(p => p.filter.Id))
foreach (var entry in filtered)
{
var (filter, keyword) = entry;
res.Add(new FilterResultEntity { Filter = FilterRenderer.RenderOne(filter), KeywordMatches = [keyword] });
@ -305,7 +240,7 @@ public class NoteRenderer(
return res;
}
private async Task<List<MentionEntity>> GetMentionsAsync(List<Note> notes)
private async Task<List<MentionEntity>> GetMentions(List<Note> notes)
{
if (notes.Count == 0) return [];
var ids = notes.SelectMany(n => n.Mentions).Distinct();
@ -315,31 +250,53 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<AttachmentEntity>> GetAttachmentsAsync(List<Note> notes)
private async Task<List<AttachmentEntity>> GetAttachments(List<Note> notes)
{
if (notes.Count == 0) return [];
var ids = notes.SelectMany(n => n.FileIds).Distinct();
return await db.DriveFiles.Where(p => ids.Contains(p.Id))
.Select(f => attachmentRenderer.Render(f, true))
.Select(f => new AttachmentEntity
{
Id = f.Id,
Url = f.AccessUrl,
Blurhash = f.Blurhash,
PreviewUrl = f.ThumbnailAccessUrl,
Description = f.Comment,
Metadata = null,
RemoteUrl = f.Uri,
Type = AttachmentEntity.GetType(f.Type),
Sensitive = f.IsSensitive
})
.ToListAsync();
}
private async Task<List<AttachmentEntity>> GetAttachmentsAsync(IEnumerable<string> fileIds)
private async Task<List<AttachmentEntity>> GetAttachments(IEnumerable<string> fileIds)
{
var ids = fileIds.Distinct().ToList();
if (ids.Count == 0) return [];
return await db.DriveFiles.Where(p => ids.Contains(p.Id))
.Select(f => attachmentRenderer.Render(f, true))
.Select(f => new AttachmentEntity
{
Id = f.Id,
Url = f.AccessUrl,
Blurhash = f.Blurhash,
PreviewUrl = f.ThumbnailAccessUrl,
Description = f.Comment,
Metadata = null,
RemoteUrl = f.Uri,
Type = AttachmentEntity.GetType(f.Type),
Sensitive = f.IsSensitive
})
.ToListAsync();
}
internal async Task<List<AccountEntity>> GetAccountsAsync(List<User> users, User? localUser)
internal async Task<List<AccountEntity>> GetAccounts(List<User> users, User? localUser)
{
if (users.Count == 0) return [];
return (await userRenderer.RenderManyAsync(users.DistinctBy(p => p.Id), localUser)).ToList();
}
private async Task<List<string>> GetLikedNotesAsync(List<Note> notes, User? user)
private async Task<List<string>> GetLikedNotes(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -348,7 +305,7 @@ public class NoteRenderer(
.ToListAsync();
}
public async Task<List<ReactionEntity>> GetReactionsAsync(List<Note> notes, User? user)
public async Task<List<ReactionEntity>> GetReactions(List<Note> notes, User? user)
{
if (notes.Count == 0) return [];
var counts = notes.ToDictionary(p => p.Id, p => p.Reactions);
@ -376,17 +333,17 @@ public class NoteRenderer(
foreach (var item in res.Where(item => item.Name.StartsWith(':')))
{
var hit = await emojiSvc.ResolveEmojiAsync(item.Name);
var hit = await emojiSvc.ResolveEmoji(item.Name);
if (hit == null) continue;
item.Url = hit.GetAccessUrl(config.Value);
item.StaticUrl = hit.GetAccessUrl(config.Value);
item.Url = hit.PublicUrl;
item.StaticUrl = hit.PublicUrl;
item.Name = item.Name.Trim(':');
}
return res;
}
private async Task<List<string>> GetBookmarkedNotesAsync(List<Note> notes, User? user)
private async Task<List<string>> GetBookmarkedNotes(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -395,7 +352,7 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<string>> GetMutedNotesAsync(List<Note> notes, User? user)
private async Task<List<string>> GetMutedNotes(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -405,7 +362,7 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<string>> GetPinnedNotesAsync(List<Note> notes, User? user)
private async Task<List<string>> GetPinnedNotes(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -414,7 +371,7 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<string>> GetRenotesAsync(List<Note> notes, User? user)
private async Task<List<string>> GetRenotes(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -426,7 +383,7 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<PollEntity>> GetPollsAsync(List<Note> notes, User? user)
private async Task<List<PollEntity>> GetPolls(List<Note> notes, User? user)
{
if (notes.Count == 0) return [];
var polls = await db.Polls.Where(p => notes.Contains(p.Note))
@ -435,7 +392,7 @@ public class NoteRenderer(
return await pollRenderer.RenderManyAsync(polls, user).ToListAsync();
}
private async Task<List<EmojiEntity>> GetEmojiAsync(IEnumerable<Note> notes)
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<Note> notes)
{
var ids = notes.SelectMany(p => p.Emojis).ToList();
if (ids.Count == 0) return [];
@ -446,15 +403,15 @@ public class NoteRenderer(
{
Id = p.Id,
Shortcode = p.Name.Trim(':'),
Url = p.GetAccessUrl(config.Value),
StaticUrl = p.GetAccessUrl(config.Value), //TODO
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true,
Category = p.Category
})
.ToListAsync();
}
private async Task<List<Filter>> GetFiltersAsync(User? user, Filter.FilterContext? filterContext)
private async Task<List<Filter>> GetFilters(User? user, Filter.FilterContext? filterContext)
{
return filterContext == null
? await db.Filters.Where(p => p.User == user).ToListAsync()
@ -476,18 +433,18 @@ public class NoteRenderer(
var data = new NoteRendererDto
{
Accounts = accounts ?? await GetAccountsAsync(allNotes.Select(p => p.User).ToList(), user),
Mentions = await GetMentionsAsync(allNotes),
Attachments = await GetAttachmentsAsync(allNotes),
Polls = await GetPollsAsync(allNotes, user),
LikedNotes = await GetLikedNotesAsync(allNotes, user),
BookmarkedNotes = await GetBookmarkedNotesAsync(allNotes, user),
MutedNotes = await GetMutedNotesAsync(allNotes, user),
PinnedNotes = await GetPinnedNotesAsync(allNotes, user),
Renotes = await GetRenotesAsync(allNotes, user),
Emoji = await GetEmojiAsync(allNotes),
Reactions = await GetReactionsAsync(allNotes, user),
Filters = await GetFiltersAsync(user, filterContext)
Accounts = accounts ?? await GetAccounts(allNotes.Select(p => p.User).ToList(), user),
Mentions = await GetMentions(allNotes),
Attachments = await GetAttachments(allNotes),
Polls = await GetPolls(allNotes, user),
LikedNotes = await GetLikedNotes(allNotes, user),
BookmarkedNotes = await GetBookmarkedNotes(allNotes, user),
MutedNotes = await GetMutedNotes(allNotes, user),
PinnedNotes = await GetPinnedNotes(allNotes, user),
Renotes = await GetRenotes(allNotes, user),
Emoji = await GetEmoji(allNotes),
Reactions = await GetReactions(allNotes, user),
Filters = await GetFilters(user, filterContext)
};
return await noteList.Select(p => RenderAsync(p, user, filterContext, data)).AwaitAllAsync();
@ -510,4 +467,4 @@ public class NoteRenderer(
public bool Source;
}
}
}

View file

@ -1,23 +1,14 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class NotificationRenderer(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
NoteRenderer noteRenderer,
UserRenderer userRenderer,
FlagService flags
) : IScopedService
public class NotificationRenderer(DatabaseContext db, NoteRenderer noteRenderer, UserRenderer userRenderer)
{
public async Task<NotificationEntity> RenderAsync(
Notification notification, User user, bool isPleroma, List<AccountEntity>? accounts = null,
@ -29,13 +20,13 @@ public class NotificationRenderer(
var targetNote = notification.Note;
var note = targetNote != null
? statuses?.FirstOrDefault(p => p.Id == targetNote.Id)
?? await noteRenderer.RenderAsync(targetNote, user, Filter.FilterContext.Notifications,
new NoteRenderer.NoteRendererDto { Accounts = accounts })
? statuses?.FirstOrDefault(p => p.Id == targetNote.Id) ??
await noteRenderer.RenderAsync(targetNote, user, Filter.FilterContext.Notifications,
new NoteRenderer.NoteRendererDto { Accounts = accounts })
: null;
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id)
?? await userRenderer.RenderAsync(dbNotifier, user);
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
await userRenderer.RenderAsync(dbNotifier, user);
string? emojiUrl = null;
if (notification.Reaction != null)
@ -50,7 +41,7 @@ public class NotificationRenderer(
var parts = notification.Reaction.Trim(':').Split('@');
emojiUrl = await db.Emojis
.Where(e => e.Name == parts[0] && e.Host == (parts.Length > 1 ? parts[1] : null))
.Select(e => e.GetAccessUrl(instance.Value))
.Select(e => e.PublicUrl)
.FirstOrDefaultAsync();
}
}
@ -64,9 +55,7 @@ public class NotificationRenderer(
CreatedAt = notification.CreatedAt.ToStringIso8601Like(),
Emoji = notification.Reaction,
EmojiUrl = emojiUrl,
Pleroma = flags.IsPleroma.Value
? new PleromaNotificationExtensions { IsSeen = notification.IsRead }
: null
Pleroma = new PleromaNotificationExtensions { IsSeen = notification.IsRead }
};
return res;
@ -79,50 +68,50 @@ public class NotificationRenderer(
var notificationList = notifications.ToList();
if (notificationList.Count == 0) return [];
var accounts = await noteRenderer
.GetAccountsAsync(notificationList
.Where(p => p.Notifier != null)
.Select(p => p.Notifier)
.Concat(notificationList.Select(p => p.Notifiee))
.Concat(notificationList.Select(p => p.Note?.Renote?.User).Where(p => p != null))
.Cast<User>()
.DistinctBy(p => p.Id)
.ToList(), user);
var accounts = await noteRenderer.GetAccounts(notificationList.Where(p => p.Notifier != null)
.Select(p => p.Notifier)
.Concat(notificationList.Select(p => p.Notifiee))
.Concat(notificationList
.Select(p => p.Note?.Renote?.User)
.Where(p => p != null))
.Cast<User>()
.DistinctBy(p => p.Id)
.ToList(), user);
var notes = await noteRenderer
.RenderManyAsync(notificationList.Where(p => p.Note != null)
.Select(p => p.Note)
.Concat(notificationList.Select(p => p.Note?.Renote).Where(p => p != null))
.Cast<Note>()
.DistinctBy(p => p.Id),
user, Filter.FilterContext.Notifications, accounts);
var notes = await noteRenderer.RenderManyAsync(notificationList.Where(p => p.Note != null)
.Select(p => p.Note)
.Concat(notificationList
.Select(p => p.Note?.Renote)
.Where(p => p != null))
.Cast<Note>()
.DistinctBy(p => p.Id),
user, Filter.FilterContext.Notifications, accounts);
var parts = notificationList.Where(p => p.Reaction != null && EmojiService.IsCustomEmoji(p.Reaction))
.Select(p =>
{
var parts = p.Reaction!.Trim(':').Split('@');
return (name: parts[0], host: parts.Length > 1 ? parts[1] : null);
})
.Distinct()
.ToArray();
var expr = ExpressionExtensions.False<Emoji>();
expr = parts.Aggregate(expr, (current, part) => current.Or(p => p.Name == part.name && p.Host == part.host));
return new { Name = parts[0], Host = parts.Length > 1 ? parts[1] : null };
});
// 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)
})
.ToDictionaryAsync(e => e.Name, e => e.Url);
//TODO: is there a better way of expressing this using LINQ?
IQueryable<Emoji> urlQ = db.Emojis;
foreach (var part in parts)
urlQ = urlQ.Concat(db.Emojis.Where(e => e.Name == part.Name && e.Host == part.Host));
var res = await notificationList
.Select(p => RenderAsync(p, user, isPleroma, accounts, notes, emojiUrls))
.AwaitAllAsync();
//TODO: can we somehow optimize this to do the dedupe database side?
var emojiUrls = await urlQ.Select(e => new
{
Name = $":{e.Name}{(e.Host != null ? "@" + e.Host : "")}:",
Url = e.PublicUrl
})
.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

@ -6,16 +6,16 @@ using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class PollRenderer(DatabaseContext db) : IScopedService
public class PollRenderer(DatabaseContext db)
{
public async Task<PollEntity> RenderAsync(Poll poll, User? user, PollRendererDto? data = null)
{
var voted = (data?.Voted ?? await GetVotedAsync([poll], user)).Contains(poll.NoteId);
var voted = (data?.Voted ?? await GetVoted([poll], user)).Contains(poll.NoteId);
var ownVotes = (data?.OwnVotes ?? await GetOwnVotesAsync([poll], user)).Where(p => p.Key == poll.NoteId)
.Select(p => p.Value)
.DefaultIfEmpty([])
.First();
var ownVotes = (data?.OwnVotes ?? await GetOwnVotes([poll], user)).Where(p => p.Key == poll.NoteId)
.Select(p => p.Value)
.DefaultIfEmpty([])
.First();
var res = new PollEntity
{
@ -38,7 +38,7 @@ public class PollRenderer(DatabaseContext db) : IScopedService
return res;
}
private async Task<List<string>> GetVotedAsync(IEnumerable<Poll> polls, User? user)
private async Task<List<string>> GetVoted(IEnumerable<Poll> polls, User? user)
{
if (user == null) return [];
return await db.PollVotes.Where(p => polls.Select(i => i.NoteId).Any(i => i == p.NoteId) && p.User == user)
@ -47,7 +47,7 @@ public class PollRenderer(DatabaseContext db) : IScopedService
.ToListAsync();
}
private async Task<Dictionary<string, int[]>> GetOwnVotesAsync(IEnumerable<Poll> polls, User? user)
private async Task<Dictionary<string, int[]>> GetOwnVotes(IEnumerable<Poll> polls, User? user)
{
if (user == null) return [];
return await db.PollVotes
@ -62,7 +62,7 @@ public class PollRenderer(DatabaseContext db) : IScopedService
var data = new PollRendererDto
{
OwnVotes = await GetOwnVotesAsync(pollList, user), Voted = await GetVotedAsync(pollList, user)
OwnVotes = await GetOwnVotes(pollList, user), Voted = await GetVoted(pollList, user)
};
return await pollList.Select(p => RenderAsync(p, user, data)).AwaitAllAsync();

View file

@ -1,11 +1,9 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -15,117 +13,68 @@ public class UserRenderer(
IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security,
MfmConverter mfmConverter,
DatabaseContext db,
FlagService flags
) : IScopedService
DatabaseContext db
)
{
private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png";
public Task<AccountEntity> RenderAsync(User user, UserProfile? profile, User? localUser, bool source = false)
=> RenderAsync(user, profile, localUser, null, source);
private async Task<AccountEntity> RenderAsync(
User user, UserProfile? profile, User? localUser, UserRendererDto? data = null, bool source = false
public async Task<AccountEntity> RenderAsync(
User user, UserProfile? profile, User? localUser, IEnumerable<EmojiEntity>? emoji = null, bool source = false
)
{
var acct = user.Username;
if (user.IsRemoteUser)
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 GetEmoji([user]);
var mentions = profile?.Mentions ?? [];
var fields = profile?.Fields
.Select(p => new Field
{
Name = p.Name,
Value = (mfmConverter.ToHtml(p.Value, mentions, user.Host)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
});
var fields = profile != null
? await profile.Fields
.Select(async p => new Field
{
Name = p.Name,
Value = await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host),
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
})
.AwaitAllAsync()
: null;
var fieldsSource = source
? 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
{
Id = user.Id,
DisplayName = user.DisplayName ?? user.Username,
AvatarUrl = user.GetAvatarUrl(config.Value),
AvatarUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value),
Username = user.Username,
Acct = acct,
FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}",
IsLocked = user.IsLocked,
CreatedAt = user.CreatedAt.ToStringIso8601Like(),
LastStatusAt = user.LastNoteAt?.ToStringIso8601Like(),
FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount,
Note = mfmConverter.ToHtml(profile?.Description ?? "", mentions, user.Host).Html,
Note = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host),
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO
AvatarDescription = avatarAlt ?? "",
HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent,
HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO
HeaderDescription = bannerAlt ?? "",
MovedToAccount = null, //TODO
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO
HeaderUrl = user.BannerUrl ?? _transparent,
HeaderStaticUrl = user.BannerUrl ?? _transparent, //TODO
MovedToAccount = null, //TODO
IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable,
Fields = fields?.ToList() ?? [],
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
Emoji = profileEmoji
};
if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
{
res.AvatarUrl = user.GetIdenticonUrl(config.Value);
res.AvatarStaticUrl = user.GetIdenticonUrl(config.Value);
res.AvatarUrl = user.GetIdenticonUrlPng(config.Value);
res.AvatarStaticUrl = user.GetIdenticonUrlPng(config.Value);
res.HeaderUrl = _transparent;
res.HeaderStaticUrl = _transparent;
}
@ -138,9 +87,8 @@ public class UserRenderer(
Fields = fieldsSource,
Language = "",
Note = profile?.Description ?? "",
Privacy =
StatusEntity.EncodeVisibility(user.UserSettings?.DefaultNoteVisibility
?? Note.NoteVisibility.Public),
Privacy = StatusEntity.EncodeVisibility(user.UserSettings?.DefaultNoteVisibility ??
Note.NoteVisibility.Public),
Sensitive = false,
FollowRequestCount = await db.FollowRequests.CountAsync(p => p.Followee == user)
};
@ -149,7 +97,7 @@ public class UserRenderer(
return res;
}
private async Task<List<EmojiEntity>> GetEmojiAsync(IEnumerable<User> users)
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<User> users)
{
var ids = users.SelectMany(p => p.Emojis).ToList();
if (ids.Count == 0) return [];
@ -160,63 +108,24 @@ public class UserRenderer(
{
Id = p.Id,
Shortcode = p.Name,
Url = p.GetAccessUrl(config.Value),
StaticUrl = p.GetAccessUrl(config.Value), //TODO
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true,
Category = p.Category
})
.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)
{
var data = new UserRendererDto
{
Emoji = emoji ?? await GetEmojiAsync([user]),
AvatarAlt = await GetAvatarAltAsync([user]),
BannerAlt = await GetBannerAltAsync([user])
};
return await RenderAsync(user, user.UserProfile, localUser, data);
return await RenderAsync(user, user.UserProfile, localUser, emoji);
}
public async Task<IEnumerable<AccountEntity>> RenderManyAsync(IEnumerable<User> users, User? localUser)
{
var userList = users.ToList();
if (userList.Count == 0) return [];
var data = new UserRendererDto
{
Emoji = await GetEmojiAsync(userList),
AvatarAlt = await GetAvatarAltAsync(userList),
BannerAlt = await GetBannerAltAsync(userList)
};
return await userList.Select(p => RenderAsync(p, p.UserProfile, localUser, data)).AwaitAllAsync();
var emoji = await GetEmoji(userList);
return await userList.Select(p => RenderAsync(p, localUser, emoji)).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.Shared.Helpers;
using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class AccountEntity : IIdentifiable
public class AccountEntity : IEntity
{
[J("username")] public required string Username { get; set; }
[J("acct")] public required string Acct { get; set; }
[J("fqn")] public required string FullyQualifiedName { get; set; }
[J("display_name")] public required string DisplayName { get; set; }
[J("locked")] public required bool IsLocked { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("followers_count")] public required long FollowersCount { get; set; }
[J("following_count")] public required long FollowingCount { get; set; }
[J("statuses_count")] public required long StatusesCount { get; set; }
[J("note")] public required string Note { get; set; }
[J("url")] public required string Url { get; set; }
[J("uri")] public required string Uri { get; set; }
[J("avatar")] public required string AvatarUrl { get; set; }
[J("avatar_static")] public required string AvatarStaticUrl { get; set; }
[J("header")] public required string HeaderUrl { get; set; }
[J("header_static")] public required string HeaderStaticUrl { get; set; }
[J("moved")] public required AccountEntity? MovedToAccount { get; set; }
[J("bot")] public required bool IsBot { get; set; }
[J("discoverable")] public required bool IsDiscoverable { get; set; }
[J("fields")] public required List<Field> Fields { get; set; }
[J("source")] public AccountSource? Source { get; set; }
[J("emojis")] public required List<EmojiEntity> Emoji { get; set; }
[J("id")] public required string Id { get; set; }
[J("last_status_at")] public string? LastStatusAt { get; set; }
[J("pleroma")] public required PleromaUserExtensions? Pleroma { get; set; }
[J("akkoma")] public required AkkomaUserExtensions? Akkoma { get; set; }
[J("avatar_description")] public required string AvatarDescription { get; set; }
[J("header_description")] public required string HeaderDescription { get; set; }
[J("username")] public required string Username { get; set; }
[J("acct")] public required string Acct { get; set; }
[J("fqn")] public required string FullyQualifiedName { get; set; }
[J("display_name")] public required string DisplayName { get; set; }
[J("locked")] public required bool IsLocked { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("followers_count")] public required long FollowersCount { get; set; }
[J("following_count")] public required long FollowingCount { get; set; }
[J("statuses_count")] public required long StatusesCount { get; set; }
[J("note")] public required string Note { get; set; }
[J("url")] public required string Url { get; set; }
[J("uri")] public required string Uri { get; set; }
[J("avatar")] public required string AvatarUrl { get; set; }
[J("avatar_static")] public required string AvatarStaticUrl { get; set; }
[J("header")] public required string HeaderUrl { get; set; }
[J("header_static")] public required string HeaderStaticUrl { get; set; }
[J("moved")] public required AccountEntity? MovedToAccount { get; set; }
[J("bot")] public required bool IsBot { get; set; }
[J("discoverable")] public required bool IsDiscoverable { get; set; }
[J("fields")] public required List<Field> Fields { get; set; }
[J("source")] public AccountSource? Source { get; set; }
[J("emojis")] public required List<EmojiEntity> Emoji { get; set; }
[J("id")] public required string Id { get; set; }
}
public class Field

View file

@ -47,15 +47,7 @@ public enum AttachmentType
Audio
}
public class AttachmentMetadata(int width, int height)
public class AttachmentMetadata
{
[J("original")] public OriginalAttachmentMetadata Original => new(width, height);
}
public class OriginalAttachmentMetadata(int width, int height)
{
[J("width")] public int Width => width;
[J("height")] public int Height => height;
[J("size")] public string Size => $"{width}x{height}";
[J("aspect")] public float Aspect => (float)width / height;
}
//TODO
}

View file

@ -1,9 +1,9 @@
using Iceshrimp.Shared.Helpers;
using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class ConversationEntity : IIdentifiable
public class ConversationEntity : IEntity
{
[J("unread")] public required bool Unread { 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.Core.Database;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
using static Iceshrimp.Backend.Core.Database.Tables.Notification;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class NotificationEntity : IIdentifiable
public class NotificationEntity : IEntity
{
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required PleromaNotificationExtensions? Pleroma { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("pleroma")] public required PleromaNotificationExtensions Pleroma { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
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;
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("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 Iceshrimp.Shared.Helpers;
using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class RelationshipEntity : IIdentifiable
public class RelationshipEntity : IEntity
{
[J("following")] public required bool Following { 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 Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JI = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class StatusEntity : IIdentifiable, ICloneable
public class StatusEntity : IEntity, ICloneable
{
[JI] public string? MastoReplyUserId;
[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("emojis")] public required List<EmojiEntity> Emojis { 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("application")] public object? Application => null; //FIXME
[J("tags")] public object[] Tags => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("language")] public string? Language => null; //FIXME
public object Clone() => MemberwiseClone();
[J("id")] public required string Id { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required PleromaStatusExtensions? Pleroma { get; set; }
[J("pleroma")] public required PleromaStatusExtensions Pleroma { get; set; }
public static string EncodeVisibility(Note.NoteVisibility visibility)
{
@ -107,10 +106,4 @@ public class StatusEdit
[J("poll")] public required PollEntity? Poll { get; set; }
[J("media_attachments")] public required List<AttachmentEntity> Attachments { 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.Core.Configuration;
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("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("pleroma")] public required PleromaInstanceExtensions Pleroma { get; set; }
[J("rules")] public required List<RuleEntity> Rules { get; set; }
//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}";
}
@ -98,5 +95,5 @@ public class InstancePollConfiguration
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.Extensions;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
@ -27,8 +26,6 @@ public class InstanceInfoV2Response(
[J("usage")] public required InstanceUsage Usage { get; set; }
[J("rules")] public required List<RuleEntity> Rules { get; set; }
//TODO: add the rest
}
@ -39,12 +36,7 @@ public class InstanceConfigurationV2(Config.InstanceSection config)
[J("media_attachments")] public InstanceMediaConfiguration Media => new();
[J("polls")] public InstancePollConfiguration Polls => new();
[J("reactions")] public InstanceReactionConfiguration Reactions => new();
[J("urls")] public InstanceUrlsV2 Urls => new(config);
}
public class InstanceUrlsV2(Config.InstanceSection config)
{
[J("streaming")] public string StreamingApi => $"wss://{config.WebDomain}";
[J("urls")] public InstanceUrls Urls => new(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

@ -91,7 +91,7 @@ public class SearchController(
return result switch
{
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(result), user)],
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(result), user)],
_ => []
};
}
@ -118,14 +118,11 @@ public class SearchController(
var host = match.Groups["host"].Value;
var result = await userResolver.ResolveOrNullAsync(GetQuery(username, host), ResolveFlags.Acct);
// @formatter:off
return result switch
{
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(result), user)],
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(result), user)],
_ => []
};
// @formatter:on
}
}
}

View file

@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mime;
using AsyncKeyedLock;
@ -12,15 +11,16 @@ using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Serialization;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.MfmSharp;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using static Iceshrimp.Parsing.MfmNodeTypes;
namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -71,32 +71,6 @@ public class StatusController(
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")]
[Authenticate("read:statuses")]
@ -122,6 +96,14 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var shouldShowContext = await db.Notes
.Where(p => p.Id == id)
.FilterHidden(user, db)
.AnyAsync();
if (!shouldShowContext)
return new StatusContext { Ancestors = [], Descendants = [] };
// Akkoma-FE calls /context on boosts
if (note.IsPureRenote)
return await GetStatusContext(note.RenoteId!);
@ -403,12 +385,6 @@ public class StatusController(
}
: null;
if (request.Visibility == "local")
{
request.Visibility = "public";
request.LocalOnly = true;
}
var visibility = StatusEntity.DecodeVisibility(request.Visibility);
var reply = request.ReplyId != null
? await db.Notes.Where(p => p.Id == request.ReplyId)
@ -429,25 +405,16 @@ public class StatusController(
if (token.AutoDetectQuotes && request.Text != null)
{
var parsed = MfmParser.Parse(request.Text);
quoteUri = parsed.LastOrDefault() switch
quoteUri = MfmParser.Parse(request.Text).LastOrDefault() switch
{
MfmUrlNode urlNode => urlNode.Url,
MfmLinkNode linkNode => linkNode.Url,
_ => quoteUri
};
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 (quoteUri != null)
parsed = parsed.SkipLast(1);
newText = MfmSerializer.Serialize(parsed).Trim();
}
if (request is { Sensitive: true, MediaIds.Count: > 0 })
@ -725,4 +692,4 @@ public class StatusController(
await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync();
return await GetNote(id);
}
}
}

View file

@ -18,19 +18,19 @@ public class DirectChannel(WebSocketConnection connection) : IChannel
public bool IsSubscribed { get; private set; }
public bool IsAggregate => false;
public async Task SubscribeAsync(StreamingRequestMessage _)
public async Task Subscribe(StreamingRequestMessage _)
{
if (IsSubscribed) return;
IsSubscribed = true;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
connection.EventService.NotePublished += OnNotePublished;
connection.EventService.NoteUpdated += OnNoteUpdated;
}
public Task UnsubscribeAsync(StreamingRequestMessage _)
public Task Unsubscribe(StreamingRequestMessage _)
{
if (!IsSubscribed) return Task.CompletedTask;
IsSubscribed = false;
@ -73,7 +73,7 @@ public class DirectChannel(WebSocketConnection connection) : IChannel
return rendered;
}
private async Task<ConversationEntity> RenderConversationAsync(
private async Task<ConversationEntity> RenderConversation(
Note note, NoteWithVisibilities wrapped, AsyncServiceScope scope
)
{
@ -106,14 +106,14 @@ public class DirectChannel(WebSocketConnection connection) : IChannel
if (connection.IsFiltered(note)) return;
if (note.CreatedAt < DateTime.UtcNow - TimeSpan.FromMinutes(5)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(note, scope)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
var message = new StreamingUpdateMessage
{
Stream = [Name],
Event = "conversation",
Payload = JsonSerializer.Serialize(await RenderConversationAsync(note, wrapped, scope))
Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped, scope))
};
await connection.SendMessageAsync(JsonSerializer.Serialize(message));
@ -132,12 +132,12 @@ public class DirectChannel(WebSocketConnection connection) : IChannel
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var message = new StreamingUpdateMessage
{
Stream = [Name],
Event = "conversation",
Payload = JsonSerializer.Serialize(await RenderConversationAsync(note, wrapped, scope))
Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped, scope))
};
await connection.SendMessageAsync(JsonSerializer.Serialize(message));

View file

@ -20,7 +20,7 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
public bool IsSubscribed => _tags.Count != 0;
public bool IsAggregate => true;
public async Task SubscribeAsync(StreamingRequestMessage msg)
public async Task Subscribe(StreamingRequestMessage msg)
{
if (msg.Tag == null)
{
@ -38,7 +38,7 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
_tags.AddIfMissing(msg.Tag);
}
public async Task UnsubscribeAsync(StreamingRequestMessage msg)
public async Task Unsubscribe(StreamingRequestMessage msg)
{
if (msg.Tag == null)
{
@ -105,8 +105,8 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(note, scope)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };
@ -130,7 +130,7 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };

View file

@ -25,7 +25,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
public bool IsSubscribed => _lists.Count != 0;
public bool IsAggregate => true;
public async Task SubscribeAsync(StreamingRequestMessage msg)
public async Task Subscribe(StreamingRequestMessage msg)
{
if (msg.List == null)
{
@ -43,7 +43,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
if (_lists.AddIfMissing(msg.List))
{
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var list = await db.UserLists.FirstOrDefaultAsync(p => p.UserId == connection.Token.User.Id &&
@ -60,7 +60,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
}
}
public async Task UnsubscribeAsync(StreamingRequestMessage msg)
public async Task Unsubscribe(StreamingRequestMessage msg)
{
if (msg.List == null)
{
@ -128,8 +128,8 @@ public class ListChannel(WebSocketConnection connection) : IChannel
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(note, scope)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };
@ -154,7 +154,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };
@ -197,7 +197,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
if (list.UserId != connection.Token.User.Id) return;
if (!_lists.Contains(list.Id)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var members = await db.UserListMembers.Where(p => p.UserListId == list.Id)

View file

@ -21,7 +21,7 @@ public class PublicChannel(
public bool IsSubscribed { get; private set; }
public bool IsAggregate => false;
public Task SubscribeAsync(StreamingRequestMessage _)
public Task Subscribe(StreamingRequestMessage _)
{
if (IsSubscribed) return Task.CompletedTask;
IsSubscribed = true;
@ -32,7 +32,7 @@ public class PublicChannel(
return Task.CompletedTask;
}
public Task UnsubscribeAsync(StreamingRequestMessage _)
public Task Unsubscribe(StreamingRequestMessage _)
{
if (!IsSubscribed) return Task.CompletedTask;
IsSubscribed = false;
@ -88,8 +88,8 @@ public class PublicChannel(
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(note, scope)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };
@ -116,7 +116,7 @@ public class PublicChannel(
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() };

View file

@ -17,12 +17,12 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
public bool IsSubscribed { get; private set; }
public bool IsAggregate => false;
public async Task SubscribeAsync(StreamingRequestMessage _)
public async Task Subscribe(StreamingRequestMessage _)
{
if (IsSubscribed) return;
IsSubscribed = true;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
if (!notificationsOnly)
@ -35,7 +35,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
connection.EventService.Notification += OnNotification;
}
public Task UnsubscribeAsync(StreamingRequestMessage _)
public Task Unsubscribe(StreamingRequestMessage _)
{
if (!IsSubscribed) return Task.CompletedTask;
IsSubscribed = false;
@ -101,8 +101,8 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
if (note.CreatedAt < DateTime.UtcNow - TimeSpan.FromMinutes(5)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(note, scope)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var intermediate = await renderer.RenderAsync(note, connection.Token.User);
@ -128,7 +128,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.GetAsyncServiceScope();
await using var scope = connection.ScopeFactory.CreateAsyncScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var intermediate = await renderer.RenderAsync(note, connection.Token.User);
@ -174,8 +174,8 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
if (!IsApplicable(notification)) return;
if (IsFiltered(notification)) return;
await using var scope = connection.GetAsyncServiceScope();
if (notification.Note != null && await connection.IsMutedThreadAsync(notification.Note, scope, true))
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (notification.Note != null && await connection.IsMutedThread(notification.Note, scope, true))
return;
var renderer = scope.ServiceProvider.GetRequiredService<NotificationRenderer>();

View file

@ -32,6 +32,7 @@ public sealed class WebSocketConnection(
public readonly WriteLockingList<Filter> Filters = [];
public readonly EventService EventService = eventSvc;
public readonly IServiceScope Scope = scopeFactory.CreateScope();
public readonly IServiceScopeFactory ScopeFactory = scopeFactory;
public readonly OauthToken Token = token;
public HashSet<string> HiddenFromHome = [];
@ -56,8 +57,6 @@ public sealed class WebSocketConnection(
public void InitializeStreamingWorker()
{
InitializeScopeLocalParameters(Scope);
_channels.Add(new ListChannel(this));
_channels.Add(new DirectChannel(this));
_channels.Add(new UserChannel(this, true));
@ -84,10 +83,10 @@ public sealed class WebSocketConnection(
EventService.FilterUpdated += OnFilterUpdated;
EventService.ListMembersUpdated += OnListMembersUpdated;
_ = InitializeRelationshipsAsync();
_ = InitializeRelationships();
}
private async Task InitializeRelationshipsAsync()
private async Task InitializeRelationships()
{
await using var db = Scope.ServiceProvider.GetRequiredService<DatabaseContext>();
Following.AddRange(await db.Followings.Where(p => p.Follower == Token.User)
@ -164,14 +163,14 @@ public sealed class WebSocketConnection(
if (channel.Scopes.Except(MastodonOauthHelpers.ExpandScopes(Token.Scopes)).Any())
await CloseAsync(WebSocketCloseStatus.PolicyViolation);
else
await channel.SubscribeAsync(message);
await channel.Subscribe(message);
break;
}
case "unsubscribe":
{
var channel =
_channels.FirstOrDefault(p => p.Name == message.Stream && (p.IsSubscribed || p.IsAggregate));
if (channel != null) await channel.UnsubscribeAsync(message);
if (channel != null) await channel.Unsubscribe(message);
break;
}
default:
@ -344,7 +343,7 @@ public sealed class WebSocketConnection(
if (list.UserId != Token.User.Id) return;
if (!list.HideFromHomeTl) return;
await using var scope = GetAsyncServiceScope();
await using var scope = ScopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
HiddenFromHome = await db.UserListMembers
@ -376,7 +375,7 @@ public sealed class WebSocketConnection(
(IsFiltered(note.Renote.Renote.User) ||
IsFilteredMentions(note.Renote.Renote.Mentions)));
public async Task<bool> IsMutedThreadAsync(Note note, AsyncServiceScope scope, bool isNotification = false)
public async Task<bool> IsMutedThread(Note note, AsyncServiceScope scope, bool isNotification = false)
{
if (!isNotification && note.Reply == null) return false;
if (!isNotification && note.User.Id == Token.UserId) return false;
@ -384,21 +383,6 @@ public sealed class WebSocketConnection(
return await db.NoteThreadMutings.AnyAsync(p => p.UserId == Token.UserId && p.ThreadId == note.ThreadId);
}
public AsyncServiceScope GetAsyncServiceScope()
{
var scope = scopeFactory.CreateAsyncScope();
InitializeScopeLocalParameters(scope);
return scope;
}
private void InitializeScopeLocalParameters(IServiceScope scope)
{
var flags = scope.ServiceProvider.GetRequiredService<FlagService>();
flags.SupportsHtmlFormatting.Value = Token.SupportsHtmlFormatting;
flags.SupportsInlineMedia.Value = Token.SupportsInlineMedia;
flags.IsPleroma.Value = Token.IsPleroma;
}
public async Task CloseAsync(WebSocketCloseStatus status)
{
Dispose();
@ -412,7 +396,7 @@ public interface IChannel
public List<string> Scopes { get; }
public bool IsSubscribed { get; }
public bool IsAggregate { get; }
public Task SubscribeAsync(StreamingRequestMessage message);
public Task UnsubscribeAsync(StreamingRequestMessage message);
public Task Subscribe(StreamingRequestMessage message);
public Task Unsubscribe(StreamingRequestMessage message);
public void Dispose();
}

View file

@ -32,7 +32,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
public async Task<IEnumerable<StatusEntity>> GetHomeTimeline(MastodonPaginationQuery query)
{
var user = HttpContext.GetUserOrFail();
var heuristic = await QueryableTimelineExtensions.GetHeuristicAsync(user, db, cache);
var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache);
return await db.Notes
.IncludeCommonProperties()
.FilterByFollowingAndOwn(user, db, heuristic)

View file

@ -29,13 +29,13 @@ public class WebSocketController(
if (!HttpContext.WebSockets.IsWebSocketRequest)
throw GracefulException.BadRequest("Not a WebSocket request");
var ct = appLifetime.ApplicationStopping;
var protocol = HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();
accessToken ??= protocol ?? throw GracefulException.BadRequest("Missing WebSocket protocol header");
var ct = appLifetime.ApplicationStopping;
accessToken ??= HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault() ??
throw GracefulException.BadRequest("Missing WebSocket protocol header");
var token = await Authenticate(accessToken);
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
try
{
await WebSocketHandler.HandleConnectionAsync(webSocket, token, eventSvc, scopeFactory,
@ -57,7 +57,7 @@ public class WebSocketController(
.Include(p => p.User.UserProfile)
.Include(p => p.User.UserSettings)
.Include(p => p.App)
.FirstOrDefaultAsync(p => p.Token == token && p.Active)
?? throw GracefulException.Unauthorized("This method requires an authenticated user");
.FirstOrDefaultAsync(p => p.Token == token && p.Active) ??
throw GracefulException.Unauthorized("This method requires an authenticated user");
}
}
}

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

@ -3,13 +3,11 @@ using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Pleroma;
@ -17,7 +15,7 @@ namespace Iceshrimp.Backend.Controllers.Pleroma;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class EmojiController(IOptions<Config.InstanceSection> instance, DatabaseContext db) : ControllerBase
public class EmojiController(DatabaseContext db) : ControllerBase
{
[HttpGet("/api/v1/pleroma/emoji")]
[ProducesResults(HttpStatusCode.OK)]
@ -28,11 +26,11 @@ public class EmojiController(IOptions<Config.InstanceSection> instance, Database
.Select(p => KeyValuePair.Create(p.Name,
new PleromaEmojiEntity
{
ImageUrl = p.GetAccessUrl(instance.Value),
ImageUrl = p.PublicUrl,
Tags = new[] { p.Category ?? "" }
}))
.ToArrayAsync();
return new Dictionary<string, PleromaEmojiEntity>(emoji);
}
}
}

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("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

@ -66,7 +66,7 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var res = await noteRenderer.GetReactionsAsync([note], user);
var res = await noteRenderer.GetReactions([note], user);
foreach (var item in res)
{
@ -98,9 +98,9 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var res = (await noteRenderer.GetReactionsAsync([note], user)).Where(r => r.Name.Split("@").First() ==
Regex.Unescape(reaction))
.ToArray();
var res = (await noteRenderer.GetReactions([note], user)).Where(r => r.Name.Split("@").First() ==
Regex.Unescape(reaction))
.ToArray();
foreach (var item in res)
{

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.Core.Database;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Helpers;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@ -36,7 +36,7 @@ public class LinkPaginationAttribute(
var entities = context.HttpContext.GetPaginationData();
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;
var ids = entities.Select(p => p.Id).ToList();
@ -89,15 +89,15 @@ public static class HttpContextExtensions
{
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);
}
public static IEnumerable<IIdentifiable>? GetPaginationData(this HttpContext ctx)
public static IEnumerable<IEntity>? GetPaginationData(this HttpContext ctx)
{
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.Services;
using Iceshrimp.Backend.Core.Tasks;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Configuration;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
@ -277,7 +276,7 @@ public class AdminController(
[ProducesResults(HttpStatusCode.OK)]
public async Task SubscribeToRelay(RelaySchemas.RelayRequest rq)
{
await relaySvc.SubscribeToRelayAsync(rq.Inbox);
await relaySvc.SubscribeToRelay(rq.Inbox);
}
[HttpDelete("relays/{id}")]
@ -287,7 +286,7 @@ public class AdminController(
{
var relay = await db.Relays.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Relay not found");
await relaySvc.UnsubscribeFromRelayAsync(relay);
await relaySvc.UnsubscribeFromRelay(relay);
}
[HttpPost("drive/prune-expired-media")]
@ -295,30 +294,12 @@ public class AdminController(
public async Task PruneExpiredMedia([FromServices] IServiceScopeFactory factory)
{
await using var scope = factory.CreateAsyncScope();
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);
await new MediaCleanupTask().Invoke(scope.ServiceProvider);
}
[HttpGet("policy")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePoliciesAsync();
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePolicies();
[HttpGet("policy/{name}")]
[ProducesResults(HttpStatusCode.OK)]
@ -326,7 +307,7 @@ public class AdminController(
public async Task<IPolicyConfiguration> GetPolicyConfiguration(string name)
{
var raw = await db.PolicyConfiguration.Where(p => p.Name == name).Select(p => p.Data).FirstOrDefaultAsync();
return await policySvc.GetConfigurationAsync(name, raw) ?? throw GracefulException.NotFound("Policy not found");
return await policySvc.GetConfiguration(name, raw) ?? throw GracefulException.NotFound("Policy not found");
}
[HttpPut("policy/{name}")]
@ -336,8 +317,7 @@ public class AdminController(
string name, [SwaggerBodyExample("{\n \"enabled\": true\n}")] JsonDocument body
)
{
var type = await policySvc.GetConfigurationTypeAsync(name) ??
throw GracefulException.NotFound("Policy not found");
var type = await policySvc.GetConfigurationType(name) ?? throw GracefulException.NotFound("Policy not found");
var data = body.Deserialize(type, JsonSerialization.Options) as IPolicyConfiguration;
if (data?.GetType() != type) throw GracefulException.BadRequest("Invalid policy config");
var serialized = JsonSerializer.Serialize(data, type, JsonSerialization.Options);
@ -347,7 +327,7 @@ public class AdminController(
.On(p => new { p.Name })
.RunAsync();
await policySvc.UpdateAsync();
await policySvc.Update();
}
[UseNewtonsoftJson]

View file

@ -23,14 +23,21 @@ namespace Iceshrimp.Backend.Controllers.Web;
public class AuthController(DatabaseContext db, UserService userSvc, UserRenderer userRenderer) : ControllerBase
{
[HttpGet]
[Authenticate(AllowInactive = true)]
[Authenticate]
[ProducesResults(HttpStatusCode.OK)]
public async Task<AuthResponse> GetAuthStatus()
{
var session = HttpContext.GetSession();
if (session == null) return new AuthResponse { Status = AuthStatusEnum.Guest };
return await GetAuthResponse(session, session.User);
return new AuthResponse
{
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token,
IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(session.User)
};
}
[HttpPost("login")]
@ -43,8 +50,8 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
Justification = "Argon2 is execution time-heavy by design")]
public async Task<AuthResponse> Login([FromBody] AuthRequest request)
{
var user = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser
&& p.UsernameLower == request.Username.ToLowerInvariant());
var user = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser &&
p.UsernameLower == request.Username.ToLowerInvariant());
if (user == null)
throw GracefulException.Forbidden("Invalid username or password");
if (user.IsSystemUser)
@ -70,7 +77,14 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
await db.SaveChangesAsync();
}
return await GetAuthResponse(session, user);
return new AuthResponse
{
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token,
IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(user)
};
}
[HttpPost("register")]
@ -86,45 +100,6 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
return await Login(request);
}
[HttpPost("2fa")]
[Authenticate(AllowInactive = true)]
[Authorize(AllowInactive = true)]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
public async Task<AuthResponse> SubmitTwoFactor([FromBody] TwoFactorRequest request)
{
var user = HttpContext.GetUserOrFail();
var session = HttpContext.GetSessionOrFail();
if (session.Active)
return await GetAuthResponse(session, user);
if (user.UserSettings?.TwoFactorEnabled != true)
throw GracefulException.BadRequest("2FA is disabled");
if (user.UserSettings?.TwoFactorSecret is not { } secret)
throw new Exception("2FA is enabled but no secret is known");
if (request.Code is not { Length: 6 } totp)
throw GracefulException.Forbidden("Missing or invalid TOTP code");
if (!TotpHelper.Validate(secret, totp))
throw GracefulException.Forbidden("Invalid TOTP code");
session.Active = true;
await db.SaveChangesAsync();
return await GetAuthResponse(session, user);
}
[HttpPost("logout")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
public async Task Logout()
{
var session = HttpContext.GetSessionOrFail();
db.Remove(session);
await db.SaveChangesAsync();
}
[HttpPost("change-password")]
[Authenticate]
[Authorize]
@ -149,16 +124,4 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
return await Login(new AuthRequest { Username = user.Username, Password = request.NewPassword });
}
private async Task<AuthResponse> GetAuthResponse(Session session, User user)
{
return new AuthResponse
{
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token,
IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(user)
};
}
}
}

View file

@ -1,11 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
@ -13,7 +10,6 @@ using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -27,91 +23,63 @@ public class DriveController(
IOptionsSnapshot<Config.StorageSection> options,
ILogger<DriveController> logger,
DriveService driveSvc,
QueueService queueSvc,
HttpClient httpClient
QueueService queueSvc
) : ControllerBase
{
private const string CacheControl = "max-age=31536000, immutable";
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/files/{accessKey}/{version?}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[HttpGet("/files/{accessKey}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
public async Task<IActionResult> GetFileByAccessKey(string accessKey)
{
return await GetFileByAccessKey(accessKey, version, null);
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmojiById(string id)
{
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Emoji not found");
if (!options.Value.ProxyRemoteMedia || emoji.Host == null)
return Redirect(emoji.RawPublicUrl);
return await ProxyAsync(emoji.RawPublicUrl, null, null);
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/avatars/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAvatarByUserId(string userId, string? version)
{
var user = await db.Users.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == userId)
?? throw GracefulException.NotFound("User not found");
if (user.Avatar is null)
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey ||
p.PublicAccessKey == accessKey ||
p.ThumbnailAccessKey == accessKey);
if (file == null)
{
var stream = await IdenticonHelper.GetIdenticonAsync(user.Id);
Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false);
Response.Headers.CacheControl = "max-age=86400";
throw GracefulException.NotFound("File not found");
}
if (!options.Value.ProxyRemoteMedia)
return Redirect(user.Avatar.RawThumbnailAccessUrl);
if (file.StoredInternal)
{
var pathBase = options.Value.Local?.Path;
if (string.IsNullOrWhiteSpace(pathBase))
{
logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey);
throw GracefulException.NotFound("File not found");
}
return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar);
}
var path = Path.Join(pathBase, accessKey);
var stream = System.IO.File.OpenRead(path);
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/banners/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
{
var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId)
?? throw GracefulException.NotFound("User not found");
Response.Headers.CacheControl = "max-age=31536000, immutable";
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
else
{
if (file.IsLink)
{
//TODO: handle remove media proxying
return NoContent();
}
if (user.Banner is null)
return NoContent();
var stream = await objectStorage.GetFileAsync(accessKey);
if (stream == null)
{
logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
throw GracefulException.NotFound("File not found");
}
if (!options.Value.ProxyRemoteMedia)
return Redirect(user.Banner.RawThumbnailAccessUrl);
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);
Response.Headers.CacheControl = "max-age=31536000, immutable";
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
}
[HttpPost]
@ -120,17 +88,16 @@ public class DriveController(
[Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[MaxRequestSizeIsMaxUploadSize]
public async Task<DriveFileResponse> UploadFile(IFormFile file, [FromQuery] string? folderId)
public async Task<DriveFileResponse> UploadFile(IFormFile file)
{
var user = HttpContext.GetUserOrFail();
var request = new DriveFileCreationRequest
{
Filename = file.FileName,
MimeType = file.ContentType,
IsSensitive = false,
FolderId = folderId
IsSensitive = false
};
var res = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, request);
var res = await driveSvc.StoreFile(file.OpenReadStream(), user, request);
return await GetFileById(res.Id);
}
@ -143,23 +110,18 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileById(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
return new DriveFileResponse
{
Id = file.Id,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Url = file.AccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
Sensitive = file.IsSensitive
};
}
@ -172,23 +134,18 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileByHash(string sha256)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256)
?? throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) ??
throw GracefulException.NotFound("File not found");
return new DriveFileResponse
{
Id = file.Id,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Url = file.AccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
Sensitive = file.IsSensitive
};
}
@ -202,8 +159,8 @@ public class DriveController(
public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
file.Name = request.Filename ?? file.Name;
file.IsSensitive = request.Sensitive ?? file.IsSensitive;
@ -221,8 +178,8 @@ public class DriveController(
public async Task<IActionResult> DeleteFile(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file))
throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar");
@ -236,318 +193,4 @@ public class DriveController(
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)
{
file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey
|| p.PublicAccessKey == accessKey
|| p.ThumbnailAccessKey == accessKey);
if (file == null)
{
Response.Headers.CacheControl = "max-age=86400";
throw GracefulException.NotFound("File not found");
}
if (file.IsLink)
{
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
if (!options.Value.ProxyRemoteMedia)
return Redirect(fetchUrl);
try
{
var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp")
? file.Name
: $"{file.Name}.webp";
return await ProxyAsync(fetchUrl, file.Type, filename);
}
catch (Exception e) when (e is not GracefulException)
{
throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true);
}
}
if (file.StoredInternal)
{
var pathBase = options.Value.Local?.Path;
if (string.IsNullOrWhiteSpace(pathBase))
{
logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey);
throw GracefulException.NotFound("File not found");
}
var path = Path.Join(pathBase, accessKey);
var stream = System.IO.File.OpenRead(path);
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
else
{
var stream = await objectStorage.GetFileAsync(accessKey);
if (stream == null)
{
logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
throw GracefulException.NotFound("File not found");
}
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
}
private async Task<IActionResult> ProxyAsync(string url, string? expectedMediaType, string? filename)
{
try
{
// @formatter:off
var res = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!res.IsSuccessStatusCode)
throw GracefulException.BadGateway($"Failed to proxy request: response status was {res.StatusCode}", suppressLog: true);
if (res.Content.Headers.ContentType?.MediaType is not { } mediaType)
throw GracefulException.BadGateway("Failed to proxy request: remote didn't return Content-Type");
if (expectedMediaType != null && mediaType != expectedMediaType && !Constants.BrowserSafeMimeTypes.Contains(mediaType))
throw GracefulException.BadGateway("Failed to proxy request: content type mismatch", suppressLog: true);
// @formatter:on
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
var stream = await res.Content.ReadAsStreamAsync();
return Constants.BrowserSafeMimeTypes.Contains(mediaType)
? new InlineFileStreamResult(stream, mediaType, filename, true)
: File(stream, mediaType, filename, true);
}
catch (Exception e) when (e is not GracefulException)
{
throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true);
}
}
}
}

View file

@ -1,18 +1,13 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Helpers;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web;
@ -23,7 +18,6 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Route("/api/iceshrimp/emoji")]
[Produces(MediaTypeNames.Application.Json)]
public class EmojiController(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
EmojiService emojiSvc,
EmojiImportService emojiImportSvc
@ -40,131 +34,59 @@ public class EmojiController(
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Tags = p.Tags,
Aliases = p.Aliases,
Category = p.Category,
PublicUrl = p.GetAccessUrl(instance.Value),
PublicUrl = p.PublicUrl,
License = p.License,
Sensitive = p.Sensitive
})
.ToListAsync();
}
[HttpGet("remote")]
[Authorize("role:moderator")]
[RestPagination(100, 500)]
[ProducesResults(HttpStatusCode.OK)]
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmoji(
[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}")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<EmojiResponse> GetEmoji(string id)
{
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Emoji not found");
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Emoji not found");
return new EmojiResponse
{
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Tags = emoji.Tags,
Aliases = emoji.Aliases,
Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value),
PublicUrl = emoji.PublicUrl,
License = emoji.License,
Sensitive = emoji.Sensitive
};
}
[HttpPost]
[Authorize("role:moderator")]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Conflict)]
public async Task<EmojiResponse> UploadEmoji(IFormFile file, [FromQuery] string name)
public async Task<EmojiResponse> UploadEmoji(IFormFile file)
{
var ext = Path.HasExtension(file.FileName) ? Path.GetExtension(file.FileName) : "";
var emoji = await emojiSvc.CreateEmojiFromStreamAsync(file.OpenReadStream(), name + ext, file.ContentType);
var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType);
return new EmojiResponse
{
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Tags = [],
Aliases = [],
Category = null,
PublicUrl = emoji.GetAccessUrl(instance.Value),
PublicUrl = emoji.PublicUrl,
License = null,
Sensitive = false
};
}
[HttpPost("clone/{name}@{host}")]
[Authorize("role:moderator")]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
public async Task<EmojiResponse> CloneEmoji(string name, string host)
@ -175,61 +97,60 @@ public class EmojiController(
var emojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == host);
if (emojo == null) throw GracefulException.NotFound("Emoji not found");
var cloned = await emojiSvc.CloneEmojiAsync(emojo);
var cloned = await emojiSvc.CloneEmoji(emojo);
return new EmojiResponse
{
Id = cloned.Id,
Name = cloned.Name,
Uri = cloned.Uri,
Tags = [],
Aliases = [],
Category = null,
PublicUrl = cloned.GetAccessUrl(instance.Value),
PublicUrl = cloned.PublicUrl,
License = null,
Sensitive = cloned.Sensitive
};
}
[HttpPost("import")]
[Authorize("role:moderator")]
[NoRequestSizeLimit]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.Accepted)]
public async Task<AcceptedResult> ImportEmoji(IFormFile file)
{
var zip = await emojiImportSvc.ParseAsync(file.OpenReadStream());
await emojiImportSvc.ImportAsync(zip); // TODO: run in background. this will take a while
var zip = await emojiImportSvc.Parse(file.OpenReadStream());
await emojiImportSvc.Import(zip); // TODO: run in background. this will take a while
return Accepted();
}
[HttpPatch("{id}")]
[Authorize("role:moderator")]
[Authorize("role:admin")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request)
{
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Tags, request.Category,
request.License, request.Sensitive)
?? throw GracefulException.NotFound("Emoji not found");
var emoji = await emojiSvc.UpdateLocalEmoji(id, request.Name, request.Aliases, request.Category,
request.License, request.Sensitive) ??
throw GracefulException.NotFound("Emoji not found");
return new EmojiResponse
{
Id = emoji.Id,
Name = emoji.Name,
Uri = emoji.Uri,
Tags = emoji.Tags,
Aliases = emoji.Aliases,
Category = emoji.Category,
PublicUrl = emoji.GetAccessUrl(instance.Value),
PublicUrl = emoji.PublicUrl,
License = emoji.License,
Sensitive = emoji.Sensitive
};
}
[HttpDelete("{id}")]
[Authorize("role:moderator")]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteEmoji(string id)
{
await emojiSvc.DeleteEmojiAsync(id);
await emojiSvc.DeleteEmoji(id);
}
}
}

View file

@ -39,7 +39,7 @@ public class FollowRequestController(
.Select(p => new { p.Id, p.Follower })
.ToListAsync();
var users = await userRenderer.RenderManyAsync(requests.Select(p => p.Follower));
var users = await userRenderer.RenderMany(requests.Select(p => p.Follower));
return requests.Select(p => new FollowRequestResponse
{
Id = p.Id, User = users.First(u => u.Id == p.Follower.Id)

View file

@ -1,15 +1,27 @@
using System.IO.Hashing;
using System.Net;
using System.Net.Mime;
using System.Text;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace Iceshrimp.Backend.Core.Helpers;
namespace Iceshrimp.Backend.Controllers.Web;
public static class IdenticonHelper
[ApiController]
[EnableCors("drive")]
[Route("/identicon/{id}")]
[Route("/identicon/{id}.png")]
[Produces(MediaTypeNames.Image.Png)]
public class IdenticonController : ControllerBase
{
public static async Task<Stream> GetIdenticonAsync(string id)
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
public async Task GetIdenticon(string id)
{
using var image = new Image<Rgb24>(Size, Size);
@ -62,10 +74,9 @@ public static class IdenticonHelper
}
}
var stream = new MemoryStream();
await image.SaveAsPngAsync(stream);
stream.Seek(0, SeekOrigin.Begin);
return stream;
Response.Headers.CacheControl = "max-age=31536000, immutable";
Response.Headers.ContentType = "image/png";
await image.SaveAsPngAsync(Response.Body);
}
#region Color definitions & Constants
@ -117,4 +128,4 @@ public static class IdenticonHelper
];
#endregion
}
}

View file

@ -1,134 +0,0 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("/api/iceshrimp/instance")]
[Produces(MediaTypeNames.Application.Json)]
public class InstanceController(
DatabaseContext db,
UserRenderer userRenderer,
IOptions<Config.InstanceSection> instanceConfig,
IOptionsSnapshot<Config.SecuritySection> securityConfig,
MetaService meta,
InstanceService instanceSvc
) : ControllerBase
{
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceResponse> GetInfo()
{
var limits = new Limitations { NoteLength = instanceConfig.Value.CharacterLimit };
return new InstanceResponse
{
AccountDomain = instanceConfig.Value.AccountDomain,
WebDomain = instanceConfig.Value.WebDomain,
Registration = (Registrations)securityConfig.Value.Registrations,
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")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
public async Task<StaffResponse> GetStaff()
{
var admins = db.Users
.Where(p => p.IsAdmin == true)
.OrderBy(p => p.UsernameLower);
var adminList = await userRenderer.RenderManyAsync(admins)
.ToListAsync();
var moderators = db.Users
.Where(p => p.IsAdmin == false && p.IsModerator == true)
.OrderBy(p => p.UsernameLower);
var moderatorList = await userRenderer.RenderManyAsync(moderators)
.ToListAsync();
return new StaffResponse { Admins = adminList, Moderators = moderatorList };
}
}

View file

@ -1,27 +0,0 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("/manifest.webmanifest")]
[Produces(MediaTypeNames.Application.Json)]
public class ManifestController(IOptions<Config.InstanceSection> config) : ControllerBase
{
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
public WebManifest GetWebManifest()
{
return new WebManifest
{
Name = config.Value.AccountDomain, ShortName = config.Value.AccountDomain
};
}
}

View file

@ -46,7 +46,7 @@ public class MiscController(DatabaseContext db, NoteRenderer noteRenderer, BiteS
await biteSvc.BiteAsync(user, target);
}
[HttpGet("muted_threads")]
[LinkPagination(20, 40)]
[ProducesResults(HttpStatusCode.OK)]
@ -61,6 +61,6 @@ public class MiscController(DatabaseContext db, NoteRenderer noteRenderer, BiteS
.PrecomputeVisibilities(user)
.ToListAsync();
return await noteRenderer.RenderManyAsync(notes.EnforceRenoteReplyVisibility(), user);
return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user);
}
}

View file

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

View file

@ -29,9 +29,7 @@ public class NoteController(
NoteRenderer noteRenderer,
UserRenderer userRenderer,
CacheService cache,
BiteService biteSvc,
PollService pollSvc,
ReportService reportSvc
BiteService biteSvc
) : ControllerBase
{
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
@ -98,7 +96,7 @@ public class NoteController(
.ToListAsync();
var notes = hits.EnforceRenoteReplyVisibility();
var res = await noteRenderer.RenderManyAsync(notes, user, Filter.FilterContext.Threads).ToListAsync();
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads).ToListAsync();
// Strip redundant reply data
foreach (var item in res.Where(p => p.Reply != null && res.Any(i => i.Id == p.Reply.Id)))
@ -133,7 +131,7 @@ public class NoteController(
.ToListAsync();
var notes = hits.EnforceRenoteReplyVisibility();
var res = await noteRenderer.RenderManyAsync(notes, user, Filter.FilterContext.Threads).ToListAsync();
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads).ToListAsync();
// Strip redundant reply data
foreach (var item in res.Where(p => p.Reply != null && res.Any(i => i.Id == p.Reply.Id)))
@ -159,15 +157,13 @@ public class NoteController(
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
name = name.Trim(':');
var users = await db.NoteReactions
.Where(p => p.Note == note && (p.Reaction == $":{name}:" || p.Reaction == name))
.Where(p => p.Note == note && p.Reaction == $":{name.Trim(':')}:")
.Include(p => p.User.UserProfile)
.Select(p => p.User)
.ToListAsync();
return await userRenderer.RenderManyAsync(users);
return await userRenderer.RenderMany(users);
}
[HttpPost("{id}/bite")]
@ -253,7 +249,7 @@ public class NoteController(
.Wrap(p => p.User)
.ToListAsync();
var res = await userRenderer.RenderManyAsync(users.Select(p => p.Entity));
var res = await userRenderer.RenderMany(users.Select(p => p.Entity));
return HttpContext.CreatePaginationWrapper(pq, users, res);
}
@ -319,7 +315,7 @@ public class NoteController(
.Wrap(p => p.User)
.ToListAsync();
var res = await userRenderer.RenderManyAsync(users.Select(p => p.Entity));
var res = await userRenderer.RenderMany(users.Select(p => p.Entity));
return HttpContext.CreatePaginationWrapper(pq, users, res);
}
@ -347,8 +343,8 @@ public class NoteController(
.Paginate(pq, ControllerContext)
.ToListAsync();
var res = await noteRenderer.RenderManyAsync(renotes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Threads);
var res = await noteRenderer.RenderMany(renotes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Threads);
return HttpContext.CreatePaginationWrapper(pq, renotes, res);
}
@ -504,64 +500,6 @@ public class NoteController(
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]
[Authenticate]
[Authorize]
@ -617,18 +555,6 @@ public class NoteController(
? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync()
: 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
{
User = user,
@ -637,8 +563,7 @@ public class NoteController(
Cw = request.Cw,
Reply = reply,
Renote = renote,
Attachments = attachments,
Poll = poll
Attachments = attachments
});
if (request.IdempotencyKey != null)
@ -646,18 +571,4 @@ public class NoteController(
return await noteRenderer.RenderOne(note, user);
}
[HttpPost("{id}/report")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task ReportNote(string id, [FromBody] NoteReportRequest request)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Include(p => p.User).EnsureVisibleFor(user).FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Note not found");
await reportSvc.CreateReportAsync(user, note.User, [note], request.Comment);
}
}

View file

@ -36,8 +36,7 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
.PrecomputeNoteVisibilities(user)
.ToListAsync();
return await notificationRenderer.RenderManyAsync(notifications.EnforceRenoteReplyVisibility(p => p.Note),
user);
return await notificationRenderer.RenderMany(notifications.EnforceRenoteReplyVisibility(p => p.Note), user);
}
[HttpPost("{id}/read")]

View file

@ -1,14 +1,12 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Web;
@ -18,11 +16,7 @@ namespace Iceshrimp.Backend.Controllers.Web;
[EnableRateLimiting("sliding")]
[Route("/api/iceshrimp/profile")]
[Produces(MediaTypeNames.Application.Json)]
public class ProfileController(
UserService userSvc,
DriveService driveSvc,
DatabaseContext db
) : ControllerBase
public class ProfileController(UserService userSvc) : ControllerBase
{
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
@ -39,12 +33,7 @@ public class ProfileController(
Location = profile.Location,
Birthday = profile.Birthday,
FFVisibility = ffVisibility,
Fields = fields.ToList(),
DisplayName = user.DisplayName ?? "",
IsBot = user.IsBot,
IsCat = user.IsCat,
SpeakAsCat = user.SpeakAsCat,
Pronouns = profile.Pronouns ?? []
Fields = fields.ToList()
};
}
@ -76,186 +65,9 @@ public class ProfileController(
profile.Birthday = birthday;
profile.Fields = fields.ToArray();
profile.FFVisibility = (UserProfile.UserProfileFFVisibility)newProfile.FFVisibility;
profile.Pronouns = newProfile.Pronouns;
user.DisplayName = string.IsNullOrWhiteSpace(newProfile.DisplayName) ? null : newProfile.DisplayName.Trim();
user.IsBot = newProfile.IsBot;
user.IsCat = newProfile.IsCat;
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 prevBannerId = user.BannerId;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
[HttpGet("avatar")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> GetAvatar()
{
var user = HttpContext.GetUserOrFail();
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")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task UpdateAvatar(IFormFile file, [FromQuery] string? altText)
{
var user = HttpContext.GetUserOrFail();
var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId;
if (!file.ContentType.StartsWith("image/"))
throw GracefulException.BadRequest("Avatar must be an image");
var rq = new DriveFileCreationRequest
{
Filename = file.FileName,
IsSensitive = false,
MimeType = file.ContentType,
Comment = altText
};
var avatar = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq);
user.Avatar = avatar;
user.AvatarId = avatar.Id;
user.AvatarBlurhash = avatar.Blurhash;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
[HttpDelete("avatar")]
[ProducesResults(HttpStatusCode.OK)]
public async Task DeleteAvatar()
{
var user = HttpContext.GetUserOrFail();
var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId;
if (prevAvatarId == null) return;
user.Avatar = null;
user.AvatarBlurhash = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
[HttpGet("banner")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> GetBanner()
{
var user = HttpContext.GetUserOrFail();
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")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task UpdateBanner(IFormFile file, [FromQuery] string? altText)
{
var user = HttpContext.GetUserOrFail();
var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId;
if (!file.ContentType.StartsWith("image/"))
throw GracefulException.BadRequest("Banner must be an image");
var rq = new DriveFileCreationRequest
{
Filename = file.FileName,
IsSensitive = false,
MimeType = file.ContentType,
Comment = altText
};
var banner = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq);
user.Banner = banner;
user.BannerId = banner.Id;
user.BannerBlurhash = banner.Blurhash;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
[HttpDelete("banner")]
[ProducesResults(HttpStatusCode.OK)]
public async Task DeleteBanner()
{
var user = HttpContext.GetUserOrFail();
var prevAvatarId = user.AvatarId;
var prevBannerId = user.BannerId;
if (prevBannerId == null) return;
user.Banner = null;
user.BannerBlurhash = null;
await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
}
}
}

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