Compare commits

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

418 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
Julia
27339e03c2 merge: Bump version (!756)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/756
2024-11-20 05:22:39 +00:00
Julia Johannesen
680c2a0718
Bump version 2024-11-20 00:09:56 -05:00
Julia
f258888408 merge: Prevent DoS from spammed media proxy requests (!754)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/754

Approved-by: Julia <julia@insertdomain.name>
2024-11-20 04:59:00 +00:00
Hazelnoot
d150e92f41 prevent DoS from spammed media proxy requests 2024-11-19 23:31:59 -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
Julia
680e3ac7a3 merge: release 2024.9.1 (!733)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/733

Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
2024-11-05 03:59:23 +00: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
dakkar
002d0def42 comment out sharkey-specific crowdin link
we don't have it set up yet ☹
2024-11-04 20:54:48 +00:00
dakkar
a769423c15 bump version number for release 2024-11-04 18:50:26 +00:00
Kio!
8477909af2 Update report-abuse.ts 2024-11-03 19:50:25 +00:00
Julia
e783359aca merge: Revert "Experimental: dont mark backfetched notes as silent" (!703)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/703

Approved-by: Julia <julia@insertdomain.name>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-03 19:39:00 +00:00
dakkar
fa03c4cebe merge: Respect user privacy settings in federation endpoints (resolves #712) (!652)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/652

Closes #712

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-02 22:02:54 +00:00
Hazelnoot
ddf572c22f fix lint errors in FollowingEntityService.ts 2024-11-02 17:43:11 -04:00
Hazelnoot
872f987845 hide instance following / followers tabs from logged-out users 2024-11-02 17:39:16 -04:00
Hazel K
37fd454f70 factor out shared code 2024-11-02 17:39:16 -04:00
Hazel K
2e6726c81f update autogen types 2024-11-02 17:39:16 -04:00
Hazel K
3a72bf453a respect following privacy settings 2024-11-02 17:39:16 -04:00
Hazel K
65d81a4ae2 Revert "fix incorrect populated object in followers endpoint"
This reverts commit 7b9473bf4c0b55facede0e1d1e33297d14184110.
2024-11-02 17:39:16 -04:00
Hazel K
8f0df1f01c check for blocks in following / followers endpoints 2024-11-02 17:39:16 -04:00
Hazel K
c566fa1f36 require auth for followers & following endpoints 2024-11-02 17:39:16 -04: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
dakkar
1906dbe1dc merge: Fix frontend TS configs (!725)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/725

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-02 18:00:57 +00:00
Hazelnoot
b97db55a94 fix eslint in frontend / frontend-embed 2024-11-02 13:00:49 -04:00
dakkar
56023140cb merge: add FriendlyCaptcha as a captcha solution (!723)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/723

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-02 15:46:25 +00:00
Hazelnoot
4ad816e0df fix frontend-embed tsconfig includes 2024-11-02 11:43:24 -04:00
Hazelnoot
5e054d0218 fix frontend tsconfig includes 2024-11-02 11:40:40 -04:00
Marie
b8b077cbad chore: replace recaptcha with frc 2024-11-02 11:02:13 +00:00
Marie
d786e96c2b
upd: add FriendlyCaptcha as a captcha solution
FriendlyCaptcha is a german captcha solution which is GDPR compliant and has a non-commerical free license
2024-11-02 02:20:35 +01:00
Hazelnoot
8824422cb5 merge: Add a clear filter option to the search widget if set (!722)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/722

Closes #786

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-11-01 18:01:19 +00:00
Hazelnoot
bcc845cdb1 merge: Allow admins to create users (resolves #764) (!719)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/719

Closes #764

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-11-01 18:00:33 +00:00
Marie
c8357a410b upd: append ✔ on set filter 2024-11-01 17:45:04 +00:00
dakkar
8b16b0fce9 merge: Hide Following Feed from guest users/logged out users and also don't show the button for migrated accounts (!721)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/721

Closes #787

Approved-by: Amber Null <puppygirlhornyposting@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-11-01 17:33:39 +00:00
Hazelnoot
4da262d98c merge: fix inconsistent following feed filters on mobile (resolves #776) (!717)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/717

Closes #776

Approved-by: Amber Null <puppygirlhornyposting@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-11-01 15:40:43 +00:00
Hazelnoot
ade801ec58 check token permissions in admin/accounts/create.ts 2024-11-01 10:12:28 -04:00
Hazelnoot
37ff2bb0ca always approve the first / root user 2024-11-01 09:29:40 -04:00
Hazelnoot
f36a1a5701 allow admins to create approved users 2024-11-01 09:29:40 -04:00
Hazelnoot
173623a24b add missing copyright header to following-feed-utils.ts 2024-11-01 09:28:20 -04:00
Hazelnoot
64e4cf8277 fix inconsistent following feed filters on mobile 2024-11-01 09:27:34 -04:00
Marie
131fab1032 upd: add clear filter option if filter is set 2024-11-01 13:08:28 +00:00
Marie
9daecc27a5 upd: lock following-feed behind loginRequired and check if user has moved instances 2024-11-01 12:50:54 +00:00
Julia
1520bc1715 merge: Split character limits between local and remote notes (resolves #723) (!669)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/669

Closes #723

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Julia <julia@insertdomain.name>
2024-10-29 03:04:25 +00:00
dakkar
276b30bdc0 merge: Collapse user activity, files, and listenbrainz on mobile (resolves #747) (!718)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/718

Closes #747

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-27 12:12:30 +00:00
dakkar
473f100b67 merge: fix inconsistent relation badges between user profile and user info block (resolves #778) (!716)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/716

Closes #778

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-27 12:07:38 +00:00
dakkar
d72c40d157 merge: fix race conditions in check_connect.js (!715)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/715

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-27 12:05:48 +00:00
Hazelnoot
6e5cbedc75 merge: Fix activity verification after key rotation (!691)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/691

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-27 02:51:07 +00:00
Hazelnoot
e87dddcca2 merge: remove stripes from posting preview (!714)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/714

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-27 02:47:59 +00:00
Hazelnoot
a541eaba5e fix test errors 2024-10-26 17:34:42 -04:00
Hazelnoot
d2a4d6d9e0 fix lint errors in home.vue / index.listenbrainz.vue 2024-10-26 12:58:07 -04:00
Hazelnoot
75fc3de405 collapse user activity/files/listenbrainz by default on mobile 2024-10-26 12:51:52 -04:00
Hazelnoot
b034e1db67 fix inconsistent relation badges between user profile and user info block 2024-10-26 11:31:11 -04:00
Hazelnoot
27b502fab5 normalize re-fetch logic between InboxProcessorService and ActivityPubServerService 2024-10-26 10:40:15 -04:00
Hazelnoot
c0a5955e0a log key rotation 2024-10-26 10:40:15 -04:00
Hazelnoot
5eb9a263e2 fix public key re-fetch logic 2024-10-26 10:40:15 -04:00
Hazelnoot
78a75171c2 remove cached public keys after deletion 2024-10-26 10:40:15 -04:00
Hazelnoot
ca1cdc4ea3 fix poll option limit in masto API 2024-10-26 10:38:29 -04:00
Hazelnoot
726013057d show separate counters for text limit and CW limit 2024-10-26 10:38:16 -04:00
Hazelnoot
c5d9bde43f expose CW limit to frontend 2024-10-26 10:37:43 -04:00
Hazelnoot
01e98c75ab add separate limits for CW length 2024-10-26 10:04:23 -04:00
Hazelnoot
10d3d9f382 fix unit tests 2024-10-26 09:49:28 -04:00
Hazel K
a6befca845 clarify comment about MAX_NOTE_TEXT_LENGTH in tests 2024-10-26 09:49:28 -04:00
Hazel K
67185a5d5d fix UUID format 2024-10-26 09:49:28 -04:00
Hazel K
560ee43dcf separate character limits for local and remote notes 2024-10-26 09:49:28 -04:00
Hazelnoot
524ddb9677 fix race conditions in check_connect.js 2024-10-26 08:57:26 -04:00
Hazelnoot
55df1ad10f merge: teach eslint to check translations (!695)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/695

Approved-by: Marie <github@yuugi.dev>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-10-25 15:23:14 +00:00
Hazelnoot
9562a830ed merge: Merge upstream security advisary (!707)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/707

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-10-25 15:22:21 +00:00
Hazelnoot
57ce32d44f merge: fix: return getFromDb directly when fanoutTimeline is not enabled (!709)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-10-25 15:20:06 +00:00
dakkar
991995673d remove stripes from posting preview 2024-10-24 21:40:34 +01:00
dakkar
beff26e6f4 merge: follow-up on !710: send MFM payload for notes that don't contain advanced MFM (!713)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/713

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-24 17:32:00 +00:00
dakkar
d7ffc0be62 MR !710 had missed renderUpNote 2024-10-24 13:47:54 +01:00
dakkar
ca91af7fa9 merge: fix: make sure outgoing remote mentions get resolved correctly if referenced with non-canonical casing (!711)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/711

Closes #646

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-24 08:53:27 +00:00
dakkar
ff220bd372 Merge branch 'develop' into 'fix/user-lookup-capitalization'
# Conflicts:
#   packages/backend/src/core/MfmService.ts
2024-10-24 08:14:53 +00:00
dakkar
4fed355592 merge: fix: send MFM payload for notes that don't contain advanced MFM (!710)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/710

Closes #647

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-24 08:10:46 +00:00
dakkar
c59852f834 merge: fix: make sure mentions of local users get rendered correctly during AP delivery (!712)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/712

Closes #645

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-24 08:10:13 +00:00
Laura Hausmann
aae7fff494
fix: make sure mentions of local users get rendered correctly during AP delivery (resolves #645) 2024-10-24 05:51:30 +02:00
Laura Hausmann
724aff6e4e
fix: make sure outgoing remote mentions get resolved correctly if referenced with non-canonical casing (resolves #646) 2024-10-24 05:45:22 +02:00
Laura Hausmann
d52f4748f2
fix: send MFM payload for notes that don't contain advanced MFM (resolves #647) 2024-10-24 05:40:17 +02:00
Lhc_fl
67f977f4ff
fix: return getfromdb when FanoutTimeline is not enabled 2024-10-23 23:14:46 +08:00
饺子w (Yumechi)
e05420a92d
Merge commit from fork
[ghsa-gq5q-c77c-v236](https://github.com/misskey-dev/misskey/security/advisories/ghsa-gq5q-c77c-v236)

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2024-10-22 22:30:17 +02:00
dakkar
f781c19df1 explicit licence in eslint files 2024-10-22 19:24:19 +01:00
dakkar
60be692a0a merge: fix: should use invite limit cycle to calculate invite/limit (!706)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/706

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-22 14:07:17 +00:00
dakkar
b26b7a9570 re-add warnExternalUrl 2024-10-22 12:05:27 +01:00
dakkar
e40b3ec4c7 fix (sort of) all missing translations 2024-10-22 12:05:25 +01:00
dakkar
2fb688803f don't lint translations in translation test 2024-10-22 12:05:07 +01:00
dakkar
cff59ce2aa use en-US translations for linting 2024-10-22 12:05:07 +01:00
dakkar
6a3dc40c31 lint translations in embeds, too 2024-10-22 12:05:07 +01:00
dakkar
a7a630bfd0 fix operator 2024-10-22 12:02:24 +01:00
dakkar
d466e05eda ignore more weirdness 2024-10-22 12:02:24 +01:00
dakkar
b0bc24f01b lint Vue templates as well
the argument detection doesn't work inside templates when invoked via
the `<I18n>` component, because it's too complicated for me now
2024-10-22 12:02:24 +01:00
dakkar
f11536c927 ignore weirder cases 2024-10-22 12:02:24 +01:00
dakkar
30d53de356 fix argument/parameter checking 2024-10-22 12:02:24 +01:00
dakkar
dba3277200 fix CallExpression detection 2024-10-22 12:02:24 +01:00
dakkar
82674d8718 lint all uses of translations 2024-10-22 12:02:23 +01:00
Lhc_fl
6aaeda13b9
fix: should use invite limit cycle to calculate invite/limit 2024-10-22 18:48:24 +08:00
Hazelnoot
42e2a58642 merge: Add followers tab to following feed + fix duplication (resolves #729) (!705)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/705

Closes #729

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-22 09:19:34 +00:00
Hazelnoot
bc45ff2103 add warning about incomplete remote data on following feed 2024-10-21 17:57:54 -04:00
Hazelnoot
04654b2f84 add "followers" tab to following feed 2024-10-21 17:55:06 -04:00
Hazelnoot
053b47d78a return error when calling following feed with both includeReplies and filesOnly 2024-10-21 17:55:06 -04:00
Hazelnoot
6430a191f7 fix duplicate users in the following feed 2024-10-21 17:55:06 -04:00
dakkar
24ecef80e7 merge: Re-add missing JP i18n string for allowClickingNotifications (!702)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/702

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-21 20:14:41 +00:00
dakkar
063c597ca6 merge: re-add "copy link (origin)" that was dropped accidentally (!704)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/704

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-21 16:04:38 +00:00
dakkar
d7a3ec9c5e re-add "copy link (origin)" that was dropped accidentally
there are 2 nearly identical blocks of code that generate the menu for
logged-in users and for not-logged-in… I had missed the logged-in one
2024-10-21 15:14:56 +01:00
dakkar
dfee4108f9 Revert "Experimental: dont mark backfetched notes as silent"
This reverts commit 1410f8d501.
2024-10-21 14:10:54 +01:00
CenTdemeern1
577e66e2ce Update index.d.ts 2024-10-21 14:26:17 +02:00
CenTdemeern1
1811933025 Re-add missing JP i18n string for allowClickingNotifications 2024-10-21 14:19:27 +02:00
dakkar
d103b76ab0 merge: chore: fix type errors in useNoteCapture (!701)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/701

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-21 08:04:34 +00:00
Lhc_fl
5e4ed13213
revert import sort 2024-10-21 09:03:14 +08:00
dakkar
649b525ab2 merge: parity with misskey locales (again) (!698)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/698

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-20 19:35:41 +00:00
Lhc_fl
684be7d709
fix: fix type error of useNoteCapture 2024-10-21 01:38:03 +08:00
Lhc_fl
54addd0390
fix: fix type error of useNoteCapture 2024-10-21 01:35:40 +08:00
dakkar
4ccc0c4b1e merge: Optimizations to InternalStorageService (resolves #753) (!694)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/694

Closes #753

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-20 15:48:46 +00:00
Hazelnoot
90d8050df4 merge: Fix "Mark as Sensitive by default" locking files into a Sensitive state (!700)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/700

Closes #760

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-10-20 12:17:17 +00:00
Hazelnoot
5482aac3aa merge: upd: remove file on cleaner, fix load more not working (!699)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/699

Closes #762

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
2024-10-20 12:14:40 +00:00
Marie
65ac5fef46
fix: default sensitive not letting users unmark files 2024-10-20 11:04:48 +02:00
Marie
5e49246c1e
upd: apply suggestion 2024-10-20 08:42:55 +02:00
Hazelnoot
fcd2c93a19 ensure that "thumbnail stored" / "web stored" messages only appear after success 2024-10-20 00:13:07 -04:00
Hazelnoot
7aee3c1617 fix comment typo in InternalStorageService.ts 2024-10-20 00:11:14 -04:00
Hazelnoot
b1d9314d6e pre-create the files directory to reduce IO operations 2024-10-20 00:10:11 -04:00
Hazelnoot
2deb64486b use async IO for InternalStorageService 2024-10-20 00:10:10 -04:00
Marie
8bf7495c92
upd: remove file on cleaner, fix load more not working 2024-10-20 01:58:35 +02:00
piuvas
61e33cb7e3
bring in portuguese locales just because 2024-10-19 08:38:35 -03:00
piuvas
f43aec7c88
merge misskey's locales 2024-10-19 08:29:35 -03:00
piuvas
aa19418037
move new strings to sharkey-locales 2024-10-19 08:28:55 -03:00
Luna Nova
3b89b73d27
fix: move cypress to optionalDependencies 2024-10-18 23:20:47 -04:00
dakkar
ba17776b19 merge: version 2024.9.0 (!675)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/675

Approved-by: Julia <julia@insertdomain.name>
Approved-by: Marie <github@yuugi.dev>
2024-10-18 21:14:49 +00:00
dakkar
2a4c91efcc Merge branch 'develop' into feature/2024.9.0 2024-10-18 22:09:11 +01:00
Marie
290bfd2075 merge: Allow logged in users to refresh polls (!686)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/686

Closes #743

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-18 21:03:07 +00:00
dakkar
52e291af67 Merge branch 'develop' into feature/2024.9.0 2024-10-18 22:00:07 +01:00
Marie
d4ef030fd9 merge: Free up Usernames after deny/decline (!696)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/696

Closes #752 and #759

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-18 18:13:43 +00:00
Marie
ed064b2193
upd: remove type username to confirm dialog
resolves # 759
2024-10-18 01:09:46 +02:00
Marie
fea7889e0c
upd: add recommended checks 2024-10-17 21:56:43 +02:00
Marie
5152192e09
fix: add missing type 2024-10-17 20:22:16 +02:00
Marie
42530b5a39
upd: add additional check from delete endpoint 2024-10-17 20:15:20 +02:00
Marie
360a127ad7
chore: indent 2024-10-17 20:14:25 +02:00
Marie
1d9cb4fad9
upd: add decline endpoint and free up username on decline 2024-10-17 20:11:10 +02:00
dakkar
786677b079 merge: Feat: Implement clickable notifications (!685)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/685

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: dakkar <dakkar@thenautilus.net>
2024-10-17 15:56:05 +00:00
Hazelnoot
31039821a1 add scripts to run eslint on all packages 2024-10-16 10:30:57 -04:00
Hazelnoot
fbe6b31878 fix eslint errors in all projects due to over-including files 2024-10-16 10:30:38 -04:00
Hazelnoot
6c30c94b92 fix eslint errors in frontend / frontend-embed 2024-10-16 09:55:29 -04:00
Hazelnoot
2c8af72168 fix formatting in boot.js 2024-10-16 09:15:03 -04:00
Hazelnoot
0c2e113e8e update fast-xml-parser to patch security issue (DoS) 2024-10-15 22:03:42 -04:00
Hazelnoot
af3bb7346e update axios and ws to patch security issue (unexploitable in our case) 2024-10-15 22:00:39 -04:00
Hazelnoot
1f53df66d4 update katex to patch security issue (DoS + 2 more unexploitable) 2024-10-15 21:51:11 -04:00
Hazelnoot
5f3cb09eb1 fix lint errors in SkFormula.vue 2024-10-15 21:50:05 -04:00
Hazelnoot
2bd87fa481 add "lint-all" script to lazy-fail and print all errors at once 2024-10-15 21:42:25 -04:00
Hazelnoot
1a9f2f84b3 fix linting and type checks in all packages 2024-10-15 21:41:36 -04:00
Hazelnoot
b20e671452 disable linting in megalodon, as it's 3rd party code 2024-10-15 21:41:23 -04:00
Hazelnoot
45ac7e50bc fix locales versioning in service worker 2024-10-15 21:40:20 -04:00
Hazelnoot
b5a1c54d65 fix lint errors in backend unit tests 2024-10-15 21:40:20 -04:00
Hazelnoot
7431866d86 fix locales versioning in backend client 2024-10-15 21:40:20 -04:00
Hazelnoot
9b06347882 fix TS errors in NoteCreateService / NoteEditService 2024-10-15 21:31:34 -04:00
Hazelnoot
de61781c4a fix TS errors in vite config files 2024-10-15 21:23:03 -04:00
Hazelnoot
7e220d6e31 fix lint error in const.ts 2024-10-15 21:22:46 -04:00
Hazelnoot
c5f1279d4b fix lint / TS errors in create-notification.ts 2024-10-15 21:22:23 -04:00
Marie
4128b38724 chore: update misskey-js 2024-10-15 18:21:09 -04:00
Marie
dedb24fe74 chore: change permission kind 2024-10-15 18:21:09 -04:00
Marie
6b56163931 upd: change condition 2024-10-15 18:21:08 -04:00
Marie
61cb46b171 upd: hide refresh if logged out and if local, change blocked error message 2024-10-15 18:21:08 -04:00
Marie
00bb958874 chore: change string to locale 2024-10-15 18:21:08 -04:00
Marie
dd58a4aa92 upd: add ability to refresh poll 2024-10-15 18:21:08 -04:00
Hazelnoot
8a34d8e9d2 Merge branch 'develop' into feature/2024.9.0
# Conflicts:
#	locales/en-US.yml
#	locales/ja-JP.yml
#	packages/backend/src/core/NoteCreateService.ts
#	packages/backend/src/core/NoteDeleteService.ts
#	packages/backend/src/core/NoteEditService.ts
#	packages/frontend-shared/js/config.ts
#	packages/frontend/src/boot/common.ts
#	packages/frontend/src/pages/following-feed.vue
#	packages/misskey-js/src/autogen/endpoint.ts
2024-10-15 18:09:11 -04:00
Hazelnoot
68b90df00b merge: Refresh locales after any change, not just a version update (resolves #732) (!692)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/692

Closes #732

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-15 21:50:56 +00:00
Marie
7647aa637a merge: Improvements and tweaks to latest note handling (resolves #744) (!688)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/688

Closes #744

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-15 21:50:32 +00:00
Hazelnoot
de9b99c937 merge: Add filter options to following feed (resolves #726) (!671)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/671

Closes #726

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-15 21:50:26 +00:00
Hazelnoot
16847ba491 merge: Restore report forwarding to Pleroma (resolves #641) (!690)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/690

Closes #641

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
2024-10-15 21:24:57 +00:00
Hazelnoot
e781be3c72 add unit tests for SkLatestNote.areEquivalent 2024-10-15 14:17:19 -04:00
Hazelnoot
e19193c9d0 unify logic of SkLatestNote.areEquivalent and SkLatestNote.keyFor 2024-10-15 14:17:19 -04:00
Hazelnoot
5dc700938d update latest_note for edits 2024-10-15 14:17:19 -04:00
Hazelnoot
93cf2f9045 factor out latest_note logic into LatestNoteService 2024-10-15 14:17:19 -04:00
Hazelnoot
c55af9c3b3 update latest note in background (don't await the result) 2024-10-15 14:17:19 -04:00
Hazelnoot
b18d7c0f3f move upgrade notes to separate file 2024-10-15 14:16:46 -04:00
Hazelnoot
fa687ecb33 fix is-renote tests 2024-10-15 14:16:46 -04:00
Hazelnoot
d3792ab201 fix test failures 2024-10-15 14:16:46 -04:00
Hazelnoot
9b1bae653d add "show bots" toggle to following feed 2024-10-15 14:16:46 -04:00
Hazelnoot
24fd35e03d revert accidental change to postgres.ts 2024-10-15 14:16:46 -04:00
Hazelnoot
e3c79b0c83 fix typos in track-latest-note-type migration 2024-10-15 14:16:46 -04:00
Hazel K
158cd3649d docs: add post-upgrade query to backfill following feed 2024-10-15 14:16:46 -04:00
Hazel K
fb7ac68ece match following endpoint default values with frontend defaults 2024-10-15 14:16:46 -04:00
Hazel K
499e8895c5 save filters for following feed 2024-10-15 14:16:46 -04:00
Hazel K
463b9ac59d add filters for following feed 2024-10-15 14:16:46 -04:00
Hazel K
56e7d7e0b1 remove un-necessary assignment to query 2024-10-15 14:16:46 -04:00
Hazel K
9d3292e6e9 add type columns to SkLatestNote 2024-10-15 14:16:46 -04:00
Hazel K
fea993f6b2 correct name of SkLatestNote 2024-10-15 14:16:46 -04:00
Hazelnoot
86a693b182 factor out tuple logic into from-tuple.ts 2024-10-15 14:03:57 -04:00
Hazelnoot
4e592fb1c9 federate Flag.object as an array to fix Pleroma compatibility 2024-10-15 13:54:28 -04:00
Hazelnoot
5c1d16947c fix import order in common.ts 2024-10-15 12:28:40 -04:00
Hazelnoot
8897b191d9 fix indentation in build-assets.mjs 2024-10-15 12:28:22 -04:00
Hazelnoot
652cc8602c refresh locales after any change, not just a version update 2024-10-15 12:23:18 -04:00
CenTdemeern1
6213018e62 Add Japanese translation 2024-10-15 14:14:59 +02:00
Julia Johannesen
d04e44b552
Fix indentation on locales/generateDTS.js 2024-10-14 15:15:24 -04:00
Hazelnoot
b15f25758a merge: feat: Allow users to view pending follow requests they sent (!663)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-14 15:52:02 +00:00
Hazelnoot
2cd41228d8 Merge branch 'develop' into feature/2024.9.0 2024-10-13 11:35:10 -04:00
Hazelnoot
bd2cdd9363 restore missing hasPendingReceivedFollowRequest in navbar 2024-10-13 01:09:49 -04:00
Lhc_fl
edce54ad0f add new i18n pendingFollowRequests 2024-10-13 01:04:41 -04:00
Lhc_fl
19a1f3111b fix default followreq tab 2024-10-13 01:04:41 -04:00
Lhc_fl
0c03f9ead0 fix default followreq tab 2024-10-13 01:04:40 -04:00
Lhc_fl
0e6ba9ccd4 ux: should not show follow requests tab when have no pending sent follow req 2024-10-13 01:04:40 -04:00
Lhc_fl
1eacf0772c FEAT: Allow users to view pending follow requests they sent
This commit implements the `following/requests/sent` interface firstly
implemented on Firefish, and provides a UI interface to view the pending
follow requests users sent.
2024-10-13 01:04:40 -04:00
Marie
45974a53f8 merge: try to avoid insert races in FederatedInstanceService (!683)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/683

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-13 00:17:16 +00:00
Marie
75ed3843fa merge: CONTRIBUTING.md: fix command to generate new migration (resolves #727) (!673)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/673

Closes #727

Approved-by: Marie <github@yuugi.dev>
Approved-by: Tess K <me@thvxl.se>
2024-10-13 00:13:49 +00:00
Marie
fdfb0faab0 merge: Add missing rejectReports field to show-instance API response (!679)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/679

Approved-by: Marie <github@yuugi.dev>
Approved-by: Tess K <me@thvxl.se>
2024-10-13 00:13:26 +00:00
CenTdemeern1
808963189e Autogen seems to have generated this file
This doesn't seem related so I'm putting it in its own commit so it can be dropped easily if needed
2024-10-13 01:03:29 +02:00
CenTdemeern1
5085c39440 Implement clickable notifications
I'm not sure about the get function pattern here as it doesn't include a value to base reactivity on; I might be too used to Svelte though and maybe that's fine
2024-10-13 01:03:29 +02:00
Hazelnoot
3093707469 merge: Prevent usernames from overflowing the reactions hover-list (resolves #737) (!682)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/682

Closes #737

Approved-by: Marie <github@yuugi.dev>
Approved-by: Tess K <me@thvxl.se>
2024-10-12 21:46:04 +00:00
dakkar
a44d58781f Merge branch 'develop' into feature/2024.9.0 2024-10-12 20:00:40 +01:00
Hazelnoot
669e5c6ca0 merge: ignore stale users/show responses - fixes #741 (!684)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/684

Closes #741

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
2024-10-12 17:57:39 +00:00
dakkar
bf6ca8efdc also ignore stale *failed* responses - #741
since `misskeyApi` doesn't pass us the request, we close over a copy
of the username we requested, to make sure it still matches the
current username value
2024-10-12 18:23:20 +01:00
dakkar
5af38db74b ignore stale users/show responses - fixes #741
* person starts typing "abcd"
* request for user "abcd" is sent to server
* person continues typing "ef"
* request for user "abcdef" is sent to server
* response for user "abcdef" arrives, icon for user "abcdef" is shown
* response for user "abcd" arrives, icon for user "abcd" was shown –
  with this commit, this response is ignored instead

This is not just "showing the wrong avatar", btw: `MkSignin` uses the
`user` variable to decide whether to use 2FA, so having the wrong data
in user can make a login attempt fail.
2024-10-12 18:16:06 +01:00
dakkar
06bd29f209 try to avoid insert races in FederatedInstanceService
Despite the cache, different processes can race to insert a row for
the same remote host.

This is particularly apparent in our unit tests, with
`test/unit/activitypub.ts` randomly failing.

This is a (somewhat clumsy) attempt at working around that race: trap
the "duplicate key value" error, and fetch the record.

Tests pass, with or without values in the cache.
2024-10-12 10:57:57 +01:00
Hazelnoot
a1375c8ab7 prevent usernames from overflowing the reactions hover-list 2024-10-12 00:29:40 -04:00
Hazelnoot
a39f5c92b4 add missing rejectReports field to show-instance response 2024-10-11 10:21:32 -04:00
Hazel K
830565787a docs: fix command to generate new migration 2024-10-09 23:41:26 -04:00
497 changed files with 8726 additions and 1597 deletions

View file

@ -167,8 +167,18 @@ id: 'aidx'
# IP address family used for outgoing request (ipv4, ipv6 or dual) # IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4 #outgoingAddressFamily: ipv4
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) # Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
maxNoteLength: 3000 #maxNoteLength: 3000
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
#maxRemoteNoteLength: 100000
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
#maxCwLength: 500
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
#maxRemoteCwLength: 5000
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Proxy for HTTP/HTTPS # Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128 #proxy: http://127.0.0.1:3128
@ -219,3 +229,8 @@ checkActivityPubGetSignature: false
# Upload or download file size limits (bytes) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #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

@ -179,6 +179,19 @@ id: 'aidx'
# IP address family used for outgoing request (ipv4, ipv6 or dual) # IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4 #outgoingAddressFamily: ipv4
# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
#maxNoteLength: 3000
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
#maxRemoteNoteLength: 100000
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
#maxCwLength: 500
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
#maxRemoteCwLength: 5000
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Proxy for HTTP/HTTPS # Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128 #proxy: http://127.0.0.1:3128
@ -209,3 +222,8 @@ allowedPrivateNetworks: [
# Upload or download file size limits (bytes) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #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

@ -250,8 +250,18 @@ id: 'aidx'
# IP address family used for outgoing request (ipv4, ipv6 or dual) # IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4 #outgoingAddressFamily: ipv4
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) # Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
maxNoteLength: 3000 #maxNoteLength: 3000
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
#maxRemoteNoteLength: 100000
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
#maxCwLength: 500
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
#maxRemoteCwLength: 5000
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Proxy for HTTP/HTTPS # Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128 #proxy: http://127.0.0.1:3128
@ -302,3 +312,8 @@ checkActivityPubGetSignature: false
# Upload or download file size limits (bytes) # Upload or download file size limits (bytes)
#maxFileSize: 262144000 #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 port: 5432
# Database name # Database name
db: misskey db: sharkey
# Auth # Auth
user: example-misskey-user user: sharkey
pass: example-misskey-pass pass: example-misskey-pass
# Whether disable Caching queries # Whether disable Caching queries
@ -261,8 +261,18 @@ id: 'aidx'
# IP address family used for outgoing request (ipv4, ipv6 or dual) # IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4 #outgoingAddressFamily: ipv4
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1) # Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
maxNoteLength: 3000 #maxNoteLength: 3000
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
#maxRemoteNoteLength: 100000
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
#maxCwLength: 500
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
#maxRemoteCwLength: 5000
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
#maxAltTextLength: 20000
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
#maxRemoteAltTextLength: 100000
# Proxy for HTTP/HTTPS # Proxy for HTTP/HTTPS
#proxy: http://127.0.0.1:3128 #proxy: http://127.0.0.1:3128
@ -324,3 +334,8 @@ checkActivityPubGetSignature: false
# PID File of master process # PID File of master process
#pidFile: /tmp/misskey.pid #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) 🔒 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) --> 🤝 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) 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 agree to follow this project's Contribution Guidelines
- [ ] I have searched the issue tracker for similar issues, and this is not a duplicate. - [ ] 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) 🔒 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) --> 🤝 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) 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 agree to follow this project's Contribution Guidelines
- [ ] I have searched the issue tracker for similar requests, and this is not a duplicate. - [ ] 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! --> <!-- 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) 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 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 --> <!-- Uncomment if your merge request has multiple authors -->
<!-- Co-authored-by: Name <email@email.com> --> <!-- Co-authored-by: Name <email@email.com> -->

View file

@ -529,7 +529,8 @@ enumの列挙の内容の削除は、その値をもつレコードを全て削
### Migration作成方法 ### Migration作成方法
packages/backendで: packages/backendで:
```sh ```sh
pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name> pnpm run build
pnpm dlx typeorm migration:generate -d ormconfig.js -o migration/<migration name>
``` ```
- 生成後、ファイルをmigration下に移してください - 生成後、ファイルをmigration下に移してください

View file

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

74
UPGRADE_NOTES.md Normal file
View file

@ -0,0 +1,74 @@
# 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
When upgrading an existing instance to version 2024.9.0, the Following Feed will initially be empty.
The feed will gradually fill as new posts federate, but it may be desirable to back-fill the feed with existing data.
This database script will populate the feed with the latest post of each type for all users, ensuring that data is fully populated after the update.
Run this after migrations but before starting the instance.
Warning: the script may take a long time to execute!
```postgresql
INSERT INTO latest_note (user_id, note_id, is_public, is_reply, is_quote)
SELECT
"userId" as user_id,
id as note_id,
visibility = 'public' AS is_public,
"replyId" IS NOT NULL AS is_reply,
(
"renoteId" IS NOT NULL
AND (
text IS NOT NULL
OR cw IS NOT NULL
OR "replyId" IS NOT NULL
OR "hasPoll"
OR "fileIds" != '{}'
)
) AS is_quote
FROM note
WHERE ( -- Exclude pure renotes (boosts)
"renoteId" IS NULL
OR text IS NOT NULL
OR cw IS NOT NULL
OR "replyId" IS NOT NULL
OR "hasPoll"
OR "fileIds" != '{}'
)
ORDER BY id DESC -- This part is very important: it ensures that we only load the *latest* notes of each type. Do not remove it!
ON CONFLICT DO NOTHING; -- Any conflicts are guaranteed to be older notes that we can ignore.
```

251
eslint/locale.js Normal file
View file

@ -0,0 +1,251 @@
/*
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* This is a ESLint rule to report use of the `i18n.ts` and `i18n.tsx`
* objects that reference translation items that don't actually exist
* in the lexicon (the `locale/` files)
*/
/* given a MemberExpression node, collects all the member names
*
* e.g. for a bit of code like `foo=one.two.three`, `collectMembers`
* called on the node for `three` would return `['one', 'two',
* 'three']`
*/
function collectMembers(node) {
if (!node) return [];
if (node.type !== 'MemberExpression') return [];
// this is something like `foo[bar]`
if (node.computed) return [];
return [ node.property.name, ...collectMembers(node.parent) ];
}
/* given an object and an array of names, recursively descends the
* object via those names
*
* e.g. `walkDown({one:{two:{three:15}}},['one','two','three'])` would
* return 15
*/
function walkDown(locale, path) {
if (!locale) return null;
if (!path || path.length === 0 || !path[0]) return locale;
return walkDown(locale[path[0]], path.slice(1));
}
/* given a MemberExpression node, returns its attached CallExpression
* node if present
*
* e.g. for a bit of code like `foo=one.two.three()`,
* `findCallExpression` called on the node for `three` would return
* the node for function call (which is the parent of the `one` and
* `two` nodes, and holds the nodes for the argument list)
*
* if the code had been `foo=one.two.three`, `findCallExpression`
* would have returned null, because there's no function call attached
* to the MemberExpressions
*/
function findCallExpression(node) {
if (!node.parent) return null;
// the second half of this guard protects from cases like
// `foo(one.two.three)` where the CallExpression is parent of the
// MemberExpressions, but via `arguments`, not `callee`
if (node.parent.type === 'CallExpression' && node.parent.callee === node) return node.parent;
if (node.parent.type === 'MemberExpression') return findCallExpression(node.parent);
return null;
}
// same, but for Vue expressions (`<I18n :src="i18n.ts.foo">`)
function findVueExpression(node) {
if (!node.parent) return null;
if (node.parent.type.match(/^VExpr/) && node.parent.expression === node) return node.parent;
if (node.parent.type === 'MemberExpression') return findVueExpression(node.parent);
return null;
}
function areArgumentsOneObject(node) {
return node.arguments.length === 1 &&
node.arguments[0].type === 'ObjectExpression';
}
// only call if `areArgumentsOneObject(node)` is true
function getArgumentObjectProperties(node) {
return new Set(node.arguments[0].properties.map(
p => {
if (p.key && p.key.type === 'Identifier') return p.key.name;
return null;
},
));
}
function getTranslationParameters(translation) {
return new Set(Array.from(translation.matchAll(/\{(\w+)\}/g)).map( m => m[1] ));
}
function setDifference(a,b) {
const result = [];
for (const element of a.values()) {
if (!b.has(element)) {
result.push(element);
}
}
return result;
}
/* the actual rule body
*/
function theRuleBody(context,node) {
// we get the locale/translations via the options; it's the data
// that goes into a specific language's JSON file, see
// `scripts/build-assets.mjs`
const locale = context.options[0];
// sometimes we get MemberExpression nodes that have a
// *descendent* with the right identifier: skip them, we'll get
// the right ones as well
if (node.object?.name !== 'i18n') {
return;
}
// `method` is going to be `'ts'` or `'tsx'`, `path` is going to
// be the various translation steps/names
const [ method, ...path ] = collectMembers(node);
const pathStr = `i18n.${method}.${path.join('.')}`;
// does that path point to a real translation?
const translation = walkDown(locale, path);
if (!translation) {
context.report({
node,
message: `translation missing for ${pathStr}`,
});
return;
}
// we hit something weird, assume the programmers know what
// they're doing (this is usually some complicated slicing of
// the translation structure)
if (typeof(translation) !== 'string') return;
const callExpression = findCallExpression(node);
const vueExpression = findVueExpression(node);
// some more checks on how the translation is called
if (method === 'ts') {
// the `<I18n> component gets parametric translations via
// `i18n.ts.*`, but we error out elsewhere
if (translation.match(/\{/) && !vueExpression) {
context.report({
node,
message: `translation for ${pathStr} is parametric, but called via 'ts'`,
});
return;
}
if (callExpression) {
context.report({
node,
message: `translation for ${pathStr} is not parametric, but is called as a function`,
});
}
}
if (method === 'tsx') {
if (!translation.match(/\{/)) {
context.report({
node,
message: `translation for ${pathStr} is not parametric, but called via 'tsx'`,
});
return;
}
if (!callExpression && !vueExpression) {
context.report({
node,
message: `translation for ${pathStr} is parametric, but not called as a function`,
});
return;
}
// we're not currently checking arguments when used via the
// `<I18n>` component, because it's too complicated (also, it
// would have to be done inside the `if (method === 'ts')`)
if (!callExpression) return;
if (!areArgumentsOneObject(callExpression)) {
context.report({
node,
message: `translation for ${pathStr} should be called with a single object as argument`,
});
return;
}
const translationParameters = getTranslationParameters(translation);
const parameterCount = translationParameters.size;
const callArguments = getArgumentObjectProperties(callExpression);
const argumentCount = callArguments.size;
if (parameterCount !== argumentCount) {
context.report({
node,
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
});
}
// node 20 doesn't have `Set.difference`...
const extraArguments = setDifference(callArguments, translationParameters);
const missingArguments = setDifference(translationParameters, callArguments);
if (extraArguments.length > 0) {
context.report({
node,
message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`,
});
}
if (missingArguments.length > 0) {
context.report({
node,
message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`,
});
}
}
}
function theRule(context) {
// we get the locale/translations via the options; it's the data
// that goes into a specific language's JSON file, see
// `scripts/build-assets.mjs`
const locale = context.options[0];
// for all object member access that have an identifier 'i18n'...
return context.getSourceCode().parserServices.defineTemplateBodyVisitor(
{
// this is for <template> bits, needs work
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
},
{
// this is for normal code
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
},
);
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'assert that all translations used are present in the locale files',
},
schema: [
// here we declare that we need the locale/translation as a
// generic object
{ type: 'object', additionalProperties: true },
],
},
create: theRule,
};

54
eslint/locale.test.js Normal file
View file

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
const {RuleTester} = require("eslint");
const localeRule = require("./locale");
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
const ruleTester = new RuleTester({
languageOptions: {
parser: require('vue-eslint-parser'),
ecmaVersion: 2015,
},
});
function testCase(code,errors) {
return { code, errors, options: [ locale ], filename: 'test.ts' };
}
function testCaseVue(code,errors) {
return { code, errors, options: [ locale ], filename: 'test.vue' };
}
ruleTester.run(
'sharkey-locale',
localeRule,
{
valid: [
testCase('i18n.ts.foo.bar'),
testCase('i18n.ts.top'),
testCase('i18n.tsx.foo.baz({x:1})'),
testCase('whatever.i18n.ts.blah.blah'),
testCase('whatever.i18n.tsx.does.not.matter'),
testCase('whatever(i18n.ts.foo.bar)'),
testCaseVue('<template><p>{{ i18n.ts.foo.bar }}</p></template>'),
testCaseVue('<template><I18n :src="i18n.ts.foo.baz"/></template>'),
// we don't detect the problem here, but should still accept it
testCase('i18n.ts.foo["something"]'),
testCase('i18n.ts.foo[something]'),
],
invalid: [
testCase('i18n.ts.not', 1),
testCase('i18n.tsx.deep.not', 1),
testCase('i18n.tsx.deep.not({x:12})', 1),
testCase('i18n.tsx.top({x:1})', 1),
testCase('i18n.ts.foo.baz', 1),
testCase('i18n.tsx.foo.baz', 1),
testCase('i18n.tsx.foo.baz({y:2})', 2),
testCaseVue('<template><p>{{ i18n.ts.not }}</p></template>', 1),
testCaseVue('<template><I18n :src="i18n.ts.not"/></template>', 1),
],
},
);

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;
};
})
];
};
};
}

View file

@ -1252,7 +1252,6 @@ _theme:
buttonBg: "خلفية الأزرار" buttonBg: "خلفية الأزرار"
buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)" buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)"
inputBorder: "حواف حقل الإدخال" inputBorder: "حواف حقل الإدخال"
listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)"
driveFolderBg: "خلفية مجلد قرص التخزين" driveFolderBg: "خلفية مجلد قرص التخزين"
messageBg: "خلفية المحادثة" messageBg: "خلفية المحادثة"
_sfx: _sfx:

View file

@ -1017,7 +1017,6 @@ _theme:
buttonBg: "বাটনের পটভূমি" buttonBg: "বাটনের পটভূমি"
buttonHoverBg: "বাটনের পটভূমি (হভার)" buttonHoverBg: "বাটনের পটভূমি (হভার)"
inputBorder: "ইনপুট ফিল্ডের বর্ডার" inputBorder: "ইনপুট ফিল্ডের বর্ডার"
listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)"
driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি"
wallpaperOverlay: "ওয়ালপেপার ওভারলে" wallpaperOverlay: "ওয়ালপেপার ওভারলে"
badge: "ব্যাজ" badge: "ব্যাজ"

View file

@ -453,6 +453,7 @@ totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d'
moderator: "Moderador/a" moderator: "Moderador/a"
moderation: "Moderació" moderation: "Moderació"
moderationNote: "Nota de moderació " moderationNote: "Nota de moderació "
moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors."
addModerationNote: "Afegir una nota de moderació " addModerationNote: "Afegir una nota de moderació "
moderationLogs: "Registre de moderació " moderationLogs: "Registre de moderació "
nUsersMentioned: "{n} usuaris mencionats" nUsersMentioned: "{n} usuaris mencionats"
@ -1284,6 +1285,15 @@ unknownWebAuthnKey: "Passkey desconeguda"
passkeyVerificationFailed: "La verificació a fallat" passkeyVerificationFailed: "La verificació a fallat"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya."
messageToFollower: "Missatge als meus seguidors" messageToFollower: "Missatge als meus seguidors"
target: "Assumpte "
testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
_abuseUserReport:
forward: "Reenviar "
forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
resolve: "Solució "
accept: "Acceptar "
reject: "Rebutjar"
resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament."
_delivery: _delivery:
status: "Estat d'entrega " status: "Estat d'entrega "
stop: "Suspés" stop: "Suspés"
@ -1421,6 +1431,7 @@ _serverSettings:
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat." reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
inquiryUrl: "URL de consulta " inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació." inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
_accountMigration: _accountMigration:
moveFrom: "Migrar un altre compte a aquest" moveFrom: "Migrar un altre compte a aquest"
moveFromSub: "Crear un àlies per un altre compte" moveFromSub: "Crear un àlies per un altre compte"
@ -1974,7 +1985,6 @@ _theme:
buttonBg: "Fons botó " buttonBg: "Fons botó "
buttonHoverBg: "Fons botó (en passar-hi per sobre)" buttonHoverBg: "Fons botó (en passar-hi per sobre)"
inputBorder: "Contorn del cap d'introducció " inputBorder: "Contorn del cap d'introducció "
listItemHoverBg: "Fons dels elements d'una llista"
driveFolderBg: "Fons de la carpeta Disc" driveFolderBg: "Fons de la carpeta Disc"
wallpaperOverlay: "Superposició del fons de pantalla " wallpaperOverlay: "Superposició del fons de pantalla "
badge: "Insígnia " badge: "Insígnia "
@ -2520,6 +2530,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "Fitxer marcat com a sensible" markSensitiveDriveFile: "Fitxer marcat com a sensible"
unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer" unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer"
resolveAbuseReport: "Informe resolt" resolveAbuseReport: "Informe resolt"
forwardAbuseReport: "Informe reenviat"
updateAbuseReportNote: "Nota de moderació d'un informe actualitzat"
createInvitation: "Crear codi d'invitació " createInvitation: "Crear codi d'invitació "
createAd: "Anunci creat" createAd: "Anunci creat"
deleteAd: "Anunci esborrat" deleteAd: "Anunci esborrat"

View file

@ -1629,7 +1629,6 @@ _theme:
buttonBg: "Pozadí tlačítka" buttonBg: "Pozadí tlačítka"
buttonHoverBg: "Pozadí tlačítka (Hover)" buttonHoverBg: "Pozadí tlačítka (Hover)"
inputBorder: "Ohraničení vstupního pole" inputBorder: "Ohraničení vstupního pole"
listItemHoverBg: "Pozadí položky seznamu (Hover)"
driveFolderBg: "Pozadí složky disku" driveFolderBg: "Pozadí složky disku"
wallpaperOverlay: "Překrytí tapety" wallpaperOverlay: "Překrytí tapety"
badge: "Odznak" badge: "Odznak"

View file

@ -1784,7 +1784,6 @@ _theme:
buttonBg: "Hintergrund von Schaltflächen" buttonBg: "Hintergrund von Schaltflächen"
buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)"
inputBorder: "Rahmen von Eingabefeldern" inputBorder: "Rahmen von Eingabefeldern"
listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)"
driveFolderBg: "Hintergrund von Drive-Ordnern" driveFolderBg: "Hintergrund von Drive-Ordnern"
wallpaperOverlay: "Hintergrundbild-Overlay" wallpaperOverlay: "Hintergrundbild-Overlay"
badge: "Wappen" badge: "Wappen"

View file

@ -112,7 +112,7 @@ enterEmoji: "Enter an emoji"
renote: "Renote" renote: "Renote"
unrenote: "Remove renote" unrenote: "Remove renote"
renoted: "Renoted." renoted: "Renoted."
renotedToX: "Renote to {name}." renotedToX: "Renoted to {name}."
cantRenote: "This post can't be renoted." cantRenote: "This post can't be renoted."
cantReRenote: "A renote can't be renoted." cantReRenote: "A renote can't be renoted."
quote: "Quote" quote: "Quote"
@ -121,7 +121,6 @@ inChannelQuote: "Channel-only Quote"
renoteToChannel: "Renote to channel" renoteToChannel: "Renote to channel"
renoteToOtherChannel: "Renote to other channel" renoteToOtherChannel: "Renote to other channel"
pinnedNote: "Pinned note" pinnedNote: "Pinned note"
pinnedOnly: "Pinned"
pinned: "Pin to profile" pinned: "Pin to profile"
you: "You" you: "You"
clickToShow: "Click to show" clickToShow: "Click to show"
@ -258,7 +257,6 @@ defaultValueIs: "Default: {value}"
noCustomEmojis: "There are no emoji" noCustomEmojis: "There are no emoji"
noJobs: "There are no jobs" noJobs: "There are no jobs"
federating: "Federating" federating: "Federating"
blockingYou: "Blocking you"
blocked: "Blocked" blocked: "Blocked"
suspended: "Suspended" suspended: "Suspended"
all: "All" all: "All"
@ -456,6 +454,7 @@ totpDescription: "Use an authenticator app to enter one-time passwords"
moderator: "Moderator" moderator: "Moderator"
moderation: "Moderation" moderation: "Moderation"
moderationNote: "Moderation note" moderationNote: "Moderation note"
moderationNoteDescription: "You can fill in notes that will be shared only among moderators."
addModerationNote: "Add moderation note" addModerationNote: "Add moderation note"
moderationLogs: "Moderation logs" moderationLogs: "Moderation logs"
nUsersMentioned: "Mentioned by {n} users" nUsersMentioned: "Mentioned by {n} users"
@ -923,6 +922,7 @@ followersVisibility: "Visibility of followers"
continueThread: "View thread continuation" continueThread: "View thread continuation"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?" deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
incorrectPassword: "Incorrect password." incorrectPassword: "Incorrect password."
incorrectTotp: "The one-time password is incorrect or has expired."
voteConfirm: "Confirm your vote for \"{choice}\"?" voteConfirm: "Confirm your vote for \"{choice}\"?"
hide: "Hide" hide: "Hide"
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile" useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
@ -1270,8 +1270,7 @@ alwaysConfirmFollow: "Always confirm when following"
inquiry: "Contact" inquiry: "Contact"
tryAgain: "Please try again later" tryAgain: "Please try again later"
confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media"
sensitiveMediaRevealConfirm: "This media might be sensitive. Are you sure you want to reveal it?" sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?"
warnExternalUrl: "Show warning when opening external URLs"
createdLists: "Created lists" createdLists: "Created lists"
createdAntennas: "Created antennas" createdAntennas: "Created antennas"
fromX: "From {x}" fromX: "From {x}"
@ -1287,6 +1286,14 @@ unknownWebAuthnKey: "Unknown Passkey"
passkeyVerificationFailed: "Passkey verification has failed." passkeyVerificationFailed: "Passkey verification has failed."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled." passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
messageToFollower: "Message to followers" messageToFollower: "Message to followers"
target: "Target"
_abuseUserReport:
forward: "Forward"
forwardDescription: "Forward the report to a remote server as an anonymous system account."
resolve: "Resolve"
accept: "Accept"
reject: "Reject"
resolveTutorial: "If the report is legitimate in content, select \"Accept\" to mark the case as resolved in the affirmative.\nIf the content of the report is not legitimate, select \"Reject\" to mark the case as resolved in the negative."
_delivery: _delivery:
status: "Delivery status" status: "Delivery status"
stop: "Suspended" stop: "Suspended"
@ -1295,7 +1302,7 @@ _delivery:
none: "Publishing" none: "Publishing"
manuallySuspended: "Manually suspended" manuallySuspended: "Manually suspended"
goneSuspended: "Server is suspended due to server deletion" goneSuspended: "Server is suspended due to server deletion"
autoSuspendedForNotResponding: "Server is suspended due to not responding" autoSuspendedForNotResponding: "Server is suspended due to no responding"
_bubbleGame: _bubbleGame:
howToPlay: "How to play" howToPlay: "How to play"
hold: "Hold" hold: "Hold"
@ -1740,7 +1747,7 @@ _role:
canManageAvatarDecorations: "Manage avatar decorations" canManageAvatarDecorations: "Manage avatar decorations"
driveCapacity: "Drive capacity" driveCapacity: "Drive capacity"
alwaysMarkNsfw: "Always mark files as NSFW" alwaysMarkNsfw: "Always mark files as NSFW"
canUpdateBioMedia: "Allow to edit an icon or a banner image" canUpdateBioMedia: "Can edit an icon or a banner image"
pinMax: "Maximum number of pinned notes" pinMax: "Maximum number of pinned notes"
antennaMax: "Maximum number of antennas" antennaMax: "Maximum number of antennas"
wordMuteMax: "Maximum number of characters allowed in word mutes" wordMuteMax: "Maximum number of characters allowed in word mutes"
@ -1977,7 +1984,6 @@ _theme:
buttonBg: "Button background" buttonBg: "Button background"
buttonHoverBg: "Button background (Hover)" buttonHoverBg: "Button background (Hover)"
inputBorder: "Input field border" inputBorder: "Input field border"
listItemHoverBg: "List item background (Hover)"
driveFolderBg: "Drive folder background" driveFolderBg: "Drive folder background"
wallpaperOverlay: "Wallpaper overlay" wallpaperOverlay: "Wallpaper overlay"
badge: "Badge" badge: "Badge"
@ -2476,22 +2482,22 @@ _webhookSettings:
reaction: "When receiving a reaction" reaction: "When receiving a reaction"
mention: "When being mentioned" mention: "When being mentioned"
_systemEvents: _systemEvents:
abuseReport: "When received a new abuse report" abuseReport: "When received a new report"
abuseReportResolved: "When resolved abuse report" abuseReportResolved: "When resolved report"
userCreated: "When user is created" userCreated: "When user is created"
deleteConfirm: "Are you sure you want to delete the Webhook?" deleteConfirm: "Are you sure you want to delete the Webhook?"
testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data." testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data."
_abuseReport: _abuseReport:
_notificationRecipient: _notificationRecipient:
createRecipient: "Add a recipient for abuse reports" createRecipient: "Add a recipient for reports"
modifyRecipient: "Edit a recipient for abuse reports" modifyRecipient: "Edit a recipient for reports"
recipientType: "Notification type" recipientType: "Notification type"
_recipientType: _recipientType:
mail: "Email" mail: "Email"
webhook: "Webhook" webhook: "Webhook"
_captions: _captions:
mail: "Send the email to moderators' email addresses when you receive abuse." mail: "Send the email to moderators' email addresses when you receive reports."
webhook: "Send a notification to SystemWebhook when you receive or resolve abuse." webhook: "Send a notification to System Webhook when you receive or resolve reports."
keywords: "Keywords" keywords: "Keywords"
notifiedUser: "Users to notify" notifiedUser: "Users to notify"
notifiedWebhook: "Webhook to use" notifiedWebhook: "Webhook to use"
@ -2524,6 +2530,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "File marked as sensitive" markSensitiveDriveFile: "File marked as sensitive"
unmarkSensitiveDriveFile: "File unmarked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive"
resolveAbuseReport: "Report resolved" resolveAbuseReport: "Report resolved"
forwardAbuseReport: "Report forwarded"
updateAbuseReportNote: "Moderation note of a report updated"
createInvitation: "Invite generated" createInvitation: "Invite generated"
createAd: "Ad created" createAd: "Ad created"
deleteAd: "Ad deleted" deleteAd: "Ad deleted"
@ -2531,18 +2539,18 @@ _moderationLogTypes:
createAvatarDecoration: "Avatar decoration created" createAvatarDecoration: "Avatar decoration created"
updateAvatarDecoration: "Avatar decoration updated" updateAvatarDecoration: "Avatar decoration updated"
deleteAvatarDecoration: "Avatar decoration deleted" deleteAvatarDecoration: "Avatar decoration deleted"
unsetUserAvatar: "Unset this user's avatar" unsetUserAvatar: "User avatar unset"
unsetUserBanner: "Unset this user's banner" unsetUserBanner: "User banner unset"
createSystemWebhook: "Create SystemWebhook" createSystemWebhook: "System Webhook created"
updateSystemWebhook: "Update SystemWebhook" updateSystemWebhook: "System Webhook updated"
deleteSystemWebhook: "Delete SystemWebhook" deleteSystemWebhook: "System Webhook deleted"
createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" createAbuseReportNotificationRecipient: "Recipient for reports created"
updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" updateAbuseReportNotificationRecipient: "Recipient for reports updated"
deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" deleteAbuseReportNotificationRecipient: "Recipient for reports deleted"
deleteAccount: "Delete the account" deleteAccount: "Account deleted"
deletePage: "Delete the page" deletePage: "Page deleted"
deleteFlash: "Delete Play" deleteFlash: "Play deleted"
deleteGalleryPost: "Delete the gallery post" deleteGalleryPost: "Gallery post deleted"
_fileViewer: _fileViewer:
title: "File details" title: "File details"
type: "File type" type: "File type"

View file

@ -1915,7 +1915,6 @@ _theme:
buttonBg: "Fondo de botón" buttonBg: "Fondo de botón"
buttonHoverBg: "Fondo de botón (hover)" buttonHoverBg: "Fondo de botón (hover)"
inputBorder: "Borde de los campos de entrada" inputBorder: "Borde de los campos de entrada"
listItemHoverBg: "Fondo de elemento de listas (hover)"
driveFolderBg: "Fondo de capeta del drive" driveFolderBg: "Fondo de capeta del drive"
wallpaperOverlay: "Transparencia del fondo de pantalla" wallpaperOverlay: "Transparencia del fondo de pantalla"
badge: "Medalla" badge: "Medalla"

View file

@ -1701,7 +1701,6 @@ _theme:
buttonBg: "Arrière-plan du bouton" buttonBg: "Arrière-plan du bouton"
buttonHoverBg: "Arrière-plan du bouton (survolé)" buttonHoverBg: "Arrière-plan du bouton (survolé)"
inputBorder: "Cadre de la zone de texte" inputBorder: "Cadre de la zone de texte"
listItemHoverBg: "Arrière-plan d'item de liste (survolé)"
driveFolderBg: "Arrière-plan du dossier de disque" driveFolderBg: "Arrière-plan du dossier de disque"
wallpaperOverlay: "Superposition de fond d'écran" wallpaperOverlay: "Superposition de fond d'écran"
badge: "Badge" badge: "Badge"

View file

@ -57,8 +57,8 @@ function createMembers(record) {
} }
export default function generateDTS() { export default function generateDTS() {
const sharkeyLocale = yaml.load(fs.readFileSync(`${__dirname}/../sharkey-locales/en-US.yml`, 'utf-8')); const sharkeyLocale = yaml.load(fs.readFileSync(`${__dirname}/../sharkey-locales/en-US.yml`, 'utf-8'));
const misskeyLocale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); const misskeyLocale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
const locale = merge(misskeyLocale, sharkeyLocale); const locale = merge(misskeyLocale, sharkeyLocale);
const members = createMembers(locale); const members = createMembers(locale);

View file

@ -1924,7 +1924,6 @@ _theme:
buttonBg: "Latar belakang tombol" buttonBg: "Latar belakang tombol"
buttonHoverBg: "Latar belakang tombol (Mengambang)" buttonHoverBg: "Latar belakang tombol (Mengambang)"
inputBorder: "Batas bidang masukan" inputBorder: "Batas bidang masukan"
listItemHoverBg: "Latar belakang daftar item (Mengambang)"
driveFolderBg: "Latar belakang folder drive" driveFolderBg: "Latar belakang folder drive"
wallpaperOverlay: "Lapisan wallpaper" wallpaperOverlay: "Lapisan wallpaper"
badge: "Lencana" badge: "Lencana"

128
locales/index.d.ts vendored
View file

@ -502,10 +502,6 @@ export interface Locale extends ILocale {
* *
*/ */
"pinnedNote": string; "pinnedNote": string;
/**
* Pinned
*/
"pinnedOnly": string;
/** /**
* *
*/ */
@ -1050,10 +1046,6 @@ export interface Locale extends ILocale {
* *
*/ */
"federating": string; "federating": string;
/**
* Blocking you
*/
"blockingYou": string;
/** /**
* *
*/ */
@ -4375,6 +4367,10 @@ export interface Locale extends ILocale {
* *
*/ */
"enableChartsForFederatedInstances": string; "enableChartsForFederatedInstances": string;
/**
*
*/
"enableStatsForFederatedInstances": string;
/** /**
* *
*/ */
@ -5111,10 +5107,6 @@ export interface Locale extends ILocale {
* This media might be sensitive. Are you sure you want to reveal it? * This media might be sensitive. Are you sure you want to reveal it?
*/ */
"sensitiveMediaRevealConfirm": string; "sensitiveMediaRevealConfirm": string;
/**
* URLを開く際に警告を表示する
*/
"warnExternalUrl": string;
/** /**
* *
*/ */
@ -5179,6 +5171,26 @@ export interface Locale extends ILocale {
* *
*/ */
"target": string; "target": string;
/**
* CAPTCHAのテストを目的とした機能です<strong>使</strong>
*/
"testCaptchaWarning": string;
/**
*
*/
"prohibitedWordsForNameOfUser": string;
/**
*
*/
"prohibitedWordsForNameOfUserDescription": string;
/**
*
*/
"yourNameContainsProhibitedWords": string;
/**
* 使
*/
"yourNameContainsProhibitedWordsDescription": string;
"_abuseUserReport": { "_abuseUserReport": {
/** /**
* *
@ -5341,6 +5353,10 @@ export interface Locale extends ILocale {
* *
*/ */
"silenceDescription": string; "silenceDescription": string;
/**
* New
*/
"new": string;
}; };
"_initialAccountSetting": { "_initialAccountSetting": {
/** /**
@ -5717,6 +5733,10 @@ export interface Locale extends ILocale {
* Specify the URL of a web page that contains a contact form or the instance operators' contact information. * Specify the URL of a web page that contains a contact form or the instance operators' contact information.
*/ */
"inquiryUrlDescription": string; "inquiryUrlDescription": string;
/**
*
*/
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
/** /**
* Logo URL * Logo URL
*/ */
@ -7769,10 +7789,6 @@ export interface Locale extends ILocale {
* *
*/ */
"inputBorder": string; "inputBorder": string;
/**
* ()
*/
"listItemHoverBg": string;
/** /**
* *
*/ */
@ -8430,6 +8446,10 @@ export interface Locale extends ILocale {
* *
*/ */
"pleaseLogin": string; "pleaseLogin": string;
/**
* Allowed
*/
"allowed": string;
}; };
"_antennaSources": { "_antennaSources": {
/** /**
@ -9641,6 +9661,10 @@ export interface Locale extends ILocale {
* *
*/ */
"roleTimeline": string; "roleTimeline": string;
/**
* Following
*/
"following": string;
}; };
}; };
"_dialog": { "_dialog": {
@ -9741,6 +9765,14 @@ export interface Locale extends ILocale {
* *
*/ */
"userCreated": string; "userCreated": string;
/**
*
*/
"inactiveModeratorsWarning": string;
/**
*
*/
"inactiveModeratorsInvitationOnlyChanged": string;
}; };
/** /**
* Webhookを削除しますか * Webhookを削除しますか
@ -10583,6 +10615,30 @@ export interface Locale extends ILocale {
* Mutuals * Mutuals
*/ */
"mutuals": string; "mutuals": string;
/**
* Private account
*/
"isLocked": string;
/**
* Administrator
*/
"isAdmin": string;
/**
* Bot user
*/
"isBot": string;
/**
* Open
*/
"open": string;
/**
* Destination address
*/
"emailDestination": string;
/**
* Date
*/
"date": string;
/** /**
* Quoted. * Quoted.
*/ */
@ -10916,6 +10972,38 @@ export interface Locale extends ILocale {
* Severing all follow relations with {host} queued. * Severing all follow relations with {host} queued.
*/ */
"severAllFollowRelationsQueued": ParameterizedString<"host">; "severAllFollowRelationsQueued": ParameterizedString<"host">;
/**
* Pending follow requests
*/
"pendingFollowRequests": string;
/**
* Show quotes
*/
"showQuotes": string;
/**
* Show replies
*/
"showReplies": string;
/**
* Show non-public
*/
"showNonPublicNotes": string;
/**
* Allow clicking on pop-up notifications
*/
"allowClickingNotifications": string;
/**
* Pinned
*/
"pinnedOnly": string;
/**
* Blocking you
*/
"blockingYou": string;
/**
* Show warning when opening external URLs
*/
"warnExternalUrl": string;
"_mfm": { "_mfm": {
/** /**
* This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks * This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks
@ -11286,6 +11374,14 @@ export interface Locale extends ILocale {
*/ */
"trustThisDomain": string; "trustThisDomain": string;
}; };
/**
* Remote followers may have incomplete or outdated activity
*/
"remoteFollowersWarning": string;
/**
* Select a follow relationship...
*/
"selectFollowRelationship": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d
moderator: "Moderatore" moderator: "Moderatore"
moderation: "moderazione" moderation: "moderazione"
moderationNote: "Promemoria di moderazione" moderationNote: "Promemoria di moderazione"
moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
addModerationNote: "Aggiungi promemoria di moderazione" addModerationNote: "Aggiungi promemoria di moderazione"
moderationLogs: "Cronologia di moderazione" moderationLogs: "Cronologia di moderazione"
nUsersMentioned: "{n} profili ne parlano" nUsersMentioned: "{n} profili ne parlano"
@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione"
hideOnlineStatus: "Modalità invisibile" hideOnlineStatus: "Modalità invisibile"
hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca." hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca."
online: "Online" online: "Online"
active: "Attività" active: "Attivo"
offline: "Offline" offline: "Offline"
notRecommended: "Sconsigliato" notRecommended: "Sconsigliato"
botProtection: "Protezione contro i bot" botProtection: "Protezione contro i bot"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" enableChartsForRemoteUser: "Abilita i grafici per i profili remoti"
enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate"
enableStatsForFederatedInstances: "Informazioni statistiche sui server federati"
showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note"
reactionsDisplaySize: "Grandezza delle reazioni" reactionsDisplaySize: "Grandezza delle reazioni"
limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale" limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta."
passkeyVerificationFailed: "La verifica della passkey non è riuscita." passkeyVerificationFailed: "La verifica della passkey non è riuscita."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato."
messageToFollower: "Messaggio ai follower" messageToFollower: "Messaggio ai follower"
target: "Riferimento"
testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. <strong>Da non utilizzare in ambiente di produzione.</strong>"
prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
_abuseUserReport:
forward: "Inoltra"
forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
resolve: "Risolvi"
accept: "Approva"
reject: "Rifiuta"
resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente."
_delivery: _delivery:
status: "Stato della consegna" status: "Stato della consegna"
stop: "Sospensione" stop: "Sospensione"
@ -1312,16 +1327,16 @@ _bubbleGame:
_announcement: _announcement:
forExistingUsers: "Solo ai profili attuali" forExistingUsers: "Solo ai profili attuali"
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
needConfirmationToRead: "Richiede la conferma di lettura" needConfirmationToRead: "Conferma di lettura obbligatoria"
needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce." needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"."
end: "Archivia l'annuncio" end: "Archivia l'annuncio"
tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi." tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi."
readConfirmTitle: "Segnare come già letto?" readConfirmTitle: "Segnare come già letto?"
readConfirmText: "Hai già letto \"{title}˝?" readConfirmText: "Hai già letto \"{title}˝?"
shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte." shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte."
dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte." dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte."
silence: "Silenziare gli annunci" silence: "Annuncio silenzioso"
silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette." silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta."
_initialAccountSetting: _initialAccountSetting:
accountCreated: "Il tuo profilo è stato creato!" accountCreated: "Il tuo profilo è stato creato!"
letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo."
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis." reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
inquiryUrl: "URL di contatto" inquiryUrl: "URL di contatto"
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
_accountMigration: _accountMigration:
moveFrom: "Migra un altro profilo dentro a questo" moveFrom: "Migra un altro profilo dentro a questo"
moveFromSub: "Crea un alias verso un altro profilo remoto" moveFromSub: "Crea un alias verso un altro profilo remoto"
@ -1975,7 +1991,6 @@ _theme:
buttonBg: "Sfondo del pulsante" buttonBg: "Sfondo del pulsante"
buttonHoverBg: "Sfondo del pulsante (sorvolato)" buttonHoverBg: "Sfondo del pulsante (sorvolato)"
inputBorder: "Inquadra casella di testo" inputBorder: "Inquadra casella di testo"
listItemHoverBg: "Sfondo della voce di elenco (sorvolato)"
driveFolderBg: "Sfondo della cartella di disco" driveFolderBg: "Sfondo della cartella di disco"
wallpaperOverlay: "Sovrapposizione dello sfondo" wallpaperOverlay: "Sovrapposizione dello sfondo"
badge: "Distintivo" badge: "Distintivo"
@ -2188,7 +2203,7 @@ _widgets:
_userList: _userList:
chooseList: "Seleziona una lista" chooseList: "Seleziona una lista"
clicker: "Cliccaggio" clicker: "Cliccaggio"
birthdayFollowings: "Chi nacque oggi" birthdayFollowings: "Compleanni del giorno"
_cw: _cw:
hide: "Nascondere" hide: "Nascondere"
show: "Continua la lettura..." show: "Continua la lettura..."
@ -2477,6 +2492,8 @@ _webhookSettings:
abuseReport: "Quando arriva una segnalazione" abuseReport: "Quando arriva una segnalazione"
abuseReportResolved: "Quando una segnalazione è risolta" abuseReportResolved: "Quando una segnalazione è risolta"
userCreated: "Quando viene creato un profilo" userCreated: "Quando viene creato un profilo"
inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo"
inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\""
deleteConfirm: "Vuoi davvero eliminare il Webhook?" deleteConfirm: "Vuoi davvero eliminare il Webhook?"
testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi." testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi."
_abuseReport: _abuseReport:
@ -2522,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "File nel Drive segnato come esplicito" markSensitiveDriveFile: "File nel Drive segnato come esplicito"
unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito" unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
resolveAbuseReport: "Segnalazione risolta" resolveAbuseReport: "Segnalazione risolta"
forwardAbuseReport: "Segnalazione inoltrata"
updateAbuseReportNote: "Ha aggiornato la segnalazione"
createInvitation: "Genera codice di invito" createInvitation: "Genera codice di invito"
createAd: "Banner creato" createAd: "Banner creato"
deleteAd: "Banner eliminato" deleteAd: "Banner eliminato"

View file

@ -121,7 +121,6 @@ inChannelQuote: "チャンネル内引用"
renoteToChannel: "チャンネルにリノート" renoteToChannel: "チャンネルにリノート"
renoteToOtherChannel: "他のチャンネルにリノート" renoteToOtherChannel: "他のチャンネルにリノート"
pinnedNote: "ピン留めされたノート" pinnedNote: "ピン留めされたノート"
pinnedOnly: "Pinned"
pinned: "ピン留め" pinned: "ピン留め"
you: "あなた" you: "あなた"
clickToShow: "クリックして表示" clickToShow: "クリックして表示"
@ -258,7 +257,6 @@ defaultValueIs: "デフォルト: {value}"
noCustomEmojis: "絵文字はありません" noCustomEmojis: "絵文字はありません"
noJobs: "ジョブはありません" noJobs: "ジョブはありません"
federating: "連合中" federating: "連合中"
blockingYou: "Blocking you"
blocked: "ブロック中" blocked: "ブロック中"
suspended: "配信停止" suspended: "配信停止"
all: "全て" all: "全て"
@ -1089,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
reactionsDisplaySize: "リアクションの表示サイズ" reactionsDisplaySize: "リアクションの表示サイズ"
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
@ -1273,7 +1272,6 @@ inquiry: "お問い合わせ"
tryAgain: "もう一度お試しください。" tryAgain: "もう一度お試しください。"
confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する" confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する"
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
warnExternalUrl: "外部URLを開く際に警告を表示する"
createdLists: "作成したリスト" createdLists: "作成したリスト"
createdAntennas: "作成したアンテナ" createdAntennas: "作成したアンテナ"
fromX: "{x}から" fromX: "{x}から"
@ -1290,6 +1288,11 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ" messageToFollower: "フォロワーへのメッセージ"
target: "対象" target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
_abuseUserReport: _abuseUserReport:
forward: "転送" forward: "転送"
@ -1443,6 +1446,7 @@ _serverSettings:
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
inquiryUrl: "問い合わせ先URL" inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
_accountMigration: _accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行" moveFrom: "別のアカウントからこのアカウントに移行"
@ -2021,7 +2025,6 @@ _theme:
buttonBg: "ボタンの背景" buttonBg: "ボタンの背景"
buttonHoverBg: "ボタンの背景 (ホバー)" buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り" inputBorder: "入力ボックスの縁取り"
listItemHoverBg: "リスト項目の背景 (ホバー)"
driveFolderBg: "ドライブフォルダーの背景" driveFolderBg: "ドライブフォルダーの背景"
wallpaperOverlay: "壁紙のオーバーレイ" wallpaperOverlay: "壁紙のオーバーレイ"
badge: "バッジ" badge: "バッジ"
@ -2556,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき" abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき" abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき" userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか" deleteConfirm: "Webhookを削除しますか"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

View file

@ -1943,7 +1943,6 @@ _theme:
buttonBg: "ボタンの背景" buttonBg: "ボタンの背景"
buttonHoverBg: "ボタンの背景 (ホバー)" buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り" inputBorder: "入力ボックスの縁取り"
listItemHoverBg: "リスト項目の背景 (ホバー)"
driveFolderBg: "ドライブフォルダーの背景" driveFolderBg: "ドライブフォルダーの背景"
wallpaperOverlay: "壁紙のオーバーレイ" wallpaperOverlay: "壁紙のオーバーレイ"
badge: "バッジ" badge: "バッジ"

View file

@ -601,8 +601,6 @@ reportAbuseOf: "{name}님얼 신고하기"
reporter: "신고한 사람" reporter: "신고한 사람"
reporteeOrigin: "신고덴 사람" reporteeOrigin: "신고덴 사람"
reporterOrigin: "신고한 곳" reporterOrigin: "신고한 곳"
forwardReport: "웬겍 서버에 신고 보내기"
forwardReportIsAnonymous: "웬겍 서버서는 나으 정보럴 몬 보고 익멩으 시스템 게정어로 보입니다."
waitingFor: "{x}(얼)럴 지달리고 잇십니다" waitingFor: "{x}(얼)럴 지달리고 잇십니다"
random: "무작이" random: "무작이"
system: "시스템" system: "시스템"

View file

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?"
retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다." retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다."
enableChartsForRemoteUser: "리모트 유저의 차트를 생성" enableChartsForRemoteUser: "리모트 유저의 차트를 생성"
enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" enableChartsForFederatedInstances: "리모트 서버의 차트를 생성"
enableStatsForFederatedInstances: "리모트 서버 정보 받아오기"
showClipButtonInNoteFooter: "노트 동작에 클립을 추가" showClipButtonInNoteFooter: "노트 동작에 클립을 추가"
reactionsDisplaySize: "리액션 표시 크기" reactionsDisplaySize: "리액션 표시 크기"
limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기" limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기"
@ -1122,7 +1123,7 @@ preservedUsernames: "예약한 사용자 이름"
preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다."
createNoteFromTheFile: "이 파일로 노트를 작성" createNoteFromTheFile: "이 파일로 노트를 작성"
archive: "아카이브" archive: "아카이브"
archived: "보관됨" archived: "아카이브 됨"
unarchive: "보관 취소" unarchive: "보관 취소"
channelArchiveConfirmTitle: "{name} 채널을 보존하시겠습니까?" channelArchiveConfirmTitle: "{name} 채널을 보존하시겠습니까?"
channelArchiveConfirmDescription: "보존한 채널은 채널 목록과 검색 결과에 표시되지 않으며 새로운 노트도 작성할 수 없습니다." channelArchiveConfirmDescription: "보존한 채널은 채널 목록과 검색 결과에 표시되지 않으며 새로운 노트도 작성할 수 없습니다."
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다." passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
messageToFollower: "팔로워에 보낼 메시지" messageToFollower: "팔로워에 보낼 메시지"
target: "대상" target: "대상"
testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
_abuseUserReport: _abuseUserReport:
forward: "전달" forward: "전달"
forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다." forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다."
@ -1431,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다." reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
inquiryUrl: "문의처 URL" inquiryUrl: "문의처 URL"
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다." inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
_accountMigration: _accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사" moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성" moveFromSub: "다른 계정에 대한 별칭을 생성"
@ -1984,7 +1991,6 @@ _theme:
buttonBg: "버튼 배경" buttonBg: "버튼 배경"
buttonHoverBg: "버튼 배경 (호버)" buttonHoverBg: "버튼 배경 (호버)"
inputBorder: "입력 필드 테두리" inputBorder: "입력 필드 테두리"
listItemHoverBg: "리스트 항목 배경 (호버)"
driveFolderBg: "드라이브 폴더 배경" driveFolderBg: "드라이브 폴더 배경"
wallpaperOverlay: "배경화면 오버레이" wallpaperOverlay: "배경화면 오버레이"
badge: "배지" badge: "배지"
@ -2486,6 +2492,8 @@ _webhookSettings:
abuseReport: "유저롭" abuseReport: "유저롭"
abuseReportResolved: "받은 신고를 처리했을 때" abuseReportResolved: "받은 신고를 처리했을 때"
userCreated: "유저가 생성되었을 때" userCreated: "유저가 생성되었을 때"
inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우"
inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우"
deleteConfirm: "Webhook을 삭제할까요?" deleteConfirm: "Webhook을 삭제할까요?"
testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다." testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다."
_abuseReport: _abuseReport:

View file

@ -1205,7 +1205,6 @@ _theme:
buttonBg: "Tło przycisku" buttonBg: "Tło przycisku"
buttonHoverBg: "Tło przycisku (po najechaniu)" buttonHoverBg: "Tło przycisku (po najechaniu)"
inputBorder: "Obramowanie pola wejścia" inputBorder: "Obramowanie pola wejścia"
listItemHoverBg: "Tło elementu listy (po najechaniu)"
driveFolderBg: "Tło folderu na dysku" driveFolderBg: "Tło folderu na dysku"
wallpaperOverlay: "Nakładka tapety" wallpaperOverlay: "Nakładka tapety"
badge: "Odznaka" badge: "Odznaka"

View file

@ -25,7 +25,7 @@ basicSettings: "Configurações básicas"
otherSettings: "Outras configurações" otherSettings: "Outras configurações"
openInWindow: "Abrir em um janela" openInWindow: "Abrir em um janela"
profile: "Perfil" profile: "Perfil"
timeline: "Cronologia" timeline: "Linha do tempo"
noAccountDescription: "Este usuário não tem uma descrição." noAccountDescription: "Este usuário não tem uma descrição."
login: "Iniciar sessão" login: "Iniciar sessão"
loggingIn: "Iniciando sessão…" loggingIn: "Iniciando sessão…"
@ -1058,7 +1058,7 @@ resetPasswordConfirm: "Deseja realmente mudar a sua senha?"
sensitiveWords: "Palavras sensíveis" sensitiveWords: "Palavras sensíveis"
sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha." sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha."
sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)"
prohibitedWords: "Palavras proibídas" prohibitedWords: "Palavras proibidas"
prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha." prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha."
prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)"
hiddenTags: "Hashtags escondidas" hiddenTags: "Hashtags escondidas"
@ -1416,7 +1416,7 @@ _achievements:
_types: _types:
_notes1: _notes1:
title: "Configurando o meu misskey" title: "Configurando o meu misskey"
description: "Post uma nota pela primeira vez" description: "Poste uma nota pela primeira vez"
flavor: "Divirta-se com o Misskey!" flavor: "Divirta-se com o Misskey!"
_notes10: _notes10:
title: "Algumas notas" title: "Algumas notas"
@ -1944,7 +1944,6 @@ _theme:
buttonBg: "Plano de fundo de botão" buttonBg: "Plano de fundo de botão"
buttonHoverBg: "Plano de fundo de botão (Selecionado)" buttonHoverBg: "Plano de fundo de botão (Selecionado)"
inputBorder: "Borda de campo digitável" inputBorder: "Borda de campo digitável"
listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)"
driveFolderBg: "Plano de fundo da pasta no Drive" driveFolderBg: "Plano de fundo da pasta no Drive"
wallpaperOverlay: "Sobreposição do papel de parede." wallpaperOverlay: "Sobreposição do papel de parede."
badge: "Emblema" badge: "Emblema"

View file

@ -1694,7 +1694,6 @@ _theme:
buttonBg: "Фон кнопки" buttonBg: "Фон кнопки"
buttonHoverBg: "Текст кнопки" buttonHoverBg: "Текст кнопки"
inputBorder: "Рамка поля ввода" inputBorder: "Рамка поля ввода"
listItemHoverBg: "Фон пункта списка (под указателем)"
driveFolderBg: "Фон папки «Диска»" driveFolderBg: "Фон папки «Диска»"
wallpaperOverlay: "Слой обоев" wallpaperOverlay: "Слой обоев"
badge: "Значок" badge: "Значок"

View file

@ -1108,7 +1108,6 @@ _theme:
buttonBg: "Pozadie tlačidla" buttonBg: "Pozadie tlačidla"
buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" buttonHoverBg: "Pozadie tlačidla (pod kurzorom)"
inputBorder: "Okraj vstupného poľa" inputBorder: "Okraj vstupného poľa"
listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)"
driveFolderBg: "Pozadie priečinu disku" driveFolderBg: "Pozadie priečinu disku"
wallpaperOverlay: "Vrstvenie pozadia" wallpaperOverlay: "Vrstvenie pozadia"
badge: "Odznak" badge: "Odznak"

View file

@ -1943,7 +1943,6 @@ _theme:
buttonBg: "ปุ่มพื้นหลัง" buttonBg: "ปุ่มพื้นหลัง"
buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)"
inputBorder: "เส้นขอบของช่องป้อนข้อมูล" inputBorder: "เส้นขอบของช่องป้อนข้อมูล"
listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)"
driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์"
wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ"
badge: "ตรา" badge: "ตรา"

View file

@ -1302,7 +1302,6 @@ _theme:
buttonBg: "Фон кнопки" buttonBg: "Фон кнопки"
buttonHoverBg: "Фон кнопки (при наведенні)" buttonHoverBg: "Фон кнопки (при наведенні)"
inputBorder: "Край поля вводу" inputBorder: "Край поля вводу"
listItemHoverBg: "Фон елементу в списку (при наведенні)"
driveFolderBg: "Фон папки на диску" driveFolderBg: "Фон папки на диску"
wallpaperOverlay: "Накладання шпалер" wallpaperOverlay: "Накладання шпалер"
badge: "Значок" badge: "Значок"

1
locales/version.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export const localesVersion: string;

14
locales/version.js Normal file
View file

@ -0,0 +1,14 @@
import { createHash } from 'crypto';
import locales from './index.js';
// MD5 is acceptable because we don't need cryptographic security.
const hash = createHash('md5');
// Derive the version hash from locale content exclusively.
// This avoids the problem of "stuck" translations after modifying locale files.
const localesText = JSON.stringify(locales);
hash.update(localesText, 'utf8');
// We can't use regular base64 since this becomes part of a filename.
// Base64URL avoids special characters that would cause an issue.
export const localesVersion = hash.digest().toString('base64url');

View file

@ -1546,7 +1546,6 @@ _theme:
buttonBg: "Nền nút" buttonBg: "Nền nút"
buttonHoverBg: "Nền nút (Chạm)" buttonHoverBg: "Nền nút (Chạm)"
inputBorder: "Đường viền khung soạn thảo" inputBorder: "Đường viền khung soạn thảo"
listItemHoverBg: "Nền mục liệt kê (Chạm)"
driveFolderBg: "Nền thư mục Ổ đĩa" driveFolderBg: "Nền thư mục Ổ đĩa"
wallpaperOverlay: "Lớp phủ hình nền" wallpaperOverlay: "Lớp phủ hình nền"
badge: "Huy hiệu" badge: "Huy hiệu"

View file

@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?"
retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForRemoteUser: "生成远程用户的图表"
enableChartsForFederatedInstances: "生成远程服务器的图表" enableChartsForFederatedInstances: "生成远程服务器的图表"
enableStatsForFederatedInstances: "获取远程服务器的信息"
showClipButtonInNoteFooter: "在贴文下方显示便签按钮" showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
reactionsDisplaySize: "回应显示大小" reactionsDisplaySize: "回应显示大小"
limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示" limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
@ -1199,10 +1200,10 @@ followingOrFollower: "关注中或关注者"
fileAttachedOnly: "仅限媒体" fileAttachedOnly: "仅限媒体"
showRepliesToOthersInTimeline: "在时间线中包含给别人的回复" showRepliesToOthersInTimeline: "在时间线中包含给别人的回复"
hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复" hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复"
showRepliesToOthersInTimelineAll: "在时间线中包含现在关注的所有人的回复" showRepliesToOthersInTimelineAll: "在时间线中显示所有现在关注的人的回复"
hideRepliesToOthersInTimelineAll: "在时间线中隐藏现在关注的所有人的回复" hideRepliesToOthersInTimelineAll: "在时间线中隐藏所有现在关注的人的回复"
confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中包含现在关注的所有人的回复吗?" confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中显示所有现在关注的人的回复吗?"
confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?" confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏所有现在关注的人的回复吗?"
externalServices: "外部服务" externalServices: "外部服务"
sourceCode: "源代码" sourceCode: "源代码"
sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。" sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。"
@ -1287,9 +1288,18 @@ passkeyVerificationFailed: "验证通行密钥失败。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。"
messageToFollower: "给关注者的消息" messageToFollower: "给关注者的消息"
target: "对象" target: "对象"
testCaptchaWarning: "此功能为测试 CAPTCHA 用。<strong>请勿在正式环境中使用。</strong>"
prohibitedWordsForNameOfUser: "用户名中禁止的词"
prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
yourNameContainsProhibitedWords: "目标用户名包含违禁词"
yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
_abuseUserReport: _abuseUserReport:
forward: "转发" forward: "转发"
forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。" forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
resolve: "解决"
accept: "确认"
reject: "拒绝"
resolveTutorial: "如果举报内容有理且已解决,选择「确认」将案件以肯定的态度标记为已解决。\n如果举报内容站不住脚选择「拒绝」将案件以否定的态度标记为已解决。"
_delivery: _delivery:
status: "投递状态" status: "投递状态"
stop: "停止投递" stop: "停止投递"
@ -1427,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
inquiryUrl: "联络地址" inquiryUrl: "联络地址"
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
_accountMigration: _accountMigration:
moveFrom: "从别的账号迁移到此账户" moveFrom: "从别的账号迁移到此账户"
moveFromSub: "为另一个账户建立别名" moveFromSub: "为另一个账户建立别名"
@ -1626,7 +1637,7 @@ _achievements:
_postedAt0min0sec: _postedAt0min0sec:
title: "报时" title: "报时"
description: "在 0 点发布一篇帖子" description: "在 0 点发布一篇帖子"
flavor: "报时信号最后一响,零点整" flavor: "嘟 · 嘟 · 嘟 · 哔——"
_selfQuote: _selfQuote:
title: "自我引用" title: "自我引用"
description: "引用了自己的帖子" description: "引用了自己的帖子"
@ -1980,7 +1991,6 @@ _theme:
buttonBg: "按钮背景" buttonBg: "按钮背景"
buttonHoverBg: "按钮背景(悬停)" buttonHoverBg: "按钮背景(悬停)"
inputBorder: "输入框边框" inputBorder: "输入框边框"
listItemHoverBg: "下拉列表项目背景(悬停)"
driveFolderBg: "网盘的文件夹背景" driveFolderBg: "网盘的文件夹背景"
wallpaperOverlay: "壁纸叠加层" wallpaperOverlay: "壁纸叠加层"
badge: "徽章" badge: "徽章"
@ -2259,7 +2269,7 @@ _profile:
avatarDecorationMax: "最多可添加 {max} 个挂件" avatarDecorationMax: "最多可添加 {max} 个挂件"
followedMessage: "被关注时显示的消息" followedMessage: "被关注时显示的消息"
followedMessageDescription: "可以设置被关注时向对方显示的短消息。" followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。" followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
_exportOrImport: _exportOrImport:
allNotes: "所有帖子" allNotes: "所有帖子"
favoritedNotes: "收藏的帖子" favoritedNotes: "收藏的帖子"
@ -2482,6 +2492,8 @@ _webhookSettings:
abuseReport: "当收到举报时" abuseReport: "当收到举报时"
abuseReportResolved: "当举报被处理时" abuseReportResolved: "当举报被处理时"
userCreated: "当用户被创建时" userCreated: "当用户被创建时"
inactiveModeratorsWarning: "当管理员在一段时间内不活跃时"
inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时"
deleteConfirm: "要删除 webhook 吗?" deleteConfirm: "要删除 webhook 吗?"
testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。"
_abuseReport: _abuseReport:

View file

@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼"
moderator: "審查員" moderator: "審查員"
moderation: "審查" moderation: "審查"
moderationNote: "管理筆記" moderationNote: "管理筆記"
moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。"
addModerationNote: "新增管理筆記" addModerationNote: "新增管理筆記"
moderationLogs: "管理日誌" moderationLogs: "管理日誌"
nUsersMentioned: "被 {n} 個人提及" nUsersMentioned: "被 {n} 個人提及"
@ -519,7 +520,7 @@ menuStyle: "選單風格"
style: "風格" style: "風格"
drawer: "側邊欄" drawer: "側邊欄"
popup: "彈出式視窗" popup: "彈出式視窗"
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的"
showReactionsCount: "顯示貼文的反應數目" showReactionsCount: "顯示貼文的反應數目"
noHistory: "沒有歷史紀錄" noHistory: "沒有歷史紀錄"
signinHistory: "登入歷史" signinHistory: "登入歷史"
@ -1018,7 +1019,7 @@ show: "檢視"
neverShow: "不再顯示" neverShow: "不再顯示"
remindMeLater: "以後再說" remindMeLater: "以後再說"
didYouLikeMisskey: "您喜歡 Misskey 嗎?" didYouLikeMisskey: "您喜歡 Misskey 嗎?"
pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!"
correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。" correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
roles: "角色" roles: "角色"
role: "角色" role: "角色"
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?"
retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForRemoteUser: "生成遠端使用者的圖表"
enableChartsForFederatedInstances: "生成遠端伺服器的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表"
enableStatsForFederatedInstances: "取得遠端伺服器資訊"
showClipButtonInNoteFooter: "新增摘錄按鈕至貼文" showClipButtonInNoteFooter: "新增摘錄按鈕至貼文"
reactionsDisplaySize: "反應的顯示尺寸" reactionsDisplaySize: "反應的顯示尺寸"
limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。" limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。"
@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文"
edited: "已編輯" edited: "已編輯"
notificationRecieveConfig: "接受通知的設定" notificationRecieveConfig: "接受通知的設定"
mutualFollow: "互相追隨" mutualFollow: "互相追隨"
followingOrFollower: "追隨中或追隨者" followingOrFollower: "追隨中或追隨者"
fileAttachedOnly: "只顯示包含附件的貼文" fileAttachedOnly: "只顯示包含附件的貼文"
showRepliesToOthersInTimeline: "顯示給其他人的回覆" showRepliesToOthersInTimeline: "顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊"
keepOriginalFilename: "保留原始檔名" keepOriginalFilename: "保留原始檔名"
keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。"
noDescription: "沒有說明文字" noDescription: "沒有說明文字"
alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" alwaysConfirmFollow: "跟隨時總是確認"
inquiry: "聯絡我們" inquiry: "聯絡我們"
tryAgain: "請再試一次。" tryAgain: "請再試一次。"
confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認"
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。"
passkeyVerificationFailed: "驗證金鑰失敗。" passkeyVerificationFailed: "驗證金鑰失敗。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。"
messageToFollower: "給追隨者的訊息" messageToFollower: "給追隨者的訊息"
target: "目標 "
testCaptchaWarning: "此功能用於 CAPTCHA 的測試。<strong>請勿在正式環境中使用。</strong>"
prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
_abuseUserReport:
forward: "轉發"
forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
resolve: "解決"
accept: "接受"
reject: "拒絕"
resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。"
_delivery: _delivery:
status: "傳送狀態" status: "傳送狀態"
stop: "停止發送" stop: "停止發送"
@ -1422,6 +1437,7 @@ _serverSettings:
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是Redis 記憶體使用量會增加。" reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是Redis 記憶體使用量會增加。"
inquiryUrl: "聯絡表單網址" inquiryUrl: "聯絡表單網址"
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam如果一段期間內沒有偵測到審查員的活動此設定將自動關閉。"
_accountMigration: _accountMigration:
moveFrom: "從其他帳戶遷移到這個帳戶" moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromSub: "為另一個帳戶建立別名" moveFromSub: "為另一個帳戶建立別名"
@ -1435,7 +1451,7 @@ _accountMigration:
startMigration: "遷移" startMigration: "遷移"
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。"
movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。"
postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。"
movedTo: "要遷移到的帳戶:" movedTo: "要遷移到的帳戶:"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
@ -1555,7 +1571,7 @@ _achievements:
_markedAsCat: _markedAsCat:
title: "我是貓" title: "我是貓"
description: "已將帳戶設定為貓" description: "已將帳戶設定為貓"
flavor: "沒有名字。" flavor: "沒有名字。"
_following1: _following1:
title: "首次追隨" title: "首次追隨"
description: "首次追隨了" description: "首次追隨了"
@ -1569,7 +1585,7 @@ _achievements:
title: "一百位朋友" title: "一百位朋友"
description: "追隨超過100人了" description: "追隨超過100人了"
_following300: _following300:
title: "朋友多" title: "朋友多"
description: "追隨超過300人了" description: "追隨超過300人了"
_followers1: _followers1:
title: "第一個追隨者" title: "第一個追隨者"
@ -1895,7 +1911,7 @@ _channel:
following: "追隨中" following: "追隨中"
usersCount: "有 {n} 人參與" usersCount: "有 {n} 人參與"
notesCount: "有 {n} 篇貼文" notesCount: "有 {n} 篇貼文"
nameAndDescription: "名稱與說明" nameAndDescription: "名稱"
nameOnly: "僅名稱" nameOnly: "僅名稱"
allowRenoteToExternal: "允許在頻道外轉發和引用" allowRenoteToExternal: "允許在頻道外轉發和引用"
_menuDisplay: _menuDisplay:
@ -1975,7 +1991,6 @@ _theme:
buttonBg: "按鈕背景" buttonBg: "按鈕背景"
buttonHoverBg: "按鈕背景 (漂浮)" buttonHoverBg: "按鈕背景 (漂浮)"
inputBorder: "輸入框邊框" inputBorder: "輸入框邊框"
listItemHoverBg: "列表物品背景 (漂浮)"
driveFolderBg: "雲端硬碟文件夾背景" driveFolderBg: "雲端硬碟文件夾背景"
wallpaperOverlay: "壁紙覆蓋層" wallpaperOverlay: "壁紙覆蓋層"
badge: "徽章" badge: "徽章"
@ -2477,6 +2492,8 @@ _webhookSettings:
abuseReport: "當使用者檢舉時" abuseReport: "當使用者檢舉時"
abuseReportResolved: "當處理了使用者的檢舉時" abuseReportResolved: "當處理了使用者的檢舉時"
userCreated: "使用者被新增時" userCreated: "使用者被新增時"
inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時"
inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制"
deleteConfirm: "請問是否要刪除 Webhook" deleteConfirm: "請問是否要刪除 Webhook"
testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。"
_abuseReport: _abuseReport:
@ -2491,7 +2508,7 @@ _abuseReport:
mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)" mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)"
webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)" webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)"
keywords: "關鍵字" keywords: "關鍵字"
notifiedUser: "通知的使用者" notifiedUser: "通知的使用者"
notifiedWebhook: "使用的 Webhook" notifiedWebhook: "使用的 Webhook"
deleteConfirm: "確定要刪除通知對象嗎?" deleteConfirm: "確定要刪除通知對象嗎?"
_moderationLogTypes: _moderationLogTypes:
@ -2522,6 +2539,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "標記為敏感檔案" markSensitiveDriveFile: "標記為敏感檔案"
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
resolveAbuseReport: "解決檢舉" resolveAbuseReport: "解決檢舉"
forwardAbuseReport: "轉發檢舉"
updateAbuseReportNote: "更新檢舉的審查備註"
createInvitation: "建立邀請碼" createInvitation: "建立邀請碼"
createAd: "建立廣告" createAd: "建立廣告"
deleteAd: "刪除廣告" deleteAd: "刪除廣告"

View file

@ -1,6 +1,6 @@
{ {
"name": "sharkey", "name": "sharkey",
"version": "2024.9.1-rc", "version": "2024.10.0-dev",
"codename": "shonk", "codename": "shonk",
"repository": { "repository": {
"type": "git", "type": "git",
@ -34,6 +34,9 @@
"watch": "pnpm dev", "watch": "pnpm dev",
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"lint": "pnpm -r lint", "lint": "pnpm -r lint",
"lint-all": "pnpm -r --no-bail lint",
"eslint": "pnpm -r eslint",
"eslint-all": "pnpm -r --no-bail eslint",
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run", "cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
@ -63,13 +66,15 @@
"esbuild": "0.23.1", "esbuild": "0.23.1",
"glob": "11.0.0" "glob": "11.0.0"
}, },
"optionalDependencies": {
"cypress": "13.14.2"
},
"devDependencies": { "devDependencies": {
"@misskey-dev/eslint-plugin": "2.0.3", "@misskey-dev/eslint-plugin": "2.0.3",
"@types/node": "20.14.12", "@types/node": "20.14.12",
"@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/eslint-plugin": "7.17.0",
"@typescript-eslint/parser": "7.17.0", "@typescript-eslint/parser": "7.17.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.14.2",
"eslint": "9.8.0", "eslint": "9.8.0",
"globals": "15.9.0", "globals": "15.9.0",
"ncp": "2.0.0", "ncp": "2.0.0",

View file

@ -1,5 +1,6 @@
import tsParser from '@typescript-eslint/parser'; import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js'; import sharedConfig from '../shared/eslint.config.js';
import globals from 'globals';
export default [ export default [
...sharedConfig, ...sharedConfig,
@ -43,4 +44,25 @@ export default [
}], }],
}, },
}, },
{
files: ['src/server/web/**/*.js', 'src/server/web/**/*.ts'],
languageOptions: {
globals: {
...globals.browser,
LANGS: true,
CLIENT_ENTRY: true,
LANGS_VERSION: true,
},
},
},
{
ignores: [
"**/lib/",
"**/temp/",
"**/built/",
"**/coverage/",
"**/node_modules/",
"**/migration/",
]
},
]; ];

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SoftLimitDriveComment1728348353115 {
name = 'SoftLimitDriveComment1728348353115'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE varchar(100000)`);
}
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class TrackLatestNoteType1728420772835 {
name = 'TrackLatestNoteType1728420772835'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_public" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_reply" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_quote" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`);
}
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: marie and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class DefaultSensitive1729414690009 {
name = 'DefaultSensitive1729414690009'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "defaultSensitive" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "defaultSensitive"`);
}
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class friendlyCaptcha1730505338000 {
name = 'friendlyCaptcha1730505338000';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableFC" boolean NOT NULL DEFAULT false`, undefined);
await queryRunner.query(`ALTER TABLE "meta" ADD "fcSiteKey" character varying(1024)`, undefined);
await queryRunner.query(`ALTER TABLE "meta" ADD "fcSecretKey" character varying(1024)`, undefined);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSecretKey"`, undefined);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSiteKey"`, undefined);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFC"`, undefined);
}
}

View file

@ -20,7 +20,7 @@
"restart": "pnpm build && pnpm start", "restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs", "dev": "node ./scripts/dev.mjs",
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit", "typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\" --cache", "eslint": "eslint --quiet \"{src,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
@ -93,6 +93,7 @@
"@swc/core": "1.6.6", "@swc/core": "1.6.6",
"@transfem-org/sfm-js": "0.24.5", "@transfem-org/sfm-js": "0.24.5",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@types/psl": "^1.1.3",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.17.1", "ajv": "8.17.1",
"archiver": "7.0.1", "archiver": "7.0.1",
@ -112,7 +113,7 @@
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "4.4.1",
"fastify": "5.0.0", "fastify": "5.0.0",
"fastify-multer": "^2.0.3", "fastify-multer": "^2.0.3",
"fastify-raw-body": "5.0.0", "fastify-raw-body": "5.0.0",
@ -135,9 +136,9 @@
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.2", "jsonld": "8.3.2",
"jsrsasign": "11.1.0", "jsrsasign": "11.1.0",
"juice": "11.0.0",
"megalodon": "workspace:*", "megalodon": "workspace:*",
"meilisearch": "0.42.0", "meilisearch": "0.42.0",
"juice": "11.0.0",
"microformats-parser": "2.0.2", "microformats-parser": "2.0.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
@ -158,6 +159,7 @@
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"psl": "^1.13.0",
"pug": "3.0.3", "pug": "3.0.3",
"punycode": "2.3.1", "punycode": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",

View file

@ -12,27 +12,49 @@ const config = loadConfig();
// createPostgresDataSource handels primaries and replicas automatically. // createPostgresDataSource handels primaries and replicas automatically.
// usually, it only opens connections first use, so we force it using // usually, it only opens connections first use, so we force it using
// .initialize() // .initialize()
createPostgresDataSource(config) async function connectToPostgres(){
.initialize() const source = createPostgresDataSource(config);
.then(c => { c.destroy() }) await source.initialize();
.catch(e => { throw e }); await source.destroy();
}
// Connect to all redis servers // Connect to all redis servers
function connectToRedis(redisOptions) { async function connectToRedis(redisOptions) {
const redis = new Redis(redisOptions); return await new Promise(async (resolve, reject) => {
redis.on('connect', () => redis.disconnect()); const redis = new Redis({
redis.on('error', (e) => { ...redisOptions,
throw e; lazyConnect: true,
reconnectOnError: false,
showFriendlyErrorStack: true,
});
redis.on('error', e => reject(e));
try {
await redis.connect();
resolve();
} catch (e) {
reject(e);
} finally {
redis.disconnect(false);
}
}); });
} }
// If not all of these are defined, the default one gets reused. // If not all of these are defined, the default one gets reused.
// so we use a Set to only try connecting once to each **uniq** redis. // so we use a Set to only try connecting once to each **uniq** redis.
(new Set([ const promises = Array
config.redis, .from(new Set([
config.redisForPubsub, config.redis,
config.redisForJobQueue, config.redisForPubsub,
config.redisForTimelines, config.redisForJobQueue,
config.redisForReactions, config.redisForTimelines,
])).forEach(connectToRedis); config.redisForReactions,
]))
.map(connectToRedis)
.concat([
connectToPostgres()
]);
await Promise.all(promises);

View file

@ -73,6 +73,11 @@ type Source = {
maxFileSize?: number; maxFileSize?: number;
maxNoteLength?: number; maxNoteLength?: number;
maxCwLength?: number;
maxRemoteCwLength?: number;
maxRemoteNoteLength?: number;
maxAltTextLength?: number;
maxRemoteAltTextLength?: number;
clusterLimit?: number; clusterLimit?: number;
@ -110,6 +115,7 @@ type Source = {
}; };
pidFile: string; pidFile: string;
filePermissionBits?: string;
}; };
export type Config = { export type Config = {
@ -149,6 +155,11 @@ export type Config = {
allowedPrivateNetworks: string[] | undefined; allowedPrivateNetworks: string[] | undefined;
maxFileSize: number; maxFileSize: number;
maxNoteLength: number; maxNoteLength: number;
maxRemoteNoteLength: number;
maxCwLength: number;
maxRemoteCwLength: number;
maxAltTextLength: number;
maxRemoteAltTextLength: number;
clusterLimit: number | undefined; clusterLimit: number | undefined;
id: string; id: string;
outgoingAddress: string | undefined; outgoingAddress: string | undefined;
@ -202,6 +213,7 @@ export type Config = {
} | undefined; } | undefined;
pidFile: string; pidFile: string;
filePermissionBits?: string;
}; };
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
@ -301,6 +313,11 @@ export function loadConfig(): Config {
allowedPrivateNetworks: config.allowedPrivateNetworks, allowedPrivateNetworks: config.allowedPrivateNetworks,
maxFileSize: config.maxFileSize ?? 262144000, maxFileSize: config.maxFileSize ?? 262144000,
maxNoteLength: config.maxNoteLength ?? 3000, maxNoteLength: config.maxNoteLength ?? 3000,
maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000,
maxCwLength: config.maxCwLength ?? 500,
maxRemoteCwLength: config.maxRemoteCwLength ?? 5000,
maxAltTextLength: config.maxAltTextLength ?? 20000,
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
clusterLimit: config.clusterLimit, clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress, outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily, outgoingAddressFamily: config.outgoingAddressFamily,
@ -332,6 +349,7 @@ export function loadConfig(): Config {
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
import: config.import, import: config.import,
pidFile: config.pidFile, pidFile: config.pidFile,
filePermissionBits: config.filePermissionBits,
}; };
} }
@ -437,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) { function _assign(path: (string | number)[], lastStep: string | number, value: string) {
let thisConfig = config as any; let thisConfig = config as any;
@ -475,7 +496,7 @@ function applyEnvOverrides(config: Source) {
_apply_top(['sentryForBackend', 'enableNodeProfiling']); _apply_top(['sentryForBackend', 'enableNodeProfiling']);
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]); _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
_apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]); _apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]);
} }

View file

@ -3,30 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export const MAX_NOTE_TEXT_LENGTH = 3000;
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
//#region hard limits
// If you change DB_* values, you must also change the DB schema.
/**
* Maximum note text length that can be stored in DB.
* Content Warnings are included in this limit.
* Surrogate pairs count as one
*/
export const DB_MAX_NOTE_TEXT_LENGTH = 100000;
/**
* Maximum image description length that can be stored in DB.
* Surrogate pairs count as one
*/
export const DB_MAX_IMAGE_COMMENT_LENGTH = 100000;
//#endregion
// ブラウザで直接表示することを許可するファイルの種類のリスト // ブラウザで直接表示することを許可するファイルの種類のリスト
// ここに含まれないものは application/octet-stream としてレスポンスされる // ここに含まれないものは application/octet-stream としてレスポンスされる
// SVGはXSSを生むので許可しない // SVGはXSSを生むので許可しない

View file

@ -10,6 +10,7 @@ import { bindThis } from '@/decorators.js';
type CaptchaResponse = { type CaptchaResponse = {
success: boolean; success: boolean;
'error-codes'?: string[]; 'error-codes'?: string[];
'errors'?: string[];
}; };
@Injectable() @Injectable()
@ -73,6 +74,35 @@ export class CaptchaService {
} }
} }
@bindThis
public async verifyFriendlyCaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('frc-failed: no response provided');
}
const result = await this.httpRequestService.send('https://api.friendlycaptcha.com/api/v1/siteverify', {
method: 'POST',
body: JSON.stringify({
secret: secret,
solution: response,
}),
headers: {
'Content-Type': 'application/json',
},
});
if (result.status !== 200) {
throw new Error('frc-failed: frc didn\'t return 200 OK');
}
const resp = await result.json() as CaptchaResponse;
if (resp.success !== true) {
const errorCodes = resp['errors'] ? resp['errors'].join(', ') : '';
throw new Error(`frc-failed: ${errorCodes}`);
}
}
// https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go // https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go
@bindThis @bindThis
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> { public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {

View file

@ -43,6 +43,7 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js'; import { NoteCreateService } from './NoteCreateService.js';
import { NoteEditService } from './NoteEditService.js'; import { NoteEditService } from './NoteEditService.js';
import { NoteDeleteService } from './NoteDeleteService.js'; import { NoteDeleteService } from './NoteDeleteService.js';
import { LatestNoteService } from './LatestNoteService.js';
import { NotePiningService } from './NotePiningService.js'; import { NotePiningService } from './NotePiningService.js';
import { NoteReadService } from './NoteReadService.js'; import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js'; import { NotificationService } from './NotificationService.js';
@ -187,6 +188,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService }; const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
@ -339,6 +341,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
NoteCreateService, NoteCreateService,
NoteEditService, NoteEditService,
NoteDeleteService, NoteDeleteService,
LatestNoteService,
NotePiningService, NotePiningService,
NoteReadService, NoteReadService,
NotificationService, NotificationService,
@ -487,6 +490,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$NoteCreateService, $NoteCreateService,
$NoteEditService, $NoteEditService,
$NoteDeleteService, $NoteDeleteService,
$LatestNoteService,
$NotePiningService, $NotePiningService,
$NoteReadService, $NoteReadService,
$NotificationService, $NotificationService,
@ -636,6 +640,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
NoteCreateService, NoteCreateService,
NoteEditService, NoteEditService,
NoteDeleteService, NoteDeleteService,
LatestNoteService,
NotePiningService, NotePiningService,
NoteReadService, NoteReadService,
NotificationService, NotificationService,
@ -783,6 +788,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$NoteCreateService, $NoteCreateService,
$NoteEditService, $NoteEditService,
$NoteDeleteService, $NoteDeleteService,
$LatestNoteService,
$NotePiningService, $NotePiningService,
$NoteReadService, $NoteReadService,
$NotificationService, $NotificationService,

View file

@ -6,7 +6,6 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as stream from 'node:stream/promises'; import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ipaddr from 'ipaddr.js';
import chalk from 'chalk'; import chalk from 'chalk';
import got, * as Got from 'got'; import got, * as Got from 'got';
import { parse } from 'content-disposition'; import { parse } from 'content-disposition';
@ -70,13 +69,6 @@ export class DownloadService {
}, },
enableUnixSockets: false, enableUnixSockets: false,
}).on('response', (res: Got.Response) => { }).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']; const contentLength = res.headers['content-length'];
if (contentLength != null) { if (contentLength != null) {
const size = Number(contentLength); const size = Number(contentLength);
@ -139,18 +131,4 @@ export class DownloadService {
cleanup(); 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

@ -227,25 +227,33 @@ export class DriveService {
const thumbnailAccessKey = 'thumbnail-' + randomUUID(); const thumbnailAccessKey = 'thumbnail-' + randomUUID();
const webpublicAccessKey = 'webpublic-' + randomUUID(); const webpublicAccessKey = 'webpublic-' + randomUUID();
const url = this.internalStorageService.saveFromPath(accessKey, path); // Ugly type is just to help TS figure out that 2nd / 3rd promises are optional.
const promises: [Promise<string>, ...(Promise<string> | undefined)[]] = [
let thumbnailUrl: string | null = null; this.internalStorageService.saveFromPath(accessKey, path),
let webpublicUrl: string | null = null; ];
if (alts.thumbnail) { if (alts.thumbnail) {
thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); promises.push(this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data));
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
} }
if (alts.webpublic) { if (alts.webpublic) {
webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); promises.push(this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data));
}
const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises);
if (thumbnailUrl) {
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
}
if (webpublicUrl) {
this.registerLogger.info(`web stored: ${webpublicAccessKey}`); this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
} }
file.storedInternal = true; file.storedInternal = true;
file.url = url; file.url = url;
file.thumbnailUrl = thumbnailUrl; file.thumbnailUrl = thumbnailUrl ?? null;
file.webpublicUrl = webpublicUrl; file.webpublicUrl = webpublicUrl ?? null;
file.accessKey = accessKey; file.accessKey = accessKey;
file.thumbnailAccessKey = thumbnailAccessKey; file.thumbnailAccessKey = thumbnailAccessKey;
file.webpublicAccessKey = webpublicAccessKey; file.webpublicAccessKey = webpublicAccessKey;
@ -560,7 +568,7 @@ export class DriveService {
file.maybeSensitive = info.sensitive; file.maybeSensitive = info.sensitive;
file.maybePorn = info.porn; file.maybePorn = info.porn;
file.isSensitive = user file.isSensitive = user
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : ? this.userEntityService.isLocalUser(user) && (profile!.alwaysMarkNsfw || profile!.defaultSensitive) ? true :
sensitive ?? false sensitive ?? false
: false; : false;
@ -720,19 +728,19 @@ export class DriveService {
@bindThis @bindThis
public async deleteFileSync(file: MiDriveFile, isExpired = false, deleter?: MiUser) { public async deleteFileSync(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
const promises = [];
if (file.storedInternal) { if (file.storedInternal) {
this.internalStorageService.del(file.accessKey!); promises.push(this.internalStorageService.del(file.accessKey!));
if (file.thumbnailUrl) { if (file.thumbnailUrl) {
this.internalStorageService.del(file.thumbnailAccessKey!); promises.push(this.internalStorageService.del(file.thumbnailAccessKey!));
} }
if (file.webpublicUrl) { if (file.webpublicUrl) {
this.internalStorageService.del(file.webpublicAccessKey!); promises.push(this.internalStorageService.del(file.webpublicAccessKey!));
} }
} else if (!file.isLink) { } else if (!file.isLink) {
const promises = [];
promises.push(this.deleteObjectStorageFile(file.accessKey!)); promises.push(this.deleteObjectStorageFile(file.accessKey!));
if (file.thumbnailUrl) { if (file.thumbnailUrl) {
@ -742,10 +750,10 @@ export class DriveService {
if (file.webpublicUrl) { if (file.webpublicUrl) {
promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!)); promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!));
} }
await Promise.all(promises);
} }
await Promise.all(promises);
this.deletePostProcess(file, isExpired, deleter); this.deletePostProcess(file, isExpired, deleter);
} }

View file

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

View file

@ -12,6 +12,8 @@ import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { QueryFailedError } from 'typeorm';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable() @Injectable()
export class FederatedInstanceService implements OnApplicationShutdown { export class FederatedInstanceService implements OnApplicationShutdown {
@ -56,11 +58,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
const index = await this.instancesRepository.findOneBy({ host }); const index = await this.instancesRepository.findOneBy({ host });
if (index == null) { if (index == null) {
const i = await this.instancesRepository.insertOne({ let i;
id: this.idService.gen(), try {
host, i = await this.instancesRepository.insertOne({
firstRetrievedAt: new Date(), id: this.idService.gen(),
}); host,
firstRetrievedAt: new Date(),
});
} catch (e: unknown) {
if (e instanceof QueryFailedError) {
if (isDuplicateKeyValueError(e)) {
i = await this.instancesRepository.findOneBy({ host });
}
}
if (i == null) {
throw e;
}
}
this.federatedInstanceCache.set(host, i); this.federatedInstanceCache.set(host, i);
return i; return i;

View file

@ -6,6 +6,7 @@
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import * as net from 'node:net'; import * as net from 'node:net';
import ipaddr from 'ipaddr.js';
import CacheableLookup from 'cacheable-lookup'; import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
@ -25,8 +26,102 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[]; 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() @Injectable()
export class HttpRequestService { 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 * Get http non-proxy agent
*/ */
@ -57,19 +152,20 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない lookup: false, // nativeのdns.lookupにfallbackしない
}); });
this.http = new http.Agent({ const agentOption = {
keepAlive: true, keepAlive: true,
keepAliveMsecs: 30 * 1000, keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction, lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress, localAddress: config.outgoingAddress,
}); };
this.https = new https.Agent({ this.httpNative = new http.Agent(agentOption);
keepAlive: true,
keepAliveMsecs: 30 * 1000, this.httpsNative = new https.Agent(agentOption);
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress, this.http = new HttpRequestServiceAgent(config, agentOption);
});
this.https = new HttpsRequestServiceAgent(config, agentOption);
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
@ -104,16 +200,22 @@ export class HttpRequestService {
* @param bypassProxy Allways bypass proxy * @param bypassProxy Allways bypass proxy
*/ */
@bindThis @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 (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; return url.protocol === 'http:' ? this.http : this.https;
} else { } else {
if (isLocalAddressAllowed && (!this.config.proxy)) {
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
}
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
} }
} }
@bindThis @bindThis
public async getActivityJson(url: string): Promise<IObject> { public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -121,6 +223,7 @@ export class HttpRequestService {
}, },
timeout: 5000, timeout: 5000,
size: 1024 * 256, size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}, { }, {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub], validators: [validateContentTypeSetAsActivityPub],
@ -129,13 +232,13 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]); assertActivityMatchesUrls(activity, [finalUrl]);
return activity; return activity;
} }
@bindThis @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, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: Object.assign({ headers: Object.assign({
@ -143,19 +246,21 @@ export class HttpRequestService {
}, headers ?? {}), }, headers ?? {}),
timeout: 5000, timeout: 5000,
size: 1024 * 256, size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}); });
return await res.json() as T; return await res.json() as T;
} }
@bindThis @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, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: Object.assign({ headers: Object.assign({
Accept: accept, Accept: accept,
}, headers ?? {}), }, headers ?? {}),
timeout: 5000, timeout: 5000,
isLocalAddressAllowed: isLocalAddressAllowed,
}); });
return await res.text(); return await res.text();
@ -170,6 +275,7 @@ export class HttpRequestService {
headers?: Record<string, string>, headers?: Record<string, string>,
timeout?: number, timeout?: number,
size?: number, size?: number,
isLocalAddressAllowed?: boolean,
} = {}, } = {},
extra: HttpRequestSendOptions = { extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
@ -183,6 +289,8 @@ export class HttpRequestService {
controller.abort(); controller.abort();
}, timeout); }, timeout);
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
const res = await fetch(url, { const res = await fetch(url, {
method: args.method ?? 'GET', method: args.method ?? 'GET',
headers: { headers: {
@ -191,7 +299,7 @@ export class HttpRequestService {
}, },
body: args.body, body: args.body,
size: args.size ?? 10 * 1024 * 1024, size: args.size ?? 10 * 1024 * 1024,
agent: (url) => this.getAgentByUrl(url), agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
signal: controller.signal, signal: controller.signal,
}); });

View file

@ -4,6 +4,7 @@
*/ */
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises';
import * as Path from 'node:path'; import * as Path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
@ -23,6 +24,8 @@ export class InternalStorageService {
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
) { ) {
// No one should erase the working directory *while the server is running*.
fs.mkdirSync(path, { recursive: true });
} }
@bindThis @bindThis
@ -36,21 +39,27 @@ export class InternalStorageService {
} }
@bindThis @bindThis
public saveFromPath(key: string, srcPath: string) { public async saveFromPath(key: string, srcPath: string): Promise<string> {
fs.mkdirSync(path, { recursive: true }); await copyFile(srcPath, this.resolvePath(key));
fs.copyFileSync(srcPath, this.resolvePath(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}`; return `${this.config.url}/files/${key}`;
} }
@bindThis @bindThis
public saveFromBuffer(key: string, data: Buffer) { public async del(key: string): Promise<void> {
fs.mkdirSync(path, { recursive: true }); await unlink(this.resolvePath(key));
fs.writeFileSync(this.resolvePath(key), data);
return `${this.config.url}/files/${key}`;
}
@bindThis
public del(key: string) {
fs.unlink(this.resolvePath(key), () => {});
} }
} }

View file

@ -0,0 +1,139 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not } from 'typeorm';
import { MiNote } from '@/models/Note.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { SkLatestNote } from '@/models/LatestNote.js';
import { DI } from '@/di-symbols.js';
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
@Injectable()
export class LatestNoteService {
private readonly logger: Logger;
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('LatestNoteService');
}
handleUpdatedNoteBG(before: MiNote, after: MiNote): void {
this
.handleUpdatedNote(before, after)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
}
async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> {
// If the key didn't change, then there's nothing to update
if (SkLatestNote.areEquivalent(before, after)) return;
// Simulate update as delete + create
await this.handleDeletedNote(before);
await this.handleCreatedNote(after);
}
handleCreatedNoteBG(note: MiNote): void {
this
.handleCreatedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
}
async handleCreatedNote(note: MiNote): Promise<void> {
// Ignore DMs.
// Followers-only posts are *included*, as this table is used to back the "following" feed.
if (note.visibility === 'specified') return;
// Ignore pure renotes
if (isPureRenote(note)) return;
// Compute the compound key of the entry to check
const key = SkLatestNote.keyFor(note);
// Make sure that this isn't an *older* post.
// We can get older posts through replies, lookups, updates, etc.
const currentLatest = await this.latestNotesRepository.findOneBy(key);
if (currentLatest != null && currentLatest.noteId >= note.id) return;
// Record this as the latest note for the given user
const latestNote = new SkLatestNote({
...key,
noteId: note.id,
});
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
}
handleDeletedNoteBG(note: MiNote): void {
this
.handleDeletedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
}
async handleDeletedNote(note: MiNote): Promise<void> {
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
if (note.visibility === 'specified') return;
// If it's a pure renote, then it can't possibly be the latest note so we can safely skip this.
if (isPureRenote(note)) return;
// Compute the compound key of the entry to check
const key = SkLatestNote.keyFor(note);
// Check if the deleted note was possibly the latest for the user
const existingLatest = await this.latestNotesRepository.findOneBy(key);
if (existingLatest == null || existingLatest.noteId !== note.id) return;
// Find the newest remaining note for the user.
// We exclude DMs and pure renotes.
const nextLatest = await this.notesRepository
.createQueryBuilder('note')
.select()
.where({
userId: key.userId,
visibility: key.isPublic
? 'public'
: Not('specified'),
replyId: key.isReply
? Not(null)
: null,
renoteId: key.isQuote
? Not(null)
: null,
})
.andWhere(`
(
note."renoteId" IS NULL
OR note.text IS NOT NULL
OR note.cw IS NOT NULL
OR note."replyId" IS NOT NULL
OR note."hasPoll"
OR note."fileIds" != '{}'
)
`)
.orderBy({ id: 'DESC' })
.getOne();
if (!nextLatest) return;
// Record it as the latest
const latestNote = new SkLatestNote({
...key,
noteId: nextLatest.id,
});
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
await this.latestNotesRepository
.createQueryBuilder('latest')
.insert()
.into(SkLatestNote)
.values(latestNote)
.orIgnore()
.execute();
}
}

View file

@ -412,8 +412,10 @@ export class MfmService {
mention: (node) => { mention: (node) => {
const a = doc.createElement('a'); const a = doc.createElement('a');
const { username, host, acct } = node.props; const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`); a.setAttribute('href', remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
a.className = 'u-url mention'; a.className = 'u-url mention';
a.textContent = acct; a.textContent = acct;
return a; return a;

View file

@ -13,8 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import { LatestNote } from '@/models/LatestNote.js'; import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js'; import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js'; import { concat } from '@/misc/prelude/array.js';
@ -46,7 +45,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { NoteReadService } from '@/core/NoteReadService.js'; import { NoteReadService } from '@/core/NoteReadService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
@ -58,7 +56,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -148,6 +146,8 @@ type Option = {
app?: MiApp | null; app?: MiApp | null;
}; };
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@Injectable() @Injectable()
export class NoteCreateService implements OnApplicationShutdown { export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
@ -172,9 +172,6 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@ -225,6 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private cacheService: CacheService, private cacheService: CacheService,
private latestNoteService: LatestNoteService,
) { ) {
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
} }
@ -338,9 +336,13 @@ export class NoteCreateService implements OnApplicationShutdown {
data.localOnly = true; data.localOnly = true;
} }
const maxTextLength = user.host == null
? this.config.maxNoteLength
: this.config.maxRemoteNoteLength;
if (data.text) { if (data.text) {
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { if (data.text.length > maxTextLength) {
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); data.text = data.text.slice(0, maxTextLength);
} }
data.text = data.text.trim(); data.text = data.text.trim();
if (data.text === '') { if (data.text === '') {
@ -350,9 +352,13 @@ export class NoteCreateService implements OnApplicationShutdown {
data.text = null; data.text = null;
} }
const maxCwLength = user.host == null
? this.config.maxCwLength
: this.config.maxRemoteCwLength;
if (data.cw) { if (data.cw) {
if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { if (data.cw.length > maxCwLength) {
data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); data.cw = data.cw.slice(0, maxCwLength);
} }
data.cw = data.cw.trim(); data.cw = data.cw.trim();
if (data.cw === '') { if (data.cw === '') {
@ -408,7 +414,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (user.host && !data.cw) { if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => { 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'; data.cw = 'Instance is marked as NSFW';
} }
}); });
@ -530,8 +536,6 @@ export class NoteCreateService implements OnApplicationShutdown {
await this.notesRepository.insert(insert); await this.notesRepository.insert(insert);
} }
await this.updateLatestNote(insert);
return insert; return insert;
} catch (e) { } catch (e) {
// duplicate key error // duplicate key error
@ -625,6 +629,7 @@ export class NoteCreateService implements OnApplicationShutdown {
this.queueService.endedPollNotificationQueue.add(note.id, { this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id, noteId: note.id,
}, { }, {
jobId: `pollEnd:${note.id}`,
delay, delay,
removeOnComplete: true, removeOnComplete: true,
}); });
@ -812,10 +817,18 @@ export class NoteCreateService implements OnApplicationShutdown {
}); });
} }
// Update the Latest Note index / following feed
this.latestNoteService.handleCreatedNoteBG(note);
// Register to search database // Register to search database
if (!user.noindex) this.index(note); if (!user.noindex) this.index(note);
} }
@bindThis
public isPureRenote(note: Option): note is PureRenoteOption {
return this.isRenote(note) && !this.isQuote(note);
}
@bindThis @bindThis
private isRenote(note: Option): note is Option & { renote: MiNote } { private isRenote(note: Option): note is Option & { renote: MiNote } {
return note.renote != null; return note.renote != null;
@ -1151,25 +1164,4 @@ export class NoteCreateService implements OnApplicationShutdown {
public async onApplicationShutdown(signal?: string | undefined): Promise<void> { public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
await this.dispose(); await this.dispose();
} }
private async updateLatestNote(note: MiNote) {
// Ignore DMs.
// Followers-only posts are *included*, as this table is used to back the "following" feed.
if (note.visibility === 'specified') return;
// Ignore pure renotes
if (isRenote(note) && !isQuote(note)) return;
// Make sure that this isn't an *older* post.
// We can get older posts through replies, lookups, etc.
const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId });
if (currentLatest != null && currentLatest.noteId >= note.id) return;
// Record this as the latest note for the given user
const latestNote = new LatestNote({
userId: note.userId,
noteId: note.id,
});
await this.latestNotesRepository.upsert(latestNote, ['userId']);
}
} }

View file

@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Brackets, In, Not } from 'typeorm'; import { Brackets, In } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import { LatestNote } from '@/models/LatestNote.js'; import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import type { InstancesRepository, MiMeta, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -24,6 +23,7 @@ import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
@Injectable() @Injectable()
export class NoteDeleteService { export class NoteDeleteService {
@ -40,9 +40,6 @@ export class NoteDeleteService {
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
@ -57,6 +54,7 @@ export class NoteDeleteService {
private notesChart: NotesChart, private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart, private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
private latestNoteService: LatestNoteService,
) {} ) {}
/** /**
@ -149,7 +147,7 @@ export class NoteDeleteService {
userId: user.id, userId: user.id,
}); });
await this.updateLatestNote(note); this.latestNoteService.handleDeletedNoteBG(note);
if (deleter && (note.userId !== deleter.id)) { if (deleter && (note.userId !== deleter.id)) {
const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
@ -232,52 +230,4 @@ export class NoteDeleteService {
this.apDeliverManagerService.deliverToUser(user, content, remoteUser); this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
} }
} }
private async updateLatestNote(note: MiNote) {
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
if (note.visibility === 'specified') return;
// Check if the deleted note was possibly the latest for the user
const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId });
if (hasLatestNote) return;
// Find the newest remaining note for the user.
// We exclude DMs and pure renotes.
const nextLatest = await this.notesRepository
.createQueryBuilder('note')
.select()
.where({
userId: note.userId,
visibility: Not('specified'),
})
.andWhere(`
(
note."renoteId" IS NULL
OR note.text IS NOT NULL
OR note.cw IS NOT NULL
OR note."replyId" IS NOT NULL
OR note."hasPoll"
OR note."fileIds" != '{}'
)
`)
.orderBy({ id: 'DESC' })
.getOne();
if (!nextLatest) return;
// Record it as the latest
const latestNote = new LatestNote({
userId: note.userId,
noteId: nextLatest.id,
});
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
await this.latestNotesRepository
.createQueryBuilder('latest')
.insert()
.into(LatestNote)
.values(latestNote)
.orIgnore()
.execute();
}
} }

View file

@ -39,7 +39,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { NoteReadService } from '@/core/NoteReadService.js'; import { NoteReadService } from '@/core/NoteReadService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { SearchService } from '@/core/SearchService.js'; import { SearchService } from '@/core/SearchService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
@ -50,6 +49,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
@ -217,6 +217,7 @@ export class NoteEditService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private cacheService: CacheService, private cacheService: CacheService,
private latestNoteService: LatestNoteService,
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
) { ) {
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
@ -363,9 +364,13 @@ export class NoteEditService implements OnApplicationShutdown {
data.localOnly = true; data.localOnly = true;
} }
const maxTextLength = user.host == null
? this.config.maxNoteLength
: this.config.maxRemoteNoteLength;
if (data.text) { if (data.text) {
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { if (data.text.length > maxTextLength) {
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); data.text = data.text.slice(0, maxTextLength);
} }
data.text = data.text.trim(); data.text = data.text.trim();
if (data.text === '') { if (data.text === '') {
@ -375,9 +380,13 @@ export class NoteEditService implements OnApplicationShutdown {
data.text = null; data.text = null;
} }
const maxCwLength = user.host == null
? this.config.maxCwLength
: this.config.maxRemoteCwLength;
if (data.cw) { if (data.cw) {
if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) { if (data.cw.length > maxCwLength) {
data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH); data.cw = data.cw.slice(0, maxCwLength);
} }
data.cw = data.cw.trim(); data.cw = data.cw.trim();
if (data.cw === '') { if (data.cw === '') {
@ -433,7 +442,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (user.host && !data.cw) { if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => { 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'; data.cw = 'Instance is marked as NSFW';
} }
}); });
@ -462,8 +471,9 @@ export class NoteEditService implements OnApplicationShutdown {
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; 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 }); const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
await this.noteEditRepository.insert({ await this.noteEditRepository.insert({
@ -535,7 +545,7 @@ export class NoteEditService implements OnApplicationShutdown {
})); }));
} }
if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) { if (pollChanged) {
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.update(MiNote, oldnote.id, note); await transactionalEntityManager.update(MiNote, oldnote.id, note);
@ -563,7 +573,7 @@ export class NoteEditService implements OnApplicationShutdown {
} }
setImmediate('post edited', { signal: this.#shutdownController.signal }).then( setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
() => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!), () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ }, () => { /* aborted, ignore this */ },
); );
@ -574,7 +584,7 @@ export class NoteEditService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async postNoteEdited(note: MiNote, user: { private async postNoteEdited(note: MiNote, oldNote: MiNote, user: {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -596,10 +606,11 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.poll && data.poll.expiresAt) { if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now(); 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, { this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id, noteId: note.id,
}, { }, {
jobId: `pollEnd:${note.id}`,
delay, delay,
removeOnComplete: true, removeOnComplete: true,
}); });
@ -771,6 +782,9 @@ export class NoteEditService implements OnApplicationShutdown {
}); });
} }
// Update the Latest Note index / following feed
this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
// Register to search database // Register to search database
if (!user.noindex) this.index(note); if (!user.noindex) this.index(note);
} }

View file

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

View file

@ -55,6 +55,7 @@ export class SignupService {
host?: string | null; host?: string | null;
reason?: string | null; reason?: string | null;
ignorePreservedUsernames?: boolean; ignorePreservedUsernames?: boolean;
approved?: boolean;
}) { }) {
const { username, password, passwordHash, host, reason } = opts; const { username, password, passwordHash, host, reason } = opts;
let hash = passwordHash; let hash = passwordHash;
@ -115,9 +116,6 @@ export class SignupService {
)); ));
let account!: MiUser; let account!: MiUser;
let defaultApproval = false;
if (!this.meta.approvalRequiredForSignup) defaultApproval = true;
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
@ -135,7 +133,7 @@ export class SignupService {
host: this.utilityService.toPunyNullable(host), host: this.utilityService.toPunyNullable(host),
token: secret, token: secret,
isRoot: isTheFirstUser, isRoot: isTheFirstUser,
approved: defaultApproval, approved: isTheFirstUser || (opts.approved ?? !this.meta.approvalRequiredForSignup),
signupReason: reason, signupReason: reason,
})); }));

View file

@ -4,9 +4,10 @@
*/ */
import { URL } from 'node:url'; import { URL } from 'node:url';
import { toASCII } from 'punycode'; import punycode from 'punycode/punycode.js';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2'; import RE2 from 're2';
import psl from 'psl';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -34,6 +35,11 @@ export class UtilityService {
return this.toPuny(this.config.host) === this.toPuny(host); 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 @bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean { public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
if (host == null) return false; if (host == null) return false;
@ -101,13 +107,13 @@ export class UtilityService {
@bindThis @bindThis
public toPuny(host: string): string { public toPuny(host: string): string {
return toASCII(host.toLowerCase()); return punycode.toASCII(host.toLowerCase());
} }
@bindThis @bindThis
public toPunyNullable(host: string | null | undefined): string | null { public toPunyNullable(host: string | null | undefined): string | null {
if (host == null) return null; if (host == null) return null;
return toASCII(host.toLowerCase()); return punycode.toASCII(host.toLowerCase());
} }
@bindThis @bindThis
@ -117,6 +123,26 @@ export class UtilityService {
return host; 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 { public isFederationAllowedHost(host: string): boolean {
if (this.meta.federation === 'none') return false; if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false; if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;

View file

@ -254,7 +254,7 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed
isRenoteMuted: false, isRenoteMuted: false,
notify: 'none', notify: 'none',
withReplies: true, withReplies: true,
ListenBrainz: null, listenbrainz: null,
...override, ...override,
}; };
} }

View file

@ -10,12 +10,14 @@ import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { getApId } from './type.js'; import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import type { IObject } from './type.js'; import type { IObject } from './type.js';
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
export type UriParseResult = { export type UriParseResult = {
/** wether the URI was generated by us */ /** wether the URI was generated by us */
@ -53,17 +55,22 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
} }
@bindThis @bindThis
public parseUri(value: string | IObject): UriParseResult { public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
const separator = '/'; const separator = '/';
const uri = new URL(getApId(value)); const apId = getApId(value);
if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; 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); const [, type, id, ...rest] = uri.pathname.split(separator);
return { return {
@ -78,7 +85,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
* AP Note => Misskey Note in DB * AP Note => Misskey Note in DB
*/ */
@bindThis @bindThis
public async getNoteFromApId(value: string | IObject): Promise<MiNote | null> { public async getNoteFromApId(value: string | IObject | [string | IObject]): Promise<MiNote | null> {
const parsed = this.parseUri(value); const parsed = this.parseUri(value);
if (parsed.local) { if (parsed.local) {
@ -98,7 +105,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
* AP Person => Misskey User in DB * AP Person => Misskey User in DB
*/ */
@bindThis @bindThis
public async getUserFromApId(value: string | IObject): Promise<MiLocalUser | MiRemoteUser | null> { public async getUserFromApId(value: string | IObject | [string | IObject]): Promise<MiLocalUser | MiRemoteUser | null> {
const parsed = this.parseUri(value); const parsed = this.parseUri(value);
if (parsed.local) { if (parsed.local) {
@ -174,10 +181,16 @@ export class ApDbResolverService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> { public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> {
this.apLoggerService.logger.debug('Re-fetching public key for user', { userId: user.id, uri: user.uri });
await this.apPersonService.updatePerson(user.uri); await this.apPersonService.updatePerson(user.uri);
const key = await this.userPublickeysRepository.findOneBy({ userId: user.id }); const key = await this.userPublickeysRepository.findOneBy({ userId: user.id });
if (key != null) { this.publicKeyByUserIdCache.set(user.id, key);
await this.publicKeyByUserIdCache.set(user.id, key);
if (key) {
this.apLoggerService.logger.info('Re-fetched public key for user', { userId: user.id, uri: user.uri });
} else {
this.apLoggerService.logger.warn('Failed to re-fetch key for user', { userId: user.id, uri: user.uri });
} }
return key; return key;
} }

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm'; import { IsNull, Not } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
@ -128,7 +129,7 @@ class DeliverManager {
for (const following of followers) { for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox; 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); 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 { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.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 { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js'; import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
@ -39,7 +41,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js'; import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.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 type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
@Injectable() @Injectable()
export class ApInboxService { export class ApInboxService {
@ -92,15 +94,26 @@ export class ApInboxService {
} }
@bindThis @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; let result = undefined as string | void;
if (isCollectionOrOrderedCollection(activity)) { if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][]; const results = [] as [string, string | void][];
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { 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); 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 { try {
results.push([getApId(item), await this.performOneActivity(actor, act)]); results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
} catch (err) { } catch (err) {
if (err instanceof Error || typeof err === 'string') { if (err instanceof Error || typeof err === 'string') {
this.logger.error(err); this.logger.error(err);
@ -115,7 +128,7 @@ export class ApInboxService {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
} }
} else { } else {
result = await this.performOneActivity(actor, activity); result = await this.performOneActivity(actor, activity, resolver);
} }
// ついでにリモートユーザーの情報が古かったら更新しておく // ついでにリモートユーザーの情報が古かったら更新しておく
@ -130,37 +143,39 @@ export class ApInboxService {
} }
@bindThis @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 (actor.isSuspended) return;
if (isCreate(activity)) { if (isCreate(activity)) {
return await this.create(actor, activity); return await this.create(actor, activity, resolver);
} else if (isDelete(activity)) { } else if (isDelete(activity)) {
return await this.delete(actor, activity); return await this.delete(actor, activity);
} else if (isUpdate(activity)) { } else if (isUpdate(activity)) {
return await this.update(actor, activity); return await this.update(actor, activity, resolver);
} else if (isFollow(activity)) { } else if (isFollow(activity)) {
return await this.follow(actor, activity); return await this.follow(actor, activity);
} else if (isAccept(activity)) { } else if (isAccept(activity)) {
return await this.accept(actor, activity); return await this.accept(actor, activity, resolver);
} else if (isReject(activity)) { } else if (isReject(activity)) {
return await this.reject(actor, activity); return await this.reject(actor, activity, resolver);
} else if (isAdd(activity)) { } else if (isAdd(activity)) {
return await this.add(actor, activity); return await this.add(actor, activity, resolver);
} else if (isRemove(activity)) { } else if (isRemove(activity)) {
return await this.remove(actor, activity); return await this.remove(actor, activity, resolver);
} else if (isAnnounce(activity)) { } else if (isAnnounce(activity)) {
return await this.announce(actor, activity); return await this.announce(actor, activity, resolver);
} else if (isLike(activity)) { } 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)) { } else if (isUndo(activity)) {
return await this.undo(actor, activity); return await this.undo(actor, activity, resolver);
} else if (isBlock(activity)) { } else if (isBlock(activity)) {
return await this.block(actor, activity); return await this.block(actor, activity);
} else if (isFlag(activity)) { } else if (isFlag(activity)) {
return await this.flag(actor, activity); return await this.flag(actor, activity);
} else if (isMove(activity)) { } else if (isMove(activity)) {
return await this.move(actor, activity); return await this.move(actor, activity, resolver);
} else { } else {
return `unrecognized activity type: ${activity.type}`; return `unrecognized activity type: ${activity.type}`;
} }
@ -184,30 +199,42 @@ export class ApInboxService {
} }
@bindThis @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 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}`; if (!note) return `skip: target note not found ${targetUri}`;
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); 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 => { try {
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { 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'; return 'skip: already reacted';
} else { } else {
throw err; throw err;
} }
}).then(() => 'ok'); }
} }
@bindThis @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; const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`); 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 => { const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`); this.logger.error(`Resolution failed: ${err}`);
@ -244,7 +271,7 @@ export class ApInboxService {
} }
@bindThis @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) { if (actor.uri !== activity.actor) {
return 'invalid actor'; return 'invalid actor';
} }
@ -254,7 +281,12 @@ export class ApInboxService {
} }
if (activity.target === actor.featured) { if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.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'; if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id); await this.notePiningService.addPinned(actor, note.id);
return; return;
@ -264,20 +296,22 @@ export class ApInboxService {
} }
@bindThis @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); const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`); this.logger.info(`Announce: ${uri}`);
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property'; const activityObject = fromTuple(activity.object);
const targetUri = getApId(activity.object); if (!activityObject) return 'skip: activity has no object property';
const targetUri = getApId(activityObject);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
const target = await resolver.resolve(activity.object).catch(e => { const target = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`); this.logger.error(`Resolution failed: ${e}`);
return e; throw e;
}); });
if (isPost(target)) return await this.announceNote(actor, activity, target); if (isPost(target)) return await this.announceNote(actor, activity, target);
@ -286,7 +320,7 @@ export class ApInboxService {
} }
@bindThis @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); const uri = getApId(activity);
if (actor.isSuspended) { if (actor.isSuspended) {
@ -308,13 +342,13 @@ export class ApInboxService {
// Announce対象をresolve // Announce対象をresolve
let renote; let renote;
try { try {
renote = await this.apNoteService.resolveNote(target); renote = await this.apNoteService.resolveNote(target, { resolver });
if (renote == null) return 'announce target is null'; if (renote == null) return 'announce target is null';
} catch (err) { } catch (err) {
// 対象が4xxならスキップ // 対象が4xxならスキップ
if (err instanceof StatusError) { if (err instanceof StatusError) {
if (!err.isRetryable) { 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}`; return `Error in announce target ${target.id} - ${err.statusCode}`;
} }
@ -327,7 +361,7 @@ export class ApInboxService {
this.logger.info(`Creating the (Re)Note: ${uri}`); 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; const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) { if (createdAt && createdAt < this.idService.parse(renote.id).date) {
@ -365,47 +399,49 @@ export class ApInboxService {
} }
@bindThis @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); const uri = getApId(activity);
this.logger.info(`Create: ${uri}`); this.logger.info(`Create: ${uri}`);
if (!activity.object) return 'skip: activity has no object property'; const activityObject = fromTuple(activity.object);
const targetUri = getApId(activity.object); if (!activityObject) return 'skip: activity has no object property';
const targetUri = getApId(activityObject);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
// copy audiences between activity <=> object. // copy audiences between activity <=> object.
if (typeof activity.object === 'object') { if (typeof activityObject === 'object') {
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); const to = unique(concat([toArray(activity.to), toArray(activityObject.to)]));
const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); const cc = unique(concat([toArray(activity.cc), toArray(activityObject.cc)]));
activity.to = to; activity.to = to;
activity.cc = cc; activity.cc = cc;
activity.object.to = to; activityObject.to = to;
activity.object.cc = cc; activityObject.cc = cc;
} }
// If there is no attributedTo, use Activity actor. // If there is no attributedTo, use Activity actor.
if (typeof activity.object === 'object' && !activity.object.attributedTo) { if (typeof activityObject === 'object' && !activityObject.attributedTo) {
activity.object.attributedTo = activity.actor; 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(activity.object).catch(e => { const object = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`); this.logger.error(`Resolution failed: ${e}`);
throw e; throw e;
}); });
if (isPost(object)) { if (isPost(object)) {
await this.createNote(resolver, actor, object, false, activity); await this.createNote(resolver, actor, object, false);
} else { } else {
return `Unknown type: ${getApType(object)}`; return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`;
} }
} }
@bindThis @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); const uri = getApId(note);
if (typeof note === 'object') { if (typeof note === 'object') {
@ -417,6 +453,8 @@ export class ApInboxService {
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
return 'skip: host in actor.uri !== note.id'; return 'skip: host in actor.uri !== note.id';
} }
} else {
return 'skip: note.id is not a string';
} }
} }
@ -426,11 +464,11 @@ export class ApInboxService {
const exist = await this.apNoteService.fetchNote(note); const exist = await this.apNoteService.fetchNote(note);
if (exist) return 'skip: note exists'; if (exist) return 'skip: note exists';
await this.apNoteService.createNote(note, resolver, silent); await this.apNoteService.createNote(note, actor, resolver, silent);
return 'ok'; return 'ok';
} catch (err) { } catch (err) {
if (err instanceof StatusError && !err.isRetryable) { if (err instanceof StatusError && !err.isRetryable) {
return `skip ${err.statusCode}`; return `skip: ${err.statusCode}`;
} else { } else {
throw err; throw err;
} }
@ -448,15 +486,15 @@ export class ApInboxService {
// 削除対象objectのtype // 削除対象objectのtype
let formerType: string | undefined; let formerType: string | undefined;
if (typeof activity.object === 'string') { const activityObject = fromTuple(activity.object);
if (typeof activityObject === 'string') {
// typeが不明だけど、どうせ消えてるのでremote resolveしない // typeが不明だけど、どうせ消えてるのでremote resolveしない
formerType = undefined; formerType = undefined;
} else { } else {
const object = activity.object; if (isTombstone(activityObject)) {
if (isTombstone(object)) { formerType = toSingle(activityObject.formerType);
formerType = toSingle(object.formerType);
} else { } else {
formerType = toSingle(object.type); formerType = toSingle(activityObject.type);
} }
} }
@ -517,7 +555,7 @@ export class ApInboxService {
const note = await this.apDbResolverService.getNoteFromApId(uri); const note = await this.apDbResolverService.getNoteFromApId(uri);
if (note == null) { if (note == null) {
return 'message not found'; return 'skip: ignoring deleted note on both ends';
} }
if (note.userId !== actor.id) { if (note.userId !== actor.id) {
@ -564,12 +602,13 @@ export class ApInboxService {
} }
@bindThis @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; const uri = activity.id ?? activity;
this.logger.info(`Reject: ${uri}`); 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 => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`); this.logger.error(`Resolution failed: ${e}`);
@ -606,7 +645,7 @@ export class ApInboxService {
} }
@bindThis @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) { if (actor.uri !== activity.actor) {
return 'invalid actor'; return 'invalid actor';
} }
@ -616,7 +655,12 @@ export class ApInboxService {
} }
if (activity.target === actor.featured) { if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.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'; if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id); await this.notePiningService.removePinned(actor, note.id);
return; return;
@ -626,7 +670,7 @@ export class ApInboxService {
} }
@bindThis @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) { if (actor.uri !== activity.actor) {
return 'invalid actor'; return 'invalid actor';
} }
@ -635,11 +679,12 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`); 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 => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`); this.logger.error(`Resolution failed: ${e}`);
return e; throw e;
}); });
// don't queue because the sender may attempt again when timeout // don't queue because the sender may attempt again when timeout
@ -649,7 +694,7 @@ export class ApInboxService {
if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
if (isAccept(object)) return await this.undoAccept(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 @bindThis
@ -744,7 +789,7 @@ export class ApInboxService {
} }
@bindThis @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 targetUri = getApId(activity.object);
const note = await this.apNoteService.fetchNote(targetUri); const note = await this.apNoteService.fetchNote(targetUri);
@ -759,14 +804,15 @@ export class ApInboxService {
} }
@bindThis @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) { if (actor.uri !== activity.actor) {
return 'skip: invalid actor'; return 'skip: invalid actor';
} }
this.logger.debug('Update'); 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 => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`); this.logger.error(`Resolution failed: ${e}`);
@ -777,22 +823,32 @@ export class ApInboxService {
await this.apPersonService.updatePerson(actor.uri, resolver, object); await this.apPersonService.updatePerson(actor.uri, resolver, object);
return 'ok: Person updated'; return 'ok: Person updated';
} else if (getApType(object) === 'Question') { } 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'; return 'ok: Question updated';
} else if (getApType(object) === 'Note') { } else if (isPost(object)) {
await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err)); // 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'; return 'ok: Note updated';
} else { } else {
return `skip: Unknown type: ${getApType(object)}`; return `skip: Unsupported type for Update: ${getApType(object)} ${getNullableApId(object)}`;
} }
} }
@bindThis @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 // fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target); const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid 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 { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import * as mfm from '@transfem-org/sfm-js'; import * as mfm from '@transfem-org/sfm-js';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.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 { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.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'; 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() @Injectable()
@ -106,7 +108,7 @@ export class ApRendererService {
to = [`${attributedTo}/followers`]; to = [`${attributedTo}/followers`];
cc = []; cc = [];
} else { } else {
throw new Error('renderAnnounce: cannot render non-public note'); throw new UnrecoverableError(`renderAnnounce: cannot render non-public note: ${getApId(object)}`);
} }
return { return {
@ -200,7 +202,8 @@ export class ApRendererService {
type: 'Flag', type: 'Flag',
actor: this.userEntityService.genLocalUserUri(user.id), actor: this.userEntityService.genLocalUserUri(user.id),
content, content,
object, // This MUST be an array for Pleroma compatibility: https://activitypub.software/TransFem-org/Sharkey/-/issues/641#note_7301
object: [object],
}; };
} }
@ -419,7 +422,7 @@ export class ApRendererService {
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend); const { content } = this.apMfmService.getNoteHtml(note, apAppend);
const emojis = await this.getEmojis(note.emojis); const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
@ -449,13 +452,11 @@ export class ApRendererService {
attributedTo, attributedTo,
summary: summary ?? undefined, summary: summary ?? undefined,
content: content ?? undefined, content: content ?? undefined,
...(noMisskeyContent ? {} : { _misskey_content: text,
_misskey_content: text, source: {
source: { content: text,
content: text, mediaType: 'text/x.misskeymarkdown',
mediaType: 'text/x.misskeymarkdown', },
},
}),
_misskey_quote: quote, _misskey_quote: quote,
quoteUrl: quote, quoteUrl: quote,
quoteUri: quote, quoteUri: quote,
@ -470,6 +471,7 @@ export class ApRendererService {
}; };
} }
// if you change this, also change `server/api/endpoints/i/update.ts`
@bindThis @bindThis
public async renderPerson(user: MiLocalUser) { public async renderPerson(user: MiLocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id); const id = this.userEntityService.genLocalUserUri(user.id);
@ -712,7 +714,7 @@ export class ApRendererService {
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend); const { content } = this.apMfmService.getNoteHtml(note, apAppend);
const emojis = await this.getEmojis(note.emojis); const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
@ -743,13 +745,11 @@ export class ApRendererService {
summary: summary ?? undefined, summary: summary ?? undefined,
content: content ?? undefined, content: content ?? undefined,
updated: note.updatedAt?.toISOString(), updated: note.updatedAt?.toISOString(),
...(noMisskeyContent ? {} : { _misskey_content: text,
_misskey_content: text, source: {
source: { content: text,
content: text, mediaType: 'text/x.misskeymarkdown',
mediaType: 'text/x.misskeymarkdown', },
},
}),
_misskey_quote: quote, _misskey_quote: quote,
quoteUrl: quote, quoteUrl: quote,
quoteUri: quote, quoteUri: quote,

View file

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

View file

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm'; import { IsNull, Not } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { InstanceActorService } from '@/core/InstanceActorService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
@ -15,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { fromTuple } from '@/misc/from-tuple.js';
import { isCollectionOrOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
@ -41,7 +43,7 @@ export class Resolver {
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService, private loggerService: LoggerService,
private recursionLimit = 100, private recursionLimit = 256,
) { ) {
this.history = new Set(); this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve'); this.logger = this.loggerService.getLogger('ap-resolve');
@ -52,6 +54,11 @@ export class Resolver {
return Array.from(this.history); return Array.from(this.history);
} }
@bindThis
public getRecursionLimit(): number {
return this.recursionLimit;
}
@bindThis @bindThis
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string' const collection = typeof value === 'string'
@ -61,12 +68,15 @@ export class Resolver {
if (isCollectionOrOrderedCollection(collection)) { if (isCollectionOrOrderedCollection(collection)) {
return collection; return collection;
} else { } else {
throw new Error(`unrecognized collection type: ${collection.type}`); throw new UnrecoverableError(`unrecognized collection type: ${collection.type}`);
} }
} }
@bindThis @bindThis
public async resolve(value: string | IObject): Promise<IObject> { public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
// eslint-disable-next-line no-param-reassign
value = fromTuple(value);
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
} }
@ -75,15 +85,15 @@ export class Resolver {
// URLs with fragment parts cannot be resolved correctly because // URLs with fragment parts cannot be resolved correctly because
// the fragment part does not get transmitted over HTTP(S). // the fragment part does not get transmitted over HTTP(S).
// Avoid strange behaviour by not trying to resolve these at all. // 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)) { 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) { 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); this.history.add(value);
@ -94,7 +104,7 @@ export class Resolver {
} }
if (!this.utilityService.isFederationAllowedHost(host)) { if (!this.utilityService.isFederationAllowedHost(host)) {
throw new Error('Instance is blocked'); throw new UnrecoverableError(`instance is blocked: ${value}`);
} }
if (this.config.signToActivityPubGet && !this.user) { if (this.config.signToActivityPubGet && !this.user) {
@ -110,15 +120,19 @@ export class Resolver {
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
object['@context'] !== '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 // HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the // `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects // object after redirects; here we double-check that no redirects
// bounced between hosts // bounced between hosts
if (object.id && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) { if (object.id == null) {
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`); 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; return object;
@ -127,7 +141,7 @@ export class Resolver {
@bindThis @bindThis
private resolveLocal(url: string): Promise<IObject> { private resolveLocal(url: string): Promise<IObject> {
const parsed = this.apDbResolverService.parseUri(url); 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) { switch (parsed.type) {
case 'notes': case 'notes':
@ -156,7 +170,7 @@ export class Resolver {
case 'follows': case 'follows':
return this.followRequestsRepository.findOneBy({ id: parsed.id }) return this.followRequestsRepository.findOneBy({ id: parsed.id })
.then(async followRequest => { .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([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({ this.usersRepository.findOneBy({
id: followRequest.followerId, id: followRequest.followerId,
@ -168,12 +182,12 @@ export class Resolver {
}), }),
]); ]);
if (follower == null || followee == null) { 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)); return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
}); });
default: 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 * as crypto from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
@ -109,7 +110,7 @@ class JsonLd {
@bindThis @bindThis
private getLoader() { private getLoader() {
return async (url: string): Promise<RemoteDocument> => { 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 (this.preLoad) {
if (url in PRELOADED_CONTEXTS) { if (url in PRELOADED_CONTEXTS) {
@ -148,7 +149,7 @@ class JsonLd {
}, },
).then(res => { ).then(res => {
if (!res.ok) { if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`); throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`);
} else { } else {
return res.json(); return res.json();
} }

View file

@ -2,26 +2,30 @@
* SPDX-FileCopyrightText: dakkar and sharkey-project * SPDX-FileCopyrightText: dakkar and sharkey-project
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UnrecoverableError } from 'bullmq';
import type { IObject } from '../type.js'; import type { IObject } from '../type.js';
function getHrefFrom(one: IObject|string): string | undefined { function getHrefsFrom(one: IObject | string | undefined | (IObject | string | undefined)[]): (string | undefined)[] {
if (typeof(one) === 'string') return one; if (Array.isArray(one)) {
return one.href; return one.flatMap(h => getHrefsFrom(h));
}
return [
typeof(one) === 'object' ? one.href : one,
];
} }
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
const idOk = activity.id !== undefined && urls.includes(activity.id); const expectedUrls = new Set(urls
if (idOk) return; .filter(u => URL.canParse(u))
.map(u => new URL(u).href),
);
const url = activity.url; const actualUrls = [activity.id, ...getHrefsFrom(activity.url)]
if (url) { .filter(u => u && URL.canParse(u))
// `activity.url` can be an `ApObject = IObject | string | (IObject .map(u => new URL(u as string).href);
// | 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));
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(); const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') { 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 ( if (
contentType.startsWith('application/activity+json') || contentType.startsWith('application/activity+json') ||
@ -17,7 +17,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
) { ) {
return; 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*(;|$)/; 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(); const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') { 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 ( if (
contentType.startsWith('application/ld+json') || contentType.startsWith('application/ld+json') ||
@ -35,5 +35,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
) { ) {
return; 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

@ -9,12 +9,13 @@ import type { DriveFilesRepository, MiMeta } from '@/models/_.js';
import type { MiRemoteUser } from '@/models/User.js'; import type { MiRemoteUser } from '@/models/User.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import { truncate } from '@/misc/truncate.js'; import { truncate } from '@/misc/truncate.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js'; import { checkHttps } from '@/misc/check-https.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.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 { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { isDocument, type IObject } from '../type.js'; import { isDocument, type IObject } from '../type.js';
@ -29,6 +30,8 @@ export class ApImageService {
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
@Inject(DI.config)
private config: Config,
private apResolverService: ApResolverService, private apResolverService: ApResolverService,
private driveService: DriveService, private driveService: DriveService,
@ -45,7 +48,7 @@ export class ApImageService {
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// 投稿者が凍結されていたらスキップ // 投稿者が凍結されていたらスキップ
if (actor.isSuspended) { 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); const image = await this.apResolverService.createResolver().resolve(value);
@ -83,7 +86,7 @@ export class ApImageService {
uri: image.url, uri: image.url,
sensitive: !!(image.sensitive), sensitive: !!(image.sensitive),
isLink: !shouldBeCached, isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH), comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
}); });
if (!file.isLink || file.url === image.url) return file; if (!file.isLink || file.url === image.url) return file;

View file

@ -5,8 +5,9 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js'; 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 { Config } from '@/config.js';
import type { MiRemoteUser } from '@/models/User.js'; import type { MiRemoteUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
@ -49,6 +50,9 @@ export class ApNoteService {
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.pollsRepository) @Inject(DI.pollsRepository)
private pollsRepository: PollsRepository, private pollsRepository: PollsRepository,
@ -82,7 +86,13 @@ export class ApNoteService {
} }
@bindThis @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 expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object); 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}`); 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())) { 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'); 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; return null;
} }
@ -116,18 +143,27 @@ export class ApNoteService {
return await this.apDbResolverService.getNoteFromApId(object); 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を作成します * Noteを作成します
*/ */
@bindThis @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 // eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver(); if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value); const object = await resolver.resolve(value);
const entryUri = getApId(value); const entryUri = getApId(value);
const err = this.validateNote(object, entryUri); const err = this.validateNote(object, entryUri, actor);
if (err) { if (err) {
this.logger.error(err.message, { this.logger.error(err.message, {
resolver: { history: resolver.getHistory() }, resolver: { history: resolver.getHistory() },
@ -141,29 +177,40 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) { if (note.id == null) {
throw new Error('unexpected schema of note.id: ' + note.id); 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); const url = getOneApHrefNullable(note.url);
if (url && !checkHttps(url)) { if (url != null) {
throw new Error('unexpected schema of note url: ' + url); 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}`); this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ // 投稿者をフェッチ
if (note.attributedTo == null) { 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 uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ // ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; // eslint-disable-next-line no-param-reassign
if (cachedActor && cachedActor.isSuspended) { actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); 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); 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 }); const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) { 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 //#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) { 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); const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
@ -228,13 +276,13 @@ export class ApNoteService {
.then(x => { .then(x => {
if (x == null) { if (x == null) {
this.logger.warn('Specified inReplyTo, but not found'); 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; return x;
}) })
.catch(async err => { .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; throw err;
}) })
: null; : null;
@ -243,16 +291,25 @@ export class ApNoteService {
let quote: MiNote | undefined | null = null; let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { 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: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' } | { 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 { try {
const res = await this.resolveNote(uri, { resolver }); 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 }; return { status: 'ok', res };
} catch (e) { } 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 { return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', 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); quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) { if (!quote) {
if (results.some(x => x.status === 'temperror')) { 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'); this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value); const duplicate = await this.fetchNote(value);
if (!duplicate) { 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; return duplicate;
} }
@ -335,16 +392,18 @@ export class ApNoteService {
* Noteを作成します * Noteを作成します
*/ */
@bindThis @bindThis
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> { public async updateNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
const noteUri = typeof value === 'string' ? value : value.id; const noteUri = getApId(value);
if (noteUri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ // 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 このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri }); const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
if (UpdatedNote == null) throw new Error('Note is not registered'); 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 // eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver(); if (resolver == null) resolver = this.apResolverService.createResolver();
@ -362,33 +421,38 @@ export class ApNoteService {
throw err; 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; const note = object as IPost;
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) { if (note.id == null) {
throw new Error('unexpected schema of note.id: ' + note.id); 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); const url = getOneApHrefNullable(note.url);
if (url && !checkHttps(url)) { if (url != null) {
throw new Error('unexpected schema of note url: ' + url); 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}`); this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ if (actor.isSuspended) {
if (note.attributedTo == null) { throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`);
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');
} }
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); 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 }); const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) { 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 //#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); const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility; let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers; const visibleUsers = noteAudience.visibleUsers;
@ -453,13 +510,13 @@ export class ApNoteService {
.then(x => { .then(x => {
if (x == null) { if (x == null) {
this.logger.warn('Specified inReplyTo, but not found'); 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; return x;
}) })
.catch(async err => { .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; throw err;
}) })
: null; : null;
@ -468,16 +525,25 @@ export class ApNoteService {
let quote: MiNote | undefined | null = null; let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) { 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: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' } | { 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 { try {
const res = await this.resolveNote(uri, { resolver }); 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 }; return { status: 'ok', res };
} catch (e) { } 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 { return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror', 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); quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) { if (!quote) {
if (results.some(x => x.status === 'temperror')) { 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); const apEmojis = emojis.map(emoji => emoji.name);
try { 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, createdAt: note.published ? new Date(note.published) : null,
files, files,
reply, reply,
@ -550,7 +616,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again'); this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value); const duplicate = await this.fetchNote(value);
if (!duplicate) { 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; return duplicate;
} }
@ -567,7 +633,7 @@ export class ApNoteService {
const uri = getApId(value); const uri = getApId(value);
if (!this.utilityService.isFederationAllowedUri(uri)) { 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); const unlock = await this.appLockService.getApLock(uri);
@ -578,15 +644,15 @@ export class ApNoteService {
if (exist) return exist; if (exist) return exist;
//#endregion //#endregion
if (uri.startsWith(this.config.url)) { if (this.utilityService.isUriLocal(uri)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
} }
// リモートサーバーからフェッチしてきて登録 // リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, false); return await this.createNote(createFrom, undefined, options.resolver, true);
} finally { } finally {
unlock(); unlock();
} }
@ -627,7 +693,7 @@ export class ApNoteService {
}); });
const emoji = await this.emojisRepository.findOneBy({ host, name }); 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; return emoji;
} }

View file

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

View file

@ -4,17 +4,20 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js'; 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 { Config } from '@/config.js';
import type { IPoll } from '@/models/Poll.js'; import type { IPoll } from '@/models/Poll.js';
import type { MiRemoteUser } from '@/models/User.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.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 { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js'; import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js';
import type { IObject, IQuestion } from '../type.js'; import type { IObject } from '../type.js';
@Injectable() @Injectable()
export class ApQuestionService { export class ApQuestionService {
@ -24,6 +27,9 @@ export class ApQuestionService {
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -32,6 +38,7 @@ export class ApQuestionService {
private apResolverService: ApResolverService, private apResolverService: ApResolverService,
private apLoggerService: ApLoggerService, private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) { ) {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
@ -42,10 +49,10 @@ export class ApQuestionService {
if (resolver == null) resolver = this.apResolverService.createResolver(); if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source); 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; 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; const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
@ -65,40 +72,50 @@ export class ApQuestionService {
* @returns true if updated * @returns true if updated
*/ */
@bindThis @bindThis
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> { public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
const uri = typeof value === 'string' ? value : value.id; const uri = getApId(value);
if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ // 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 このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri }); 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 }); 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 //#endregion
// resolve new Question object // resolve new Question object
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver(); 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)}`); 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; 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; let changed = false;
for (const choice of poll.choices) { for (const choice of poll.choices) {
const oldCount = poll.votes[poll.choices.indexOf(choice)]; const oldCount = poll.votes[poll.choices.indexOf(choice)];
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems; 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) { if (oldCount <= newCount) {
changed = true; changed = true;
poll.votes[poll.choices.indexOf(choice)] = newCount; poll.votes[poll.choices.indexOf(choice)] = newCount;
} }

View file

@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { UnrecoverableError } from 'bullmq';
import { fromTuple } from '@/misc/from-tuple.js';
export type Obj = { [x: string]: any }; export type Obj = { [x: string]: any };
export type ApObject = IObject | string | (IObject | string)[]; export type ApObject = IObject | string | (IObject | string)[];
@ -53,10 +56,25 @@ export function getOneApId(value: ApObject): string {
/** /**
* Get ActivityStreams Object id * Get ActivityStreams Object id
*/ */
export function getApId(value: string | IObject): string { export function getApId(value: string | IObject | [string | IObject]): string {
// eslint-disable-next-line no-param-reassign
value = fromTuple(value);
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id; if (typeof value.id === 'string') return value.id;
throw new Error('cannot detemine 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;
} }
/** /**
@ -85,7 +103,9 @@ export function getApHrefNullable(value: string | IObject | undefined): string |
export interface IActivity extends IObject { export interface IActivity extends IObject {
//type: 'Activity'; //type: 'Activity';
actor: IObject | string; actor: IObject | string;
object: IObject | string; // ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties
// Misskey can only handle one value, so we use a tuple for that case.
object: IObject | string | [IObject | string] ;
target?: IObject | string; target?: IObject | string;
/** LD-Signature */ /** LD-Signature */
signature?: { signature?: {
@ -316,6 +336,10 @@ export interface ILike extends IActivity {
_misskey_reaction?: string; _misskey_reaction?: string;
} }
export interface IDislike extends IActivity {
type: 'Dislike';
}
export interface IAnnounce extends IActivity { export interface IAnnounce extends IActivity {
type: 'Announce'; type: 'Announce';
} }
@ -333,6 +357,7 @@ export interface IMove extends IActivity {
target: IObject | string; 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 isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
@ -347,6 +372,7 @@ export const isLike = (object: IObject): object is ILike => {
const type = getApType(object); const type = getApType(object);
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type); 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 isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';

View file

@ -6,6 +6,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ChartLoggerService } from '@/core/chart/ChartLoggerService.js';
import Logger from '@/logger.js';
import FederationChart from './charts/federation.js'; import FederationChart from './charts/federation.js';
import NotesChart from './charts/notes.js'; import NotesChart from './charts/notes.js';
import UsersChart from './charts/users.js'; import UsersChart from './charts/users.js';
@ -24,6 +26,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
export class ChartManagementService implements OnApplicationShutdown { export class ChartManagementService implements OnApplicationShutdown {
private charts; private charts;
private saveIntervalId: NodeJS.Timeout; private saveIntervalId: NodeJS.Timeout;
private readonly logger: Logger;
constructor( constructor(
private federationChart: FederationChart, private federationChart: FederationChart,
@ -38,6 +41,7 @@ export class ChartManagementService implements OnApplicationShutdown {
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private perUserDriveChart: PerUserDriveChart, private perUserDriveChart: PerUserDriveChart,
private apRequestChart: ApRequestChart, private apRequestChart: ApRequestChart,
private chartLoggerService: ChartLoggerService,
) { ) {
this.charts = [ this.charts = [
this.federationChart, this.federationChart,
@ -53,6 +57,7 @@ export class ChartManagementService implements OnApplicationShutdown {
this.perUserDriveChart, this.perUserDriveChart,
this.apRequestChart, this.apRequestChart,
]; ];
this.logger = chartLoggerService.logger;
} }
@bindThis @bindThis
@ -62,6 +67,7 @@ export class ChartManagementService implements OnApplicationShutdown {
for (const chart of this.charts) { for (const chart of this.charts) {
chart.save(); chart.save();
} }
this.logger.info('All charts saved');
}, 1000 * 60 * 20); }, 1000 * 60 * 20);
} }
@ -72,6 +78,7 @@ export class ChartManagementService implements OnApplicationShutdown {
await Promise.all( await Promise.all(
this.charts.map(chart => chart.save()), 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); 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); const date = Chart.dateToTimestamp(current);
@ -398,7 +398,7 @@ export default abstract class Chart<T extends Schema> {
...columns, ...columns,
}) as RawRecord<T>; }) 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; return log;
} finally { } finally {
@ -418,7 +418,7 @@ export default abstract class Chart<T extends Schema> {
@bindThis @bindThis
public async save(): Promise<void> { public async save(): Promise<void> {
if (this.buffer.length === 0) { if (this.buffer.length === 0) {
this.logger.info(`${this.name}: Write skipped`); this.logger.debug(`${this.name}: Write skipped`);
return; return;
} }
@ -519,7 +519,7 @@ export default abstract class Chart<T extends Schema> {
.execute(), .execute(),
]); ]);
this.logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); this.logger.debug(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`);
// TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする // TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする
this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group));

View file

@ -8,11 +8,14 @@ import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import { MiBlocking } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js'; import { MiUserProfile } from '@/models/UserProfile.js';
import type { MiFollowing } from '@/models/Following.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { MiFollowing } from '@/models/Following.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from './UserEntityService.js'; import { UserEntityService } from './UserEntityService.js';
type LocalFollowerFollowing = MiFollowing & { type LocalFollowerFollowing = MiFollowing & {
@ -47,6 +50,8 @@ export class FollowingEntityService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private queryService: QueryService,
private roleService: RoleService,
) { ) {
} }
@ -70,6 +75,53 @@ export class FollowingEntityService {
return following.followeeHost != null; return following.followeeHost != null;
} }
@bindThis
public async getFollowing(me: MiLocalUser, params: FollowsQueryParams) {
return await this.getFollows(me, params, 'following.followerHost = :host');
}
@bindThis
public async getFollowers(me: MiLocalUser, params: FollowsQueryParams) {
return await this.getFollows(me, params, 'following.followeeHost = :host');
}
private async getFollows(me: MiLocalUser, params: FollowsQueryParams, condition: string) {
const builder = this.followingsRepository.createQueryBuilder('following');
const query = this.queryService
.makePaginationQuery(builder, params.sinceId, params.untilId)
.andWhere(condition, { host: params.host })
.limit(params.limit);
if (!await this.roleService.isModerator(me)) {
query.setParameter('me', me.id);
// Make sure that the followee doesn't block us, if their profile will be included.
if (params.includeFollowee) {
query.leftJoin(MiBlocking, 'followee_blocking', 'followee_blocking."blockerId" = following."followeeId" AND followee_blocking."blockeeId" = :me');
query.andWhere('followee_blocking.id IS NULL');
}
// Make sure that the follower doesn't block us, if their profile will be included.
if (params.includeFollower) {
query.leftJoin(MiBlocking, 'follower_blocking', 'follower_blocking."blockerId" = following."followerId" AND follower_blocking."blockeeId" = :me');
query.andWhere('follower_blocking.id IS NULL');
}
// Make sure that the followee hasn't hidden this connection.
query.leftJoin(MiUserProfile, 'followee', 'followee."userId" = following."followeeId"');
query.leftJoin(MiFollowing, 'me_following_followee', 'me_following_followee."followerId" = :me AND me_following_followee."followeeId" = following."followerId"');
query.andWhere('(followee."userId" = :me OR followee."followersVisibility" = \'public\' OR (followee."followersVisibility" = \'followers\' AND me_following_followee.id IS NOT NULL))');
// Make sure that the follower hasn't hidden this connection.
query.leftJoin(MiUserProfile, 'follower', 'follower."userId" = following."followerId"');
query.leftJoin(MiFollowing, 'me_following_follower', 'me_following_follower."followerId" = :me AND me_following_follower."followeeId" = following."followerId"');
query.andWhere('(follower."userId" = :me OR follower."followingVisibility" = \'public\' OR (follower."followingVisibility" = \'followers\' AND me_following_follower.id IS NOT NULL))');
}
const followings = await query.getMany();
return await this.packMany(followings, me, { populateFollowee: params.includeFollowee, populateFollower: params.includeFollower });
}
@bindThis @bindThis
public async pack( public async pack(
src: MiFollowing['id'] | MiFollowing, src: MiFollowing['id'] | MiFollowing,
@ -124,3 +176,12 @@ export class FollowingEntityService {
} }
} }
interface FollowsQueryParams {
readonly host: string;
readonly limit: number;
readonly includeFollower: boolean;
readonly includeFollowee: boolean;
readonly sinceId?: string;
readonly untilId?: string;
}

View file

@ -59,6 +59,7 @@ export class InstanceEntityService {
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
isNSFW: instance.isNSFW, isNSFW: instance.isNSFW,
rejectReports: instance.rejectReports,
moderationNote: iAmModerator ? instance.moderationNote : null, moderationNote: iAmModerator ? instance.moderationNote : null,
}; };
} }

View file

@ -98,6 +98,8 @@ export class MetaEntityService {
recaptchaSiteKey: instance.recaptchaSiteKey, recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile, enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey, turnstileSiteKey: instance.turnstileSiteKey,
enableFC: instance.enableFC,
fcSiteKey: instance.fcSiteKey,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
@ -110,6 +112,11 @@ export class MetaEntityService {
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: this.config.maxNoteLength, maxNoteTextLength: this.config.maxNoteLength,
maxRemoteNoteTextLength: this.config.maxRemoteNoteLength,
maxCwLength: this.config.maxCwLength,
maxRemoteCwLength: this.config.maxRemoteCwLength,
maxAltTextLength: this.config.maxAltTextLength,
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
defaultLightTheme, defaultLightTheme,
defaultDarkTheme, defaultDarkTheme,
defaultLike: instance.defaultLike, defaultLike: instance.defaultLike,

View file

@ -16,7 +16,9 @@ import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CacheService } from '../CacheService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js'; import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js'; import type { UserEntityService } from './UserEntityService.js';
@ -27,6 +29,7 @@ import type { Config } from '@/config.js';
export class NoteEntityService implements OnModuleInit { export class NoteEntityService implements OnModuleInit {
private userEntityService: UserEntityService; private userEntityService: UserEntityService;
private driveFileEntityService: DriveFileEntityService; private driveFileEntityService: DriveFileEntityService;
private cacheService: CacheService;
private customEmojiService: CustomEmojiService; private customEmojiService: CustomEmojiService;
private reactionService: ReactionService; private reactionService: ReactionService;
private reactionsBufferingService: ReactionsBufferingService; private reactionsBufferingService: ReactionsBufferingService;
@ -75,6 +78,7 @@ export class NoteEntityService implements OnModuleInit {
onModuleInit() { onModuleInit() {
this.userEntityService = this.moduleRef.get('UserEntityService'); this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.cacheService = this.moduleRef.get('CacheService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.reactionService = this.moduleRef.get('ReactionService'); this.reactionService = this.moduleRef.get('ReactionService');
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService'); this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
@ -119,29 +123,32 @@ export class NoteEntityService implements OnModuleInit {
} else if (packedNote.renote && (meId === packedNote.renote.userId)) { } else if (packedNote.renote && (meId === packedNote.renote.userId)) {
hide = false; hide = false;
} else { } else {
if (packedNote.renote) { // フォロワーかどうか
const isFollowing = await this.followingsRepository.exists({ const isFollowing = await this.followingsRepository.exists({
where: { where: {
followeeId: packedNote.renote.userId, followeeId: packedNote.userId,
followerId: meId, followerId: meId,
}, },
}); });
hide = !isFollowing; hide = !isFollowing;
} else {
// フォロワーかどうか
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
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) { if (hide) {
packedNote.visibleUserIds = undefined; packedNote.visibleUserIds = undefined;
packedNote.fileIds = []; packedNote.fileIds = [];
@ -149,6 +156,12 @@ export class NoteEntityService implements OnModuleInit {
packedNote.text = null; packedNote.text = null;
packedNote.poll = undefined; packedNote.poll = undefined;
packedNote.cw = null; packedNote.cw = null;
packedNote.repliesCount = 0;
packedNote.reactionAcceptance = null;
packedNote.reactionAndUserPairCache = undefined;
packedNote.reactionCount = 0;
packedNote.reactionEmojis = {};
packedNote.reactions = {};
packedNote.isHidden = true; packedNote.isHidden = true;
} }
} }
@ -262,7 +275,8 @@ export class NoteEntityService implements OnModuleInit {
return true; return true;
} else { } 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({ this.followingsRepository.count({
where: { where: {
followeeId: note.userId, followeeId: note.userId,
@ -273,6 +287,8 @@ export class NoteEntityService implements OnModuleInit {
this.usersRepository.findOneByOrFail({ id: meId }), this.usersRepository.findOneByOrFail({ id: meId }),
]); ]);
if (blocked) return false;
/* If we know the following, everyhting is fine. /* If we know the following, everyhting is fine.
But if we do not know the following, it might be that both the 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; return true;
} }

View file

@ -374,6 +374,13 @@ export class UserEntityService implements OnModuleInit {
return count > 0; return count > 0;
} }
@bindThis
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
return this.followRequestsRepository.existsBy({
followerId: userId,
});
}
@bindThis @bindThis
public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' { public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' {
if (user.hideOnlineStatus) return 'unknown'; if (user.hideOnlineStatus) return 'unknown';
@ -620,6 +627,7 @@ export class UserEntityService implements OnModuleInit {
injectFeaturedNote: profile!.injectFeaturedNote, injectFeaturedNote: profile!.injectFeaturedNote,
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
alwaysMarkNsfw: profile!.alwaysMarkNsfw, alwaysMarkNsfw: profile!.alwaysMarkNsfw,
defaultSensitive: profile!.defaultSensitive,
autoSensitive: profile!.autoSensitive, autoSensitive: profile!.autoSensitive,
carefulBot: profile!.carefulBot, carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed, autoAcceptFollowed: profile!.autoAcceptFollowed,
@ -643,6 +651,7 @@ export class UserEntityService implements OnModuleInit {
hasUnreadChannel: false, // 後方互換性のため hasUnreadChannel: false, // 後方互換性のため
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount, unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords, mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords, hardMutedWords: profile!.hardMutedWords,

View file

@ -0,0 +1,7 @@
export function fromTuple<T>(value: T | [T]): T {
if (Array.isArray(value)) {
return value[0];
}
return value;
}

View file

@ -23,6 +23,17 @@ type Quote =
hasPoll: true hasPoll: true
}); });
type PureRenote =
Renote & {
text: null,
cw: null,
replyId: null,
hasPoll: false,
fileIds: {
length: 0,
},
};
export function isRenote(note: MiNote): note is Renote { export function isRenote(note: MiNote): note is Renote {
return note.renoteId != null; return note.renoteId != null;
} }
@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote {
note.fileIds.length > 0; note.fileIds.length > 0;
} }
export function isPureRenote(note: MiNote): note is PureRenote {
return isRenote(note) && !isQuote(note);
}
type PackedRenote = type PackedRenote =
Packed<'Note'> & { Packed<'Note'> & {
renoteId: NonNullable<Packed<'Note'>['renoteId']> renoteId: NonNullable<Packed<'Note'>['renoteId']>
@ -54,6 +69,14 @@ type PackedQuote =
fileIds: NonNullable<Packed<'Note'>['fileIds']> 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 { export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
return note.renoteId != null; return note.renoteId != null;
} }
@ -65,3 +88,7 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote {
note.poll != null || note.poll != null ||
(note.fileIds != null && note.fileIds.length > 0); (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 = ''; let str = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
let rand = Math.floor((crypto.randomBytes(1).readUInt8(0) / 0xFF) * chars_len); const rand = crypto.randomInt(0, chars_len);
if (rand === chars_len) {
rand = chars_len - 1;
}
str += chars.charAt(rand); str += chars.charAt(rand);
} }

View file

@ -4,7 +4,6 @@
*/ */
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiDriveFolder } from './DriveFolder.js'; import { MiDriveFolder } from './DriveFolder.js';
@ -61,8 +60,7 @@ export class MiDriveFile {
}) })
public size: number; public size: number;
@Column('varchar', { @Column('text', {
length: DB_MAX_IMAGE_COMMENT_LENGTH,
nullable: true, nullable: true,
comment: 'The comment of the DriveFile.', comment: 'The comment of the DriveFile.',
}) })

View file

@ -6,6 +6,7 @@
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
/** /**
* Maps a user to the most recent post by that user. * Maps a user to the most recent post by that user.
@ -13,7 +14,7 @@ import { MiNote } from '@/models/Note.js';
* DMs are not counted. * DMs are not counted.
*/ */
@Entity('latest_note') @Entity('latest_note')
export class LatestNote { export class SkLatestNote {
@PrimaryColumn({ @PrimaryColumn({
name: 'user_id', name: 'user_id',
type: 'varchar' as const, type: 'varchar' as const,
@ -21,6 +22,24 @@ export class LatestNote {
}) })
public userId: string; public userId: string;
@PrimaryColumn('boolean', {
name: 'is_public',
default: false,
})
public isPublic: boolean;
@PrimaryColumn('boolean', {
name: 'is_reply',
default: false,
})
public isReply: boolean;
@PrimaryColumn('boolean', {
name: 'is_quote',
default: false,
})
public isQuote: boolean;
@ManyToOne(() => MiUser, { @ManyToOne(() => MiUser, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@ -44,11 +63,38 @@ export class LatestNote {
}) })
public note: MiNote | null; public note: MiNote | null;
constructor(data?: Partial<LatestNote>) { constructor(data?: Partial<SkLatestNote>) {
if (!data) return; if (!data) return;
for (const [k, v] of Object.entries(data)) { for (const [k, v] of Object.entries(data)) {
(this as Record<string, unknown>)[k] = v; (this as Record<string, unknown>)[k] = v;
} }
} }
/**
* Generates a compound key matching a provided note.
*/
static keyFor(note: MiNote) {
return {
userId: note.userId,
isPublic: note.visibility === 'public',
isReply: note.replyId != null,
isQuote: isRenote(note) && isQuote(note),
};
}
/**
* Checks if two notes would produce equivalent compound keys.
*/
static areEquivalent(first: MiNote, second: MiNote): boolean {
const firstKey = SkLatestNote.keyFor(first);
const secondKey = SkLatestNote.keyFor(second);
return (
firstKey.userId === secondKey.userId &&
firstKey.isPublic === secondKey.isPublic &&
firstKey.isReply === secondKey.isReply &&
firstKey.isQuote === secondKey.isQuote
);
}
} }

View file

@ -269,6 +269,23 @@ export class MiMeta {
}) })
public turnstileSecretKey: string | null; public turnstileSecretKey: string | null;
@Column('boolean', {
default: false,
})
public enableFC: boolean;
@Column('varchar', {
length: 1024,
nullable: true,
})
public fcSiteKey: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public fcSecretKey: string | null;
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
@Column('enum', { @Column('enum', {

View file

@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { import {
LatestNote, SkLatestNote,
MiAbuseReportNotificationRecipient, MiAbuseReportNotificationRecipient,
MiAbuseUserReport, MiAbuseUserReport,
MiAccessToken, MiAccessToken,
@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = {
const $latestNotesRepository: Provider = { const $latestNotesRepository: Provider = {
provide: DI.latestNotesRepository, provide: DI.latestNotesRepository,
useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository<LatestNote>), useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository<SkLatestNote>),
inject: [DI.db], inject: [DI.db],
}; };

View file

@ -196,6 +196,11 @@ export class MiUserProfile {
}) })
public alwaysMarkNsfw: boolean; public alwaysMarkNsfw: boolean;
@Column('boolean', {
default: false,
})
public defaultSensitive: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View file

@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { OrmUtils } from 'typeorm/util/OrmUtils.js';
import { LatestNote } from '@/models/LatestNote.js'; import { SkLatestNote } from '@/models/LatestNote.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAccessToken } from '@/models/AccessToken.js';
@ -127,7 +127,7 @@ export const miRepository = {
} satisfies MiRepository<ObjectLiteral>; } satisfies MiRepository<ObjectLiteral>;
export { export {
LatestNote, SkLatestNote,
MiAbuseUserReport, MiAbuseUserReport,
MiAbuseReportNotificationRecipient, MiAbuseReportNotificationRecipient,
MiAccessToken, MiAccessToken,
@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository<MiGalleryPost> & MiRepository<Mi
export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>; export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>; export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>;
export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>; export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>;
export type LatestNotesRepository = Repository<LatestNote> & MiRepository<LatestNote>; export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>;
export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>; export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>;
export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>; export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>; export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;

View file

@ -121,6 +121,11 @@ export const packedFederationInstanceSchema = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
rejectReports: {
type: 'boolean',
optional: false,
nullable: false,
},
moderationNote: { moderationNote: {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,

View file

@ -127,6 +127,14 @@ export const packedMetaLiteSchema = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
enableFC: {
type: 'boolean',
optional: false, nullable: false,
},
fcSiteKey: {
type: 'string',
optional: false, nullable: true,
},
enableAchievements: { enableAchievements: {
type: 'boolean', type: 'boolean',
optional: false, nullable: true, optional: false, nullable: true,
@ -168,6 +176,26 @@ export const packedMetaLiteSchema = {
type: 'number', type: 'number',
optional: false, nullable: false, optional: false, nullable: false,
}, },
maxRemoteNoteTextLength: {
type: 'number',
optional: false, nullable: false,
},
maxCwLength: {
type: 'number',
optional: false, nullable: false,
},
maxRemoteCwLength: {
type: 'number',
optional: false, nullable: false,
},
maxAltTextLength: {
type: 'number',
optional: false, nullable: false,
},
maxRemoteAltTextLength: {
type: 'number',
optional: false, nullable: false,
},
ads: { ads: {
type: 'array', type: 'array',
optional: false, nullable: false, optional: false, nullable: false,

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