Compare commits
1 commit
dev
...
wip/cluste
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b148c8a0ba |
778 changed files with 10694 additions and 36920 deletions
|
@ -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
|
||||
|
|
|
@ -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"]
|
|
@ -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"]
|
|
@ -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"]
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
|
@ -6,28 +6,22 @@ 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
|
||||
run: make test VERBOSE=true DEP_VULN_WERROR=true
|
||||
run: make test VERBOSE=true
|
||||
- name: Build docker image
|
||||
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 }}
|
||||
|
|
|
@ -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
|
||||
- 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 }}
|
||||
|
|
|
@ -7,19 +7,16 @@ 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
|
||||
run: make test VERBOSE=true DEP_VULN_WERROR=true
|
||||
run: make test VERBOSE=true
|
||||
|
|
|
@ -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>
|
690
CHANGELOG.md
690
CHANGELOG.md
|
@ -1,697 +1,9 @@
|
|||
## 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.
|
||||
|
||||
### Blazor frontend
|
||||
- Empty timelines no longer cause a frontend crash
|
||||
- Unauthenticated access to some pages no longer causes a frontend crash
|
||||
- The overflow behavior of several list UI elements was fixed
|
||||
- An issue that caused UI elements to not fade out properly when a dialog was open has been resolved
|
||||
- Code blocks now scroll horizontally
|
||||
- Opening the emoji picker no longer scrolls the page to the top
|
||||
- A button to copy the link to a post to the clipboard has been added
|
||||
|
||||
### Attribution
|
||||
This release was made possible by project contributors: Lilian
|
||||
|
||||
## v2024.1-beta3
|
||||
This release contains lots of new features & bugfixes. Upgrading is recommended for all server operators.
|
||||
|
||||
### Highlights
|
||||
- Significant frontend UX improvements
|
||||
- The backend can now load plugin assemblies. Plugins can inject new endpoints, react to events, instantiate cron tasks & store configuration data.
|
||||
- There is now an easy migration assistant for existing users of Iceshrimp-JS.
|
||||
- The image processing pipeline is now modular & highly configurable. The defaults match the previous hardcoded behavior, minus a couple bugs.
|
||||
- The request / job id is now printed alongside every log message
|
||||
- When running in an interactive terminal, logs are now color-formatted for easier readability
|
||||
- WebFinger reverse discovery is now supported, fixing federation with older Pixelfed instances & other miscellaneous AP implementations
|
||||
- Error responses are now returned as HTML, XML or JSON depending on the Accept header of the request
|
||||
- Database connection multiplexing is now enabled by default, allowing for massive bursts in traffic without compromising stability or degrading performance beyond expectations.
|
||||
- The partial cluster mode implementation has been removed to minimize development overhead. It may be added back once vertical scaling proves to not be enough for larger instances.
|
||||
|
||||
### Blazor frontend
|
||||
- The MFM plain node is now supported
|
||||
- The sidebar layout has been reworked
|
||||
- A bug that prevented you from renoting of your own notes has been fixed
|
||||
- Notes which have been renoted are now indicated
|
||||
- Notes that cannot be renoted are now indicated more clearly
|
||||
- Note state is now updated across the application on changes
|
||||
- A page for managing follow requests has been added
|
||||
- Follow request related notifications are now supported
|
||||
- A follow button has been added
|
||||
- Erroneous compile time warnings related to ILLink have been fixed
|
||||
- The sidebar has been reworked in the mobile view
|
||||
- A bug that made it impossible to interact with renotes has been resolved
|
||||
- Extraneous debug logging has been removed
|
||||
- Basic logging has been added
|
||||
- A version information page has been added
|
||||
- Most menus are now self-closing
|
||||
- Performance has been improved significantly by only re-rendering notes when required
|
||||
- The reply tree now updates when new replies are added
|
||||
- The scroll state in single note view is now preserved while navigating back and forth
|
||||
- Deleted notes are now removed from timeline and single note view
|
||||
- The timestamp display on notes has been fixed & now live-updates
|
||||
- Renote visibility is now selectable
|
||||
- The single note view now loads much faster via concurrent network requests
|
||||
- The styling of MFM links and URLs has been fixed
|
||||
- An Error UI when an unhandled exception occurs has been introduced, displaying the stacktrace and allowing log download
|
||||
- An in memory logger has been added
|
||||
- A profile editing page has been added
|
||||
- The send post button now reflects request state
|
||||
- The top of the page now displays a sticky indicator of the current page
|
||||
- Filters can now be configured in settings and are enforced
|
||||
- Threads can now be muted
|
||||
- A button to accept a follow request & follow the sender back has been added
|
||||
|
||||
### Backend
|
||||
- The emoji import endpoints are now named consistently
|
||||
- Custom emoji in user display names & bios now federate correctly
|
||||
- Local users can now be resolved by their fully qualified names
|
||||
- The emoji reaction detection regex now detects unicode emoji more accurately
|
||||
- Sporadic GetOrCreateSystemUserAndKeypairAsync failures no longer occur
|
||||
- SystemUserService now has significantly improved logging
|
||||
- A new endpoint to refetch a note & its relations has been added
|
||||
- The rate limiting rules have been adjusted
|
||||
- Endpoints for managing follow requests have been added
|
||||
- The clone emoji endpoint now functions correctly
|
||||
- All MVC controllers have been refactored for better readability of endpoint attributes
|
||||
- Preloaded JSON-LD contexts are now embedded into the compiled .dll instead of being shipped alongside the binaries
|
||||
- The nodeinfo response now includes release codename and edition
|
||||
- Media file names are now preserved for S3 & local storage
|
||||
- User profile properties that only concern local users have been moved into a user settings table
|
||||
- Several unused user profile properties have been removed
|
||||
- Validation errors are now returned in a more sensible format
|
||||
- Endpoints concerning the user profile of the authenticated user have been added
|
||||
- The application no longer crashes on startup when running on IIS on Windows Server
|
||||
- The deliver queue now handles client errors (4xx) more gracefully
|
||||
- HTTP signatures now validate correctly on systems that use CRLF line endings
|
||||
- The user resolver now correctly updates the last fetched date before proceeding
|
||||
- The user settings attribute "auto accept followed" and "always mark sensitive" are now respected
|
||||
- Extraneous user table columns have been removed
|
||||
- Constraint/index differences between migrated and .net-native instances have been resolved
|
||||
- Endpoints concerning management of filters have been added
|
||||
- The entire note is now marked as sensitive if any attachments are tagged as such, improving federation compatibility
|
||||
- Streaming connection handlers no longer randomly crash due to prematurely disposed DbContext instances
|
||||
- The streaming service now supports the note deleted event
|
||||
- Endpoints related to muted threads have been added
|
||||
- Hashtags contained in incoming notes are now fixed up to be hashtags instead of links
|
||||
- Location and birthday profile fields now federate correctly
|
||||
- RazorRuntimeCompilation has been removed, as it doesn't behave correctly with CSS isolation
|
||||
- HTTP timeout exceptions no longer cause an excessively long stack trace to be logged
|
||||
- Animated PNG images are now being processed correctly
|
||||
- Added emoji now bypass the image processing pipeline
|
||||
- The ImageSharp dependency was swapped for an in-house fork that supports detection of animated images properly
|
||||
- The queue dashboard now has an overview page with live updates
|
||||
- A race condition in the queue system causing transient queue stalls has been fixed
|
||||
- The queue dashboard now uses local time instead of UTC when rendering timestamps
|
||||
- Unordered ASCollection(Page) objects now serialize & deserialize correctly
|
||||
- The light theme has been removed from the WaterCSS derivative stylesheets, allowing for significantly smaller file sizes & a less visually broken experience for light theme users. It may be added again in the future, after the display issues have been resolved.
|
||||
- The queue overview page now uses consistent ordering of queue names
|
||||
- Poll expiry jobs for polls that expire more than a year into the future aren't queued
|
||||
- The queue & application shutdown process has been significantly improved
|
||||
- The ImageSharp maximum memory allocation is now computed from the resolution limits instead of being hardcoded
|
||||
- The queue system algorithm is now explained in detail in code comments
|
||||
- Drive deduplication handling was improved
|
||||
- The OpenGraph embed preview no longer shows "0 Attachments" if there are no attachments
|
||||
- HTTP responses to outgoing requests are now limited to 1MiB (except for drive)
|
||||
- Remote media is now being processed using safe stream processing with enforced allocation limits
|
||||
- The default configuration is now embedded into the .dll, instead of being bundled alongside
|
||||
- MFM italic and bold nodes are now also parsed when using their alternative representation
|
||||
- Exception logging in DriveService has been improved
|
||||
- HEIC images are now allowed for inline viewing
|
||||
- A federation issue with a GoToSocial beta build was resolved
|
||||
- Requests from suspended users are now rejected on the middleware layer
|
||||
- Activities from suspended remote users are now rejected early
|
||||
- The preloaded JSON-LD contexts have been updated
|
||||
- Streaming connections now store their data in WriteLockingHashSets, significantly improving lookup performance
|
||||
- The WebFinger algorithm now reuses responses more efficiently, cutting down on unnecessary requests to remote instances
|
||||
- Local usernames are now validated more strictly
|
||||
- The WebFinger & host-meta serializers now support the XRD/XML host-meta format for both serialization and deserialization, fixing federation with Hubzilla
|
||||
- Hubzilla-style hashtags are now fixed up automatically
|
||||
- WebFinger responses now contain the user aliases
|
||||
- Host-meta responses are now returned with proper content negotiation
|
||||
- A possibly unbounded UserResolver recursion was fixed
|
||||
- The systemd logger now supports colored output, though journalctl strips color by default
|
||||
- The console logger now supports logging timestamps. This feature can be enabled by setting the `LOG_TIMESTAMPS=1` environment variable.
|
||||
- Untrusted XML input is now deserialized using a XmlReader instead of a XmlSerializer
|
||||
- The public preview lockdown message has been improved to be easier to understand by regular users
|
||||
- There are now unit tests for the unicode emoji detection code
|
||||
- Invalid misskey heart emoji reactions now get canonicalized to their correct representation
|
||||
- The razor error page now uses the same request ID in use in every other component
|
||||
- Federation with Friendica now works correctly
|
||||
- A bug causing sporadic "unique constraint violation error" logs has been resolved
|
||||
- ASLike activities with `content` property instead of `_misskey_reaction` are now being ingested correctly
|
||||
- Quotes are now being tagged as object links (FEP-e232)
|
||||
- Note updates with nonsensical timestamps are now discarded automatically
|
||||
- The deliver queue no longer caches `the userPrivateKey` property, as a cache lookup is equally expensive as a lookup in the `user_keypair` table, and we're lowering the chance of a cache hit by duplicating this data in memory.
|
||||
- Files larger than 28MB no longer fail to upload
|
||||
- An endpoint to get drive files by their hash has been added, only searching through the requesting users' files to make sure no metadata is being leaked.
|
||||
- The MFM quote node type is now supported
|
||||
- The note replies collection is now being exposed over ActivityPub
|
||||
- The canonical WebFinger address of local users is now exposed over ActivityPub (FEP-2c59)
|
||||
- The JSON-LD roundtrip unit test now validates that deserialization works as well
|
||||
- Blurhash performance & memory efficiency has been significantly improved for both LibVips and ImageSharp
|
||||
|
||||
### Razor (public preview, queue dashboard, etc.)
|
||||
- Code blocks that are wider than the viewport can now be scrolled through horizontally
|
||||
|
||||
### Mastodon client API
|
||||
- Inaccessible quotes & replies now carry a lock indicator even when the note has no text, and are rendered more consistently
|
||||
- Feature flags are now exposed as toggles on the OAuth authorization screen
|
||||
- The client_credentials grant type is now supported
|
||||
- Threads can now be muted
|
||||
- The user setting hideInaccessible is now respected in streaming connections
|
||||
- Custom emoji no longer render with double colons on either side in some clients
|
||||
- The OAuth authorization screen now allows 1-click login for users that are already authenticated in the blazor frontend
|
||||
- The user statuses endpoint now filters unresolved replies/renotes when exclude_replies or exclude_reblogs are set
|
||||
- Clients that support this are now informed that the server supports both polls and media in the same post
|
||||
- Editing filter keywords no longer results in API exceptions
|
||||
|
||||
### Miscellaneous
|
||||
- The dockerfiles & docker build scripts have been updated
|
||||
- The CI workflows have been updated
|
||||
- A global.json file has been added to configure the dotnet SDK version in a more precise manner
|
||||
- A Makefile has been added for easier build and deployment
|
||||
- Source Link now works correctly for docker builds, allowing the resulting application to know which git commit it has been built from
|
||||
- Docker & make publish builds now use deterministic source paths
|
||||
- Common build options were extracted into Directory.Build.props
|
||||
- The dotnet environment information is now printed in CI runs, allowing for easier auditing of security patch statuses
|
||||
- The README has been updated
|
||||
- All dependencies have been updated, with some manual overrides for transitive dependencies to patch vulnerabilities
|
||||
- Compile warnings are now being treated as errors, with limited exceptions
|
||||
- Transitive dependencies are now being audited for vulnerabilities as well
|
||||
- Docker builds based on musl no longer result in glibc binaries
|
||||
- FEDERATION.md (FEP-67ff) has been added to the project root
|
||||
|
||||
### Attribution
|
||||
This release was made possible by project contributors: Kopper, Laura Hausmann, Lilian, pancakes & zotan
|
||||
|
||||
## v2024.1-beta2.security3
|
||||
This is a security hotfix release. It's identical to v2024.1-beta2.security2, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
|
||||
|
||||
### Backend
|
||||
- Updated dotNetRdf to `3.2.9-iceshrimp` (addressing a possible DoS attack vector)
|
||||
- Limited the maximum HttpClient response size to 1MiB (up from 2GiB, addressing a possible DoS attack vector)
|
||||
- Refactored DriveService to use stream processing for remote media (addressing a possible DoS attack vector)
|
||||
|
||||
### Attribution
|
||||
This release was made possible by project contributors: Laura Hausmann
|
||||
|
||||
## v2024.1-beta2.security2
|
||||
This is a security hotfix release. It's identical to v2024.1-beta2.security1, except for referencing an updated version of the `SixLabors.ImageSharp` dependency, fixing a Denial of Service vulnerability ([GHSA-63p8-c4ww-9cg7](https://github.com/advisories/GHSA-63p8-c4ww-9cg7)). Upgrading is strongly recommended for all server operators.
|
||||
|
||||
### Backend
|
||||
- Updated SixLabors.ImageSharp to 3.1.5 (addressing [GHSA-63p8-c4ww-9cg7](https://github.com/advisories/GHSA-63p8-c4ww-9cg7))
|
||||
|
||||
### Attribution
|
||||
This release was made possible by project contributors: Laura Hausmann
|
||||
|
||||
## v2024.1-beta2.security1
|
||||
This is a security hotfix release. It's identical to v2024.1-beta2, except for referencing an updated version of the `System.Text.Json` dependency, fixing a Denial of Service vulnerability ([GHSA-hh2w-p6rv-4g7w](https://github.com/advisories/GHSA-hh2w-p6rv-4g7w)). Upgrading is strongly recommended for all server operators.
|
||||
|
||||
### Backend
|
||||
- Updated System.Text.Json to 8.0.4 (addressing [GHSA-hh2w-p6rv-4g7w](https://github.com/advisories/GHSA-hh2w-p6rv-4g7w))
|
||||
|
||||
### Attribution
|
||||
This release was made possible by project contributors: Laura Hausmann
|
||||
|
||||
## v2024.1-beta2
|
||||
This release contains various features & bugfixes, including a security issue. Upgrading is strongly recommended for all server operators.
|
||||
|
||||
### Frontend
|
||||
- Various leftover debug logging has been removed
|
||||
- The MFM nodes `center`, `quote`, `hashtag`, `small` and `strike` are now rendered correctly
|
||||
- The MFM node types `center`, `quote`, `hashtag`, `small` and `strike` are now rendered correctly
|
||||
- Custom emoji are now rendered in a visually consistent way when compared to iceshrimp-js
|
||||
- Non-image attachments are now rendered correctly
|
||||
- Stacking issues with positioned elements have been fixed
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<Project>
|
||||
<!-- Target framework & language version -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@ -12,22 +12,23 @@
|
|||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<SatelliteResourceLanguages>none</SatelliteResourceLanguages>
|
||||
<UseCurrentRuntimeIdentifier>true</UseCurrentRuntimeIdentifier>
|
||||
</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>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- When DependencyVulnsAsError is not set, also suppress the remaining dependency vulnerability warnings -->
|
||||
<PropertyGroup Condition="'$(DependencyVulnsAsError)' != 'true'">
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors),NU1903,NU1904</WarningsNotAsErrors>
|
||||
<WarningsNotAsErrors>NU1901,NU1902</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Version metadata -->
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>2025.1</VersionPrefix>
|
||||
<VersionSuffix>beta5.patch2.security1</VersionSuffix>
|
||||
<VersionPrefix>2024.1</VersionPrefix>
|
||||
<VersionSuffix>beta2.security2</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -49,8 +50,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>
|
||||
|
|
18
Dockerfile
18
Dockerfile
|
@ -1,25 +1,24 @@
|
|||
# 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"
|
||||
|
||||
ARG AOT=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 IMAGE=${AOT/true/alpine-wasm}
|
||||
ARG AOT=false
|
||||
ARG IMAGE=${AOT/true/wasm}
|
||||
ARG IMAGE=${IMAGE/false/alpine}
|
||||
|
||||
FROM --platform=$BUILDPLATFORM iceshrimp.dev/iceshrimp/dotnet-sdk:9.0-$IMAGE AS builder
|
||||
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 +31,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 +56,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-alpine-composite AS image
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app .
|
||||
USER app
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
# Federation
|
||||
|
||||
## Terminology
|
||||
|
||||
The key words "**MUST**", "**MUST NOT**", "**REQUIRED**", "**SHALL**", "**SHALL NOT**", "**SHOULD**", "**SHOULD NOT**", "**RECOMMENDED**", "**MAY**", and "**OPTIONAL**" in this document are to be interpreted as described in [RFC-2119](https://datatracker.ietf.org/doc/html/rfc2119).
|
||||
|
||||
This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases. Specifically, the following aliases are used:
|
||||
- `as:` - `https://www.w3.org/ns/activitystreams#`
|
||||
- `toot:` - `http://joinmastodon.org/ns#`
|
||||
- `fedibird:` - `http://fedibird.com/ns#`
|
||||
- `misskey:` - `https://misskey-hub.net/ns#`
|
||||
- `litepub:` - `http://litepub.social/ns#`
|
||||
|
||||
## Supported federation protocols and standards
|
||||
|
||||
- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
|
||||
- We perform full JSON-LD processing.
|
||||
- Incoming activities (whether sent to the shared/actor inbox or fetched via federation requests) **MUST** carry a valid JSON-LD context for successful federation with Iceshrimp.NET instances.
|
||||
- Regardless, we attempt to make sense of activities carrying some known invalid LD contexts. Specifically:
|
||||
+ We resolve the nonexistent `http://joinmastodon.org/ns` context ([toot.json](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts/toot.json)) for federation with GTS (which references it by link)
|
||||
+ We resolve some unofficial ActivityStreams context extensions ([as-extensions.json](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts/as-extensions.json)), since some implementors incorrectly reference it by link.
|
||||
+ 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)
|
||||
+ 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/)
|
||||
- Any actors referenced in activities **MUST** be queryable via WebFinger for federation with Iceshrimp.NET instances.
|
||||
- Actor `@id` URIs **SHOULD** be directly queryable via WebFinger, but [reverse discovery](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/#reverse-discovery) is performed as a fallback.
|
||||
- Split domain configurations are supported (for local and remote actors).
|
||||
+ Implementors **MUST NOT** have multiple actors with the same `preferredUsername` on each web or account domain.
|
||||
+ Mentions referencing a user by their non-canonical `acct` (`@user@web.domain.tld`) get canonicalized on note ingestion.
|
||||
- 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.
|
||||
- [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.
|
||||
- [LD Signatures](https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/)
|
||||
+ Both LD-signing outgoing activities and accepting LD signatures are disabled by default due to privacy concerns, but instance operators can choose to enable them.
|
||||
+ `as:Delete` activities, which don't come with any of the privacy concerns mentioned above, are however accepted regardless of the configuration.
|
||||
- [NodeInfo](https://nodeinfo.diaspora.software/) (versions 2.0 and 2.1)
|
||||
|
||||
## Supported FEPs
|
||||
|
||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md)
|
||||
+ `litepub:EmojiReact` activities are processed as emoji reactions.
|
||||
+ `as:Like` activities with `as:content` or `misskey:_misskey_reaction` properties get canonicalized to `litepub:EmojiReact`.
|
||||
+ Multiple emoji reactions are supported.
|
||||
+ Remote custom emoji reactions are supported if two conditions are met:
|
||||
* The `as:content` property is set to `:emoji@remoteinstance.tld:` or `emoji@remoteinstance.tld`
|
||||
* The emoji `emoji` is already known from a post or reaction by a user on `remoteinstance.tld`
|
||||
- [FEP-e232: Object Links](https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md)
|
||||
+ Specifically, inline quotes with the following `rel` attributes are supported:
|
||||
* `misskey:_misskey_quote`
|
||||
* `fedibird:quoteUri`
|
||||
* `as:quoteUrl`
|
||||
- [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)
|
||||
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md)
|
||||
- [FEP-96ff: Explicit signalling of ActivityPub Semantics](https://codeberg.org/fediverse/fep/src/branch/main/fep/96ff/fep-96ff.md)
|
||||
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
|
||||
- [FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
|
||||
|
||||
## Supported non-FEP extensions
|
||||
- `Bite` activities (as specified in [mia:Bite](https://ns.mia.jetzt/as/#Bite))
|
|
@ -1,7 +0,0 @@
|
|||
@using Microsoft.AspNetCore.Components.Web
|
||||
@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>
|
|
@ -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)];
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
@using Microsoft.AspNetCore.Components.Web
|
||||
<PageTitle>@Title - Admin - @(InstanceName ?? "Iceshrimp.NET")</PageTitle>
|
||||
<AdminHead/>
|
||||
<AdminNav/>
|
||||
<h2>@Title</h2>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public required string Title { get; set; }
|
||||
|
||||
[CascadingParameter(Name = "InstanceName")]
|
||||
public required string? InstanceName { get; set; }
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
using Iceshrimp.Backend.Core.Middleware;
|
||||
using static Iceshrimp.Backend.Pages.Shared.RootComponent;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.Helpers;
|
||||
|
||||
[Authenticate("role:admin")]
|
||||
[RequireAuthorization]
|
||||
public class AdminComponentBase : AsyncComponentBase;
|
|
@ -1,70 +0,0 @@
|
|||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Overrides to allow for asynchronous actions to be performed in fully SSR pages before the page gets rendered
|
||||
/// </summary>
|
||||
public class AsyncComponentBase : ComponentBase
|
||||
{
|
||||
[CascadingParameter] public required HttpContext Context { get; set; }
|
||||
[Inject] public required DatabaseContext Database { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
private bool _initialized;
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
if (_initialized) return CallOnParametersSetAsync();
|
||||
_initialized = true;
|
||||
|
||||
return RunInitAndSetParametersAsync();
|
||||
}
|
||||
|
||||
private async Task RunInitAndSetParametersAsync()
|
||||
{
|
||||
OnInitialized();
|
||||
await OnInitializedAsync();
|
||||
await CallOnParametersSetAsync();
|
||||
}
|
||||
|
||||
private async Task CallOnParametersSetAsync()
|
||||
{
|
||||
OnParametersSet();
|
||||
await OnParametersSetAsync();
|
||||
await RunMethodHandlerAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected virtual Task OnPost() => Task.CompletedTask;
|
||||
protected virtual Task OnGet() => Task.CompletedTask;
|
||||
|
||||
private async Task RunMethodHandlerAsync()
|
||||
{
|
||||
if (string.Equals(Context.Request.Method, "GET", StringComparison.InvariantCultureIgnoreCase))
|
||||
await OnGet();
|
||||
else if (string.Equals(Context.Request.Method, "POST", StringComparison.InvariantCultureIgnoreCase))
|
||||
await OnPost();
|
||||
}
|
||||
|
||||
protected void RedirectToLogin() => Redirect($"/login?rd={Context.Request.Path.ToString().UrlEncode()}");
|
||||
|
||||
protected void Redirect(string target, bool permanent = false)
|
||||
{
|
||||
if (permanent)
|
||||
{
|
||||
Context.Response.OnStarting(() =>
|
||||
{
|
||||
Context.Response.StatusCode = 301;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
Navigation.NavigateTo(target);
|
||||
}
|
||||
|
||||
protected void ReloadPage() => Navigation.Refresh(true);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
<link @attributes="AdditionalAttributes" href="/@Assets[href.TrimStart('/')]"/>
|
||||
|
||||
@code {
|
||||
@* ReSharper disable InconsistentNaming *@
|
||||
[Parameter, EditorRequired] public required string href { get; set; }
|
||||
@* ReSharper restore InconsistentNaming *@
|
||||
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public IDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
<script src="/@Assets[src.TrimStart('/')]" @attributes="AdditionalAttributes"></script>
|
||||
|
||||
@code {
|
||||
@* ReSharper disable InconsistentNaming *@
|
||||
[Parameter, EditorRequired] public required string src { get; set; }
|
||||
@* ReSharper restore InconsistentNaming *@
|
||||
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public IDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
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 override int Order => 99999; // That's ActionConstraintMatcherPolicy - 1
|
||||
|
||||
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
|
||||
{
|
||||
return endpoints.Any(p => p.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null);
|
||||
}
|
||||
|
||||
public Task ApplyAsync(HttpContext ctx, CandidateSet candidates)
|
||||
{
|
||||
var applies = Enumerate(candidates)
|
||||
.Any(p => p.Score >= 0 && p.Endpoint.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null);
|
||||
if (!applies) return Task.CompletedTask;
|
||||
|
||||
// Add Vary: Accept to the response headers to prevent caches serving the wrong response
|
||||
ctx.Response.Headers.Append(HeaderNames.Vary, HeaderNames.Cookie);
|
||||
|
||||
var hasCookie = ctx.Request.Cookies.ContainsKey("sessions");
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
var candidate = candidates[i];
|
||||
var hasAttr = candidate.Endpoint.Metadata.GetMetadata<PublicPreviewRouteFilterAttribute>() != null;
|
||||
candidates.SetValidity(i, !hasCookie || !hasAttr);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static IEnumerable<CandidateState> Enumerate(CandidateSet candidates)
|
||||
{
|
||||
for (var i = 0; i < candidates.Count; i++) yield return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class PublicPreviewRouteFilterAttribute : Attribute;
|
|
@ -1,34 +0,0 @@
|
|||
@using Iceshrimp.Backend.Components.PublicPreview.Schemas
|
||||
|
||||
|
||||
<UserComponent User="Note.User" Link="true"/>
|
||||
|
||||
<!-- TODO: figure out a better place to put this
|
||||
<small>Published at: @Note.CreatedAt</small>
|
||||
@if (Note.UpdatedAt != null)
|
||||
{
|
||||
<br/>
|
||||
<small>Edited at: @Note.UpdatedAt</small>
|
||||
}
|
||||
-->
|
||||
|
||||
<div class="content">
|
||||
@if (Note.Text != null)
|
||||
{
|
||||
if (Note.Cw != null)
|
||||
{
|
||||
<details>
|
||||
<summary>@Note.Cw</summary>
|
||||
@Note.Text
|
||||
</details>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Note.Text
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public required PreviewNote Note { get; set; }
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background-color: #f7f7f7;
|
||||
background-color: var(--background-alt);
|
||||
padding: 10px;
|
||||
margin: 1em 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
|
||||
using Iceshrimp.Backend.Core.Database.Tables;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
|
||||
using Iceshrimp.Backend.Core.Services;
|
||||
using Iceshrimp.MfmSharp;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
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 MfmRenderData? Render(
|
||||
string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement,
|
||||
List<PreviewAttachment>? media = null
|
||||
)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Database.Tables;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Iceshrimp.Backend.Core.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
|
||||
|
||||
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)
|
||||
{
|
||||
if (note == null) return null;
|
||||
|
||||
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);
|
||||
|
||||
return Render(note, users, mentions, emoji, attachments, polls);
|
||||
}
|
||||
|
||||
private PreviewNote Render(
|
||||
Note note, List<PreviewUser> users, Dictionary<string, List<Note.MentionedUser>> mentions,
|
||||
Dictionary<string, List<Emoji>> emoji, Dictionary<string, List<PreviewAttachment>?> attachments,
|
||||
Dictionary<string, PreviewPoll> polls
|
||||
)
|
||||
{
|
||||
var renderedText = mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span", attachments[note.Id]);
|
||||
var inlineMediaUrls = renderedText?.InlineMedia.Select(m => m.Src).ToArray() ?? [];
|
||||
|
||||
var res = new PreviewNote
|
||||
{
|
||||
User = users.First(p => p.Id == note.User.Id),
|
||||
Text = renderedText?.Html,
|
||||
Cw = note.Cw,
|
||||
RawText = note.Text,
|
||||
Uri = note.Uri ?? note.GetPublicUri(instance.Value),
|
||||
QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
|
||||
QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
|
||||
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
|
||||
Poll = polls.GetValueOrDefault(note.Id),
|
||||
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
|
||||
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, List<Note.MentionedUser>>> GetMentionsAsync(List<Note> notes)
|
||||
{
|
||||
var mentions = notes.SelectMany(n => n.Mentions).Distinct().ToList();
|
||||
if (mentions.Count == 0) return notes.ToDictionary<Note, string, List<Note.MentionedUser>>(p => p.Id, _ => []);
|
||||
|
||||
var users = await db.Users.Where(p => mentions.Contains(p.Id))
|
||||
.ToDictionaryAsync(p => p.Id,
|
||||
p => new Note.MentionedUser
|
||||
{
|
||||
Host = p.Host,
|
||||
Uri = p.Uri ?? p.GetPublicUri(instance.Value),
|
||||
Url = p.UserProfile?.Url,
|
||||
Username = p.Username
|
||||
});
|
||||
return notes.ToDictionary(p => p.Id,
|
||||
p => users.Where(u => p.Mentions.Contains(u.Key)).Select(u => u.Value).ToList());
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, List<Emoji>>> GetEmojiAsync(List<Note> notes)
|
||||
{
|
||||
var ids = notes.SelectMany(n => n.Emojis).Distinct().ToList();
|
||||
if (ids.Count == 0) return notes.ToDictionary<Note, string, List<Emoji>>(p => p.Id, _ => []);
|
||||
|
||||
var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
|
||||
return notes.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
|
||||
}
|
||||
|
||||
private async Task<List<PreviewUser>> GetUsersAsync(List<Note> notes)
|
||||
{
|
||||
if (notes is []) return [];
|
||||
return await userRenderer.RenderManyAsync(notes.Select(p => p.User).Distinct().ToList());
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, List<PreviewAttachment>?>> GetAttachmentsAsync(List<Note> notes)
|
||||
{
|
||||
if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
|
||||
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
|
||||
p => p.FileIds is [] ? null : []);
|
||||
|
||||
var ids = notes.SelectMany(p => p.FileIds).ToList();
|
||||
var files = await db.DriveFiles.Where(p => ids.Contains(p.Id)).ToListAsync();
|
||||
return notes
|
||||
.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
|
||||
p => files
|
||||
.Where(f => p.FileIds.Contains(f.Id))
|
||||
.Select(f => new PreviewAttachment
|
||||
{
|
||||
MimeType = f.Type,
|
||||
Url = mediaProxy.GetProxyUrl(f),
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
using Iceshrimp.Backend.Components.PublicPreview.Schemas;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Database.Tables;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
|
||||
|
||||
public class UserRenderer(
|
||||
DatabaseContext db,
|
||||
MfmRenderer mfm,
|
||||
IOptions<Config.InstanceSection> instance,
|
||||
IOptionsSnapshot<Config.SecuritySection> security
|
||||
) : IScopedService
|
||||
{
|
||||
public async Task<PreviewUser?> RenderOne(User? user)
|
||||
{
|
||||
if (user == null) return null;
|
||||
var emoji = await GetEmojiAsync([user]);
|
||||
return Render(user, emoji);
|
||||
}
|
||||
|
||||
private PreviewUser Render(User user, Dictionary<string, List<Emoji>> emoji)
|
||||
{
|
||||
var mentions = user.UserProfile?.Mentions ?? [];
|
||||
|
||||
// @formatter:off
|
||||
var res = new PreviewUser
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
Host = user.Host ?? instance.Value.AccountDomain,
|
||||
Uri = user.GetUriOrPublicUri(instance.Value),
|
||||
Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath,
|
||||
AvatarUrl = user.GetAvatarUrl(instance.Value),
|
||||
BannerUrl = user.GetBannerUrl(instance.Value),
|
||||
RawDisplayName = user.DisplayName,
|
||||
DisplayName = mfm.RenderSimple(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
|
||||
Bio = mfm.RenderSimple(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
|
||||
MovedToUri = user.MovedToUri
|
||||
};
|
||||
// @formatter:on
|
||||
|
||||
if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
|
||||
{
|
||||
res.AvatarUrl = user.IdenticonUrlPath;
|
||||
res.BannerUrl = null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, List<Emoji>>> GetEmojiAsync(List<User> users)
|
||||
{
|
||||
var ids = users.SelectMany(n => n.Emojis).Distinct().ToList();
|
||||
if (ids.Count == 0) return users.ToDictionary<User, string, List<Emoji>>(p => p.Id, _ => []);
|
||||
|
||||
var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
|
||||
return users.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PreviewUser>> RenderManyAsync(List<User> users)
|
||||
{
|
||||
var emoji = await GetEmojiAsync(users);
|
||||
return users.Select(p => Render(p, emoji)).ToList();
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
|
||||
|
||||
public class PreviewNote
|
||||
{
|
||||
public required PreviewUser User;
|
||||
public required string? RawText;
|
||||
public required MarkupString? Text;
|
||||
public required string? Cw;
|
||||
public required string? 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;
|
||||
}
|
||||
|
||||
public class PreviewAttachment
|
||||
{
|
||||
public required string MimeType;
|
||||
public required string Url;
|
||||
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; }
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
|
||||
|
||||
public class PreviewUser
|
||||
{
|
||||
public required string Id;
|
||||
public required string? RawDisplayName;
|
||||
public required MarkupString? DisplayName;
|
||||
public required MarkupString? Bio;
|
||||
public required string Username;
|
||||
public required string Host;
|
||||
public required string Url;
|
||||
public required string Uri;
|
||||
public required string AvatarUrl;
|
||||
public required string? BannerUrl;
|
||||
public required string? MovedToUri;
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
@using Iceshrimp.Backend.Components.PublicPreview.Schemas
|
||||
|
||||
@if (Link)
|
||||
{
|
||||
<div class="user">
|
||||
<a href="@User.Url">
|
||||
<img src="@User.AvatarUrl" class="avatar" alt="User avatar"/>
|
||||
</a>
|
||||
<div class="title">
|
||||
<a class="display-name" href="@User.Url">
|
||||
@if (User.DisplayName != null)
|
||||
{
|
||||
@User.DisplayName
|
||||
}
|
||||
else
|
||||
{
|
||||
@User.Username
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
<span class="acct">@@@User.Username<span class="host">@@@User.Host</span></span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="@User.AvatarUrl" class="avatar" alt="User avatar"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public required PreviewUser User { get; set; }
|
||||
[Parameter] public bool Link { get; set; } = true;
|
||||
}
|
|
@ -2,10 +2,8 @@ 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;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||
|
@ -13,7 +11,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 +26,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,108 +51,38 @@ 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();
|
||||
}
|
||||
|
||||
[HttpGet("/notes/{id}/replies")]
|
||||
[AuthorizedFetch]
|
||||
[OverrideResultType<ASOrderedCollection>]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task<JObject> GetNoteReplies(string id)
|
||||
{
|
||||
var actor = HttpContext.GetActor();
|
||||
var note = await db.Notes
|
||||
.EnsureVisibleFor(actor)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.User.IsLocalUser) ??
|
||||
throw GracefulException.NotFound("Note not found");
|
||||
|
||||
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();
|
||||
|
||||
var rendered = replies.Select(noteRenderer.RenderLite).Cast<ASObject>().ToList();
|
||||
var res = new ASOrderedCollection
|
||||
{
|
||||
Id = $"{note.GetPublicUri(config.Value)}/replies",
|
||||
TotalItems = (ulong)rendered.Count,
|
||||
Items = rendered
|
||||
};
|
||||
|
||||
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();
|
||||
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("/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 +97,6 @@ public class ActivityPubController(
|
|||
|
||||
[HttpGet("/users/{id}/collections/featured")]
|
||||
[AuthorizedFetch]
|
||||
[OutputCache(PolicyName = "federation")]
|
||||
[OverrideResultType<ASOrderedCollection>]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
|
@ -200,71 +125,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)]
|
||||
|
@ -278,7 +140,7 @@ public class ActivityPubController(
|
|||
var remoteUser = await db.Users
|
||||
.IncludeCommonProperties()
|
||||
.FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() &&
|
||||
p.Host == split[1].ToPunycodeLower());
|
||||
p.Host == split[1].ToLowerInvariant().ToPunycode());
|
||||
|
||||
if (remoteUser?.Uri != null)
|
||||
return RedirectPermanent(remoteUser.Uri);
|
||||
|
@ -287,8 +149,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 +180,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 +192,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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
@ -18,12 +16,7 @@ namespace Iceshrimp.Backend.Controllers.Federation;
|
|||
[Route("/nodeinfo")]
|
||||
[EnableCors("well-known")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class NodeInfoController(
|
||||
IOptions<Config.InstanceSection> instanceConfig,
|
||||
IOptions<Config.StorageSection> storageConfig,
|
||||
DatabaseContext db,
|
||||
MetaService meta
|
||||
) : ControllerBase
|
||||
public class NodeInfoController(IOptions<Config.InstanceSection> config, DatabaseContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("2.1")]
|
||||
[HttpGet("2.0")]
|
||||
|
@ -32,7 +25,7 @@ public class NodeInfoController(
|
|||
{
|
||||
var cutoffMonth = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||||
var cutoffHalfYear = DateTime.UtcNow - TimeSpan.FromDays(180);
|
||||
var instance = instanceConfig.Value;
|
||||
var instance = config.Value;
|
||||
var totalUsers =
|
||||
await db.Users.LongCountAsync(p => p.IsLocalUser && !Constants.SystemUsers.Contains(p.UsernameLower));
|
||||
var activeMonth =
|
||||
|
@ -43,13 +36,8 @@ public class NodeInfoController(
|
|||
await db.Users.LongCountAsync(p => p.IsLocalUser &&
|
||||
!Constants.SystemUsers.Contains(p.UsernameLower) &&
|
||||
p.LastActiveDate > cutoffHalfYear);
|
||||
var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null);
|
||||
var maxUploadSize = storageConfig.Value.MaxUploadSizeBytes;
|
||||
var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null);
|
||||
|
||||
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 +69,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),
|
||||
|
@ -102,32 +90,9 @@ public class NodeInfoController(
|
|||
MaxCaptionTextLength = 0,
|
||||
EnableGithubIntegration = false,
|
||||
EnableDiscordIntegration = false,
|
||||
EnableEmail = false,
|
||||
PublicTimelineVisibility = new NodeInfoResponse.PleromaPublicTimelineVisibility
|
||||
{
|
||||
Bubble = false,
|
||||
Federated = false,
|
||||
Local = false
|
||||
},
|
||||
// @formatter:off
|
||||
UploadLimits = new NodeInfoResponse.PleromaUploadLimits
|
||||
{
|
||||
General = maxUploadSize,
|
||||
Avatar = maxUploadSize,
|
||||
Background = maxUploadSize,
|
||||
Banner = maxUploadSize
|
||||
},
|
||||
// @formatter:on
|
||||
Suggestions = new NodeInfoResponse.PleromaSuggestions { Enabled = false },
|
||||
Federation = new NodeInfoResponse.PleromaFederation { Enabled = true }
|
||||
EnableEmail = false
|
||||
},
|
||||
OpenRegistrations = false
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("2.0.json")]
|
||||
public IActionResult GetNodeInfoAkkoma()
|
||||
{
|
||||
return Redirect("/nodeinfo/2.0");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Federation.Schemas;
|
||||
|
||||
public class HostMetaJsonResponse()
|
||||
{
|
||||
public HostMetaJsonResponse(string webDomain) : this()
|
||||
{
|
||||
Links = [new HostMetaJsonResponseLink(webDomain)];
|
||||
}
|
||||
|
||||
[J("links")] public List<HostMetaJsonResponseLink>? Links { get; set; }
|
||||
}
|
||||
|
||||
public class HostMetaJsonResponseLink()
|
||||
{
|
||||
public HostMetaJsonResponseLink(string webDomain) : this()
|
||||
{
|
||||
Rel = "lrdd";
|
||||
Type = "application/jrd+json";
|
||||
Template = $"https://{webDomain}/.well-known/webfinger?resource={{uri}}";
|
||||
}
|
||||
|
||||
[J("rel")] public string? Rel { get; set; }
|
||||
[J("type")] public string? Type { get; set; }
|
||||
[J("template")] public string? Template { get; set; }
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Federation.Schemas;
|
||||
|
||||
[XmlRoot("XRD", Namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0", IsNullable = false)]
|
||||
public class HostMetaXmlResponse()
|
||||
{
|
||||
[XmlElement("Link")] public required HostMetaXmlResponseLink Link;
|
||||
|
||||
[SetsRequiredMembers]
|
||||
public HostMetaXmlResponse(string webDomain) : this() => Link = new HostMetaXmlResponseLink(webDomain);
|
||||
}
|
||||
|
||||
public class HostMetaXmlResponseLink()
|
||||
{
|
||||
[XmlAttribute("rel")] public string Rel = "lrdd";
|
||||
[XmlAttribute("template")] public required string Template;
|
||||
[XmlAttribute("type")] public string Type = "application/xrd+xml";
|
||||
|
||||
[SetsRequiredMembers]
|
||||
public HostMetaXmlResponseLink(string webDomain) : this() =>
|
||||
Template = $"https://{webDomain}/.well-known/webfinger?resource={{uri}}";
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Xml.Serialization;
|
||||
using Iceshrimp.Backend.Controllers.Federation.Attributes;
|
||||
using Iceshrimp.Backend.Controllers.Federation.Schemas;
|
||||
using Iceshrimp.Backend.Controllers.Shared.Attributes;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
|
@ -9,7 +13,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,24 +21,18 @@ 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")]
|
||||
[Produces("application/jrd+json", "application/xrd+xml")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task<WebFingerResponse> WebFinger([FromQuery] string resource)
|
||||
{
|
||||
User? user;
|
||||
if (Uri.TryCreate(resource, UriKind.Absolute, out var uri) && uri.Scheme is "https")
|
||||
if (resource.StartsWith($"https://{config.Value.WebDomain}/users/"))
|
||||
{
|
||||
if (uri.Host != config.Value.WebDomain)
|
||||
throw GracefulException.NotFound("User not found");
|
||||
if (!uri.AbsolutePath.StartsWith("/users/"))
|
||||
throw GracefulException.NotFound("User not found");
|
||||
|
||||
var id = uri.AbsolutePath["/users/".Length..];
|
||||
var id = resource[$"https://{config.Value.WebDomain}/users/".Length..];
|
||||
user = await db.Users.FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser);
|
||||
}
|
||||
else
|
||||
|
@ -69,12 +66,6 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
|
|||
Href = user.GetPublicUri(config.Value)
|
||||
},
|
||||
new WebFingerLink
|
||||
{
|
||||
Rel = "self",
|
||||
Type = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
||||
Href = user.GetPublicUri(config.Value)
|
||||
},
|
||||
new WebFingerLink
|
||||
{
|
||||
Rel = "http://webfinger.net/rel/profile-page",
|
||||
Type = "text/html",
|
||||
|
@ -85,8 +76,7 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
|
|||
Rel = "http://ostatus.org/schema/1.0/subscribe",
|
||||
Template = $"https://{config.Value.WebDomain}/authorize-follow?acct={{uri}}"
|
||||
}
|
||||
],
|
||||
Aliases = [user.GetPublicUrl(config.Value), user.GetPublicUri(config.Value)]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -116,14 +106,35 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
|
|||
[HttpGet("host-meta")]
|
||||
[Produces("application/xrd+xml", "application/jrd+json")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public HostMetaResponse HostMeta()
|
||||
public ActionResult<HostMetaJsonResponse> HostMeta()
|
||||
{
|
||||
if (Request.Headers.Accept is []) Request.Headers.Accept = "application/xrd+xml";
|
||||
return new HostMetaResponse(config.Value.WebDomain);
|
||||
var accept = Request.Headers.Accept.OfType<string>()
|
||||
.SelectMany(p => p.Split(","))
|
||||
.Select(MediaTypeWithQualityHeaderValue.Parse)
|
||||
.Select(p => p.MediaType)
|
||||
.ToList();
|
||||
|
||||
if (accept.Contains("application/jrd+json") || accept.Contains("application/json"))
|
||||
return Ok(HostMetaJson());
|
||||
|
||||
var obj = new HostMetaXmlResponse(config.Value.WebDomain);
|
||||
var serializer = new XmlSerializer(obj.GetType());
|
||||
var writer = new Utf8StringWriter();
|
||||
|
||||
serializer.Serialize(writer, obj);
|
||||
return Content(writer.ToString(), "application/xrd+xml");
|
||||
}
|
||||
|
||||
[HttpGet("host-meta.json")]
|
||||
[Produces("application/jrd+json")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public HostMetaResponse HostMetaJson() => new(config.Value.WebDomain);
|
||||
public HostMetaJsonResponse HostMetaJson()
|
||||
{
|
||||
return new HostMetaJsonResponse(config.Value.WebDomain);
|
||||
}
|
||||
|
||||
private class Utf8StringWriter : StringWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Mime;
|
||||
|
@ -13,13 +12,11 @@ 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;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||
|
||||
|
@ -45,7 +42,7 @@ public class AccountController(
|
|||
public async Task<AccountEntity> VerifyUserCredentials()
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
return await userRenderer.RenderAsync(user, user.UserProfile, user, source: true);
|
||||
return await userRenderer.RenderAsync(user, user.UserProfile, source: true);
|
||||
}
|
||||
|
||||
[HttpPatch("update_credentials")]
|
||||
|
@ -106,9 +103,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.Url;
|
||||
}
|
||||
|
||||
if (request.Banner != null)
|
||||
|
@ -119,13 +117,14 @@ 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.Url;
|
||||
}
|
||||
|
||||
user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
|
||||
return await userRenderer.RenderAsync(user, user.UserProfile, user, source: true);
|
||||
return await userRenderer.RenderAsync(user, user.UserProfile, source: true);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/profile/avatar")]
|
||||
|
@ -139,11 +138,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 +160,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 +180,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));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/follow")]
|
||||
|
@ -222,8 +203,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,34 +236,13 @@ 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);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/remove_from_followers")]
|
||||
[Authorize("write:follows")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.BadRequest)]
|
||||
public async Task<RelationshipEntity> RemoveFromFollowers(string id)
|
||||
{
|
||||
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();
|
||||
|
||||
await userSvc.RemoveFromFollowersAsync(user, follower);
|
||||
return RenderRelationship(follower);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/mute")]
|
||||
[Authorize("write:mutes")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
|
@ -297,8 +257,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 +280,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 +301,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 +322,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 +354,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 +381,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");
|
||||
|
@ -444,7 +401,7 @@ public class AccountController(
|
|||
.SelectMany(p => p.Followers)
|
||||
.IncludeCommonProperties()
|
||||
.Paginate(query, ControllerContext)
|
||||
.RenderAllForMastodonAsync(userRenderer, user);
|
||||
.RenderAllForMastodonAsync(userRenderer);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/following")]
|
||||
|
@ -460,8 +417,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");
|
||||
|
@ -480,7 +437,7 @@ public class AccountController(
|
|||
.SelectMany(p => p.Following)
|
||||
.IncludeCommonProperties()
|
||||
.Paginate(query, ControllerContext)
|
||||
.RenderAllForMastodonAsync(userRenderer, user);
|
||||
.RenderAllForMastodonAsync(userRenderer);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/featured_tags")]
|
||||
|
@ -490,8 +447,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 [];
|
||||
}
|
||||
|
@ -511,7 +468,7 @@ public class AccountController(
|
|||
.ToListAsync();
|
||||
|
||||
HttpContext.SetPaginationData(requests);
|
||||
return await userRenderer.RenderManyAsync(requests.Select(p => p.Entity), user);
|
||||
return await userRenderer.RenderManyAsync(requests.Select(p => p.Entity));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/favourites")]
|
||||
|
@ -574,7 +531,7 @@ public class AccountController(
|
|||
.ToListAsync();
|
||||
|
||||
HttpContext.SetPaginationData(blocks);
|
||||
return await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity), user);
|
||||
return await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/mutes")]
|
||||
|
@ -593,7 +550,7 @@ public class AccountController(
|
|||
.ToListAsync();
|
||||
|
||||
HttpContext.SetPaginationData(mutes);
|
||||
return await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity), user);
|
||||
return await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/follow_requests/{id}/authorize")]
|
||||
|
@ -615,8 +572,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 +595,8 @@ public class AccountController(
|
|||
.IncludeCommonProperties()
|
||||
.PrecomputeRelationshipData(user)
|
||||
.Select(u => RenderRelationship(u))
|
||||
.FirstOrDefaultAsync()
|
||||
?? throw GracefulException.RecordNotFound();
|
||||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.RecordNotFound();
|
||||
}
|
||||
|
||||
[HttpGet("lookup")]
|
||||
|
@ -647,13 +604,8 @@ public class AccountController(
|
|||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task<AccountEntity> LookupUser([FromQuery] string acct)
|
||||
{
|
||||
const ResolveFlags flags =
|
||||
ResolveFlags.Acct | ResolveFlags.Uri | ResolveFlags.MatchUrl | ResolveFlags.OnlyExisting;
|
||||
|
||||
var localUser = HttpContext.GetUser();
|
||||
var user = await userResolver.ResolveOrNullAsync(acct, flags) ?? throw GracefulException.RecordNotFound();
|
||||
user = await userResolver.GetUpdatedUserAsync(user);
|
||||
return await userRenderer.RenderAsync(user, localUser);
|
||||
var user = await userResolver.LookupAsync(acct) ?? throw GracefulException.RecordNotFound();
|
||||
return await userRenderer.RenderAsync(user);
|
||||
}
|
||||
|
||||
private static RelationshipEntity RenderRelationship(User u)
|
||||
|
@ -676,4 +628,4 @@ public class AccountController(
|
|||
ShowingReblogs = true //FIXME
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,7 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
|
|||
{
|
||||
var announcementRead = new AnnouncementRead
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Announcement = announcement,
|
||||
User = user
|
||||
|
|
|
@ -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)
|
||||
|
@ -66,7 +65,7 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
|
|||
|
||||
var app = new OauthApp
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
ClientId = CryptographyHelpers.GenerateRandomString(32),
|
||||
ClientSecret = CryptographyHelpers.GenerateRandomString(32),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,97 +0,0 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Mime;
|
||||
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
|
||||
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;
|
||||
|
||||
[MastodonApiController]
|
||||
[Route("/api/v1")]
|
||||
[Authenticate]
|
||||
[EnableCors("mastodon")]
|
||||
[EnableRateLimiting("sliding")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class BiteController(DatabaseContext db, BiteService biteSvc) : ControllerBase
|
||||
{
|
||||
[HttpPost("bite")]
|
||||
[Authenticate("write:bites")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
|
||||
public async Task BiteUser([FromHybrid] string id)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
if (user.Id == id)
|
||||
throw GracefulException.BadRequest("You cannot bite yourself");
|
||||
|
||||
var target = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
|
||||
throw GracefulException.NotFound("User not found");
|
||||
|
||||
await biteSvc.BiteAsync(user, target);
|
||||
}
|
||||
|
||||
[HttpPost("users/{id}/bite")]
|
||||
[Authenticate("write:bites")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
|
||||
public async Task BiteUser2(string id)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
if (user.Id == id)
|
||||
throw GracefulException.BadRequest("You cannot bite yourself");
|
||||
|
||||
var target = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
|
||||
throw GracefulException.NotFound("User not found");
|
||||
|
||||
await biteSvc.BiteAsync(user, target);
|
||||
}
|
||||
|
||||
[HttpPost("users/{id}/bite_back")]
|
||||
[Authenticate("write:bites")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
|
||||
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")]
|
||||
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")]
|
||||
public async Task BiteBack(string id)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
var target = await db.Bites
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Id == id)
|
||||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.NotFound("Bite not found");
|
||||
|
||||
if (user.Id != (target.TargetUserId ?? target.TargetNote?.UserId ?? target.TargetBite?.UserId))
|
||||
throw GracefulException.BadRequest("You can only bite back at a user who bit you");
|
||||
|
||||
await biteSvc.BiteAsync(user, target);
|
||||
}
|
||||
|
||||
[HttpPost("statuses/{id}/bite")]
|
||||
[Authenticate("write:bites")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
|
||||
public async Task BiteStatus(string id)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
if (user.Id == id)
|
||||
throw GracefulException.BadRequest("You cannot bite your own note");
|
||||
|
||||
var target = await db.Notes
|
||||
.Where(p => p.Id == id)
|
||||
.IncludeCommonProperties()
|
||||
.EnsureVisibleFor(user)
|
||||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.NotFound("Note not found");
|
||||
|
||||
await biteSvc.BiteAsync(user, target);
|
||||
}
|
||||
}
|
|
@ -42,10 +42,10 @@ public class ConversationsController(
|
|||
.IncludeCommonProperties()
|
||||
.FilterHiddenConversations(user, db)
|
||||
.FilterMutedThreads(user, db)
|
||||
.Paginate(p => p.ThreadId, pq, ControllerContext)
|
||||
.Paginate(p => p.ThreadIdOrId, pq, ControllerContext)
|
||||
.Select(p => new Conversation
|
||||
{
|
||||
Id = p.ThreadId,
|
||||
Id = p.ThreadIdOrId,
|
||||
LastNote = p,
|
||||
UserIds = p.VisibleUserIds,
|
||||
Unread = db.Notifications.Any(n => n.Note == p &&
|
||||
|
@ -66,7 +66,7 @@ public class ConversationsController(
|
|||
.Where(p => userIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var accounts = await userRenderer.RenderManyAsync(users, user).ToListAsync();
|
||||
var accounts = await userRenderer.RenderManyAsync(users).ToListAsync();
|
||||
|
||||
var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts: accounts);
|
||||
|
||||
|
@ -96,10 +96,10 @@ public class ConversationsController(
|
|||
var user = HttpContext.GetUserOrFail();
|
||||
var conversation = await db.Conversations(user)
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.ThreadId == id)
|
||||
.Where(p => (p.ThreadIdOrId) == id)
|
||||
.Select(p => new Conversation
|
||||
{
|
||||
Id = p.ThreadId,
|
||||
Id = p.ThreadIdOrId,
|
||||
LastNote = p,
|
||||
UserIds = p.VisibleUserIds,
|
||||
Unread = db.Notifications.Any(n => n.Note == p &&
|
||||
|
@ -132,7 +132,7 @@ public class ConversationsController(
|
|||
.Where(p => userIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var accounts = await userRenderer.RenderManyAsync(users, user).ToListAsync();
|
||||
var accounts = await userRenderer.RenderManyAsync(users).ToListAsync();
|
||||
|
||||
var noteRendererDto = new NoteRenderer.NoteRendererDto { Accounts = accounts };
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
|
|||
[EnableRateLimiting("sliding")]
|
||||
[EnableCors("mastodon")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class FilterController(DatabaseContext db, QueueService queueSvc, EventService eventSvc) : ControllerBase
|
||||
public class FilterController(DatabaseContext db, QueueService queueSvc, IEventService eventSvc) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("read:filters")]
|
||||
|
@ -95,7 +95,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
|
||||
db.Add(filter);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterAdded(this, filter);
|
||||
await eventSvc.RaiseFilterAdded(this, filter);
|
||||
|
||||
if (expiry.HasValue)
|
||||
{
|
||||
|
@ -159,7 +159,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
|
||||
db.Update(filter);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterUpdated(this, filter);
|
||||
await eventSvc.RaiseFilterUpdated(this, filter);
|
||||
|
||||
if (expiry.HasValue)
|
||||
{
|
||||
|
@ -183,7 +183,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
|
||||
db.Remove(filter);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterRemoved(this, filter);
|
||||
await eventSvc.RaiseFilterRemoved(this, filter);
|
||||
|
||||
return new object();
|
||||
}
|
||||
|
@ -216,9 +216,9 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
var keyword = request.WholeWord ? $"\"{request.Keyword}\"" : request.Keyword;
|
||||
filter.Keywords.Add(keyword);
|
||||
|
||||
db.Update(filter);
|
||||
db.Update(keyword);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterUpdated(this, filter);
|
||||
await eventSvc.RaiseFilterUpdated(this, filter);
|
||||
|
||||
return new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1);
|
||||
}
|
||||
|
@ -257,7 +257,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
filter.Keywords[keywordId] = request.WholeWord ? $"\"{request.Keyword}\"" : request.Keyword;
|
||||
db.Update(filter);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterUpdated(this, filter);
|
||||
await eventSvc.RaiseFilterUpdated(this, filter);
|
||||
|
||||
return new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId);
|
||||
}
|
||||
|
@ -279,7 +279,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
|
|||
filter.Keywords.RemoveAt(keywordId);
|
||||
db.Update(filter);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseFilterUpdated(this, filter);
|
||||
await eventSvc.RaiseFilterUpdated(this, filter);
|
||||
|
||||
return new object();
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ using System.Net.Mime;
|
|||
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
|
||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
|
||||
using Iceshrimp.Backend.Controllers.Shared.Attributes;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
|
@ -21,11 +20,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 +32,11 @@ public class InstanceController(
|
|||
var instanceCount = await db.Instances.LongCountAsync();
|
||||
|
||||
var (instanceName, instanceDescription, adminContact) =
|
||||
await meta.GetManyAsync(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);
|
||||
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
|
||||
|
||||
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()
|
||||
Stats = new InstanceStats(userCount, noteCount, instanceCount)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -56,19 +45,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 +67,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 +83,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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,7 +25,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
|
|||
[EnableRateLimiting("sliding")]
|
||||
[EnableCors("mastodon")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class ListController(DatabaseContext db, UserRenderer userRenderer, EventService eventSvc) : ControllerBase
|
||||
public class ListController(DatabaseContext db, UserRenderer userRenderer, IEventService eventSvc) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("read:lists")]
|
||||
|
@ -77,7 +77,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
var user = HttpContext.GetUserOrFail();
|
||||
var list = new UserList
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
User = user,
|
||||
Name = request.Title,
|
||||
|
@ -139,7 +139,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
|
||||
db.Remove(list);
|
||||
await db.SaveChangesAsync();
|
||||
eventSvc.RaiseListMembersUpdated(this, list);
|
||||
await eventSvc.RaiseListMembersUpdated(this, list);
|
||||
return new object();
|
||||
}
|
||||
|
||||
|
@ -161,13 +161,13 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
.Where(p => p.UserList == list)
|
||||
.Include(p => p.User.UserProfile)
|
||||
.Select(p => p.User)
|
||||
.RenderAllForMastodonAsync(userRenderer, user)
|
||||
.RenderAllForMastodonAsync(userRenderer)
|
||||
: await db.UserListMembers
|
||||
.Where(p => p.UserList == list)
|
||||
.Paginate(pq, ControllerContext)
|
||||
.Include(p => p.User.UserProfile)
|
||||
.Select(p => p.User)
|
||||
.RenderAllForMastodonAsync(userRenderer, user);
|
||||
.RenderAllForMastodonAsync(userRenderer);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/accounts")]
|
||||
|
@ -196,7 +196,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
|
||||
var memberships = subjects.Select(subject => new UserListMember
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UserList = list,
|
||||
UserId = subject
|
||||
|
@ -204,8 +204,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
|
||||
await db.AddRangeAsync(memberships);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
eventSvc.RaiseListMembersUpdated(this, list);
|
||||
await eventSvc.RaiseListMembersUpdated(this, list);
|
||||
|
||||
return new object();
|
||||
}
|
||||
|
@ -229,7 +228,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
|
|||
.Where(p => p.UserList == list && request.AccountIds.Contains(p.UserId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
eventSvc.RaiseListMembersUpdated(this, list);
|
||||
await eventSvc.RaiseListMembersUpdated(this, list);
|
||||
|
||||
return new object();
|
||||
}
|
||||
|
|
|
@ -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,13 +23,8 @@ 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")]
|
||||
[HttpPost("/api/v2/media")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
|
@ -43,8 +38,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 +50,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 +64,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 +75,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.PublicUrl,
|
||||
Blurhash = file.Blurhash,
|
||||
Description = file.Comment,
|
||||
PreviewUrl = file.PublicThumbnailUrl,
|
||||
RemoteUrl = file.Uri,
|
||||
Sensitive = file.IsSensitive
|
||||
//Metadata = TODO,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -33,9 +33,7 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
|
|||
MastodonPaginationQuery query, NotificationSchemas.GetNotificationsRequest request
|
||||
)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
var isPleroma = HttpContext.GetOauthToken()!.IsPleroma;
|
||||
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
return await db.Notifications
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Notifiee == user)
|
||||
|
@ -48,16 +46,14 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
|
|||
p.Type == NotificationType.Like ||
|
||||
p.Type == NotificationType.PollEnded ||
|
||||
p.Type == NotificationType.FollowRequestReceived ||
|
||||
p.Type == NotificationType.Reaction ||
|
||||
p.Type == NotificationType.Edit ||
|
||||
p.Type == NotificationType.Bite)
|
||||
p.Type == NotificationType.Edit)
|
||||
.FilterByGetNotificationsRequest(request)
|
||||
.EnsureNoteVisibilityFor(p => p.Note, user)
|
||||
.FilterHiddenNotifications(user, db)
|
||||
.FilterMutedThreads(user, db)
|
||||
.Paginate(p => p.MastoId, query, ControllerContext)
|
||||
.PrecomputeNoteVisibilities(user)
|
||||
.RenderAllForMastodonAsync(notificationRenderer, user, isPleroma);
|
||||
.RenderAllForMastodonAsync(notificationRenderer, user);
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}")]
|
||||
|
@ -66,9 +62,7 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
|
|||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task<NotificationEntity> GetNotification(long id)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
var isPleroma = HttpContext.GetOauthToken()!.IsPleroma;
|
||||
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
var notification = await db.Notifications
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Notifiee == user && p.MastoId == id)
|
||||
|
@ -77,7 +71,7 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
|
|||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.RecordNotFound();
|
||||
|
||||
return await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note),
|
||||
user, isPleroma);
|
||||
var res = await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note), user);
|
||||
return res;
|
||||
}
|
||||
}
|
|
@ -81,7 +81,7 @@ public class PollController(
|
|||
|
||||
var vote = new PollVote
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
User = user,
|
||||
Note = note,
|
||||
|
@ -100,7 +100,7 @@ public class PollController(
|
|||
|
||||
var vote = new PollVote
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
User = user,
|
||||
Note = note,
|
||||
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
|
@ -39,7 +39,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa
|
|||
{
|
||||
pushSubscription = new PushSubscription
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Endpoint = request.Subscription.Endpoint,
|
||||
User = token.User,
|
||||
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
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;
|
||||
|
@ -14,15 +13,12 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
|||
|
||||
public class NoteRenderer(
|
||||
IOptions<Config.InstanceSection> config,
|
||||
IOptionsSnapshot<Config.SecuritySection> security,
|
||||
UserRenderer userRenderer,
|
||||
PollRenderer pollRenderer,
|
||||
MfmConverter mfmConverter,
|
||||
DatabaseContext db,
|
||||
EmojiService emojiSvc,
|
||||
AttachmentRenderer attachmentRenderer,
|
||||
FlagService flags
|
||||
) : IScopedService
|
||||
EmojiService emojiSvc
|
||||
)
|
||||
{
|
||||
private static readonly FilterResultEntity InaccessibleFilter = new()
|
||||
{
|
||||
|
@ -66,37 +62,27 @@ public class NoteRenderer(
|
|||
await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
|
||||
var bookmarked = data?.BookmarkedNotes?.Contains(note.Id) ??
|
||||
await db.NoteBookmarks.AnyAsync(p => p.Note == note && p.User == user);
|
||||
var muted = data?.MutedNotes?.Contains(note.ThreadId) ??
|
||||
await db.NoteThreadMutings.AnyAsync(p => p.ThreadId == note.ThreadId && p.User == user);
|
||||
var muted = data?.MutedNotes?.Contains(note.ThreadIdOrId) ??
|
||||
await db.NoteThreadMutings.AnyAsync(p => p.ThreadId == note.ThreadIdOrId && p.User == user);
|
||||
var pinned = data?.PinnedNotes?.Contains(note.Id) ??
|
||||
await db.UserNotePins.AnyAsync(p => p.Note == note && p.User == user);
|
||||
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 +97,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);
|
||||
|
||||
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)
|
||||
|
@ -138,53 +128,6 @@ 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;
|
||||
|
||||
var res = new StatusEntity
|
||||
{
|
||||
Id = note.Id,
|
||||
|
@ -207,9 +150,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,21 +161,19 @@ public class NoteRenderer(
|
|||
Emojis = noteEmoji,
|
||||
Poll = poll,
|
||||
Reactions = reactions,
|
||||
Tags = tags,
|
||||
Filtered = filterResult,
|
||||
Pleroma = pleromaExtensions
|
||||
Filtered = filterResult
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public async Task<List<StatusEdit>> RenderHistoryAsync(Note note, User? user)
|
||||
public async Task<List<StatusEdit>> RenderHistoryAsync(Note note)
|
||||
{
|
||||
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,
|
||||
|
@ -242,29 +183,17 @@ public class NoteRenderer(
|
|||
})
|
||||
.ToList();
|
||||
|
||||
var account = await userRenderer.RenderAsync(note.User, user);
|
||||
var account = await userRenderer.RenderAsync(note.User);
|
||||
var lastDate = note.CreatedAt;
|
||||
|
||||
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 +225,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 +234,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 +244,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.PublicUrl,
|
||||
Blurhash = f.Blurhash,
|
||||
PreviewUrl = f.PublicThumbnailUrl,
|
||||
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.PublicUrl,
|
||||
Blurhash = f.Blurhash,
|
||||
PreviewUrl = f.PublicThumbnailUrl,
|
||||
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)
|
||||
{
|
||||
if (users.Count == 0) return [];
|
||||
return (await userRenderer.RenderManyAsync(users.DistinctBy(p => p.Id), localUser)).ToList();
|
||||
return (await userRenderer.RenderManyAsync(users.DistinctBy(p => p.Id))).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 +299,7 @@ public class NoteRenderer(
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<ReactionEntity>> GetReactionsAsync(List<Note> notes, User? user)
|
||||
private 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);
|
||||
|
@ -365,28 +316,23 @@ public class NoteRenderer(
|
|||
Me = user != null &&
|
||||
db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
|
||||
i.Reaction == p.First().Reaction &&
|
||||
i.User == user),
|
||||
AccountIds = db.NoteReactions
|
||||
.Where(i => i.NoteId == p.First().NoteId &&
|
||||
p.Select(r => r.Id).Contains(i.Id))
|
||||
.Select(i => i.UserId)
|
||||
.ToList()
|
||||
i.User == user)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
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,17 +341,17 @@ 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 [];
|
||||
var ids = notes.Select(p => p.ThreadId).Distinct();
|
||||
var ids = notes.Select(p => p.ThreadIdOrId).Distinct();
|
||||
return await db.NoteThreadMutings.Where(p => p.User == user && ids.Contains(p.ThreadId))
|
||||
.Select(p => p.ThreadId)
|
||||
.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 +360,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 +372,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 +381,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 +392,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 +422,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()),
|
||||
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();
|
||||
|
@ -498,11 +444,11 @@ public class NoteRenderer(
|
|||
public List<AccountEntity>? Accounts;
|
||||
public List<AttachmentEntity>? Attachments;
|
||||
public List<string>? BookmarkedNotes;
|
||||
public List<string>? MutedNotes;
|
||||
public List<EmojiEntity>? Emoji;
|
||||
public List<Filter>? Filters;
|
||||
public List<string>? LikedNotes;
|
||||
public List<MentionEntity>? Mentions;
|
||||
public List<string>? MutedNotes;
|
||||
public List<string>? PinnedNotes;
|
||||
public List<PollEntity>? Polls;
|
||||
public List<ReactionEntity>? Reactions;
|
||||
|
@ -510,4 +456,4 @@ public class NoteRenderer(
|
|||
|
||||
public bool Source;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,128 +1,70 @@
|
|||
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;
|
||||
using Iceshrimp.Backend.Core.Middleware;
|
||||
|
||||
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(NoteRenderer noteRenderer, UserRenderer userRenderer)
|
||||
{
|
||||
public async Task<NotificationEntity> RenderAsync(
|
||||
Notification notification, User user, bool isPleroma, List<AccountEntity>? accounts = null,
|
||||
IEnumerable<StatusEntity>? statuses = null, Dictionary<string, string>? emojiUrls = null
|
||||
Notification notification, User user, List<AccountEntity>? accounts = null,
|
||||
IEnumerable<StatusEntity>? statuses = null
|
||||
)
|
||||
{
|
||||
var dbNotifier = notification.Notifier ?? throw new Exception("Notification has no notifier");
|
||||
var dbNotifier = notification.Notifier ?? throw new GracefulException("Notification has no notifier");
|
||||
|
||||
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);
|
||||
|
||||
string? emojiUrl = null;
|
||||
if (notification.Reaction != null)
|
||||
{
|
||||
// explicitly check to skip another database call if url is actually null
|
||||
if (emojiUrls != null)
|
||||
{
|
||||
emojiUrl = emojiUrls.GetValueOrDefault(notification.Reaction);
|
||||
}
|
||||
else if (EmojiService.IsCustomEmoji(notification.Reaction))
|
||||
{
|
||||
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))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
var notifier = accounts?.FirstOrDefault(p => p.Id == dbNotifier.Id) ??
|
||||
await userRenderer.RenderAsync(dbNotifier);
|
||||
|
||||
var res = new NotificationEntity
|
||||
{
|
||||
Id = notification.MastoId.ToString(),
|
||||
Type = NotificationEntity.EncodeType(notification.Type, isPleroma),
|
||||
Type = NotificationEntity.EncodeType(notification.Type),
|
||||
Note = note,
|
||||
Notifier = notifier,
|
||||
CreatedAt = notification.CreatedAt.ToStringIso8601Like(),
|
||||
Emoji = notification.Reaction,
|
||||
EmojiUrl = emojiUrl,
|
||||
Pleroma = flags.IsPleroma.Value
|
||||
? new PleromaNotificationExtensions { IsSeen = notification.IsRead }
|
||||
: null
|
||||
CreatedAt = notification.CreatedAt.ToStringIso8601Like()
|
||||
};
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NotificationEntity>> RenderManyAsync(
|
||||
IEnumerable<Notification> notifications, User user, bool isPleroma
|
||||
IEnumerable<Notification> notifications, User user
|
||||
)
|
||||
{
|
||||
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());
|
||||
|
||||
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));
|
||||
|
||||
// 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);
|
||||
|
||||
var res = await notificationList
|
||||
.Select(p => RenderAsync(p, user, isPleroma, accounts, notes, emojiUrls))
|
||||
.AwaitAllAsync();
|
||||
|
||||
return res;
|
||||
return await notificationList
|
||||
.Select(p => RenderAsync(p, user, accounts, notes))
|
||||
.AwaitAllAsync();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -1,135 +1,71 @@
|
|||
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;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
||||
|
||||
public class UserRenderer(
|
||||
IOptions<Config.InstanceSection> config,
|
||||
IOptionsSnapshot<Config.SecuritySection> security,
|
||||
MfmConverter mfmConverter,
|
||||
DatabaseContext db,
|
||||
FlagService flags
|
||||
) : IScopedService
|
||||
public class UserRenderer(IOptions<Config.InstanceSection> config, MfmConverter mfmConverter, 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, 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.HeaderUrl = _transparent;
|
||||
res.HeaderStaticUrl = _transparent;
|
||||
}
|
||||
|
||||
if (source)
|
||||
{
|
||||
//TODO: populate these
|
||||
|
@ -138,9 +74,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 +84,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 +95,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)
|
||||
public async Task<AccountEntity> RenderAsync(User user, List<EmojiEntity>? emoji = null)
|
||||
{
|
||||
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);
|
||||
return await RenderAsync(user, user.UserProfile, emoji);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AccountEntity>> RenderManyAsync(IEnumerable<User> users, User? localUser)
|
||||
public async Task<IEnumerable<AccountEntity>> RenderManyAsync(IEnumerable<User> users)
|
||||
{
|
||||
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, emoji)).AwaitAllAsync();
|
||||
}
|
||||
|
||||
private class UserRendererDto
|
||||
{
|
||||
public required List<EmojiEntity> Emoji;
|
||||
public required Dictionary<string, string?> AvatarAlt;
|
||||
public required Dictionary<string, string?> BannerAlt;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -1,27 +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("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")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public required PleromaNotificationExtensions? Pleroma { get; set; }
|
||||
//TODO: [J("reaction")] public required Reaction? Reaction { get; set; }
|
||||
|
||||
public static string EncodeType(NotificationType type, bool isPleroma)
|
||||
public static string EncodeType(NotificationType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
|
@ -29,19 +23,12 @@ public class NotificationEntity : IIdentifiable
|
|||
NotificationType.Mention => "mention",
|
||||
NotificationType.Reply => "mention",
|
||||
NotificationType.Renote => "reblog",
|
||||
NotificationType.Quote => "status",
|
||||
NotificationType.Like => "favourite",
|
||||
NotificationType.PollEnded => "poll",
|
||||
NotificationType.FollowRequestReceived => "follow_request",
|
||||
NotificationType.Edit => "update",
|
||||
|
||||
NotificationType.Quote when isPleroma => "mention",
|
||||
NotificationType.Quote when !isPleroma => "status",
|
||||
|
||||
NotificationType.Reaction when isPleroma => "pleroma:emoji_reaction",
|
||||
NotificationType.Reaction when !isPleroma => "reaction",
|
||||
|
||||
NotificationType.Bite => "bite",
|
||||
|
||||
_ => throw new GracefulException($"Unsupported notification type: {type}")
|
||||
};
|
||||
}
|
||||
|
@ -50,17 +37,14 @@ public class NotificationEntity : IIdentifiable
|
|||
{
|
||||
return type switch
|
||||
{
|
||||
"follow" => [NotificationType.Follow],
|
||||
"mention" => [NotificationType.Mention, NotificationType.Reply],
|
||||
"reblog" => [NotificationType.Renote, NotificationType.Quote],
|
||||
"favourite" => [NotificationType.Like],
|
||||
"poll" => [NotificationType.PollEnded],
|
||||
"follow_request" => [NotificationType.FollowRequestReceived],
|
||||
"update" => [NotificationType.Edit],
|
||||
"reaction" => [NotificationType.Reaction],
|
||||
"pleroma:emoji_reaction" => [NotificationType.Reaction],
|
||||
"bite" => [NotificationType.Bite],
|
||||
_ => []
|
||||
"follow" => [NotificationType.Follow],
|
||||
"mention" => [NotificationType.Mention, NotificationType.Reply],
|
||||
"reblog" => [NotificationType.Renote, NotificationType.Quote],
|
||||
"favourite" => [NotificationType.Like],
|
||||
"poll" => [NotificationType.PollEnded],
|
||||
"follow_request" => [NotificationType.FollowRequestReceived],
|
||||
"update" => [NotificationType.Edit],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -1,14 +1,13 @@
|
|||
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,19 +45,16 @@ 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; }
|
||||
|
||||
public static string EncodeVisibility(Note.NoteVisibility visibility)
|
||||
{
|
||||
return visibility switch
|
||||
|
@ -107,10 +103,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; }
|
||||
}
|
||||
}
|
|
@ -1,5 +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;
|
||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
@ -14,7 +12,7 @@ public class InstanceInfoV1Response(
|
|||
)
|
||||
{
|
||||
[J("stats")] public required InstanceStats Stats { get; set; }
|
||||
[J("version")] public string Version => $"4.2.1 (compatible; Iceshrimp.NET/{config.Instance.RawVersion})";
|
||||
[J("version")] public string Version => $"4.2.1 (compatible; Iceshrimp.NET/{config.Instance.Version})";
|
||||
|
||||
[J("max_toot_chars")] public int MaxNoteChars => config.Instance.CharacterLimit;
|
||||
[J("uri")] public string AccountDomain => config.Instance.AccountDomain;
|
||||
|
@ -33,17 +31,13 @@ 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}";
|
||||
}
|
||||
|
@ -89,14 +83,13 @@ public class InstanceMediaConfiguration
|
|||
|
||||
public class InstancePollConfiguration
|
||||
{
|
||||
[J("allow_media")] public bool AllowMedia => true;
|
||||
[J("max_options")] public int MaxOptions => 10;
|
||||
[J("max_characters_per_option")] public int MaxCharsPerOption => 50;
|
||||
[J("min_expiration")] public int MinExpiration => 50;
|
||||
[J("max_expiration")] public int MaxExpiration => 2629746;
|
||||
[J("max_options")] public int MaxOptions => 10;
|
||||
[J("max_characters_per_option")] public int MaxCharsPerOption => 50;
|
||||
[J("min_expiration")] public int MinExpiration => 50;
|
||||
[J("max_expiration")] public int MaxExpiration => 2629746;
|
||||
}
|
||||
|
||||
public class InstanceReactionConfiguration
|
||||
{
|
||||
[J("max_reactions")] public int MaxOptions => 100;
|
||||
[J("max_reactions")] public int MaxOptions => 1;
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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; } = "";
|
||||
}
|
||||
}
|
|
@ -45,8 +45,6 @@ public abstract class StatusSchemas
|
|||
|
||||
[B(Name = "poll")] [J("poll")] public PollData? Poll { get; set; }
|
||||
|
||||
[B(Name = "preview")] [J("preview")] public bool Preview { get; set; } = false;
|
||||
|
||||
public class PollData
|
||||
{
|
||||
[B(Name = "options")]
|
||||
|
|
|
@ -18,7 +18,6 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||
|
||||
|
@ -82,18 +81,18 @@ public class SearchController(
|
|||
|
||||
if (search.Resolve)
|
||||
{
|
||||
if (search.Query!.StartsWith("https://"))
|
||||
if (search.Query!.StartsWith("https://") || search.Query.StartsWith("http://"))
|
||||
{
|
||||
if (pagination.Offset is not null and not 0) return [];
|
||||
|
||||
var result = await userResolver
|
||||
.ResolveOrNullAsync(search.Query, ResolveFlags.Uri | ResolveFlags.MatchUrl);
|
||||
|
||||
return result switch
|
||||
try
|
||||
{
|
||||
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(result), user)],
|
||||
_ => []
|
||||
};
|
||||
var result = await userResolver.ResolveAsync(search.Query);
|
||||
return [await userRenderer.RenderAsync(result)];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
var regex = new Regex
|
||||
|
@ -110,22 +109,22 @@ public class SearchController(
|
|||
.Where(p => p.UsernameLower == username)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return result != null ? [await userRenderer.RenderAsync(result, user)] : [];
|
||||
return result != null ? [await userRenderer.RenderAsync(result)] : [];
|
||||
}
|
||||
else
|
||||
{
|
||||
var username = match.Groups["user"].Value;
|
||||
var host = match.Groups["host"].Value;
|
||||
|
||||
var result = await userResolver.ResolveOrNullAsync(GetQuery(username, host), ResolveFlags.Acct);
|
||||
|
||||
// @formatter:off
|
||||
return result switch
|
||||
try
|
||||
{
|
||||
not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(result), user)],
|
||||
_ => []
|
||||
};
|
||||
// @formatter:on
|
||||
var result = await userResolver.ResolveAsync($"@{username}@{host}");
|
||||
return [await userRenderer.RenderAsync(result)];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,7 +136,7 @@ public class SearchController(
|
|||
.Where(p => !search.Following || p.IsFollowedBy(user))
|
||||
.OrderByDescending(p => p.NotesCount)
|
||||
.PaginateByOffset(pagination, ControllerContext)
|
||||
.RenderAllForMastodonAsync(userRenderer, user);
|
||||
.RenderAllForMastodonAsync(userRenderer);
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall",
|
||||
|
@ -149,7 +148,7 @@ public class SearchController(
|
|||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
|
||||
if (search.Resolve && search.Query!.StartsWith("https://"))
|
||||
if (search.Resolve && (search.Query!.StartsWith("https://") || search.Query.StartsWith("http://")))
|
||||
{
|
||||
if (pagination.Offset is not null and not 0) return [];
|
||||
|
||||
|
|
|
@ -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,9 +96,13 @@ 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");
|
||||
|
||||
// Akkoma-FE calls /context on boosts
|
||||
if (note.IsPureRenote)
|
||||
return await GetStatusContext(note.RenoteId!);
|
||||
var shouldShowContext = await db.Notes
|
||||
.Where(p => p.Id == id)
|
||||
.FilterHidden(user, db)
|
||||
.AnyAsync();
|
||||
|
||||
if (!shouldShowContext)
|
||||
return new StatusContext { Ancestors = [], Descendants = [] };
|
||||
|
||||
var ancestors = await db.NoteAncestors(id, maxAncestors)
|
||||
.IncludeCommonProperties()
|
||||
|
@ -141,8 +119,6 @@ public class StatusController(
|
|||
.PrecomputeVisibilities(user)
|
||||
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads);
|
||||
|
||||
if (user != null) await noteSvc.EnqueueBackfillTaskAsync(note, user);
|
||||
|
||||
return new StatusContext
|
||||
{
|
||||
Ancestors = ancestors.OrderAncestors(), Descendants = descendants.OrderDescendants()
|
||||
|
@ -354,9 +330,6 @@ public class StatusController(
|
|||
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)]
|
||||
public async Task<StatusEntity> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request)
|
||||
{
|
||||
if (request.Preview)
|
||||
throw GracefulException.UnprocessableEntity("Previewing is not supported yet");
|
||||
|
||||
var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage");
|
||||
var user = token.User;
|
||||
|
||||
|
@ -403,12 +376,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 +396,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 })
|
||||
|
@ -475,13 +433,7 @@ public class StatusController(
|
|||
.FirstOrDefaultAsync()
|
||||
: await db.Notes
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Uri == quoteUri)
|
||||
.EnsureVisibleFor(user)
|
||||
.FilterHidden(user, db, filterMutes: false)
|
||||
.FirstOrDefaultAsync() ??
|
||||
await db.Notes
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Url == quoteUri)
|
||||
.Where(p => p.Uri == quoteUri || p.Url == quoteUri)
|
||||
.EnsureVisibleFor(user)
|
||||
.FilterHidden(user, db, filterMutes: false)
|
||||
.FirstOrDefaultAsync()
|
||||
|
@ -492,18 +444,8 @@ public class StatusController(
|
|||
if (quote != null && request.Text != null && newText != null && urls.OfType<string>().Contains(quoteUri))
|
||||
request.Text = newText;
|
||||
|
||||
var note = await noteSvc.CreateNoteAsync(new NoteService.NoteCreationData
|
||||
{
|
||||
User = user,
|
||||
Visibility = visibility,
|
||||
Text = request.Text,
|
||||
Cw = request.Cw,
|
||||
Reply = reply,
|
||||
Renote = quote,
|
||||
Attachments = attachments,
|
||||
Poll = poll,
|
||||
LocalOnly = request.LocalOnly
|
||||
});
|
||||
var note = await noteSvc.CreateNoteAsync(user, visibility, request.Text, request.Cw, reply, quote, attachments,
|
||||
poll, request.LocalOnly);
|
||||
|
||||
if (idempotencyKey != null)
|
||||
await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24));
|
||||
|
@ -552,15 +494,7 @@ public class StatusController(
|
|||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
note = await noteSvc.UpdateNoteAsync(new NoteService.NoteUpdateData
|
||||
{
|
||||
Note = note,
|
||||
Text = request.Text,
|
||||
Cw = request.Cw,
|
||||
Attachments = attachments,
|
||||
Poll = poll
|
||||
});
|
||||
|
||||
note = await noteSvc.UpdateNoteAsync(note, request.Text, request.Cw, attachments, poll);
|
||||
return await noteRenderer.RenderAsync(note, user);
|
||||
}
|
||||
|
||||
|
@ -624,7 +558,7 @@ public class StatusController(
|
|||
.ToListAsync();
|
||||
|
||||
HttpContext.SetPaginationData(likes);
|
||||
return await userRenderer.RenderManyAsync(likes.Select(p => p.Entity), user);
|
||||
return await userRenderer.RenderManyAsync(likes.Select(p => p.Entity));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/reblogged_by")]
|
||||
|
@ -657,7 +591,7 @@ public class StatusController(
|
|||
.ToListAsync();
|
||||
|
||||
HttpContext.SetPaginationData(renotes);
|
||||
return await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity), user);
|
||||
return await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/history")]
|
||||
|
@ -681,7 +615,7 @@ public class StatusController(
|
|||
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null)
|
||||
throw GracefulException.Forbidden("Public preview is disabled on this instance");
|
||||
|
||||
return await noteRenderer.RenderHistoryAsync(note, user);
|
||||
return await noteRenderer.RenderHistoryAsync(note);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/mute")]
|
||||
|
@ -693,13 +627,13 @@ public class StatusController(
|
|||
var user = HttpContext.GetUserOrFail();
|
||||
var target = await db.Notes.Where(p => p.Id == id)
|
||||
.EnsureVisibleFor(user)
|
||||
.Select(p => p.ThreadId)
|
||||
.Select(p => p.ThreadIdOrId)
|
||||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.RecordNotFound();
|
||||
|
||||
var mute = new NoteThreadMuting
|
||||
{
|
||||
Id = IdHelpers.GenerateSnowflakeId(),
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ThreadId = target,
|
||||
UserId = user.Id
|
||||
|
@ -718,11 +652,11 @@ public class StatusController(
|
|||
var user = HttpContext.GetUserOrFail();
|
||||
var target = await db.Notes.Where(p => p.Id == id)
|
||||
.EnsureVisibleFor(user)
|
||||
.Select(p => p.ThreadId)
|
||||
.Select(p => p.ThreadIdOrId)
|
||||
.FirstOrDefaultAsync() ??
|
||||
throw GracefulException.RecordNotFound();
|
||||
|
||||
await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync();
|
||||
return await GetNote(id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
{
|
||||
|
@ -86,12 +86,12 @@ public class DirectChannel(WebSocketConnection connection) : IChannel
|
|||
var users = await db.Users.IncludeCommonProperties()
|
||||
.Where(p => note.VisibleUserIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var accounts = await userRenderer.RenderManyAsync(users, connection.Token.User);
|
||||
var accounts = await userRenderer.RenderManyAsync(users);
|
||||
|
||||
return new ConversationEntity
|
||||
{
|
||||
Accounts = accounts.ToList(),
|
||||
Id = note.ThreadId,
|
||||
Id = note.ThreadIdOrId,
|
||||
LastStatus = rendered,
|
||||
Unread = true
|
||||
};
|
||||
|
@ -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));
|
||||
|
|
|
@ -13,14 +13,14 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
|
|||
private readonly ILogger<HashtagChannel> _logger =
|
||||
connection.Scope.ServiceProvider.GetRequiredService<ILogger<HashtagChannel>>();
|
||||
|
||||
private readonly WriteLockingHashSet<string> _tags = [];
|
||||
private readonly WriteLockingList<string> _tags = [];
|
||||
|
||||
public string Name => local ? "hashtag:local" : "hashtag";
|
||||
public List<string> Scopes => ["read:statuses"];
|
||||
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)
|
||||
{
|
||||
|
@ -46,7 +46,7 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann
|
|||
return;
|
||||
}
|
||||
|
||||
_tags.RemoveWhere(p => p == msg.Tag);
|
||||
_tags.RemoveAll(p => p == msg.Tag);
|
||||
|
||||
if (!IsSubscribed) Dispose();
|
||||
}
|
||||
|
@ -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() };
|
||||
|
|
|
@ -12,7 +12,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming.Channels;
|
|||
|
||||
public class ListChannel(WebSocketConnection connection) : IChannel
|
||||
{
|
||||
private readonly WriteLockingHashSet<string> _lists = [];
|
||||
private readonly WriteLockingList<string> _lists = [];
|
||||
|
||||
private readonly ILogger<ListChannel> _logger =
|
||||
connection.Scope.ServiceProvider.GetRequiredService<ILogger<ListChannel>>();
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -68,7 +68,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
|
|||
return;
|
||||
}
|
||||
|
||||
_lists.RemoveWhere(p => p == msg.List);
|
||||
_lists.RemoveAll(p => p == msg.List);
|
||||
_members.TryRemove(msg.List, out _);
|
||||
_applicableUserIds = _members.Values.SelectMany(p => p).Distinct();
|
||||
|
||||
|
@ -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)
|
||||
|
|
|
@ -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() };
|
||||
|
|
|
@ -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,16 +174,15 @@ 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))
|
||||
return;
|
||||
await using var scope = connection.ScopeFactory.CreateAsyncScope();
|
||||
if (notification.Note != null && await connection.IsMutedThread(notification.Note, scope)) return;
|
||||
|
||||
var renderer = scope.ServiceProvider.GetRequiredService<NotificationRenderer>();
|
||||
|
||||
NotificationEntity rendered;
|
||||
try
|
||||
{
|
||||
rendered = await renderer.RenderAsync(notification, connection.Token.User, connection.Token.IsPleroma);
|
||||
rendered = await renderer.RenderAsync(notification, connection.Token.User);
|
||||
}
|
||||
catch (GracefulException)
|
||||
{
|
||||
|
|
|
@ -18,26 +18,27 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming;
|
|||
public sealed class WebSocketConnection(
|
||||
WebSocket socket,
|
||||
OauthToken token,
|
||||
EventService eventSvc,
|
||||
IEventService eventSvc,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
CancellationToken ct
|
||||
) : IDisposable
|
||||
{
|
||||
private readonly SemaphorePlus _lock = new(1);
|
||||
private readonly List<IChannel> _channels = [];
|
||||
private readonly WriteLockingHashSet<string> _blockedBy = [];
|
||||
private readonly WriteLockingHashSet<string> _blocking = [];
|
||||
private readonly WriteLockingHashSet<string> _muting = [];
|
||||
public readonly WriteLockingHashSet<string> Following = [];
|
||||
public readonly WriteLockingList<Filter> Filters = [];
|
||||
public readonly EventService EventService = eventSvc;
|
||||
public readonly IServiceScope Scope = scopeFactory.CreateScope();
|
||||
public readonly OauthToken Token = token;
|
||||
public HashSet<string> HiddenFromHome = [];
|
||||
private readonly WriteLockingList<string> _blockedBy = [];
|
||||
private readonly WriteLockingList<string> _blocking = [];
|
||||
private readonly SemaphorePlus _lock = new(1);
|
||||
private readonly WriteLockingList<string> _muting = [];
|
||||
public readonly List<IChannel> Channels = [];
|
||||
public readonly IEventService EventService = eventSvc;
|
||||
public readonly WriteLockingList<Filter> Filters = [];
|
||||
public readonly WriteLockingList<string> Following = [];
|
||||
public readonly IServiceScope Scope = scopeFactory.CreateScope();
|
||||
public readonly IServiceScopeFactory ScopeFactory = scopeFactory;
|
||||
public readonly OauthToken Token = token;
|
||||
public List<string> HiddenFromHome = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var channel in _channels)
|
||||
foreach (var channel in Channels)
|
||||
channel.Dispose();
|
||||
|
||||
EventService.UserBlocked -= OnUserUnblock;
|
||||
|
@ -56,22 +57,20 @@ 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));
|
||||
_channels.Add(new UserChannel(this, false));
|
||||
_channels.Add(new HashtagChannel(this, true));
|
||||
_channels.Add(new HashtagChannel(this, false));
|
||||
_channels.Add(new PublicChannel(this, "public", true, true, false));
|
||||
_channels.Add(new PublicChannel(this, "public:media", true, true, true));
|
||||
_channels.Add(new PublicChannel(this, "public:allow_local_only", true, true, false));
|
||||
_channels.Add(new PublicChannel(this, "public:allow_local_only:media", true, true, true));
|
||||
_channels.Add(new PublicChannel(this, "public:local", true, false, false));
|
||||
_channels.Add(new PublicChannel(this, "public:local:media", true, false, true));
|
||||
_channels.Add(new PublicChannel(this, "public:remote", false, true, false));
|
||||
_channels.Add(new PublicChannel(this, "public:remote:media", false, true, true));
|
||||
Channels.Add(new ListChannel(this));
|
||||
Channels.Add(new DirectChannel(this));
|
||||
Channels.Add(new UserChannel(this, true));
|
||||
Channels.Add(new UserChannel(this, false));
|
||||
Channels.Add(new HashtagChannel(this, true));
|
||||
Channels.Add(new HashtagChannel(this, false));
|
||||
Channels.Add(new PublicChannel(this, "public", true, true, false));
|
||||
Channels.Add(new PublicChannel(this, "public:media", true, true, true));
|
||||
Channels.Add(new PublicChannel(this, "public:allow_local_only", true, true, false));
|
||||
Channels.Add(new PublicChannel(this, "public:allow_local_only:media", true, true, true));
|
||||
Channels.Add(new PublicChannel(this, "public:local", true, false, false));
|
||||
Channels.Add(new PublicChannel(this, "public:local:media", true, false, true));
|
||||
Channels.Add(new PublicChannel(this, "public:remote", false, true, false));
|
||||
Channels.Add(new PublicChannel(this, "public:remote:media", false, true, true));
|
||||
|
||||
EventService.UserBlocked += OnUserUnblock;
|
||||
EventService.UserUnblocked += OnUserBlock;
|
||||
|
@ -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)
|
||||
|
@ -120,19 +119,11 @@ public sealed class WebSocketConnection(
|
|||
.Where(p => p.UserList.UserId == Token.User.Id && p.UserList.HideFromHomeTl)
|
||||
.Select(p => p.UserId)
|
||||
.Distinct()
|
||||
.ToArrayAsync()
|
||||
.ContinueWithResult(p => p.ToHashSet());
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task HandleSocketMessageAsync(string payload)
|
||||
{
|
||||
// Akkoma-FE sends a plain ping
|
||||
if (payload == "ping")
|
||||
{
|
||||
await SendMessageAsync("pong");
|
||||
return;
|
||||
}
|
||||
|
||||
StreamingRequestMessage? message = null;
|
||||
try
|
||||
{
|
||||
|
@ -159,19 +150,19 @@ public sealed class WebSocketConnection(
|
|||
case "subscribe":
|
||||
{
|
||||
var channel =
|
||||
_channels.FirstOrDefault(p => p.Name == message.Stream && (!p.IsSubscribed || p.IsAggregate));
|
||||
Channels.FirstOrDefault(p => p.Name == message.Stream && (!p.IsSubscribed || p.IsAggregate));
|
||||
if (channel == null) return;
|
||||
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);
|
||||
Channels.FirstOrDefault(p => p.Name == message.Stream && (p.IsSubscribed || p.IsAggregate));
|
||||
if (channel != null) await channel.Unsubscribe(message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
@ -344,14 +335,13 @@ 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
|
||||
.Where(p => p.UserList.UserId == Token.User.Id && p.UserList.HideFromHomeTl)
|
||||
.Select(p => p.UserId)
|
||||
.ToArrayAsync()
|
||||
.ContinueWithResult(p => p.ToHashSet());
|
||||
.ToListAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
@ -376,29 +366,14 @@ 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)
|
||||
{
|
||||
if (!isNotification && note.Reply == null) return false;
|
||||
if (!isNotification && note.User.Id == Token.UserId) return false;
|
||||
if (note.Reply == null) return false;
|
||||
if (note.User.Id == Token.UserId) return false;
|
||||
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
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 +387,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();
|
||||
}
|
|
@ -8,7 +8,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Streaming;
|
|||
public static class WebSocketHandler
|
||||
{
|
||||
public static async Task HandleConnectionAsync(
|
||||
WebSocket socket, OauthToken token, EventService eventSvc, IServiceScopeFactory scopeFactory,
|
||||
WebSocket socket, OauthToken token, IEventService eventSvc, IServiceScopeFactory scopeFactory,
|
||||
string? stream, string? list, string? tag, CancellationToken ct
|
||||
)
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -14,7 +14,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
|
|||
public class WebSocketController(
|
||||
IHostApplicationLifetime appLifetime,
|
||||
DatabaseContext db,
|
||||
EventService eventSvc,
|
||||
IEventService eventSvc,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<WebSocketController> logger
|
||||
) : ControllerBase
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
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.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;
|
||||
|
||||
[MastodonApiController]
|
||||
[EnableCors("mastodon")]
|
||||
[EnableRateLimiting("sliding")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class EmojiController(IOptions<Config.InstanceSection> instance, DatabaseContext db) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/v1/pleroma/emoji")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public async Task<Dictionary<string, PleromaEmojiEntity>> GetCustomEmojis()
|
||||
{
|
||||
var emoji = await db.Emojis
|
||||
.Where(p => p.Host == null)
|
||||
.Select(p => KeyValuePair.Create(p.Name,
|
||||
new PleromaEmojiEntity
|
||||
{
|
||||
ImageUrl = p.GetAccessUrl(instance.Value),
|
||||
Tags = new[] { p.Category ?? "" }
|
||||
}))
|
||||
.ToArrayAsync();
|
||||
|
||||
return new Dictionary<string, PleromaEmojiEntity>(emoji);
|
||||
}
|
||||
}
|
|
@ -1,24 +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 Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma;
|
||||
|
||||
[MastodonApiController]
|
||||
[EnableCors("mastodon")]
|
||||
[EnableRateLimiting("sliding")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class FrontendController : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/pleroma/frontend_configurations")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public FrontendConfigurationsResponse GetFrontendConfigurations()
|
||||
{
|
||||
return new FrontendConfigurationsResponse();
|
||||
}
|
||||
}
|
|
@ -1,60 +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.Entities;
|
||||
using Iceshrimp.Backend.Controllers.Pleroma.Schemas;
|
||||
using Iceshrimp.Backend.Controllers.Shared.Attributes;
|
||||
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;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma;
|
||||
|
||||
[MastodonApiController]
|
||||
[Authenticate]
|
||||
[EnableCors("mastodon")]
|
||||
[EnableRateLimiting("sliding")]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class NotificationController(DatabaseContext db, NotificationRenderer notificationRenderer) : ControllerBase
|
||||
{
|
||||
[HttpPost("/api/v1/pleroma/notifications/read")]
|
||||
[Authorize("read:notifications")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public async Task<List<NotificationEntity>> MarkNotificationsAsRead(
|
||||
[FromHybrid] PleromaNotificationSchemas.ReadNotificationsRequest request
|
||||
)
|
||||
{
|
||||
var user = HttpContext.GetUserOrFail();
|
||||
|
||||
if (request is { Id: not null, MaxId: not null })
|
||||
throw GracefulException.BadRequest("id and max_id are mutually exclusive.");
|
||||
|
||||
var q = db.Notifications
|
||||
.IncludeCommonProperties()
|
||||
.Where(p => p.Notifiee == user)
|
||||
.Where(p => p.Notifier != null)
|
||||
.Where(p => !p.IsRead)
|
||||
.EnsureNoteVisibilityFor(p => p.Note, user)
|
||||
.OrderByDescending(n => n.MastoId)
|
||||
.Take(80);
|
||||
|
||||
if (request.Id != null)
|
||||
q = q.Where(n => n.MastoId == request.Id);
|
||||
else if (request.MaxId != null)
|
||||
q = q.Where(n => n.MastoId <= request.MaxId);
|
||||
else
|
||||
throw GracefulException.BadRequest("One of id or max_id are required.");
|
||||
|
||||
var notifications = await q.ToListAsync();
|
||||
foreach (var notif in notifications)
|
||||
notif.IsRead = true;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return (await notificationRenderer.RenderManyAsync(notifications, user, isPleroma: true)).ToList();
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
|
||||
|
||||
public class PleromaEmojiEntity
|
||||
{
|
||||
[J("image_url")] public required string ImageUrl { get; set; }
|
||||
[J("tags")] public string[] Tags { get; set; } = [];
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
|
||||
|
||||
public class PleromaInstanceExtensions
|
||||
{
|
||||
[J("vapid_public_key")] public required string VapidPublicKey { get; set; }
|
||||
[J("metadata")] public required InstanceMetadata Metadata { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceMetadata
|
||||
{
|
||||
[J("post_formats")] public string[] PostFormats => ["text/plain", "text/x.misskeymarkdown"];
|
||||
|
||||
[J("features")]
|
||||
public string[] Features =>
|
||||
[
|
||||
"pleroma_api",
|
||||
"akkoma_api",
|
||||
"mastodon_api",
|
||||
"mastodon_api_streaming",
|
||||
"polls",
|
||||
"quote_posting",
|
||||
"editing",
|
||||
"pleroma_emoji_reactions",
|
||||
"exposable_reactions",
|
||||
"custom_emoji_reactions",
|
||||
"pleroma:bites"
|
||||
];
|
||||
|
||||
[J("fields_limits")] public FieldsLimits FieldsLimits => new();
|
||||
}
|
||||
|
||||
// there doesn't seem to be any limits there, from briefly checking the code
|
||||
public class FieldsLimits
|
||||
{
|
||||
[J("max_fields")] public int MaxFields => int.MaxValue;
|
||||
[J("max_remote_fields")] public int MaxRemoteFields => int.MaxValue;
|
||||
[J("name_length")] public int NameLength => int.MaxValue;
|
||||
[J("value_length")] public int ValueLength => int.MaxValue;
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
|
||||
|
||||
public class PleromaNotificationExtensions
|
||||
{
|
||||
[J("is_seen")] public required bool IsSeen { get; set; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||
|
||||
namespace Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
|
||||
|
||||
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; }
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue