Compare commits

...
Sign in to create a new pull request.

876 commits

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

Also keep the denychildattach which Run would have added. See:
https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/
2025-03-10 22:46:21 +01:00
Laura Hausmann
72c21ce51c
[backend/database] Move the remaining migrations into their respective versioned folders 2025-03-09 17:21:12 +01:00
Laura Hausmann
65d6edf799
[backend/database] Fix slow AddUserLastNoteAdd migration
This change ensures that the migration doesn't have to read every note in the database, but only up to one per known user.
2025-03-09 02:56:00 +01:00
Laura Hausmann
4b5a9386c7
[backend/masto-client] Add /api/v1/preferences endpoint (ISH-746) 2025-03-07 21:56:35 +01:00
pancakes
f639124881
[backend/api] Check reaction name with and without colons 2025-03-07 21:23:57 +01:00
pancakes
091d0e0c3f
[backend/api] Fix endpoint for getting specific reactions on notes not working with Unicode emojis 2025-03-07 21:23:57 +01:00
Kopper
5d27233d08
[backend/masto-client] Expose AccountEntity.last_status_at
For performance reasons (it's set on the same query as the one that
increments the user notes counter) this does not take renotes into
account
2025-03-07 21:22:48 +01:00
Laura Hausmann
ec7056f11a
[backend/csproj] Update ImageSharp version 2025-03-06 18:02:11 +01:00
Laura Hausmann
864148129b
[docs] Add Ivory to supported Mastodon clients 2025-03-06 16:16:12 +01:00
Laura Hausmann
a4717da8ab
[backend/core] Fix link verification for sites served with Transfer-Encoding: chunked 2025-03-06 15:52:19 +01:00
pancakes
107160c690
[frontend/core] Reconstruct stored users to minimal state if they are malformed 2025-03-06 15:04:48 +10:00
Laura Hausmann
57488e5641
[backend/configuration] Allow disabling authorized fetch signature validation 2025-03-05 15:25:10 +01:00
Laura Hausmann
befe550f37
[backend/middleware] Authenticate requests with signature headers when authorized fetch is disabled
This fixes a bug that prevented fetching follower-only posts from remote instances when the local instance has authorized fetch disabled.
2025-03-05 15:25:10 +01:00
Laura Hausmann
0442f676e1
[backend/database] [frontend] [shared] Rename emoji aliases to tags (ISH-717) 2025-03-04 23:28:59 +01:00
Laura Hausmann
6391e5f185
[tests] Improve SearchQueryTests 2025-03-04 23:18:08 +01:00
Laura Hausmann
220c4b776d
[parsing] Add visibility:local search query filter (ISH-707) 2025-03-04 23:18:00 +01:00
Laura Hausmann
822d5f90d3
[backend/core] Fix negated multi word search query filters (ISH-739) 2025-03-04 23:11:00 +01:00
Laura Hausmann
4a4c674776
[parsing] Add note visibility search query type (ISH-707) 2025-03-04 23:06:09 +01:00
Laura Hausmann
fe24a11ca8
[backend/core] Move mutes on account migration (ISH-721) 2025-03-04 22:54:52 +01:00
Laura Hausmann
4aa548dab4
[backend/razor] Fix details element styles for Chrome >= 131 & future Firefox versions 2025-03-03 18:52:33 +01:00
Laura Hausmann
bcc8377bec
[backend/api] Fix emoji import request size limiter not being disabled 2025-02-27 01:56:18 +01:00
Laura Hausmann
ed1623b572
[backend/core] Fix path-style object storage access URLs 2025-02-26 05:06:05 +01:00
Laura Hausmann
bf5f1e927c
[backend/core] Verify object storage credentials & access URL before starting the object storage migration task 2025-02-26 03:07:00 +01:00
Laura Hausmann
b98de5a2f4
Release: v2025.1-beta5.patch2 2025-02-26 00:46:17 +01:00
pancakes
fbf69608f0
[frontend/components] Correctly size field value 2025-02-26 00:18:43 +01:00
pancakes
23833bb1b4
[frontend/components] Fix word wrapping and improve field spacing 2025-02-26 00:18:42 +01:00
Kopper
26d86eee65
[backend/federation] Fix null values in LDLocalizedString 2025-02-26 00:17:20 +01:00
Lilian
67a8819ad9
[frontend/components] Fix JS interop being used before initialization on SingleNote Page 2025-02-25 16:56:42 +01:00
Laura Hausmann
a8e3597811
[frontend/components] Fix sideways notes 2025-02-24 03:59:51 +01:00
Laura Hausmann
3e7785d51d
[frontend/components] Fix extraneous whitespace above quote content 2025-02-23 23:59:40 +01:00
pancakes
ace2c44279
[frontend/components] Display error notice when poll voting fails 2025-02-23 12:00:24 +10:00
pancakes
8da7bf7cf7
[frontend/components] Improve contrast on poll results 2025-02-23 11:46:20 +10:00
Laura Hausmann
30310a4718
[frontend/components] Fix avatar sizing during load, prevent firefox from displaying alt text during load 2025-02-23 01:29:21 +01:00
pancakes
75ff3be764
[frontend/components] Remove LoadError title 2025-02-23 01:29:21 +01:00
pancakes
ff4d202305
[frontend/components] Fallback avatar to user identicon 2025-02-23 01:29:21 +01:00
pancakes
c5f8bac5e2
[frontend/components] Add onerror image fallback to UserAvatar 2025-02-23 01:29:21 +01:00
pancakes
cb9d078496
[frontend/components] Clip overflowing avatar alt text when avatar fails to load 2025-02-23 01:29:21 +01:00
pancakes
e73c719416
[backend/masto-client] Fix InstanceInfoV2 streaming URL 2025-02-22 23:44:44 +01:00
pancakes
98bffe58d3
[frontend/components] Disable close button while sending note or uploading file 2025-02-22 23:43:09 +01:00
pancakes
22f1d7c8e1
[frontend/components] Reset send button state when opening compose and after successful send 2025-02-22 23:43:08 +01:00
Laura Hausmann
5bf14a6a06
[backend/core] Fix profile field verification for MFM link node fields 2025-02-22 23:32:08 +01:00
Laura Hausmann
9d480e3fba
[backend/api] Fix API errors on poll vote 2025-02-22 23:18:01 +01:00
Laura Hausmann
d33e2d7bcc
[ci] Fix docker images not getting tagged as :latest 2025-02-22 22:59:04 +01:00
pancakes
04a5512c61
[backend/core] Verify links that have a matching link element in their document head 2025-02-21 13:28:35 +10:00
Laura Hausmann
da2cb76aab
[make] Don't use compiled EF models
These appear to break LINQ-SQL translation, possibly due to a SDK version mismatch.
2025-02-20 21:15:03 +01:00
Laura Hausmann
a6e967af11
Release: v2025.1-beta5.patch1 2025-02-19 23:56:26 +01:00
Lilian
b53af5165d
[frontend/core] Prevent duplicate notifications (ISH-723) 2025-02-19 22:59:49 +01:00
Lilian
04af2e2fb6
[frontend/components] Fix moderation settings on mobile 2025-02-19 22:59:49 +01:00
Laura Hausmann
ce20f04e04
[backend/core] Fix erroneous non-updating security configuration references 2025-02-19 20:30:03 +01:00
Lilian
804215fa50
[frontend] Roll back PhosphorIcons 2025-02-19 20:23:10 +01:00
pancakes
2deb391b09
[frontend/components] Fix account dropdown positioning when page has scrolled 2025-02-19 20:09:32 +01:00
Laura Hausmann
df3e56f422
[backend/core] Prevent creation of pure renote replies 2025-02-19 16:37:11 +01:00
Laura Hausmann
70349c25c0
Release: v2025.1-beta5 2025-02-19 01:28:09 +01:00
Laura Hausmann
f26e97f402
[make] Fix release script 2025-02-19 01:28:09 +01:00
Laura Hausmann
5995f232e9
[build] Fix pre-fingerprinted static assets collected from razor class libraries not being mapped correctly 2025-02-19 00:38:57 +01:00
Laura Hausmann
0bf88aec43
[build] Fix compressed class library assets not having corresponding static asset selector routes 2025-02-19 00:38:57 +01:00
Lilian
b0af6307f3
[frontend/components] Fix notification list visually jumping 2025-02-18 22:49:46 +01:00
Lilian
3eb80a0954
[frontend/core] Fix data update mistake in NoteStore/TimelineStore 2025-02-18 22:49:46 +01:00
Lilian
fe0848e08c
[frontend/components] Rework custom emoji management 2025-02-18 22:49:46 +01:00
Lilian
fcc5d555aa
[backend/web] Allow fetching list of remote emoji hosts 2025-02-18 22:49:46 +01:00
Laura Hausmann
8ed309b13d
[backend/razor] Fix navbar overflow handling 2025-02-18 22:33:16 +01:00
Laura Hausmann
80896fcf88
[sln] Update dependencies 2025-02-18 22:02:28 +01:00
Laura Hausmann
88dee6b410
[backend/api] Replace IEntity with IIdentifiable, move EntityWrapper<T> to shared project 2025-02-18 21:47:33 +01:00
pancakes
ce1e64b33a
[frontend/pages] Set titles with HeadTitle 2025-02-18 17:23:21 +01:00
pancakes
c91b3013c7
[frontend/components] Add HeadTitle component 2025-02-18 17:23:20 +01:00
pancakes
df244a3c20
[frontend/components] Set poll percentages to 0 if there are no votes 2025-02-18 17:41:22 +10:00
pancakes
318651ac5d
[backend/razor] Set poll percentages to 0 if there are no votes 2025-02-18 17:40:53 +10:00
Laura Hausmann
4cdd5e9889
[backend/core] Code cleanup 2025-02-17 23:40:53 +01:00
Lilian
fe083d3a5c
[frontend] Code cleanup 2025-02-17 23:32:45 +01:00
Laura Hausmann
8b514986a4
[backend/middleware] Require administrator role for masto admin scope endpoints
This isn't currently used anywhere, but may become relevant in the future.
2025-02-17 23:29:05 +01:00
Lilian
3a51684e07
[frontend] Adjust logging severity of some errors 2025-02-17 23:16:51 +01:00
Lilian
7e8b62f9cf
[frontend/core] Fix notification streaming 2025-02-17 23:13:08 +01:00
Lilian
6c8a7e4c3f
[frontend/core] Fix note store not updating renotes and replies 2025-02-17 22:59:02 +01:00
pancakes
4266629302
[frontend/components] Properly limit global dialog max width on mobile 2025-02-17 22:23:10 +01:00
pancakes
d7ec77f8e1
[frontend/css] Remove default background color from dialogs 2025-02-17 22:23:10 +01:00
pancakes
7caabbdbfd
[frontend/components] Make dropdowns larger on mobile 2025-02-17 22:19:58 +01:00
pancakes
07ca99f814
[frontend/components] Fix visibility dropdown positioning in compose 2025-02-17 22:19:58 +01:00
pancakes
46eb9f5bb9
[frontend/components] Disable control+enter keymap when posting is disabled 2025-02-17 22:19:19 +01:00
pancakes
84dfc746b8
[frontend/components] Add uploading file indicator to note composer 2025-02-17 22:19:18 +01:00
pancakes
ed31b0646e
[frontend/components] Use notice dialog for attachment alt text view 2025-02-17 22:17:22 +01:00
Laura Hausmann
16d62f6c33
[backend/database] Fix emoji type migration for emoji with multiple corresponding drive_file entries 2025-02-17 22:16:58 +01:00
Lilian
a94e1e7130
[frontend/components] Move quotes to NoteBody 2025-02-17 21:52:58 +01:00
Lilian
db1e2f18e7
[frontend/components] Remove scroller from profile view (ISH-711) 2025-02-17 21:20:33 +01:00
Laura Hausmann
9f33e71b0f
[backend/federation] Wrap ASEmoji with colons on either side
This fixes emoji federation with NodeBB
2025-02-17 20:47:07 +01:00
Laura Hausmann
7c93c5591a
[backend/core] Populate & render emoji media type if available 2025-02-17 20:31:41 +01:00
Laura Hausmann
ca786026c2
[backend/api] Improve emoji alias update algorithm 2025-02-17 19:47:29 +01:00
Laura Hausmann
a143ac8b50
[backend/database] Fixup empty string emoji alias entries 2025-02-17 19:44:20 +01:00
Laura Hausmann
cb0e0dadef
[backend/core] Improve profile field verification error handling 2025-02-13 12:50:44 +01:00
Laura Hausmann
dd8f6b063e
[backend/core] Fix profile field verification handling for non-html links 2025-02-13 12:38:08 +01:00
Lilian
5ef8e3581c
[frontend/components] Use double for attachment view position calculation 2025-02-12 19:41:07 +01:00
Lilian
1701654f89
[frontend/components] Make mobile layout menu respect safe-area-inset 2025-02-10 13:46:15 +01:00
Lilian
a6346b7429
[frontend/components] Add logout and account change menu 2025-02-10 13:18:30 +01:00
Lilian
b8c699a79b
[frontend/core] Don't remove admin cookie unless session is ended 2025-02-10 13:18:30 +01:00
pancakes
8cffaeb661
[frontend/components] Indent CW and reactions when note is indented 2025-02-10 12:06:44 +01:00
pancakes
5d6f194358
[frontend/components] Make mfm line-height consistent with -js 2025-02-10 11:43:42 +10:00
pancakes
361c41d700
[frontend/components] Clip notification note contents 2025-02-10 11:43:20 +10:00
pancakes
8ad22eddc3
[frontend/components] Make sure bottom bar doesn't cut off page content on mobile layout 2025-02-10 11:42:58 +10:00
pancakes
b4d7a5c853
[frontend/pages] Update stored userdata on profile update 2025-02-09 23:30:54 +01:00
Lilian
652df49326
[frontend/components] Make virtual scroller gracefully handle no content 2025-02-09 23:02:48 +01:00
Lilian
13495a692a
[frontend/components] Hide virtual scroller ScrollEnd buttons when not needed 2025-02-09 22:30:05 +01:00
Lilian
cd1973e000
[frontend/components] Fix settings layout height 2025-02-09 17:11:56 +01:00
Lilian
ae23f34784
[frontend/components] Make moderation menu navigable on small layout 2025-02-09 17:11:35 +01:00
Lilian
c13ae77a8d
[frontend/components] Add proper loading spinner to all loading indicators 2025-02-09 16:48:22 +01:00
Lilian
1b84d3b87e
[frontend/core] Update stored userdata on user select (ISH-705) 2025-02-09 14:32:36 +01:00
Lilian
13a25c78ff
[frontend/css] Various style improvements 2025-02-09 14:30:50 +01:00
Lilian
e10cdee478
[frontend/components] Clarify description for private mode 2025-02-09 13:23:39 +01:00
Lilian
5517e7ea66
[frontend/css] Make accent colors less bright 2025-02-08 14:04:46 +01:00
Lilian
31a8492d51
[frontend/components] Restyle sidebar and add profile picture 2025-02-08 14:04:28 +01:00
Laura Hausmann
e1c5a99ab3
[backend/api] Fix remote emoji lookup pagination 2025-02-07 17:14:48 +01:00
pancakes
a16aca11d2
[frontend/pages] Let emoji search display in empty and error states 2025-02-07 09:20:41 +10:00
Lilian
12b22bcf56
[frontend/components] Tie Polls into NoteStore 2025-02-06 22:41:50 +01:00
pancakes
60a38bd768
[backend/api] Batch poll votes query when rendering note poll 2025-02-06 22:41:50 +01:00
pancakes
66f3b23e46
[backend/razor] Add polls to public preview notes 2025-02-06 22:41:50 +01:00
pancakes
5f99f1d055
[frontend/components] Disable vote button if no option is selected 2025-02-06 22:41:50 +01:00
pancakes
b8fa9c952a
[frontend/components] Fix vote button and add disabled button style 2025-02-06 22:41:50 +01:00
pancakes
d9eef2c791
[frontend/components] Floor vote percentages 2025-02-06 22:41:50 +01:00
pancakes
9dd04f3035
[frontend/components] Fix vote percentage calculations 2025-02-06 22:41:50 +01:00
pancakes
e091eb01e9
[frontend/components] Don't allow repeated multiple choice votes 2025-02-06 22:41:50 +01:00
pancakes
6c31197bbc
[backend/api] Don't allow repeated multiple choice votes 2025-02-06 22:41:50 +01:00
pancakes
288c549f20
[frontend/components] Add polls to notes and allow voting 2025-02-06 22:41:50 +01:00
pancakes
70e0d45c12
[backend/api] Set minimum poll duration to 5 minutes 2025-02-06 22:41:14 +01:00
pancakes
013f028684
[backend/api] Add endpoint for voting on polls 2025-02-06 22:41:14 +01:00
pancakes
33c850fb2b
[backend/api] Fix crash when rendering notes with no poll 2025-02-06 22:41:14 +01:00
pancakes
735bdfe09a
[backend/api] Add polls to CreateNote 2025-02-06 22:41:14 +01:00
pancakes
189938af13
[backend/api] Render note polls in Web API 2025-02-06 22:41:14 +01:00
Lilian
d33238afee
[frontend/components] Make main layout separators thinner 2025-02-06 22:39:13 +01:00
Lilian
20a4282690
[frontend/css] Default theme changes 2025-02-06 22:38:41 +01:00
Lilian
310bfe2d3d
[frontend] Gracefully handle virtual scroller content shrinking on re-display 2025-02-06 21:52:36 +01:00
Laura Hausmann
e5c2edf8cb
[backend/api] Fix unpaginated remote emoji lookup 2025-02-06 19:31:08 +01:00
Lilian
014f35af6c
[frontend/components] Fix virtual scroller jumping 2025-02-06 17:32:42 +01:00
pancakes
a5c57ab77c
[frontend/components] Vertically center emoji management entries 2025-02-06 16:13:35 +01:00
pancakes
d0eb54b903
[frontend/components] Enable emoji hover and space between aliases 2025-02-06 16:13:35 +01:00
pancakes
e0e8fe8b72
[frontend/pages] Add upload and import actions to emoji management page 2025-02-06 16:13:34 +01:00
pancakes
fd5ced1c59
[frontend/pages] Add loading spinner to emoji management page 2025-02-06 16:13:34 +01:00
pancakes
9ee200c50e
[frontend/components] Use global dialogs for emoji management actions 2025-02-06 16:13:34 +01:00
pancakes
0951728f0a
[frontend/components] Add menu and actions to emoji management entries 2025-02-06 16:13:34 +01:00
pancakes
a9378fbdae
[frontend/pages] Move emoji entry into a component 2025-02-06 16:13:34 +01:00
pancakes
fdca264241
[frontend/pages] Add page for managing local and remote custom emojis 2025-02-06 16:13:34 +01:00
pancakes
91e0f09bc5
[backend/api] Add remote emoji list endpoint for moderators 2025-02-06 16:13:34 +01:00
pancakes
9abee2955e
[frontend/pages] Add mod dashboard skeleton 2025-02-06 16:13:34 +01:00
pancakes
2d42ab251c
[frontend] Add moderator role to CustomAuthStateProvider 2025-02-06 16:13:33 +01:00
Laura Hausmann
b9724e29fb
[backend/masto-client] Fix concurrent DbContext use in UserRenderer 2025-02-06 14:37:45 +01:00
pancakes
685cb7d0b9
[frontend/components] Add mobile styling to menu and emoji picker 2025-02-06 13:54:22 +01:00
pancakes
78078cac9b
[frontend/components] Fix Menu positioning inside Compose 2025-02-06 13:25:39 +01:00
pancakes
69435bfab3
[frontend/components] Use global dialogs for compose attachments 2025-02-06 13:25:39 +01:00
pancakes
5b871ea13f
[frontend/components] Add attachment view to compose 2025-02-06 13:25:38 +01:00
pancakes
26eab64e8c
[backend/api] Include avatar and banner alt text in user renderer 2025-02-06 13:09:49 +01:00
pancakes
36c6092fd2
Revert "[backend/api] Add missing inclusions of user avatar and banner"
This reverts commit b4084b8e2222d0935fb536feac78d5ed31dc6fa5.
2025-02-06 13:09:49 +01:00
pancakes
82a2331ae0
[backend/masto-client] Add avatar_description and header_description to AccountEntity 2025-02-06 13:09:49 +01:00
pancakes
6f69ae6ffb
[backend/api] Add missing inclusions of user avatar and banner 2025-02-06 13:09:48 +01:00
pancakes
2262830a2e
[backend/federation] Remove namespace from pronouns in LD context 2025-02-05 18:10:42 +01:00
pancakes
d2e1f186e8
[backend/federation] Don't use a constant for pancakes:pronouns 2025-02-05 18:10:42 +01:00
pancakes
5d938c5f47
[backend/api] Use [] instead of new Dictionary 2025-02-05 18:10:41 +01:00
pancakes
dd834128e5
[shared] Remove MaxLength from UserProfileEntity.Pronouns 2025-02-05 18:10:41 +01:00
pancakes
dcc03c3eb6
[backend] Remove unused imports 2025-02-05 18:10:41 +01:00
pancakes
98611179ea
[backend/database] Recreate Pronouns column in UserProfile with jsonb 2025-02-05 18:10:41 +01:00
pancakes
664fb73ebb
Revert "[backend/database] Create Pronouns column in UserProfile"
This reverts commit 5d51131bb5f6e917d847d63be65272be53e53ce8.
2025-02-05 18:10:41 +01:00
pancakes
a3ad8e5991
[backend/razor] Display pronouns in UserPreview 2025-02-05 18:10:41 +01:00
pancakes
997ab9558e
[backend/csproj] Disable PredefinedCulturesOnly 2025-02-05 18:10:41 +01:00
pancakes
c095c9bcdc
[frontend/pages] Switch from pancakes:Pronouns (V1) to pancakes:pronouns (V2) 2025-02-05 18:10:41 +01:00
pancakes
480dbc2835
[backend/api] Switch from pancakes:Pronouns (V1) to pancakes:pronouns (V2) 2025-02-05 18:10:00 +01:00
pancakes
df9d3388cf
[backend/federation] Switch from pancakes:Pronouns (V1) to pancakes:pronouns (V2) 2025-02-05 18:10:00 +01:00
pancakes
2b25affa2b
[backend/federation] Use LDLocalizedString for pancakes:Pronouns 2025-02-05 18:09:59 +01:00
pancakes
9e713419ce
[backend/federation] Add LDLocalizedString 2025-02-05 18:09:59 +01:00
pancakes
3bfb133661
[backend/api] Render remote language-mapped pronouns 2025-02-05 18:09:59 +01:00
pancakes
a27280885c
[frontend/components] Add pronouns to profile badges and fields 2025-02-05 18:09:59 +01:00
pancakes
a506854ba4
[frontend/pages] Add pronouns setting to profile settings 2025-02-05 18:09:59 +01:00
pancakes
f16164ca98
[backend/api] Add pronouns to profile responses and update profile request 2025-02-05 18:09:59 +01:00
pancakes
2aa6eb608b
[backend/federation] Federate user pronouns 2025-02-05 18:09:59 +01:00
pancakes
6001ff960a
[backend/database] Create Pronouns column in UserProfile 2025-02-05 18:09:59 +01:00
pancakes
f270b90fe9
[frontend/components] Display a spinner while dialogs perform their actions 2025-02-05 18:07:04 +01:00
pancakes
69db0cfe99
[frontend/components] Set cursor pointer for non-image sensitive media 2025-02-05 18:04:30 +01:00
pancakes
fb8be046e8
[frontend/components] Use object-fit cover for blurred image attachments 2025-02-05 18:04:29 +01:00
pancakes
81fc32cfe1
[frontend/components] Use a placeholder element for "blurred" sensitive media 2025-02-05 18:04:29 +01:00
pancakes
0e4fc7e3bb
[frontend/pages] Use consistent icon for uploading 2025-02-05 18:03:27 +01:00
pancakes
4f9c3dc3c7
Revert "[backend/api] Add conflict check to UpdateFileParent"
This reverts commit ccf7a0f757d4968f11e32ba8606bfed4e44df7c9.
2025-02-05 18:03:27 +01:00
pancakes
16a0a76528
[backend/api] Fix edge case where GetAvatar and GetBanner can be both IsAvatar and IsBanner 2025-02-05 18:03:27 +01:00
pancakes
6322d5fb91
[backend/api] Add conflict check to UpdateFileParent 2025-02-05 18:03:27 +01:00
pancakes
957e84eab7
[frontend/components] Use correct callback for RenameFolder 2025-02-05 18:03:27 +01:00
pancakes
b5788ccaf4
[frontend/pages] Use global dialogs for drive folder actions 2025-02-05 18:03:27 +01:00
pancakes
5a8973b4a4
[frontend/components] Use global dialogs for drive entry actions 2025-02-05 18:03:26 +01:00
pancakes
87b686e5cb
[backend/api] Set appropriate values for IsAvatar and IsBanner in GetAvatar and GetBanner 2025-02-05 18:03:26 +01:00
pancakes
bc7745c9fc
[backend/api] Clone files that exist in different folders owned by the same user instead of creating copies 2025-02-05 18:03:26 +01:00
pancakes
59c26686e4
[backend/api] Use SuppressMessage attribute instead of pragma warning disable 2025-02-05 18:03:26 +01:00
pancakes
805393b3e5
[backend/api] Fix drive folder endpoint 2025-02-05 18:03:26 +01:00
pancakes
85e24e03f1
[backend/api] Add conflict check when creating folders 2025-02-05 18:03:26 +01:00
pancakes
72800a68e8
[backend/api] Improve DriveFolder existing check 2025-02-05 18:03:26 +01:00
pancakes
d8192bb37d
[frontend/components] Update menu position calculation and fix DriveEntry styling 2025-02-05 18:03:26 +01:00
pancakes
637e17be14
[frontend/components] Add move folder/file button 2025-02-05 18:03:26 +01:00
pancakes
d6329b7783
[frontend/pages] Add uploaded files to top of file list 2025-02-05 18:03:25 +01:00
pancakes
8b54228d44
[backend/api] Order DriveFiles in folder by CreatedAt descending and DriveFolders by Name ascending 2025-02-05 18:03:25 +01:00
pancakes
038637339e
[backend/api] Add endpoint to move DriveFile and change move DriveFolder to POST 2025-02-05 18:03:25 +01:00
pancakes
da6d080fa2
[backend/api] Use FromHybrid instead of query for UpdateFolder 2025-02-05 18:03:25 +01:00
pancakes
d3ee46634a
[frontend/components] Label files that are the user's avatar or banner 2025-02-05 18:03:25 +01:00
pancakes
b6047c9dc1
[backend/api] Add IsAvatar and IsBanner to DriveFile 2025-02-05 18:03:25 +01:00
pancakes
877d195fd7
[frontend/components] Show an alert instead of crashing if renaming causes a conflict 2025-02-05 18:03:25 +01:00
pancakes
1ee73e3648
[frontend/components] Don't show null if setting alt text on a file with no alt text 2025-02-05 18:03:25 +01:00
pancakes
d99e354651
[frontend/pages] Add button to create drive folder 2025-02-05 18:03:25 +01:00
pancakes
4d55bd56ac
[frontend/pages] Add button to delete folder if it is empty 2025-02-05 18:03:24 +01:00
pancakes
42da7e5f43
[backend/api] Add endpoint to delete non-empty folders 2025-02-05 18:03:24 +01:00
pancakes
c973db1c65
[frontend/components] Display alt text when hovering file alt text label 2025-02-05 18:03:24 +01:00
pancakes
f59bb86e24
[frontend/components] Move non-destructive drive file actions above destructive ones 2025-02-05 18:03:24 +01:00
pancakes
85ba75e608
[frontend/components] Add drive folder menu 2025-02-05 18:03:24 +01:00
pancakes
f8c36f1097
[frontend/components] Add drive file menu 2025-02-05 18:03:24 +01:00
pancakes
d0477743e6
[backend/api] Add endpoints for renaming and moving drive folders 2025-02-05 18:03:24 +01:00
pancakes
0dfe788f7f
[backend/pages] Allow uploading files to the selected folder instead of just the root folder 2025-02-05 18:03:24 +01:00
pancakes
078f344a26
[backend/services] Only check for duplicate files in the same folder if specified 2025-02-05 18:03:24 +01:00
pancakes
98686e334e
[backend/api] Allow optionally storing a drive file in a specified folder 2025-02-05 18:03:23 +01:00
pancakes
673e5c88bc
[frontend/pages] Add drive file upload 2025-02-05 18:03:23 +01:00
pancakes
afb766f51c
[frontend] Add action buttons styling to top bar 2025-02-05 18:03:23 +01:00
pancakes
ff3817efb7
[frontend/components] Fix drive entry hover appearance 2025-02-05 18:03:23 +01:00
pancakes
1af0231126
[frontend] Add folder navigation 2025-02-05 18:03:23 +01:00
pancakes
793efd9ab6
[frontend] Move drive entries into DriveEntry component 2025-02-05 18:03:23 +01:00
pancakes
0947fd4ac8
[frontend/pages] Add labels to drive files 2025-02-05 18:03:23 +01:00
pancakes
2f18a3e75c
[frontend/pages] Improve state management, use authorization and include alt text 2025-02-05 18:03:23 +01:00
pancakes
f3fe34e051
[frontend/pages] Simple display of drive folder contents 2025-02-05 18:03:23 +01:00
pancakes
e3608a2bf9
[frontend/pages] Add page boilerplate for drive management 2025-02-05 18:03:22 +01:00
pancakes
59e1cdde81
[backend/api] Add endpoint for creating drive folders 2025-02-05 18:03:22 +01:00
pancakes
a2e3c475d0
[backend/api] Correctly return file ID for drive folder files 2025-02-05 18:03:22 +01:00
pancakes
76054927f7
[backend/api] Add endpoints to get root drive folder and drive folder by ID 2025-02-05 18:03:22 +01:00
Lilian
cca978a400
[frontend/core] Remove deprecated MessageService 2025-02-04 20:12:49 +01:00
Lilian
e1e78a1052
[frontend/components] Fix new descendants not showing up in single note view. 2025-02-04 20:12:49 +01:00
Laura Hausmann
60f100385d
[backend/drive] Fix explicit identicon references 2025-02-04 16:38:28 +01:00
pancakes
220d65fa3d
[backend/api] Remove unnecessary database query 2025-02-04 13:39:04 +01:00
pancakes
9ac80f3945
[backend/api] Recursively reload user after refetching 2025-02-04 13:39:04 +01:00
pancakes
72a6d83351
[frontend/pages] Add refetch user action to profile page 2025-02-04 13:39:04 +01:00
pancakes
4ceeebc40d
[backend/api] Add refetch user endpoint 2025-02-04 13:39:03 +01:00
pancakes
c6ac156b34
[backend/services] Make verification page size limit more readable 2025-02-04 13:02:44 +01:00
pancakes
01799a84cd
[backend/services] Ignore pages over 1MB when verifying links 2025-02-04 13:02:44 +01:00
pancakes
4fe3fe8d2c
[backend/services] Move profile field verification into background-task queue 2025-02-04 13:02:44 +01:00
pancakes
9d0cb44743
[backend/services] Also check user URI for local users when verifying links 2025-02-04 13:02:44 +01:00
pancakes
aa3262caea
[backend/services] Check for profile URL and user URI when verifying links 2025-02-04 13:02:44 +01:00
pancakes
fc07f55fe3
[frontend/components] Fix spacing of verified fields 2025-02-04 13:02:43 +01:00
pancakes
858a7724a2
[backend/services] Verify user profile field links 2025-02-04 13:02:43 +01:00
Kopper
7595f257f8 [backend/masto-client] Use List<> instead of HashSet<> for batch endpoints
CustomCollectionModelBinder's `as IList` cast does not work for HashSet,
breaking the code around trailing []s in query arguments.

The values are still being treated as a set in the query so adding the
same value multiple times shouldn't do anything except hit the max limit
quicker.

The intention was to aid clients which may not be doing their own
deduplication but I haven't really observed that behavior in the wild
and I doubt it's anything too much to expect from clients.

That said, testing on mastodon.social shows Mastodon itself may be doing
deduplication here before checking the limit, though I'm not entirely sure
if this will ever be noticed in the wild.
2025-02-04 10:19:24 +03:00
Laura Hausmann
ab0b1e543a
[backend/csproj] Exclude wwwroot/assets/LICENSE.md from build 2025-02-02 22:59:57 +01:00
Laura Hausmann
bed17be21f
[backend/assets] Improve LICENSE.md 2025-02-02 22:56:34 +01:00
Laura Hausmann
44ab441c3d
[backend/assets] Clarify license 2025-02-02 22:55:49 +01:00
Lilian
75f14d0302
[frontend/components] Improve streaming status indicator 2025-02-02 22:28:05 +01:00
Lilian
ed627474a3
[frontend] Unbreak gesture based navigation 2025-02-02 15:06:48 +01:00
Lilian
9e43594019
[frontend] Improve overscroll behavior 2025-02-02 14:23:15 +01:00
Lilian
a374c5e8c2
[frontend/components] Make cw button text change on state 2025-02-02 14:11:48 +01:00
Lilian
0aff68b398
[frontend/core] Fix avatar and banner response nullability 2025-02-01 22:06:21 +01:00
Lilian
56b602d3bf
[frontend/components] Fix inconsistent height of virtual scroller load buttons 2025-02-01 21:45:10 +01:00
Lilian
1823496298
[frontend/components] Allow toggling of all content warnings in single note view 2025-02-01 19:46:38 +01:00
Laura Hausmann
be1e393140
[sln] Update dependencies 2025-02-01 13:18:02 +01:00
Lilian
1daaf83f88
[frontend/components] Prevent overly long instance names from overflowing 2025-01-31 22:12:03 +01:00
Lilian
17561f8401
[frontend/css] Change notice colour 2025-01-31 21:39:34 +01:00
Lilian
e86c7f3663
[frontend/components] Highlight footer buttons on hover 2025-01-31 21:39:17 +01:00
Lilian
c4cc73a601
[frontend/components] Include character count in content warnings 2025-01-31 21:19:29 +01:00
Lilian
5205836846
[frontend/components] Fix display of overlength notes with content warnings 2025-01-31 21:11:05 +01:00
Laura Hausmann
8ff9aea33a
[sln] Code cleanup 2025-01-31 16:58:57 +01:00
Laura Hausmann
045ce709aa
[backend] Switch to Iceshrimp.EntityFrameworkCore.Extensions 2025-01-31 16:56:11 +01:00
pancakes
db04e6dadf
[frontend/components] Fix dialog button styling 2025-01-30 11:12:55 +01:00
pancakes
b4f51ec561
[frontend/components] Set default value for prompt dialog 2025-01-30 11:12:55 +01:00
pancakes
e6cf2f897d
[frontend/components] Allow scrolling really long dialogs 2025-01-30 11:12:55 +01:00
pancakes
af8cb0611b
[frontend/components] Optionally allow prompts to be empty 2025-01-30 11:12:55 +01:00
pancakes
9a7fbc8027
[frontend/components] Use global dialogs in note actions 2025-01-30 11:12:55 +01:00
pancakes
fd4fd72b30
[frontend/pages] Use global dialogs in profile page 2025-01-30 11:12:54 +01:00
pancakes
476ba83106
[frontend/components] Add SelectDialog 2025-01-30 11:12:54 +01:00
pancakes
b966f7dd2e
[frontend/components] Add PromptDialog 2025-01-30 11:12:54 +01:00
pancakes
66b8108c8f
[frontend/components] Add NoticeDialog 2025-01-30 11:12:54 +01:00
pancakes
54a6b8d2ba
[frontend/components] Add ConfirmDialog 2025-01-30 11:12:54 +01:00
pancakes
e0f3bf0b39
[frontend/components] Remove avatar self-repairing 2025-01-30 11:11:23 +01:00
pancakes
24e7624487
[backend/api] Fix profile avatar and banner endpoints 2025-01-30 11:11:23 +01:00
pancakes
dff87e9db5
[backend/api] Move Avatar and Banner queries onto single lines 2025-01-30 11:11:23 +01:00
pancakes
78550aa878
[backend/api] Simplify Avatar and Banner queries 2025-01-30 11:11:23 +01:00
pancakes
fa7c09c94b
[backend] Fix outgoing alt text federation 2025-01-30 11:11:23 +01:00
pancakes
d5b2ec15b8
[frontend/pages] Use UserProfileEntity.AvatarAlt and .BannerAlt 2025-01-30 11:11:23 +01:00
pancakes
58d10228e9
[backend/api] Move newAvatarAlt and newBannerAlt into UserProfileEntity 2025-01-30 11:11:23 +01:00
pancakes
811c5d7a46
[backend/api] Simplify queries for getting avatar and banner when updating profile 2025-01-30 11:11:23 +01:00
pancakes
f40c0e2499
[backend/api] Simplify GetAvatar and GetBanner database queries 2025-01-30 11:11:22 +01:00
pancakes
b9cb72475d
[backend/api] Remove debug prints 2025-01-30 11:11:22 +01:00
pancakes
b976bd9489
[frontend/pages] Allow setting alt text for new or existing avatar and banner 2025-01-30 11:11:22 +01:00
pancakes
d4efbfd2ce
[backend/api] Allow optionally setting avatar and banner alt text 2025-01-30 11:11:22 +01:00
pancakes
beef9f7503
[frontend/pages] Use DriveFileResponse for avatar and banner 2025-01-30 11:11:22 +01:00
pancakes
cd412e3056
[backend/api] Return DriveFileResponse for avatar and banner 2025-01-30 11:11:22 +01:00
pancakes
95c0a3e9ff
[frontend/pages] Display alt text on banner and add title for banner 2025-01-30 11:11:22 +01:00
pancakes
dab05d8e71
[frontend/components] Merge all avatars into UserAvatar component 2025-01-30 11:11:22 +01:00
pancakes
4d39f657b0
[backend/api] Include AvatarAlt and BannerAlt in UserResponse 2025-01-30 11:11:22 +01:00
pancakes
a5fedc930c
[backend/federation] Include media type for avatar and banner in UserRenderer 2025-01-30 11:11:22 +01:00
pancakes
68ceecd8f1
[backend/services] Use ASImage.Description to set DriveFile.Comment for resolved users' avatar and banner 2025-01-30 11:11:21 +01:00
pancakes
fdadc41c61
[backend/federation] Include alt text for avatar and banner in UserRenderer 2025-01-30 11:11:21 +01:00
pancakes
207249280f
[backend/razor] Update table class and include action separators 2025-01-30 11:09:27 +01:00
pancakes
c406ba999c
[frontend/pages] Display rules if they are set before allowing registration 2025-01-30 11:09:27 +01:00
pancakes
a2ff2484a2
[backend/razor] Display rules on instance about page 2025-01-30 11:09:27 +01:00
pancakes
fc27572e38
[backend/razor] Add button to remove rule 2025-01-30 11:09:26 +01:00
pancakes
d165af041c
[backend/razor] Add edit rule page 2025-01-30 11:09:26 +01:00
pancakes
513635b7dc
[backend/razor] Add rules to admin dashboard 2025-01-30 11:09:26 +01:00
pancakes
b962fafdf8
[backend/api] Move rule create/update functionality to instance service 2025-01-30 11:09:26 +01:00
pancakes
d3b8df77ce
[backend/api] Change rule management auth from role:moderator to role:admin 2025-01-30 11:09:26 +01:00
pancakes
464008b2e1
[backend/masto-client] Add rules endpoint and include rules in InstanceV1 and Instance2 2025-01-30 11:09:26 +01:00
pancakes
76bb3f1c95
[backend/api] Add instance rules endpoints 2025-01-30 11:09:26 +01:00
pancakes
f35dc27f20
[backend/database] Add Rule table 2025-01-30 11:09:25 +01:00
Laura Hausmann
ffbcccb79c
[backend/drive] Ignore deletion failures during fixup-media 2025-01-27 16:09:00 +01:00
Laura Hausmann
1d64af7ed0
[backend/federation] Fix like activities with content not being processed as reactions 2025-01-27 15:57:54 +01:00
Laura Hausmann
848bfc0045
[backend/csproj] Bump dotNetRdf version 2025-01-27 14:24:34 +01:00
Laura Hausmann
9c9427de89
[backend/csproj] Update Iceshrimp.Utils.Common version 2025-01-21 22:24:38 +01:00
Laura Hausmann
bdfd3a8d4e
[backend/database] Fix emoji unique index, update minimum postgres version to v15 2025-01-21 22:03:56 +01:00
Laura Hausmann
013ed7380f
[backend/masto-client] Don't require authentication for /accounts/{id}/statuses (ISH-688) 2025-01-21 21:31:38 +01:00
Laura Hausmann
f32cd81ada
[backend/masto-client] Reformat AccountController 2025-01-21 21:30:59 +01:00
Laura Hausmann
ac2527aa75
[backend/federation] Transfer blocks on migration (ISH-563) 2025-01-21 21:22:31 +01:00
Laura Hausmann
b79d2213be
Update Iceshrimp.Utils.Common version 2025-01-20 18:42:29 +01:00
Kopper
02d4b35ab1
[backend/masto-client] Limit batch endpoints 2025-01-20 18:21:21 +01:00
Kopper
7e3320e3b1
[backend/masto-client] Populate Status.tags 2025-01-20 18:21:20 +01:00
Kopper
ad00ddd14e
[backend/masto-client] Implement /api/v1/statuses (View multiple statuses) 2025-01-20 18:21:20 +01:00
Kopper
f6957ba4de
[backend/masto-client] Implement /api/v1/accounts (Get multiple accounts) 2025-01-20 18:21:20 +01:00
Laura Hausmann
905b7d173a
[backend/core] Switch to Iceshrimp.Utils.Common for Result<T> 2025-01-20 17:00:03 +01:00
Laura Hausmann
77936b710d
[backend/federation] Fix future publishedAt/updatedAt DateTime handling 2025-01-20 11:18:38 +01:00
Lilian
57645f017d
[frontend/components] Add streaming status display 2025-01-19 22:53:25 +01:00
Lilian
56325b356c
[frontend] Implement timeline streaming 2025-01-19 22:53:25 +01:00
Lilian
213801ed4b
[frontend/components] Fix button positioning on virtual scroller 2025-01-19 22:53:25 +01:00
Lilian
a3c04d397d
[frontend/components] Rename NewVirtualScroller to VirtualScroller 2025-01-19 22:53:25 +01:00
Lilian
b3ec870975
[frontend] Remove old Virtual Scroller and associated code 2025-01-19 22:53:25 +01:00
Lilian
790e120545
[frontend] Code cleanup 2025-01-19 22:53:25 +01:00
Lilian
4b8bc540f8
[frontend/core] Fix sorting of ascendant notes 2025-01-19 22:53:24 +01:00
Lilian
122bba5b55
[frontend/core] Recursively update descendants 2025-01-19 22:53:24 +01:00
Lilian
45e0e058c9
[frontend] New Virtual Scroller and Note Store 2025-01-19 22:53:24 +01:00
Lilian
beb0044d7c
[frontend/components] Prevent sending note multiple times in compose 2025-01-19 22:47:53 +01:00
Laura Hausmann
1bb199bf59
[backend/federation] Make publish/update date checks stricter (ISH-687) 2025-01-19 09:59:43 +01:00
Laura Hausmann
cd76e82a1e
[backend/libmfm] Set correct class for hashtag nodes in MfmConverter 2025-01-16 19:15:54 +01:00
Laura Hausmann
8d772fe16f
[tests] Switch to in-house assertions library 2025-01-15 14:05:21 +01:00
Laura Hausmann
49a9b4de2b
[tests] Lock FluentAssertions version to 7.0.0 due to v8 not being free software 2025-01-15 13:06:29 +01:00
Lilian
8c42644b20
[frontend/components] Allow submitting post with ctrl/cmd + enter 2025-01-14 18:22:25 +01:00
pancakes
9551e34e58
[frontend/components] Increase emoji picker z-index 2025-01-13 09:31:29 +01:00
Laura Hausmann
b714db5eb9
[backend] Code cleanup 2025-01-13 09:27:43 +01:00
Laura Hausmann
2a06240f2f
[backend/cron] Automatically update timeline heuristic for recently active users 2025-01-13 09:27:24 +01:00
pancakes
c68fa153f0
[frontend/components] Fix compose visibility dropdown z-index 2025-01-13 00:48:11 +10:00
pancakes
fbde18d130
[frontend/components] Stop compose inputs and preview going off screen 2025-01-12 22:36:29 +10:00
pancakes
02c9262175
[frontend/components] Allow compose footer to wrap 2025-01-12 22:25:38 +10:00
pancakes
3b7fb93b3e
[frontend/components] Fix reply/quote style in compose 2025-01-12 22:17:41 +10:00
pancakes
ec0e23a1f7
[frontend/components] Fix compose going off screen and add mobile styling 2025-01-12 22:14:13 +10:00
Laura Hausmann
9e37942e9d
[backend/federation] Improve hashtag handling (ISH-651) 2025-01-12 12:27:34 +01:00
Laura Hausmann
09919bdc77
[backend/core] Add support for HTTP proxy authentication 2025-01-12 08:34:07 +01:00
Laura Hausmann
ea7bcfa652
[backend/core] Add HTTP proxy support 2025-01-12 08:26:22 +01:00
pancakes
2811d3bded
[frontend/components] Fix newlines in MFM preview 2025-01-11 15:33:16 +01:00
pancakes
f0ea307334
[frontend/components] Add character count to compose and disable posting notes that are too long 2025-01-11 15:33:15 +01:00
pancakes
b6d8f9c00b
[backend/api] Add note length limit to instance response 2025-01-11 15:33:15 +01:00
pancakes
a92ddee5f1
[frontend/components] Move note preview below compose footer 2025-01-11 15:33:15 +01:00
pancakes
f93690dab1
[frontend/components] Add note preview 2025-01-11 15:33:15 +01:00
pancakes
d0eac29552
[frontend/pages] Don't show copy remote link button if URL is null 2025-01-11 05:16:01 +01:00
pancakes
1b032a6cd9
[frontend/pages] Don't show open original page button if URL is null 2025-01-11 05:16:01 +01:00
pancakes
35f391b042
[frontend/pages] Fix profile URL null check 2025-01-11 05:16:01 +01:00
pancakes
f8ec2fd163
[backend/api] Fix UserProfileResponse.Url 2025-01-11 05:16:00 +01:00
Laura Hausmann
86e941c111
[docs] Update README.md 2025-01-10 12:42:00 +01:00
Laura Hausmann
5d7020faeb
[backend/drive] Don't proxy requests for local emoji 2025-01-10 10:24:54 +01:00
Lilian
6b17623cfd
[backend] Add font "Inter" 2025-01-10 09:25:27 +01:00
Laura Hausmann
19d9f4b719
[backend/libmfm] Don't parse 0-content-length markup node sequences as markup nodes 2025-01-10 09:11:18 +01:00
Laura Hausmann
1d418b1c0c
[backend/core] Fix negated search parameters when match:words is used 2025-01-10 07:55:40 +01:00
Laura Hausmann
77fef1e63e
[backend/core] Revert: "Fix negated search parameters when match:words is used"
This reverts commit 37fc124b08, as it caused search to not use the FTS index.
2025-01-10 07:31:26 +01:00
Laura Hausmann
37fc124b08
[backend/core] Fix negated search parameters when match:words is used 2025-01-10 07:04:57 +01:00
Laura Hausmann
af750a010d
[backend/drive] Improve handling of configurations with disabled media proxy 2025-01-10 06:23:54 +01:00
a549a04801
[backend/razor] Fix navigation between remote user pages 2025-01-09 09:54:08 -05:00
pancakes
afce3552e3
[frontend/components] Refactor timestamps 2025-01-09 23:45:23 +10:00
Laura Hausmann
26bb26c0da
[backend/startup] Delete ASPNETCORE_TEMP writability test file on close 2025-01-09 13:17:26 +01:00
Laura Hausmann
c335a44327
[backend/drive] Don't use thumbnail proxy url when no thumbnail is available 2025-01-09 12:54:25 +01:00
Laura Hausmann
885aaad080
[backend/database] Fix typo 2025-01-09 12:44:42 +01:00
Laura Hausmann
8acb9bd932
[backend/federation] Federate processed images with the correct mime type 2025-01-09 12:40:54 +01:00
Laura Hausmann
113bd98b0e
[backend/drive] Proxy remote media by default 2025-01-09 12:36:34 +01:00
Laura Hausmann
241486a778
[backend/razor] Add remote user management & search to admin dashboard (ISH-582) 2025-01-09 09:28:44 +01:00
Laura Hausmann
ba0b8f76d4
[backend/razor] Fix NavBarLink not being highlighted when url matches but query doesn't 2025-01-09 09:08:35 +01:00
Laura Hausmann
9c4aa7a092
[backend/razor] Add search box to federation management admin dashboard page 2025-01-09 08:57:54 +01:00
Laura Hausmann
3bafb0ec7c
[backend/federation] Improve UserResolver uri/acct mismatch error message 2025-01-09 07:54:07 +01:00
Laura Hausmann
3611155786
[backend/core] Increase TOTP window size to +-1 2025-01-08 18:09:12 +01:00
Laura Hausmann
8d9856fd62
[parsing] Migrate SearchQuery parser to C# 2025-01-08 17:33:31 +01:00
Laura Hausmann
553c0cf7ab
[backend/api] Allow naming single emojis during upload (ISH-602) 2025-01-08 14:42:26 +01:00
Laura Hausmann
a8e8b67e0b
[backend/razor] Make generate invite button on admin dashboard accessible (ISH-600) 2025-01-08 12:42:17 +01:00
Laura Hausmann
873b48e2f8
[backend/core] Add CollapseWhitespace rewrite policy (ISH-625) 2025-01-08 12:16:22 +01:00
Laura Hausmann
62493dbe19
[backend/asp] Allow customizing the unix socket permissions that are set on startup (ISH-671) 2025-01-08 12:04:53 +01:00
Laura Hausmann
ce6784b4c4
[backend/libmfm] Ignore trailing question marks when parsing URLs 2025-01-08 11:53:24 +01:00
pancakes
1d9280d688
[frontend/components] Correctly display follow back button on follow requests 2025-01-08 01:06:34 +10:00
Laura Hausmann
0c18f50a1a
[backend/federation] Reject requests signed by suspended remote users in AuthorizedFetchMiddleware and InboxValidationMiddleware (ISH-639) 2025-01-07 13:01:33 +01:00
Laura Hausmann
e2b888a776
[backend/libmfm] Collapse duplicate leading newlines when parsing quote nodes, improve quote & code block parse performance, 2025-01-07 12:56:05 +01:00
Laura Hausmann
e5fc326531
[backend/masto-client] Adjust line breaks after blockquote when SupportsHtmlFormatting is false 2025-01-07 09:06:27 +01:00
Laura Hausmann
bacd9a9262
[backend/masto-client] Fix missing line break after blockquote when SupportsHtmlFormatting is false 2025-01-07 09:04:04 +01:00
Laura Hausmann
dc2ea392ae
[backend/startup] Check whether ASPNETCORE_TEMP is writable during startup 2025-01-07 08:41:53 +01:00
Laura Hausmann
0723f39797
[backend/api] Add mastodon session management endpoints 2025-01-07 08:32:42 +01:00
Laura Hausmann
76a3f94fe8
[backend/masto-client] Fix missing replyInaccessible marker 2025-01-07 08:29:15 +01:00
Laura Hausmann
9de6d15a0c
[backend/masto-client] Move replyInaccessible marker into CW field for notes with CW 2025-01-07 08:14:33 +01:00
Laura Hausmann
c21147c86f
[backend/federation] Bypass authorized fetch for relay actor (ISH-529) 2025-01-07 07:38:57 +01:00
Laura Hausmann
2fbff537a6
[backend/federation] Don't mark notes as edited during refetch (ISH-601) 2025-01-07 07:02:32 +01:00
Laura Hausmann
1ecd823cbe
[sln] Code cleanup 2025-01-07 06:38:23 +01:00
Laura Hausmann
74daf1ad47
[backend/federation] Fix sporadic LD signature validation errors (ISH-642) 2025-01-07 06:21:39 +01:00
Laura Hausmann
c3ca7d389d
[backend/federation] Use Uri struct comparison instead of string comparison when validating EnforceUri in UserResolver (ISH-679) 2025-01-07 06:13:07 +01:00
Kopper
66ee14b2a1 [backend/federation] Hide outbox if public preview is locked down 2025-01-07 02:05:14 +03:00
Kopper
ff320e43c9 [backend/federation] Expose outbox collection 2025-01-07 01:42:18 +03:00
Kopper
d965653972 [backend] Backfill users on incoming follow 2025-01-07 01:42:18 +03:00
Kopper
6b1d84801e [backend] Implement infrastructure for outbox backfill 2025-01-07 01:42:15 +03:00
Laura Hausmann
5ea44932d6
[mfmsharp] Allow uppercase ascii letters in fn descriptor and arg keys 2025-01-06 08:11:56 +01:00
Laura Hausmann
fd08aa1d00
[mfmsharp] Allow uppercase ascii letters in fn arg values 2025-01-06 08:08:21 +01:00
blueb
48717745de [backend] only use MetaService once and include admin contact email as well 2025-01-06 03:18:00 +01:00
blueb
b64a8898aa [backend] use MetaService to provide instance name and description in nodeinfo 2025-01-06 03:18:00 +01:00
pancakes
2b124f9e34 [frontend/mfm] Use double.TryParse instead of try/catch 2025-01-06 03:06:46 +01:00
pancakes
dd768242e1 [backend/libmfm] Use double.TryParse instead of try/catch 2025-01-06 03:06:46 +01:00
pancakes
29c5b995dd [backend/libmfm] Render dates in universal time and append " UTC" instead of the offset " +00:00" 2025-01-06 03:06:46 +01:00
pancakes
a67420508f [frontend/mfm] Display unixtime nodes in local time 2025-01-06 03:06:46 +01:00
pancakes
14cbfaed7c [backend/libmfm] Convert outgoing unixtime nodes to human readable date if possible 2025-01-06 03:06:46 +01:00
Laura Hausmann
dca1ba5ef3
[frontend] Bump MfmSharp version 2025-01-06 02:48:31 +01:00
Laura Hausmann
7ff49746c2
[backend/libmfm] Fix edge cases regarding mentions followed by special characters 2025-01-06 02:47:56 +01:00
Laura Hausmann
16bd8fb1f9
[backend/federation] Preload w3id/identity-v1 context definition 2025-01-05 10:16:57 +01:00
Tamara Schmitz
27206633bb
[backend/core] don't schedule reply backfill jobs for followers only posts when authenticated user backfill is disabled 2025-01-05 06:38:12 +01:00
pancakes
ecd2aae4b0
[frontend/pages] Update default note and renote visibility settings dropdowns 2025-01-05 06:28:18 +01:00
Laura Hausmann
729dfac6df
[backend] Fix build 2025-01-05 06:24:08 +01:00
Laura Hausmann
49c85543a0
[backend/federation] Make UserResolver fall back to building the acct from actor uri when it's not contained in WebFinger response 2025-01-05 06:22:43 +01:00
Laura Hausmann
0df2d49560
[backend/api] [frontend/pages] Remove specified as an option for default renote visibility 2025-01-05 05:34:20 +01:00
Laura Hausmann
75941c2a1a
[sln] Update MfmSharp version 2025-01-05 05:21:33 +01:00
Laura Hausmann
45b4792cf8
[backend/libmfm] Remove extraneous line breaks after quote blocks 2025-01-05 04:54:07 +01:00
Laura Hausmann
4d904bc673
[backend/federation] Fix compatibility with AP servers that send <br> without a trailing newline character 2025-01-05 04:32:50 +01:00
Laura Hausmann
729e32594b
[backend/federation] Set MkContent to empty string when quote without text is rendered 2025-01-05 04:26:31 +01:00
Laura Hausmann
4ed6e305b6
[sln] Update MfmSharp version 2025-01-05 04:03:48 +01:00
Laura Hausmann
0a680c1931
[sln] Update MfmSharp version 2025-01-05 03:51:56 +01:00
Laura Hausmann
64126208bb
[docs] Update README.md 2025-01-03 03:44:04 +01:00
pancakes
2af4d7df7e
[backend/api] Change emoji management auth from role:admin to role:moderator 2025-01-02 22:43:35 +01:00
Laura Hausmann
9a151be99d
[backend/api] Don't apply default request size limit to the batch emoji import endpoint 2025-01-02 21:46:35 +01:00
Laura Hausmann
b70de11da8
[backend/masto-client] Deduplicate filter matches by filter id 2025-01-01 02:12:01 +01:00
Laura Hausmann
f2c90fef85
[docs] Update README.md 2024-12-21 01:32:35 +01:00
Laura Hausmann
4e06d416a9
[backend/api] Resolve notes as authenticated user when doing AP lookups, don't redirect to inaccessible notes 2024-12-20 20:54:40 +01:00
Laura Hausmann
cf37567108
[backend/core] Use the fetched object id as the lock key in NoteService.ResolveNoteAsync 2024-12-19 21:22:25 +01:00
Laura Hausmann
fbb9165bec
[backend/libmfm] Deserialize HTML tags to MFM tag nodes instead of symbol nodes 2024-12-19 21:05:30 +01:00
Laura Hausmann
b15cc7c3e1
[backend/core] Fix runtime ArrayTypeMismatchException in UserProfileMentionsResolver 2024-12-19 20:51:17 +01:00
Lilian
f02c5ac2f1
[backend/api] Fix remote user profiles not loading 2024-12-18 02:07:10 +01:00
Laura Hausmann
6be761b2d3
[backend/federation] Fix account migration with remote instances that require alsoKnownAs to be an array 2024-12-18 01:59:47 +01:00
pancakes
8302be8c3a
[frontend/pages] Use date picker for Birthday and add Set Birthday checkbox to make it optional 2024-12-17 19:38:52 +01:00
pancakes
c84f0319ea
[frontend/pages] Make setting names more like Iceshrimp-js and make bio textarea larger 2024-12-17 19:38:52 +01:00
pancakes
b1d64b5866
[backend/api] Remove "Async" from function names 2024-12-17 19:38:52 +01:00
pancakes
0497b35ec3
Code cleanup 2024-12-17 19:38:52 +01:00
pancakes
63d700f01a
Code cleanup 2024-12-17 19:38:52 +01:00
pancakes
2f3a61378d
[frontend/api] Merge DisplayName into UserProfile 2024-12-17 19:38:52 +01:00
pancakes
3647d87ef6
[backend/api] Merge UpdateDisplayName into UpdateProfile 2024-12-17 19:38:52 +01:00
pancakes
7d111ef05c
[frontend/pages] Add placeholder for birthday profile setting 2024-12-17 19:38:51 +01:00
pancakes
31e9cf65af
[frontend/pages] Add emoji picker for bio profile setting 2024-12-17 19:38:51 +01:00
pancakes
cabfbdce0c
[frontend/pages] Add bot, cat, and speak as cat profile settings 2024-12-17 19:38:51 +01:00
pancakes
dad0fabc5b
[backend/api] Add IsBot, IsCat and SpeakAsCat profile settings 2024-12-17 19:38:51 +01:00
pancakes
6656fbac18
[frontend/pages] Add update avatar and banner to profile settings 2024-12-17 19:38:51 +01:00
pancakes
d56b7760df
[frontend/pages] Add setting to remove avatar and banner 2024-12-17 19:38:51 +01:00
pancakes
496f2f003a
[frontend/api] Add get and delete for avatar and banner 2024-12-17 19:38:51 +01:00
pancakes
93fd573ca6
[frontend/api] Make GetDisplayNameAsync not nullable 2024-12-17 19:38:51 +01:00
pancakes
482172b8e9
[backend/api] Return empty string instead of null when getting avatar, banner, or display name 2024-12-17 19:38:50 +01:00
pancakes
458bb301b3
[backend/api] Add endpoints to get avatar and banner URL 2024-12-17 19:38:50 +01:00
pancakes
95cd3d2d33
[frontend] Add display name to profile settings 2024-12-17 19:38:50 +01:00
pancakes
6605997e80
[backend/api] Add endpoints for getting and updating display name 2024-12-17 19:38:50 +01:00
pancakes
d35e5a7e3f
[backend/api] Add endpoints for updating and deleting user banner 2024-12-17 19:38:50 +01:00
pancakes
94af2fe9ac
[backend/api] Add endpoints for updating and deleting user avatar 2024-12-17 19:38:50 +01:00
pancakes
c16c0e761f
[frontend/pages] Improve more button styling 2024-12-17 19:38:00 +01:00
pancakes
c21d6e629e
[frontend/pages] Use NavigationManager to go to profile settings 2024-12-17 19:38:00 +01:00
pancakes
64affd20f6
[frontend/pages] Vertically center profile header contents and improve spacing when wrapped 2024-12-17 19:38:00 +01:00
pancakes
873cf1122a
[frontend/pages] Add context menu to user profile 2024-12-17 19:38:00 +01:00
pancakes
bc93246cea
[backend/api] Include Public URL in UserProfileResponse 2024-12-17 19:37:59 +01:00
pancakes
1d9f79046d
[frontend/components] Lazy load InlineEmoji custom emojis 2024-12-17 19:36:32 +01:00
pancakes
d14a544dcf
[frontend/components] Use InlineEmoji in emoji picker 2024-12-17 19:36:31 +01:00
pancakes
58238f03f0
[frontend/components] Use InlineEmoji for note reactions 2024-12-17 19:36:31 +01:00
pancakes
7b291fa142
[frontend/components] Style reaction notification with InlineEmoji params 2024-12-17 19:36:31 +01:00
pancakes
87801b8bcf
[frontend/components] Make InlineEmoji more flexible 2024-12-17 19:36:31 +01:00
pancakes
8484d10e61
[frontend/components] Use "font-color" color for reacted reactions for better contrast 2024-12-17 19:36:31 +01:00
pancakes
d8c5a43100
[frontend/components] Use InlineEmoji for reaction notifications 2024-12-17 19:36:31 +01:00
pancakes
8cb9f272e3
[frontend/components] Make InlineEmoji generic 2024-12-17 19:36:31 +01:00
pancakes
7e6fba61da
[frontend/components] Make inline emojis consistent with reaction emojis but with fixed width 2024-12-17 19:36:31 +01:00
pancakes
0a866bad75
[frontend/components] Display NoteReactionDetails reaction count the same as in note reactions 2024-12-17 19:36:31 +01:00
pancakes
39426da00e
[frontend/components] Reaction title, consistent custom emoji, fixed gaps, and translucent reacted background color 2024-12-17 19:36:30 +01:00
pancakes
7d2af70250
[frontend/components] Contain emojis, use pointer cursor, and make emojis easier to press on mobile in Emoji Picker 2024-12-17 19:36:30 +01:00
pancakes
8990ff08b1
[frontend/mfm] Make tada fn 150% size 2024-12-17 19:34:53 +01:00
pancakes
427c2c2109
[frontend/mfm] Fix sizes of x3 and x4 fn nodes 2024-12-17 19:34:53 +01:00
pancakes
27eb943066
[frontend/mfm] Add center and small fn nodes 2024-12-17 19:34:53 +01:00
pancakes
4a4544cf1d
[frontend/components] Use pointer cursor on blurred attachments and images 2024-12-17 19:34:08 +01:00
pancakes
a572569066
[frontend/components] Use IsNullOrWhiteSpace for Attachment Viewer 2024-12-17 19:34:08 +01:00
pancakes
465d0b4c8b
[frontend/components] Vertically align file attachments 2024-12-17 19:34:08 +01:00
pancakes
9499ee825b
[frontend/components] Improve file attachment appearance 2024-12-17 19:34:08 +01:00
pancakes
12f7d40fcc
[frontend/components] Only open image attachments in image viewer 2024-12-17 19:34:08 +01:00
pancakes
ce16fca697
[frontend/components] Use IsNullOrWhiteSpace to check if attachment has alt text 2024-12-17 19:34:08 +01:00
pancakes
c131efc7c8
[frontend/pages] Move register link to bottom and only show when registrations aren't closed 2024-12-17 19:32:59 +01:00
pancakes
47f8d94f88
[backend/razor] Revert Instance config and use snapshot of Security config 2024-12-17 19:32:59 +01:00
pancakes
c7498696b4
[frontend/pages] Add links between login and register pages 2024-12-17 19:32:59 +01:00
pancakes
ca567e4aba
[backend/razor] Show register link in Public Preview footer if registrations aren't closed 2024-12-17 19:32:58 +01:00
Laura Hausmann
49e9c6d825
[backend] Reformat DriveService.cs 2024-12-16 21:30:35 +01:00
Laura Hausmann
48e688e514
[backend/drive] Normalize empty / whitespace alt text to null 2024-12-16 21:30:26 +01:00
Laura Hausmann
ce646e84d9
[backend/core] Fix runtime ArrayTypeMismatchException in EmojiService 2024-12-15 01:09:06 +01:00
Laura Hausmann
b8ba778842
[backend/core] Improve inline media lookup performance 2024-12-15 01:05:56 +01:00
Laura Hausmann
f1f79a3435
[backend/api] Add session management endpoints 2024-12-15 01:03:31 +01:00
Laura Hausmann
3b89e0be9c
[sln] Bump MfmSharp version 2024-12-15 00:44:22 +01:00
Laura Hausmann
e8842d70a2
[backend/core] Fix runtime ArrayTypeMismatchException in NoteService 2024-12-15 00:36:19 +01:00
Laura Hausmann
010dd2bb96
[backend/core] Fix CW edits not being processed correctly, replace CW line endings during note ingest 2024-12-15 00:34:29 +01:00
Laura Hausmann
2453e0f673
[backend/database] Normalize note text & user bio line endings 2024-12-15 00:22:31 +01:00
Laura Hausmann
9865f3dde7
[backend/core] Replace MFM line endings during user/note ingestion
This saves significant amounts of time & memory during parsing.
2024-12-15 00:11:31 +01:00
Laura Hausmann
a6b5b4c69f
[backend/api] Return correct auth status for tokens in "2fa pending" state 2024-12-14 22:11:03 +01:00
Laura Hausmann
eab83fb500
[backend/api] Add logout endpoint (ISH-664) 2024-12-14 22:11:03 +01:00
Kopper
71cfa9e501
[backend/razor] Render inline media 2024-12-13 22:19:31 +01:00
Kopper
149fae3363
[backend] address review 2024-12-13 22:19:31 +01:00
Kopper
1e1364e2bb
[backend/masto-client] Add feature flag for inline media 2024-12-13 22:19:31 +01:00
Kopper
b6953236c3
[backend/masto-client] Fix edits breaking inline media 2024-12-13 22:19:31 +01:00
Kopper
89060599eb
[backend] Implement inline media
Inline media can be created by:

1. Attach media to note as usual
2. Copy media URL (public one, for remote instances)
3. Use the new $[media url ] MFM extension to place it wherever you
   wish. (The trailing space is necessary as the parser currently
   treats the closing ] as a part of the URL)

The Iceshrimp frontend may make this easier later on (by having a
"copy inline MFM" button on attachments, maybe?)

Federates as <img>, <video>, <audio>, or <a download> HTML tags
depending on the media type for interoperability. (<a download> is
not handled for incoming media yet).

The media will also be present in the attachments field, both as a
fallback for instance software that do not support inline media,
but also for MFM federation to discover which media it is allowed to
embed (and metadata like alt text and sensitive-ness). This is not
required for remote instances sending inline media, as it will be
extracted out from the HTML.

The Iceshrimp frontend does not render inline media yet. That is
blocked on #67.
2024-12-13 22:19:30 +01:00
Laura Hausmann
3edda1e70e
[backend/api] Document 200 response for /users/{id}/unfollow (ISH-662) 2024-12-13 22:04:49 +01:00
Laura Hausmann
f013520b84
[backend] Remove unused import 2024-12-12 00:41:24 +01:00
Laura Hausmann
df6a3a95ac
[sln] Update dependencies 2024-12-12 00:41:16 +01:00
Laura Hausmann
16235a901f
[sln] Update MfmSharp version 2024-12-12 00:40:03 +01:00
Laura Hausmann
7dc3143089
[backend/masto-client] Return attachment metadata when available (ISH-657) 2024-12-12 00:34:34 +01:00
Laura Hausmann
911480afcb
[backend] Remove unused imports 2024-12-11 19:58:52 +01:00
Laura Hausmann
4e2358ef2a
[sln] Update MfmSharp version 2024-12-11 00:04:38 +01:00
Laura Hausmann
8e0b5b1d2e
[sln] Update MfmSharp version 2024-12-10 04:21:03 +01:00
Laura Hausmann
c7d88d6417
[sln] Update MfmSharp version 2024-12-10 04:18:05 +01:00
Laura Hausmann
63e7281bb3
[sln] Update dependencies 2024-12-07 22:31:52 +01:00
Lilian
0ddb2a0d03
[frontend/pages] Fix profile view regex (ISH-655) 2024-12-07 21:50:57 +01:00
Laura Hausmann
c032f56d37
[frontend/mfm] Fix HTML rendering of link nodes 2024-12-07 20:27:43 +01:00
Laura Hausmann
d4a24be837
[backend/libmfm] Fix HTML rendering of link nodes 2024-12-07 20:27:36 +01:00
Laura Hausmann
5a6fbcef13
[backend/libmfm] Fix HTML rendering when text formatting is not supported 2024-12-07 20:20:45 +01:00
Laura Hausmann
e588dfaff2
[ci] Fix possible command injection vulnerabilities 2024-12-07 19:30:17 +01:00
Laura Hausmann
b160a97f0e
[backend/core] Fix ArrayTypeMismatchException in APMentionsResolver 2024-12-07 04:41:00 +01:00
Laura Hausmann
5dee8bc783
[parsing] Bump MfmSharp version 2024-12-07 04:41:00 +01:00
Laura Hausmann
6dc22d7316
[sln] Add more non-code files 2024-12-06 19:21:05 +01:00
Laura Hausmann
443f0682d2
[backend/core] Fix out of bounds array read in APMentionsResolver 2024-12-06 19:07:17 +01:00
Laura Hausmann
55943bacc6
[backend/streaming] Echo back specified WebSocket protocol for mastodon streaming connections when set (ISH-635) 2024-12-06 18:55:37 +01:00
Laura Hausmann
f4f7833f36
[tests] Enable greater degree of parallelization 2024-12-06 18:14:47 +01:00
Laura Hausmann
00e157cc7e
[parsing] Switch to MfmSharp 2024-12-06 18:14:00 +01:00
Laura Hausmann
eeb171fb5a
[shared] Make all response DTOs with string identifiers implement IIdentifiable 2024-12-05 00:50:49 +01:00
Laura Hausmann
7b61865287
[backend/libmfm] Fix HTML markup being dropped for federation & public preview requests 2024-12-03 23:33:47 +01:00
Lilian
3dabdc09e8
[frontend/components] Adjust AttachmentView button size 2024-11-28 22:31:08 +01:00
Lilian
1b50e86b1d
[frontend/components] Display alt text under images in AttachmentViewer (ISH-641) 2024-11-28 21:59:26 +01:00
Lilian
6edfc66c3e
[frontend/components] Keyboard navigation for AttachmentViewer (ISH-641) 2024-11-28 21:34:09 +01:00
Lilian
e70cb01c5b
[frontend/components] Fix off by one error in AttachmentView navigation 2024-11-28 21:33:07 +01:00
Lilian
8a7bcde0c3
[frontend/components] Scroll to attachment that was clicked on (ISH-641) 2024-11-28 21:11:52 +01:00
Lilian
35c4babd63
[frontend/components] Fix menu positioning 2024-11-28 17:55:14 +01:00
Lilian
1d0a297768
[frontend] Fix incorrect CSS colors 2024-11-28 17:36:09 +01:00
Lilian
ac2ebd346e
[frontend/pages] Implement user picker on login page (ISH-648) 2024-11-28 17:19:56 +01:00
Laura Hausmann
156a317adf
[docs] Update changelog 2024-11-27 23:02:14 +01:00
Laura Hausmann
c6408805d0
[sln] Update version to be in line with security hotfix release
This avoids "am I vulnerable to this?" confusion if running a :dev image or a -git package.
2024-11-27 22:08:46 +01:00
Laura Hausmann
112d6affd0
[parsing/mfm] Improve urlNode(Brackets) performance & reliability, improve performance of long inputs for some node types 2024-11-27 20:50:54 +01:00
Laura Hausmann
5a3d4b9bb4
[parsing/mfm] Improve aggregateText & aggregateTextInline performance 2024-11-27 05:46:25 +01:00
Laura Hausmann
bd56788542
[parsing/mfm] Optimize restOfLineContains case for single char, remove unnecessary pushLine/assertLine calls 2024-11-27 04:53:24 +01:00
Laura Hausmann
92c6e01988
[parsing/mfm] Only trigger recursion depth update once per recursion 2024-11-27 04:53:24 +01:00
Laura Hausmann
6d04870c50
[parsing/mfm] Reuse error messages 2024-11-27 04:53:24 +01:00
Laura Hausmann
59ab914f0a
[parsing/mfm] Further limit recursion depth to 20 2024-11-27 04:53:24 +01:00
Laura Hausmann
d6f4f5bd51
[backend/libmfm] Wrap MFM payloads that cause the parser to time out in a MfmPlainNode 2024-11-27 04:53:24 +01:00
Laura Hausmann
556ada822f
[parsing/mfm] Emit special node type when the parser times out 2024-11-27 04:53:24 +01:00
Laura Hausmann
753fbdefe4
[parsing/mfm] Check if the rest of the stream contains a closing tag before parsing asymmetric nodes 2024-11-27 04:53:24 +01:00
Laura Hausmann
a342e3df25
[parsing/mfm] Fail & fall back to text node when parsing takes more than 500ms (backend) / 2000ms (wasm) 2024-11-27 04:53:23 +01:00
Laura Hausmann
19f5c2d74e
[parsing/mfm] Fall back to MfmTextNode when the parser fails 2024-11-27 04:53:23 +01:00
Lilian
8e2a6e15bf
[frontend] Color changes 2024-11-27 04:30:37 +01:00
pancakes
c313c501f8
[frontend/components] Increase z-index on top bar 2024-11-26 19:55:03 +01:00
pancakes
c08cb5aad4
[frontend/mfm] Update MfmFnNode to work with .NET 9 2024-11-26 19:55:03 +01:00
pancakes
51ee9f8aeb
[frontend/components] Make Menu work like EmojiPicker 2024-11-26 19:55:03 +01:00
pancakes
b4dff783a2
[frontend] Add overflow clipping to note 2024-11-26 19:55:03 +01:00
pancakes
2a456485bb
[frontend/mfm] Fix fg, bg, and border rendering 2024-11-26 19:55:03 +01:00
pancakes
313e7b678a
[frontend/mfm] Implement unixtime fn node 2024-11-26 19:55:02 +01:00
pancakes
90f6456e30
[frontend/mfm] Implement ruby fn node 2024-11-26 19:55:02 +01:00
pancakes
e29a3083f4
[frontend] Add credit to Misskey for MFM animations 2024-11-26 19:55:02 +01:00
pancakes
e50aa6eeca
[frontend] Fix CSS formatting 2024-11-26 19:55:02 +01:00
pancakes
d4db5f917e
[frontend/mfm] Implement fade fn node 2024-11-26 19:55:02 +01:00
pancakes
ae493773ec
[frontend/mfm] Implement rainbow fn node 2024-11-26 19:55:02 +01:00
pancakes
533f8f71d4
[frontend/mfm] Implement twitch fn node 2024-11-26 19:55:02 +01:00
pancakes
fa422bb034
[frontend/mfm] Implement shake fn node 2024-11-26 19:55:02 +01:00
pancakes
6e982c95fe
[frontend/mfm] Add default speed for animation fn nodes 2024-11-26 19:55:01 +01:00
pancakes
cc156c9760
[frontend/mfm] Implement spin fn node 2024-11-26 19:55:01 +01:00
pancakes
0a9758f988
[frontend/mfm] Implement bounce fn node 2024-11-26 19:55:01 +01:00
pancakes
f61642c30b
[frontend/mfm] Implement jump fn node 2024-11-26 19:55:01 +01:00
pancakes
e4304b3100
[frontend/mfm] Implement tada fn node 2024-11-26 19:55:01 +01:00
pancakes
89e5a22131
[frontend/mfm] Make MfmFnJelly a reusable generic animation node 2024-11-26 19:55:01 +01:00
pancakes
3098e17a77
[frontend/mfm] Implement jelly fn node 2024-11-26 19:55:01 +01:00
pancakes
d2964a2502
[frontend/mfm] Refactor x2, x3, and x4 fn nodes 2024-11-26 19:55:01 +01:00
pancakes
864f156923
[frontend/mfm] Validate colors in fg, bg, and border nodes 2024-11-26 19:55:01 +01:00
pancakes
326192148f
[frontend/mfm] Implement border fn node 2024-11-26 19:55:00 +01:00
pancakes
90511c7b58
[frontend/mfm] Implement fg and bg fn nodes 2024-11-26 19:55:00 +01:00
pancakes
5d6e1d841f
[frontend/mfm] Implement scale fn node 2024-11-26 19:55:00 +01:00
pancakes
fe4a9518f5
[frontend/mfm] Implement position fn node 2024-11-26 19:55:00 +01:00
pancakes
fec60a6b69
[frontend/mfm] Implement crop fn node 2024-11-26 19:55:00 +01:00
pancakes
db5d6f469f
[frontend/mfm] Implement flip, font, x2, x3, x4, blur, and rotate fn nodes 2024-11-26 19:54:59 +01:00
Lilian
af6ef3bb08
[frontend/pages] Autofocus TOTP input 2024-11-26 16:03:09 +01:00
Lilian
bd5db40489
[backend/web] Code reformat 2024-11-26 04:22:44 +01:00
Lilian
c609f97ef0
[frontend/components] Make more restrictive default visibility take priority 2024-11-26 04:21:30 +01:00
Lilian
77e11ec400
[frontend] Code cleanup 2024-11-26 04:21:30 +01:00
Lilian
729ae593ad
[frontend/components] Respect default note visibility 2024-11-26 04:21:30 +01:00
Lilian
ed11602158
[frontend/core] Add settings service 2024-11-26 04:21:30 +01:00
Lilian
b2b206f065
[frontend/pages] Use state button on register page 2024-11-26 04:21:30 +01:00
Lilian
d088c7a442
[frontend] Add user settings page, enable 2FA enroll and login (ISH-581) 2024-11-26 04:21:30 +01:00
Lilian
b427340d94
[frontend/components] Correctly tag username and password fields 2024-11-26 04:21:30 +01:00
Lilian
a130934787
[frontend/components] Add registration page and update login page style (ISH-472) 2024-11-26 04:21:30 +01:00
Laura Hausmann
c84156e946
[parsing] Fix DoS caused by quirk in FParsec's many1Till (ISH-637)
Ref: https://github.com/stephan-tolksdorf/fparsec/issues/111
2024-11-26 04:18:54 +01:00
Laura Hausmann
aa593f78b8
[parsing/mfm] Limit inline node recursion to 100 2024-11-26 00:18:52 +01:00
pancakes
9036eacd98
[frontend/components] Don't use simple MFM for field value 2024-11-25 16:42:26 +10:00
pancakes
c15832f37a
[frontend/components] Use UserDisplayName in NotificationComponent 2024-11-24 14:51:19 +10:00
pancakes
0c8b79a489
[backend/core] Improve GetReactionsAsync 2024-11-24 05:40:00 +01:00
pancakes
13a359a1b6
[frontend/components] Add emoji to reaction notifications 2024-11-24 05:40:00 +01:00
pancakes
54a899df0b
[backend/core] Add reaction to reaction notification 2024-11-24 05:40:00 +01:00
pancakes
e056d7ec9f
[frontend/components] Add icons to notifications 2024-11-24 05:40:00 +01:00
pancakes
7e4cf31ca1
[frontend/components] Add missing notification types 2024-11-24 05:39:59 +01:00
Laura Hausmann
7b71a14537
[sln] Update code style rules 2024-11-23 21:46:19 +01:00
Laura Hausmann
ecf83c17ff
[backend/razor] Tweak "recent jobs" table styles 2024-11-23 21:40:49 +01:00
Laura Hausmann
a76f70d030
[backend/database] Convert DatabaseContext to use LF newlines 2024-11-23 21:34:30 +01:00
Laura Hausmann
853f598bc4
[backend/razor] Add "top delayed" section to queue dashboard 2024-11-23 21:34:30 +01:00
Laura Hausmann
d333961ff0
[tests] Switch to MSTest.Sdk / Microsoft.Testing.Platform 2024-11-23 20:45:40 +01:00
pancakes
e5e6166266
[frontend/components] Add alt text button to note attachments 2024-11-23 20:04:35 +01:00
Kopper
dab3b7ed4c
[backend/federation] Hide private replies from the replies collection 2024-11-23 18:24:11 +01:00
Kopper
36d9a8cc49
[backend/federation] Context collection 2024-11-23 18:20:41 +01:00
Laura Hausmann
64e8ef03c8
[sln] Update code style rules 2024-11-23 18:14:54 +01:00
Laura Hausmann
10346e795a
[sln] Code cleanup 2024-11-23 02:11:22 +01:00
Laura Hausmann
135737937c
[backend/api] Allow enrolling existing users into 2FA (ISH-626) 2024-11-23 02:10:35 +01:00
Laura Hausmann
9989aae29e
[backend/libmfm] Set alt text for emoji in public preview 2024-11-22 22:42:25 +01:00
Laura Hausmann
5cf951e908
[backend/razor] Add TOTP 2FA support to OAuth page 2024-11-21 22:04:13 +01:00
Laura Hausmann
ec6e334266
[backend/api] Add TOTP submission endpoint (ISH-580) 2024-11-21 20:04:21 +01:00
Laura Hausmann
569e112069
[tests] Remove flaky benchmark test 2024-11-21 18:53:38 +01:00
pancakes
63aadfdf07
[frontend/mfm] Hide host part of mentions to local users 2024-11-21 18:51:55 +01:00
pancakes
b581714387
[frontend/components] Don't mention yourself or local host part when composing replies (ISH-603) 2024-11-21 18:51:54 +01:00
pancakes
11575e4daf
[frontend] Add Instance Metadata service 2024-11-21 18:51:54 +01:00
pancakes
09b39a64ea
[backend/api] Add basic instance info endpoint 2024-11-21 18:51:54 +01:00
Laura Hausmann
0a899b27d5
[backend/razor] Improve footer responsiveness 2024-11-21 18:42:34 +01:00
Laura Hausmann
9be9e05200
[backend/razor] Add wordmark to index page 2024-11-21 18:35:37 +01:00
Laura Hausmann
b9be67302c
[parsing] Fix silent link nodes 2024-11-21 16:52:30 +01:00
pancakes
718e67827e
[frontend/components] Fix crash when selecting emoji in compose (ISH-623) 2024-11-21 12:15:44 +10:00
Laura Hausmann
328c3a5c91
[sln] Update version to be in line with security hotfix release
This avoids "am I vulnerable to this?" confusion if running a :dev image or a -git package.
2024-11-21 00:46:04 +01:00
Laura Hausmann
fe8c736821
[backend/federation] Limit UserResolver invocations in NoteService (ISH-622) 2024-11-21 00:45:30 +01:00
Laura Hausmann
863c9ca9c9
[backend/federation] Improve actor & note validation (ISH-547) 2024-11-20 23:54:32 +01:00
Laura Hausmann
c0e8a6d680
[backend/streaming] Fix streaming updates not containing html markup if supported 2024-11-20 02:36:32 +01:00
Laura Hausmann
862d477dec
[backend/masto-client] Remove confusing status context logic
This does technically match Mastodon's behavior, but it doesn't match the behavior of iceshrimp-js, or the Iceshrimp.NET web API. It's also very confusing.
2024-11-20 01:56:41 +01:00
Laura Hausmann
727ba75d0e
[backend/federation] Deduplicate recipients in ActivityDeliverService 2024-11-20 01:31:14 +01:00
Laura Hausmann
48bb81af1c
[backend/core] Deliver note creates & updates to reply author even if they're not mentioned 2024-11-20 01:30:56 +01:00
Laura Hausmann
ccdc816c26
[backend] Update ReSharper rules 2024-11-20 01:22:53 +01:00
Laura Hausmann
00d3248b8a
[backend] Fix build 2024-11-20 01:22:09 +01:00
Laura Hausmann
51e4846635
[backend/core] Revert Task.ContinueWithResult naming 2024-11-20 01:14:50 +01:00
Laura Hausmann
a762a9201e
[backend/core] Revert IAsyncEnumerable naming 2024-11-20 01:12:32 +01:00
Laura Hausmann
9f4c989ca8
[frontend] Update async void methods to either return a Task or to catch all exceptions to prevent frontend crashes 2024-11-20 00:48:34 +01:00
Laura Hausmann
c91a1fa8bc
[backend/signalr] Catch exceptions in event handlers to prevent exceptions from causing backend crashes 2024-11-20 00:48:34 +01:00
Laura Hausmann
004af22b68
[sln] Update inspection severities 2024-11-20 00:48:34 +01:00
Laura Hausmann
0ce00090cb
[frontend/components] Code cleanup 2024-11-20 00:48:29 +01:00
Laura Hausmann
48807ec4fa
[backend/razor] Code cleanup 2024-11-20 00:48:29 +01:00
Laura Hausmann
24b412bd75
[sln] Reformat code 2024-11-20 00:48:29 +01:00
Laura Hausmann
7dec2514da
[sln] Apply code style 2024-11-20 00:48:29 +01:00
Laura Hausmann
b8452ccb75
[sln] Add ReSharper rule to enforce Task-returning methods being suffixed with Async 2024-11-20 00:48:28 +01:00
Laura Hausmann
3119c08a11
[backend/razor] Use new favicon 2024-11-19 23:05:17 +01:00
Lilian
ef753be067
[frontend] Code cleanup 2024-11-19 23:05:16 +01:00
Lilian
a5f8583d1f
[backend/web] Directly return VersionResponse 2024-11-19 23:05:16 +01:00
Lilian
dc12aa0915
[frontend/components] Allow opening notes in profile view 2024-11-19 23:05:16 +01:00
Lilian
9d3d8bf64b
[frontend/components] Adjust fetch priority for attachments 2024-11-19 23:05:16 +01:00
Lilian
1e680bd382
[frontend/components] Fix error handling when checking for updates. 2024-11-19 23:05:16 +01:00
Lilian
6a6961baa1
[frontend/components] Change banner notification location 2024-11-19 23:05:16 +01:00
Lilian
9a3a79ed2c
[frontend/components] Check for updates when opening about page 2024-11-19 23:05:16 +01:00
Lilian
bd6972a0c3
[frontend] Set theme-color meta tag to --background-color 2024-11-19 23:05:16 +01:00
Lilian
1a39b2856a
[frontend] Make nav-bar respect safe-area-inset 2024-11-19 23:05:15 +01:00
Lilian
1da774b8f2
[frontend] Add update checking and banner notifications 2024-11-19 23:05:15 +01:00
Lilian
aac01b6abf
[backend/web] Add controller for Webmanifest 2024-11-19 23:05:15 +01:00
Lilian
88bf04a54c
[frontend/core] Add version controller model 2024-11-19 23:05:15 +01:00
Lilian
aa72e52124
[backend/web] Add version endpoint 2024-11-19 23:05:15 +01:00
Lilian
8b9019b76e
[frontend] Initial PWA enablement 2024-11-19 23:05:15 +01:00
Laura Hausmann
55f7b48d80
[backend/asp] Fix BlazorSsrHandoffMiddleware not getting triggered 2024-11-19 20:06:06 +01:00
Jeder
1cccdcb832 [frontend/components] Refactor notifications 2024-11-19 20:00:19 +01:00
Laura Hausmann
579f8c233a
[backend/core] Code cleanup 2024-11-19 19:38:31 +01:00
Laura Hausmann
7eb220b43d
[sln] Update dependencies 2024-11-19 19:12:28 +01:00
Laura Hausmann
6240dc8e30
[build] Always use -O3 for WASM AOT builds 2024-11-19 04:35:16 +01:00
Laura Hausmann
e128182934
[build] Fix typo 2024-11-19 04:22:42 +01:00
Laura Hausmann
77bc189bbc
[build] Fix typo 2024-11-19 04:21:12 +01:00
Laura Hausmann
4f7b75b5c2
[build] Use correct defaults for WasmOptLevel 2024-11-19 04:14:06 +01:00
Laura Hausmann
3dd85deb0d
[build] Fix static assets not being mapped correctly 2024-11-19 04:06:51 +01:00
Laura Hausmann
764e1a01ec
[build] Build release builds with WasmOptLevel 3 and native wasm linking 2024-11-19 03:53:59 +01:00
Laura Hausmann
9ba4390a87
[backend/csproj] Don't copy build files to output directory 2024-11-19 03:24:32 +01:00
Laura Hausmann
eba2ce0119
[sln] Add solution folders 2024-11-19 03:14:24 +01:00
Laura Hausmann
ee9279bb5f
[build] Disable gzip compression during build 2024-11-19 03:14:13 +01:00
Laura Hausmann
1511692b1e
[docker] Add workaround for wasm-opt stack size issue 2024-11-18 22:49:32 +01:00
Laura Hausmann
8d469b07df
[docker] Add python3 to iceshrimp/dotnet-sdk:9.0-alpine-wasm
This partially fixes AOT docker builds (it doesn't fix the emcc segfault, though)
2024-11-18 19:43:19 +01:00
Laura Hausmann
0ee5ff377e
[backend] Move OutputCacheMiddleware into UseCustomMiddleware to make the order more obvious 2024-11-18 19:20:23 +01:00
Laura Hausmann
b4ebe00cdb
[docker] Copy build task files before building 2024-11-18 19:15:14 +01:00
Laura Hausmann
c3cfbbcbee
[docker] Add missing dependency reference 2024-11-18 19:13:52 +01:00
Laura Hausmann
8926081d11
[docker] Update dockerfile 2024-11-18 19:11:43 +01:00
Laura Hausmann
dd7b7634c6
[backend/core] Convert scoped services with request-specific properties to singletons by utilizing AsyncLocal<T> 2024-11-18 19:02:44 +01:00
Laura Hausmann
4356a47b9d
[backend/asp] Make services runtime-discoverable 2024-11-18 19:02:44 +01:00
Laura Hausmann
705e061f74
[backend/asp] Refactor middleware stack
This commit splits the request pipeline conditionally instead of invoking every middleware in the stack.

It also simplifies middleware instantiation by using runtime discovery, allowing for Plugins to add Middleware.
2024-11-18 19:02:44 +01:00
Laura Hausmann
70c692e1cb
[backend/razor] Add error code to ErrorPage title 2024-11-18 19:02:43 +01:00
Laura Hausmann
4af69a6afa
[backend/asp] Add ExceptionVerbosity level Debug 2024-11-18 19:02:43 +01:00
Laura Hausmann
f252c1a85e
[backend] Code cleanup 2024-11-18 19:02:43 +01:00
Laura Hausmann
86762dc386
[backend/asp] Make BlazorSsrHandoffMiddleware less hacky 2024-11-18 19:02:43 +01:00
Laura Hausmann
3b8a4b89a9
[backend/openapi] Replace ConsumesHybrid condition with FromHybrid 2024-11-18 19:02:43 +01:00
Laura Hausmann
2c0e0d0922
[backend/openapi] Don't regenerate OpenAPI schemas files on every request 2024-11-18 19:02:43 +01:00
Laura Hausmann
84b8553a40
[backend/database] Switch to in-model entity configuration 2024-11-18 19:02:43 +01:00
Laura Hausmann
59d14297a3
[backend/startup] Log initialization time during startup 2024-11-18 19:02:43 +01:00
Laura Hausmann
1200bcbba5
[make] Compile database model in release builds
Ideally we'd want to do this during publish, however due to upstream issues with the MSBuild task, this will suffice for now.

Reduces startup time by ~500ms.
2024-11-18 19:02:43 +01:00
Laura Hausmann
ff59b07035
[backend/federation] Add short-lived cache for non-specific federation requests 2024-11-18 19:02:42 +01:00
Laura Hausmann
762278426f
[backend/csproj] Update in-house dependencies to net90 2024-11-18 19:02:42 +01:00
Laura Hausmann
1e5c033fb1
[backend/razor] Add transparent decompression hook to MapStaticAssets, replacing the old BlazorFrameworkFiles hook 2024-11-18 19:02:42 +01:00
Laura Hausmann
6b4e372973
[backend/razor] Strip all non-brotli-compressed static assets, not just blazor framework ones 2024-11-18 19:02:42 +01:00
Laura Hausmann
b6714dbba9
[backend/razor] Switch to MapStaticAssets
This allows for fingerprinted filenames, ETags & enhanced caching.
2024-11-18 19:02:42 +01:00
Laura Hausmann
4186388d4c
[backend/openapi] Add scalar-ui, add OpenAPI overview page 2024-11-18 19:02:42 +01:00
Laura Hausmann
01bd71c97d
[backend/razor] Switch to constructor injection in Blazor SSR code-behind files 2024-11-18 19:02:42 +01:00
Laura Hausmann
ce0cc58738
[tests] Use FSharpList collection expressions in MFM tests, refactor Dictionary initializers for better readability 2024-11-18 19:02:42 +01:00
Laura Hausmann
ffb1678169
[ci] Switch to net90 CI images 2024-11-18 19:02:42 +01:00
Laura Hausmann
107d9314a9
[backend/database] Fix design-time database updates 2024-11-18 19:02:41 +01:00
Laura Hausmann
752c22980d
[backend/federation] Fix FilterHidden with except userId failing to translate
This seems to be an EF 9 specific bug, though I can't reproduce it in an isolated query. Benchmarks say the resulting queries perform identically, so I don't think this is much of an issue.
2024-11-18 19:02:41 +01:00
Laura Hausmann
6c76b2b2c5
[backend] Switch params methods to IEnumerable<T> 2024-11-18 19:02:41 +01:00
Laura Hausmann
b49be2f904
[backend/database] Fix efcore.pg v9 enum mapping 2024-11-18 19:02:41 +01:00
Laura Hausmann
a18c1c59ea
[parsing] Fix compatibility with the new F# reference type nullability checks 2024-11-18 19:02:41 +01:00
Laura Hausmann
e3bce3bc0e
[sln] Update dependencies to their latest non-preview versions (where available) 2024-11-18 19:02:41 +01:00
Laura Hausmann
1e3f9f0c1f
[sln] Remove NuGetAuditMode='all' property (enabled by default in .NET 9) 2024-11-18 19:02:41 +01:00
Laura Hausmann
c0eec686ca
[backend/csproj] Drop DATAS opt-in (enabled by default in .NET 9) 2024-11-18 19:02:41 +01:00
Laura Hausmann
8d288e9f44
[backend/core] Convert GeneratedRegex partial methods to partial properties 2024-11-18 19:02:41 +01:00
Laura Hausmann
c6adb0bee1
[backend/core] Switch to lock objects for locking purposes 2024-11-18 19:02:40 +01:00
Laura Hausmann
946062a0ae
[docker] Update Dockerfile
Since https://github.com/dotnet/sdk/issues/32327 is resolved in net90, we no longer need to build with ubuntu.
2024-11-18 19:02:40 +01:00
Laura Hausmann
ee7d4d1477
[sln] Update SDK version to 9.0 2024-11-18 19:02:40 +01:00
pancakes
602d207ea5
[frontend/components] Add private badge to locked profiles 2024-11-19 00:48:27 +10:00
pancakes
c11beba8a7
[backend] Add IsLocked to UserProfileResponse 2024-11-19 00:47:36 +10:00
Laura Hausmann
da83dd9c8e
[ci] Switch to versioned ci-env tags 2024-11-17 19:14:02 +01:00
Laura Hausmann
940e6f847e
[backend/drive] Fix errors when uploading files with long names containing unicode characters to object storage 2024-11-17 17:12:19 +01:00
Laura Hausmann
2ef78e3f41
[parsing] Add strike html tag support, keep italic/bold/strike in html tag form when reserializing 2024-11-17 16:44:05 +01:00
pancakes
cd84b480a6
[frontend/components] Add bot indicator to notes (ISH-407) 2024-11-17 13:44:30 +01:00
pancakes
e0d63d9a6b
[frontend] Move follows you badge next to identifier 2024-11-17 13:44:30 +01:00
pancakes
b5ced00a73
[frontend/components] Add automated badge to profile info 2024-11-17 13:44:29 +01:00
pancakes
68684dbfcb
[backend] Add bot and cat fields to users 2024-11-17 13:44:29 +01:00
pancakes
a79d012fcb
[frontend/components] Add admin and moderator badges to profile info 2024-11-17 13:44:29 +01:00
pancakes
14201c8489
[backend] Add UserProfile.Role 2024-11-17 13:44:29 +01:00
pancakes
1c587584bc
[frontend/components] Add follows you badge to profile info 2024-11-17 13:44:29 +01:00
pancakes
60ed8d3c3c
[frontend/components] Don't show Follow button for yourself 2024-11-17 13:44:28 +01:00
pancakes
3dfc7c6b63
[frontend/components] Refactor EmojiPicker to categorize emojis and allow search 2024-11-17 04:22:53 +01:00
Laura Hausmann
ea461d139b
[docs] Fix typo 2024-11-16 19:25:45 +01:00
Laura Hausmann
cd9cf33ae4
[docs] Update SECURITY.md 2024-11-16 16:10:18 +01:00
Laura Hausmann
e2004f5472
[parsing] Add parenthesis tracking to MfmUrlNode and MfmLinkNode parsers (ISH-608) 2024-11-16 15:03:54 +01:00
Laura Hausmann
9ca2deba67
[backend/parsing] Drastically improve MFM parser performance
This commit improves MFM parser performance by up to 22x, depending on input and platform.
2024-11-16 02:01:29 +01:00
Laura Hausmann
8b3e25e7db
[backend/api] Report errors during acct user lookups to the client
This causes user lookups for blocked users to return a more specific error message than "No result found".
2024-11-15 18:59:28 +01:00
pancakes
28b3b56646 [backend/api] Add instance staff endpoint 2024-11-13 06:55:07 +01:00
Laura Hausmann
b6dddd9439
[backend/core] Trim UserPublicKey before inserting into database 2024-11-12 20:57:16 +01:00
Laura Hausmann
5e6dfcb2db
[docker] Update .NET 9 docker containers to the release version 2024-11-12 20:37:31 +01:00
Laura Hausmann
a29720b536
[parsing] Fix inline nodes being marked as block nodes 2024-11-11 05:41:28 +01:00
Laura Hausmann
2b046fc444
[parsing] Add support for has:media search query 2024-11-11 01:52:48 +01:00
Laura Hausmann
43ed9d4c5d
[frontend] Remove unused ElementReference 2024-11-11 01:19:26 +01:00
Laura Hausmann
c969265e61
[parsing] Allow digits in mfm fn node function names 2024-11-11 00:39:16 +01:00
Laura Hausmann
3f28a586a5
[parsing] Require whitespace in front of mfm italic nodes using asterisk or underscore delimiters
This ensures matching behavior with mfm.js
2024-11-11 00:10:12 +01:00
Laura Hausmann
b341af67ed
[tests] Add complex fn node / mfm art roundtrip test case 2024-11-10 23:46:28 +01:00
Laura Hausmann
73783fa0ee
[backend/libmfm] Fix MfmSerializer dropping leading & trailing whitespace on recursive calls 2024-11-10 22:24:55 +01:00
Laura Hausmann
e73b68dc0d
[backend/razor] Reformat code 2024-11-09 19:24:52 +01:00
Laura Hausmann
8a806c611f
[backend/razor] Make expanded navbar hamburger menu full-width 2024-11-09 05:18:57 +01:00
Laura Hausmann
555d1108f5
[backend/razor] Move navbar placeholder into the NavBar component 2024-11-09 05:15:00 +01:00
Laura Hausmann
346803935a
[backend/razor] Add a navigation bar to the admin dashboard
This is implemented using a reusable navigation bar component.
2024-11-09 05:07:04 +01:00
pancakes
7841bfa1ba
[frontend] Render emojis in top bar of SingleNote page 2024-11-08 14:15:57 +10:00
Lilian
f56f947392
[frontend/note-rendering] Make the hovered emoji appear above all others 2024-11-08 03:35:54 +01:00
Lilian
fa354a1869
[frontend/components] Decrease username and instance name size 2024-11-08 03:29:23 +01:00
Lilian
938b010aed
[frontend/components] Fix auto minimum width of note container 2024-11-08 03:29:23 +01:00
Lilian
2eb080afe5
[frontend/components] Only break words when absolutely necessary 2024-11-08 03:29:23 +01:00
Laura Hausmann
374d750e2e
[frontend/components] Only render first line in UserDisplayName and ProfileInfoField 2024-11-08 03:07:38 +01:00
Laura Hausmann
02765568db
[backend/api] Fix DbContext race condition in UserRenderer, refactor to make data flow clearer 2024-11-08 03:07:38 +01:00
pancakes
5fd86f4c79
[frontend/components] Render emoji in content warning 2024-11-08 03:07:38 +01:00
pancakes
6c527423a3
[frontend/components] Render emojis in user bio and profile fields 2024-11-08 03:07:37 +01:00
pancakes
d65e8ac55e
[frontend/components] Use UserDisplayName in ProfileView 2024-11-08 03:07:37 +01:00
pancakes
c14daf2531
[frontend/components] Refactor NoteUserInfo 2024-11-08 03:07:37 +01:00
pancakes
f04572fc4f
[frontend/components] Use a single component for display names with emoji rendering 2024-11-08 03:07:37 +01:00
pancakes
354d0a4f45
[frontend/mfm] Add support for simple custom emojis 2024-11-08 03:07:37 +01:00
pancakes
16f617b1e2
[parsing/mfm] Add simple MFM parser 2024-11-08 03:07:37 +01:00
pancakes
28548a5770
[backend] Add Emojis to UserResponse 2024-11-08 03:07:37 +01:00
Laura Hausmann
33098f2186
[parsing] Remove outdated comment 2024-11-08 03:07:33 +01:00
Kopper
11170ffbcc [backend/masto-client] Allow admin:read and admin:write scopes
These aren't actually used anywhere but supported just to make sure
clients that specify them in their scopes can log in successfully.
2024-11-07 21:34:40 +01:00
Kopper
d22a21c7e7
[backend/federation] Render Create in /notes/{id}/activity when not a renote 2024-11-07 20:13:47 +01:00
Laura Hausmann
3e8ba289c3
[backend/drive] Fix incorrect mime type & file extension when converting original files (ISH-593)
Closes #64

Co-authored-by: Tamara Schmitz <tamara.zoe.schmitz@posteo.de>
2024-11-07 20:04:34 +01:00
Laura Hausmann
e34b4f5c1a
[backend/drive] Convert ImageFormat to a closed record 2024-11-07 20:04:34 +01:00
Kopper
94328a3eef [backend] Use a stack instead of queue when backfilling
This makes each reply chain load completely before loading the next
chain, instead of the current behavior that loads all replies of one
depth before loading the next "layer".

This won't make much of a difference *now*, but should result in more
intuitive behavior when live updating of newly loaded replies gets
implemented.
2024-11-06 20:02:29 +01:00
Kopper
40b35a7bd2 [backend/akko-client] Handle "local" visibility (ISH-559) 2024-11-06 13:06:18 +03:00
Laura Hausmann
6c72b27f86
[docs] Update README 2024-11-05 23:57:39 +01:00
Laura Hausmann
4b01ecddfa
[backend/startup] Reference help shortlinks instead of directing to the wiki 2024-11-05 23:41:20 +01:00
Laura Hausmann
aea5efd012
[parsing] Add support for italic and bold segments surrounded by underscores (ISH-585) 2024-11-05 22:54:02 +01:00
pancakes
4d85fce1cf
[backend/core] Catch failed resolves in ImportFollowingAsync 2024-11-06 00:32:34 +10:00
Laura Hausmann
ceb8da8f3d
[shared] Only compute version info once 2024-11-03 22:43:28 +01:00
Laura Hausmann
c40c13261d
[shared] Improve fork version string handling 2024-11-03 22:29:19 +01:00
Laura Hausmann
8d29869f76
[shared] Correctly handle fork information in the version string 2024-11-03 22:10:43 +01:00
Laura Hausmann
c73cbd355e
[docs] Fix duplicate attribution in CHANGELOG 2024-11-03 21:56:28 +01:00
Laura Hausmann
1614e23323
[docs] Update README 2024-11-03 21:44:44 +01:00
Laura Hausmann
1aa5530694
[docs] Fix typo 2024-11-03 21:44:14 +01:00
Laura Hausmann
d919d6032e
Release: v2024.1-beta4 2024-11-03 21:19:32 +01:00
Lilian
6a936288b0 [frontend/components] Disable interacting with inline-replies 2024-11-03 21:19:12 +01:00
Laura Hausmann
1b885e6ed4
[backend/queue] Also deliver note updates to relays 2024-11-03 19:40:35 +01:00
636 changed files with 24008 additions and 8689 deletions

View file

@ -9,7 +9,9 @@ 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 ci-env:dotnet
build ci-env-wasm ci-env:dotnet-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
docker buildx prune -a -f --keep-storage 10G

View file

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

View file

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

View file

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

View file

@ -1,5 +1,13 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-alpine
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
RUN dotnet workload install wasm-tools
RUN apk add --no-cache --no-progress bash
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 ln -sf /bin/bash /bin/sh
CMD ["/bin/bash"]

View file

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

14
.editorconfig Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,475 @@
## v2025.1-beta5.patch2.security1
This is a security hotfix release. It's identical to v2025.1-beta5.patch2, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
### Backend
- Updated SixLabors.ImageSharp to 3.1.7 (addressing [GHSA-2cmq-823j-5qj8](https://github.com/advisories/GHSA-2cmq-823j-5qj8))
### Attribution
This release was made possible by project contributors: Laura Hausmann
## v2025.1-beta5.patch2
This is a hotfix release. It's identical to v2025.1-beta5.patch1, except for a bunch of bugfixes. Upgrading is strongly recommended for all server operators running v2025.1-beta5 or v2025.1-beta5.patch1.
### Blazor Frontend
- The compose dialog send button no longer gets stuck on "Sent!"
- The compose dialog can no longer be closed while files are uploading or the composed note is being created
- Alt text no longer overflows the avatar box when the avatar fails to load
- Firefox no longer displays the avatar alt text while the image is loading
- If an avatar fails to load, the user's identicon is loaded as a fallback image
- Avatars now take up the same amount of space while they're loading
- The contrast of poll results has been improved
- An error dialog is displayed when poll voting fails
- Quotes no longer have extraneous whitespace above their content
- A frontend crash related to early JSInterop use has been fixed
- Profile fields now get rendered with an improved layout which no longer breaks on overflow
### Backend
- Link verification now works with `rel="me"` links in the `<head>` section, and works with MFM/aliased links
- Voting on a poll using the Web API no longer fails
- A bug related to LDLocalizedString serialization has been resolved
### Mastodon client API
- `/api/v2/instance` now returns the streaming API URL in the correct format
### Miscellaneous
- Release builds no longer use compiled EF models, fixing API errors & frontend page load issues
- Docker builds are getting tagged as :latest again
### Attribution
This release was made possible by project contributors: Kopper, Laura Hausmann, Lilian & pancakes
## v2025.1-beta5.patch1
This is a hotfix release. It's identical to v2025.1-beta5, except for a bunch of bugfixes. Upgrading is strongly recommended for all server operators running v2025.1-beta5.
### Blazor frontend
- Notifications no longer get randomly duplicated
- The moderation page now works correctly on mobile
- Style issues related to icons have been resolved
- The account dropdown now get positioned correctly when the page scroll position is not at the top
### Backend
- Pure renote replies now get rejected no matter which method of creating them is used
- Security configuration changes now apply in real-time everywhere, resolving an issue where the frontend would not offer invite registration despite it being enabled
### Attribution
This release was made possible by project contributors: Laura Hausmann, Lilian & pancakes
## v2025.1-beta5
This release contains lots of new features & bug fixes. Upgrading is recommended for all server operators.
### Release notes
This release contains a **breaking change** - we now require PostgreSQL version 15 or higher. If you need assistance upgrading, please reach out to the [support chat](https://chat.iceshrimp.dev).
### Highlights
- The MFM parser has been completely rewritten, improving frontend performance by several orders of magnitude, as well as fixing countless bugs, slowdowns & edge cases.
- TOTP 2FA is now supported and can be configured in the user settings
- Instance rules can now be configured and displayed
- Links in user profile fields are now verified
- Full drive file management has been added
- Federated user pronouns have been added
- Remote media is now proxied by default
- The project and all in-house libraries now target .NET 9.0
### Blazor frontend
- Custom emoji in user bios, user fields, user display names, & note content warnings are now rendered correctly
- Display names & fields now only render their respective first line
- The note display now only breaks words break when necessary
- The font used on the frontend is now downloaded if it's not available on the client system
- The Iceshrimp.NET frontend can now be installed as a PWA (Offline support is not enabled yet)
- The frontend will automatically check for and notify about new versions
- Better emoji picker with categories and search support
- Follow button will no longer show up for your own profile
- User profiles now have badges that indicate if a user is following you, as well as badges for moderators, administrators, and automated accounts
- Notes by automated accounts are tagged as such
- Improved rendering of notifications
- Accounts that require follow approval are tagged appropriately
- Notes in the profile view can now be opened correctly
- The emoji picker now works correctly when composing a note
- Composing a reply no longer adds a mention for yourself
- The host part of local mentions is now hidden
- Alt text can now easily be viewed for note attachments
- More notification types are now supported, and feature appropriate icons and emoji
- A registration page has been added
- The login page has been reworked and now features an account selector for existing sessions
- TOTP 2FA enrollment and authentication are now possible
- Buttons that have a state now reflect their state better
- Default note visiblity is respected when composing new notes
- MfM rendering now supports many many more functions and should render most MfM art correctly (flip, font, x2/3/4, blur, rotate, crop, position, scale, fg, bg, fn, jelly, tada, jump, bounce, spin, shake, twitch, rainbow, fade, ruby, unixtime, center, small)
- All popover menus are now improved
- The attachment viewer now supports keyboard navigation and displays alt text
- Single character profiles can now be opened correctly
- When composing a note, attached files are now listed and have a preview
- Improved display of note reaction details
- User profile now has a menu for contextual actions
- Look of all buttons has been improved
- Full profile customization is now possible, including changing banners, profile pictures, tags, etc.
- The follow back button now renders correctly
- The note composer now has a preview of what your note will look like
- Note composer now features character count
- Posts can be submitted with ctrl/cmd + enter
- Virtual scroller was completely rewritten to be more performant
- Fetched note data is now cached
- You can now create rules that will be displayed on the registration page and the instances about page
- Support for setting profile avatar and banner alt text
- New better looking dialog system for prompts, notices, etc.
- Button to open/close all content warnings in a thread
- The cw button now shows how long the post behind the CW is
- Removed overscroll in places where it looks bad
- Added status indicator for notification and timeline streaming
- Refetch profile option for the profile page
- Drive management has been added, including folder support, upload, and deletion, and modification
- Added a dedicated pronoun field on the profile page
- Menus take up more of the screen on the mobile UI and are easier to navigate
- Management page for local and remote emoji (Upload, modification, cloning)
- Completely reworked default theme
- Style improvements to go with the new default theme
- Support for poll rendering and voting
- Improved loading spinners
- Menu to change accounts or log out
- Settings pages no longer exceed screen height unless needed
- Notification content is limited to a reasonable size
- Improved rendering of cw and reactions in indented notes
- Admin cookie persists unless you log out the admin account
- Fixed a crash in the attachment viewer on chrome
- Content warnings now correctly hide quotes
- Added indicator when attachments are uploading
- Disabled posting note while attachments are uploading or note is empty
- Blurred images are now easier to deblur
- Many z-index issues have been fixed
- Page title now reflects instance name and current page
### Razor (public preview, admin panel, queue dashboard, etc.)
- The admin dashboard now has a responsive navigation bar
- Constructor dependency injection is now used where applicable
- Static assets are now collected, compressed & fingerprinted at build time
- The favicon is now correctly set to the project logo
- The index page now displays the Iceshrimp project wordmark
- The page footer is now more responsive
- Emoji now have their name set as alt text
- The queue dashboard index page now has a "top delayed" section
- The page footer now shows a registration link when registrations are open or invite-only
- The generate invite button on the admin panel is now accessible to screen-reader users
- The federation management page of the admin panel now has a search box
- The admin panel now supports remote user management & user search
- Polls are now displayed in public preview
### Backend
- Fork information in the version string is now handled correctly
- Version information is now only computed once
- Failed user resolutions no longer break the follow list import process
- Command line output referencing help pages now uses shortlinks, to prevent link rot
- Note backfilling now uses a stack instead of a queue
- MIME type & file extension are now being set correctly for converted images
- Locally originating create activities can now be fetched by their URI
- User responses now contain any emoji used in their display name or bio
- A DbContext race condition in UserRenderer has been fixed, resolving transient concurrency errors
- The search query parser now supports the has:media query
- User publickeys now have any extra whitespace removed before being added to the database
- Instance staff endpoints have been added
- User lookup error messages are now more specific
- User profile responses now include user roles, as well as the IsBot, IsCat and IsLocked fields
- Uploading files with long unicode names now works correctly
- Lock statements now use lock objects for improved performance
- GeneratedRegex partial methods have been converted to partial properties
- All params methods have been converted to take `IEnumerable<T>` as parameters
- The `dotnet ef database update` command now works as expected with multiplexing enabled
- An alternative OpenAPI UI - Scalar - has been added (accessible under `/scalar` & `/openapi`)
- Unauthenticated federation endpoints now cache their outputs for a short duration, easing database load during request bursts
- Release builds now use compiled EF models, reducing startup time by ~500ms
- The startup duration is now logged to console
- Entity model configuration has been moved into the respective entity classes
- The OpenAPI schema is now only generated once
- Usages of the `ConsumesHybrid` attribute have been replaced with `FromHybrid`
- `BlazorSsrHandoffMiddleware` now uses reflection instead of modifying the response
- A new exception verbosity option `Debug` has been added
- The error page title now contain the status code
- Middleware is now invoked conditionally, improving performance, simplifying stack traces and allowing plugins to add middleware to the stack
- Services are now runtime-discoverable, greatly improving readability
- Scoped services with request-specific properties have been converted to singletons using `AsyncLocal<T>`
- Unneeded compressed assets are no longer generated during build, improving build times
- The solution file now has virtual folders for build assets & project root files
- Version & web manifest endpoints have been added to support frontend PWA features
- Exceptions in StreamingConnectionAggregate no longer crash the backend
- Note creates & updates now get delivered to the author of note being replied to even if they're not mentioned
- Note recipients now get deduplicated
- Instance info endpoints have been added
- Support for note context collections has been added
- Reaction notifications now contain more information about the received reaction
- Note inline media is now supported using the `$[media <uri>]` MFM tag
- Session management endpoints have been added
- Line endings now get canonicalized during note/user ingest/update for improved frontend performance
- Empty & whitespace alt text now gets treated as no alt text
- User profile responses now contain the public URL of the user
- Endpoints related to user avatar, banner & display name have been added
- The user settings endpoints now allow for configuring the `isBot`, `isCat` and `speakAsCat` properties
- HTML markup tags are now deserialized to their corresponding MFM tag equivalent, instead of using symbol tags
- The note resolution lock now uses the fetched object `@id` property as its key
- Note lookups are now authenticated with the requesting user & don't attempt to redirect to inaccessible notes
- The batch emoji import endpoint is now excluded from the request size limit
- The emoji management endpoints now require moderator permissions instead of administrator ones
- Quotes without text no longer federate incorrectly to quote-aware implementations
- Notes from implementations sending HTML line breaks not followed by newline characters now get parsed correctly
- Quote blocks now aren't surrounded by extraneous line breaks
- The default renote visibility user setting can no longer be set to `specified`
- The user resolver now falls back to building the username/host tuple from the actor URI when it's not contained in the WebFinger response
- Reply backfill jobs now don't get scheduled for followers-only posts when authenticated user backfill is disabled
- The `w3id/identity-v1` JSON-LD context definition is now preloaded
- Outgoing unixtime MFM nodes now get converted to human-readable HTML
- Nodeinfo responses now return the configured instance name, description & admin contact email
- Support for backfilling user profiles has been added
- The exposed outbox collection is now functional
- Transient LD signature validation errors due to use of the wrong media type parser have been resolved
- Note refetches no longer wrongly mark notes as edited
- Fetching the relay actor now bypasses authorized fetch
- A startup error is now raised if the `ASPNETCORE_TEMP` is not writable
- Requests sent by suspended remote users are now rejected early during authorized fetch / inbox validation
- The unix socket permissions are now customizable
- The rewrite policy `CollapseWhitespace` was added
- Single emoji can now be given a name before uploading them
- The search query parser has been rewritten in C#, dropping the `FSharp.Core` dependency
- The UserResolver acct/uri mismatch message has been significantly improved
- Processed images now federate with the correct content type
- Negated search parameters now work with `match:words`
- The instance info response now contains the note length limit
- HTTP proxy configurations are now supported
- Hashtags are now handled more correctly, improving federation compatibility
- The home timeline heuristic now gets updated automatically for recently active users
- Hashtags now get the correct class set in when serialized to HTML
- Notes with `publishedAt`/`updatedAt` set to timestamps from the future will now get clamped to the current time
- The `Result<T>` helper type is now provided by `Iceshrimp.Utils.Common`
- User migration events now also transfer incoming and outgoing blocks to the new account
- The emoji table now correctly enforces unique names for local emoji (duplicates get fixed automatically, the newest entry is preserved)
- Like activities with `content` property now get correctly processed as reactions
- Deletion failures during media fixup are now ignored
- Avatar & banner alt text now federates bidirectionally, is returned in corresponding API responses & can be set
- `ExpressionExtensions` and `QueryableExtensions.AsChunkedAsyncEnumerable<T>` are now provided by `Iceshrimp.EntityFrameworkCore.Extensions`
- The license of assets included in the repository has been clarified to be `CC BY-SA 4.0`
- A refetch user endpoint has been added
- Remote emoji management endpoints have been added
- Polls can now be created, retrieved & voted on via the Web API
- Emoji media types now get populated & federated as appropriate
- Emoji entity names now get wrapped in colons for federation, resolving an interoperability issue with NodeBB
### Akkoma client API
- Local-only visibility is now respected
### Mastodon client API
- Admin scopes are now considered valid, allowing clients who request these to authenticate
- The confusing status context logic has been removed, matching -js & web api behavior
- The specified WebSocket protocol is now echoed back for streaming connections, fixing compatibility issues with some clients
- Attachment metadata is now returned when available
- Filter matches are now deduplicated, preventing duplicate filter match mesages
- The "reply inaccessible" marker now gets moved into the content warning (if any) and is more consistent
- Blockquotes now get rendered correctly when `supportsHtmlFormatting` is disabled
- Multiple accounts can now be fetched in one go via `/api/v1/accounts`
- Multiple statuses can now be fetched in one go via `/api/v1/statuses`
- The status response now correctly lists all hashtags
- The `/api/v1/accounts/{id}/statuses` endpoint no longer requires authentication, matching Mastodon's behavior
### Unit tests
- Tests now take less time to run due to higher parallelization
- The testing platform has been changed from `VSTest` to `Microsoft.Testing.Platform`
- The assertions library has been changed from `FluentAssertions` to `Iceshrimp.Assertions` due to a license change
### Build tasks
- Compressed razor class library assets now have corresponding static asset selector routes
- Pre-fingerprinted static assets collected from razor class libraries now get mapped correctly
### Miscellaneous
- The README has been updated
- The Dockerfile has been updated
- The security policy has been updated
- The OpenAPI documentation has been improved
### Attribution
This release was made possible by project contributors: blueb, Jeder, Kopper, Laura Hausmann, Lilian, notfire, pancakes & Tamara Schmitz
## v2024.1-beta4.security2
This is a security hotfix release. It's identical to v2024.1-beta4.security1, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
### Backend
- Several DoS & stack overflow vulnerabilities in the MFM parser were resolved
### Miscellaneous
- Performance of the MFM parser (and by extension, the frontend) should be significantly improved, as the backport of the security fixes also contains all other performance-related changes since v2024.1-beta4.
### Attribution
This release was made possible by project contributors: Laura Hausmann
Furthermore, I want to give special thanks to Natty for helping with investigating this vulnerability.
## v2024.1-beta4.security1
This is a security hotfix release. It's identical to v2024.1-beta4, except for the security mitigations listed below. Upgrading is strongly recommended for all server operators.
### Backend
- ActivityPub actor and note validation has been improved & now protects against cross-origin identifiers in more places, resolving a database pollution vulnerability
- Cross-origin `url` properties on actor & note objects now get set to null before ingestion, resolving a clickjacking vulnerability
- User resolution when processing incoming notes is now limited
### Attribution
This release was made possible by project contributors: Laura Hausmann
Furthermore, I want to give special thanks to Hazel Koehler for the vulnerability disclosure.
## v2024.1-beta4
This release contains lots of new features & bug fixes, including security fixes. Upgrading is strongly recommended for all server operators.
### Release notes
This release contains a **breaking change** regarding the configuration file. If you have configured a *natural duration* using the units for \[w\]eeks, \[m\]onths, or \[y\]ears, please update your configuration file to use one of \[s\]econds, \[m\]inutes, \[h\]ours, and \[d\]ays. This change was necessary to accommodate the newly added minute unit.
Furthermore, this release contains a migration that may take a while, as it goes through every note in the database in order to migrate to a new thread schema required for reply backfilling.
### Highlights
- Akkoma clients are now supported, including Akko-FE
- Note reply backfilling is now available as an opt-in experimental feature
- Index redirects for unauthenticated users are now configurable
- Incoming, outgoing, local-local & remote-remote account migrations are now supported
- Inbox jobs are now retried with exponential backoff
- Connecting to relays is now supported
- Reject & rewrite policies are now supported & can be arbitrarily extended via plugins
- Full text search now also searches for alt text matches
- Basic moderation actions are now supported
- A basic admin dashboard has been added
- Commands for fixing up media & pruning unreferenced files have been added
- The frontend now shows significantly more note details
- The frontend layout & stylesheet have been significantly refined
- The follow list can now be imported & exported
### Blazor frontend
- Version information is now displayed correctly
- The .NET Runtime version is now shown on the about page
- Note footer buttons now have correct accessibility labeling
- Notifications have received a visual overhaul
- Unsupported notification details are now displayed
- Bites notifications are now rendered correctly
- Buttons to bite users and notes have been added
- Note search now supports state reconstruction
- Links now open in a new tab
- Initial loads for the single note view have been reworked
- Reply count is now shown next to the reply button
- Replies are now shown inline on the timeline
- Replies to inaccessible notes are now marked with a lock
- The login page now redirects to the previous page after successful authentication
- Erroneous "note not found" messages in the single note view have been resolved
- Long notes now get truncated correctly
- Accessibility issues with the compose dialog have been resolved
- The main layout now carries accessibility landmarks
- Profile images on notes are now indicated as being links
- Various bits of missing alt text have been added
- User profiles now show the profile banner, if set
- Verified, birthday & location fields now have appropriate icons
### Backend
- The content root path is now set to the assembly directory instead of the working directory
- Additional domains to permit can now be added in the configuration file
- Reply notifications are no longer generated for remote users
- User creations with database conflicts now fail early and with a better error message
- Paginated collections are now handled correctly
- Raw JSON-LD value types are now deserialized correctly
- Dead instances are no longer erroneously marked as responsive
- The program now exits when started with --migrate & no pending migrations
- Several long-running tasks now consume less memory due to improved database abstractions
- Files larger than 128MB can now be uploaded
- Non-image attachments no longer have leading dashes erroneously added to their filenames
- Drive files can now be deleted
- Links converted from HTML now get shortened if the url and text components match
- The local-only flag is now enforced for renotes & replies of local-only notes
- Invalid accept activities now have improved error logging
- Mention in parentheses are now parsed correctly
- The media cleanup task no longer causes database query warnings
- Newlines surrounding code blocks are now handled correctly
- Code blocks are now serialized correctly
- Erroneous job timeouts are no longer logged
- Job timeouts now log improved error messages
- Queue exceptions are no longer logged twice
- The prune-designer-cs-files helper script has been relicensed under MIT
- Inbox queue logs have been improved
- Creating local follow relationships no longer cause errors related to instance stats
- Delayed jobs can now be abandoned in the queue dashboard
- Renotes & quotes mentioning muted/blocked users now get filtered
- System users can no longer be followed
- Reply/renote accessibility is now indicated correctly in Web API responses
- Zero-durations in the configuration file now get treated the same regardless of their suffix
- Media cleanup can now be triggered manually
- Punycode hosts are now represented in lowercase everywhere
- Deep threads no longer cause API errors
- Emoji can now be marked as sensitive
- Erroneous inbox job failures for activities referencing deleted notes have been resolved
- System users can no longer log in or create notes
- Avatar & banner updates now set the denormalized URLs to the AccessUrl instead of the regular Url
- Files served by /files are now returned as inline attachments
- Endpoints to get all blocked/allowed instances have been added
- Log messages related to jobs that were queued for more than 10 seconds have been improved
- The background-task queue timeout has been increased to accommodate longer-running tasks
- The inbox queue timeout has been increased to accommodate longer-running jobs
- Erroneous voter counts for polls from instances that don't return a voter count value have been resolved
- Drive file expiry no longer leaves orphaned file versions in the storage backend
- MFM fn nodes now get parsed correctly
- Content warnings can now be searched for explicitly using the cw: search filter
- The replies collection is now exposed for local notes
- A bug in the drive file cleanup job related to locally stored files has been resolved
- The job queue now supports a mutex field to prevent the same job from being queued by multiple threads
- Negative voter counts are now rejected
- It's now possible to bite users, posts & other bites
- InboxValidationMiddleware error handling has been improved
- A typo causing confusing log messages in ActivityHandlerService has been fixed
- UserResolver has been fully reworked, deduplicating significant amounts of code & greatly limiting attack surface, as well as improving consistency & performance
- Endpoints for listing note likes, renotes & quotes have been added
- Web API responses now use RestPagination instead of LinkPagination
- Stripped reply data is now returned for the note ascendants & descendants Web API endpoints
- The request trace identifier is now returned as a header even when no errors have occurred
- The WebFinger JSON-LD context definition is now preloaded
- The natural duration configuration parser has been reworked to support seconds & minutes. Support for weeks, months & years has been removed.
- Lists using stars as item indicators no longer get mis-parsed by libmfm
- HTTP/2 is now preferred for outgoing connections
- The StreamingService render-only-once mutex implementation has been fixed
- DriveController is no longer serving files with possibly invalid extensions
- The thread mute endpoints no longer have incorrect rate limits
- A bug causing some followers-only renote activities to be registered as specified has been fixed
- Stricter guard clauses have been added to some federation-related methods
- ActivityPub URIs are now enforced to be https everywhere
- More efficient time & duration is now being used where applicable
- An edge case related to local mentions in profile fields & bios has been resolved
- Followers can now be removed via a new endpoint
### Razor (public preview, admin panel, queue dashboard, etc.)
- Basic user page public preview has been added
- Razor pages now carry a footer with login, instance & version information
- The RestrictedNoMedia public preview mode is now enforced
- Avatars are now replaced with identicons when public preview mode is set to RestrictedNoMedia
- Public hashtag preview now displays a placeholder instead of loading the blazor frontend
- When public preview is disabled, a better error message is now shown
- The instance name is now shown in the title of queue dashboard pages
- You can now click on avatars & display names of users on public note preview pages
- The queue dashboard now allows for batch retries of failed jobs
- Custom emoji are now displayed on public preview pages
- The error page for disabled public preview now has a login button
- Public preview pages have been rebuilt using Blazor SSR (Razor components)
- Sensitive media is now skipped for public preview embeds
- Public preview embeds with images now use the correct card type
- Delayed jobs with a retry count of zero are now marked as scheduled on the queue dashboard
- The entries in the queue dashboard overview table are now clickable
- Abandoning or descheduling jobs in the queue dashboard now requires confirmation
- CSS & JS files are now versioned on razor pages & blazor SSR
### Mastodon client API
- Blockquotes are now handled better for some clients
- Reaction notification are now shown in supported clients
- The git revision is no longer reported in the backend version string
- The bite extension is now supported, allowing bites to originate from compatible clients
### Akkoma client API
- Akkoma-specific endpoints have been implemented, adding support for Akkoma clients, including Akko-FE
### Miscellaneous
- The frontend is no longer unnecessarily rebuilt during CI runs
- SECURITY.md has been added to the repository root
- FEDERATION.md has been updated to reflect support for FEP-9fde
- Vulnerable dependency checks no longer cause build failures by default. To opt back in to the previous behavior, add the `DependencyVulnsAsError=true` build flag, or the `DEP_VULN_WERROR=true` make flag.
### Attribution
This release was made possible by project contributors: Jeder, Laura Hausmann, Lilian, Samuel Proulx, kopper, notfire & pancakes
## v2024.1-beta3.patch1
This is a hotfix release. It's identical to v2024.1-beta3, except for a bunch of fixed frontend crashes. Upgrading is strongly recommended for all server operators.

View file

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

View file

@ -1,28 +1,25 @@
# syntax=docker/dockerfile-upstream:master
# To build with AOT enabled, run docker build --build-arg="AOT=true"
# To build with ILLink & AOT enabled, run docker build --build-arg="AOT=true"
# To build without VIPS support, run docker build --build-arg="VIPS=false"
# We have to build AOT images on linux-glibc, at least until .NET 9.0 (See https://github.com/dotnet/sdk/issues/32327 for details)
ARG AOT=false
ARG IMAGE=${AOT/true/wasm}
ARG IMAGE=${AOT/true/alpine-wasm}
ARG IMAGE=${IMAGE/false/alpine}
ARG RUNNER=${AOT/true/noble-chiseled}
ARG RUNNER=${RUNNER/false/alpine}
FROM --platform=$BUILDPLATFORM iceshrimp.dev/iceshrimp/dotnet-sdk:8.0-$IMAGE AS builder
FROM --platform=$BUILDPLATFORM iceshrimp.dev/iceshrimp/dotnet-sdk:9.0-$IMAGE AS builder
WORKDIR /src
ARG BUILDPLATFORM
ARG AOT=false
# copy csproj/fsproj & nuget config, then restore as distinct layers
# copy csproj files & nuget config, then restore as distinct layers
COPY NuGet.Config /src
COPY Iceshrimp.Backend/*.csproj /src/Iceshrimp.Backend/
COPY Iceshrimp.Parsing/*.fsproj /src/Iceshrimp.Parsing/
COPY Iceshrimp.Parsing/*.csproj /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
@ -35,6 +32,7 @@ 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
@ -60,7 +58,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:8.0-$RUNNER-composite AS image
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine-composite AS image
WORKDIR /app
COPY --from=builder /app .
USER app

View file

@ -22,7 +22,7 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
+ See [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs#L16-L24) and [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts) to see all preloaded LD contexts we ship.
- Outgoing activities are compacted against our well-known LD context ([iceshrimp.json](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts/iceshrimp.json)).
+ For compatibility with implementors that are not doing full LD processing, we force some attributes to be an array:
* `tag`, `attachment`, `to`, `cc`, `bcc`, `bto` (all in the `https://www.w3.org/ns/activitystreams` namespace)
* `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/)
@ -34,12 +34,14 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
- We support WebFinger over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing).
+ However, we do not ask for `xrd+xml` in our `Accept` header for outgoing WebFinger requests due to [compatibility issues](https://github.com/friendica/friendica/issues/14370) with Friendica.
+ Responses **MUST** have their `Content-Type` set to `application/jrd+json`, `application/xrd+xml`, `application/json`, or `application/xml`.
+ Responses **MUST** contain a link with the attributes `rel='self'` and `type='application/activity+json'`.
* `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` is treated interchangably with `application/activity+json`.
+ Responses **SHOULD** contain the `acct:` URI of the actor in the `subject` or `aliases` fields.
* If no such URI is found, we attempt to fetch the actor via ActivityPub and assemble the link from the actor's `preferredUsername` and `@id` host.
- We support host-meta over `application/jrd+json` as well as `application/xrd+xml` (both incoming and outgoing).
+ The json representation is also accessible under `/.well-known/host-meta.json`.
+ Implementors **SHOULD** advertise the WebFinger `Content-Type` in the `type` attribute of the WebFinger template in the host-meta response.
* However, since major implementors either omit the attribute, or incorrectly advertise `jrd+json` as `xrd+xml`, we presently ignore this property.
+ Implementors **MUST** return a link with the attributes `rel='self'` and `type='application/activity+json'` in the response.
* `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` is treated interchangably with `application/activity+json`.
* 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.
@ -66,6 +68,10 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
- [FEP-9fde: Mechanism for servers to expose supported operations](https://codeberg.org/fediverse/fep/src/branch/main/fep/9fde/fep-9fde.md)
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
+ Specifically, we use it in a "conversational context" sense, where each note has an attached context, which maps to an internal "thread".
+ We currently do not use the context for anything other than grouping.
+ Our context collections contain objects, not activities.
## FEPs we intend to support in the future
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,23 +1,40 @@
using Iceshrimp.Backend.Core.Configuration;
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.Parsing;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.MfmSharp;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
public class MfmRenderer(IOptions<Config.InstanceSection> config)
{
private readonly MfmConverter _converter = new(config);
public readonly record struct MfmRenderData(MarkupString Html, List<MfmInlineMedia> InlineMedia);
public async Task<MarkupString?> Render(
string? text, string? host, List<Note.MentionedUser> mentions, List<Emoji> emoji, string rootElement
[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 = Mfm.parse(text);
var serialized = await _converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement);
return new MarkupString(serialized);
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;
}
}

View file

@ -3,6 +3,7 @@ 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;
@ -12,9 +13,10 @@ 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)
{
@ -22,28 +24,35 @@ public class NoteRenderer(
var allNotes = ((Note?[]) [note, note.Reply, note.Renote]).NotNull().ToList();
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var users = await GetUsers(allNotes);
var attachments = await GetAttachments(allNotes);
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 await Render(note, users, mentions, emoji, attachments);
return Render(note, users, mentions, emoji, attachments, polls);
}
private async Task<PreviewNote> Render(
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, 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 = await mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span"),
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],
Attachments = attachments[note.Id]?.Where(p => !inlineMediaUrls.Contains(p.Url)).ToList(),
Poll = polls.GetValueOrDefault(note.Id),
CreatedAt = note.CreatedAt.ToDisplayStringTz(),
UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
};
@ -51,7 +60,7 @@ public class NoteRenderer(
return res;
}
private async Task<Dictionary<string, List<Note.MentionedUser>>> GetMentions(List<Note> notes)
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, _ => []);
@ -69,7 +78,7 @@ public class NoteRenderer(
p => users.Where(u => p.Mentions.Contains(u.Key)).Select(u => u.Value).ToList());
}
private async Task<Dictionary<string, List<Emoji>>> GetEmoji(List<Note> notes)
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, _ => []);
@ -78,13 +87,13 @@ public class NoteRenderer(
return notes.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
}
private async Task<List<PreviewUser>> GetUsers(List<Note> notes)
private async Task<List<PreviewUser>> GetUsersAsync(List<Note> notes)
{
if (notes is []) return [];
return await userRenderer.RenderMany(notes.Select(p => p.User).Distinct().ToList());
return await userRenderer.RenderManyAsync(notes.Select(p => p.User).Distinct().ToList());
}
private async Task<Dictionary<string, List<PreviewAttachment>?>> GetAttachments(List<Note> notes)
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,
@ -92,28 +101,48 @@ public class NoteRenderer(
var ids = notes.SelectMany(p => p.FileIds).ToList();
var files = await db.DriveFiles.Where(p => ids.Contains(p.Id)).ToListAsync();
return notes.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => files
.Where(f => p.FileIds.Contains(f.Id))
.Select(f => new PreviewAttachment
{
MimeType = f.Type,
Url = f.AccessUrl,
Name = f.Name,
Alt = f.Comment,
Sensitive = f.IsSensitive
})
.ToList());
return notes
.ToDictionary<Note, string, List<PreviewAttachment>?>(p => p.Id,
p => files
.Where(f => p.FileIds.Contains(f.Id))
.Select(f => new PreviewAttachment
{
MimeType = f.Type,
Url = mediaProxy.GetProxyUrl(f),
Name = f.Name,
Alt = f.Comment,
Sensitive = f.IsSensitive
})
.ToList());
}
public async Task<List<PreviewNote>> RenderMany(List<Note> notes)
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 GetUsers(allNotes);
var mentions = await GetMentions(allNotes);
var emoji = await GetEmoji(allNotes);
var attachments = await GetAttachments(allNotes);
return await notes.Select(p => Render(p, users, mentions, emoji, attachments)).AwaitAllAsync().ToListAsync();
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();
}
}
}

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ 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;
@ -12,6 +13,7 @@ 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;
@ -27,8 +29,9 @@ public class ActivityPubController(
QueueService queues,
ActivityPub.NoteRenderer noteRenderer,
ActivityPub.UserRenderer userRenderer,
IOptions<Config.InstanceSection> config
) : ControllerBase
IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security
) : ControllerBase, IScopedService
{
[HttpGet("/notes/{id}")]
[AuthorizedFetch]
@ -52,27 +55,28 @@ public class ActivityPubController(
[HttpGet("/notes/{id}/activity")]
[AuthorizedFetch]
[OverrideResultType<ASAnnounce>]
[OverrideResultType<ASActivity>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetRenote(string id)
public async Task<JObject> GetNoteActivity(string id)
{
var actor = HttpContext.GetActor();
var note = await db.Notes
.IncludeCommonProperties()
.EnsureVisibleFor(actor)
.Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null)
.Where(p => p.Id == id && p.UserHost == null)
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
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();
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")]
@ -90,6 +94,7 @@ public class ActivityPubController(
var replies = await db.Notes
.Where(p => p.ReplyId == id)
.EnsureVisibleFor(actor)
.OrderByDescending(p => p.Id)
.Select(p => new Note { Id = p.Id, Uri = p.Uri })
.ToListAsync();
@ -105,15 +110,53 @@ public class ActivityPubController(
return res.Compact();
}
[HttpGet("/threads/{id}")]
[AuthorizedFetch]
[OverrideResultType<ASOrderedCollection>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetThread(string id)
{
var actor = HttpContext.GetActor();
var thread = await db.NoteThreads
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == id && p.User != null && p.User.IsLocalUser) ??
throw GracefulException.NotFound("Thread not found");
var notes = await db.Notes
.Where(p => p.ThreadId == id)
.EnsureVisibleFor(actor)
.OrderByDescending(p => p.Id)
.Select(p => new Note { Id = p.Id, Uri = p.Uri })
.ToListAsync();
var rendered = notes.Select(noteRenderer.RenderLite).Cast<ASObject>().ToList();
var res = new ASOrderedCollection
{
Id = thread.GetPublicUri(config.Value),
AttributedTo = [new ASObjectBase(thread.User!.GetPublicUri(config.Value))],
TotalItems = (ulong)rendered.Count,
Items = rendered
};
return res.Compact();
}
[HttpGet("/users/{id}")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[OverrideResultType<ASActor>]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ActionResult<JObject>> GetUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id);
var user = await db.Users
.IncludeCommonProperties()
.Include(p => p.Avatar)
.Include(p => p.Banner)
.FirstOrDefaultAsync(p => p.Id == id);
if (user == null) throw GracefulException.NotFound("User not found");
if (user.IsRemoteUser)
{
@ -128,6 +171,7 @@ public class ActivityPubController(
[HttpGet("/users/{id}/collections/featured")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[OverrideResultType<ASOrderedCollection>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
@ -156,8 +200,71 @@ 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)]
@ -180,6 +287,8 @@ 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");
@ -211,6 +320,7 @@ public class ActivityPubController(
[HttpGet("/emoji/{name}")]
[AuthorizedFetch]
[OutputCache(PolicyName = "federation")]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[OverrideResultType<ASEmoji>]
[ProducesResults(HttpStatusCode.OK)]
@ -223,10 +333,10 @@ public class ActivityPubController(
var rendered = new ASEmoji
{
Id = emoji.GetPublicUri(config.Value),
Name = emoji.Name,
Image = new ASImage { Url = new ASLink(emoji.PublicUrl) }
Name = $":{emoji.Name}:",
Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl), MediaType = emoji.Type }
};
return LdHelpers.Compact(rendered);
}
}
}

View file

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

View file

@ -9,6 +9,7 @@ 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;
@ -17,6 +18,7 @@ namespace Iceshrimp.Backend.Controllers.Federation;
[FederationApiController]
[Route("/.well-known")]
[EnableCors("well-known")]
[OutputCache(PolicyName = "federation")]
public class WellKnownController(IOptions<Config.InstanceSection> config, DatabaseContext db) : ControllerBase
{
[HttpGet("webfinger")]

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
@ -12,6 +13,7 @@ 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;
@ -104,10 +106,9 @@ public class AccountController(
IsSensitive = false,
MimeType = request.Avatar.ContentType
};
var avatar = await driveSvc.StoreFile(request.Avatar.OpenReadStream(), user, rq);
var avatar = await driveSvc.StoreFileAsync(request.Avatar.OpenReadStream(), user, rq);
user.Avatar = avatar;
user.AvatarBlurhash = avatar.Blurhash;
user.AvatarUrl = avatar.AccessUrl;
}
if (request.Banner != null)
@ -118,10 +119,9 @@ public class AccountController(
IsSensitive = false,
MimeType = request.Banner.ContentType
};
var banner = await driveSvc.StoreFile(request.Banner.OpenReadStream(), user, rq);
var banner = await driveSvc.StoreFileAsync(request.Banner.OpenReadStream(), user, rq);
user.Banner = banner;
user.BannerBlurhash = banner.Blurhash;
user.BannerUrl = banner.AccessUrl;
}
user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
@ -139,12 +139,11 @@ public class AccountController(
var id = user.AvatarId;
user.AvatarId = null;
user.AvatarUrl = null;
user.AvatarBlurhash = null;
db.Update(user);
await db.SaveChangesAsync();
await driveSvc.RemoveFile(id);
await driveSvc.RemoveFileAsync(id);
}
return await VerifyUserCredentials();
@ -161,17 +160,36 @@ public class AccountController(
var id = user.BannerId;
user.BannerId = null;
user.BannerUrl = null;
user.BannerBlurhash = null;
db.Update(user);
await db.SaveChangesAsync();
await driveSvc.RemoveFile(id);
await driveSvc.RemoveFileAsync(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)]
@ -181,13 +199,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.GetUpdatedUser(user), localUser);
return await userRenderer.RenderAsync(await userResolver.GetUpdatedUserAsync(user), localUser);
}
[HttpPost("{id}/follow")]
@ -204,8 +222,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");
@ -237,8 +255,8 @@ public class AccountController(
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.UnfollowUserAsync(user, followee);
return RenderRelationship(followee);
@ -253,14 +271,14 @@ public class AccountController(
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot unfollow yourself");
var follower = await db.Followings
.Where(p => p.FolloweeId == user.Id && p.FollowerId == id)
.Select(p => p.Follower)
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
await userSvc.RemoveFromFollowersAsync(user, follower);
return RenderRelationship(follower);
}
@ -279,8 +297,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);
@ -302,8 +320,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);
@ -323,8 +341,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);
@ -344,8 +362,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);
@ -376,7 +394,10 @@ public class AccountController(
string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query
)
{
var user = HttpContext.GetUserOrFail();
var user = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound();
return await db.Notes
@ -403,8 +424,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");
@ -439,8 +460,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");
@ -469,8 +490,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 [];
}
@ -594,8 +615,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")]
@ -617,8 +638,8 @@ public class AccountController(
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
}
[HttpGet("lookup")]
@ -631,7 +652,7 @@ public class AccountController(
var localUser = HttpContext.GetUser();
var user = await userResolver.ResolveOrNullAsync(acct, flags) ?? throw GracefulException.RecordNotFound();
user = await userResolver.GetUpdatedUser(user);
user = await userResolver.GetUpdatedUserAsync(user);
return await userRenderer.RenderAsync(user, localUser);
}
@ -655,4 +676,4 @@ public class AccountController(
ShowingReblogs = true //FIXME
};
}
}
}

View file

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

View file

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

View file

@ -21,7 +21,11 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase
public class InstanceController(
IOptions<Config.InstanceSection> instance,
DatabaseContext db,
MetaService meta
) : ControllerBase
{
[HttpGet("/api/v1/instance")]
[ProducesResults(HttpStatusCode.OK)]
@ -33,15 +37,17 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
var instanceCount = await db.Instances.LongCountAsync();
var (instanceName, instanceDescription, adminContact) =
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
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.Get(MetaEntity.VapidPublicKey);
var vapidKey = await meta.GetAsync(MetaEntity.VapidPublicKey);
return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact)
{
Stats = new InstanceStats(userCount, noteCount, instanceCount),
Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() }
Pleroma = new PleromaInstanceExtensions { VapidPublicKey = vapidKey, Metadata = new InstanceMetadata() },
Rules = await GetRules()
};
}
@ -50,16 +56,19 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
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.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
await meta.GetManyAsync(MetaEntity.InstanceName, MetaEntity.InstanceDescription,
MetaEntity.AdminContactEmail);
return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact)
{
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } }
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } },
Rules = await GetRules()
};
}
@ -72,14 +81,25 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
{
Id = p.Id,
Shortcode = p.Name,
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
Url = p.GetAccessUrl(instance.Value),
StaticUrl = p.GetAccessUrl(instance.Value), //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();
@ -88,7 +108,7 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceExtendedDescription> GetExtendedDescription()
{
var description = await meta.Get(MetaEntity.InstanceDescription);
var description = await meta.GetAsync(MetaEntity.InstanceDescription);
return new InstanceExtendedDescription(description);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -13,68 +15,117 @@ public class UserRenderer(
IOptions<Config.InstanceSection> config,
IOptionsSnapshot<Config.SecuritySection> security,
MfmConverter mfmConverter,
DatabaseContext db
)
DatabaseContext db,
FlagService flags
) : IScopedService
{
private readonly string _transparent = $"https://{config.Value.WebDomain}/assets/transparent.png";
public async Task<AccountEntity> RenderAsync(
User user, UserProfile? profile, User? localUser, IEnumerable<EmojiEntity>? emoji = null, bool source = false
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
)
{
var acct = user.Username;
if (user.IsRemoteUser)
acct += $"@{user.Host}";
var profileEmoji = emoji?.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([user]);
var profileEmoji = data?.Emoji.Where(p => user.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([user]);
var mentions = profile?.Mentions ?? [];
var fields = profile != null
? await profile.Fields
.Select(async p => new Field
{
Name = p.Name,
Value = await mfmConverter.ToHtmlAsync(p.Value, mentions, user.Host),
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
})
.AwaitAllAsync()
: null;
var fields = profile?.Fields
.Select(p => new Field
{
Name = p.Name,
Value = (mfmConverter.ToHtml(p.Value, mentions, user.Host)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
});
var fieldsSource = source
? profile?.Fields.Select(p => new Field { Name = p.Name, Value = p.Value }).ToList() ?? []
: [];
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.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value),
AvatarUrl = user.GetAvatarUrl(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 = await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host),
Note = mfmConverter.ToHtml(profile?.Description ?? "", mentions, user.Host).Html,
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.AvatarUrl ?? user.GetIdenticonUrlPng(config.Value), //TODO
HeaderUrl = user.BannerUrl ?? _transparent,
HeaderStaticUrl = user.BannerUrl ?? _transparent, //TODO
MovedToAccount = null, //TODO
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
IsBot = user.IsBot,
IsDiscoverable = user.IsExplorable,
Fields = fields?.ToList() ?? [],
Emoji = profileEmoji
Emoji = profileEmoji,
Pleroma = flags?.IsPleroma.Value == true
? new PleromaUserExtensions
{
IsAdmin = user.IsAdmin,
IsModerator = user.IsModerator,
Favicon = favicon!
} : null,
Akkoma = flags?.IsPleroma.Value == true
? new AkkomaUserExtensions
{
Instance = new AkkomaInstanceEntity
{
Name = user.Host ?? config.Value.AccountDomain,
NodeInfo = new AkkomaNodeInfoEntity
{
Software = new AkkomaNodeInfoSoftwareEntity
{
Name = softwareName,
Version = softwareVersion
}
}
}
} : null
};
if (localUser is null && security.Value.PublicPreview == Enums.PublicPreview.RestrictedNoMedia) //TODO
{
res.AvatarUrl = user.GetIdenticonUrlPng(config.Value);
res.AvatarStaticUrl = user.GetIdenticonUrlPng(config.Value);
res.AvatarUrl = user.GetIdenticonUrl(config.Value);
res.AvatarStaticUrl = user.GetIdenticonUrl(config.Value);
res.HeaderUrl = _transparent;
res.HeaderStaticUrl = _transparent;
}
@ -87,8 +138,9 @@ 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)
};
@ -97,7 +149,7 @@ public class UserRenderer(
return res;
}
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<User> users)
private async Task<List<EmojiEntity>> GetEmojiAsync(IEnumerable<User> users)
{
var ids = users.SelectMany(p => p.Emojis).ToList();
if (ids.Count == 0) return [];
@ -108,24 +160,63 @@ public class UserRenderer(
{
Id = p.Id,
Shortcode = p.Name,
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
Url = p.GetAccessUrl(config.Value),
StaticUrl = p.GetAccessUrl(config.Value), //TODO
VisibleInPicker = true,
Category = p.Category
})
.ToListAsync();
}
private async Task<Dictionary<string, string?>> GetAvatarAltAsync(IEnumerable<User> users)
{
var ids = users.Select(p => p.Id).ToList();
return await db.Users
.Where(p => ids.Contains(p.Id))
.Include(p => p.Avatar)
.ToDictionaryAsync(p => p.Id, p => p.Avatar?.Comment);
}
private async Task<Dictionary<string, string?>> GetBannerAltAsync(IEnumerable<User> users)
{
var ids = users.Select(p => p.Id).ToList();
return await db.Users
.Where(p => ids.Contains(p.Id))
.Include(p => p.Banner)
.ToDictionaryAsync(p => p.Id, p => p.Banner?.Comment);
}
public async Task<AccountEntity> RenderAsync(User user, User? localUser, List<EmojiEntity>? emoji = null)
{
return await RenderAsync(user, user.UserProfile, localUser, emoji);
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)
{
var userList = users.ToList();
if (userList.Count == 0) return [];
var emoji = await GetEmoji(userList);
return await userList.Select(p => RenderAsync(p, localUser, emoji)).AwaitAllAsync();
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();
}
}
private class UserRendererDto
{
public required List<EmojiEntity> Emoji;
public required Dictionary<string, string?> AvatarAlt;
public required Dictionary<string, string?> BannerAlt;
}
}

View file

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

View file

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

View file

@ -47,7 +47,15 @@ public enum AttachmentType
Audio
}
public class AttachmentMetadata
public class AttachmentMetadata(int width, int height)
{
//TODO
}
[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;
}

View file

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

View file

@ -1,21 +1,25 @@
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 : IEntity
public class NotificationEntity : IIdentifiable
{
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("pleroma")] public required PleromaNotificationExtensions Pleroma { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
[J("created_at")] public required string CreatedAt { get; set; }
[J("type")] public required string Type { get; set; }
[J("account")] public required AccountEntity Notifier { get; set; }
[J("status")] public required StatusEntity? Note { get; set; }
[J("id")] public required string Id { get; set; }
[J("emoji")] public string? Emoji { get; set; }
[J("emoji_url")] public string? EmojiUrl { get; set; }
[J("pleroma")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required PleromaNotificationExtensions? Pleroma { get; set; }
public static string EncodeType(NotificationType type, bool isPleroma)
{

View file

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

View file

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

View file

@ -1,11 +1,11 @@
using System.Text.Json.Serialization;
using Iceshrimp.Backend.Core.Database;
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 RelationshipEntity : IEntity
public class RelationshipEntity : IIdentifiable
{
[J("following")] public required bool Following { get; set; }
[J("followed_by")] public required bool FollowedBy { get; set; }

View file

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

View file

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

View file

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

View file

@ -1,3 +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.Extensions;
@ -32,15 +33,17 @@ 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 InstanceUrls Urls => new(config.Instance);
[J("urls")] public InstanceUrlsV1 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 InstanceUrls(Config.InstanceSection config)
public class InstanceUrlsV1(Config.InstanceSection config)
{
[J("streaming_api")] public string StreamingApi => $"wss://{config.WebDomain}";
}
@ -95,5 +98,5 @@ public class InstancePollConfiguration
public class InstanceReactionConfiguration
{
[J("max_reactions")] public int MaxOptions => 1;
[J("max_reactions")] public int MaxOptions => 100;
}

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mime;
using AsyncKeyedLock;
@ -11,16 +12,15 @@ 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,6 +71,32 @@ 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")]
@ -96,14 +122,6 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance");
var shouldShowContext = await db.Notes
.Where(p => p.Id == id)
.FilterHidden(user, db)
.AnyAsync();
if (!shouldShowContext)
return new StatusContext { Ancestors = [], Descendants = [] };
// Akkoma-FE calls /context on boosts
if (note.IsPureRenote)
return await GetStatusContext(note.RenoteId!);
@ -385,6 +403,12 @@ 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)
@ -405,16 +429,25 @@ public class StatusController(
if (token.AutoDetectQuotes && request.Text != null)
{
var parsed = MfmParser.Parse(request.Text);
quoteUri = MfmParser.Parse(request.Text).LastOrDefault() switch
quoteUri = parsed.LastOrDefault() switch
{
MfmUrlNode urlNode => urlNode.Url,
MfmLinkNode linkNode => linkNode.Url,
_ => quoteUri
};
if (quoteUri != null)
parsed = parsed.SkipLast(1);
newText = MfmSerializer.Serialize(parsed).Trim();
newText = quoteUri != null ? parsed.SkipLast(1).Serialize() : parsed.Serialize();
if (
newText.AsSpan().Trim().Length == 0
&& request.Cw?.AsSpan().Trim().Length is null or 0
&& request.Poll is null or { Options.Count: 0 }
&& attachments?.Count is null or 0
)
{
quoteUri = null;
newText = null;
}
}
if (request is { Sensitive: true, MediaIds.Count: > 0 })
@ -692,4 +725,4 @@ public class StatusController(
await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync();
return await GetNote(id);
}
}
}

View file

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

View file

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

View file

@ -25,7 +25,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel
public bool IsSubscribed => _lists.Count != 0;
public bool IsAggregate => true;
public async Task Subscribe(StreamingRequestMessage msg)
public async Task SubscribeAsync(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.ScopeFactory.CreateAsyncScope();
await using var scope = connection.GetAsyncServiceScope();
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 Unsubscribe(StreamingRequestMessage msg)
public async Task UnsubscribeAsync(StreamingRequestMessage msg)
{
if (msg.List == null)
{
@ -128,8 +128,8 @@ public class ListChannel(WebSocketConnection connection) : IChannel
var wrapped = IsApplicable(note);
if (wrapped == null) return;
if (connection.IsFiltered(note)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(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.ScopeFactory.CreateAsyncScope();
await using var scope = connection.GetAsyncServiceScope();
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.ScopeFactory.CreateAsyncScope();
await using var scope = connection.GetAsyncServiceScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var members = await db.UserListMembers.Where(p => p.UserListId == list.Id)

View file

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

View file

@ -17,12 +17,12 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
public bool IsSubscribed { get; private set; }
public bool IsAggregate => false;
public async Task Subscribe(StreamingRequestMessage _)
public async Task SubscribeAsync(StreamingRequestMessage _)
{
if (IsSubscribed) return;
IsSubscribed = true;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
await using var scope = connection.GetAsyncServiceScope();
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 Unsubscribe(StreamingRequestMessage _)
public Task UnsubscribeAsync(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.ScopeFactory.CreateAsyncScope();
if (await connection.IsMutedThread(note, scope)) return;
await using var scope = connection.GetAsyncServiceScope();
if (await connection.IsMutedThreadAsync(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.ScopeFactory.CreateAsyncScope();
await using var scope = connection.GetAsyncServiceScope();
var renderer = scope.ServiceProvider.GetRequiredService<NoteRenderer>();
var intermediate = await renderer.RenderAsync(note, connection.Token.User);
@ -174,8 +174,8 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly)
if (!IsApplicable(notification)) return;
if (IsFiltered(notification)) return;
await using var scope = connection.ScopeFactory.CreateAsyncScope();
if (notification.Note != null && await connection.IsMutedThread(notification.Note, scope, true))
await using var scope = connection.GetAsyncServiceScope();
if (notification.Note != null && await connection.IsMutedThreadAsync(notification.Note, scope, true))
return;
var renderer = scope.ServiceProvider.GetRequiredService<NotificationRenderer>();

View file

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

View file

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

View file

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

View file

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

View file

@ -3,11 +3,13 @@ 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;
@ -15,7 +17,7 @@ namespace Iceshrimp.Backend.Controllers.Pleroma;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class EmojiController(DatabaseContext db) : ControllerBase
public class EmojiController(IOptions<Config.InstanceSection> instance, DatabaseContext db) : ControllerBase
{
[HttpGet("/api/v1/pleroma/emoji")]
[ProducesResults(HttpStatusCode.OK)]
@ -26,11 +28,11 @@ public class EmojiController(DatabaseContext db) : ControllerBase
.Select(p => KeyValuePair.Create(p.Name,
new PleromaEmojiEntity
{
ImageUrl = p.PublicUrl,
ImageUrl = p.GetAccessUrl(instance.Value),
Tags = new[] { p.Category ?? "" }
}))
.ToArrayAsync();
return new Dictionary<string, PleromaEmojiEntity>(emoji);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Backend.Core.Tasks;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Configuration;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
@ -276,7 +277,7 @@ public class AdminController(
[ProducesResults(HttpStatusCode.OK)]
public async Task SubscribeToRelay(RelaySchemas.RelayRequest rq)
{
await relaySvc.SubscribeToRelay(rq.Inbox);
await relaySvc.SubscribeToRelayAsync(rq.Inbox);
}
[HttpDelete("relays/{id}")]
@ -286,7 +287,7 @@ public class AdminController(
{
var relay = await db.Relays.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Relay not found");
await relaySvc.UnsubscribeFromRelay(relay);
await relaySvc.UnsubscribeFromRelayAsync(relay);
}
[HttpPost("drive/prune-expired-media")]
@ -294,12 +295,30 @@ public class AdminController(
public async Task PruneExpiredMedia([FromServices] IServiceScopeFactory factory)
{
await using var scope = factory.CreateAsyncScope();
await new MediaCleanupTask().Invoke(scope.ServiceProvider);
await new MediaCleanupTask().InvokeAsync(scope.ServiceProvider);
}
[HttpPost("tasks/{id}/run")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public void RunCronTask([FromServices] CronService cronSvc, string id)
{
var task = cronSvc.Tasks.FirstOrDefault(p => p.Task.GetType().FullName == id)
?? throw GracefulException.NotFound("Task not found");
Task.Factory.StartNew(async () =>
{
await cronSvc.RunCronTaskAsync(task.Task, task.Trigger);
task.Trigger.UpdateNextTrigger();
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
[HttpGet("policy")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePolicies();
public async Task<List<string>> GetAvailablePolicies() => await policySvc.GetAvailablePoliciesAsync();
[HttpGet("policy/{name}")]
[ProducesResults(HttpStatusCode.OK)]
@ -307,7 +326,7 @@ public class AdminController(
public async Task<IPolicyConfiguration> GetPolicyConfiguration(string name)
{
var raw = await db.PolicyConfiguration.Where(p => p.Name == name).Select(p => p.Data).FirstOrDefaultAsync();
return await policySvc.GetConfiguration(name, raw) ?? throw GracefulException.NotFound("Policy not found");
return await policySvc.GetConfigurationAsync(name, raw) ?? throw GracefulException.NotFound("Policy not found");
}
[HttpPut("policy/{name}")]
@ -317,7 +336,8 @@ public class AdminController(
string name, [SwaggerBodyExample("{\n \"enabled\": true\n}")] JsonDocument body
)
{
var type = await policySvc.GetConfigurationType(name) ?? throw GracefulException.NotFound("Policy not found");
var type = await policySvc.GetConfigurationTypeAsync(name) ??
throw GracefulException.NotFound("Policy not found");
var data = body.Deserialize(type, JsonSerialization.Options) as IPolicyConfiguration;
if (data?.GetType() != type) throw GracefulException.BadRequest("Invalid policy config");
var serialized = JsonSerializer.Serialize(data, type, JsonSerialization.Options);
@ -327,7 +347,7 @@ public class AdminController(
.On(p => new { p.Name })
.RunAsync();
await policySvc.Update();
await policySvc.UpdateAsync();
}
[UseNewtonsoftJson]

View file

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

View file

@ -1,8 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
@ -10,6 +13,7 @@ using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -23,63 +27,91 @@ public class DriveController(
IOptionsSnapshot<Config.StorageSection> options,
ILogger<DriveController> logger,
DriveService driveSvc,
QueueService queueSvc
QueueService queueSvc,
HttpClient httpClient
) : ControllerBase
{
private const string CacheControl = "max-age=31536000, immutable";
[EnableCors("drive")]
[HttpGet("/files/{accessKey}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[EnableRateLimiting("proxy")]
[HttpGet("/files/{accessKey}/{version?}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey)
public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
{
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey ||
p.PublicAccessKey == accessKey ||
p.ThumbnailAccessKey == accessKey);
if (file == null)
return await GetFileByAccessKey(accessKey, version, null);
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmojiById(string id)
{
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Emoji not found");
if (!options.Value.ProxyRemoteMedia || emoji.Host == null)
return Redirect(emoji.RawPublicUrl);
return await ProxyAsync(emoji.RawPublicUrl, null, null);
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/avatars/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAvatarByUserId(string userId, string? version)
{
var user = await db.Users.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == userId)
?? throw GracefulException.NotFound("User not found");
if (user.Avatar is null)
{
Response.Headers.CacheControl = "max-age=86400";
throw GracefulException.NotFound("File not found");
var stream = await IdenticonHelper.GetIdenticonAsync(user.Id);
Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false);
}
if (file.StoredInternal)
{
var pathBase = options.Value.Local?.Path;
if (string.IsNullOrWhiteSpace(pathBase))
{
logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey);
throw GracefulException.NotFound("File not found");
}
if (!options.Value.ProxyRemoteMedia)
return Redirect(user.Avatar.RawThumbnailAccessUrl);
var path = Path.Join(pathBase, accessKey);
var stream = System.IO.File.OpenRead(path);
return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar);
}
Response.Headers.CacheControl = "max-age=31536000, immutable";
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
else
{
if (file.IsLink)
{
//TODO: handle remove media proxying
return NoContent();
}
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/banners/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
{
var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId)
?? throw GracefulException.NotFound("User not found");
var stream = await objectStorage.GetFileAsync(accessKey);
if (stream == null)
{
logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
throw GracefulException.NotFound("File not found");
}
if (user.Banner is null)
return NoContent();
Response.Headers.CacheControl = "max-age=31536000, immutable";
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
if (!options.Value.ProxyRemoteMedia)
return Redirect(user.Banner.RawThumbnailAccessUrl);
return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner);
}
[EnableCors("drive")]
[HttpGet("/identicon/{userId}")]
[HttpGet("/identicon/{userId}.png")]
[Produces(MediaTypeNames.Image.Png)]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetIdenticonByUserId(string userId)
{
var stream = await IdenticonHelper.GetIdenticonAsync(userId);
Response.Headers.CacheControl = CacheControl;
return new InlineFileStreamResult(stream, "image/png", $"{userId}.png", false);
}
[HttpPost]
@ -88,16 +120,17 @@ public class DriveController(
[Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[MaxRequestSizeIsMaxUploadSize]
public async Task<DriveFileResponse> UploadFile(IFormFile file)
public async Task<DriveFileResponse> UploadFile(IFormFile file, [FromQuery] string? folderId)
{
var user = HttpContext.GetUserOrFail();
var request = new DriveFileCreationRequest
{
Filename = file.FileName,
MimeType = file.ContentType,
IsSensitive = false
IsSensitive = false,
FolderId = folderId
};
var res = await driveSvc.StoreFile(file.OpenReadStream(), user, request);
var res = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, request);
return await GetFileById(res.Id);
}
@ -110,18 +143,23 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileById(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse
{
Id = file.Id,
Url = file.AccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
}
@ -134,18 +172,23 @@ public class DriveController(
public async Task<DriveFileResponse> GetFileByHash(string sha256)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) ??
throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles
.Include(p => p.UserAvatar)
.Include(p => p.UserBanner)
.FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256)
?? throw GracefulException.NotFound("File not found");
return new DriveFileResponse
{
Id = file.Id,
Url = file.AccessUrl,
ThumbnailUrl = file.ThumbnailAccessUrl,
Url = file.RawAccessUrl,
ThumbnailUrl = file.RawThumbnailAccessUrl,
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
}
@ -159,8 +202,8 @@ public class DriveController(
public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
file.Name = request.Filename ?? file.Name;
file.IsSensitive = request.Sensitive ?? file.IsSensitive;
@ -178,8 +221,8 @@ public class DriveController(
public async Task<IActionResult> DeleteFile(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id)
?? throw GracefulException.NotFound("File not found");
if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file))
throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar");
@ -193,4 +236,318 @@ public class DriveController(
return StatusCode(StatusCodes.Status202Accepted);
}
}
[HttpPost("{id}/move")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> UpdateFileParent(string id, DriveMoveRequest request)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
if (request.FolderId != null)
{
var parent = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id);
if (parent == null)
throw GracefulException.NotFound("The new parent folder doesn't exist");
}
file.FolderId = request.FolderId;
await db.SaveChangesAsync();
return await GetFileById(file.Id);
}
[HttpGet("folder")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
public async Task<DriveFolderResponse> GetRootFolder()
{
return await GetFolder(null);
}
[HttpPost("folder")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> CreateFolder(DriveFolderRequest request)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(request.Name))
throw GracefulException.BadRequest("Folder name cannot be empty");
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == request.Name.Trim().ToLower()
&& p.ParentId == request.ParentId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists");
var driveFolder = await driveSvc.CreateFolderAsync(user, request.Name.Trim(), request.ParentId);
return new DriveFolderResponse
{
Id = driveFolder.Id,
Name = driveFolder.Name,
ParentId = driveFolder.ParentId
};
}
[HttpGet("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFolderResponse> GetFolder(string? id)
{
var user = HttpContext.GetUserOrFail();
var folder = id != null
? await db.DriveFolders.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound()
: null;
var driveFiles = await db.DriveFiles
.Where(p => p.FolderId == id && p.UserId == user.Id)
.OrderByDescending(p => p.CreatedAt)
.ThenBy(p => p.Id)
.Select(p => new DriveFileResponse
{
Id = p.Id,
Url = p.RawAccessUrl,
ThumbnailUrl = p.RawThumbnailAccessUrl,
Filename = p.Name,
ContentType = p.Type,
Sensitive = p.IsSensitive,
Description = p.Comment,
IsAvatar = p.UserAvatar != null,
IsBanner = p.UserBanner != null
})
.ToListAsync();
var driveFolders = await db.DriveFolders
.Where(p => p.ParentId == id && p.UserId == user.Id)
.OrderBy(p => p.Name)
.ThenBy(p => p.Id)
.Select(p => new DriveFolderResponse
{
Id = p.Id, Name = p.Name, ParentId = p.ParentId
})
.ToListAsync();
return new DriveFolderResponse
{
Id = folder?.Id,
Name = folder?.Name,
ParentId = folder?.ParentId,
Files = driveFiles,
Folders = driveFolders
};
}
[HttpPut("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> UpdateFolder(string id, [FromHybrid] string name)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(name))
throw GracefulException.BadRequest("Name must not be empty");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == name.Trim().ToLower()
&& p.ParentId == folder.ParentId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists");
folder.Name = name.Trim();
await db.SaveChangesAsync();
return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId };
}
[HttpDelete("folder/{id}")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
public async Task DeleteFolder(string id)
{
var user = HttpContext.GetUserOrFail();
if (string.IsNullOrWhiteSpace(id))
throw GracefulException.BadRequest("Cannot delete root folder");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
var driveFiles = await db.DriveFiles
.CountAsync(p => p.FolderId == id);
var driveFolders = await db.DriveFolders
.CountAsync(p => p.ParentId == id);
if (driveFiles != 0 || driveFolders != 0)
throw GracefulException.Conflict("Cannot delete a non-empty folder");
db.Remove(folder);
await db.SaveChangesAsync();
}
[HttpPost("folder/{id}/move")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[SuppressMessage("Performance", "CA1862", Justification = "string.Equals() cannot be used in DbSet LINQ operations")]
public async Task<DriveFolderResponse> UpdateFolderParent(string id, DriveMoveRequest request)
{
var user = HttpContext.GetUserOrFail();
if (request.FolderId == id)
throw GracefulException.BadRequest("Cannot move a folder into itself");
var folder = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id)
?? throw GracefulException.RecordNotFound();
if (request.FolderId != null)
{
var parent = await db.DriveFolders
.FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id);
if (parent == null)
throw GracefulException.NotFound("The new parent folder doesn't exist");
}
var existing = await db.DriveFolders
.AnyAsync(p => p.Name.ToLower() == folder.Name.ToLower()
&& p.ParentId == request.FolderId
&& p.UserId == user.Id);
if (existing)
throw GracefulException.Conflict("A folder with this name already exists in the new parent folder");
folder.ParentId = request.FolderId;
await db.SaveChangesAsync();
return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId, };
}
private async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version, DriveFile? file)
{
file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey
|| p.PublicAccessKey == accessKey
|| p.ThumbnailAccessKey == accessKey);
if (file == null)
{
Response.Headers.CacheControl = "max-age=86400";
throw GracefulException.NotFound("File not found");
}
if (file.IsLink)
{
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
if (!options.Value.ProxyRemoteMedia)
return Redirect(fetchUrl);
try
{
var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp")
? file.Name
: $"{file.Name}.webp";
return await ProxyAsync(fetchUrl, file.Type, filename);
}
catch (Exception e) when (e is not GracefulException)
{
throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true);
}
}
if (file.StoredInternal)
{
var pathBase = options.Value.Local?.Path;
if (string.IsNullOrWhiteSpace(pathBase))
{
logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey);
throw GracefulException.NotFound("File not found");
}
var path = Path.Join(pathBase, accessKey);
var stream = System.IO.File.OpenRead(path);
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
else
{
var stream = await objectStorage.GetFileAsync(accessKey);
if (stream == null)
{
logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
throw GracefulException.NotFound("File not found");
}
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
return Constants.BrowserSafeMimeTypes.Contains(file.Type)
? new InlineFileStreamResult(stream, file.Type, file.Name, true)
: File(stream, file.Type, file.Name, true);
}
}
private async Task<IActionResult> ProxyAsync(string url, string? expectedMediaType, string? filename)
{
try
{
// @formatter:off
var res = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!res.IsSuccessStatusCode)
throw GracefulException.BadGateway($"Failed to proxy request: response status was {res.StatusCode}", suppressLog: true);
if (res.Content.Headers.ContentType?.MediaType is not { } mediaType)
throw GracefulException.BadGateway("Failed to proxy request: remote didn't return Content-Type");
if (expectedMediaType != null && mediaType != expectedMediaType && !Constants.BrowserSafeMimeTypes.Contains(mediaType))
throw GracefulException.BadGateway("Failed to proxy request: content type mismatch", suppressLog: true);
// @formatter:on
Response.Headers.CacheControl = CacheControl;
Response.Headers.XContentTypeOptions = "nosniff";
var stream = await res.Content.ReadAsStreamAsync();
return Constants.BrowserSafeMimeTypes.Contains(mediaType)
? new InlineFileStreamResult(stream, mediaType, filename, true)
: File(stream, mediaType, filename, true);
}
catch (Exception e) when (e is not GracefulException)
{
throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true);
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,134 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Controllers.Web;
[ApiController]
[EnableRateLimiting("sliding")]
[Route("/api/iceshrimp/instance")]
[Produces(MediaTypeNames.Application.Json)]
public class InstanceController(
DatabaseContext db,
UserRenderer userRenderer,
IOptions<Config.InstanceSection> instanceConfig,
IOptionsSnapshot<Config.SecuritySection> securityConfig,
MetaService meta,
InstanceService instanceSvc
) : ControllerBase
{
[HttpGet]
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceResponse> GetInfo()
{
var limits = new Limitations { NoteLength = instanceConfig.Value.CharacterLimit };
return new InstanceResponse
{
AccountDomain = instanceConfig.Value.AccountDomain,
WebDomain = instanceConfig.Value.WebDomain,
Registration = (Registrations)securityConfig.Value.Registrations,
Name = await meta.GetAsync(MetaEntity.InstanceName),
Limits = limits
};
}
[HttpGet("rules")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<RuleResponse>> GetRules()
{
return await db.Rules
.OrderBy(p => p.Order)
.ThenBy(p => p.Id)
.Select(p => new RuleResponse { Id = p.Id, Text = p.Text, Description = p.Description })
.ToListAsync();
}
[HttpPost("rules")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<RuleResponse> CreateRule(RuleCreateRequest request)
{
var rule = await instanceSvc.CreateRuleAsync(request.Text.Trim(), request.Description?.Trim());
return new RuleResponse { Id = rule.Id, Text = rule.Text, Description = rule.Description };
}
[HttpPatch("rules/{id}")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<RuleResponse> UpdateRule(string id, RuleUpdateRequest request)
{
var rule = await db.Rules.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
var order = request.Order ?? 0;
var text = request.Text?.Trim() ?? rule.Text;
var description = request.Description != null
? string.IsNullOrWhiteSpace(request.Description)
? null
: request.Description.Trim()
: rule.Description;
var res = await instanceSvc.UpdateRuleAsync(rule, order, text, description);
return new RuleResponse { Id = res.Id, Text = res.Text, Description = res.Description };
}
[HttpDelete("rules/{id}")]
[Authenticate]
[Authorize("role:admin")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteRule(string id)
{
var rule = await db.Rules.FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.RecordNotFound();
var rules = await db.Rules
.Where(p => p.Order > rule.Order)
.ToListAsync();
db.Remove(rule);
foreach (var r in rules)
r.Order -= 1;
db.UpdateRange(rules);
await db.SaveChangesAsync();
}
[HttpGet("staff")]
[Authenticate]
[Authorize]
[ProducesResults(HttpStatusCode.OK)]
public async Task<StaffResponse> GetStaff()
{
var admins = db.Users
.Where(p => p.IsAdmin == true)
.OrderBy(p => p.UsernameLower);
var adminList = await userRenderer.RenderManyAsync(admins)
.ToListAsync();
var moderators = db.Users
.Where(p => p.IsAdmin == false && p.IsModerator == true)
.OrderBy(p => p.UsernameLower);
var moderatorList = await userRenderer.RenderManyAsync(moderators)
.ToListAsync();
return new StaffResponse { Admins = adminList, Moderators = moderatorList };
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,22 +14,23 @@ public class NoteRenderer(
UserRenderer userRenderer,
DatabaseContext db,
EmojiService emojiSvc,
MediaProxyService mediaProxy,
IOptions<Config.InstanceSection> config
)
) : IScopedService
{
public async Task<NoteResponse> RenderOne(
Note note, User? user, Filter.FilterContext? filterContext = null, NoteRendererDto? data = null
)
{
var res = await RenderBaseInternal(note, user, data);
var res = await RenderBaseInternalAsync(note, user, data);
var renote = note is { Renote: not null, IsPureRenote: true }
? await RenderRenote(note.Renote, user, data)
? await RenderRenoteAsync(note.Renote, user, data)
: null;
var quote = note is { Renote: not null, IsQuote: true } ? await RenderBase(note.Renote, user, data) : null;
var reply = note.Reply != null ? await RenderBase(note.Reply, user, data) : null;
var quote = note is { Renote: not null, IsQuote: true } ? await RenderBaseAsync(note.Renote, user, data) : null;
var reply = note.Reply != null ? await RenderBaseAsync(note.Reply, user, data) : null;
var filters = data?.Filters ?? await GetFilters(user, filterContext);
var filters = data?.Filters ?? await GetFiltersAsync(user, filterContext);
var filtered = FilterHelper.IsFiltered([note, note.Reply, note.Renote, note.Renote?.Renote], filters);
if (filtered.HasValue)
@ -37,6 +38,7 @@ public class NoteRenderer(
res.Filtered = new NoteFilteredSchema
{
Id = filtered.Value.filter.Id,
Name = filtered.Value.filter.Name,
Keyword = filtered.Value.keyword,
Hide = filtered.Value.filter.Action == Filter.FilterAction.Hide
};
@ -54,10 +56,10 @@ public class NoteRenderer(
return res;
}
private async Task<NoteWithQuote> RenderRenote(Note note, User? user, NoteRendererDto? data = null)
private async Task<NoteWithQuote> RenderRenoteAsync(Note note, User? user, NoteRendererDto? data = null)
{
var res = await RenderBaseInternal(note, user, data);
var quote = note.Renote is { IsPureRenote: false } ? await RenderBase(note.Renote, user, data) : null;
var res = await RenderBaseInternalAsync(note, user, data);
var quote = note.Renote is { IsPureRenote: false } ? await RenderBaseAsync(note.Renote, user, data) : null;
res.Quote = quote;
res.QuoteId = note.RenoteId;
@ -66,17 +68,19 @@ public class NoteRenderer(
return res;
}
private async Task<NoteBase> RenderBase(Note note, User? localUser, NoteRendererDto? data = null)
=> await RenderBaseInternal(note, localUser, data);
private async Task<NoteBase> RenderBaseAsync(Note note, User? localUser, NoteRendererDto? data = null)
=> await RenderBaseInternalAsync(note, localUser, data);
private async Task<NoteResponse> RenderBaseInternal(Note note, User? user, NoteRendererDto? data = null)
private async Task<NoteResponse> RenderBaseInternalAsync(Note note, User? user, NoteRendererDto? data = null)
{
var noteUser = (data?.Users ?? await GetUsers([note])).First(p => p.Id == note.User.Id);
var attachments = (data?.Attachments ?? await GetAttachments([note])).Where(p => note.FileIds.Contains(p.Id));
var reactions = (data?.Reactions ?? await GetReactions([note], user)).Where(p => p.NoteId == note.Id);
var liked = data?.LikedNotes?.Contains(note.Id) ??
await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
var emoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmoji([note]);
var noteUser = (data?.Users ?? await GetUsersAsync([note])).First(p => p.Id == note.User.Id);
var attachments =
(data?.Attachments ?? await GetAttachmentsAsync([note])).Where(p => note.FileIds.Contains(p.Id));
var reactions = (data?.Reactions ?? await GetReactionsAsync([note], user)).Where(p => p.NoteId == note.Id);
var liked = data?.LikedNotes?.Contains(note.Id)
?? await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
var emoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([note]);
var poll = (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.NoteId == note.Id);
return new NoteResponse
{
@ -87,6 +91,7 @@ public class NoteRenderer(
Text = note.Text,
Cw = note.Cw,
Visibility = (NoteVisibility)note.Visibility,
LocalOnly = note.LocalOnly,
User = noteUser,
Attachments = attachments.ToList(),
Reactions = reactions.ToList(),
@ -94,18 +99,19 @@ public class NoteRenderer(
Renotes = note.RenoteCount,
Replies = note.RepliesCount,
Liked = liked,
Emoji = emoji
Emoji = emoji,
Poll = poll
};
}
private async Task<List<UserResponse>> GetUsers(List<Note> notesList)
private async Task<List<UserResponse>> GetUsersAsync(List<Note> notesList)
{
if (notesList.Count == 0) return [];
var users = notesList.Select(p => p.User).DistinctBy(p => p.Id);
return await userRenderer.RenderMany(users).ToListAsync();
return await userRenderer.RenderManyAsync(users).ToListAsync();
}
private async Task<List<NoteAttachment>> GetAttachments(List<Note> notesList)
private async Task<List<NoteAttachment>> GetAttachmentsAsync(List<Note> notesList)
{
if (notesList.Count == 0) return [];
var ids = notesList.SelectMany(p => p.FileIds).Distinct();
@ -113,17 +119,18 @@ public class NoteRenderer(
return files.Select(p => new NoteAttachment
{
Id = p.Id,
Url = p.AccessUrl,
ThumbnailUrl = p.ThumbnailAccessUrl,
Url = mediaProxy.GetProxyUrl(p),
ThumbnailUrl = mediaProxy.GetThumbnailProxyUrl(p),
ContentType = p.Type,
Blurhash = p.Blurhash,
AltText = p.Comment,
IsSensitive = p.IsSensitive
IsSensitive = p.IsSensitive,
FileName = p.Name
})
.ToList();
}
private async Task<List<NoteReactionSchema>> GetReactions(List<Note> notes, User? user)
private async Task<List<NoteReactionSchema>> GetReactionsAsync(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -134,28 +141,30 @@ public class NoteRenderer(
.Select(p => new NoteReactionSchema
{
NoteId = p.First().NoteId,
Count = (int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
Reacted = db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
i.Reaction == p.First().Reaction &&
i.User == user),
Name = p.First().Reaction,
Url = null,
Sensitive = false,
Count =
(int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
Reacted =
db.NoteReactions.Any(i => i.NoteId == p.First().NoteId
&& i.Reaction == p.First().Reaction
&& i.User == user),
Name = p.First().Reaction,
Url = null,
Sensitive = false
})
.ToListAsync();
foreach (var item in res.Where(item => item.Name.StartsWith(':')))
{
var hit = await emojiSvc.ResolveEmoji(item.Name);
var hit = await emojiSvc.ResolveEmojiAsync(item.Name);
if (hit == null) continue;
item.Url = hit.PublicUrl;
item.Url = hit.GetAccessUrl(config.Value);
item.Sensitive = hit.Sensitive;
}
return res;
}
private async Task<List<string>> GetLikedNotes(List<Note> notes, User? user)
private async Task<List<string>> GetLikedNotesAsync(List<Note> notes, User? user)
{
if (user == null) return [];
if (notes.Count == 0) return [];
@ -172,13 +181,13 @@ public class NoteRenderer(
.ToList();
}
private async Task<List<Filter>> GetFilters(User? user, Filter.FilterContext? filterContext)
private async Task<List<Filter>> GetFiltersAsync(User? user, Filter.FilterContext? filterContext)
{
if (filterContext == null) return [];
return await db.Filters.Where(p => p.User == user && p.Contexts.Contains(filterContext.Value)).ToListAsync();
}
private async Task<List<EmojiResponse>> GetEmoji(IEnumerable<Note> notes)
private async Task<List<EmojiResponse>> GetEmojiAsync(IEnumerable<Note> notes)
{
var ids = notes.SelectMany(p => p.Emojis).ToList();
if (ids.Count == 0) return [];
@ -190,16 +199,48 @@ public class NoteRenderer(
Id = p.Id,
Name = p.Name,
Uri = p.Uri,
Aliases = p.Aliases,
Tags = p.Tags,
Category = p.Category,
PublicUrl = p.PublicUrl,
PublicUrl = p.GetAccessUrl(config.Value),
License = p.License,
Sensitive = p.Sensitive
})
.ToListAsync();
}
public async Task<IEnumerable<NoteResponse>> RenderMany(
private async Task<List<NotePollSchema>> GetPollsAsync(IEnumerable<Note> notes, User? user)
{
var polls = await db.Polls
.Where(p => notes.Contains(p.Note))
.ToListAsync();
var votes = user != null
? await db.PollVotes
.Where(p => notes.Contains(p.Note) && p.UserId == user.Id)
.ToListAsync()
: [];
return polls
.Select(p => new NotePollSchema
{
NoteId = p.NoteId,
ExpiresAt = p.ExpiresAt,
Multiple = p.Multiple,
Choices = p.Choices.Zip(p.Votes)
.Select((c, i) => new NotePollChoice
{
Value = c.First,
Votes = c.Second,
Voted = user != null
&& votes.Any(v => v.NoteId == p.NoteId && v.Choice == i)
})
.ToList(),
VotersCount = p.VotersCount
})
.ToList();
}
public async Task<IEnumerable<NoteResponse>> RenderManyAsync(
IEnumerable<Note> notes, User? user, Filter.FilterContext? filterContext = null
)
{
@ -208,12 +249,13 @@ public class NoteRenderer(
var allNotes = GetAllNotes(notesList);
var data = new NoteRendererDto
{
Users = await GetUsers(allNotes),
Attachments = await GetAttachments(allNotes),
Reactions = await GetReactions(allNotes, user),
Filters = await GetFilters(user, filterContext),
LikedNotes = await GetLikedNotes(allNotes, user),
Emoji = await GetEmoji(allNotes)
Users = await GetUsersAsync(allNotes),
Attachments = await GetAttachmentsAsync(allNotes),
Reactions = await GetReactionsAsync(allNotes, user),
Filters = await GetFiltersAsync(user, filterContext),
LikedNotes = await GetLikedNotesAsync(allNotes, user),
Emoji = await GetEmojiAsync(allNotes),
Polls = await GetPollsAsync(allNotes, user)
};
return await notesList.Select(p => RenderOne(p, user, filterContext, data)).AwaitAllAsync();
@ -227,5 +269,6 @@ public class NoteRenderer(
public List<string>? LikedNotes;
public List<NoteReactionSchema>? Reactions;
public List<UserResponse>? Users;
public List<NotePollSchema>? Polls;
}
}
}

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