Compare commits

...

212 commits

Author SHA1 Message Date
Werner Kroneman
667b324572 Added nix flake stuff. 2024-12-05 12:31:58 +02:00
dakkar
ab9969283b merge: maybe laxer match on authority - fixes #815 (!773)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/773

Closes #815

Approved-by: Amber Null <puppygirlhornyposting@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-12-05 09:12:05 +00:00
Marie
7dc90e078e merge: Interpret Dislike activities as Undo(Like) (resolves #800) (!731)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/731

Closes #800

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-12-01 09:38:22 +00:00
Marie
69ffa0e520 merge: Add option filePermissionBits to override permissions on locally-stored files (!791)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/791

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-12-01 09:14:37 +00:00
Marie
b48ae3e9fa merge: Make the MR/issue templates look a bit nicer (!793)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/793

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-12-01 09:11:08 +00:00
CenTdemeern1
b4b72ab2df Opinionated changes to merge/issue templates
In hopes of making them look a bit nicer by default.
2024-11-30 11:46:38 +01:00
Hazelnoot
3d3cf5bd7a add option filePermissionBits to override permissions on locally-stored files
This is useful for custom deployments, such as using a reverse proxy to serve static files directly
2024-11-29 13:00:51 -05:00
Hazelnoot
e6e48fb6bc interpret Dislike activities as Undo(Like) 2024-11-29 12:43:04 -05:00
dakkar
495a19540b merge: improve block confirmation dialog. (!790)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/790

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-29 16:23:22 +00:00
dakkar
51afbbaf72 handle .masto.host specially 2024-11-29 11:39:18 +00:00
dakkar
f0139ae1e0 actually use the correct import syntax
CenTdemeern1 had told me, but I got it wrong ☹
2024-11-29 11:39:18 +00:00
dakkar
97d17c537b spaces / lint 2024-11-29 11:39:18 +00:00
dakkar
82376f312d use "userland" punycode, plus tests
thanks to CenTdemeern1 for the `import` incantation
2024-11-29 11:39:18 +00:00
dakkar
fd2af6dfe6 silence linter?
it started complaining about that `true &&` all of a sudden
2024-11-29 11:39:18 +00:00
dakkar
387dc4bb4b UNTESTED maybe laxer match on authority - fixes #815 2024-11-29 11:39:17 +00:00
Hazelnoot
536060e63c merge: Clear jobs from blocked domains (resolves #773) (!720)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/720

Closes #773

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-29 10:29:27 +00:00
Hazelnoot
0efd5eff2b remove unused import from InternalStorageService 2024-11-28 19:17:34 -05:00
piuvas
51bc393d58
remove title and change dialog icon 2024-11-28 19:12:07 -03:00
piuvas
385846d43d
make block confirm dialog localizable. 2024-11-28 18:47:20 -03:00
dakkar
aa220a0411 merge: Fix Megalodon unit tests on Windows (!787)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/787

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-28 11:18:39 +00:00
Julia
52976588a7 merge: Bump develop version (!789)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/789
2024-11-28 06:15:32 +00:00
Julia Johannesen
22bb09c6ed
Bump develop version 2024-11-28 01:09:05 -05:00
Julia
150d949a3e merge: fixes for 2024.9.4 (if we want to) (!770)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/770

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Julia <julia@insertdomain.name>
2024-11-28 05:23:38 +00:00
Hazelnoot
9f640beecc fix megalodon unit tests 2024-11-27 22:52:53 -05:00
Marie
7aba846446 merge: silence linter (!785)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/785

Approved-by: Marie <github@yuugi.dev>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-28 03:31:40 +00:00
dakkar
9309872cff simpler check for "property present" 2024-11-27 21:25:54 +00:00
dakkar
3ea85b14a3 silence linter
those objects always have the normal prototype, and can't have
`hasOwnProperty` redefined, let me call it normally

(otherwise I'd have to write
`Object.prototype.hasOwnProperty.call(newUser, field)` and that's
ugly)
2024-11-27 21:01:12 +00:00
dakkar
3164e7b4fc merge: only "publish to followers" when things really change - fixes #733 (!781)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/781

Closes #733

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 20:41:37 +00:00
dakkar
3a020d53d1 merge: better poll editing - fixes #668 (!783)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/783

Closes #668

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 20:41:19 +00:00
Marie
b6db4ef88c merge: fix lint error in secure-rndstr.ts (!784)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/784

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-27 20:40:11 +00:00
Marie
92db359654 lint 2024-11-27 19:26:56 +00:00
Marie
531a003a2a merge: embed video thumbnail (!782)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/782

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 19:20:54 +00:00
Marie
436e93540a merge: use a better random integer generator - fixes #810 (!779)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/779

Closes #810

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 19:19:34 +00:00
Marie
cd10e98937 merge: added mutual and following to user popup (!777)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/777

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 17:59:39 +00:00
Hazelnoot
ebb6ac195f merge: Allow anonymous activities (resolves #819) (!776)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/776

Closes #819

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 17:32:33 +00:00
Hazelnoot
b7e6e13b8d merge: Show pinned notes by default on user profiles (!772)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/772

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 17:08:56 +00:00
Marie
16909ed6bd merge: fix XRD+XML serialisation of Alias (!780)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/780

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-27 16:45:46 +00:00
dakkar
1f53eb2ed1 better poll editing - fixes #668
* editing _just the poll_ is now recognised as an actual change to the
  note
* the "poll ended" notification job is now replaced (with potentially
  the new expiry time)
2024-11-27 11:57:19 +00:00
dakkar
1626e50fbf expose video thumbnail to 3rd parties "cards" 2024-11-27 11:23:32 +00:00
dakkar
fc277839b6 only "publish to followers" when things really change - fixes #733 2024-11-27 10:36:19 +00:00
dakkar
57b31366e5 fix XRD+XML serialisation of Alias 2024-11-27 10:06:21 +00:00
dakkar
2e3eaaddcc use a better random integer generator - fixes #810 2024-11-27 09:33:20 +00:00
dakkar
1ec5e846c5 merge: Add aliases to webfinger request. (!778)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/778

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-27 09:29:33 +00:00
piuvas
f1168f0165
add profile link to aliases 2024-11-26 20:31:20 -03:00
amy
4f1694cd99
added mutual and following to user popup 2024-11-26 22:57:28 +03:30
Hazelnoot
1e99782666 allow anonymous activities (resolves #819) 2024-11-26 09:05:04 -05:00
Hazelnoot
face6527f2 remove duplicate check for note.url 2024-11-26 08:59:08 -05:00
Hazelnoot
43d87270d9 improve AP error formtting 2024-11-26 08:59:08 -05:00
Hazelnoot
3e72d99cf9 fix build errors in ApNoteService.ts 2024-11-26 08:59:08 -05:00
Hazelnoot
6f8736c1af improve comment on getNullableApId 2024-11-26 08:59:08 -05:00
Hazelnoot
baf19420dd log details when a quote fails to resolve 2024-11-26 08:59:08 -05:00
Hazelnoot
b951b31ef5 use IdentifiableError in ApImageService.createImage 2024-11-26 08:59:08 -05:00
Hazelnoot
4ec6bffca7 don't suppress errors when Update(Question) or Update(Note) fails 2024-11-26 08:59:08 -05:00
Hazelnoot
c5f572dcfd clarify logging when a Move (migration) is rejected 2024-11-26 08:59:08 -05:00
Hazelnoot
f4ec837d6e clarify "unknown activity type" logging in ApInboxService.undo 2024-11-26 08:59:08 -05:00
Hazelnoot
f115116454 skip Delete(Note) activities when the note is already deleted 2024-11-26 08:59:08 -05:00
Hazelnoot
9eb98ae8a5 clarify logging for Create/Update type checks 2024-11-26 08:59:08 -05:00
Hazelnoot
3f5ea11a1f clarify logging when an inbox job is skipped or fails 2024-11-26 08:59:08 -05:00
Hazelnoot
4708c0abef don't retry jobs when processing returns a non-retryable error 2024-11-26 08:59:08 -05:00
Hazelnoot
b9fd7e1b77 clarify "failed to resolve quote" message 2024-11-26 08:59:08 -05:00
Hazelnoot
2afbd251e1 avoid potential crash if Question activity is corrupt 2024-11-26 08:59:08 -05:00
Hazelnoot
ab97b91606 improve AP job clearing and failure logging 2024-11-26 08:59:06 -05:00
Hazelnoot
e38e408b97 merge: Normalize AP IDs during verification (resolves #818) (!774)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/774

Closes #818

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-26 03:15:53 +00:00
Hazelnoot
0de009f946 merge: Add shared (cross-resource) rate limit for proxy (!775)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/775

Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-25 19:35:48 +00:00
Hazelnoot
a47590e64c add shared (cross-resource) rate limit for proxy 2024-11-25 13:03:51 -05:00
Hazelnoot
1fb1875ac3 normalize AP IDs during verification 2024-11-23 20:23:05 -05:00
Hazelnoot
b477de1d98 show pinned notes by default on user profiles 2024-11-22 20:49:16 -05:00
Hazelnoot
b0420c948c merge: Move cypress to optionalDependencies (!697)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/697

Closes #761

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-23 01:19:14 +00:00
dakkar
b4a278ae54 merge: Comply with type for Packed<'Note'> (fixes aria client compatibility) (!771)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/771

Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-22 23:57:22 +00:00
dakkar
a51fef29c0 remove minInterval from FileServerService
when showing a reply, browser will request the replied-to avatar twice
at the same time, and get confused if one of the requests is refused

something similar seems to happen with videos and their previews
2024-11-22 23:25:07 +00:00
dakkar
8e07eb7f44 remove duplicate limit
the `users/lists/push` endpoint already has a limit, of 30/hour
2024-11-22 23:14:37 +00:00
dakkar
caaa78d98d merge: Add default rate limit (!768)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/768

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 23:03:34 +00:00
dakkar
0ea0466313 merge: Filter Add / Remove activities with non-Note payloads (resolves #750) (!693)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/693

Closes #750

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 23:03:12 +00:00
dakkar
3ae9f4e8e6 merge: Accept Like(Note) and Update(Note) activities where the Note isn't already cached (resolves #795 and #748) (!729)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/729

Closes #795 and #748

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 23:02:39 +00:00
dakkar
59afb56b5b merge: Support following feed in Deck UI (resolves #789) (!724)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/724

Closes #789

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 23:02:31 +00:00
dakkar
a596718bbf merge: Add rate limits to all public endpoints (!767)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/767

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-22 23:01:10 +00:00
dakkar
ab992422a8 bump version 2024-11-22 22:59:13 +00:00
Hazelnoot
3faad0a5e5 merge: Fix typo "to many requests" (!769)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/769

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 21:33:03 +00:00
tess
ebdfb2feb7 Comply with type for Packed<'Note'> 2024-11-22 21:57:04 +01:00
Hazelnoot
dbab122a99 fix typo "to many requests" 2024-11-22 15:26:55 -05:00
Hazelnoot
e3b826db5a add rate limits to all public endpoints 2024-11-22 15:19:24 -05:00
Hazelnoot
7e3f519a5b merge: Fix note hiding when renote and target have different visibility settings (resolves #803) (!741)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/741

Closes #803

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-22 19:35:19 +00:00
Hazelnoot
6b54405003 add default / fallback rate limit 2024-11-22 13:53:41 -05:00
Hazelnoot
e32fb4e86d remove unused import from ApInboxService.ts (introduced by merge error) 2024-11-22 09:22:26 -05:00
Hazelnoot
2b9c3f0d5c log type of unsupported featured object 2024-11-22 09:20:49 -05:00
Hazelnoot
ae7b90de6c allow any valid post to be featured, not just Note 2024-11-22 09:20:46 -05:00
Hazelnoot
d74cf9e4ff filter Add / Remove activities with non-Note payloads 2024-11-22 09:20:11 -05:00
Hazelnoot
9d5bc6cb28 pass resolver when creating notes via side-effect 2024-11-22 09:16:52 -05:00
Hazelnoot
9d3321fca4 allow Update(Note) and Update(Poll) to implicitly create missing notes 2024-11-22 09:16:48 -05:00
Hazelnoot
2bbccde2ce reduce inbox log spam when fetching blocked / unavailable notes 2024-11-22 09:16:03 -05:00
Hazelnoot
47eb0daebb fetch target note of Like(Note) activities 2024-11-22 09:16:03 -05:00
dakkar
fd47bf3483 merge: Fix: Center SkModPlayer on big displays (!734)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/734

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Tess K <me@thvxl.se>
2024-11-22 10:41:08 +00:00
dakkar
9bf18546fc merge: keep popup elements on screen (!744)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/744

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-22 10:40:55 +00:00
Hazelnoot
fadcabeaa6 merge: Don't preview URLs to blocked hosts (!751)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/751

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-22 10:37:29 +00:00
Hazelnoot
2ac36e4a5c merge: Fix federation error "The note creation failed with duplication error even when there is no duplication" (resolves #749) (!745)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/745

Closes #749

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-22 10:37:11 +00:00
Hazelnoot
4b5a400264 merge: Allow Update activities for non-note posts (resolves #794) (!728)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/728

Closes #794

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-22 10:35:21 +00:00
Hazelnoot
5b72c08a68 merge: Fix type confusion with exceptions in AP handling (resolves #796) (!730)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/730

Closes #796

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-21 16:44:54 +00:00
Hazelnoot
9f3b97effb merge: Reduce log spam from ApPersonService.updateFeatured (!747)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/747

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-21 16:35:18 +00:00
Hazelnoot
34a5dbe21b merge: Reduce log spam from charts (!748)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/748

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-21 16:32:32 +00:00
Hazelnoot
241b186a8a merge: Prevent "mark instance as NSFW" from producing hellspawns (!749)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/749

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-21 05:26:41 +00:00
Hazelnoot
a150bc53ab merge: Respect pinned note limit for remote users (resolves #780) (!750)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/750

Closes #780

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-21 05:26:10 +00:00
Hazelnoot
4b503f88e1 normalize naming of isPackedPureRenote and PackedPureRenote 2024-11-20 22:27:52 -05:00
Hazelnoot
faf1b3559a fix note hiding when renote and target have different visibility settings 2024-11-20 22:27:50 -05:00
Hazelnoot
2fb2e52312 add isPureRenotePacked 2024-11-20 22:27:43 -05:00
Hazelnoot
2a4c432f41 don't generate URL previews for blocked domains 2024-11-20 22:25:49 -05:00
Hazelnoot
4c6cec552e verify that preview URL is valid 2024-11-20 22:25:49 -05:00
Hazelnoot
c48faca707 fix lint errors in UrlPreviewService 2024-11-20 22:25:49 -05:00
Hazelnoot
c9afaba0d4 adjust translation string "Select a follow relationship..." 2024-11-20 22:22:30 -05:00
Hazelnoot
5b48032681 restore animation and styling in following-feed 2024-11-20 22:22:30 -05:00
Hazelnoot
83472dbd82 add following feed to the deck UI 2024-11-20 22:22:30 -05:00
Hazelnoot
2b0a622875 separate following feed's menu component from the actual filter options 2024-11-20 22:22:30 -05:00
Hazelnoot
4a43e1a9e9 factor out remote followers warning in SkRemoteFollowersWarning.vue 2024-11-20 22:22:30 -05:00
Hazelnoot
1ca350e45d define defult Following Feed state in following-feed-utils.ts instead of store.ts 2024-11-20 22:22:30 -05:00
Hazelnoot
38e30c0d54 allow following-feed-utils to use alternate state backends 2024-11-20 22:22:30 -05:00
Hazelnoot
38787712d9 add responsive padding to recent-notes.vue 2024-11-20 22:22:29 -05:00
Hazelnoot
0515fed92d remove unused ref from recent-notes.vue 2024-11-20 22:22:29 -05:00
Hazelnoot
1d16656b39 add <Suspense> to enable async components and dynamic imports under the deck UI 2024-11-20 22:22:29 -05:00
Hazelnoot
ed6c781426 fix responsive breakpoint in SkUserRecentNotes 2024-11-20 22:22:29 -05:00
Hazelnoot
8cbc0761db add functions to access deck column state 2024-11-20 22:22:29 -05:00
Hazelnoot
455ccc660e allow deck column updates to be awaited 2024-11-20 22:22:29 -05:00
Hazelnoot
a40b77a66b prevent the following feed from auto-selecting a user under the mobile UI 2024-11-20 22:22:29 -05:00
Hazelnoot
194bc20af1 fix type of deepMerge 2024-11-20 22:22:29 -05:00
Hazelnoot
ca94959fff factor out Following Feed list into SkFollowingRecentNotes.vue 2024-11-20 22:22:29 -05:00
Hazelnoot
bcc20d6dc4 allow Update activities for non-note posts 2024-11-20 22:08:20 -05:00
Hazelnoot
0de7a084a9 fix exception handling for Undo activities 2024-11-20 22:05:10 -05:00
Hazelnoot
cfc3ab4b04 fix exception handling for Announce activities 2024-11-20 22:05:10 -05:00
Hazelnoot
8f42e8434e fix exception handling for Like activities 2024-11-20 22:05:10 -05:00
Hazelnoot
dff465000c fix import-order in ApInboxService 2024-11-20 22:05:10 -05:00
Hazelnoot
0f6d26e065 reduce log spam from charts 2024-11-20 22:03:32 -05:00
Hazelnoot
cc394d9a4b quote all symbols in hellspawn upgrade script 2024-11-20 22:03:17 -05:00
Hazelnoot
c9934c379f remove duplicate isPureRenote method 2024-11-20 22:03:17 -05:00
Hazelnoot
eb1e326813 add script to fix hellspawns 2024-11-20 22:03:17 -05:00
Hazelnoot
a62e4f1cf2 ignore isNSFW for pure renotes 2024-11-20 22:03:17 -05:00
Hazelnoot
dcd5b6d972 replace console.error with this.logger.error (merge error) 2024-11-20 22:02:59 -05:00
Hazelnoot
fedf0d7e20 further reduce log spam from updateFeatured errors 2024-11-20 22:02:59 -05:00
Hazelnoot
984cfe358d reduce log spam from updateFeatured 2024-11-20 22:02:59 -05:00
Hazelnoot
aabb1945e8 respect pinned note limit for remote users 2024-11-20 22:02:47 -05:00
Hazelnoot
4e0f7ced84 preserve the raw URI in parseUri 2024-11-20 22:02:31 -05:00
Julia
41536480ce merge: Bump develop version (!766)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/766
2024-11-21 02:58:28 +00:00
Julia Johannesen
59e160147f
Bump develop version 2024-11-20 21:32:12 -05:00
Julia
a38d8a91a1 merge: Fix .punyHost misuse (!765)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/765
2024-11-21 02:26:43 +00:00
Julia Johannesen
6027b516e1
Fix .punyHost misuse 2024-11-20 21:24:35 -05:00
Julia
757d9aa5ee merge: Fix type error(s) in security fixes (!764)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/764

Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-21 01:44:15 +00:00
Julia Johannesen
36af07abe2
Fix another style error 2024-11-20 20:31:22 -05:00
Julia Johannesen
23c4aa2571
Fix style error 2024-11-20 20:24:59 -05:00
Julia Johannesen
1758f29364
Fix error in test function calls 2024-11-20 20:16:43 -05:00
Julia Johannesen
fa3cf6c299
Fix type error in security fixes 2024-11-20 20:06:46 -05:00
Julia
4b556efdaa merge: (re-merge) Prevent DoS from spammed media proxy requests (!763)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/763
2024-11-21 00:40:52 +00:00
Hazelnoot
b0834ebf55 prevent DoS from spammed media proxy requests 2024-11-20 19:37:38 -05:00
Julia
2234fbcb11 merge: Bump version (!762)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/762
2024-11-21 00:23:26 +00:00
Julia Johannesen
8e90484b3e
Bump version 2024-11-20 19:21:57 -05:00
Julia
0fcb23c4c1 merge: Coordinated Security Release (!761)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/761
2024-11-21 00:20:48 +00:00
rectcoordsystem
776f6fd1f5
fix(backend): allow fetchSummaryFromProxy, trueMail to access local addresses 2024-11-20 19:17:25 -05:00
rectcoordsystem
7b3e3f8e25
fix(backend): add isLocalAddressAllowed option to getAgentByUrl and send (HttpRequestService) 2024-11-20 19:17:25 -05:00
rectcoordsystem
360d71278a
fix(backend): lint and typecheck 2024-11-20 19:17:25 -05:00
rectcoordsystem
663c06be00
Apply suggestions from code review
Co-authored-by: anatawa12 <anatawa12@icloud.com>
2024-11-20 19:17:25 -05:00
rectcoordsystem
7ccccf5545
fix(backend): allow accessing private IP when testing 2024-11-20 19:17:25 -05:00
rectcoordsystem
f36f4b5398
fix(backend): check target IP before sending HTTP request 2024-11-20 19:17:25 -05:00
Julia Johannesen
cc4e99fdde
fix: Try using CacheService to avoid excess db lookups
This isn't perfect, theoretically if some massive number of users
blocked the user making this request the set lookup could take a long
amount of time, but eh, it works, and that scenario is highly unlikely.
2024-11-20 19:17:25 -05:00
Julia Johannesen
5764fa55cb
fix: primitives 25-33: proper local instance checks 2024-11-20 19:17:25 -05:00
Julia Johannesen
74565f67f7
fix: primitives 21, 22, and 23: reuse resolver
This also increases the default `recursionLimit` for `Resolver`, as it
theoretically will go higher that it previously would and could possibly
fail on non-malicious collection activities.
2024-11-20 19:17:25 -05:00
Julia Johannesen
408e782507
fix: primitive 19 & 20: respect blocks and hide more
Ideally, the user property should also be hidden (as leaving it in leaks
information slightly), but given the schema of the note endpoint, I
don't think that would be possible without introducing some kind of
"ghost" user, who is attributed for posts by users who have you blocked.
2024-11-20 19:17:25 -05:00
Julia Johannesen
cbf8cc376e
fix: primitive 18: ap/get bypasses access checks
One might argue that we could make this one actually preform access
checks against the returned activity object, but I feel like that's a
lot more work than just restricting it to administrators, since, to me
at least, it seems more like a debugging tool than anything else.
2024-11-20 19:17:25 -05:00
Julia Johannesen
c04f344049
fix: primitive 13: check attribution against actor in notes 2024-11-20 19:17:25 -05:00
Julia Johannesen
b9080da75d
fix: code style for primitive 17 2024-11-20 19:17:24 -05:00
Laura Hausmann
4d925fc086
fix: primitive 17: note same-origin identifier validation can be bypassed by wrapping the id in an array 2024-11-20 19:17:24 -05:00
Laura Hausmann
b74e2e9167
fix: primitive 16: improper same-origin validation for user uri and url 2024-11-20 19:17:24 -05:00
Laura Hausmann
ebea1a2962
fix: primitive 15: improper same-origin validation for note uri and url 2024-11-20 19:17:24 -05:00
Julia Johannesen
4c432c07cb
fix: code style for primitive 14 2024-11-20 19:17:24 -05:00
Laura Hausmann
322b3b677f
fix: primitive 14: improper validation of outbox, followers, following & shared inbox collections 2024-11-20 19:17:24 -05:00
Julia Johannesen
1c7e05ce9e
fix: primitive 7 & 12: prevent poll spoofing 2024-11-20 19:17:24 -05:00
Laura Hausmann
9ab25ede28
fix: primitives 9, 10 & 11: http signature validation doesn't enforce required headers or specify auth header name 2024-11-20 19:17:24 -05:00
Laura Hausmann
174dfb83d0
fix: primitive 6: reject anonymous objects that were fetched by their id 2024-11-20 19:17:24 -05:00
Laura Hausmann
ad8e8793c7
fix: primitives 5 & 8: reject activities with non-string identifiers 2024-11-20 19:17:24 -05:00
Laura Hausmann
1e14612f0e
fix: primitive 4: missing same-origin identifier validation of collection-wrapped activities 2024-11-20 19:17:24 -05:00
Laura Hausmann
9090b745e6
fix: primitive 3: validation of non-final url 2024-11-20 19:17:24 -05:00
Laura Hausmann
d883934826
fix: primitive 2: acceptance of cross-origin alternate links 2024-11-20 19:17:23 -05:00
Julia
e0bb796aff merge: Fix linter error in emojis endpoint (!758)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/758
2024-11-20 06:29:48 +00:00
Julia Johannesen
fb54546573
Fix linter error in emojis endpoint 2024-11-20 01:17:24 -05:00
Julia
9e0b759197 merge: Bump develop version (!757)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/757
2024-11-20 05:56:55 +00:00
Julia Johannesen
41c500851b
Bump develop version 2024-11-20 00:54:30 -05:00
dakkar
482538c7f8 merge: make emoji categories and names case insensitive. (!746)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/746

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-17 13:22:39 +00:00
Hazelnoot
d579687156 merge: Dockerfile mkdir files (!740)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/740

Approved-by: Tess K <me@thvxl.se>
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-17 00:48:37 +00:00
Hazelnoot
de970ff54e merge: Change example config - db name and user consistent with docs (!739)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/739

Approved-by: Tess K <me@thvxl.se>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-17 00:41:14 +00:00
Hazelnoot
1bfb0dc395 merge: check harder for connectibility (!737)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-17 00:40:52 +00:00
Hazelnoot
da2dfee0a8 merge: Remove check to prevent admin reporting (Fixes #757) (!727)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/727

Closes #757

Approved-by: Julia <julia@insertdomain.name>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-17 00:39:08 +00:00
piuvas
eaad96aae3
edit query 2024-11-15 13:40:53 -03:00
dakkar
0a05841f33 merge: Add "pinned" section to notes tab on user profiles (!689)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/689

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Tess K <me@thvxl.se>
2024-11-13 11:14:59 +00:00
tess
68e5b5a84a Set horizontal margin for even better consistency 2024-11-12 22:09:37 +01:00
tess
6d6b03dfe2 tweak popup left margin for consistency 2024-11-12 21:39:38 +01:00
tess
19be113cb4 Keep MkUserPopup from extending past left side of screen 2024-11-12 21:39:38 +01:00
tess
101ca9e0f7 make sure popup position is never off screen to the left 2024-11-12 21:39:38 +01:00
Luna Nova
906c2863db
fix: move cypress to optionalDependencies in packages/frontent/package.json 2024-11-12 14:33:05 -05:00
dakkar
917e67d356 merge: Styling of following feed. (!738)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/738

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-11 11:14:52 +00:00
Hazelnoot
cd2e597223 merge: Improve performance of notes/following API (!743)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/743

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-09 10:54:18 +00:00
Caramel
03559156b9 Improve performance of notes/following API 2024-11-09 00:32:03 +01:00
Rachel Y
aebdbf07b4 creat and chown /sharkey/files in dockerfile 2024-11-07 20:09:52 +00:00
Rachel Y
00ab7f5bd1 Update file Dockerfile 2024-11-07 20:09:01 +00:00
Maciej
83f780978c Change example config - db name and user consistent with docs 2024-11-07 07:57:35 +00:00
piuvas
7f9a151055
give ff entries clickable pointer 2024-11-05 20:27:24 -03:00
piuvas
e0a2e7aedc
animations following feed 2024-11-05 20:22:56 -03:00
dakkar
9fe5dc679a check harder for connectibility
`allSettled` does not throw if a promise is rejected, so
`check_connect` never actually failed
2024-11-05 14:21:58 +00:00
Julia
6ed38f53f5 merge: Bump version number (!735)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/735
2024-11-05 06:14:24 +00:00
Julia Johannesen
0f07f27642
chore: Bump version number 2024-11-05 01:10:49 -05:00
CenTdemeern1
c2c2120b76 Center SkModPlayer on big displays
Authored-by: Freeplay <freeplay@duck.com>
Co-authored-by: Freeplay <freeplay@duck.com>
2024-11-04 22:50:56 +01:00
Kio!
8477909af2 Update report-abuse.ts 2024-11-03 19:50:25 +00:00
Hazelnoot
a15e5c52f4 remove idea section 2024-11-02 17:38:20 -04:00
Hazelnoot
1c181df086 restore ordering of MkNotes attributes in index.timeline.vue 2024-11-02 17:38:20 -04:00
Hazelnoot
f5652605ec remove notes-container.vue and revert refactor 2024-11-02 17:38:20 -04:00
Hazelnoot
9d3aa6bb41 add pinned section to "notes" tab on user profiles 2024-11-02 17:38:19 -04:00
Hazelnoot
5b64b9001d fix weird spacing on notes/home.vue 2024-11-02 17:38:19 -04:00
Luna Nova
3b89b73d27
fix: move cypress to optionalDependencies 2024-10-18 23:20:47 -04:00
327 changed files with 5372 additions and 773 deletions

View file

@ -229,3 +229,8 @@ checkActivityPubGetSignature: false
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# CHMod-style permission bits to apply to uploaded files.
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
#filePermissionBits: '644'

View file

@ -222,3 +222,8 @@ allowedPrivateNetworks: [
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# CHMod-style permission bits to apply to uploaded files.
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
#filePermissionBits: '644'

View file

@ -312,3 +312,8 @@ checkActivityPubGetSignature: false
# Upload or download file size limits (bytes)
#maxFileSize: 262144000
# CHMod-style permission bits to apply to uploaded files.
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
#filePermissionBits: '644'

View file

@ -99,10 +99,10 @@ db:
port: 5432
# Database name
db: misskey
db: sharkey
# Auth
user: example-misskey-user
user: sharkey
pass: example-misskey-pass
# Whether disable Caching queries
@ -334,3 +334,8 @@ checkActivityPubGetSignature: false
# PID File of master process
#pidFile: /tmp/misskey.pid
# CHMod-style permission bits to apply to uploaded files.
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
#filePermissionBits: '644'

View file

@ -3,27 +3,33 @@
🔒 Found a security vulnerability? [Please disclose it responsibly.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/SECURITY.md)
🤝 By submitting this feature request, you agree to follow our [Contribution Guidelines.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md) -->
**What happened?** _(Please give us a brief description of what happened.)_
# **What happened?**
<!-- Please give us a brief description of what happened. -->
**What did you expect to happen?** _(Please give us a brief description of what you expected to happen.)_
# **What did you expect to happen?**
<!-- Please give us a brief description of what you expected to happen. -->
**Version** _(What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information.)_
# **Version**
<!-- What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information. -->
**Instance** _(What instance of Sharkey are you using?)_
# **Instance**
<!-- What instance of Sharkey are you using? -->
**What type of issue is this?** _(If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side.)_
# **What type of issue is this?**
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
**What browser are you using? (Client-side issues only)**
# **What browser are you using? (Client-side issues only)**
**What operating system are you using? (Client-side issues only)**
# **What operating system are you using? (Client-side issues only)**
**How do you deploy Sharkey on your server? (Server-side issues only)**
# **How do you deploy Sharkey on your server? (Server-side issues only)**
**What operating system are you using? (Server-side issues only)**
# **What operating system are you using? (Server-side issues only)**
**Relevant log output** _(Please copy and paste any relevant log output. You can find your log by inspecting the page, and going to the "console" tab. This will be automatically formatted into code, so no need for backticks.)_
# **Relevant log output**
<!-- Please copy and paste any relevant log output. You can find your log by inspecting the page, and going to the "console" tab. This will be automatically formatted into code, so no need for backticks. -->
**Contribution Guidelines**
# **Contribution Guidelines**
By submitting this issue, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
- [ ] I agree to follow this project's Contribution Guidelines
- [ ] I have searched the issue tracker for similar issues, and this is not a duplicate.

View file

@ -3,15 +3,19 @@
🔒 Found a security vulnerability? [Please disclose it responsibly.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/SECURITY.md)
🤝 By submitting this feature request, you agree to follow our [Contribution Guidelines.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md) -->
**What feature would you like implemented?** _(Please give us a brief description of what you'd like.)_
# **What feature would you like implemented?**
<!-- Please give us a brief description of what you'd like. -->
**Why should we add this feature?** _(Please give us a brief description of why your feature is important.)_
# **Why should we add this feature?**
<!-- Please give us a brief description of why your feature is important. -->
**Version** _(What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information.)_
# **Version**
<!-- What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information. -->
**Instance** _(What instance of Sharkey are you using?)_
# **Instance**
<!-- What instance of Sharkey are you using? -->
**Contribution Guidelines**
# **Contribution Guidelines**
By submitting this issue, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
- [ ] I agree to follow this project's Contribution Guidelines
- [ ] I have searched the issue tracker for similar requests, and this is not a duplicate.

View file

@ -1,11 +1,12 @@
<!-- Thanks for taking the time to make Sharkey better! -->
**What does this PR do?** _(Please give us a brief description of what this PR does.)_
# **What does this MR do?**
<!-- Please give us a brief description of what this PR does. -->
**Contribution Guidelines**
# **Contribution Guidelines**
By submitting this merge request, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
- [ ] I agree to follow this project's Contribution Guidelines
- [ ] I have made sure to test this pull request
- [ ] I have made sure to test this merge request
<!-- Uncomment if your merge request has multiple authors -->
<!-- Co-authored-by: Name <email@email.com> -->

View file

@ -40,6 +40,8 @@ RUN apk add ffmpeg tini jemalloc \
&& corepack enable \
&& addgroup -g "${GID}" sharkey \
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \
&& mkdir /sharkey/files \
&& chown sharkey:sharkey /sharkey/files \
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -exec chmod u-s {} \; \
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -exec chmod g-s {} \;

View file

@ -1,5 +1,38 @@
# Upgrade Notes
## 2024.10.0
### Hellspawns
Sharkey versions before 2024.10 suffered from a bug in the "Mark instance as NSFW" feature.
When a user from such an instance boosted a note, the boost would be converted to a hellspawn (pure renote with Content Warning).
Hellspawns are buggy and do not properly federate, so it may be desirable to correct any that already exist in the database.
The following script will correct any local or remote hellspawns in the database.
```postgresql
/* Remove "instance is marked as NSFW" hellspawns */
UPDATE "note"
SET "cw" = null
WHERE
"renoteId" IS NOT NULL
AND "text" IS NULL
AND "cw" = 'Instance is marked as NSFW'
AND "replyId" IS NULL
AND "hasPoll" = false
AND "fileIds" = '{}';
/* Fix legacy / user-created hellspawns */
UPDATE "note"
SET "text" = '.'
WHERE
"renoteId" IS NOT NULL
AND "text" IS NULL
AND "cw" IS NOT NULL
AND "replyId" IS NULL
AND "hasPoll" = false
AND "fileIds" = '{}';
```
## 2024.9.0
### Following Feed

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1733212471,
"narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: {
nixosConfigurations.container = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules =
[
( import ./sharkey-service.nix )
({ pkgs, ... }: {
boot.isContainer = true;
# Let 'nixos-version --json' know about the Git revision
# of this flake.
system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev;
# Network configuration.
networking.useDHCP = false;
networking.firewall.allowedTCPPorts = [ 3000 ];
system.stateVersion = "24.04";
services.sharkey = {
enable = true;
package = (pkgs.callPackage ./sharkey.nix {});
settings = {
url = "https://sharkey.localhost";
};
redis.createLocally = true;
database.createLocally = true;
};
})
];
};
};
}

8
locales/index.d.ts vendored
View file

@ -9661,6 +9661,10 @@ export interface Locale extends ILocale {
*
*/
"roleTimeline": string;
/**
* Following
*/
"following": string;
};
};
"_dialog": {
@ -11374,6 +11378,10 @@ export interface Locale extends ILocale {
* Remote followers may have incomplete or outdated activity
*/
"remoteFollowersWarning": string;
/**
* Select a follow relationship...
*/
"selectFollowRelationship": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2024.9.2",
"version": "2024.10.0-dev",
"codename": "shonk",
"repository": {
"type": "git",
@ -66,13 +66,15 @@
"esbuild": "0.23.1",
"glob": "11.0.0"
},
"optionalDependencies": {
"cypress": "13.14.2"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "2.0.3",
"@types/node": "20.14.12",
"@typescript-eslint/eslint-plugin": "7.17.0",
"@typescript-eslint/parser": "7.17.0",
"cross-env": "7.0.3",
"cypress": "13.14.2",
"eslint": "9.8.0",
"globals": "15.9.0",
"ncp": "2.0.0",

View file

@ -93,6 +93,7 @@
"@swc/core": "1.6.6",
"@transfem-org/sfm-js": "0.24.5",
"@twemoji/parser": "15.1.1",
"@types/psl": "^1.1.3",
"accepts": "1.3.8",
"ajv": "8.17.1",
"archiver": "7.0.1",
@ -135,9 +136,9 @@
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.1.0",
"juice": "11.0.0",
"megalodon": "workspace:*",
"meilisearch": "0.42.0",
"juice": "11.0.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
@ -158,6 +159,7 @@
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"proxy-addr": "^2.0.7",
"psl": "^1.13.0",
"pug": "3.0.3",
"punycode": "2.3.1",
"qrcode": "1.5.4",

View file

@ -57,4 +57,4 @@ const promises = Array
connectToPostgres()
]);
await Promise.allSettled(promises);
await Promise.all(promises);

View file

@ -115,6 +115,7 @@ type Source = {
};
pidFile: string;
filePermissionBits?: string;
};
export type Config = {
@ -212,6 +213,7 @@ export type Config = {
} | undefined;
pidFile: string;
filePermissionBits?: string;
};
const _filename = fileURLToPath(import.meta.url);
@ -347,6 +349,7 @@ export function loadConfig(): Config {
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
import: config.import,
pidFile: config.pidFile,
filePermissionBits: config.filePermissionBits,
};
}
@ -452,7 +455,10 @@ function applyEnvOverrides(config: Source) {
}
}
const alwaysStrings = { 'chmodSocket': true } as { [key: string]: boolean };
const alwaysStrings: { [key in string]?: boolean } = {
'chmodSocket': true,
'filePermissionBits': true,
};
function _assign(path: (string | number)[], lastStep: string | number, value: string) {
let thisConfig = config as any;
@ -490,7 +496,7 @@ function applyEnvOverrides(config: Source) {
_apply_top(['sentryForBackend', 'enableNodeProfiling']);
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]);
}

View file

@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common';
import ipaddr from 'ipaddr.js';
import chalk from 'chalk';
import got, * as Got from 'got';
import { parse } from 'content-disposition';
@ -70,13 +69,6 @@ export class DownloadService {
},
enableUnixSockets: false,
}).on('response', (res: Got.Response) => {
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
if (this.isPrivateIp(res.ip)) {
this.logger.warn(`Blocked address: ${res.ip}`);
req.destroy();
}
}
const contentLength = res.headers['content-length'];
if (contentLength != null) {
const size = Number(contentLength);
@ -139,18 +131,4 @@ export class DownloadService {
cleanup();
}
}
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
}

View file

@ -312,6 +312,7 @@ export class EmailService {
Accept: 'application/json',
Authorization: truemailAuthKey,
},
isLocalAddressAllowed: true,
});
const json = (await res.json()) as {

View file

@ -6,6 +6,7 @@
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import ipaddr from 'ipaddr.js';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
@ -25,8 +26,102 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[];
};
declare module 'node:http' {
interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
}
}
class HttpRequestServiceAgent extends http.Agent {
constructor(
private config: Config,
options?: http.AgentOptions,
) {
super(options);
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
}
});
return socket;
}
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
}
class HttpsRequestServiceAgent extends https.Agent {
constructor(
private config: Config,
options?: https.AgentOptions,
) {
super(options);
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
}
});
return socket;
}
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
}
@Injectable()
export class HttpRequestService {
/**
* Get http non-proxy agent (without local address filtering)
*/
private httpNative: http.Agent;
/**
* Get https non-proxy agent (without local address filtering)
*/
private httpsNative: https.Agent;
/**
* Get http non-proxy agent
*/
@ -57,19 +152,20 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない
});
this.http = new http.Agent({
const agentOption = {
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress,
});
};
this.https = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress,
});
this.httpNative = new http.Agent(agentOption);
this.httpsNative = new https.Agent(agentOption);
this.http = new HttpRequestServiceAgent(config, agentOption);
this.https = new HttpsRequestServiceAgent(config, agentOption);
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
@ -104,16 +200,22 @@ export class HttpRequestService {
* @param bypassProxy Allways bypass proxy
*/
@bindThis
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
if (isLocalAddressAllowed) {
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
}
return url.protocol === 'http:' ? this.http : this.https;
} else {
if (isLocalAddressAllowed && (!this.config.proxy)) {
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
}
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
}
}
@bindThis
public async getActivityJson(url: string): Promise<IObject> {
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
const res = await this.send(url, {
method: 'GET',
headers: {
@ -121,6 +223,7 @@ export class HttpRequestService {
},
timeout: 5000,
size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}, {
throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
@ -129,13 +232,13 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]);
assertActivityMatchesUrls(activity, [finalUrl]);
return activity;
}
@bindThis
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
const res = await this.send(url, {
method: 'GET',
headers: Object.assign({
@ -143,19 +246,21 @@ export class HttpRequestService {
}, headers ?? {}),
timeout: 5000,
size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
});
return await res.json() as T;
}
@bindThis
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
const res = await this.send(url, {
method: 'GET',
headers: Object.assign({
Accept: accept,
}, headers ?? {}),
timeout: 5000,
isLocalAddressAllowed: isLocalAddressAllowed,
});
return await res.text();
@ -170,6 +275,7 @@ export class HttpRequestService {
headers?: Record<string, string>,
timeout?: number,
size?: number,
isLocalAddressAllowed?: boolean,
} = {},
extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true,
@ -183,6 +289,8 @@ export class HttpRequestService {
controller.abort();
}, timeout);
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
const res = await fetch(url, {
method: args.method ?? 'GET',
headers: {
@ -191,7 +299,7 @@ export class HttpRequestService {
},
body: args.body,
size: args.size ?? 10 * 1024 * 1024,
agent: (url) => this.getAgentByUrl(url),
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
signal: controller.signal,
});

View file

@ -4,7 +4,7 @@
*/
import * as fs from 'node:fs';
import { copyFile, mkdir, unlink, writeFile } from 'node:fs/promises';
import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises';
import * as Path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
@ -41,12 +41,20 @@ export class InternalStorageService {
@bindThis
public async saveFromPath(key: string, srcPath: string): Promise<string> {
await copyFile(srcPath, this.resolvePath(key));
return `${this.config.url}/files/${key}`;
return await this.finalizeSavedFile(key);
}
@bindThis
public async saveFromBuffer(key: string, data: Buffer): Promise<string> {
await writeFile(this.resolvePath(key), data);
return await this.finalizeSavedFile(key);
}
private async finalizeSavedFile(key: string): Promise<string> {
if (this.config.filePermissionBits) {
const path = this.resolvePath(key);
await chmod(path, this.config.filePermissionBits);
}
return `${this.config.url}/files/${key}`;
}

View file

@ -146,6 +146,8 @@ type Option = {
app?: MiApp | null;
};
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@Injectable()
export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
@ -412,7 +414,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
if (i.isNSFW) {
if (i.isNSFW && !this.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
});
@ -627,6 +629,7 @@ export class NoteCreateService implements OnApplicationShutdown {
this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id,
}, {
jobId: `pollEnd:${note.id}`,
delay,
removeOnComplete: true,
});
@ -821,6 +824,11 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!user.noindex) this.index(note);
}
@bindThis
public isPureRenote(note: Option): note is PureRenoteOption {
return this.isRenote(note) && !this.isQuote(note);
}
@bindThis
private isRenote(note: Option): note is Option & { renote: MiNote } {
return note.renote != null;

View file

@ -442,7 +442,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
if (i.isNSFW) {
if (i.isNSFW && !this.noteCreateService.isPureRenote(data)) {
data.cw = 'Instance is marked as NSFW';
}
});
@ -471,8 +471,9 @@ export class NoteEditService implements OnApplicationShutdown {
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
const pollChanged = data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll);
if (Object.keys(update).length > 0 || filesChanged) {
if (Object.keys(update).length > 0 || filesChanged || pollChanged) {
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
await this.noteEditRepository.insert({
@ -544,7 +545,7 @@ export class NoteEditService implements OnApplicationShutdown {
}));
}
if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) {
if (pollChanged) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.update(MiNote, oldnote.id, note);
@ -605,10 +606,11 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.remove(note.id);
this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id,
}, {
jobId: `pollEnd:${note.id}`,
delay,
removeOnComplete: true,
});

View file

@ -56,7 +56,7 @@ export class RemoteUserResolveService {
host = this.utilityService.toPuny(host);
if (this.config.host === host) {
if (host === this.utilityService.toPuny(this.config.host)) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
if (u == null) {

View file

@ -4,9 +4,10 @@
*/
import { URL } from 'node:url';
import { toASCII } from 'punycode';
import punycode from 'punycode/punycode.js';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
import psl from 'psl';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
@ -34,6 +35,11 @@ export class UtilityService {
return this.toPuny(this.config.host) === this.toPuny(host);
}
@bindThis
public isUriLocal(uri: string): boolean {
return this.punyHost(uri) === this.toPuny(this.config.host);
}
@bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
if (host == null) return false;
@ -101,13 +107,13 @@ export class UtilityService {
@bindThis
public toPuny(host: string): string {
return toASCII(host.toLowerCase());
return punycode.toASCII(host.toLowerCase());
}
@bindThis
public toPunyNullable(host: string | null | undefined): string | null {
if (host == null) return null;
return toASCII(host.toLowerCase());
return punycode.toASCII(host.toLowerCase());
}
@bindThis
@ -117,6 +123,26 @@ export class UtilityService {
return host;
}
private specialSuffix(hostname: string): string | null {
// masto.host provides domain names for its clients, we have to
// treat it as if it were a public suffix
const mastoHost = hostname.match(/\.?([a-zA-Z0-9-]+\.masto\.host)$/i);
if (mastoHost) {
return mastoHost[1];
}
return null;
}
@bindThis
public punyHostPSLDomain(url: string): string {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
return host;
}
public isFederationAllowedHost(host: string): boolean {
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
@ -55,6 +56,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService,
private apPersonService: ApPersonService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
@ -64,8 +66,11 @@ export class ApDbResolverService implements OnApplicationShutdown {
public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
const separator = '/';
const uri = new URL(getApId(value));
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
const apId = getApId(value);
const uri = new URL(apId);
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
return { local: false, uri: apId };
}
const [, type, id, ...rest] = uri.pathname.split(separator);
return {

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
@ -128,7 +129,7 @@ class DeliverManager {
for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox;
if (inbox === null) throw new Error('inbox is null');
if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`);
inboxes.set(inbox, following.followerSharedInbox != null);
}
}

View file

@ -30,7 +30,9 @@ import type { MiRemoteUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { fromTuple } from '@/misc/from-tuple.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js';
@ -39,8 +41,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
import { fromTuple } from '@/misc/from-tuple.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
@Injectable()
export class ApInboxService {
@ -93,15 +94,26 @@ export class ApInboxService {
}
@bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
let result = undefined as string | void;
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
}
for (const item of items) {
const act = await resolver.resolve(item);
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
this.logger.debug('skipping activity: activity id is null or mismatching');
continue;
}
try {
results.push([getApId(item), await this.performOneActivity(actor, act)]);
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
this.logger.error(err);
@ -116,7 +128,7 @@ export class ApInboxService {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
}
} else {
result = await this.performOneActivity(actor, activity);
result = await this.performOneActivity(actor, activity, resolver);
}
// ついでにリモートユーザーの情報が古かったら更新しておく
@ -131,37 +143,39 @@ export class ApInboxService {
}
@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
if (actor.isSuspended) return;
if (isCreate(activity)) {
return await this.create(actor, activity);
return await this.create(actor, activity, resolver);
} else if (isDelete(activity)) {
return await this.delete(actor, activity);
} else if (isUpdate(activity)) {
return await this.update(actor, activity);
return await this.update(actor, activity, resolver);
} else if (isFollow(activity)) {
return await this.follow(actor, activity);
} else if (isAccept(activity)) {
return await this.accept(actor, activity);
return await this.accept(actor, activity, resolver);
} else if (isReject(activity)) {
return await this.reject(actor, activity);
return await this.reject(actor, activity, resolver);
} else if (isAdd(activity)) {
return await this.add(actor, activity);
return await this.add(actor, activity, resolver);
} else if (isRemove(activity)) {
return await this.remove(actor, activity);
return await this.remove(actor, activity, resolver);
} else if (isAnnounce(activity)) {
return await this.announce(actor, activity);
return await this.announce(actor, activity, resolver);
} else if (isLike(activity)) {
return await this.like(actor, activity);
return await this.like(actor, activity, resolver);
} else if (isDislike(activity)) {
return await this.dislike(actor, activity);
} else if (isUndo(activity)) {
return await this.undo(actor, activity);
return await this.undo(actor, activity, resolver);
} else if (isBlock(activity)) {
return await this.block(actor, activity);
} else if (isFlag(activity)) {
return await this.flag(actor, activity);
} else if (isMove(activity)) {
return await this.move(actor, activity);
return await this.move(actor, activity, resolver);
} else {
return `unrecognized activity type: ${activity.type}`;
}
@ -185,30 +199,42 @@ export class ApInboxService {
}
@bindThis
private async like(actor: MiRemoteUser, activity: ILike): Promise<string> {
private async like(actor: MiRemoteUser, activity: ILike, resolver?: Resolver): Promise<string> {
const targetUri = getApId(activity.object);
const note = await this.apNoteService.fetchNote(targetUri);
const object = fromTuple(activity.object);
if (!object) return 'skip: activity has no object property';
const note = await this.apNoteService.resolveNote(object, { resolver });
if (!note) return `skip: target note not found ${targetUri}`;
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
try {
await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name);
return 'ok';
} catch (err) {
if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
return 'skip: already reacted';
} else {
throw err;
}
}).then(() => 'ok');
}
}
@bindThis
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
private async dislike(actor: MiRemoteUser, dislike: IDislike): Promise<string> {
return await this.undoLike(actor, dislike);
}
@bindThis
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
@ -245,7 +271,7 @@ export class ApInboxService {
}
@bindThis
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -255,8 +281,12 @@ export class ApInboxService {
}
if (activity.target === actor.featured) {
const object = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(object);
const activityObject = fromTuple(activity.object);
if (isApObject(activityObject) && !isPost(activityObject)) {
return `unsupported featured object type: ${getApType(activityObject)}`;
}
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id);
return;
@ -266,12 +296,13 @@ export class ApInboxService {
}
@bindThis
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const activityObject = fromTuple(activity.object);
if (!activityObject) return 'skip: activity has no object property';
@ -280,7 +311,7 @@ export class ApInboxService {
const target = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
return e;
throw e;
});
if (isPost(target)) return await this.announceNote(actor, activity, target);
@ -289,7 +320,7 @@ export class ApInboxService {
}
@bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
if (actor.isSuspended) {
@ -311,13 +342,13 @@ export class ApInboxService {
// Announce対象をresolve
let renote;
try {
renote = await this.apNoteService.resolveNote(target);
renote = await this.apNoteService.resolveNote(target, { resolver });
if (renote == null) return 'announce target is null';
} catch (err) {
// 対象が4xxならスキップ
if (err instanceof StatusError) {
if (!err.isRetryable) {
return `Ignored announce target ${target.id} - ${err.statusCode}`;
return `skip: ignored announce target ${target.id} - ${err.statusCode}`;
}
return `Error in announce target ${target.id} - ${err.statusCode}`;
}
@ -330,7 +361,7 @@ export class ApInboxService {
this.logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
@ -368,7 +399,7 @@ export class ApInboxService {
}
@bindThis
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Create: ${uri}`);
@ -394,7 +425,8 @@ export class ApInboxService {
activityObject.attributedTo = activity.actor;
}
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -402,14 +434,14 @@ export class ApInboxService {
});
if (isPost(object)) {
await this.createNote(resolver, actor, object, false, activity);
await this.createNote(resolver, actor, object, false);
} else {
return `Unknown type: ${getApType(object)}`;
return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`;
}
}
@bindThis
private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> {
private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false): Promise<string> {
const uri = getApId(note);
if (typeof note === 'object') {
@ -421,6 +453,8 @@ export class ApInboxService {
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
return 'skip: host in actor.uri !== note.id';
}
} else {
return 'skip: note.id is not a string';
}
}
@ -430,11 +464,11 @@ export class ApInboxService {
const exist = await this.apNoteService.fetchNote(note);
if (exist) return 'skip: note exists';
await this.apNoteService.createNote(note, resolver, silent);
await this.apNoteService.createNote(note, actor, resolver, silent);
return 'ok';
} catch (err) {
if (err instanceof StatusError && !err.isRetryable) {
return `skip ${err.statusCode}`;
return `skip: ${err.statusCode}`;
} else {
throw err;
}
@ -521,7 +555,7 @@ export class ApInboxService {
const note = await this.apDbResolverService.getNoteFromApId(uri);
if (note == null) {
return 'message not found';
return 'skip: ignoring deleted note on both ends';
}
if (note.userId !== actor.id) {
@ -568,12 +602,13 @@ export class ApInboxService {
}
@bindThis
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Reject: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -610,7 +645,7 @@ export class ApInboxService {
}
@bindThis
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -621,7 +656,11 @@ export class ApInboxService {
if (activity.target === actor.featured) {
const activityObject = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(activityObject);
if (isApObject(activityObject) && !isPost(activityObject)) {
return `unsupported featured object type: ${getApType(activityObject)}`;
}
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id);
return;
@ -631,7 +670,7 @@ export class ApInboxService {
}
@bindThis
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -640,11 +679,12 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
return e;
throw e;
});
// don't queue because the sender may attempt again when timeout
@ -654,7 +694,7 @@ export class ApInboxService {
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
if (isAccept(object)) return await this.undoAccept(actor, object);
return `skip: unknown object type ${getApType(object)}`;
return `skip: unknown activity type ${getApType(object)}`;
}
@bindThis
@ -749,7 +789,7 @@ export class ApInboxService {
}
@bindThis
private async undoLike(actor: MiRemoteUser, activity: ILike): Promise<string> {
private async undoLike(actor: MiRemoteUser, activity: ILike | IDislike): Promise<string> {
const targetUri = getApId(activity.object);
const note = await this.apNoteService.fetchNote(targetUri);
@ -764,14 +804,15 @@ export class ApInboxService {
}
@bindThis
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
this.logger.debug('Update');
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -782,22 +823,32 @@ export class ApInboxService {
await this.apPersonService.updatePerson(actor.uri, resolver, object);
return 'ok: Person updated';
} else if (getApType(object) === 'Question') {
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
// If we get an Update(Question) for a note that doesn't exist, then create it instead
if (!await this.apNoteService.hasNote(object)) {
return await this.create(actor, activity, resolver);
}
await this.apQuestionService.updateQuestion(object, actor, resolver);
return 'ok: Question updated';
} else if (getApType(object) === 'Note') {
await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err));
} else if (isPost(object)) {
// If we get an Update(Note) for a note that doesn't exist, then create it instead
if (!await this.apNoteService.hasNote(object)) {
return await this.create(actor, activity, resolver);
}
await this.apNoteService.updateNote(object, actor, resolver);
return 'ok: Note updated';
} else {
return `skip: Unknown type: ${getApType(object)}`;
return `skip: Unsupported type for Update: ${getApType(object)} ${getNullableApId(object)}`;
}
}
@bindThis
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
}
}

View file

@ -7,6 +7,7 @@ import { createPublicKey, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import * as mfm from '@transfem-org/sfm-js';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
@ -30,6 +31,7 @@ import { IdService } from '@/core/IdService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import { getApId } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@Injectable()
@ -106,7 +108,7 @@ export class ApRendererService {
to = [`${attributedTo}/followers`];
cc = [];
} else {
throw new Error('renderAnnounce: cannot render non-public note');
throw new UnrecoverableError(`renderAnnounce: cannot render non-public note: ${getApId(object)}`);
}
return {
@ -469,6 +471,7 @@ export class ApRendererService {
};
}
// if you change this, also change `server/api/endpoints/i/update.ts`
@bindThis
public async renderPerson(user: MiLocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id);

View file

@ -18,6 +18,7 @@ import type Logger from '@/logger.js';
import type { IObject } from './type.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
import { UtilityService } from "@/core/UtilityService.js";
type Request = {
url: string;
@ -147,6 +148,7 @@ export class ApRequestService {
private userKeypairService: UserKeypairService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private utilityService: UtilityService,
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
@ -241,7 +243,9 @@ export class ApRequestService {
if (alternate) {
const href = alternate.getAttribute('href');
if (href) {
return await this.signedGet(href, user, false);
if (this.utilityService.punyHostPSLDomain(url) === this.utilityService.punyHostPSLDomain(href)) {
return await this.signedGet(href, user, false);
}
}
}
} catch (e) {
@ -257,7 +261,7 @@ export class ApRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]);
assertActivityMatchesUrls(activity, [finalUrl]);
return activity;
}

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
@ -15,12 +16,12 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { fromTuple } from '@/misc/from-tuple.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { fromTuple } from '@/misc/from-tuple.js';
export class Resolver {
private history: Set<string>;
@ -42,7 +43,7 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 100,
private recursionLimit = 256,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@ -53,6 +54,11 @@ export class Resolver {
return Array.from(this.history);
}
@bindThis
public getRecursionLimit(): number {
return this.recursionLimit;
}
@bindThis
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string'
@ -62,7 +68,7 @@ export class Resolver {
if (isCollectionOrOrderedCollection(collection)) {
return collection;
} else {
throw new Error(`unrecognized collection type: ${collection.type}`);
throw new UnrecoverableError(`unrecognized collection type: ${collection.type}`);
}
}
@ -79,15 +85,15 @@ export class Resolver {
// URLs with fragment parts cannot be resolved correctly because
// the fragment part does not get transmitted over HTTP(S).
// Avoid strange behaviour by not trying to resolve these at all.
throw new Error(`cannot resolve URL with fragment: ${value}`);
throw new UnrecoverableError(`cannot resolve URL with fragment: ${value}`);
}
if (this.history.has(value)) {
throw new Error('cannot resolve already resolved one');
throw new Error(`cannot resolve already resolved URL: ${value}`);
}
if (this.history.size > this.recursionLimit) {
throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
throw new Error(`hit recursion limit: ${value}`);
}
this.history.add(value);
@ -98,7 +104,7 @@ export class Resolver {
}
if (!this.utilityService.isFederationAllowedHost(host)) {
throw new Error('Instance is blocked');
throw new UnrecoverableError(`instance is blocked: ${value}`);
}
if (this.config.signToActivityPubGet && !this.user) {
@ -114,15 +120,19 @@ export class Resolver {
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
) {
throw new Error('invalid response');
throw new UnrecoverableError(`invalid AP object ${value}: does not have ActivityStreams context`);
}
// HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) {
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
if (object.id == null) {
throw new UnrecoverableError(`invalid AP object ${value}: missing id`);
}
if (this.utilityService.punyHostPSLDomain(object.id) !== this.utilityService.punyHostPSLDomain(value)) {
throw new UnrecoverableError(`invalid AP object ${value}: id ${object.id} has different host`);
}
return object;
@ -131,7 +141,7 @@ export class Resolver {
@bindThis
private resolveLocal(url: string): Promise<IObject> {
const parsed = this.apDbResolverService.parseUri(url);
if (!parsed.local) throw new Error('resolveLocal: not local');
if (!parsed.local) throw new UnrecoverableError(`resolveLocal - not a local URL: ${url}`);
switch (parsed.type) {
case 'notes':
@ -160,7 +170,7 @@ export class Resolver {
case 'follows':
return this.followRequestsRepository.findOneBy({ id: parsed.id })
.then(async followRequest => {
if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID');
if (followRequest == null) throw new UnrecoverableError(`resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: followRequest.followerId,
@ -172,12 +182,12 @@ export class Resolver {
}),
]);
if (follower == null || followee == null) {
throw new Error('resolveLocal: follower or followee does not exist');
throw new Error(`resolveLocal - follower or followee does not exist: ${url}`);
}
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
});
default:
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
throw new UnrecoverableError(`resolveLocal: type ${parsed.type} unhandled: ${url}`);
}
}
}

View file

@ -5,6 +5,7 @@
import * as crypto from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
@ -109,7 +110,7 @@ class JsonLd {
@bindThis
private getLoader() {
return async (url: string): Promise<RemoteDocument> => {
if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`);
if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`);
if (this.preLoad) {
if (url in PRELOADED_CONTEXTS) {
@ -148,7 +149,7 @@ class JsonLd {
},
).then(res => {
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`);
} else {
return res.json();
}

View file

@ -2,26 +2,30 @@
* SPDX-FileCopyrightText: dakkar and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UnrecoverableError } from 'bullmq';
import type { IObject } from '../type.js';
function getHrefFrom(one: IObject|string): string | undefined {
if (typeof(one) === 'string') return one;
return one.href;
function getHrefsFrom(one: IObject | string | undefined | (IObject | string | undefined)[]): (string | undefined)[] {
if (Array.isArray(one)) {
return one.flatMap(h => getHrefsFrom(h));
}
return [
typeof(one) === 'object' ? one.href : one,
];
}
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
const idOk = activity.id !== undefined && urls.includes(activity.id);
if (idOk) return;
const expectedUrls = new Set(urls
.filter(u => URL.canParse(u))
.map(u => new URL(u).href),
);
const url = activity.url;
if (url) {
// `activity.url` can be an `ApObject = IObject | string | (IObject
// | string)[]`, we have to look inside it
const activityUrls = Array.isArray(url) ? url.map(getHrefFrom) : [getHrefFrom(url)];
const goodUrl = activityUrls.find(u => u && urls.includes(u));
const actualUrls = [activity.id, ...getHrefsFrom(activity.url)]
.filter(u => u && URL.canParse(u))
.map(u => new URL(u as string).href);
if (goodUrl) return;
if (!actualUrls.some(u => expectedUrls.has(u))) {
throw new UnrecoverableError(`bad Activity: neither id(${activity.id}) nor url(${JSON.stringify(activity.url)}) match location(${urls})`);
}
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${JSON.stringify(activity?.url)}) match location(${urls})`);
}

View file

@ -9,7 +9,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') {
throw new Error('Validate content type of AP response: No content-type header');
throw new Error(`invalid content type of AP response - no content-type header: ${response.url}`);
}
if (
contentType.startsWith('application/activity+json') ||
@ -17,7 +17,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
) {
return;
}
throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json');
throw new Error(`invalid content type of AP response - content type is not application/activity+json or application/ld+json: ${response.url}`);
}
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
@ -26,7 +26,7 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') {
throw new Error('Validate content type of JSON LD: No content-type header');
throw new Error(`invalid content type of JSON LD - no content-type header: ${response.url}`);
}
if (
contentType.startsWith('application/ld+json') ||
@ -35,5 +35,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
) {
return;
}
throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json');
throw new Error(`invalid content type of JSON LD - content type is not application/ld+json or application/json: ${response.url}`);
}

View file

@ -15,6 +15,7 @@ import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { Config } from '@/config.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { isDocument, type IObject } from '../type.js';
@ -47,7 +48,7 @@ export class ApImageService {
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`);
}
const image = await this.apResolverService.createResolver().resolve(value);

View file

@ -5,8 +5,9 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiRemoteUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
@ -49,6 +50,9 @@ export class ApNoteService {
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@ -82,7 +86,13 @@ export class ApNoteService {
}
@bindThis
public validateNote(object: IObject, uri: string): Error | null {
public validateNote(
object: IObject,
uri: string,
actor?: MiRemoteUser,
user?: MiRemoteUser,
note?: MiNote,
): Error | null {
const expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object);
@ -99,10 +109,27 @@ export class ApNoteService {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
if (actor) {
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
if (attribution !== actor.uri) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
}
if (user && attribution !== user.uri) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
}
}
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
}
if (note) {
const url = (object.url) ? getOneApId(object.url) : note.url;
if (url && url !== note.url) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`);
}
}
return null;
}
@ -116,18 +143,27 @@ export class ApNoteService {
return await this.apDbResolverService.getNoteFromApId(object);
}
/**
* Returns true if the provided object / ID exists in the local database.
*/
@bindThis
public async hasNote(object: string | IObject | [string | IObject]): Promise<boolean> {
const uri = getApId(object);
return await this.notesRepository.existsBy({ uri });
}
/**
* Noteを作成します
*/
@bindThis
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri);
const err = this.validateNote(object, entryUri, actor);
if (err) {
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },
@ -141,29 +177,40 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) {
throw new Error('unexpected schema of note.id: ' + note.id);
if (note.id == null) {
throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`);
}
if (!checkHttps(note.id)) {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`);
}
const url = getOneApHrefNullable(note.url);
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of note url: ' + url);
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${entryUri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
throw new UnrecoverableError(`note url <> uri host mismatch: ${url} <> ${note.id} in ${entryUri}`);
}
}
this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ
if (note.attributedTo == null) {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`);
}
const uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
if (actor && actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${uri} has been suspended: ${entryUri}`);
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
@ -190,15 +237,16 @@ export class ApNoteService {
*/
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${entryUri}`);
}
//#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${entryUri}`);
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
@ -228,13 +276,13 @@ export class ApNoteService {
.then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`);
}
return x;
})
.catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
throw err;
})
: null;
@ -243,16 +291,25 @@ export class ApNoteService {
let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
const tryResolveNote = async (uri: string): Promise<
const tryResolveNote = async (uri: unknown): Promise<
| { status: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' }
> => {
if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' };
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
return { status: 'permerror' };
}
try {
const res = await this.resolveNote(uri, { resolver });
if (res == null) return { status: 'permerror' };
if (res == null) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
return { status: 'permerror' };
}
return { status: 'ok', res };
} catch (e) {
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
};
@ -265,7 +322,7 @@ export class ApNoteService {
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error('quote resolve failed');
throw new Error(`temporary error resolving quote for ${entryUri}`);
}
}
}
@ -325,7 +382,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value);
if (!duplicate) {
throw new Error('The note creation failed with duplication error even when there is no duplication');
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`);
}
return duplicate;
}
@ -335,16 +392,18 @@ export class ApNoteService {
* Noteを作成します
*/
@bindThis
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
const noteUri = typeof value === 'string' ? value : value.id;
if (noteUri == null) throw new Error('uri is null');
public async updateNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
const noteUri = getApId(value);
// URIがこのサーバーを指しているならスキップ
if (noteUri.startsWith(this.config.url + '/')) throw new Error('uri points local');
if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`);
//#region このサーバーに既に登録されているか
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
if (UpdatedNote == null) throw new Error('Note is not registered');
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`);
const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null;
if (user == null) throw new Error(`Note is not registered (no user): ${noteUri}`);
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
@ -362,33 +421,38 @@ export class ApNoteService {
throw err;
}
// `validateNote` checks that the actor and user are one and the same
// eslint-disable-next-line no-param-reassign
actor ??= user;
const note = object as IPost;
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) {
throw new Error('unexpected schema of note.id: ' + note.id);
if (note.id == null) {
throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`);
}
if (!checkHttps(note.id)) {
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`);
}
const url = getOneApHrefNullable(note.url);
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of note url: ' + url);
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${noteUri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
throw new UnrecoverableError(`note url <> id host mismatch: ${url} <> ${note.id} in ${noteUri}`);
}
}
this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ
if (note.attributedTo == null) {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
}
const uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
if (actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`);
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
@ -415,17 +479,10 @@ export class ApNoteService {
*/
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${noteUri}`);
}
//#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers;
@ -453,13 +510,13 @@ export class ApNoteService {
.then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
throw new Error(`could not fetch inReplyTo ${note.inReplyTo}: ${entryUri}`);
}
return x;
})
.catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo}: ${entryUri}`);
throw err;
})
: null;
@ -468,16 +525,25 @@ export class ApNoteService {
let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
const tryResolveNote = async (uri: string): Promise<
const tryResolveNote = async (uri: unknown): Promise<
| { status: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' }
> => {
if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' };
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
return { status: 'permerror' };
}
try {
const res = await this.resolveNote(uri, { resolver });
if (res == null) return { status: 'permerror' };
if (res == null) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
return { status: 'permerror' };
}
return { status: 'ok', res };
} catch (e) {
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
};
@ -490,7 +556,7 @@ export class ApNoteService {
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error('quote resolve failed');
throw new Error(`temporary error resolving quote for ${entryUri}`);
}
}
}
@ -525,7 +591,7 @@ export class ApNoteService {
const apEmojis = emojis.map(emoji => emoji.name);
try {
return await this.noteEditService.edit(actor, UpdatedNote.id, {
return await this.noteEditService.edit(actor, updatedNote.id, {
createdAt: note.published ? new Date(note.published) : null,
files,
reply,
@ -550,7 +616,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value);
if (!duplicate) {
throw new Error('The note creation failed with duplication error even when there is no duplication');
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${noteUri}`);
}
return duplicate;
}
@ -567,7 +633,7 @@ export class ApNoteService {
const uri = getApId(value);
if (!this.utilityService.isFederationAllowedUri(uri)) {
throw new StatusError('blocked host', 451);
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
}
const unlock = await this.appLockService.getApLock(uri);
@ -578,15 +644,15 @@ export class ApNoteService {
if (exist) return exist;
//#endregion
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
if (this.utilityService.isUriLocal(uri)) {
throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
}
// リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, true);
return await this.createNote(createFrom, undefined, options.resolver, true);
} finally {
unlock();
}
@ -627,7 +693,7 @@ export class ApNoteService {
});
const emoji = await this.emojisRepository.findOneBy({ host, name });
if (emoji == null) throw new Error('emoji update failed');
if (emoji == null) throw new Error(`emoji update failed: ${name}:${host}`);
return emoji;
}

View file

@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { AbortError } from 'node-fetch';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
@ -136,35 +138,49 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.utilityService.punyHost(uri);
const expectHost = this.utilityService.punyHostPSLDomain(uri);
if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`);
throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`);
}
if (!(typeof x.id === 'string' && x.id.length > 0)) {
throw new Error('invalid Actor: wrong id');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`);
}
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
throw new Error('invalid Actor: wrong inbox');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`);
}
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
throw new Error('invalid Actor: inbox has different host');
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
if (inboxHost !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`);
}
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (sharedInboxObject != null) {
const sharedInbox = getApId(sharedInboxObject);
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`);
}
}
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
const collectionUri = (x as IActor)[collection];
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
throw new Error(`invalid Actor: ${collection} has different host`);
const xCollection = (x as IActor)[collection];
if (xCollection != null) {
const collectionUri = getApId(xCollection);
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`);
}
} else if (collectionUri != null) {
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
}
}
}
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
throw new Error('invalid Actor: wrong username');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`);
}
// These fields are only informational, and some AP software allows these
@ -172,7 +188,7 @@ export class ApPersonService implements OnModuleInit {
// we can at least see these users and their activities.
if (x.name) {
if (!(typeof x.name === 'string' && x.name.length > 0)) {
throw new Error('invalid Actor: wrong name');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`);
}
x.name = truncate(x.name, nameLength);
} else if (x.name === '') {
@ -181,24 +197,24 @@ export class ApPersonService implements OnModuleInit {
}
if (x.summary) {
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
throw new Error('invalid Actor: wrong summary');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`);
}
x.summary = truncate(x.summary, summaryLength);
}
const idHost = this.utilityService.punyHost(x.id);
const idHost = this.utilityService.punyHostPSLDomain(x.id);
if (idHost !== expectHost) {
throw new Error('invalid Actor: id has different host');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`);
}
if (x.publicKey) {
if (typeof x.publicKey.id !== 'string') {
throw new Error('invalid Actor: publicKey.id is not a string');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`);
}
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
if (publicKeyIdHost !== expectHost) {
throw new Error('invalid Actor: publicKey.id has different host');
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`);
}
}
@ -284,24 +300,23 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`);
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
const host = this.utilityService.punyHost(uri);
if (host === this.utilityService.toPuny(this.config.host)) {
throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user');
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
if (object.id == null) throw new UnrecoverableError(`null object.id in ${uri}`);
const person = this.validateActor(object, uri);
this.logger.info(`Creating the Person: ${person.id}`);
const host = this.utilityService.punyHost(object.id);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
@ -327,8 +342,18 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
if (person.id == null) {
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`);
}
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
}
}
// Create user
@ -419,7 +444,7 @@ export class ApPersonService implements OnModuleInit {
if (isDuplicateKeyValueError(e)) {
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({ uri: person.id });
if (u == null) throw new Error('already registered');
if (u == null) throw new UnrecoverableError(`already registered a user with conflicting data: ${uri}`);
user = u as MiRemoteUser;
} else {
@ -428,7 +453,7 @@ export class ApPersonService implements OnModuleInit {
}
}
if (user == null) throw new Error('failed to create user: user is null');
if (user == null) throw new Error(`failed to create user - user is null: ${uri}`);
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
@ -477,10 +502,10 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(`${this.config.url}/`)) return;
if (this.utilityService.isUriLocal(uri)) return;
//#region このサーバーに既に登録されているか
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
@ -529,8 +554,18 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
if (person.id == null) {
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`);
}
if (url != null) {
if (!checkHttps(url)) {
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
}
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
}
}
const updates = {
@ -640,7 +675,7 @@ export class ApPersonService implements OnModuleInit {
});
}
return 'skip';
return 'skip: too soon to migrate accounts';
}
/**
@ -690,8 +725,16 @@ export class ApPersonService implements OnModuleInit {
const _resolver = resolver ?? this.apResolverService.createResolver();
// Resolve to (Ordered)Collection Object
const collection = await _resolver.resolveCollection(user.featured);
if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection');
const collection = await _resolver.resolveCollection(user.featured).catch(err => {
if (err instanceof AbortError || err instanceof StatusError) {
this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`);
} else {
this.logger.error('Failed to update featured notes:', err);
}
});
if (!collection) return;
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`);
// Resolve to Object(may be Note) arrays
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
@ -699,9 +742,10 @@ export class ApPersonService implements OnModuleInit {
// Resolve and regist Notes
const limit = promiseLimit<MiNote | null>(2);
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
.slice(0, 5)
.slice(0, maxPinned)
.map(item => limit(() => this.apNoteService.resolveNote(item, {
resolver: _resolver,
sentFrom: new URL(user.uri),
@ -747,7 +791,7 @@ export class ApPersonService implements OnModuleInit {
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
dst = await this.fetchPerson(src.movedToUri) ?? dst;
} else {
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
if (this.utilityService.isUriLocal(src.movedToUri)) {
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
return 'failed: movedTo is local but not found';
}

View file

@ -4,17 +4,20 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, PollsRepository } from '@/models/_.js';
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { IPoll } from '@/models/Poll.js';
import type { MiRemoteUser } from '@/models/User.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isQuestion } from '../type.js';
import { UtilityService } from '@/core/UtilityService.js';
import { getApId, getApType, getNullableApId, getOneApId, isQuestion } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IQuestion } from '../type.js';
import type { IObject } from '../type.js';
@Injectable()
export class ApQuestionService {
@ -24,6 +27,9 @@ export class ApQuestionService {
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -32,6 +38,7 @@ export class ApQuestionService {
private apResolverService: ApResolverService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) {
this.logger = this.apLoggerService.logger;
}
@ -42,10 +49,10 @@ export class ApQuestionService {
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
if (!isQuestion(question)) throw new UnrecoverableError(`invalid type ${getApType(question)}: ${getNullableApId(question)}`);
const multiple = question.oneOf === undefined;
if (multiple && question.anyOf === undefined) throw new Error('invalid question');
if (multiple && question.anyOf === undefined) throw new UnrecoverableError(`invalid question - neither oneOf nor anyOf is defined: ${getNullableApId(question)}`);
const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
@ -65,38 +72,48 @@ export class ApQuestionService {
* @returns true if updated
*/
@bindThis
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null');
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
const uri = getApId(value);
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
if (this.utilityService.isUriLocal(uri)) throw new Error(`uri points local: ${uri}`);
//#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri });
if (note == null) throw new Error('Question is not registered');
if (note == null) throw new Error(`Question is not registered (no note): ${uri}`);
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
if (poll == null) throw new Error('Question is not registered');
if (poll == null) throw new Error(`Question is not registered (no poll): ${uri}`);
const user = await this.usersRepository.findOneBy({ id: poll.userId });
if (user == null) throw new Error(`Question is not registered (no user): ${uri}`);
//#endregion
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value) as IQuestion;
const question = await resolver.resolve(value);
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
if (question.type !== 'Question') throw new Error('object is not a Question');
if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`);
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
const attributionMatchesExisting = attribution === user.uri;
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
if (!attributionMatchesExisting || !actorMatchesAttribution) {
throw new UnrecoverableError(`Refusing to ingest update for poll by different user: ${uri}`);
}
const apChoices = question.oneOf ?? question.anyOf;
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
if (apChoices == null) throw new UnrecoverableError(`poll has no choices: ${uri}`);
let changed = false;
for (const choice of poll.choices) {
const oldCount = poll.votes[poll.choices.indexOf(choice)];
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new UnrecoverableError(`invalid newCount: ${newCount} in ${uri}`);
if (oldCount <= newCount) {
changed = true;

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UnrecoverableError } from 'bullmq';
import { fromTuple } from '@/misc/from-tuple.js';
export type Obj = { [x: string]: any };
@ -61,7 +62,19 @@ export function getApId(value: string | IObject | [string | IObject]): string {
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
throw new Error('cannot determine id');
throw new UnrecoverableError('cannot determine id');
}
/**
* Get ActivityStreams Object id, or null if not present
*/
export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
// eslint-disable-next-line no-param-reassign
value = fromTuple(value);
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
return null;
}
/**
@ -323,6 +336,10 @@ export interface ILike extends IActivity {
_misskey_reaction?: string;
}
export interface IDislike extends IActivity {
type: 'Dislike';
}
export interface IAnnounce extends IActivity {
type: 'Announce';
}
@ -340,6 +357,7 @@ export interface IMove extends IActivity {
target: IObject | string;
}
export const isApObject = (object: string | IObject): object is IObject => typeof(object) === 'object';
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
@ -354,6 +372,7 @@ export const isLike = (object: IObject): object is ILike => {
const type = getApType(object);
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type);
};
export const isDislike = (object: IObject): object is IDislike => getApType(object) === 'Dislike';
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';

View file

@ -6,6 +6,8 @@
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { ChartLoggerService } from '@/core/chart/ChartLoggerService.js';
import Logger from '@/logger.js';
import FederationChart from './charts/federation.js';
import NotesChart from './charts/notes.js';
import UsersChart from './charts/users.js';
@ -24,6 +26,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
export class ChartManagementService implements OnApplicationShutdown {
private charts;
private saveIntervalId: NodeJS.Timeout;
private readonly logger: Logger;
constructor(
private federationChart: FederationChart,
@ -38,6 +41,7 @@ export class ChartManagementService implements OnApplicationShutdown {
private perUserFollowingChart: PerUserFollowingChart,
private perUserDriveChart: PerUserDriveChart,
private apRequestChart: ApRequestChart,
private chartLoggerService: ChartLoggerService,
) {
this.charts = [
this.federationChart,
@ -53,6 +57,7 @@ export class ChartManagementService implements OnApplicationShutdown {
this.perUserDriveChart,
this.apRequestChart,
];
this.logger = chartLoggerService.logger;
}
@bindThis
@ -62,6 +67,7 @@ export class ChartManagementService implements OnApplicationShutdown {
for (const chart of this.charts) {
chart.save();
}
this.logger.info('All charts saved');
}, 1000 * 60 * 20);
}
@ -72,6 +78,7 @@ export class ChartManagementService implements OnApplicationShutdown {
await Promise.all(
this.charts.map(chart => chart.save()),
);
this.logger.info('All charts saved');
}
}

View file

@ -368,7 +368,7 @@ export default abstract class Chart<T extends Schema> {
// 初期ログデータを作成
data = this.getNewLog(null);
this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
this.logger.debug(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
}
const date = Chart.dateToTimestamp(current);
@ -398,7 +398,7 @@ export default abstract class Chart<T extends Schema> {
...columns,
}) as RawRecord<T>;
this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
this.logger.debug(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
return log;
} finally {
@ -418,7 +418,7 @@ export default abstract class Chart<T extends Schema> {
@bindThis
public async save(): Promise<void> {
if (this.buffer.length === 0) {
this.logger.info(`${this.name}: Write skipped`);
this.logger.debug(`${this.name}: Write skipped`);
return;
}
@ -519,7 +519,7 @@ export default abstract class Chart<T extends Schema> {
.execute(),
]);
this.logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`);
this.logger.debug(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`);
// TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする
this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group));

View file

@ -16,7 +16,9 @@ import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CacheService } from '../CacheService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js';
@ -27,6 +29,7 @@ import type { Config } from '@/config.js';
export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
private driveFileEntityService: DriveFileEntityService;
private cacheService: CacheService;
private customEmojiService: CustomEmojiService;
private reactionService: ReactionService;
private reactionsBufferingService: ReactionsBufferingService;
@ -75,6 +78,7 @@ export class NoteEntityService implements OnModuleInit {
onModuleInit() {
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.cacheService = this.moduleRef.get('CacheService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.reactionService = this.moduleRef.get('ReactionService');
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
@ -119,29 +123,32 @@ export class NoteEntityService implements OnModuleInit {
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false;
} else {
if (packedNote.renote) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.renote.userId,
followerId: meId,
},
});
// フォロワーかどうか
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
hide = !isFollowing;
} else {
// フォロワーかどうか
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
hide = !isFollowing;
}
hide = !isFollowing;
}
}
// If this is a pure renote (boost), then we should *also* check the boosted note's visibility.
// Otherwise we can have empty notes on the timeline, which is not good.
// Notes are packed in depth-first order, so we can safely grab the "isHidden" property to avoid duplicated checks.
// This is pulled out to ensure that we check both the renote *and* the boosted note.
if (packedNote.renote?.isHidden && isPackedPureRenote(packedNote)) {
hide = true;
}
if (!hide && meId && packedNote.userId !== meId) {
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId);
if (isBlocked) hide = true;
}
if (hide) {
packedNote.visibleUserIds = undefined;
packedNote.fileIds = [];
@ -149,6 +156,12 @@ export class NoteEntityService implements OnModuleInit {
packedNote.text = null;
packedNote.poll = undefined;
packedNote.cw = null;
packedNote.repliesCount = 0;
packedNote.reactionAcceptance = null;
packedNote.reactionAndUserPairCache = undefined;
packedNote.reactionCount = 0;
packedNote.reactionEmojis = {};
packedNote.reactions = {};
packedNote.isHidden = true;
}
}
@ -262,7 +275,8 @@ export class NoteEntityService implements OnModuleInit {
return true;
} else {
// フォロワーかどうか
const [following, user] = await Promise.all([
const [blocked, following, user] = await Promise.all([
this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
this.followingsRepository.count({
where: {
followeeId: note.userId,
@ -273,6 +287,8 @@ export class NoteEntityService implements OnModuleInit {
this.usersRepository.findOneByOrFail({ id: meId }),
]);
if (blocked) return false;
/* If we know the following, everyhting is fine.
But if we do not know the following, it might be that both the
@ -284,6 +300,12 @@ export class NoteEntityService implements OnModuleInit {
}
}
if (meId != null) {
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId);
if (isBlocked) return false;
}
return true;
}

View file

@ -69,6 +69,14 @@ type PackedQuote =
fileIds: NonNullable<Packed<'Note'>['fileIds']>
});
type PackedPureRenote = PackedRenote & {
text: NonNullable<Packed<'Note'>['text']>;
cw: NonNullable<Packed<'Note'>['cw']>;
replyId: NonNullable<Packed<'Note'>['replyId']>;
poll: NonNullable<Packed<'Note'>['poll']>;
fileIds: NonNullable<Packed<'Note'>['fileIds']>;
}
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
return note.renoteId != null;
}
@ -80,3 +88,7 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote {
note.poll != null ||
(note.fileIds != null && note.fileIds.length > 0);
}
export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote {
return isRenotePacked(note) && !isQuotePacked(note);
}

View file

@ -14,10 +14,7 @@ export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string {
let str = '';
for (let i = 0; i < length; i++) {
let rand = Math.floor((crypto.randomBytes(1).readUInt8(0) / 0xFF) * chars_len);
if (rand === chars_len) {
rand = chars_len - 1;
}
const rand = crypto.randomInt(0, chars_len);
str += chars.charAt(rand);
}

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { StatusError } from '@/misc/status-error.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -132,7 +133,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
// 何故かeがundefinedで来ることがある
if (!e) return '?';
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError' || e instanceof StatusError) {
return `${e.name}: ${e.message}`;
}
@ -146,12 +147,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
function renderJob(job?: Bull.Job) {
if (!job) return '?';
return {
name: job.name || undefined,
const info: Record<string, string> = {
info: getJobInfo(job),
failedReason: job.failedReason || undefined,
data: job.data,
};
if (job.name) info.name = job.name;
if (job.failedReason) info.failedReason = job.failedReason;
return info;
}
//#region system

View file

@ -7,6 +7,7 @@ import { URL } from 'node:url';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
import * as Bull from 'bullmq';
import { AbortError } from 'node-fetch';
import type Logger from '@/logger.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
@ -192,6 +193,9 @@ export class InboxProcessorService implements OnApplicationShutdown {
if (signerHost !== activityIdHost) {
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
}
} else {
// Activity ID should only be string or undefined.
delete activity.id;
}
// Update stats
@ -215,7 +219,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
try {
const result = await this.apInboxService.performActivity(authUser.user, activity);
if (result && !result.startsWith('ok')) {
this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`);
if (result.startsWith('skip:')) {
this.logger.info(`inbox activity ignored: id=${activity.id} reason=${result}`);
} else {
this.logger.warn(`inbox activity failed: id=${activity.id} reason=${result}`);
}
return result;
}
} catch (e) {
@ -230,6 +238,19 @@ export class InboxProcessorService implements OnApplicationShutdown {
return e.message;
}
}
if (e instanceof StatusError) {
if (e.isRetryable) {
return `temporary error ${e.statusCode}`;
} else {
return `skip: permanent error ${e.statusCode}`;
}
}
if (e instanceof AbortError) {
return 'request aborted';
}
throw e;
}
return 'ok';

View file

@ -152,7 +152,7 @@ export class ActivityPubServerService {
let signature;
try {
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
} catch (e) {
// not signed, or malformed signature: refuse
this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
@ -229,7 +229,7 @@ export class ActivityPubServerService {
let signature;
try {
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'digest', 'host', 'date'], authorizationHeaderName: 'signature' });
} catch (e) {
reply.code(401);
return;

View file

@ -31,6 +31,7 @@ import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.
import { RateLimiterService } from '@/server/api/RateLimiterService.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { AuthenticateService } from '@/server/api/AuthenticateService.js';
import type { IEndpointMeta } from '@/server/api/endpoints.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import type Limiter from 'ratelimiter';
@ -82,7 +83,7 @@ export class FileServerService {
});
fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => {
if (!await this.checkRateLimit(request, reply, `/files/${request.params.key}`)) return;
if (!await this.checkRateLimit(request, reply, '/files/', request.params.key)) return;
return await this.sendDriveFile(request, reply)
.catch(err => this.errorHandler(request, reply, err));
@ -109,7 +110,7 @@ export class FileServerService {
keyUrl.username = '';
keyUrl.password = '';
if (!await this.checkRateLimit(request, reply, `/proxy/${keyUrl}`)) return;
if (!await this.checkRateLimit(request, reply, '/proxy/', keyUrl.href)) return;
return await this.proxyHandler(request, reply)
.catch(err => this.errorHandler(request, reply, err));
@ -603,7 +604,8 @@ export class FileServerService {
Params?: Record<string, unknown> | unknown,
}>,
reply: FastifyReply,
rateLimitKey: string,
group: string,
resource: string,
): Promise<boolean> {
const body = request.method === 'GET'
? request.query
@ -622,32 +624,48 @@ export class FileServerService {
const [user] = await this.authenticateService.authenticate(token);
const actor = user?.id ?? getIpHash(request.ip);
// Call both limits: the per-resource limit and the shared cross-resource limit
return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group);
}
private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string): Promise<boolean> {
const limit = {
// Group by resource
key: rateLimitKey,
key: `${group}${resource}`,
// Maximum of 10 requests / 10 minutes
max: 10,
duration: 1000 * 60 * 10,
// Minimum of 250 ms between each request
minInterval: 250,
};
// Rate limit proxy requests
return await this.checkLimit(reply, actor, limit);
}
private async checkSharedLimit(reply: FastifyReply, actor: string, group: string): Promise<boolean> {
const limit = {
key: group,
// Maximum of 3600 requests per hour, which is an average of 1 per second.
max: 3600,
duration: 1000 * 60 * 60,
};
return await this.checkLimit(reply, actor, limit);
}
private async checkLimit(reply: FastifyReply, actor: string, limit: IEndpointMeta['limit'] & { key: NonNullable<string> }): Promise<boolean> {
try {
await this.rateLimiterService.limit(limit, actor);
return true;
} catch (err) {
// errはLimiter.LimiterInfoであることが期待される
reply.code(429);
if (hasRateLimitInfo(err)) {
const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000);
// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
}
reply.code(429);
reply.send({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',

View file

@ -140,6 +140,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
}
const subject = `acct:${user.username}@${this.config.host}`;
const profileLink = `${this.config.url}/@${user.username}`;
const self = {
rel: 'self',
type: 'application/activity+json',
@ -148,7 +149,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `${this.config.url}/@${user.username}`,
href: profileLink,
};
const subscribe = {
rel: 'http://ostatus.org/schema/1.0/subscribe',
@ -164,12 +165,14 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
{ element: 'Subject', value: subject },
{ element: 'Link', attributes: self },
{ element: 'Link', attributes: profilePage },
{ element: 'Link', attributes: subscribe });
{ element: 'Link', attributes: subscribe },
{ element: 'Alias', value: profileLink });
} else {
reply.type(jrd);
return {
subject,
links: [self, profilePage, subscribe],
aliases: [profileLink],
};
}
});

View file

@ -311,7 +311,15 @@ export class ApiCallService implements OnApplicationShutdown {
throw new ApiError(accessDenied);
}
if (ep.meta.limit) {
// For endpoints without a limit, the default is 10 calls per second
const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? {
duration: 1000,
max: 10,
};
// We don't need this check, but removing it would cause a big merge conflict.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (endpointLimit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
if (user) {
@ -320,7 +328,7 @@ export class ApiCallService implements OnApplicationShutdown {
limitActor = getIpHash(request.ip);
}
const limit = Object.assign({}, ep.meta.limit);
const limit = Object.assign({}, endpointLimit);
if (limit.key == null) {
(limit as any).key = ep.name;

View file

@ -25,6 +25,12 @@ export const meta = {
ref: 'Announcement',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -27,6 +27,12 @@ export const meta = {
id: 'b57b5e1d-4f49-404a-9edb-46b00268f121',
},
},
// 5 calls per second
limit: {
duration: 1000,
max: 5,
},
} as const;
export const paramDef = {

View file

@ -47,6 +47,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'Antenna',
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -24,6 +24,12 @@ export const meta = {
id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -25,6 +25,12 @@ export const meta = {
ref: 'Antenna',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -41,6 +41,12 @@ export const meta = {
ref: 'Note',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -30,6 +30,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'Antenna',
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -45,6 +45,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'Antenna',
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
export const meta = {
tags: ['federation'],
requireAdmin: true,
requireCredential: true,
kind: 'read:federation',

View file

@ -140,7 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.mergePack(
me,
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
);
}

View file

@ -22,6 +22,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'App',
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'App',
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
id: '9c72d8de-391a-43c1-9d06-08d29efde8df',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -40,6 +40,12 @@ export const meta = {
id: '92f93e63-428e-4f2f-a5a4-39e1407fe998',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -43,6 +43,12 @@ export const meta = {
},
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -51,6 +51,12 @@ export const meta = {
id: '8c8a4145-02cc-4cca-8e66-29ba60445a8e',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
ref: 'Blocking',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -40,6 +40,12 @@ export const meta = {
},
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
id: '4938f5f3-6167-4c04-9149-6607b7542861',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -23,6 +23,12 @@ export const meta = {
ref: 'Channel',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
ref: 'Channel',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -25,6 +25,12 @@ export const meta = {
ref: 'Channel',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
ref: 'Channel',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
ref: 'Channel',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -28,6 +28,12 @@ export const meta = {
id: '6f6c314b-7486-4897-8966-c04a66a02923',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -38,6 +38,12 @@ export const meta = {
id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -25,6 +25,12 @@ export const meta = {
id: '353c68dd-131a-476c-aa99-88a345e83668',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -26,6 +26,12 @@ export const meta = {
id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -10,6 +10,7 @@ import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
import ms from 'ms';
export const meta = {
tags: ['channels'],
@ -43,6 +44,11 @@ export const meta = {
id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b',
},
},
limit: {
duration: ms('1hour'),
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -16,6 +16,12 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -18,9 +18,10 @@ export const meta = {
kind: 'write:account',
// 60 calls per hour
limit: {
duration: ms('1hour'),
max: 20,
max: 60,
},
errors: {

View file

@ -32,6 +32,12 @@ export const meta = {
id: '920f7c2d-6208-4b76-8082-e632020f5883',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -22,6 +22,12 @@ export const meta = {
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -32,6 +32,12 @@ export const meta = {
id: '92658936-c625-4273-8326-2d790129256e',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -25,6 +25,12 @@ export const meta = {
ref: 'Clip',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -25,6 +25,12 @@ export const meta = {
ref: 'Clip',
},
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

View file

@ -35,6 +35,12 @@ export const meta = {
ref: 'Note',
},
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {

View file

@ -30,6 +30,12 @@ export const meta = {
id: 'aff017de-190e-434b-893e-33a9ff5049d8',
},
},
// 2 calls per second
limit: {
duration: 1000,
max: 2,
},
} as const;
export const paramDef = {

View file

@ -30,6 +30,12 @@ export const meta = {
optional: false, nullable: false,
ref: 'Clip',
},
// 3 calls per second
limit: {
duration: 1000,
max: 3,
},
} as const;
export const paramDef = {

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