Compare commits

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

471 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
367 changed files with 12512 additions and 3050 deletions

View file

@ -19,10 +19,10 @@ jobs:
- name: Print environment info
run: dotnet --info
- name: Build release artifacts
run: make release-artifacts "ARCHIVE_BASENAME=$REPO" "ARCHIVE_VERSION=$VERSION" 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 }}
VERSION: ${{ github.ref_name }}
RELEASE_VERSION: ${{ github.ref_name }}
- name: Upload artifacts
uses: actions/release-action@main
with:
@ -37,8 +37,8 @@ jobs:
# We always want to tag :{version} and :pre, but only tag :latest for stable releases, and (temporarily) v2024.1-beta releases
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
:

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,289 @@
## 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.

View file

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

View file

@ -22,7 +22,7 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
+ See [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs#L16-L24) and [here](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/branch/dev/Iceshrimp.Backend/Core/Federation/ActivityStreams/Contexts) to see all preloaded LD contexts we ship.
- 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.

View file

@ -10,9 +10,11 @@
[
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)
];

View file

@ -15,13 +15,12 @@
}
@if (OverflowsLg)
{
var offset = Links.Count - MaxItemsLg;
<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[offset..])
@foreach (var link in Links[MaxItemsLg..])
{
<li>
<NavBarLink Link="link"/>
@ -29,7 +28,10 @@
}
@if (Right is { Count: > 0 })
{
<li class="dropdown-spacer"></li>
if (MaxItemsLg != Links.Count)
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right)
{
<li>
@ -71,13 +73,12 @@
}
@if (OverflowsMd)
{
var offset = Links.Count - MaxItemsMd;
<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[offset..])
@foreach (var link in Links[MaxItemsMd..])
{
<li>
<NavBarLink Link="link"/>
@ -85,7 +86,10 @@
}
@if (Right is { Count: > 0 })
{
<li class="dropdown-spacer"></li>
if (MaxItemsMd != Links.Count)
{
<li class="dropdown-spacer"></li>
}
@foreach (var link in Right)
{
<li>

View file

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

View file

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

View file

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

View file

@ -8,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

@ -333,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.RawPublicUrl) }
Name = $":{emoji.Name}:",
Image = new ASImage { Url = new ASLink(emoji.RawPublicUrl), MediaType = emoji.Type }
};
return LdHelpers.Compact(rendered);
}
}
}

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;
@ -168,6 +170,26 @@ public class AccountController(
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)]
@ -177,8 +199,8 @@ 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");
@ -200,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");
@ -233,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);
@ -254,8 +276,8 @@ public class AccountController(
.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);
@ -275,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);
@ -298,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);
@ -319,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);
@ -340,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);
@ -372,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
@ -399,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");
@ -435,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");
@ -465,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 [];
}
@ -590,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")]
@ -613,8 +638,8 @@ public class AccountController(
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
.FirstOrDefaultAsync()
?? throw GracefulException.RecordNotFound();
}
[HttpGet("lookup")]
@ -651,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)).Html).AwaitAllAsync();
res.ForEach(p => p.Content = mfmConverter.ToHtml(p.Content, [], null).Html);
return res;
}

View file

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

View file

@ -46,7 +46,8 @@ public class InstanceController(
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()
};
}
@ -66,7 +67,8 @@ public class InstanceController(
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()
};
}
@ -87,6 +89,17 @@ public class InstanceController(
.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();

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Pleroma.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -13,38 +15,61 @@ 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 GetEmojiAsync([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)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
})
.AwaitAllAsync()
: null;
var fields = profile?.Fields
.Select(p => new Field
{
Name = p.Name,
Value = (mfmConverter.ToHtml(p.Value, mentions, user.Host)).Html,
VerifiedAt = p.IsVerified.HasValue && p.IsVerified.Value
? DateTime.Now.ToStringIso8601Like()
: null
});
var fieldsSource = source
? profile?.Fields.Select(p => new Field { Name = p.Name, Value = p.Value }).ToList() ?? []
: [];
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,
@ -55,20 +80,46 @@ public class UserRenderer(
FullyQualifiedName = $"{user.Username}@{user.Host ?? config.Value.AccountDomain}",
IsLocked = user.IsLocked,
CreatedAt = user.CreatedAt.ToStringIso8601Like(),
LastStatusAt = user.LastNoteAt?.ToStringIso8601Like(),
FollowersCount = user.FollowersCount,
FollowingCount = user.FollowingCount,
StatusesCount = user.NotesCount,
Note = (await mfmConverter.ToHtmlAsync(profile?.Description ?? "", mentions, user.Host)).Html,
Note = mfmConverter.ToHtml(profile?.Description ?? "", mentions, user.Host).Html,
Url = profile?.Url ?? user.Uri ?? user.GetPublicUrl(config.Value),
Uri = user.Uri ?? user.GetPublicUri(config.Value),
AvatarStaticUrl = user.GetAvatarUrl(config.Value), //TODO
AvatarDescription = avatarAlt ?? "",
HeaderUrl = user.GetBannerUrl(config.Value) ?? _transparent,
HeaderStaticUrl = user.GetBannerUrl(config.Value) ?? _transparent, //TODO
MovedToAccount = null, //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
@ -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)
};
@ -116,16 +168,55 @@ public class UserRenderer(
.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 GetEmojiAsync(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

@ -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

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mime;
using AsyncKeyedLock;
@ -14,6 +15,7 @@ using Iceshrimp.Backend.Core.Helpers;
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;
@ -69,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")]
@ -409,6 +437,17 @@ public class StatusController(
};
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 })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +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;
@ -34,7 +36,7 @@ public class DriveController(
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/files/{accessKey}/{version?}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey, string? version)
{
@ -44,14 +46,14 @@ public class DriveController(
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/media/emoji/{id}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[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)
if (!options.Value.ProxyRemoteMedia || emoji.Host == null)
return Redirect(emoji.RawPublicUrl);
return await ProxyAsync(emoji.RawPublicUrl, null, null);
@ -60,7 +62,7 @@ public class DriveController(
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/avatars/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAvatarByUserId(string userId, string? version)
{
@ -83,7 +85,7 @@ public class DriveController(
[EnableCors("drive")]
[EnableRateLimiting("proxy")]
[HttpGet("/banners/{userId}/{version}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Redirect)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetBannerByUserId(string userId, string? version)
{
@ -98,6 +100,19 @@ public class DriveController(
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]
[Authenticate]
@ -105,14 +120,15 @@ 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.StoreFileAsync(file.OpenReadStream(), user, request);
return await GetFileById(res.Id);
@ -127,7 +143,10 @@ 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)
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
@ -138,7 +157,9 @@ public class DriveController(
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
}
@ -151,7 +172,10 @@ 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)
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
@ -162,7 +186,9 @@ public class DriveController(
Filename = file.Name,
ContentType = file.Type,
Description = file.Comment,
Sensitive = file.IsSensitive
Sensitive = file.IsSensitive,
IsAvatar = file.UserAvatar != null,
IsBanner = file.UserBanner != null
};
}
@ -210,6 +236,220 @@ 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)
{
@ -224,15 +464,15 @@ public class DriveController(
if (file.IsLink)
{
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
if (!options.Value.ProxyRemoteMedia)
return NoContent();
return Redirect(fetchUrl);
try
{
var fetchUrl = version is "thumbnail"
? file.RawThumbnailAccessUrl
: file.RawAccessUrl;
var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp")
? file.Name
: $"{file.Name}.webp";

View file

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

View file

@ -10,6 +10,7 @@ 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;
@ -22,23 +23,94 @@ public class InstanceController(
DatabaseContext db,
UserRenderer userRenderer,
IOptions<Config.InstanceSection> instanceConfig,
IOptions<Config.SecuritySection> securityConfig,
MetaService meta
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)
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]

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 =>
@ -157,8 +159,10 @@ public class NoteController(
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
name = name.Trim(':');
var users = await db.NoteReactions
.Where(p => p.Note == note && p.Reaction == $":{name.Trim(':')}:")
.Where(p => p.Note == note && (p.Reaction == $":{name}:" || p.Reaction == name))
.Include(p => p.User.UserProfile)
.Select(p => p.User)
.ToListAsync();
@ -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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ public sealed class Config
public required InstanceSection Instance { get; init; } = new();
public required DatabaseSection Database { get; init; } = new();
public required SecuritySection Security { get; init; } = new();
public required NetworkSection Network { get; init; } = new();
public required StorageSection Storage { get; init; } = new();
public required PerformanceSection Performance { get; init; } = new();
public required QueueSection Queue { get; init; } = new();
@ -53,18 +54,26 @@ public sealed class Config
public sealed class SecuritySection
{
public bool AuthorizedFetch { get; init; } = true;
public bool AttachLdSignatures { get; init; } = false;
public bool AcceptLdSignatures { get; init; } = false;
public bool AllowLoopback { get; init; } = false;
public bool AllowLocalIPv6 { get; init; } = false;
public bool AllowLocalIPv4 { get; init; } = false;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
public bool AuthorizedFetch { get; init; } = true;
public bool ValidateRequestSignatures { get; init; } = true;
public bool AttachLdSignatures { get; init; } = false;
public bool AcceptLdSignatures { get; init; } = false;
public bool AllowLoopback { get; init; } = false;
public bool AllowLocalIPv6 { get; init; } = false;
public bool AllowLocalIPv4 { get; init; } = false;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
public Enums.PublicPreview PublicPreview { get; init; } = Enums.PublicPreview.Public;
}
public sealed class NetworkSection
{
public string? HttpProxy { get; init; } = null;
public string? HttpProxyUser { get; init; } = null!;
public string? HttpProxyPass { get; init; } = null!;
}
public sealed class DatabaseSection
@ -423,4 +432,4 @@ public sealed class Config
_ => throw new Exception("Unsupported suffix, use one of: [s]econds, [m]inutes, [h]ours, [d]ays, [w]eeks")
};
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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