summaryrefslogtreecommitdiff
path: root/packages/backend/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/server')
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts418
-rw-r--r--packages/backend/src/server/FileServerService.ts30
-rw-r--r--packages/backend/src/server/NodeinfoServerService.ts8
-rw-r--r--packages/backend/src/server/ServerModule.ts31
-rw-r--r--packages/backend/src/server/ServerService.ts67
-rw-r--r--packages/backend/src/server/ServerUtilityService.ts162
-rw-r--r--packages/backend/src/server/SkRateLimiterService.md13
-rw-r--r--packages/backend/src/server/SkRateLimiterService.ts161
-rw-r--r--packages/backend/src/server/WellKnownServerService.ts27
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts9
-rw-r--r--packages/backend/src/server/api/ApiServerService.ts3
-rw-r--r--packages/backend/src/server/api/AuthenticateService.ts6
-rw-r--r--packages/backend/src/server/api/RateLimiterService.ts107
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts9
-rw-r--r--packages/backend/src/server/api/SignupApiService.ts2
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts106
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts35
-rw-r--r--packages/backend/src/server/api/endpoints.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/accounts/create.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/admin/accounts/delete.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/copy.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/delete.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/list.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/clear.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/jobs.ts38
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/promote.ts77
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts36
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/queues.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts38
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts38
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/show-job.ts38
-rw-r--r--packages/backend/src/server/api/endpoints/admin/reset-password.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-user.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts58
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts62
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts15
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/ap/show.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/app/current.ts73
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/chat/history.ts75
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts106
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts123
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/delete.ts51
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/react.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts75
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/search.ts78
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/show.ts65
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/unreact.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts73
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/create.ts63
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/delete.ts56
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts69
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts56
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts69
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/join.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/joining.ts60
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/leave.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/members.ts76
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/mute.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/owned.ts56
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/show.ts60
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/update.ts67
-rw-r--r--packages/backend/src/server/api/endpoints/clips/create.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/clips/notes.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/clips/update.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/create.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/federation/instances.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/federation/update-remote-user.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/following/invalidate.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/hashtags/list.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/hashtags/users.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/update-key.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/change-password.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/delete-account.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-antennas.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-blocking.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-following.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-muting.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-user-lists.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/move.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications-grouped.ts51
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts64
-rw-r--r--packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts51
-rw-r--r--packages/backend/src/server/api/endpoints/i/regenerate-token.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts38
-rw-r--r--packages/backend/src/server/api/endpoints/invite/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/invite/delete.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/invite/limit.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/invite/list.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/miauth/gen-token.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/notes/children.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.test.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/edit.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/notes/featured.ts30
-rw-r--r--packages/backend/src/server/api/endpoints/notes/following.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/notes/global-timeline.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/local-timeline.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/mentions.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/vote.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/renotes.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/replies.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/list.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/notes/show.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/translate.ts165
-rw-r--r--packages/backend/src/server/api/endpoints/notes/unrenote.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/versions.ts34
-rw-r--r--packages/backend/src/server/api/endpoints/reset-db.ts19
-rw-r--r--packages/backend/src/server/api/endpoints/reset-password.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/server-info.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/sponsors.ts53
-rw-r--r--packages/backend/src/server/api/endpoints/users.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/users/featured-notes.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/users/notes.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/users/reactions.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/recommendation.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/search.ts81
-rw-r--r--packages/backend/src/server/api/endpoints/users/show.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts2
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonApiServerService.ts1029
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonClientService.ts71
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonConverters.ts (renamed from packages/backend/src/server/api/mastodon/converters.ts)174
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonDataService.ts2
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonLogger.ts208
-rw-r--r--packages/backend/src/server/api/mastodon/argsUtils.ts (renamed from packages/backend/src/server/api/mastodon/timelineArgs.ts)4
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints.ts22
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/account.ts368
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/apps.ts122
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/auth.ts97
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/filter.ts118
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/instance.ts93
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/meta.ts66
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/notifications.ts100
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/search.ts242
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/status.ts606
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/timeline.ts314
-rw-r--r--packages/backend/src/server/api/mastodon/pagination.ts170
-rw-r--r--packages/backend/src/server/api/openapi/gen-spec.ts10
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts6
-rw-r--r--packages/backend/src/server/api/stream/Connection.ts123
-rw-r--r--packages/backend/src/server/api/stream/channel.ts17
-rw-r--r--packages/backend/src/server/api/stream/channels/antenna.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/bubble-timeline.ts8
-rw-r--r--packages/backend/src/server/api/stream/channels/channel.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-room.ts82
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-user.ts82
-rw-r--r--packages/backend/src/server/api/stream/channels/global-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/hashtag.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/home-timeline.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/hybrid-timeline.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/local-timeline.ts5
-rw-r--r--packages/backend/src/server/api/stream/channels/main.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/role-timeline.ts2
-rw-r--r--packages/backend/src/server/api/stream/channels/user-list.ts2
-rw-r--r--packages/backend/src/server/oauth/OAuth2ProviderService.ts143
-rw-r--r--packages/backend/src/server/web/ClientServerService.ts69
-rw-r--r--packages/backend/src/server/web/FeedService.ts2
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts434
-rw-r--r--packages/backend/src/server/web/boot.embed.js11
-rw-r--r--packages/backend/src/server/web/boot.js43
-rw-r--r--packages/backend/src/server/web/error.css107
-rw-r--r--packages/backend/src/server/web/error.js40
-rw-r--r--packages/backend/src/server/web/style.css1
-rw-r--r--packages/backend/src/server/web/style.embed.css1
-rw-r--r--packages/backend/src/server/web/views/base.pug4
-rw-r--r--packages/backend/src/server/web/views/error.pug44
-rw-r--r--packages/backend/src/server/web/views/info-card.pug2
-rw-r--r--packages/backend/src/server/web/views/oauth.pug2
209 files changed, 6222 insertions, 3314 deletions
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index 10dba1660f..41beadb56d 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -14,7 +14,7 @@ import accepts from 'accepts';
import vary from 'vary';
import secureJson from 'secure-json-parse';
import { DI } from '@/di-symbols.js';
-import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js';
+import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@@ -22,7 +22,6 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { QueueService } from '@/core/QueueService.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js';
import type { MiFollowing } from '@/models/Following.js';
import { countIf } from '@/misc/prelude/array.js';
@@ -33,11 +32,13 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
-import { IActivity } from '@/core/activitypub/type.js';
+import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import * as Acct from '@/misc/acct.js';
+import { CacheService } from '@/core/CacheService.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm';
+import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
@@ -51,6 +52,9 @@ export class ActivityPubServerService {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -77,13 +81,14 @@ export class ActivityPubServerService {
private utilityService: UtilityService,
private userEntityService: UserEntityService,
- private instanceActorService: InstanceActorService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private queueService: QueueService,
private userKeypairService: UserKeypairService,
private queryService: QueryService,
+ private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
private loggerService: LoggerService,
+ private readonly cacheService: CacheService,
) {
//this.createServer = this.createServer.bind(this);
this.logger = this.loggerService.getLogger('apserv', 'pink');
@@ -106,7 +111,7 @@ export class ActivityPubServerService {
* @param author Author of the note
*/
@bindThis
- private async packActivity(note: MiNote, author: MiUser): Promise<any> {
+ private async packActivity(note: MiNote, author: MiUser): Promise<ICreate | IAnnounce> {
if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
@@ -115,10 +120,55 @@ export class ActivityPubServerService {
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
}
- @bindThis
- private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise<boolean> {
- if (!this.config.checkActivityPubGetSignature) return false;
+ /**
+ * Checks Authorized Fetch.
+ * Returns an object with two properties:
+ * * reject - true if the request should be ignored by the caller, false if it should be processed.
+ * * redact - true if the caller should redact response data, false if it should return full data.
+ * When "reject" is true, the HTTP status code will be automatically set to 401 unauthorized.
+ */
+ private async checkAuthorizedFetch(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ userId?: string,
+ essential?: boolean,
+ ): Promise<{ reject: boolean, redact: boolean }> {
+ // Federation disabled => reject
+ if (this.meta.federation === 'none') {
+ reply.code(401);
+ return { reject: true, redact: true };
+ }
+
+ // Auth fetch disabled => accept
+ const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId);
+ if (allowUnsignedFetch === 'always') {
+ return { reject: false, redact: false };
+ }
+ // Valid signature => accept
+ const error = await this.checkSignature(request);
+ if (!error) {
+ return { reject: false, redact: false };
+ }
+
+ // Unsigned, but essential => accept redacted
+ if (allowUnsignedFetch === 'essential' && essential) {
+ return { reject: false, redact: true };
+ }
+
+ // Unsigned, not essential => reject
+ this.authlogger.warn(error);
+ reply.code(401);
+ return { reject: true, redact: true };
+ }
+
+ /**
+ * Verifies HTTP Signatures for a request.
+ * Returns null of success (valid signature).
+ * Returns a string error on validation failure.
+ */
+ @bindThis
+ private async checkSignature(request: FastifyRequest): Promise<string | null> {
/* this code is inspired from the `inbox` function below, and
`queue/processors/InboxProcessorService`
@@ -129,59 +179,33 @@ export class ActivityPubServerService {
this is also inspired by FireFish's `checkFetch`
*/
- /* tell any caching proxy that they should not cache these
- responses: we wouldn't want the proxy to return a 403 to
- someone presenting a valid signature, or return a cached
- response body to someone we've blocked!
- */
- reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
-
- /* we always allow requests about our instance actor, because when
- a remote instance needs to check our signature on a request we
- sent, it will need to fetch information about the user that
- signed it (which is our instance actor), and if we try to check
- their signature on *that* request, we'll fetch *their* instance
- actor... leading to an infinite recursion */
- if (userId) {
- const instanceActor = await this.instanceActorService.getInstanceActor();
-
- if (userId === instanceActor.id || userId === instanceActor.username) {
- this.authlogger.debug(`${request.id} ${request.url} request to instance.actor, letting through`);
- return false;
- }
- }
-
- let signature;
+ let signature: httpSignature.IParsedSignature;
try {
- signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
+ signature = httpSignature.parseRequest(request.raw, {
+ headers: ['(request-target)', 'host', 'date'],
+ authorizationHeaderName: 'signature',
+ });
} catch (e) {
// not signed, or malformed signature: refuse
- this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
- reply.code(401);
- return true;
+ return `${request.id} ${request.url} not signed, or malformed signature: refuse`;
}
const keyId = new URL(signature.keyId);
const keyHost = this.utilityService.toPuny(keyId.hostname);
- const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) apparently from ${keyHost}:`;
+ const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) claims to be from ${keyHost}:`;
- if (signature.params.headers.indexOf('host') === -1
- || request.headers.host !== this.config.host) {
+ if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) {
// no destination host, or not us: refuse
- this.authlogger.warn(`${logPrefix} no destination host, or not us: refuse`);
- reply.code(401);
- return true;
+ return `${logPrefix} no destination host, or not us: refuse`;
}
if (!this.utilityService.isFederationAllowedHost(keyHost)) {
/* blocked instance: refuse (we don't care if the signature is
good, if they even pretend to be from a blocked instance,
they're out) */
- this.authlogger.warn(`${logPrefix} instance is blocked: refuse`);
- reply.code(401);
- return true;
+ return `${logPrefix} instance is blocked: refuse`;
}
// do we know the signer already?
@@ -200,34 +224,64 @@ export class ActivityPubServerService {
if (authUser?.key == null) {
// we can't figure out who the signer is, or we can't get their key: refuse
- this.authlogger.warn(`${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`);
- reply.code(401);
- return true;
+ return `${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`;
}
- let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
+ if (authUser.user.isSuspended) {
+ // Signer is suspended locally
+ return `${logPrefix} signer is suspended: refuse`;
+ }
+
+ // some fedi implementations include the query (`?foo=bar`) in the
+ // signature, some don't, so we have to handle both cases
+ function verifyWithOrWithoutQuery() {
+ const httpSignatureValidated = httpSignature.verifySignature(signature, authUser!.key!.keyPem);
+ if (httpSignatureValidated) return true;
+
+ const requestUrl = new URL(`http://whatever${request.raw.url}`);
+ if (! requestUrl.search) return false;
+
+ // verification failed, the request URL contained a query, let's try without
+ const semiRawRequest = request.raw;
+ semiRawRequest.url = requestUrl.pathname;
+
+ // no need for try/catch, if the original request parsed, this
+ // one will, too
+ const signatureWithoutQuery = httpSignature.parseRequest(semiRawRequest, {
+ headers: ['(request-target)', 'host', 'date'],
+ authorizationHeaderName: 'signature',
+ });
+
+ return httpSignature.verifySignature(signatureWithoutQuery, authUser!.key!.keyPem);
+ }
+
+ let httpSignatureValidated = verifyWithOrWithoutQuery();
// maybe they changed their key? refetch it
+ // TODO rate-limit this using lastFetchedAt
if (!httpSignatureValidated) {
authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user);
if (authUser.key != null) {
- httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
+ httpSignatureValidated = verifyWithOrWithoutQuery();
}
}
if (!httpSignatureValidated) {
// bad signature: refuse
- this.authlogger.info(`${logPrefix} failed to validate signature: refuse`);
- reply.code(401);
- return true;
+ return `${logPrefix} failed to validate signature: refuse`;
}
// all good, don't refuse
- return false;
+ return null;
}
@bindThis
private inbox(request: FastifyRequest, reply: FastifyReply) {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
let signature;
try {
@@ -299,7 +353,13 @@ export class ActivityPubServerService {
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
reply: FastifyReply,
) {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
+ if (reject) return;
const userId = request.params.user;
@@ -326,11 +386,9 @@ export class ActivityPubServerService {
if (profile.followersVisibility === 'private') {
reply.code(403);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
return;
} else if (profile.followersVisibility === 'followers') {
reply.code(403);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
return;
}
//#endregion
@@ -382,7 +440,6 @@ export class ActivityPubServerService {
user.followersCount,
`${partOf}?page=true`,
);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
}
@@ -393,7 +450,13 @@ export class ActivityPubServerService {
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
reply: FastifyReply,
) {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
+ if (reject) return;
const userId = request.params.user;
@@ -420,11 +483,9 @@ export class ActivityPubServerService {
if (profile.followingVisibility === 'private') {
reply.code(403);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
return;
} else if (profile.followingVisibility === 'followers') {
reply.code(403);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
return;
}
//#endregion
@@ -476,7 +537,6 @@ export class ActivityPubServerService {
user.followingCount,
`${partOf}?page=true`,
);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
}
@@ -484,7 +544,13 @@ export class ActivityPubServerService {
@bindThis
private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
+ if (reject) return;
const userId = request.params.user;
@@ -517,7 +583,6 @@ export class ActivityPubServerService {
renderedNotes,
);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
}
@@ -530,7 +595,13 @@ export class ActivityPubServerService {
}>,
reply: FastifyReply,
) {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
+ if (reject) return;
const userId = request.params.user;
@@ -567,16 +638,28 @@ export class ActivityPubServerService {
const partOf = `${this.config.url}/users/${userId}/outbox`;
if (page) {
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
- .andWhere('note.userId = :userId', { userId: user.id })
- .andWhere(new Brackets(qb => {
- qb
- .where('note.visibility = \'public\'')
- .orWhere('note.visibility = \'home\'');
- }))
- .andWhere('note.localOnly = FALSE');
-
- const notes = await query.limit(limit).getMany();
+ const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({
+ sinceId: sinceId ?? null,
+ untilId: untilId ?? null,
+ limit: limit,
+ allowPartial: false, // Possibly true? IDK it's OK for ordered collection.
+ me: null,
+ redisTimelines: [
+ `userTimeline:${user.id}`,
+ `userTimelineWithReplies:${user.id}`,
+ ],
+ useDbFallback: true,
+ ignoreAuthorFromMute: true,
+ excludePureRenotes: false,
+ noteFilter: (note) => {
+ if (note.visibility !== 'home' && note.visibility !== 'public') return false;
+ if (note.localOnly) return false;
+ return true;
+ },
+ dbFallback: async (untilId, sinceId, limit) => {
+ return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id);
+ },
+ }) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id);
if (sinceId) notes.reverse();
@@ -608,14 +691,32 @@ export class ActivityPubServerService {
`${partOf}?page=true`,
`${partOf}?page=true&since_id=000000000000000000000000`,
);
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(rendered));
}
}
@bindThis
- private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
+ private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) {
+ return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
+ .andWhere('note.userId = :userId', { userId })
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('note.visibility = \'public\'')
+ .orWhere('note.visibility = \'home\'');
+ }))
+ .andWhere('note.localOnly = FALSE')
+ .limit(limit)
+ .getMany();
+ }
+
+ @bindThis
+ private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
if (user == null) {
reply.code(404);
return;
@@ -631,10 +732,12 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
-
this.setResponseType(request, reply);
- return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser)));
+
+ const person = redact
+ ? await this.apRendererService.renderPersonRedacted(user as MiLocalUser)
+ : await this.apRendererService.renderPerson(user as MiLocalUser);
+ return this.apRendererService.addContext(person);
}
@bindThis
@@ -687,6 +790,13 @@ export class ActivityPubServerService {
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Expose-Headers', 'Vary');
+
+ /* tell any caching proxy that they should not cache these
+ responses: we wouldn't want the proxy to return a 403 to
+ someone presenting a valid signature, or return a cached
+ response body to someone we've blocked!
+ */
+ reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
done();
});
@@ -697,16 +807,22 @@ export class ActivityPubServerService {
// note
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
-
vary(reply.raw, 'Accept');
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
const note = await this.notesRepository.findOneBy({
id: request.params.note,
visibility: In(['public', 'home']),
localOnly: false,
});
+ const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId);
+ if (reject) return;
+
if (note == null) {
reply.code(404);
return;
@@ -722,7 +838,6 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
@@ -731,10 +846,13 @@ export class ActivityPubServerService {
// note activity
fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
-
vary(reply.raw, 'Accept');
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
const note = await this.notesRepository.findOneBy({
id: request.params.note,
userHost: IsNull(),
@@ -742,18 +860,66 @@ export class ActivityPubServerService {
localOnly: false,
});
+ const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId);
+ if (reject) return;
+
if (note == null) {
reply.code(404);
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
return (this.apRendererService.addContext(await this.packActivity(note, author)));
});
+ // replies
+ fastify.get<{
+ Params: { note: string; };
+ Querystring: { page?: unknown; until_id?: unknown; };
+ }>('/notes/:note/replies', async (request, reply) => {
+ vary(reply.raw, 'Accept');
+ this.setResponseType(request, reply);
+
+ // Raw query to avoid fetching the while entity just to check access and get the user ID
+ const note = await this.notesRepository
+ .createQueryBuilder('note')
+ .andWhere({
+ id: request.params.note,
+ userHost: IsNull(),
+ visibility: In(['public', 'home']),
+ localOnly: false,
+ })
+ .select(['note.id', 'note.userId'])
+ .getRawOne<{ note_id: string, note_userId: string }>();
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId);
+ if (reject) return;
+
+ if (note == null) {
+ reply.code(404);
+ return;
+ }
+
+ const untilId = request.query.until_id;
+ if (untilId != null && typeof(untilId) !== 'string') {
+ reply.code(400);
+ return;
+ }
+
+ // If page is unset, then we just provide the outer wrapper.
+ // This is because the spec doesn't allow the wrapper to contain both elements *and* pages.
+ // We could technically do it anyway, but that may break other instances.
+ if (request.query.page !== 'true') {
+ const collection = await this.apRendererService.renderRepliesCollection(note.note_id);
+ return this.apRendererService.addContext(collection);
+ }
+
+ const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined);
+ return this.apRendererService.addContext(page);
+ });
+
// outbox
fastify.get<{
Params: { user: string; };
@@ -777,7 +943,13 @@ export class ActivityPubServerService {
// publickey
fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user, true);
+ if (reject) return;
const userId = request.params.user;
@@ -794,7 +966,6 @@ export class ActivityPubServerService {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
if (this.userEntityService.isLocalUser(user)) {
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair)));
} else {
@@ -804,10 +975,16 @@ export class ActivityPubServerService {
});
fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
+ const { reject, redact } = await this.checkAuthorizedFetch(request, reply, request.params.user, true);
+ if (reject) return;
vary(reply.raw, 'Accept');
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
const userId = request.params.user;
const user = await this.usersRepository.findOneBy({
@@ -815,29 +992,41 @@ export class ActivityPubServerService {
isSuspended: false,
});
- return await this.userInfo(request, reply, user);
+ return await this.userInfo(request, reply, user, redact);
});
fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply, request.params.acct)) return;
-
vary(reply.raw, 'Accept');
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
const acct = Acct.parse(request.params.acct);
const user = await this.usersRepository.findOneBy({
- usernameLower: acct.username,
+ usernameLower: acct.username.toLowerCase(),
host: acct.host ?? IsNull(),
isSuspended: false,
});
- return await this.userInfo(request, reply, user);
+ const { reject, redact } = await this.checkAuthorizedFetch(request, reply, user?.id, true);
+ if (reject) return;
+
+ return await this.userInfo(request, reply, user, redact);
});
//#endregion
// emoji
fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply);
+ if (reject) return;
const emoji = await this.emojisRepository.findOneBy({
host: IsNull(),
@@ -849,17 +1038,22 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji)));
});
// like
fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like });
+ const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId);
+ if (reject) return;
+
if (reaction == null) {
reply.code(404);
return;
@@ -872,14 +1066,19 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note)));
});
// follow
fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
+ const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.follower);
+ if (reject) return;
// This may be used before the follow is completed, so we do not
// check if the following exists.
@@ -900,14 +1099,16 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
});
// follow
- fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
- if (await this.shouldRefuseGetRequest(request, reply)) return;
+ fastify.get<{ Params: { followRequestId: string; } }>('/follows/:followRequestId', async (request, reply) => {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
// This may be used before the follow is completed, so we do not
// check if the following exists and only check if the follow request exists.
@@ -916,6 +1117,9 @@ export class ActivityPubServerService {
id: request.params.followRequestId,
});
+ const { reject } = await this.checkAuthorizedFetch(request, reply, followRequest?.followerId);
+ if (reject) return;
+
if (followRequest == null) {
reply.code(404);
return;
@@ -937,11 +1141,21 @@ export class ActivityPubServerService {
return;
}
- if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
});
done();
}
+
+ private async getUnsignedFetchAllowance(userId: string | undefined) {
+ const user = userId ? await this.cacheService.findLocalUserById(userId) : null;
+
+ // User system value if there is no user, or if user has deferred the choice.
+ if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') {
+ return this.meta.allowUnsignedFetch;
+ }
+
+ return user.allowUnsignedFetch;
+ }
}
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index a7e13a1b78..4ef5539cff 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -195,6 +195,10 @@ export class FileServerService {
reply.header('Content-Length', file.file.size);
if (!image) {
+ if (file.file.size > 0) {
+ reply.header('Accept-Ranges', 'bytes');
+ }
+
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
@@ -215,7 +219,6 @@ export class FileServerService {
};
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
- reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
@@ -257,6 +260,10 @@ export class FileServerService {
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', filename));
+ if (file.file.size > 0) {
+ reply.header('Accept-Ranges', 'bytes');
+ }
+
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
@@ -271,7 +278,6 @@ export class FileServerService {
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
- reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
@@ -284,6 +290,10 @@ export class FileServerService {
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
+ if (file.file.size > 0) {
+ reply.header('Accept-Ranges', 'bytes');
+ }
+
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
@@ -298,7 +308,6 @@ export class FileServerService {
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
- reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
@@ -442,6 +451,10 @@ export class FileServerService {
}
if (!image) {
+ if (file.file && file.file.size > 0) {
+ reply.header('Accept-Ranges', 'bytes');
+ }
+
if (request.headers.range && file.file && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
@@ -462,7 +475,6 @@ export class FileServerService {
};
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
- reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
@@ -519,7 +531,7 @@ export class FileServerService {
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
- { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
+ { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
@@ -663,9 +675,11 @@ export class FileServerService {
if (info.blocked) {
reply.code(429);
reply.send({
- message: 'Rate limit exceeded. Please try again later.',
- code: 'RATE_LIMIT_EXCEEDED',
- id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ error: {
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ },
});
return false;
diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts
index 6dee6ecd78..55e8827696 100644
--- a/packages/backend/src/server/NodeinfoServerService.ts
+++ b/packages/backend/src/server/NodeinfoServerService.ts
@@ -8,11 +8,11 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { MemorySingleCache } from '@/misc/cache.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import NotesChart from '@/core/chart/charts/notes.js';
import UsersChart from '@/core/chart/charts/users.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
const nodeinfo2_1path = '/nodeinfo/2.1';
@@ -25,7 +25,7 @@ export class NodeinfoServerService {
@Inject(DI.config)
private config: Config,
- private userEntityService: UserEntityService,
+ private systemAccountService: SystemAccountService,
private metaService: MetaService,
private notesChart: NotesChart,
private usersChart: UsersChart,
@@ -69,7 +69,7 @@ export class NodeinfoServerService {
const activeHalfyear = null;
const activeMonth = null;
- const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null;
+ const proxyAccount = await this.systemAccountService.fetch('proxy');
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
@@ -130,7 +130,7 @@ export class NodeinfoServerService {
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
- proxyAccountName: proxyAccount ? proxyAccount.username : null,
+ proxyAccountName: proxyAccount.username,
themeColor: meta.themeColor ?? '#86b300',
},
};
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 2c067afe88..8ff8da380a 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -7,6 +7,16 @@ import { Module } from '@nestjs/common';
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js';
+import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js';
+import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js';
+import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js';
+import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js';
+import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js';
+import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js';
+import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js';
+import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
@@ -19,14 +29,13 @@ import { ActivityPubServerService } from './ActivityPubServerService.js';
import { ApiLoggerService } from './api/ApiLoggerService.js';
import { ApiServerService } from './api/ApiServerService.js';
import { AuthenticateService } from './api/AuthenticateService.js';
-import { RateLimiterService } from './api/RateLimiterService.js';
import { SigninApiService } from './api/SigninApiService.js';
import { SigninService } from './api/SigninService.js';
import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
-import { MastoConverters } from './api/mastodon/converters.js';
+import { MastodonConverters } from './api/mastodon/MastodonConverters.js';
import { MastodonLogger } from './api/mastodon/MastodonLogger.js';
import { MastodonDataService } from './api/mastodon/MastodonDataService.js';
import { FeedService } from './web/FeedService.js';
@@ -50,6 +59,8 @@ import { ServerStatsChannelService } from './api/stream/channels/server-stats.js
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
+import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
+import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -77,8 +88,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ApiServerService,
AuthenticateService,
SkRateLimiterService,
- // No longer used, but kept for backwards compatibility
- RateLimiterService,
SigninApiService,
SigninWithPasskeyApiService,
SigninService,
@@ -93,6 +102,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
BubbleTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
+ ChatUserChannelService,
+ ChatRoomChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,
@@ -104,9 +115,19 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
OpenApiServerService,
MastodonApiServerService,
OAuth2ProviderService,
- MastoConverters,
+ MastodonConverters,
MastodonLogger,
MastodonDataService,
+ MastodonClientService,
+ ApiAccountMastodon,
+ ApiAppsMastodon,
+ ApiFilterMastodon,
+ ApiInstanceMastodon,
+ ApiNotificationsMastodon,
+ ApiSearchMastodon,
+ ApiStatusMastodon,
+ ApiTimelineMastodon,
+ ServerUtilityService,
],
exports: [
ServerService,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 690fdcfe29..2d20aa1222 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -7,7 +7,7 @@ import cluster from 'node:cluster';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import Fastify, { FastifyInstance } from 'fastify';
+import Fastify, { type FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyRawBody from 'fastify-raw-body';
import { IsNull } from 'typeorm';
@@ -105,6 +105,43 @@ export class ServerService implements OnApplicationShutdown {
serve: false,
});
+ // if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects
+ //
+ // this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com
+ //
+ // this is not required by standard but protect us from peers that did not validate final URL.
+ if (this.config.disallowExternalApRedirect) {
+ const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i;
+ fastify.addHook('onSend', (request, reply, _, done) => {
+ const location = reply.getHeader('location');
+ if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') {
+ done();
+ return;
+ }
+
+ if (!maybeApLookupRegex.test(request.headers.accept ?? '')) {
+ done();
+ return;
+ }
+
+ const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://');
+ if (effectiveLocation.startsWith(`https://${this.config.host}/`)) {
+ done();
+ return;
+ }
+
+ reply.status(406);
+ reply.removeHeader('location');
+ reply.header('content-type', 'text/plain; charset=utf-8');
+ reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
+ done(null, [
+ "Refusing to relay remote ActivityPub object lookup.",
+ "",
+ `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
+ ].join('\n'));
+ });
+ }
+
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer);
fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' });
@@ -186,18 +223,18 @@ export class ServerService implements OnApplicationShutdown {
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
- reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
+ reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');
}
});
- fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => {
- reply.header('Content-Type', 'image/png');
+ fastify.get<{ Params: { x: string } }>('/identicon/:x', (request, reply) => {
+ reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=86400');
if (this.meta.enableIdenticonGeneration) {
- return await genIdenticon(request.params.x);
+ return genIdenticon(request.params.x);
} else {
return reply.redirect('/static-assets/avatar.png');
}
@@ -256,13 +293,14 @@ export class ServerService implements OnApplicationShutdown {
if (fs.existsSync(this.config.socket)) {
fs.unlinkSync(this.config.socket);
}
- fastify.listen({ path: this.config.socket }, (err, address) => {
- if (this.config.chmodSocket) {
- fs.chmodSync(this.config.socket!, this.config.chmodSocket);
- }
- });
+
+ await fastify.listen({ path: this.config.socket });
+
+ if (this.config.chmodSocket) {
+ fs.chmodSync(this.config.socket!, this.config.chmodSocket);
+ }
} else {
- fastify.listen({ port: this.config.port, host: this.config.address });
+ await fastify.listen({ port: this.config.port, host: this.config.address });
}
await fastify.ready();
@@ -274,6 +312,13 @@ export class ServerService implements OnApplicationShutdown {
await this.#fastify.close();
}
+ /**
+ * Get the Fastify instance for testing.
+ */
+ public get fastify(): FastifyInstance {
+ return this.#fastify;
+ }
+
@bindThis
async onApplicationShutdown(signal: string): Promise<void> {
await this.dispose();
diff --git a/packages/backend/src/server/ServerUtilityService.ts b/packages/backend/src/server/ServerUtilityService.ts
new file mode 100644
index 0000000000..00eb97f679
--- /dev/null
+++ b/packages/backend/src/server/ServerUtilityService.ts
@@ -0,0 +1,162 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import querystring from 'querystring';
+import multipart from '@fastify/multipart';
+import { Inject, Injectable } from '@nestjs/common';
+import { FastifyInstance } from 'fastify';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { saveToTempFile } from '@/misc/create-temp.js';
+
+@Injectable()
+export class ServerUtilityService {
+ constructor(
+ @Inject(DI.config)
+ private readonly config: Config,
+ ) {}
+
+ public addMultipartFormDataContentType(fastify: FastifyInstance): void {
+ fastify.register(multipart, {
+ limits: {
+ fileSize: this.config.maxFileSize,
+ files: 1,
+ },
+ });
+
+ // Default behavior saves files to memory - we don't want that!
+ // Store to temporary file instead, and copy the body fields while we're at it.
+ fastify.addHook<{ Body?: Record<string, string | string[] | undefined> }>('preValidation', async request => {
+ if (request.isMultipart()) {
+ // We can't use saveRequestFiles() because it erases all the data fields.
+ // Instead, recreate it manually.
+ // https://github.com/fastify/fastify-multipart/issues/549
+
+ for await (const part of request.parts()) {
+ if (part.type === 'field') {
+ const k = part.fieldname;
+ const v = part.value;
+ const body = request.body ??= {};
+
+ // Value can be string, buffer, or undefined.
+ // We only support the first one.
+ if (typeof(v) !== 'string') continue;
+
+ // This is just progressive conversion from undefined -> string -> string[]
+ if (!body[k]) {
+ body[k] = v;
+ } else if (Array.isArray(body[k])) {
+ body[k].push(v);
+ } else {
+ body[k] = [body[k], v];
+ }
+ } else { // Otherwise it's a file
+ try {
+ const [filepath] = await saveToTempFile(part.file);
+
+ const tmpUploads = (request.tmpUploads ??= []);
+ tmpUploads.push(filepath);
+
+ const requestSavedFiles = (request.savedRequestFiles ??= []);
+ requestSavedFiles.push({
+ ...part,
+ filepath,
+ });
+ } catch (e) {
+ // Cleanup to avoid file leak in case of errors
+ await request.cleanRequestFiles();
+ request.tmpUploads = null;
+ request.savedRequestFiles = null;
+ throw e;
+ }
+ }
+ }
+ }
+ });
+ }
+
+ public addFormUrlEncodedContentType(fastify: FastifyInstance) {
+ fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
+ let body = '';
+ payload.on('data', (data) => {
+ body += data;
+ });
+ payload.on('end', () => {
+ try {
+ const parsed = querystring.parse(body);
+ done(null, parsed);
+ } catch (e) {
+ done(e as Error);
+ }
+ });
+ payload.on('error', done);
+ });
+ }
+
+ public addCORS(fastify: FastifyInstance) {
+ fastify.addHook('preHandler', (_, reply, done) => {
+ // Allow web-based clients to connect from other origins.
+ reply.header('Access-Control-Allow-Origin', '*');
+
+ // Mastodon uses all types of request methods.
+ reply.header('Access-Control-Allow-Methods', '*');
+
+ // Allow web-based clients to access Link header - required for mastodon pagination.
+ // https://stackoverflow.com/a/54928828
+ // https://docs.joinmastodon.org/api/guidelines/#pagination
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers
+ reply.header('Access-Control-Expose-Headers', 'Link');
+
+ // Cache to avoid extra pre-flight requests
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
+ reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds
+
+ done();
+ });
+ }
+
+ public addFlattenedQueryType(fastify: FastifyInstance) {
+ // Remove trailing "[]" from query params
+ fastify.addHook<{ Querystring?: Record<string, string | string[] | undefined> }>('preValidation', (request, _reply, done) => {
+ if (!request.query || typeof(request.query) !== 'object') {
+ return done();
+ }
+
+ for (const key of Object.keys(request.query)) {
+ if (!key.endsWith('[]')) {
+ continue;
+ }
+ if (request.query[key] == null) {
+ continue;
+ }
+
+ const newKey = key.substring(0, key.length - 2);
+ const newValue = request.query[key];
+ const oldValue = request.query[newKey];
+
+ // Move the value to the correct key
+ if (oldValue != null) {
+ if (Array.isArray(oldValue)) {
+ // Works for both array and single values
+ request.query[newKey] = oldValue.concat(newValue);
+ } else if (Array.isArray(newValue)) {
+ // Preserve order
+ request.query[newKey] = [oldValue, ...newValue];
+ } else {
+ // Preserve order
+ request.query[newKey] = [oldValue, newValue];
+ }
+ } else {
+ request.query[newKey] = newValue;
+ }
+
+ // Remove the invalid key
+ delete request.query[key];
+ }
+
+ return done();
+ });
+ }
+}
diff --git a/packages/backend/src/server/SkRateLimiterService.md b/packages/backend/src/server/SkRateLimiterService.md
index c8a2b4e85c..55786664e1 100644
--- a/packages/backend/src/server/SkRateLimiterService.md
+++ b/packages/backend/src/server/SkRateLimiterService.md
@@ -34,13 +34,18 @@ Header meanings and usage have been devised by adapting common patterns to work
## Performance
-SkRateLimiterService makes between 1 and 4 redis transactions per rate limit check.
+SkRateLimiterService makes between 0 and 4 redis transactions per rate limit check.
The first call is read-only, while the others perform at least one write operation.
+No calls are made if a client has already been blocked at least once, as the block status is stored in a short-term memory cache.
Two integer keys are stored per client/subject, and both expire together after the maximum duration of the limit.
While performance has not been formally tested, it's expected that SkRateLimiterService has an impact roughly on par with the legacy RateLimiterService.
Redis memory usage should be notably lower due to the reduced number of keys and avoidance of set / array constructions.
If redis load does become a concern, then a dedicated node can be assigned via the `redisForRateLimit` config setting.
+To prevent Redis DoS, SkRateLimiterService internally tracks the number of concurrent requests for each unique client/endpoint combination.
+If the number of requests exceeds the limit's maximum value, then any further requests are automatically rejected.
+The lockout will automatically end when the number of active requests drops to within the limit value.
+
## Concurrency and Multi-Node Correctness
To provide consistency across multi-node environments, leaky bucket is implemented with only atomic operations (`Increment`, `Decrement`, `Add`, and `Subtract`).
@@ -54,6 +59,12 @@ Any possible conflict would have to occur within a few-milliseconds window, whic
This error does not compound, as all further operations are relative (Increment and Add).
Thus, it's considered an acceptable tradeoff given the limitations imposed by Redis and ioredis.
+In-process memory caches are used sparingly to avoid consistency problems.
+Besides the role factor cache, there is one "important" cache which directly impacts limit calculations: the lockout cache.
+This cache stores response data for blocked limits, preventing repeated calls to redis if a client ignores the 429 errors and continues making requests.
+Consistency is guaranteed by only caching blocked limits (allowances are not cached), and by limiting cached data to the duration of the block.
+This ensures that stale limit info is never used.
+
## Algorithm Pseudocode
The Atomic Leaky Bucket algorithm is described here, in pseudocode:
diff --git a/packages/backend/src/server/SkRateLimiterService.ts b/packages/backend/src/server/SkRateLimiterService.ts
index 30bf092e4f..8978318045 100644
--- a/packages/backend/src/server/SkRateLimiterService.ts
+++ b/packages/backend/src/server/SkRateLimiterService.ts
@@ -17,10 +17,23 @@ import type { RoleService } from '@/core/RoleService.js';
// Required because MemoryKVCache doesn't support null keys.
const defaultUserKey = '';
+interface ParsedLimit {
+ key: string;
+ now: number;
+ bucketSize: number;
+ dripRate: number;
+ dripSize: number;
+ fullResetMs: number;
+ fullResetSec: number;
+}
+
@Injectable()
export class SkRateLimiterService {
// 1-minute cache interval
private readonly factorCache = new MemoryKVCache<number>(1000 * 60);
+ // 10-second cache interval
+ private readonly lockoutCache = new MemoryKVCache<number>(1000 * 10);
+ private readonly requestCounts = new Map<string, number>();
private readonly disabled: boolean;
constructor(
@@ -58,6 +71,8 @@ export class SkRateLimiterService {
}
const actor = typeof(actorOrUser) === 'object' ? actorOrUser.id : actorOrUser;
+ const actorKey = `@${actor}#${limit.key}`;
+
const userCacheKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : defaultUserKey;
const userRoleKey = typeof(actorOrUser) === 'object' ? actorOrUser.id : null;
const factor = this.factorCache.get(userCacheKey) ?? await this.factorCache.fetch(userCacheKey, async () => {
@@ -73,25 +88,81 @@ export class SkRateLimiterService {
throw new Error(`Rate limit factor is zero or negative: ${factor}`);
}
+ const parsedLimit = this.parseLimit(limit, factor);
+ if (parsedLimit == null) {
+ return disabledLimitInfo;
+ }
+
+ // Fast-path to avoid extra redis calls for blocked clients
+ const lockout = this.getLockout(actorKey, parsedLimit);
+ if (lockout) {
+ return lockout;
+ }
+
+ // Fast-path to avoid queuing requests that are guaranteed to fail
+ const overflow = this.incrementOverflow(actorKey, parsedLimit);
+ if (overflow) {
+ return overflow;
+ }
+
+ try {
+ const info = await this.limitBucket(parsedLimit, actor);
+
+ // Store blocked status to avoid hammering redis
+ if (info.blocked) {
+ this.lockoutCache.set(actorKey, info.resetMs);
+ }
+
+ return info;
+ } finally {
+ this.decrementOverflow(actorKey);
+ }
+ }
+
+ private getLockout(lockoutKey: string, limit: ParsedLimit): LimitInfo | null {
+ const lockoutReset = this.lockoutCache.get(lockoutKey);
+ if (!lockoutReset) {
+ // Not blocked, proceed with redis check
+ return null;
+ }
+
+ if (limit.now >= lockoutReset) {
+ // Block expired, clear and proceed with redis check
+ this.lockoutCache.delete(lockoutKey);
+ return null;
+ }
+
+ // Lockout is still active, pre-emptively reject the request
+ return {
+ blocked: true,
+ remaining: 0,
+ resetMs: limit.fullResetMs,
+ resetSec: limit.fullResetSec,
+ fullResetMs: limit.fullResetMs,
+ fullResetSec: limit.fullResetSec,
+ };
+ }
+
+ private parseLimit(limit: Keyed<RateLimit>, factor: number): ParsedLimit | null {
if (isLegacyRateLimit(limit)) {
- return await this.limitLegacy(limit, actor, factor);
+ return this.parseLegacyLimit(limit, factor);
} else {
- return await this.limitBucket(limit, actor, factor);
+ return this.parseBucketLimit(limit, factor);
}
}
- private async limitLegacy(limit: Keyed<LegacyRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
+ private parseLegacyLimit(limit: Keyed<LegacyRateLimit>, factor: number): ParsedLimit | null {
if (hasMaxLimit(limit)) {
- return await this.limitLegacyMinMax(limit, actor, factor);
+ return this.parseLegacyMinMax(limit, factor);
} else if (hasMinLimit(limit)) {
- return await this.limitLegacyMinOnly(limit, actor, factor);
+ return this.parseLegacyMinOnly(limit, factor);
} else {
- return disabledLimitInfo;
+ return null;
}
}
- private async limitLegacyMinMax(limit: Keyed<MaxLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (limit.duration === 0) return disabledLimitInfo;
+ private parseLegacyMinMax(limit: Keyed<MaxLegacyLimit>, factor: number): ParsedLimit | null {
+ if (limit.duration === 0) return null;
if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`);
if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`);
@@ -104,35 +175,30 @@ export class SkRateLimiterService {
// Calculate final dripRate from dripSize and duration/max
const dripRate = Math.max(Math.round(limit.duration / (limit.max / dripSize)), 1);
- const bucketLimit: Keyed<BucketRateLimit> = {
+ return this.parseBucketLimit({
type: 'bucket',
key: limit.key,
size: limit.max,
dripRate,
dripSize,
- };
- return await this.limitBucket(bucketLimit, actor, factor);
+ }, factor);
}
- private async limitLegacyMinOnly(limit: Keyed<MinLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> {
- if (limit.minInterval === 0) return disabledLimitInfo;
+ private parseLegacyMinOnly(limit: Keyed<MinLegacyLimit>, factor: number): ParsedLimit | null {
+ if (limit.minInterval === 0) return null;
if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
const dripRate = Math.max(Math.round(limit.minInterval), 1);
- const bucketLimit: Keyed<BucketRateLimit> = {
+ return this.parseBucketLimit({
type: 'bucket',
key: limit.key,
size: 1,
dripRate,
dripSize: 1,
- };
- return await this.limitBucket(bucketLimit, actor, factor);
+ }, factor);
}
- /**
- * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details.
- */
- private async limitBucket(limit: Keyed<BucketRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
+ private parseBucketLimit(limit: Keyed<BucketRateLimit>, factor: number): ParsedLimit {
if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`);
if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`);
if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`);
@@ -142,7 +208,27 @@ export class SkRateLimiterService {
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
const dripRate = Math.ceil(limit.dripRate ?? 1000);
const dripSize = Math.ceil(limit.dripSize ?? 1);
- const expirationSec = Math.max(Math.ceil((dripRate * Math.ceil(bucketSize / dripSize)) / 1000), 1);
+ const fullResetMs = dripRate * Math.ceil(bucketSize / dripSize);
+ const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1);
+
+ return {
+ key: limit.key,
+ now,
+ bucketSize,
+ dripRate,
+ dripSize,
+ fullResetMs,
+ fullResetSec,
+ };
+ }
+
+ /**
+ * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details.
+ */
+ private async limitBucket(limit: ParsedLimit, actor: string): Promise<LimitInfo> {
+ // 0 - Calculate (extracted to other function)
+ const { now, bucketSize, dripRate, dripSize } = limit;
+ const expirationSec = limit.fullResetSec;
// 1 - Read
const counterKey = createLimitKey(limit, actor, 'c');
@@ -262,13 +348,44 @@ export class SkRateLimiterService {
return responses;
}
+
+ private incrementOverflow(actorKey: string, limit: ParsedLimit): LimitInfo | null {
+ const oldCount = this.requestCounts.get(actorKey) ?? 0;
+
+ if (oldCount >= limit.bucketSize) {
+ // Overflow, pre-emptively reject the request
+ return {
+ blocked: true,
+ remaining: 0,
+ resetMs: limit.fullResetMs,
+ resetSec: limit.fullResetSec,
+ fullResetMs: limit.fullResetMs,
+ fullResetSec: limit.fullResetSec,
+ };
+ }
+
+ // No overflow, increment and continue to redis
+ this.requestCounts.set(actorKey, oldCount + 1);
+ return null;
+ }
+
+ private decrementOverflow(actorKey: string): void {
+ const count = this.requestCounts.get(actorKey);
+ if (count) {
+ if (count > 1) {
+ this.requestCounts.set(actorKey, count - 1);
+ } else {
+ this.requestCounts.delete(actorKey);
+ }
+ }
+ }
}
// Not correct, but good enough for the basic commands we use.
type RedisResult = string | null;
type RedisCommand = [command: string, ...args: unknown[]];
-function createLimitKey(limit: Keyed<RateLimit>, actor: string, value: string): string {
+function createLimitKey(limit: ParsedLimit, actor: string, value: string): string {
return `rl_${actor}_${limit.key}_${value}`;
}
diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts
index 70f672e5d9..f48310c50f 100644
--- a/packages/backend/src/server/WellKnownServerService.ts
+++ b/packages/backend/src/server/WellKnownServerService.ts
@@ -8,7 +8,7 @@ import { IsNull } from 'typeorm';
import vary from 'vary';
import fastifyAccepts from '@fastify/accepts';
import { DI } from '@/di-symbols.js';
-import type { UsersRepository } from '@/models/_.js';
+import type { MiMeta, UsersRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js';
import type { MiUser } from '@/models/User.js';
@@ -26,6 +26,9 @@ export class WellKnownServerService {
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -66,6 +69,11 @@ export class WellKnownServerService {
});
fastify.get('/.well-known/host-meta', async (request, reply) => {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
reply.header('Content-Type', xrd);
return XRD({ element: 'Link', attributes: {
rel: 'lrdd',
@@ -75,6 +83,11 @@ export class WellKnownServerService {
});
fastify.get('/.well-known/host-meta.json', async (request, reply) => {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
reply.header('Content-Type', 'application/json');
return {
links: [{
@@ -86,6 +99,11 @@ export class WellKnownServerService {
});
fastify.get('/.well-known/nodeinfo', async (request, reply) => {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
return { links: this.nodeinfoServerService.getLinks() };
});
@@ -99,6 +117,11 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
*/
fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => {
+ if (this.meta.federation === 'none') {
+ reply.code(403);
+ return;
+ }
+
const fromId = (id: MiUser['id']): FindOptionsWhere<MiUser> => ({
id,
host: IsNull(),
@@ -115,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
!acct.host || acct.host === this.config.host.toLowerCase() ? {
- usernameLower: acct.username,
+ usernameLower: acct.username.toLowerCase(),
host: IsNull(),
isSuspended: false,
} : 422;
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 5ce358d68f..0d2dafd556 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -213,6 +213,7 @@ export class ApiCallService implements OnApplicationShutdown {
? request.headers.authorization.slice(7)
: fields['i'];
if (token != null && typeof token !== 'string') {
+ cleanup();
reply.code(400);
return;
}
@@ -223,6 +224,7 @@ export class ApiCallService implements OnApplicationShutdown {
}, request, reply).then((res) => {
this.send(reply, res);
}).catch((err: ApiError) => {
+ cleanup();
this.#sendApiError(reply, err);
});
@@ -230,6 +232,7 @@ export class ApiCallService implements OnApplicationShutdown {
this.logIp(request, user);
}
}).catch(err => {
+ cleanup();
this.#sendAuthenticationError(reply, err);
});
}
@@ -369,7 +372,7 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
- if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) {
+ if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
throw new ApiError({
@@ -389,10 +392,10 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
- if (ep.meta.requireRolePolicy != null && !user!.isRoot) {
+ if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
const policies = await this.roleService.getUserPolicies(user!.id);
- if (!policies[ep.meta.requireRolePolicy] && !myRoles.some(r => r.isAdministrator)) {
+ if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) {
throw new ApiError({
message: 'You are not assigned to a required role.',
code: 'ROLE_PERMISSION_DENIED',
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 0d77309537..12459d5698 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -6,7 +6,6 @@
import { Inject, Injectable } from '@nestjs/common';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
-import fastifyCookie from '@fastify/cookie';
import { ModuleRef } from '@nestjs/core';
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { Config } from '@/config.js';
@@ -57,8 +56,6 @@ export class ApiServerService {
},
});
- fastify.register(fastifyCookie, {});
-
// Prevent cache
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts
index 690ff2e022..397626c49d 100644
--- a/packages/backend/src/server/api/AuthenticateService.ts
+++ b/packages/backend/src/server/api/AuthenticateService.ts
@@ -11,7 +11,7 @@ import type { MiAccessToken } from '@/models/AccessToken.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { MiApp } from '@/models/App.js';
import { CacheService } from '@/core/CacheService.js';
-import isNativeToken from '@/misc/is-native-token.js';
+import { isNativeUserToken } from '@/misc/token.js';
import { bindThis } from '@/decorators.js';
export class AuthenticationError extends Error {
@@ -46,7 +46,7 @@ export class AuthenticateService implements OnApplicationShutdown {
return [null, null];
}
- if (isNativeToken(token)) {
+ if (isNativeUserToken(token)) {
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>);
@@ -84,6 +84,8 @@ export class AuthenticateService implements OnApplicationShutdown {
return [user, {
id: accessToken.id,
permission: app.permission,
+ appId: app.id,
+ app,
} as MiAccessToken];
} else {
return [user, accessToken];
diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts
deleted file mode 100644
index 879529090f..0000000000
--- a/packages/backend/src/server/api/RateLimiterService.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import Limiter from 'ratelimiter';
-import * as Redis from 'ioredis';
-import { DI } from '@/di-symbols.js';
-import type Logger from '@/logger.js';
-import { LoggerService } from '@/core/LoggerService.js';
-import { bindThis } from '@/decorators.js';
-import { LegacyRateLimit } from '@/misc/rate-limit-utils.js';
-import type { IEndpointMeta } from './endpoints.js';
-
-/** @deprecated Use SkRateLimiterService instead */
-@Injectable()
-export class RateLimiterService {
- private logger: Logger;
- private disabled = false;
-
- constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
-
- private loggerService: LoggerService,
- ) {
- this.logger = this.loggerService.getLogger('limiter');
-
- if (process.env.NODE_ENV !== 'production') {
- this.disabled = true;
- }
- }
-
- @bindThis
- public limit(limitation: LegacyRateLimit & { key: NonNullable<string> }, actor: string, factor = 1) {
- return new Promise<void>((ok, reject) => {
- if (this.disabled) ok();
-
- // Short-term limit
- const minP = (): void => {
- const minIntervalLimiter = new Limiter({
- id: `${actor}:${limitation.key}:min`,
- duration: limitation.minInterval! * factor,
- max: 1,
- db: this.redisClient,
- });
-
- minIntervalLimiter.get((err, info) => {
- if (err) {
- return reject({ code: 'ERR', info });
- }
-
- this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
-
- if (info.remaining === 0) {
- return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
- } else {
- if (hasLongTermLimit) {
- return maxP();
- } else {
- return ok();
- }
- }
- });
- };
-
- // Long term limit
- const maxP = (): void => {
- const limiter = new Limiter({
- id: `${actor}:${limitation.key}`,
- duration: limitation.duration! * factor,
- max: limitation.max! / factor,
- db: this.redisClient,
- });
-
- limiter.get((err, info) => {
- if (err) {
- return reject({ code: 'ERR', info });
- }
-
- this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
-
- if (info.remaining === 0) {
- return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
- } else {
- return ok();
- }
- });
- };
-
- const hasShortTermLimit = typeof limitation.minInterval === 'number';
-
- const hasLongTermLimit =
- typeof limitation.duration === 'number' &&
- typeof limitation.max === 'number';
-
- if (hasShortTermLimit) {
- minP();
- } else if (hasLongTermLimit) {
- maxP();
- } else {
- ok();
- }
- });
- }
-}
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 72712bce60..7f371ea309 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -35,7 +35,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
// Up to 10 attempts, then 1 per minute
const signinRateLimit: Keyed<RateLimit> = {
key: 'signin',
- max: 10,
+ type: 'bucket',
+ size: 10,
dripRate: 1000 * 60,
};
@@ -146,7 +147,7 @@ export class SigninApiService {
if (isSystemAccount(user)) {
return error(403, {
- id: 's8dhsj9s-a93j-493j-ja9k-kas9sj20aml2',
+ id: 'ba4ba3bc-ef1e-4c74-ad88-1d2b7d69a100',
});
}
@@ -243,7 +244,7 @@ export class SigninApiService {
if (profile.password!.startsWith('$2')) {
const newHash = await argon2.hash(password);
this.userProfilesRepository.update(user.id, {
- password: newHash
+ password: newHash,
});
}
if (!this.meta.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
@@ -267,7 +268,7 @@ export class SigninApiService {
if (profile.password!.startsWith('$2')) {
const newHash = await argon2.hash(password);
this.userProfilesRepository.update(user.id, {
- password: newHash
+ password: newHash,
});
}
await this.userAuthService.twoFactorAuthenticate(profile, token);
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index 42137d3298..cb71047a24 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
@@ -205,7 +204,6 @@ export class SignupApiService {
const code = secureRndstr(16, { chars: L_CHARS });
// Generate hash of password
- //const salt = await bcrypt.genSalt(8);
const hash = await argon2.hash(password);
const pendingUser = await this.userPendingsRepository.insertOne({
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 6e7abcfae6..eaeaecb1c2 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -10,8 +10,9 @@ import * as WebSocket from 'ws';
import proxyAddr from 'proxy-addr';
import ms from 'ms';
import { DI } from '@/di-symbols.js';
-import type { UsersRepository, MiAccessToken } from '@/models/_.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
+import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js';
+import type { Config } from '@/config.js';
+import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@@ -25,12 +26,16 @@ import { AuthenticateService, AuthenticationError } from './AuthenticateService.
import MainStreamConnection from './stream/Connection.js';
import { ChannelsService } from './stream/ChannelsService.js';
import type * as http from 'node:http';
-import type { IEndpointMeta } from './endpoints.js';
+
+// Maximum number of simultaneous connections by client (user ID or IP address).
+// Excess connections will be closed automatically.
+const MAX_CONNECTIONS_PER_CLIENT = 32;
@Injectable()
export class StreamingApiServerService {
#wss: WebSocket.WebSocketServer;
#connections = new Map<WebSocket.WebSocket, number>();
+ #connectionsByClient = new Map<string, Set<WebSocket.WebSocket>>(); // key: IP / user ID -> value: connection
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
constructor(
@@ -41,7 +46,6 @@ export class StreamingApiServerService {
private usersRepository: UsersRepository,
private cacheService: CacheService,
- private noteReadService: NoteReadService,
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
private notificationService: NotificationService,
@@ -49,22 +53,17 @@ export class StreamingApiServerService {
private channelFollowingService: ChannelFollowingService,
private rateLimiterService: SkRateLimiterService,
private loggerService: LoggerService,
+
+ @Inject(DI.config)
+ private config: Config,
) {
}
@bindThis
private async rateLimitThis(
- user: MiLocalUser | null | undefined,
- requestIp: string,
- limit: IEndpointMeta['limit'] & { key: NonNullable<string> },
+ limitActor: MiUser | string,
+ limit: Keyed<RateLimit>,
) : Promise<boolean> {
- let limitActor: string | MiLocalUser;
- if (user) {
- limitActor = user;
- } else {
- limitActor = getIpHash(requestIp);
- }
-
// Rate limit
const rateLimit = await this.rateLimiterService.limit(limit, limitActor);
return rateLimit.blocked;
@@ -74,6 +73,7 @@ export class StreamingApiServerService {
public attach(server: http.Server): void {
this.#wss = new WebSocket.WebSocketServer({
noServer: true,
+ perMessageDeflate: this.config.websocketCompression,
});
server.on('upgrade', async (request, socket, head) => {
@@ -83,21 +83,6 @@ export class StreamingApiServerService {
return;
}
- // ServerServices sets `trustProxy: true`, which inside
- // fastify/request.js ends up calling `proxyAddr` in this way,
- // so we do the same
- const requestIp = proxyAddr(request, () => { return true; } );
-
- if (await this.rateLimitThis(null, requestIp, {
- key: 'wsconnect',
- duration: ms('5min'),
- max: 32,
- })) {
- socket.write('HTTP/1.1 429 Rate Limit Exceeded\r\n\r\n');
- socket.destroy();
- return;
- }
-
const q = new URL(request.url, `http://${request.headers.host}`).searchParams;
let user: MiLocalUser | null = null;
@@ -135,21 +120,55 @@ export class StreamingApiServerService {
return;
}
+ // ServerServices sets `trustProxy: true`, which inside fastify/request.js ends up calling `proxyAddr` in this way, so we do the same.
+ const requestIp = proxyAddr(request, () => true );
+ const limitActor = user?.id ?? getIpHash(requestIp);
+ if (await this.rateLimitThis(limitActor, {
+ // Up to 32 connections, then 1 every 10 seconds
+ type: 'bucket',
+ key: 'wsconnect',
+ size: 32,
+ dripRate: 10 * 1000,
+ })) {
+ socket.write('HTTP/1.1 429 Rate Limit Exceeded\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ // For performance and code simplicity, obtain and hold this reference for the lifetime of the connection.
+ // This should be safe because the map entry should only be deleted after *all* connections close.
+ let connectionsForClient = this.#connectionsByClient.get(limitActor);
+ if (!connectionsForClient) {
+ connectionsForClient = new Set();
+ this.#connectionsByClient.set(limitActor, connectionsForClient);
+ }
+
+ // Close excess connections
+ while (connectionsForClient.size >= MAX_CONNECTIONS_PER_CLIENT) {
+ // Set maintains insertion order, so first entry is the oldest.
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const oldestConnection = connectionsForClient.values().next().value!;
+
+ // Technically, the close() handler should remove this entry.
+ // But if that ever fails, then we could enter an infinite loop.
+ // We manually remove the connection here just in case.
+ oldestConnection.close(1008, 'Disconnected - too many simultaneous connections');
+ connectionsForClient.delete(oldestConnection);
+ }
+
const rateLimiter = () => {
- // rather high limit, because when catching up at the top of a
- // timeline, the frontend may render many many notes, each of
- // which causes a message via `useNoteCapture` to ask for
- // realtime updates of that note
- return this.rateLimitThis(user, requestIp, {
+ // Rather high limit because when catching up at the top of a timeline, the frontend may render many many notes.
+ // Each of which causes a message via `useNoteCapture` to ask for realtime updates of that note.
+ return this.rateLimitThis(limitActor, {
+ type: 'bucket',
key: 'wsmessage',
- duration: ms('2sec'),
- max: 4096,
+ size: 4096, // Allow spikes of up to 4096
+ dripRate: 50, // Then once every 50ms (20/second rate)
});
};
const stream = new MainStreamConnection(
this.channelsService,
- this.noteReadService,
this.notificationService,
this.cacheService,
this.channelFollowingService,
@@ -161,6 +180,19 @@ export class StreamingApiServerService {
await stream.init();
this.#wss.handleUpgrade(request, socket, head, (ws) => {
+ connectionsForClient.add(ws);
+
+ // Call before emit() in case it throws an error.
+ // We don't want to leave dangling references!
+ ws.once('close', () => {
+ connectionsForClient.delete(ws);
+
+ // Make sure we don't leak the Set objects!
+ if (connectionsForClient.size < 1) {
+ this.#connectionsByClient.delete(limitActor);
+ }
+ });
+
this.#wss.emit('connection', ws, request, {
stream, user, app,
});
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index a641a14448..a78c3e9ae6 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -72,8 +72,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js';
export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js';
export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js';
export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js';
-export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js';
+export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js';
+export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js';
+export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js';
+export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js';
+export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js';
export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js';
+export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js';
+export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js';
export * as 'admin/reject-quotes' from './endpoints/admin/reject-quotes.js';
export * as 'admin/relays/add' from './endpoints/admin/relays/add.js';
export * as 'admin/relays/list' from './endpoints/admin/relays/list.js';
@@ -109,6 +115,7 @@ export * as 'admin/unsilence-user' from './endpoints/admin/unsilence-user.js';
export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js';
export * as 'admin/update-abuse-user-report' from './endpoints/admin/update-abuse-user-report.js';
export * as 'admin/update-meta' from './endpoints/admin/update-meta.js';
+export * as 'admin/update-proxy-account' from './endpoints/admin/update-proxy-account.js';
export * as 'admin/update-user-note' from './endpoints/admin/update-user-note.js';
export * as 'announcements' from './endpoints/announcements.js';
export * as 'announcements/show' from './endpoints/announcements/show.js';
@@ -121,6 +128,7 @@ export * as 'antennas/update' from './endpoints/antennas/update.js';
export * as 'ap/get' from './endpoints/ap/get.js';
export * as 'ap/show' from './endpoints/ap/show.js';
export * as 'app/create' from './endpoints/app/create.js';
+export * as 'app/current' from './endpoints/app/current.js';
export * as 'app/show' from './endpoints/app/show.js';
export * as 'auth/accept' from './endpoints/auth/accept.js';
export * as 'auth/session/generate' from './endpoints/auth/session/generate.js';
@@ -273,7 +281,6 @@ export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped.
export * as 'i/page-likes' from './endpoints/i/page-likes.js';
export * as 'i/pages' from './endpoints/i/pages.js';
export * as 'i/pin' from './endpoints/i/pin.js';
-export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js';
export * as 'i/read-announcement' from './endpoints/i/read-announcement.js';
export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js';
export * as 'i/registry/get' from './endpoints/i/registry/get.js';
@@ -418,4 +425,28 @@ export * as 'users/search' from './endpoints/users/search.js';
export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js';
export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
+export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js';
+export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js';
+export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
+export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
+export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
+export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js';
+export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
+export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
+export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
+export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js';
+export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js';
+export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js';
+export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js';
+export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js';
+export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js';
+export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js';
+export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js';
+export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js';
+export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js';
+export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js';
+export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js';
+export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
+export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
+export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index fd6b9bb14b..0ba041c536 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -40,7 +40,7 @@ interface IEndpointMetaBase {
*/
readonly requireAdmin?: boolean;
- readonly requireRolePolicy?: KeyOf<'RolePolicies'>;
+ readonly requiredRolePolicy?: KeyOf<'RolePolicies'>;
/**
* 引っ越し済みのユーザーによるリクエストを禁止するか
@@ -100,7 +100,7 @@ export type IEndpointMeta = (Omit<IEndpointMetaBase, 'requireCrential' | 'requir
}) | (Omit<IEndpointMetaBase, 'requireAdmin' | 'kind'> & {
requireAdmin: true,
kind: (typeof permissions)[number],
-})
+});
export interface IEndpoint {
name: string;
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
index 1a47f56bc6..88490800cf 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -5,10 +5,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UsersRepository } from '@/models/_.js';
+import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -90,20 +89,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config)
private config: Config,
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private userEntityService: UserEntityService,
private signupService: SignupService,
- private instanceActorService: InstanceActorService,
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
- const realUsers = await this.instanceActorService.realLocalUsersPresent();
- if (!realUsers && me == null && token == null) {
+ if (this.serverSettings.rootUserId == null && me == null && token == null) {
// 初回セットアップの場合
if (this.config.setupPassword != null) {
// 初期パスワードが設定されている場合
@@ -127,7 +127,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// Anonymous access is only allowed for initial instance setup (this check may be redundant)
- if (!me && realUsers) {
+ if (!me && this.serverSettings.rootUserId != null) {
throw new ApiError(meta.errors.noCredential);
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
index ece1984cff..d04f52dd64 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts
@@ -42,10 +42,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('user not found');
}
- if (user.isRoot) {
- throw new Error('cannot delete a root account');
- }
-
await this.deleteAccoountService.deleteAccount(user, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts
index 87d80cbe80..0121c302ac 100644
--- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageAvatarDecorations',
+ requiredRolePolicy: 'canManageAvatarDecorations',
kind: 'write:admin:avatar-decorations',
res: {
diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts
index 3a5673d99d..13660d0b8c 100644
--- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts
+++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts
@@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageAvatarDecorations',
+ requiredRolePolicy: 'canManageAvatarDecorations',
kind: 'write:admin:avatar-decorations',
errors: {
},
diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts
index d785f085ac..d4d9a7235b 100644
--- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts
@@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageAvatarDecorations',
+ requiredRolePolicy: 'canManageAvatarDecorations',
kind: 'read:admin:avatar-decorations',
res: {
diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts
index 34b3b5a11f..22476a6888 100644
--- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts
@@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageAvatarDecorations',
+ requiredRolePolicy: 'canManageAvatarDecorations',
kind: 'write:admin:avatar-decorations',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
index 795b579041..56db393996 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 1c5316a002..5ef8307df0 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -16,7 +16,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
index 07ffa0b1c7..cbf78ada3e 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
@@ -17,7 +17,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
index cec9f700c3..7993edcc07 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts
@@ -11,7 +11,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
index 50c45b6ac5..87ed3f5f18 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts
@@ -11,7 +11,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
index ee7706f31a..921ecacaf3 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts
@@ -13,7 +13,7 @@ import { DI } from '@/di-symbols.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
} as const;
export const paramDef = {
@@ -33,7 +33,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly driveFilesRepository: DriveFilesRepository,
) {
super(meta, paramDef, async (ps, me) => {
- const file = await driveFilesRepository.findOneByOrFail({ id: ps.fileId });
+ const file = await this.driveFilesRepository.findOneByOrFail({ id: ps.fileId });
await this.moderationLogService.log(me, 'importCustomEmojis', {
fileName: file.name,
});
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
index 1182918ea2..7982c1f0bd 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts
@@ -16,7 +16,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'read:admin:emoji',
res: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
index f35a6667f4..b1b8e63d2f 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts
@@ -16,7 +16,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'read:admin:emoji',
res: {
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
index 066eb1c7d9..2d8867b9fd 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
index 8980ef0c86..8086af8ed5 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
index 2510349210..5d3b39d7da 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
index a0205ae24a..4b916508a7 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index fd6db9c4ab..492122422c 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -14,7 +14,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'write:admin:emoji',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts
index 5695866265..85e3cd0477 100644
--- a/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts
+++ b/packages/backend/src/server/api/endpoints/admin/gen-vapid-keys.ts
@@ -26,7 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const keys = await generateVAPIDKeys();
-
+
+ // TODO add moderation log
+
return { public: keys.publicKey, private: keys.privateKey };
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 436dcf27cb..fe8ca012b2 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -9,6 +9,8 @@ import { MetaService } from '@/core/MetaService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { instanceUnsignedFetchOptions } from '@/const.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
export const meta = {
tags: ['meta'],
@@ -264,7 +266,7 @@ export const meta = {
},
proxyAccountId: {
type: 'string',
- optional: false, nullable: true,
+ optional: false, nullable: false,
format: 'id',
},
email: {
@@ -443,6 +445,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ translationTimeout: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
deeplAuthKey: {
type: 'string',
optional: false, nullable: true,
@@ -459,6 +465,14 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ libreTranslateURL: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ libreTranslateKey: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
defaultDarkTheme: {
type: 'string',
optional: false, nullable: true,
@@ -467,6 +481,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ defaultLike: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
description: {
type: 'string',
optional: false, nullable: true,
@@ -571,6 +589,7 @@ export const meta = {
},
federation: {
type: 'string',
+ enum: ['all', 'specified', 'none'],
optional: false, nullable: false,
},
federationHosts: {
@@ -581,6 +600,19 @@ export const meta = {
optional: false, nullable: false,
},
},
+ hasLegacyAuthFetchSetting: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ allowUnsignedFetch: {
+ type: 'string',
+ enum: instanceUnsignedFetchOptions,
+ optional: false, nullable: false,
+ },
+ enableProxyAccount: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
},
} as const;
@@ -599,10 +631,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private config: Config,
private metaService: MetaService,
+ private systemAccountService: SystemAccountService,
) {
super(meta, paramDef, async () => {
const instance = await this.metaService.fetch(true);
+ const proxy = await this.systemAccountService.fetch('proxy');
+
return {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
@@ -652,7 +687,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
defaultLike: instance.defaultLike,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,
- translatorAvailable: instance.deeplAuthKey != null,
+ translatorAvailable: instance.deeplAuthKey != null || instance.libreTranslateURL != null || instance.deeplFreeMode && instance.deeplFreeInstance != null,
cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
pinnedUsers: instance.pinnedUsers,
@@ -675,7 +710,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
enableBotTrending: instance.enableBotTrending,
- proxyAccountId: instance.proxyAccountId,
+ proxyAccountId: proxy.id,
email: instance.email,
smtpSecure: instance.smtpSecure,
smtpHost: instance.smtpHost,
@@ -696,10 +731,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
objectStorageUseProxy: instance.objectStorageUseProxy,
objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
+ translationTimeout: instance.translationTimeout,
deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro,
deeplFreeMode: instance.deeplFreeMode,
deeplFreeInstance: instance.deeplFreeInstance,
+ libreTranslateURL: instance.libreTranslateURL,
+ libreTranslateKey: instance.libreTranslateKey,
enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation,
enableVerifymailApi: instance.enableVerifymailApi,
@@ -735,6 +773,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
federation: instance.federation,
federationHosts: instance.federationHosts,
+ hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null,
+ allowUnsignedFetch: instance.allowUnsignedFetch,
+ enableProxyAccount: instance.enableProxyAccount,
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts
index 3f7df0e63d..81cb4b8119 100644
--- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts
+++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts
@@ -6,7 +6,7 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
-import { QueueService } from '@/core/QueueService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
@@ -18,8 +18,11 @@ export const meta = {
export const paramDef = {
type: 'object',
- properties: {},
- required: [],
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] },
+ },
+ required: ['queue', 'state'],
} as const;
@Injectable()
@@ -29,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
- this.queueService.destroy();
+ this.queueService.queueClear(ps.queue, ps.state);
this.moderationLogService.log(me, 'clearQueue');
});
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts
new file mode 100644
index 0000000000..aba68376ad
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'read:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ state: { type: 'array', items: { type: 'string', enum: ['active', 'paused', 'wait', 'delayed', 'completed', 'failed'] } },
+ search: { type: 'string' },
+ },
+ required: ['queue', 'state'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts
new file mode 100644
index 0000000000..d22385e261
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ },
+ required: ['queue'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private moderationLogService: ModerationLogService,
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ this.queueService.queuePromoteJobs(ps.queue);
+
+ this.moderationLogService.log(me, 'promoteQueue');
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts
deleted file mode 100644
index 7502d4e1f7..0000000000
--- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import { ModerationLogService } from '@/core/ModerationLogService.js';
-import { QueueService } from '@/core/QueueService.js';
-
-export const meta = {
- tags: ['admin'],
-
- requireCredential: true,
- requireModerator: true,
- kind: 'write:admin:queue',
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {
- type: { type: 'string', enum: ['deliver', 'inbox'] },
- },
- required: ['type'],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
- constructor(
- private moderationLogService: ModerationLogService,
- private queueService: QueueService,
- ) {
- super(meta, paramDef, async (ps, me) => {
- let delayedQueues;
-
- switch (ps.type) {
- case 'deliver':
- delayedQueues = await this.queueService.deliverQueue.getDelayed();
- for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
- const queue = delayedQueues[queueIndex];
- try {
- await queue.promote();
- } catch (e) {
- if (e instanceof Error) {
- if (e.message.indexOf('not in a delayed state') !== -1) {
- throw e;
- }
- } else {
- throw e;
- }
- }
- }
- break;
-
- case 'inbox':
- delayedQueues = await this.queueService.inboxQueue.getDelayed();
- for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
- const queue = delayedQueues[queueIndex];
- try {
- await queue.promote();
- } catch (e) {
- if (e instanceof Error) {
- if (e.message.indexOf('not in a delayed state') !== -1) {
- throw e;
- }
- } else {
- throw e;
- }
- }
- }
- break;
- }
-
- this.moderationLogService.log(me, 'promoteQueue');
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts
new file mode 100644
index 0000000000..10ce48332a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'read:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ },
+ required: ['queue'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ return this.queueService.queueGetQueue(ps.queue);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts
new file mode 100644
index 0000000000..3a38275f60
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'read:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ return this.queueService.queueGetQueues();
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts
new file mode 100644
index 0000000000..2c73f689d0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ jobId: { type: 'string' },
+ },
+ required: ['queue', 'jobId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private moderationLogService: ModerationLogService,
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ this.queueService.queueRemoveJob(ps.queue, ps.jobId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts
new file mode 100644
index 0000000000..b2603128f8
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ jobId: { type: 'string' },
+ },
+ required: ['queue', 'jobId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private moderationLogService: ModerationLogService,
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ this.queueService.queueRetryJob(ps.queue, ps.jobId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts
new file mode 100644
index 0000000000..63747b5540
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'read:admin:queue',
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ queue: { type: 'string', enum: QUEUE_TYPES },
+ jobId: { type: 'string' },
+ },
+ required: ['queue', 'jobId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private moderationLogService: ModerationLogService,
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ return this.queueService.queueGetJob(ps.queue, ps.jobId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts
index e4bb545f5d..57b7170052 100644
--- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts
+++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts
@@ -4,10 +4,9 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
+import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@@ -45,6 +44,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -60,7 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('user not found');
}
- if (user.isRoot) {
+ if (this.serverSettings.rootUserId === user.id) {
throw new Error('cannot reset password of root');
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
index e0c02f7a5d..f92f7ebaeb 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts
@@ -36,6 +36,7 @@ export const paramDef = {
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
asBadge: { type: 'boolean' },
+ preserveAssignmentOnMoveAccount: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' },
policies: {
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
index 465ad7aaaf..175adcb63f 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts
@@ -41,6 +41,7 @@ export const paramDef = {
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean' },
asBadge: { type: 'boolean' },
+ preserveAssignmentOnMoveAccount: { type: 'boolean' },
canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' },
policies: {
@@ -78,6 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,
asBadge: ps.asBadge,
+ preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount,
canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder,
policies: ps.policies,
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 669bffe2dc..1579719246 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { IdService } from '@/core/IdService.js';
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
+import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['admin'],
@@ -111,6 +112,7 @@ export const meta = {
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
+ chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig },
achievementEarned: { optional: true, ...notificationRecieveConfig },
app: { optional: true, ...notificationRecieveConfig },
test: { optional: true, ...notificationRecieveConfig },
@@ -185,6 +187,36 @@ export const meta = {
},
},
},
+ followStats: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ totalFollowing: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ totalFollowers: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ localFollowing: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ localFollowers: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ remoteFollowing: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ remoteFollowers: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ },
+ },
},
},
} as const;
@@ -212,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
private roleEntityService: RoleEntityService,
private idService: IdService,
+ private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const [user, profile] = await Promise.all([
@@ -236,6 +269,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const roleAssigns = await this.roleService.getUserAssigns(user.id);
const roles = await this.roleService.getUserRoles(user.id);
+ const followStats = await this.cacheService.getFollowStats(user.id);
+
return {
email: profile.email,
emailVerified: profile.emailVerified,
@@ -268,6 +303,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
roleId: a.roleId,
})),
+ followStats: {
+ ...followStats,
+ totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers),
+ totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing),
+ },
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index b3733d3d39..7c3d485a0f 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -8,6 +8,7 @@ import type { MiMeta } from '@/models/Meta.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { MetaService } from '@/core/MetaService.js';
+import { instanceUnsignedFetchOptions } from '@/const.js';
export const meta = {
tags: ['admin'],
@@ -68,7 +69,7 @@ export const paramDef = {
description: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true },
- defaultLike: { type: 'string', nullable: true },
+ defaultLike: { type: 'string' },
cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' },
@@ -95,7 +96,6 @@ export const paramDef = {
setSensitiveFlagAutomatically: { type: 'boolean' },
enableSensitiveMediaDetectionForVideos: { type: 'boolean' },
enableBotTrending: { type: 'boolean' },
- proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true },
maintainerName: { type: 'string', nullable: true },
maintainerEmail: { type: 'string', nullable: true },
langs: {
@@ -103,10 +103,13 @@ export const paramDef = {
type: 'string',
},
},
+ translationTimeout: { type: 'number' },
deeplAuthKey: { type: 'string', nullable: true },
deeplIsPro: { type: 'boolean' },
deeplFreeMode: { type: 'boolean' },
deeplFreeInstance: { type: 'string', nullable: true },
+ libreTranslateURL: { type: 'string', nullable: true },
+ libreTranslateKey: { type: 'string', nullable: true },
enableEmail: { type: 'boolean' },
email: { type: 'string', nullable: true },
smtpSecure: { type: 'boolean' },
@@ -127,7 +130,7 @@ export const paramDef = {
useObjectStorage: { type: 'boolean' },
objectStorageBaseUrl: { type: 'string', nullable: true },
objectStorageBucket: { type: 'string', nullable: true },
- objectStoragePrefix: { type: 'string', nullable: true },
+ objectStoragePrefix: { type: 'string', pattern: /^[a-zA-Z0-9-._\/]*$/.source, nullable: true },
objectStorageEndpoint: { type: 'string', nullable: true },
objectStorageRegion: { type: 'string', nullable: true },
objectStoragePort: { type: 'integer', nullable: true },
@@ -203,6 +206,15 @@ export const paramDef = {
type: 'string',
},
},
+ allowUnsignedFetch: {
+ type: 'string',
+ enum: instanceUnsignedFetchOptions,
+ nullable: false,
+ },
+ enableProxyAccount: {
+ type: 'boolean',
+ nullable: false,
+ },
},
required: [],
} as const;
@@ -397,14 +409,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey;
}
- if (ps.enableFC !== undefined) {
- set.enableFC = ps.enableFC;
- }
-
if (ps.enableTestcaptcha !== undefined) {
set.enableTestcaptcha = ps.enableTestcaptcha;
}
+ if (ps.enableFC !== undefined) {
+ set.enableFC = ps.enableFC;
+ }
+
if (ps.fcSiteKey !== undefined) {
set.fcSiteKey = ps.fcSiteKey;
}
@@ -417,10 +429,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.enableBotTrending = ps.enableBotTrending;
}
- if (ps.proxyAccountId !== undefined) {
- set.proxyAccountId = ps.proxyAccountId;
- }
-
if (ps.maintainerName !== undefined) {
set.maintainerName = ps.maintainerName;
}
@@ -553,6 +561,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
}
+ if (ps.translationTimeout !== undefined) {
+ set.translationTimeout = ps.translationTimeout;
+ }
+
if (ps.deeplAuthKey !== undefined) {
if (ps.deeplAuthKey === '') {
set.deeplAuthKey = null;
@@ -577,6 +589,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
+ if (ps.libreTranslateURL !== undefined) {
+ if (ps.libreTranslateURL === '') {
+ set.libreTranslateURL = null;
+ } else {
+ set.libreTranslateURL = ps.libreTranslateURL;
+ }
+ }
+
+ if (ps.libreTranslateKey !== undefined) {
+ if (ps.libreTranslateKey === '') {
+ set.libreTranslateKey = null;
+ } else {
+ set.libreTranslateKey = ps.libreTranslateKey;
+ }
+ }
+
if (ps.enableIpLogging !== undefined) {
set.enableIpLogging = ps.enableIpLogging;
}
@@ -735,6 +763,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
}
+ if (ps.allowUnsignedFetch !== undefined) {
+ set.allowUnsignedFetch = ps.allowUnsignedFetch;
+ }
+
+ if (ps.enableProxyAccount !== undefined) {
+ set.enableProxyAccount = ps.enableProxyAccount;
+ }
+
const before = await this.metaService.fetch(true);
await this.metaService.update(set);
diff --git a/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts
new file mode 100644
index 0000000000..6c9612c71a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import {
+ descriptionSchema,
+} from '@/models/User.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:account',
+
+ res: {
+ type: 'object',
+ nullable: false, optional: false,
+ ref: 'UserDetailed',
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ description: { ...descriptionSchema, nullable: true },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private userEntityService: UserEntityService,
+ private moderationLogService: ModerationLogService,
+ private systemAccountService: SystemAccountService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const proxy = await this.systemAccountService.updateCorrespondingUserProfile('proxy', {
+ description: ps.description,
+ });
+
+ const updated = await this.userEntityService.pack(proxy.id, proxy, {
+ schema: 'MeDetailed',
+ });
+
+ if (ps.description !== undefined) {
+ this.moderationLogService.log(me, 'updateProxyAccountDescription', {
+ before: null, //TODO
+ after: ps.description,
+ });
+ }
+
+ return updated;
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts
index 9a6caa3317..55d686e390 100644
--- a/packages/backend/src/server/api/endpoints/antennas/create.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/create.ts
@@ -79,6 +79,7 @@ export const paramDef = {
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
+ excludeNotesInSensitiveChannel: { type: 'boolean' },
},
required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
} as const;
@@ -139,6 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
+ excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
});
this.globalEventService.publishInternalEvent('antennaCreated', antenna);
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index be4cf1e0ca..b90ba6aa0d 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -8,7 +8,6 @@ import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
@@ -65,9 +64,6 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.redisForTimelines)
- private redisForTimelines: Redis.Redis,
-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@@ -77,7 +73,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
- private noteReadService: NoteReadService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
) {
@@ -119,9 +114,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
+ // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
+ // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
+
+ this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
const notes = await query.getMany();
if (sinceId != null && untilId == null) {
@@ -130,8 +129,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
notes.sort((a, b) => a.id > b.id ? -1 : 1);
}
- this.noteReadService.read(me.id, notes);
-
return await this.noteEntityService.packMany(notes, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts
index 919a4cb3f5..3f2513bf75 100644
--- a/packages/backend/src/server/api/endpoints/antennas/update.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/update.ts
@@ -78,6 +78,7 @@ export const paramDef = {
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
+ excludeNotesInSensitiveChannel: { type: 'boolean' },
},
required: ['antennaId'],
} as const;
@@ -135,6 +136,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
+ excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
isActive: true,
lastUsedAt: new Date(),
});
diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts
index 22bec8ef95..d69850515c 100644
--- a/packages/backend/src/server/api/endpoints/ap/show.ts
+++ b/packages/backend/src/server/api/endpoints/ap/show.ts
@@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiNote } from '@/models/Note.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { isActor, isPost, getApId, getNullableApId } from '@/core/activitypub/type.js';
+import { isActor, isPost, getApId } from '@/core/activitypub/type.js';
import type { SchemaType } from '@/misc/json-schema.js';
import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
@@ -18,7 +18,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
-import { InstanceActorService } from '@/core/InstanceActorService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
@@ -30,7 +30,8 @@ export const meta = {
// Up to 30 calls, then 1 per 1/2 second
limit: {
- max: 30,
+ type: 'bucket',
+ size: 30,
dripRate: 500,
},
@@ -55,11 +56,6 @@ export const meta = {
code: 'RESPONSE_INVALID',
id: '70193c39-54f3-4813-82f0-70a680f7495b',
},
- responseInvalidIdHostNotMatch: {
- message: 'Requested URI and response URI host does not match.',
- code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH',
- id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a',
- },
noSuchObject: {
message: 'No such object.',
code: 'NO_SUCH_OBJECT',
@@ -123,7 +119,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apPersonService: ApPersonService,
private apNoteService: ApNoteService,
private readonly apRequestService: ApRequestService,
- private readonly instanceActorService: InstanceActorService,
+ private readonly systemAccountService: SystemAccountService,
) {
super(meta, paramDef, async (ps, me) => {
const object = await this.fetchAny(ps.uri, me);
@@ -144,7 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.federationNotAllowed);
}
- let local = await this.mergePack(me, ...await Promise.all([
+ const local = await this.mergePack(me, ...await Promise.all([
this.apDbResolverService.getUserFromApId(uri),
this.apDbResolverService.getNoteFromApId(uri),
]));
@@ -153,7 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// No local object found with that uri.
// Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that.
// Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup.
- uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign
+ uri = await this.resolveCanonicalUri(uri);
if (!this.utilityService.isFederationAllowedUri(uri)) {
throw new ApiError(meta.errors.federationNotAllowed);
}
@@ -177,10 +173,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
throw new ApiError(meta.errors.federationNotAllowed);
case '72180409-793c-4973-868e-5a118eb5519b':
- case 'ad2dc287-75c1-44c4-839d-3d2e64576675':
throw new ApiError(meta.errors.responseInvalid);
- case 'fd93c2fa-69a8-440f-880b-bf178e0ec877':
- throw new ApiError(meta.errors.responseInvalidIdHostNotMatch);
// resolveLocal
case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8':
@@ -196,25 +189,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.requestFailed);
});
- if (object.id == null) {
- throw new ApiError(meta.errors.responseInvalid);
- }
-
- // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
- // これはDBに存在する可能性があるため再度DB検索
- if (uri !== object.id) {
- local = await this.mergePack(me, ...await Promise.all([
- this.apDbResolverService.getUserFromApId(object.id),
- this.apDbResolverService.getNoteFromApId(object.id),
- ]));
- if (local != null) return local;
- }
-
- // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
+ // Object is already validated to have a valid id (URI).
+ // We can pass it through with the same resolver and sentFrom to avoid a duplicate fetch.
+ // The resolve* methods automatically check for locally cached copies.
return await this.mergePack(
me,
- isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
- isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
+ isActor(object) ? await this.apPersonService.resolvePerson(object, resolver, uri) : null,
+ isPost(object) ? await this.apNoteService.resolveNote(object, { resolver, sentFrom: uri }) : null,
);
}
@@ -245,8 +226,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
* Resolves an arbitrary URI to its canonical, post-redirect form.
*/
private async resolveCanonicalUri(uri: string): Promise<string> {
- const user = await this.instanceActorService.getInstanceActor();
+ const user = await this.systemAccountService.getInstanceActor();
const res = await this.apRequestService.signedGet(uri, user, true);
- return getNullableApId(res) ?? uri;
+ return getApId(res);
}
}
diff --git a/packages/backend/src/server/api/endpoints/app/current.ts b/packages/backend/src/server/api/endpoints/app/current.ts
new file mode 100644
index 0000000000..39b5ef347c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/app/current.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { AppsRepository } from '@/models/_.js';
+import { AppEntityService } from '@/core/entities/AppEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['app'],
+
+ errors: {
+ credentialRequired: {
+ message: 'Credential required.',
+ code: 'CREDENTIAL_REQUIRED',
+ id: '1384574d-a912-4b81-8601-c7b1c4085df1',
+ httpStatusCode: 401,
+ },
+ noAppLogin: {
+ message: 'Not logged in with an app.',
+ code: 'NO_APP_LOGIN',
+ id: '339a4ad2-48c3-47fc-bd9d-2408f05120f8',
+ },
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'App',
+ },
+
+ // 10 calls per 5 seconds
+ limit: {
+ duration: 1000 * 5,
+ max: 10,
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {},
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.appsRepository)
+ private appsRepository: AppsRepository,
+
+ private appEntityService: AppEntityService,
+ ) {
+ super(meta, paramDef, async (_, user, token) => {
+ if (!user) {
+ throw new ApiError(meta.errors.credentialRequired);
+ }
+ if (!token || !token.appId) {
+ throw new ApiError(meta.errors.noAppLogin);
+ }
+
+ const app = token.app ?? await this.appsRepository.findOneByOrFail({ id: token.appId });
+
+ return await this.appEntityService.pack(app, user, {
+ detail: true,
+ includeSecret: false,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 0bd01d712c..6336f43e9f 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -137,9 +137,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
+ this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withRenotes === false) {
diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts
new file mode 100644
index 0000000000..fdd9055106
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/history.ts
@@ -0,0 +1,75 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ room: { type: 'boolean', default: false },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
+
+ const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me);
+
+ if (ps.room) {
+ const roomIds = history.map(m => m.toRoomId!);
+ const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds);
+
+ for (const message of packedMessages) {
+ message.isRead = readStateMap[message.toRoomId!] ?? false;
+ }
+ } else {
+ const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!);
+ const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds);
+
+ for (const message of packedMessages) {
+ const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!;
+ message.isRead = readStateMap[otherId] ?? false;
+ }
+ }
+
+ return packedMessages;
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
new file mode 100644
index 0000000000..ad2b82e219
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 500,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLiteForRoom',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6',
+ },
+
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db',
+ },
+
+ contentRequired: {
+ message: 'Content required. You need to set text or fileId.',
+ code: 'CONTENT_REQUIRED',
+ id: '340517b7-6d04-42c0-bac1-37ee804e3594',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ text: { type: 'string', nullable: true, maxLength: 2000 },
+ fileId: { type: 'string', format: 'misskey:id' },
+ toRoomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['toRoomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ private getterService: GetterService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const room = await this.chatService.findRoomById(ps.toRoomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ let file = null;
+ if (ps.fileId != null) {
+ file = await this.driveFilesRepository.findOneBy({
+ id: ps.fileId,
+ userId: me.id,
+ });
+
+ if (file == null) {
+ throw new ApiError(meta.errors.noSuchFile);
+ }
+ }
+
+ // テキストが無いかつ添付ファイルも無かったらエラー
+ if (ps.text == null && file == null) {
+ throw new ApiError(meta.errors.contentRequired);
+ }
+
+ return await this.chatService.createMessageToRoom(me, room, {
+ text: ps.text,
+ file: file,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
new file mode 100644
index 0000000000..fa34a7d558
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
@@ -0,0 +1,123 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 500,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLiteFor1on1',
+ },
+
+ errors: {
+ recipientIsYourself: {
+ message: 'You can not send a message to yourself.',
+ code: 'RECIPIENT_IS_YOURSELF',
+ id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e',
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+ },
+
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: '4372b8e2-185d-4146-8749-2f68864a3e5f',
+ },
+
+ contentRequired: {
+ message: 'Content required. You need to set text or fileId.',
+ code: 'CONTENT_REQUIRED',
+ id: '25587321-b0e6-449c-9239-f8925092942c',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot send a message because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'c15a5199-7422-4968-941a-2a462c478f7d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ text: { type: 'string', nullable: true, maxLength: 2000 },
+ fileId: { type: 'string', format: 'misskey:id' },
+ toUserId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['toUserId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ private getterService: GetterService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ let file = null;
+ if (ps.fileId != null) {
+ file = await this.driveFilesRepository.findOneBy({
+ id: ps.fileId,
+ userId: me.id,
+ });
+
+ if (file == null) {
+ throw new ApiError(meta.errors.noSuchFile);
+ }
+ }
+
+ // テキストが無いかつ添付ファイルも無かったらエラー
+ if (ps.text == null && file == null) {
+ throw new ApiError(meta.errors.contentRequired);
+ }
+
+ // Myself
+ if (ps.toUserId === me.id) {
+ throw new ApiError(meta.errors.recipientIsYourself);
+ }
+
+ const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ return await this.chatService.createMessageToUser(me, toUser, {
+ text: ps.text,
+ file: file,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
new file mode 100644
index 0000000000..52a054303b
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
@@ -0,0 +1,51 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '36b67f0e-66a6-414b-83df-992a55294f17',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const message = await this.chatService.findMyMessageById(me.id, ps.messageId);
+ if (message == null) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ await this.chatService.deleteMessage(message);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
new file mode 100644
index 0000000000..2197e7bf80
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '9b5839b9-0ba0-4351-8c35-37082093d200',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ reaction: { type: 'string' },
+ },
+ required: ['messageId', 'reaction'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.react(ps.messageId, me.id, ps.reaction);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
new file mode 100644
index 0000000000..c0e344b889
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
@@ -0,0 +1,75 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLiteForRoom',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+ this.chatService.readRoomChatMessage(me.id, room.id);
+
+ return await this.chatEntityService.packMessagesLiteForRoom(messages);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
new file mode 100644
index 0000000000..682597f76d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '460b3669-81b0-4dc9-a997-44442141bf83',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ query: { type: 'string', minLength: 1, maxLength: 256 },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ userId: { type: 'string', format: 'misskey:id', nullable: true },
+ roomId: { type: 'string', format: 'misskey:id', nullable: true },
+ },
+ required: ['query'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ if (ps.roomId != null) {
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!(await this.chatService.isRoomMember(room, me.id))) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+ }
+
+ const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, {
+ userId: ps.userId,
+ roomId: ps.roomId,
+ });
+
+ return await this.chatEntityService.packMessagesDetailed(messages, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
new file mode 100644
index 0000000000..9a2bbb8742
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '3710865b-1848-4da9-8d61-cfed15510b93',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private roleService: RoleService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const message = await this.chatService.findMessageById(ps.messageId);
+ if (message == null) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ return this.chatEntityService.packMessageDetailed(message, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts
new file mode 100644
index 0000000000..adfcd232f9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: 'c39ea42f-e3ca-428a-ad57-390e0a711595',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ reaction: { type: 'string' },
+ },
+ required: ['messageId', 'reaction'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.unreact(ps.messageId, me.id, ps.reaction);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
new file mode 100644
index 0000000000..a057e2e088
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLiteFor1on1',
+ },
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ private getterService: GetterService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const other = await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId);
+
+ this.chatService.readUserChatMessage(me.id, other.id);
+
+ return await this.chatEntityService.packMessagesLiteFor1on1(messages);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
new file mode 100644
index 0000000000..68a53f0886
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1day'),
+ max: 10,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ name: { type: 'string', maxLength: 256 },
+ description: { type: 'string', maxLength: 1024 },
+ },
+ required: ['name'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const room = await this.chatService.createRoom(me, {
+ name: ps.name,
+ description: ps.description ?? '',
+ });
+ return await this.chatEntityService.packRoom(room);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
new file mode 100644
index 0000000000..1ea81448c1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ await this.chatService.deleteRoom(room, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
new file mode 100644
index 0000000000..b1f049f2b9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
@@ -0,0 +1,69 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1day'),
+ max: 50,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomInvitation',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId', 'userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+ const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId);
+ return await this.chatEntityService.packRoomInvitation(invitation, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
new file mode 100644
index 0000000000..88ea234527
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.ignoreRoomInvitation(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
new file mode 100644
index 0000000000..8a02d1c704
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomInvitation',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+ return this.chatEntityService.packRoomInvitations(invitations, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts
new file mode 100644
index 0000000000..0702ba086c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts
@@ -0,0 +1,69 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomInvitation',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'a3c6b309-9717-4316-ae94-a69b53437237',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId);
+ return this.chatEntityService.packRoomInvitations(invitations, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
new file mode 100644
index 0000000000..550b4da1a6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '84416476-5ce8-4a2c-b568-9569f1b10733',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.joinToRoom(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
new file mode 100644
index 0000000000..ba9242c762
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomMembership',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId);
+
+ return this.chatEntityService.packRoomMemberships(memberships, me, {
+ populateUser: false,
+ populateRoom: true,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
new file mode 100644
index 0000000000..f99b408d67
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.leaveRoom(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
new file mode 100644
index 0000000000..f5ffa21d32
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomMembership',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!(await this.chatService.isRoomMember(room, me.id))) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+ return this.chatEntityService.packRoomMemberships(memberships, me, {
+ populateUser: true,
+ populateRoom: false,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
new file mode 100644
index 0000000000..ee60f92505
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ mute: { type: 'boolean' },
+ },
+ required: ['roomId', 'mute'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ await this.chatService.muteRoom(me.id, ps.roomId, ps.mute);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
new file mode 100644
index 0000000000..accf7e1bee
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+ return this.chatEntityService.packRooms(rooms, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
new file mode 100644
index 0000000000..50da210d81
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'read');
+
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ return this.chatEntityService.packRoom(room, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
new file mode 100644
index 0000000000..0cd62cb040
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ name: { type: 'string', maxLength: 256 },
+ description: { type: 'string', maxLength: 1024 },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.checkChatAvailability(me.id, 'write');
+
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const updated = await this.chatService.updateRoom(room, {
+ name: ps.name,
+ description: ps.description,
+ });
+
+ return this.chatEntityService.packRoom(updated, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts
index c2f72ad9ae..e3b8f33f97 100644
--- a/packages/backend/src/server/api/endpoints/clips/create.ts
+++ b/packages/backend/src/server/api/endpoints/clips/create.ts
@@ -45,7 +45,7 @@ export const paramDef = {
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean', default: false },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
},
required: ['name'],
} as const;
@@ -59,7 +59,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
let clip: MiClip;
try {
- clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null);
+ // 空文字列をnullにしたいので??は使わない
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description || null);
} catch (e) {
if (e instanceof ClipService.TooManyClipsError) {
throw new ApiError(meta.errors.tooManyClips);
diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts
index 6175c4d0e5..59513e530d 100644
--- a/packages/backend/src/server/api/endpoints/clips/notes.ts
+++ b/packages/backend/src/server/api/endpoints/clips/notes.ts
@@ -91,10 +91,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
+ this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const notes = await query
diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts
index b776f1357d..beab427b69 100644
--- a/packages/backend/src/server/api/endpoints/clips/update.ts
+++ b/packages/backend/src/server/api/endpoints/clips/update.ts
@@ -45,7 +45,7 @@ export const paramDef = {
clipId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
isPublic: { type: 'boolean' },
- description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
+ description: { type: 'string', nullable: true, maxLength: 2048 },
},
required: ['clipId'],
} as const;
@@ -59,7 +59,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
try {
- await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description);
+ // 空文字列をnullにしたいので??は使わない
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description || null);
} catch (e) {
if (e instanceof ClipService.NoSuchClipError) {
throw new ApiError(meta.errors.noSuchClip);
diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts
index 5df212415d..26b7b32001 100644
--- a/packages/backend/src/server/api/endpoints/drive/files.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files.ts
@@ -46,6 +46,7 @@ export const paramDef = {
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] },
searchQuery: { type: 'string', default: '' },
+ showAll: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -63,10 +64,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId)
.andWhere('file.userId = :userId', { userId: me.id });
- if (ps.folderId) {
- query.andWhere('file.folderId = :folderId', { folderId: ps.folderId });
- } else {
- query.andWhere('file.folderId IS NULL');
+ if (!ps.showAll) {
+ if (ps.folderId) {
+ query.andWhere('file.folderId = :folderId', { folderId: ps.folderId });
+ } else {
+ query.andWhere('file.folderId IS NULL');
+ }
}
if (ps.searchQuery.length > 0) {
diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts
index f67ff6ddc4..f4c47d71bf 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/create.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts
@@ -62,6 +62,13 @@ export const meta = {
code: 'COMMENT_TOO_LONG',
id: '333652d9-0826-40f5-a2c3-e2bedcbb9fe5',
},
+
+ maxFileSizeExceeded: {
+ message: 'Cannot upload the file because it exceeds the maximum file size.',
+ code: 'MAX_FILE_SIZE_EXCEEDED',
+ id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a',
+ httpStatusCode: 413,
+ },
},
} as const;
@@ -128,6 +135,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (err instanceof IdentifiableError) {
if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate);
if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace);
+ if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded);
}
throw new ApiError();
} finally {
diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts
index 97384d2498..eeaebc3708 100644
--- a/packages/backend/src/server/api/endpoints/federation/instances.ts
+++ b/packages/backend/src/server/api/endpoints/federation/instances.ts
@@ -113,11 +113,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
if (typeof ps.blocked === 'boolean') {
- const meta = await this.metaService.fetch(true);
if (ps.blocked) {
- query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts });
+ query.andWhere('instance.host IN (select unnest("blockedHosts") as x from "meta")');
} else {
- query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts });
+ query.andWhere('instance.host NOT IN (select unnest("blockedHosts") as x from "meta")');
}
}
@@ -146,36 +145,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
if (typeof ps.silenced === 'boolean') {
- const meta = await this.metaService.fetch(true);
-
if (ps.silenced) {
- if (meta.silencedHosts.length === 0) {
- return [];
- }
- query.andWhere('instance.host IN (:...silences)', {
- silences: meta.silencedHosts,
- });
- } else if (meta.silencedHosts.length > 0) {
- query.andWhere('instance.host NOT IN (:...silences)', {
- silences: meta.silencedHosts,
- });
+ query.andWhere('instance.host IN (select unnest("silencedHosts") as x from "meta")');
+ } else {
+ query.andWhere('instance.host NOT IN (select unnest("silencedHosts") as x from "meta")');
}
}
if (typeof ps.bubble === 'boolean') {
- const meta = await this.metaService.fetch(true);
-
if (ps.bubble) {
- if (meta.bubbleInstances.length === 0) {
- return [];
- }
- query.andWhere('instance.host IN (:...bubble)', {
- bubble: meta.bubbleInstances,
- });
- } else if (meta.bubbleInstances.length > 0) {
- query.andWhere('instance.host NOT IN (:...bubble)', {
- bubble: meta.bubbleInstances,
- });
+ query.andWhere('instance.host IN (select unnest("bubbleInstances") as x from "meta")');
+ } else {
+ query.andWhere('instance.host NOT IN (select unnest("bubbleInstances") as x from "meta")');
}
}
diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts
index 5217f79065..67fa5ed343 100644
--- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts
+++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts
@@ -16,7 +16,8 @@ export const meta = {
// Up to 10 calls, then 4 / second.
// This allows for reliable automation.
limit: {
- max: 10,
+ type: 'bucket',
+ size: 10,
dripRate: 250,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts
index 8935c2c2da..b45d21410b 100644
--- a/packages/backend/src/server/api/endpoints/following/invalidate.ts
+++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts
@@ -96,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.userFollowingService.unfollow(follower, followee);
- return await this.userEntityService.pack(followee.id, me);
+ return await this.userEntityService.pack(follower.id, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
index e73110648c..ae8ad6c044 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts
@@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
- await this.featuredService.updateGalleryPostsRanking(post.id, 1);
+ await this.featuredService.updateGalleryPostsRanking(post, 1);
}
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
index b0fad1eff2..be0a5a5584 100644
--- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
+++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts
@@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// ランキング更新
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
- await this.featuredService.updateGalleryPostsRanking(post.id, -1);
+ await this.featuredService.updateGalleryPostsRanking(post, -1);
}
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts
index f378c5558e..b49c907432 100644
--- a/packages/backend/src/server/api/endpoints/hashtags/list.ts
+++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts
@@ -58,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0');
if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0');
+ // Ignore hidden hashtags
+ query.andWhere(`
+ NOT EXISTS (
+ SELECT 1 FROM meta WHERE tag.name = ANY(meta."hiddenTags")
+ )`);
+
switch (ps.sort) {
case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break;
case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break;
diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts
index eb2289960a..68c795de73 100644
--- a/packages/backend/src/server/api/endpoints/hashtags/users.ts
+++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts
@@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
+import { RoleService } from '@/core/RoleService.js';
export const meta = {
requireCredential: false,
@@ -41,6 +42,7 @@ export const paramDef = {
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
+ trending: { type: 'boolean', default: false },
},
required: ['tag', 'sort'],
} as const;
@@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
+ private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
@@ -80,7 +83,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break;
}
- const users = await query.limit(ps.limit).getMany();
+ let users = await query.limit(ps.limit).getMany();
+
+ // This is not ideal, for a couple of reasons:
+ // 1. It may return less than "limit" results.
+ // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
+ // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
+ if (ps.trending) {
+ const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
+ users = usersWithRoles
+ .filter(([,p]) => p.canTrend)
+ .map(([u]) => u);
+ }
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
});
diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts
index 48a2e3b40a..177bc601ac 100644
--- a/packages/backend/src/server/api/endpoints/i.ts
+++ b/packages/backend/src/server/api/endpoints/i.ts
@@ -34,7 +34,8 @@ export const meta = {
// up to 20 calls, then 1 per second.
// This handles bursty traffic when all tabs reload as a group
limit: {
- max: 20,
+ type: 'bucket',
+ size: 20,
dripSize: 1,
dripRate: 1000,
},
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
index 370d9915a3..6d1972456d 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
index 893ea30391..77f71ce5fd 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
index d27c14c69b..6fde3a90a7 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode';
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
index b01e452056..d4098458d7 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
index 2fe4fdc4c0..fc5a51f81b 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
index 4a41c7b984..a9f631cfaf 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts
index 4069683740..ea84ef24d7 100644
--- a/packages/backend/src/server/api/endpoints/i/change-password.ts
+++ b/packages/backend/src/server/api/endpoints/i/change-password.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
@@ -65,7 +64,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// Generate hash of password
- //const salt = await bcrypt.genSalt(8);
const hash = await argon2.hash(ps.newPassword);
await this.userProfilesRepository.update(me.id, {
diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts
index 10fb923d4f..8a2b523449 100644
--- a/packages/backend/src/server/api/endpoints/i/delete-account.ts
+++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
index bdf6c065e8..ccec96ffbb 100644
--- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
@@ -16,7 +16,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canImportAntennas',
+ requiredRolePolicy: 'canImportAntennas',
prohibitMoved: true,
limit: {
diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts
index d7bb6bcd22..2fa450558b 100644
--- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts
@@ -15,7 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canImportBlocking',
+ requiredRolePolicy: 'canImportBlocking',
prohibitMoved: true,
limit: {
diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts
index e03192d8c6..9186fca162 100644
--- a/packages/backend/src/server/api/endpoints/i/import-following.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-following.ts
@@ -15,7 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canImportFollowing',
+ requiredRolePolicy: 'canImportFollowing',
prohibitMoved: true,
limit: {
duration: ms('1hour'),
diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts
index 76b285bb7e..b6dbacd371 100644
--- a/packages/backend/src/server/api/endpoints/i/import-muting.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts
@@ -15,7 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canImportMuting',
+ requiredRolePolicy: 'canImportMuting',
prohibitMoved: true,
limit: {
diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
index 76ecfd082c..5de0a70bbb 100644
--- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
+++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts
@@ -15,7 +15,7 @@ import { ApiError } from '../../error.js';
export const meta = {
secure: true,
requireCredential: true,
- requireRolePolicy: 'canImportUserLists',
+ requiredRolePolicy: 'canImportUserLists',
prohibitMoved: true,
limit: {
duration: ms('1hour'),
diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts
index 1bd641232c..7852b5a2e1 100644
--- a/packages/backend/src/server/api/endpoints/i/move.ts
+++ b/packages/backend/src/server/api/endpoints/i/move.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
@@ -19,6 +19,8 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import * as Acct from '@/misc/acct.js';
+import { DI } from '@/di-symbols.js';
+import { MiMeta } from '@/models/_.js';
export const meta = {
tags: ['users'],
@@ -81,6 +83,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.meta)
+ private serverSettings: MiMeta,
+
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
@@ -92,7 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root
- if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
+ if (this.serverSettings.rootUserId === me.id) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
index ad4577be58..b9c41b057d 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
@@ -7,9 +7,13 @@ import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
-import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
+import {
+ obsoleteNotificationTypes,
+ groupedNotificationTypes,
+ FilterUnionByProperty,
+ notificationTypes,
+} from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
@@ -48,10 +52,10 @@ export const paramDef = {
markAsRead: { type: 'boolean', default: true },
// 後方互換のため、廃止された通知タイプも受け付ける
includeTypes: { type: 'array', items: {
- type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
+ type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
excludeTypes: { type: 'array', items: {
- type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
+ type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
},
required: [],
@@ -63,13 +67,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.redis)
private redisClient: Redis.Redis,
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
private idService: IdService,
private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
const EXTRA_LIMIT = 100;
@@ -79,31 +79,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
// excludeTypes に全指定されている場合はクエリしない
- if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) {
+ if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
return [];
}
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
- const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
- const notificationsRes = await this.redisClient.xrevrange(
- `notificationTimeline:${me.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
- ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
- 'COUNT', limit);
-
- if (notificationsRes.length === 0) {
- return [];
- }
-
- let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[];
-
- if (includeTypes && includeTypes.length > 0) {
- notifications = notifications.filter(notification => includeTypes.includes(notification.type));
- } else if (excludeTypes && excludeTypes.length > 0) {
- notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
- }
+ const notifications = await this.notificationService.getNotifications(me.id, {
+ sinceId: ps.sinceId,
+ untilId: ps.untilId,
+ limit: ps.limit,
+ includeTypes,
+ excludeTypes,
+ });
if (notifications.length === 0) {
return [];
@@ -162,14 +151,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
groupedNotifications = groupedNotifications.slice(0, ps.limit);
- const noteIds = groupedNotifications
- .filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote' | 'edited'> => ['mention', 'reply', 'quote', 'edited'].includes(notification.type))
- .map(notification => notification.noteId!);
-
- if (noteIds.length > 0) {
- const notes = await this.notesRepository.findBy({ id: In(noteIds) });
- this.noteReadService.read(me.id, notes);
- }
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
});
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index 5e97b90f99..f5a48b2f69 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
@@ -69,7 +68,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
// includeTypes が空の場合はクエリしない
@@ -84,67 +82,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
- let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null;
- let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null;
-
- let notifications: MiNotification[];
- for (;;) {
- let notificationsRes: [id: string, fields: string[]][];
-
- // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照
- if (sinceTime && !untilTime) {
- notificationsRes = await this.redisClient.xrange(
- `notificationTimeline:${me.id}`,
- '(' + sinceTime,
- '+',
- 'COUNT', ps.limit);
- } else {
- notificationsRes = await this.redisClient.xrevrange(
- `notificationTimeline:${me.id}`,
- untilTime ? '(' + untilTime : '+',
- sinceTime ? '(' + sinceTime : '-',
- 'COUNT', ps.limit);
- }
-
- if (notificationsRes.length === 0) {
- return [];
- }
-
- notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[];
-
- if (includeTypes && includeTypes.length > 0) {
- notifications = notifications.filter(notification => includeTypes.includes(notification.type));
- } else if (excludeTypes && excludeTypes.length > 0) {
- notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
- }
-
- if (notifications.length !== 0) {
- // 通知が1件以上ある場合は返す
- break;
- }
-
- // フィルタしたことで通知が0件になった場合、次のページを取得する
- if (ps.sinceId && !ps.untilId) {
- sinceTime = notificationsRes[notificationsRes.length - 1][0];
- } else {
- untilTime = notificationsRes[notificationsRes.length - 1][0];
- }
- }
+ const notifications = await this.notificationService.getNotifications(me.id, {
+ sinceId: ps.sinceId,
+ untilId: ps.untilId,
+ limit: ps.limit,
+ includeTypes,
+ excludeTypes,
+ });
// Mark all as read
if (ps.markAsRead) {
this.notificationService.readAllNotification(me.id);
}
- const noteIds = notifications
- .filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote' | 'edited'> => ['mention', 'reply', 'quote', 'edited'].includes(notification.type))
- .map(notification => notification.noteId);
-
- if (noteIds.length > 0) {
- const notes = await this.notesRepository.findBy({ id: In(noteIds) });
- this.noteReadService.read(me.id, notes);
- }
-
return await this.notificationEntityService.packMany(notifications, me.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
deleted file mode 100644
index edf570fcc5..0000000000
--- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { NoteUnreadsRepository } from '@/models/_.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { DI } from '@/di-symbols.js';
-
-export const meta = {
- tags: ['account'],
-
- requireCredential: true,
-
- kind: 'write:account',
-
- // 2 calls per second
- limit: {
- duration: 1000,
- max: 2,
- },
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {},
- required: [],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
- constructor(
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
- private globalEventService: GlobalEventService,
- ) {
- super(meta, paramDef, async (ps, me) => {
- // Remove documents
- await this.noteUnreadsRepository.delete({
- userId: me.id,
- });
-
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions');
- this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes');
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts
index 38328bb7d4..4fd6202604 100644
--- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts
+++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts
@@ -3,13 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
-import generateUserToken from '@/misc/generate-native-user-token.js';
+import { generateNativeUserToken } from '@/misc/token.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@@ -57,7 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('incorrect password');
}
- const newToken = generateUserToken();
+ const newToken = generateNativeUserToken();
await this.usersRepository.update(me.id, {
token: newToken,
diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts
index 0be8bfb695..dc07556760 100644
--- a/packages/backend/src/server/api/endpoints/i/update-email.ts
+++ b/packages/backend/src/server/api/endpoints/i/update-email.ts
@@ -5,7 +5,6 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index f74452e2af..f35e395841 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -6,7 +6,6 @@
import * as mfm from '@transfem-org/sfm-js';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
-import { JSDOM } from 'jsdom';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
@@ -31,8 +30,10 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js';
import { safeForSql } from '@/misc/safe-for-sql.js';
+import { verifyFieldLinks } from '@/misc/verify-field-link.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
+import { userUnsignedFetchOptions } from '@/const.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
@@ -214,6 +215,7 @@ export const paramDef = {
autoSensitive: { type: 'boolean' },
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
+ chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -235,6 +237,7 @@ export const paramDef = {
receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig,
roleAssigned: notificationRecieveConfig,
+ chatRoomInvitationReceived: notificationRecieveConfig,
achievementEarned: notificationRecieveConfig,
app: notificationRecieveConfig,
test: notificationRecieveConfig,
@@ -255,6 +258,11 @@ export const paramDef = {
enum: ['default', 'parent', 'defaultParent', 'parentDefault'],
nullable: false,
},
+ allowUnsignedFetch: {
+ type: 'string',
+ enum: userUnsignedFetchOptions,
+ nullable: false,
+ },
},
} as const;
@@ -319,10 +327,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.listenbrainz !== undefined) profileUpdates.listenbrainz = ps.listenbrainz;
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
+ if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
- // TODO: ちゃんと数える
- const length = JSON.stringify(mutedWords).length;
+ const length = mutedWords.reduce((sum, word) => {
+ const wordLength = Array.isArray(word)
+ ? word.reduce((l, w) => l + w.length, 0)
+ : word.length;
+ return sum + wordLength;
+ }, 0);
+
if (length > limit) {
throw new ApiError(meta.errors.tooManyMutedWords);
}
@@ -519,6 +533,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
profileUpdates.defaultCWPriority = ps.defaultCWPriority;
}
+ if (ps.allowUnsignedFetch !== undefined) {
+ updates.allowUnsignedFetch = ps.allowUnsignedFetch;
+ }
+
//#region emojis/tags
let emojis = [] as string[];
@@ -574,9 +592,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
}
+ const verified_links = await verifyFieldLinks(newFields, `${this.config.url}/@${user.username}`, this.httpRequestService);
+
await this.userProfilesRepository.update(user.id, {
...profileUpdates,
- verifiedLinks: [],
+ verifiedLinks: verified_links,
});
const iObj = await this.userEntityService.pack(user.id, user, {
@@ -598,18 +618,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// フォロワーにUpdateを配信
if (this.userNeedsPublishing(user, updates) || this.profileNeedsPublishing(profile, updatedProfile)) {
- this.accountUpdateService.publishToFollowers(user.id);
- }
-
- const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://'));
- for (const url of urls) {
- this.verifyLink(url.value, user);
+ this.accountUpdateService.publishToFollowers(user);
}
return iObj;
});
}
+ // this function is superseded by '@/misc/verify-field-link.ts'
+ /*
private async verifyLink(url: string, user: MiLocalUser) {
if (!safeForSql(url)) return;
@@ -641,6 +658,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// なにもしない
}
}
+ */
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts
index d661eab364..f607a35515 100644
--- a/packages/backend/src/server/api/endpoints/invite/create.ts
+++ b/packages/backend/src/server/api/endpoints/invite/create.ts
@@ -18,7 +18,7 @@ export const meta = {
tags: ['meta'],
requireCredential: true,
- requireRolePolicy: 'canInvite',
+ requiredRolePolicy: 'canInvite',
kind: 'write:invite-codes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts
index 408200164c..d15d400e9b 100644
--- a/packages/backend/src/server/api/endpoints/invite/delete.ts
+++ b/packages/backend/src/server/api/endpoints/invite/delete.ts
@@ -14,7 +14,7 @@ export const meta = {
tags: ['meta'],
requireCredential: true,
- requireRolePolicy: 'canInvite',
+ requiredRolePolicy: 'canInvite',
kind: 'write:invite-codes',
errors: {
diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts
index 8d92c4957c..150f4de441 100644
--- a/packages/backend/src/server/api/endpoints/invite/limit.ts
+++ b/packages/backend/src/server/api/endpoints/invite/limit.ts
@@ -15,7 +15,7 @@ export const meta = {
tags: ['meta'],
requireCredential: true,
- requireRolePolicy: 'canInvite',
+ requiredRolePolicy: 'canInvite',
kind: 'read:invite-codes',
res: {
diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts
index b63b41edd3..12e3873304 100644
--- a/packages/backend/src/server/api/endpoints/invite/list.ts
+++ b/packages/backend/src/server/api/endpoints/invite/list.ts
@@ -14,7 +14,7 @@ export const meta = {
tags: ['meta'],
requireCredential: true,
- requireRolePolicy: 'canInvite',
+ requiredRolePolicy: 'canInvite',
kind: 'read:invite-codes',
res: {
diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
index c42df7ca80..f962bd49f1 100644
--- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
+++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AccessTokensRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
+import { NotificationService } from '@/core/NotificationService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DI } from '@/di-symbols.js';
@@ -56,6 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private accessTokensRepository: AccessTokensRepository,
private idService: IdService,
+ private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
// Generate access token
@@ -77,6 +79,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
permission: ps.permission,
});
+ // アクセストークンが生成されたことを通知
+ this.notificationService.createNotification(me.id, 'createToken', {});
+
return {
token: accessToken,
};
diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
index d36d1dfc15..df030d90aa 100644
--- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts
@@ -7,8 +7,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
-import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
+import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
@@ -92,11 +92,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- if (me) {
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
- this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
- }
+ this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts
index e69ba9be7e..8f19d534d4 100644
--- a/packages/backend/src/server/api/endpoints/notes/children.ts
+++ b/packages/backend/src/server/api/endpoints/notes/children.ts
@@ -79,8 +79,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const notes = await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts
index 18d80e867b..545889a7ee 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.test.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts
@@ -65,7 +65,7 @@ describe('api:notes/create', () => {
test('0 characters cw', () => {
expect(v({ text: 'Body', cw: '' }))
- .toBe(INVALID);
+ .toBe(VALID);
});
test('reject only cw', () => {
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index b0f32bfda8..3dd90c3dca 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -159,7 +159,7 @@ export const paramDef = {
visibleUserIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
- cw: { type: 'string', nullable: true, minLength: 1 },
+ cw: { type: 'string', nullable: true },
localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
@@ -400,7 +400,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
text: ps.text ?? undefined,
reply,
renote,
- cw: ps.cw,
+ cw: ps.cw || null,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts
index cc2293c5d6..2c01b26584 100644
--- a/packages/backend/src/server/api/endpoints/notes/edit.ts
+++ b/packages/backend/src/server/api/endpoints/notes/edit.ts
@@ -31,13 +31,11 @@ export const meta = {
res: {
type: 'object',
- optional: false,
- nullable: false,
+ optional: false, nullable: false,
properties: {
createdNote: {
type: 'object',
- optional: false,
- nullable: false,
+ optional: false, nullable: false,
ref: 'Note',
},
},
@@ -209,7 +207,7 @@ export const paramDef = {
format: 'misskey:id',
},
},
- cw: { type: 'string', nullable: true, minLength: 1 },
+ cw: { type: 'string', nullable: true },
localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
@@ -454,7 +452,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
text: ps.text ?? undefined,
reply,
renote,
- cw: ps.cw,
+ cw: ps.cw || null,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts
index 4853489827..8ab9f72139 100644
--- a/packages/backend/src/server/api/endpoints/notes/featured.ts
+++ b/packages/backend/src/server/api/endpoints/notes/featured.ts
@@ -11,6 +11,9 @@ import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
export const meta = {
tags: ['notes'],
@@ -29,10 +32,19 @@ export const meta = {
},
},
- // 10 calls per 5 seconds
+ errors: {
+ ltlDisabled: {
+ message: 'Local timeline has been disabled.',
+ code: 'LTL_DISABLED',
+ id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
+ },
+ },
+
+ // Burst of 10 calls to handle tab reload, then 4/second for refresh
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 10,
+ dripSize: 4,
},
} as const;
@@ -58,8 +70,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private cacheService: CacheService,
private noteEntityService: NoteEntityService,
private featuredService: FeaturedService,
+ private queryService: QueryService,
+ private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
+ const policies = await this.roleService.getUserPolicies(me ? me.id : null);
+ if (!policies.ltlAvailable) {
+ throw new ApiError(meta.errors.ltlDisabled);
+ }
+
let noteIds: string[];
if (ps.channelId) {
noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50);
@@ -98,7 +117,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
- .leftJoinAndSelect('note.channel', 'channel');
+ .leftJoinAndSelect('note.channel', 'channel')
+ .andWhere('user.isExplorable = TRUE');
+
+ this.queryService.generateBlockedHostQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts
index 228793fbf6..5f6ee9f903 100644
--- a/packages/backend/src/server/api/endpoints/notes/following.ts
+++ b/packages/backend/src/server/api/endpoints/notes/following.ts
@@ -143,9 +143,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('"user"."isBot" = false');
}
+ // Hide blocked users / instances
+ query.andWhere('"user"."isSuspended" = false');
+ query.andWhere('("replyUser" IS NULL OR "replyUser"."isSuspended" = false)');
+ query.andWhere('("renoteUser" IS NULL OR "renoteUser"."isSuspended" = false)');
+ this.queryService.generateBlockedHostQueryForNote(query);
+
// Respect blocks and mutes
- this.queryService.generateBlockedUserQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
// Support pagination
this.queryService
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index 0f2592bd78..e82d9ca7af 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -76,11 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled);
}
- const [
- followings,
- ] = me ? await Promise.all([
- this.cacheService.userFollowingsCache.fetch(me.id),
- ]) : [undefined];
+ const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
@@ -93,9 +89,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
+ this.queryService.generateBlockedHostQueryForNote(query);
+
if (me) {
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 3c66154e19..6461a2e33f 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -254,8 +254,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index 1f986079c2..f55853f3f3 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -167,8 +167,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
index 38912421a4..269b57366c 100644
--- a/packages/backend/src/server/api/endpoints/notes/mentions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
@@ -58,7 +57,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
@@ -80,9 +78,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
@@ -95,8 +94,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const mentions = await query.limit(ps.limit).getMany();
- this.noteReadService.read(me.id, mentions);
-
return await this.noteEntityService.packMany(mentions, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
index a5014a490f..0b318304f3 100644
--- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
+++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
@@ -174,7 +174,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// リモートフォロワーにUpdate配信
- this.pollService.deliverQuestionUpdate(note.id);
+ this.pollService.deliverQuestionUpdate(note);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts
index e683cc87bd..f2355518a2 100644
--- a/packages/backend/src/server/api/endpoints/notes/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts
@@ -80,8 +80,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('reaction.reaction = :type', { type });
}
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
const reactions = await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts
index 15f114266a..0f08cc9cf2 100644
--- a/packages/backend/src/server/api/endpoints/notes/renotes.ts
+++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts
@@ -91,8 +91,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
const renotes = await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts
index 3f0a8157c4..0882e19182 100644
--- a/packages/backend/src/server/api/endpoints/notes/replies.ts
+++ b/packages/backend/src/server/api/endpoints/notes/replies.ts
@@ -62,8 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
const timeline = await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
index 4dd3d7a81a..cbf3a961c0 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
@@ -100,7 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
id: string;
note: {
text?: string;
- cw?: string|null;
+ cw?: string | null;
fileIds: string[];
visibility: typeof noteVisibilities[number];
visibleUsers: Packed<'UserLite'>[];
@@ -133,6 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
renote, reply,
renoteId: item.note.renote,
replyId: item.note.reply,
+ poll: item.note.poll ? await this.fillPoll(item.note.poll) : undefined,
},
};
}));
@@ -155,4 +156,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
return null;
}
+
+ // Pulled from NoteEntityService and modified to work with MiNoteSchedule
+ // originally planned to use directly from NoteEntityService but since the poll doesn't actually exist yet that doesn't work
+ @bindThis
+ private async fillPoll(poll: { multiple: boolean; choices: string[]; expiresAt: string | null }) {
+ const choices = poll.choices.map(c => ({
+ text: c,
+ votes: 0, // Default to 0 as there will never be any registered votes while scheduled
+ isVoted: false, // Default to false as the author can't vote anyways since the poll does not exist in the repo yet
+ }));
+
+ return {
+ multiple: poll.multiple,
+ expiresAt: poll.expiresAt ? new Date(poll.expiresAt).toISOString() : null,
+ choices,
+ };
+ }
}
diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
index 6bba7bf37e..91874a8195 100644
--- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
+++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
@@ -97,14 +97,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
+ if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
- const [
- followings,
- ] = me ? await Promise.all([
- this.cacheService.userFollowingsCache.fetch(me.id),
- ]) : [undefined];
+ const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
try {
if (ps.tag) {
diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts
index f0c9db38b4..44e7137f29 100644
--- a/packages/backend/src/server/api/endpoints/notes/show.ts
+++ b/packages/backend/src/server/api/endpoints/notes/show.ts
@@ -67,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
if (me) {
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const note = await query.getOne();
diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
index 732d644a29..29c6aa7434 100644
--- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js
import { IdService } from '@/core/IdService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@@ -52,7 +51,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
private getterService: GetterService,
- private noteReadService: NoteReadService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -69,8 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}],
});
- await this.noteReadService.read(me.id, mutedNotes);
-
await this.noteThreadMutingsRepository.insert({
id: this.idService.gen(),
threadId: note.threadId ?? note.id,
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index 5a46f66f9e..a2dfa7fdac 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -209,8 +209,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts
index 61a511510c..a97542c063 100644
--- a/packages/backend/src/server/api/endpoints/notes/translate.ts
+++ b/packages/backend/src/server/api/endpoints/notes/translate.ts
@@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
-import { ApiError } from '../../error.js';
-import { MiMeta } from '@/models/_.js';
+import type { MiMeta, MiNote } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
+import { CacheService } from '@/core/CacheService.js';
+import { hasText } from '@/models/Note.js';
+import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
+import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
+ // TODO allow unauthenticated if default template allows?
+ // Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role.
+ // This will allow unauthenticated requests without leaking post data to restricted clients.
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
- optional: true, nullable: false,
+ optional: false, nullable: false,
properties: {
- sourceLang: { type: 'string' },
- text: { type: 'string' },
+ sourceLang: { type: 'string', optional: true, nullable: false },
+ text: { type: 'string', optional: true, nullable: false },
},
},
@@ -45,6 +51,11 @@ export const meta = {
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d',
},
+ translationFailed: {
+ message: 'Failed to translate note. Please try again later or contact an administrator for assistance.',
+ code: 'TRANSLATION_FAILED',
+ id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f',
+ },
},
// 10 calls per 5 seconds
@@ -73,6 +84,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
private httpRequestService: HttpRequestService,
private roleService: RoleService,
+ private readonly cacheService: CacheService,
+ private readonly loggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
@@ -89,56 +102,110 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}
- if (note.text == null) {
- return;
+ if (!hasText(note)) {
+ return {};
}
- if (this.serverSettings.deeplAuthKey == null && !this.serverSettings.deeplFreeMode) {
- throw new ApiError(meta.errors.unavailable);
- }
-
- if (this.serverSettings.deeplFreeMode && !this.serverSettings.deeplFreeInstance) {
- throw new ApiError(meta.errors.unavailable);
- }
+ const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance;
+ const canDeepl = !!this.serverSettings.deeplAuthKey || canDeeplFree;
+ const canLibre = !!this.serverSettings.libreTranslateURL;
+ if (!canDeepl && !canLibre) throw new ApiError(meta.errors.unavailable);
let targetLang = ps.targetLang;
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
- const params = new URLSearchParams();
- if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
- params.append('text', note.text);
- params.append('target_lang', targetLang);
+ let response = await this.cacheService.getCachedTranslation(note, targetLang);
+ if (!response) {
+ this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`);
+ response = await this.fetchTranslation(note, targetLang);
+ if (!response) {
+ throw new ApiError(meta.errors.translationFailed);
+ }
- const endpoint = this.serverSettings.deeplFreeMode && this.serverSettings.deeplFreeInstance ? this.serverSettings.deeplFreeInstance : this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
+ await this.cacheService.setCachedTranslation(note, targetLang, response);
+ }
+ return response;
+ });
+ }
- const res = await this.httpRequestService.send(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- Accept: 'application/json, */*',
- },
- body: params.toString(),
- });
- if (this.serverSettings.deeplAuthKey) {
- const json = (await res.json()) as {
- translations: {
- detected_source_language: string;
- text: string;
- }[];
- };
+ private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) {
+ // Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts
+ try {
+ // Ignore deeplFreeInstance unless deeplFreeMode is set
+ const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null;
+
+ // DeepL/DeepLX handling
+ if (this.serverSettings.deeplAuthKey || deeplFreeInstance) {
+ const params = new URLSearchParams();
+ if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
+ params.append('text', note.text);
+ params.append('target_lang', targetLang);
+ const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
+
+ const res = await this.httpRequestService.send(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json, */*',
+ },
+ body: params.toString(),
+ timeout: this.serverSettings.translationTimeout,
+ });
+ if (this.serverSettings.deeplAuthKey) {
+ const json = (await res.json()) as {
+ translations: {
+ detected_source_language: string;
+ text: string;
+ }[];
+ };
+
+ return {
+ sourceLang: json.translations[0].detected_source_language,
+ text: json.translations[0].text,
+ };
+ } else {
+ const json = (await res.json()) as {
+ code: number,
+ message: string,
+ data: string,
+ source_lang: string,
+ target_lang: string,
+ alternatives: string[],
+ };
+
+ const languageNames = new Intl.DisplayNames(['en'], {
+ type: 'language',
+ });
+
+ return {
+ sourceLang: languageNames.of(json.source_lang),
+ text: json.data,
+ };
+ }
+ }
+
+ // LibreTranslate handling
+ if (this.serverSettings.libreTranslateURL) {
+ const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json, */*',
+ },
+ body: JSON.stringify({
+ q: note.text,
+ source: 'auto',
+ target: targetLang,
+ format: 'text',
+ api_key: this.serverSettings.libreTranslateKey ?? '',
+ }),
+ timeout: this.serverSettings.translationTimeout,
+ });
- return {
- sourceLang: json.translations[0].detected_source_language,
- text: json.translations[0].text,
- };
- } else {
const json = (await res.json()) as {
- code: number,
- message: string,
- data: string,
- source_lang: string,
- target_lang: string,
alternatives: string[],
+ detectedLanguage: { [key: string]: string | number },
+ translatedText: string,
};
const languageNames = new Intl.DisplayNames(['en'], {
@@ -146,10 +213,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
return {
- sourceLang: languageNames.of(json.source_lang),
- text: json.data,
+ sourceLang: languageNames.of(json.detectedLanguage.language as string),
+ text: json.translatedText,
};
}
- });
+ } catch (e) {
+ this.loggerService.logger.error('Unhandled error from translation API: ', { e });
+ }
+
+ return null;
}
}
diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
index 58932bd83a..f2a927f3c5 100644
--- a/packages/backend/src/server/api/endpoints/notes/unrenote.ts
+++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
@@ -66,6 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
renoteId: note.id,
});
+ // TODO inline this into the above query
for (const note of renotes) {
if (ps.quote) {
if (note.text) this.noteDeleteService.delete(await this.usersRepository.findOneByOrFail({ id: me.id }), note, false);
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
index 55cda135e2..60f18a09b0 100644
--- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -190,8 +190,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
diff --git a/packages/backend/src/server/api/endpoints/notes/versions.ts b/packages/backend/src/server/api/endpoints/notes/versions.ts
index 9b98d19fb1..1c6f9838f5 100644
--- a/packages/backend/src/server/api/endpoints/notes/versions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/versions.ts
@@ -17,8 +17,25 @@ export const meta = {
requireCredential: false,
res: {
- type: 'object',
- optional: false, nullable: false,
+ type: 'array',
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ oldDate: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ updatedAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ text: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ },
+ },
},
errors: {
@@ -60,13 +77,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = await this.notesRepository.createQueryBuilder('note')
+ const query = this.notesRepository.createQueryBuilder('note')
.where('note.id = :noteId', { noteId: ps.noteId })
.innerJoinAndSelect('note.user', 'user');
this.queryService.generateVisibilityQuery(query, me);
if (me) {
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const note = await query.getOne();
@@ -75,6 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchNote);
}
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (note.user!.requireSigninToViewContents && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
@@ -84,17 +102,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
- let editArray = [];
+ let editArray: { oldDate: string, updatedAt: string, text: string | null }[] = [];
for (const edit of edits) {
editArray.push({
- oldDate: edit.oldDate as Date | null ?? null,
- updatedAt: edit.updatedAt,
+ oldDate: (edit.oldDate ?? edit.updatedAt).toISOString(),
+ updatedAt: edit.updatedAt.toISOString(),
text: edit.oldText ?? edit.newText ?? null,
});
}
- editArray = editArray.sort((a, b) => { return new Date(b.oldDate ?? b.updatedAt).getTime() - new Date(a.oldDate ?? a.updatedAt).getTime(); });
+ editArray = editArray.sort((a, b) => { return new Date(b.oldDate).getTime() - new Date(a.oldDate).getTime(); });
return editArray;
});
diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts
index bf40e1e3e0..fe23160bb8 100644
--- a/packages/backend/src/server/api/endpoints/reset-db.ts
+++ b/packages/backend/src/server/api/endpoints/reset-db.ts
@@ -6,9 +6,12 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
+import { LoggerService } from '@/core/LoggerService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { resetDb } from '@/misc/reset-db.js';
+import { MetaService } from '@/core/MetaService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
export const meta = {
tags: ['non-productive'],
@@ -42,13 +45,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.redis)
private redisClient: Redis.Redis,
+
+ private loggerService: LoggerService,
+ private metaService: MetaService,
+ private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
- await redisClient.flushdb();
+ const logger = this.loggerService.getLogger('reset-db');
+ logger.info('---- Resetting database...');
+
+ await this.redisClient.flushdb();
await resetDb(this.db);
+ // DIコンテナで管理しているmetaのインスタンスには上記のリセット処理が届かないため、
+ // 初期値を流して明示的にリフレッシュする
+ const meta = await this.metaService.fetch(true);
+ this.globalEventService.publishInternalEvent('metaUpdated', { after: meta });
+
+ logger.info('---- Database reset complete.');
+
await new Promise(resolve => setTimeout(resolve, 1000));
});
}
diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts
index d9240dec5e..ba0c60f4ec 100644
--- a/packages/backend/src/server/api/endpoints/reset-password.ts
+++ b/packages/backend/src/server/api/endpoints/reset-password.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-//import bcrypt from 'bcryptjs';
import * as argon2 from 'argon2';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/_.js';
@@ -60,7 +59,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// Generate hash of password
- //const salt = await bcrypt.genSalt(8);
const hash = await argon2.hash(ps.password);
await this.userProfilesRepository.update(req.userId, {
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
index b3c73e0391..d1c2e4b686 100644
--- a/packages/backend/src/server/api/endpoints/roles/notes.ts
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -108,8 +108,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateMutedUserQueryForNotes(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts
index 528de76707..33ef48226d 100644
--- a/packages/backend/src/server/api/endpoints/server-info.ts
+++ b/packages/backend/src/server/api/endpoints/server-info.ts
@@ -66,7 +66,8 @@ export const meta = {
// 24 calls, then 7 per second-ish (1 for each type of server info graph)
limit: {
- max: 24,
+ type: 'bucket',
+ size: 24,
dripSize: 7,
dripRate: 900,
},
diff --git a/packages/backend/src/server/api/endpoints/sponsors.ts b/packages/backend/src/server/api/endpoints/sponsors.ts
index 401d9292bc..8e1ac749d5 100644
--- a/packages/backend/src/server/api/endpoints/sponsors.ts
+++ b/packages/backend/src/server/api/endpoints/sponsors.ts
@@ -14,6 +14,39 @@ export const meta = {
requireCredential: false,
requireCredentialPrivateMode: false,
+ res: {
+ type: 'object',
+ nullable: false, optional: false,
+ properties: {
+ sponsor_data: {
+ type: 'array',
+ nullable: false, optional: false,
+ items: {
+ type: 'object',
+ nullable: false, optional: false,
+ properties: {
+ name: {
+ type: 'string',
+ nullable: false, optional: false,
+ },
+ image: {
+ type: 'string',
+ nullable: true, optional: false,
+ },
+ website: {
+ type: 'string',
+ nullable: true, optional: false,
+ },
+ profile: {
+ type: 'string',
+ nullable: false, optional: false,
+ },
+ },
+ },
+ },
+ },
+ },
+
// 2 calls per second
limit: {
duration: 1000,
@@ -24,6 +57,7 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
+ // TODO remove this or make staff-only to prevent DoS
forceUpdate: { type: 'boolean', default: false },
instance: { type: 'boolean', default: false },
},
@@ -35,12 +69,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private sponsorsService: SponsorsService,
) {
- super(meta, paramDef, async (ps, me) => {
- if (ps.instance) {
- return { sponsor_data: await this.sponsorsService.instanceSponsors(ps.forceUpdate) };
- } else {
- return { sponsor_data: await this.sponsorsService.sharkeySponsors(ps.forceUpdate) };
- }
+ super(meta, paramDef, async (ps) => {
+ const sponsors = ps.instance
+ ? await this.sponsorsService.instanceSponsors(ps.forceUpdate)
+ : await this.sponsorsService.sharkeySponsors(ps.forceUpdate);
+
+ return {
+ sponsor_data: sponsors.map(s => ({
+ name: s.name,
+ image: s.image,
+ website: s.website,
+ profile: s.profile,
+ })),
+ };
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts
index 089d346cd2..defd38fe96 100644
--- a/packages/backend/src/server/api/endpoints/users.ts
+++ b/packages/backend/src/server/api/endpoints/users.ts
@@ -4,11 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository } from '@/models/_.js';
+import { MiFollowing } from '@/models/_.js';
+import type { MiUser, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
+import { RoleService } from '@/core/RoleService.js';
+import type { SelectQueryBuilder } from 'typeorm';
export const meta = {
tags: ['users'],
@@ -25,10 +28,11 @@ export const meta = {
},
},
- // 2 calls per second
+ // 20 calls, then 4 per second
limit: {
- duration: 1000,
- max: 2,
+ type: 'bucket',
+ size: 20,
+ dripRate: 250,
},
} as const;
@@ -37,7 +41,7 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
- sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
+ sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
hostname: {
@@ -58,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private queryService: QueryService,
+ private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.usersRepository.createQueryBuilder('user')
@@ -80,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
switch (ps.sort) {
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
+ case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break;
+ case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'ASC'); break;
case '+createdAt': query.orderBy('user.id', 'DESC'); break;
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
@@ -93,9 +100,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.limit(ps.limit);
query.offset(ps.offset);
- const users = await query.getMany();
+ const allUsers = await query.getMany();
+
+ // This is not ideal, for a couple of reasons:
+ // 1. It may return less than "limit" results.
+ // 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
+ // Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
+ const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
+ const users = usersWithRoles
+ .filter(([,p]) => p.canTrend)
+ .map(([u]) => u);
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
});
}
+
+ private addLocalFollowers(query: SelectQueryBuilder<MiUser>) {
+ query.innerJoin(qb => {
+ return qb
+ .from(MiFollowing, 'f')
+ .addSelect('f."followeeId"')
+ .addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers')
+ .addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers')
+ .groupBy('"followeeId"');
+ }, 'f', 'user.id = f."followeeId"');
+ }
}
diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts
index e6acae08b1..3fb091cc0e 100644
--- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts
+++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts
@@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { CacheService } from '@/core/CacheService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
+import { QueryService } from '@/core/QueryService.js';
export const meta = {
tags: ['notes'],
@@ -55,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private featuredService: FeaturedService,
private cacheService: CacheService,
+ private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set<string>();
@@ -91,6 +93,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
+ this.queryService.generateBlockedHostQueryForNote(query);
+
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
index 6416e43ff1..965baa859a 100644
--- a/packages/backend/src/server/api/endpoints/users/notes.ts
+++ b/packages/backend/src/server/api/endpoints/users/notes.ts
@@ -145,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
redisTimelines,
useDbFallback: true,
ignoreAuthorFromMute: true,
+ ignoreAuthorFromInstanceBlock: true,
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes,
@@ -216,9 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query, true);
if (me) {
- this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) {
diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts
index 49c1190197..56f59bd285 100644
--- a/packages/backend/src/server/api/endpoints/users/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/users/reactions.ts
@@ -108,6 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reaction.note', 'note');
this.queryService.generateVisibilityQuery(query, me);
+ this.queryService.generateBlockedHostQueryForNote(query);
const reactions = (await query
.limit(ps.limit)
diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts
index 46af1f38ac..642d788459 100644
--- a/packages/backend/src/server/api/endpoints/users/recommendation.ts
+++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts
@@ -69,7 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQueryForUsers(query, me);
this.queryService.generateBlockQueryForUsers(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
index fda56ea6fe..f1a0fc5ddb 100644
--- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -52,7 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userSearchService: UserSearchService,
) {
super(meta, paramDef, (ps, me) => {
- return this.userSearchService.search({
+ return this.userSearchService.searchByUsernameAndHost({
username: ps.username,
host: ps.host,
}, {
diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts
index 2d17c91e1d..138cef2ec5 100644
--- a/packages/backend/src/server/api/endpoints/users/search.ts
+++ b/packages/backend/src/server/api/endpoints/users/search.ts
@@ -3,14 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
-import type { MiUser } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
-import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import { UserSearchService } from '@/core/UserSearchService.js';
export const meta = {
tags: ['users'],
@@ -51,79 +48,15 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
- @Inject(DI.userProfilesRepository)
- private userProfilesRepository: UserProfilesRepository,
-
private userEntityService: UserEntityService,
+ private userSearchService: UserSearchService,
) {
super(meta, paramDef, async (ps, me) => {
- const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
-
- ps.query = ps.query.trim();
- const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1;
-
- let users: MiUser[] = [];
-
- const nameQuery = this.usersRepository.createQueryBuilder('user')
- .where(new Brackets(qb => {
- qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
-
- if (isUsername) {
- qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' });
- } else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username
- qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
- }
- }))
- .andWhere(new Brackets(qb => {
- qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
- }))
- .andWhere('user.isSuspended = FALSE');
-
- if (ps.origin === 'local') {
- nameQuery.andWhere('user.host IS NULL');
- } else if (ps.origin === 'remote') {
- nameQuery.andWhere('user.host IS NOT NULL');
- }
-
- users = await nameQuery
- .orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
- .limit(ps.limit)
- .offset(ps.offset)
- .getMany();
-
- if (users.length < ps.limit) {
- const profQuery = this.userProfilesRepository.createQueryBuilder('prof')
- .select('prof.userId')
- .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
-
- if (ps.origin === 'local') {
- profQuery.andWhere('prof.userHost IS NULL');
- } else if (ps.origin === 'remote') {
- profQuery.andWhere('prof.userHost IS NOT NULL');
- }
-
- const query = this.usersRepository.createQueryBuilder('user')
- .where(`user.id IN (${ profQuery.getQuery() })`)
- .andWhere(new Brackets(qb => {
- qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
- }))
- .andWhere('user.isSuspended = FALSE')
- .setParameters(profQuery.getParameters());
-
- users = users.concat(await query
- .orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
- .limit(ps.limit)
- .offset(ps.offset)
- .getMany(),
- );
- }
+ const users = await this.userSearchService.search(ps.query.trim(), me?.id ?? null, {
+ offset: ps.offset,
+ limit: ps.limit,
+ origin: ps.origin,
+ });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts
index 118362149d..7b1c8adfb8 100644
--- a/packages/backend/src/server/api/endpoints/users/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/show.ts
@@ -59,7 +59,8 @@ export const meta = {
// up to 50 calls @ 4 per second
limit: {
- max: 50,
+ type: 'bucket',
+ size: 50,
dripRate: 250,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts
index 9426318e34..7139715293 100644
--- a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts
+++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts
@@ -12,7 +12,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
- requireRolePolicy: 'canManageCustomEmojis',
+ requiredRolePolicy: 'canManageCustomEmojis',
kind: 'read:admin:emoji',
res: {
diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
index 69799bdade..74fd9d7d59 100644
--- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
+++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
@@ -3,926 +3,242 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import querystring from 'querystring';
-import { megalodon, Entity, MegalodonInterface } from 'megalodon';
-import { IsNull } from 'typeorm';
-import multer from 'fastify-multer';
-import { Inject, Injectable } from '@nestjs/common';
-import type { AccessTokensRepository, UserProfilesRepository, UsersRepository, MiMeta } from '@/models/_.js';
-import { DI } from '@/di-symbols.js';
+import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
-import type { Config } from '@/config.js';
-import { DriveService } from '@/core/DriveService.js';
-import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
-import { ApiAccountMastodonRoute } from '@/server/api/mastodon/endpoints/account.js';
-import { ApiSearchMastodonRoute } from '@/server/api/mastodon/endpoints/search.js';
-import { ApiFilterMastodonRoute } from '@/server/api/mastodon/endpoints/filter.js';
-import { ApiNotifyMastodonRoute } from '@/server/api/mastodon/endpoints/notifications.js';
-import { AuthenticateService } from '@/server/api/AuthenticateService.js';
-import { MiLocalUser } from '@/models/User.js';
-import { AuthMastodonRoute } from './endpoints/auth.js';
-import { toBoolean } from './timelineArgs.js';
-import { convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList, MastoConverters } from './converters.js';
-import { getInstance } from './endpoints/meta.js';
-import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js';
-import type { FastifyInstance, FastifyPluginOptions, FastifyRequest } from 'fastify';
-
-export function getAccessToken(authorization: string | undefined): string | null {
- const accessTokenArr = authorization?.split(' ') ?? [null];
- return accessTokenArr[accessTokenArr.length - 1];
-}
-
-export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
- const accessToken = getAccessToken(authorization);
- return megalodon('misskey', BASE_URL, accessToken);
-}
+import { getErrorData, getErrorException, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js';
+import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js';
+import { ApiFilterMastodon } from '@/server/api/mastodon/endpoints/filter.js';
+import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js';
+import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js';
+import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifications.js';
+import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js';
+import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js';
+import { ApiError } from '@/server/api/error.js';
+import { ServerUtilityService } from '@/server/ServerUtilityService.js';
+import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js';
+import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js';
+import type { Entity } from 'megalodon';
+import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable()
export class MastodonApiServerService {
constructor(
- @Inject(DI.meta)
- private readonly serverSettings: MiMeta,
- @Inject(DI.usersRepository)
- private readonly usersRepository: UsersRepository,
- @Inject(DI.userProfilesRepository)
- private readonly userProfilesRepository: UserProfilesRepository,
- @Inject(DI.accessTokensRepository)
- private readonly accessTokensRepository: AccessTokensRepository,
- @Inject(DI.config)
- private readonly config: Config,
- private readonly driveService: DriveService,
- private readonly mastoConverters: MastoConverters,
+ private readonly mastoConverters: MastodonConverters,
private readonly logger: MastodonLogger,
- private readonly authenticateService: AuthenticateService,
- ) { }
-
- @bindThis
- public async getAuthClient(request: FastifyRequest): Promise<{ client: MegalodonInterface, me: MiLocalUser | null }> {
- const accessToken = getAccessToken(request.headers.authorization);
- const [me] = await this.authenticateService.authenticate(accessToken);
-
- const baseUrl = `${request.protocol}://${request.host}`;
- const client = megalodon('misskey', baseUrl, accessToken);
-
- return { client, me };
- }
-
- @bindThis
- public async getAuthOnly(request: FastifyRequest): Promise<MiLocalUser | null> {
- const accessToken = getAccessToken(request.headers.authorization);
- const [me] = await this.authenticateService.authenticate(accessToken);
- return me;
- }
+ private readonly clientService: MastodonClientService,
+ private readonly apiAccountMastodon: ApiAccountMastodon,
+ private readonly apiAppsMastodon: ApiAppsMastodon,
+ private readonly apiFilterMastodon: ApiFilterMastodon,
+ private readonly apiInstanceMastodon: ApiInstanceMastodon,
+ private readonly apiNotificationsMastodon: ApiNotificationsMastodon,
+ private readonly apiSearchMastodon: ApiSearchMastodon,
+ private readonly apiStatusMastodon: ApiStatusMastodon,
+ private readonly apiTimelineMastodon: ApiTimelineMastodon,
+ private readonly serverUtilityService: ServerUtilityService,
+ ) {}
@bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
- const upload = multer({
- storage: multer.diskStorage({}),
- limits: {
- fileSize: this.config.maxFileSize || 262144000,
- files: 1,
- },
- });
-
- fastify.addHook('onRequest', (_, reply, done) => {
- reply.header('Access-Control-Allow-Origin', '*');
- done();
- });
-
- fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
- let body = '';
- payload.on('data', (data) => {
- body += data;
- });
- payload.on('end', () => {
- try {
- const parsed = querystring.parse(body);
- done(null, parsed);
- } catch (e) {
- done(e as Error);
- }
- });
- payload.on('error', done);
- });
-
- fastify.register(multer.contentParser);
-
- fastify.get('/v1/custom_emojis', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const data = await client.getInstanceCustomEmojis();
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/custom_emojis', data);
- reply.code(401).send(data);
- }
- });
-
- fastify.get('/v1/instance', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await client.getInstance();
- const admin = await this.usersRepository.findOne({
- where: {
- host: IsNull(),
- isRoot: true,
- isDeleted: false,
- isSuspended: false,
- },
- order: { id: 'ASC' },
- });
- const contact = admin == null ? null : await this.mastoConverters.convertAccount((await client.getAccount(admin.id)).data);
- reply.send(await getInstance(data.data, contact as Entity.Account, this.config, this.serverSettings));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/instance', data);
- reply.code(401).send(data);
- }
- });
+ this.serverUtilityService.addMultipartFormDataContentType(fastify);
+ this.serverUtilityService.addFormUrlEncodedContentType(fastify);
+ this.serverUtilityService.addCORS(fastify);
+ this.serverUtilityService.addFlattenedQueryType(fastify);
- fastify.get('/v1/announcements', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const data = await client.getInstanceAnnouncements();
- reply.send(data.data.map((announcement) => convertAnnouncement(announcement)));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/announcements', data);
- reply.code(401).send(data);
- }
- });
+ // Convert JS exceptions into error responses
+ fastify.setErrorHandler((error, request, reply) => {
+ const data = getErrorData(error);
+ const status = getErrorStatus(error);
+ const exception = getErrorException(error);
- fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.body.id) return reply.code(400).send({ error: 'Missing required payload "id"' });
- const data = await client.dismissInstanceAnnouncement(_request.body['id']);
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/announcements/${_request.body.id}/dismiss`, data);
- reply.code(401).send(data);
+ if (exception) {
+ this.logger.exception(request, exception);
}
- });
- fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const multipartData = await _request.file();
- if (!multipartData) {
- reply.code(401).send({ error: 'No image' });
- return;
- }
- const data = await client.uploadMedia(multipartData);
- reply.send(convertAttachment(data.data as Entity.Attachment));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v1/media', data);
- reply.code(401).send(data);
- }
+ return reply.code(status).send(data);
});
- fastify.post<{ Body: { description?: string; focus?: string }}>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const multipartData = await _request.file();
- if (!multipartData) {
- reply.code(401).send({ error: 'No image' });
- return;
+ // Log error responses (including converted JSON exceptions)
+ fastify.addHook('onSend', (request, reply, payload, done) => {
+ if (reply.statusCode >= 400) {
+ if (typeof(payload) === 'string' && String(reply.getHeader('content-type')).toLowerCase().includes('application/json')) {
+ const body = JSON.parse(payload);
+ const data = getErrorData(body);
+ this.logger.error(request, data, reply.statusCode);
}
- const data = await client.uploadMedia(multipartData, _request.body);
- reply.send(convertAttachment(data.data as Entity.Attachment));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v2/media', data);
- reply.code(401).send(data);
- }
- });
-
- fastify.get('/v1/filters', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await client.getFilters();
- reply.send(data.data.map((filter) => convertFilter(filter)));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/filters', data);
- reply.code(401).send(data);
- }
- });
-
- fastify.get('/v1/trends', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await client.getInstanceTrends();
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/trends', data);
- reply.code(401).send(data);
- }
- });
-
- fastify.get('/v1/trends/tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await client.getInstanceTrends();
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/trends/tags', data);
- reply.code(401).send(data);
- }
- });
-
- fastify.get('/v1/trends/links', async (_request, reply) => {
- // As we do not have any system for news/links this will just return empty
- reply.send([]);
- });
-
- fastify.post<AuthMastodonRoute>('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await ApiAuthMastodon(_request, client);
- reply.send(data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/apps', data);
- reply.code(401).send(data);
}
+ done();
});
- fastify.get('/v1/preferences', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- const data = await client.getPreferences();
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/preferences', data);
- reply.code(401).send(data);
- }
- });
+ // External endpoints
+ this.apiAccountMastodon.register(fastify);
+ this.apiAppsMastodon.register(fastify);
+ this.apiFilterMastodon.register(fastify);
+ this.apiInstanceMastodon.register(fastify);
+ this.apiNotificationsMastodon.register(fastify);
+ this.apiSearchMastodon.register(fastify);
+ this.apiStatusMastodon.register(fastify);
+ this.apiTimelineMastodon.register(fastify);
- //#region Accounts
- fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.verifyCredentials());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/accounts/verify_credentials', data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/custom_emojis', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.getInstanceCustomEmojis();
+ return reply.send(data.data);
});
- fastify.patch<{
- Body: {
- discoverable?: string,
- bot?: string,
- display_name?: string,
- note?: string,
- avatar?: string,
- header?: string,
- locked?: string,
- source?: {
- privacy?: string,
- sensitive?: string,
- language?: string,
- },
- fields_attributes?: {
- name: string,
- value: string,
- }[],
- },
- }>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
- // displayed without being logged in
- try {
- // Check if there is an Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it.
- if (_request.files.length > 0 && accessTokens) {
- const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') });
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const avatar = (_request.files as any).find((obj: any) => {
- return obj.fieldname === 'avatar';
- });
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const header = (_request.files as any).find((obj: any) => {
- return obj.fieldname === 'header';
- });
-
- if (tokeninfo && avatar) {
- const upload = await this.driveService.addFile({
- user: { id: tokeninfo.userId, host: null },
- path: avatar.path,
- name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined,
- sensitive: false,
- });
- if (upload.type.startsWith('image/')) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (_request.body as any).avatar = upload.id;
- }
- } else if (tokeninfo && header) {
- const upload = await this.driveService.addFile({
- user: { id: tokeninfo.userId, host: null },
- path: header.path,
- name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined,
- sensitive: false,
- });
- if (upload.type.startsWith('image/')) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (_request.body as any).header = upload.id;
- }
- }
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- if ((_request.body as any).fields_attributes) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const fields = (_request.body as any).fields_attributes.map((field: any) => {
- if (!(field.name.trim() === '' && field.value.trim() === '')) {
- if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty');
- if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty');
- }
- return {
- ...field,
- };
- });
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0);
- }
-
- const options = {
- ..._request.body,
- discoverable: toBoolean(_request.body.discoverable),
- bot: toBoolean(_request.body.bot),
- locked: toBoolean(_request.body.locked),
- source: _request.body.source ? {
- ..._request.body.source,
- sensitive: toBoolean(_request.body.source.sensitive),
- } : undefined,
- };
- const data = await client.updateCredentials(options);
- reply.send(await this.mastoConverters.convertAccount(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('PATCH /v1/accounts/update_credentials', data);
- reply.code(401).send(data);
- }
- });
+ fastify.get('/v1/announcements', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.getInstanceAnnouncements();
+ const response = data.data.map((announcement) => convertAnnouncement(announcement));
- fastify.get<{ Querystring: { acct?: string }}>('/v1/accounts/lookup', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isn't displayed without being logged in
- try {
- if (!_request.query.acct) return reply.code(400).send({ error: 'Missing required property "acct"' });
- const data = await client.search(_request.query.acct, { type: 'accounts' });
- const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id });
- data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? [];
- reply.send(await this.mastoConverters.convertAccount(data.data.accounts[0]));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/accounts/lookup', data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[], 'id[]'?: string | string[] }}>('/v1/accounts/relationships', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- let ids = _request.query['id[]'] ?? _request.query['id'] ?? [];
- if (typeof ids === 'string') {
- ids = [ids];
- }
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getRelationships(ids));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/accounts/relationships', data);
- reply.code(401).send(data);
- }
- });
+ fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
+ if (!_request.body.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "id"' });
- fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getAccount(_request.params.id);
- const account = await this.mastoConverters.convertAccount(data.data);
- reply.send(account);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
- });
+ const client = this.clientService.getClient(_request);
+ const data = await client.dismissInstanceAnnouncement(_request.body.id);
- fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getStatuses());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}/statuses`, data);
- reply.code(401).send(data);
- }
+ return reply.send(data.data);
});
- fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getFeaturedTags();
- reply.send(data.data.map((tag) => convertFeaturedTag(tag)));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}/featured_tags`, data);
- reply.code(401).send(data);
+ fastify.post('/v1/media', async (_request, reply) => {
+ const multipartData = _request.savedRequestFiles?.[0];
+ if (!multipartData) {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
- });
- fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getFollowers());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}/followers`, data);
- reply.code(401).send(data);
- }
- });
+ const client = this.clientService.getClient(_request);
+ const data = await client.uploadMedia(multipartData);
+ const response = convertAttachment(data.data as Entity.Attachment);
- fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getFollowing());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}/following`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getAccountLists(_request.params.id);
- reply.send(data.data.map((list) => convertList(list)));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/accounts/${_request.params.id}/lists`, data);
- reply.code(401).send(data);
+ fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => {
+ const multipartData = _request.savedRequestFiles?.[0];
+ if (!multipartData) {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
- });
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.addFollow());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/follow`, data);
- reply.code(401).send(data);
- }
- });
+ const client = this.clientService.getClient(_request);
+ const data = await client.uploadMedia(multipartData, _request.body);
+ const response = convertAttachment(data.data as Entity.Attachment);
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.rmFollow());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/unfollow`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.addBlock());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/block`, data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/trends', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.getInstanceTrends();
+ return reply.send(data.data);
});
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.rmBlock());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/unblock`, data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/trends/tags', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.getInstanceTrends();
+ return reply.send(data.data);
});
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.addMute());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/mute`, data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/trends/links', async (_request, reply) => {
+ // As we do not have any system for news/links this will just return empty
+ return reply.send([]);
});
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.rmMute());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/accounts/${_request.params.id}/unmute`, data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/preferences', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.getPreferences();
+ return reply.send(data.data);
});
fastify.get('/v1/followed_tags', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const data = await client.getFollowedTags();
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/followed_tags', data);
- reply.code(401).send(data);
- }
+ const client = this.clientService.getClient(_request);
+ const data = await client.getFollowedTags();
+ return reply.send(data.data);
});
- fastify.get<ApiAccountMastodonRoute>('/v1/bookmarks', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getBookmarks());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/bookmarks', data);
- reply.code(401).send(data);
- }
- });
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(_request);
- fastify.get<ApiAccountMastodonRoute>('/v1/favourites', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getFavourites());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/favourites', data);
- reply.code(401).send(data);
- }
- });
+ const data = await client.getBookmarks(parseTimelineArgs(_request.query));
+ const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me)));
- fastify.get<ApiAccountMastodonRoute>('/v1/mutes', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getMutes());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/mutes', data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.get<ApiAccountMastodonRoute>('/v1/blocks', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.getBlocks());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/blocks', data);
- reply.code(401).send(data);
- }
- });
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(_request);
- fastify.get<{ Querystring: { limit?: string }}>('/v1/follow_requests', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const limit = _request.query.limit ? parseInt(_request.query.limit) : 20;
- const data = await client.getFollowRequests(limit);
- reply.send(await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account as Entity.Account))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/follow_requests', data);
- reply.code(401).send(data);
+ if (!me) {
+ throw new ApiError({
+ message: 'Credential required.',
+ code: 'CREDENTIAL_REQUIRED',
+ id: '1384574d-a912-4b81-8601-c7b1c4085df1',
+ httpStatusCode: 401,
+ });
}
- });
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.acceptFollow());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/follow_requests/${_request.params.id}/authorize`, data);
- reply.code(401).send(data);
- }
- });
+ const args = {
+ ...parseTimelineArgs(_request.query),
+ userId: me.id,
+ };
+ const data = await client.getFavourites(args);
+ const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me)));
- fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const account = new ApiAccountMastodon(_request, client, me, this.mastoConverters);
- reply.send(await account.rejectFollow());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/follow_requests/${_request.params.id}/reject`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- //#endregion
- //#region Search
- fastify.get<ApiSearchMastodonRoute>('/v1/search', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- try {
- const { client, me } = await this.getAuthClient(_request);
- const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
- reply.send(await search.SearchV1());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/search', data);
- reply.code(401).send(data);
- }
- });
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
- fastify.get<ApiSearchMastodonRoute>('/v2/search', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- try {
- const { client, me } = await this.getAuthClient(_request);
- const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
- reply.send(await search.SearchV2());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v2/search', data);
- reply.code(401).send(data);
- }
- });
+ const data = await client.getMutes(parseTimelineArgs(_request.query));
+ const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
- fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- try {
- const { client, me } = await this.getAuthClient(_request);
- const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
- reply.send(await search.getStatusTrends());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/trends/statuses', data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- try {
- const { client, me } = await this.getAuthClient(_request);
- const search = new ApiSearchMastodon(_request, client, me, BASE_URL, this.mastoConverters);
- reply.send(await search.getSuggestions());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v2/suggestions', data);
- reply.code(401).send(data);
- }
- });
- //#endregion
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
- //#region Notifications
- fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
- reply.send(await notify.getNotifications());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/notifications', data);
- reply.code(401).send(data);
- }
- });
+ const data = await client.getBlocks(parseTimelineArgs(_request.query));
+ const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
- fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
- reply.send(await notify.getNotification());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/notification/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.getAuthClient(_request);
- const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
- reply.send(await notify.rmNotification());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/notification/${_request.params.id}/dismiss`, data);
- reply.code(401).send(data);
- }
- });
+ fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
- fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
- try {
- const { client, me } = await this.getAuthClient(_request);
- const notify = new ApiNotifyMastodon(_request, client, me, this.mastoConverters);
- reply.send(await notify.rmNotifications());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v1/notifications/clear', data);
- reply.code(401).send(data);
- }
- });
- //#endregion
+ const limit = _request.query.limit ? parseInt(_request.query.limit) : 20;
+ const data = await client.getFollowRequests(limit);
+ const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account)));
- //#region Filters
- fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const filter = new ApiFilterMastodon(_request, client);
- _request.params.id
- ? reply.send(await filter.getFilter())
- : reply.send(await filter.getFilters());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/filters/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- fastify.post<ApiFilterMastodonRoute>('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- const filter = new ApiFilterMastodon(_request, client);
- reply.send(await filter.createFilter());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v1/filters', data);
- reply.code(401).send(data);
- }
- });
+ fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const filter = new ApiFilterMastodon(_request, client);
- reply.send(await filter.updateFilter());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/filters/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
- });
+ const client = this.clientService.getClient(_request);
+ const data = await client.acceptFollowRequest(_request.params.id);
+ const response = convertRelationship(data.data);
- fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const filter = new ApiFilterMastodon(_request, client);
- reply.send(await filter.rmFilter());
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`DELETE /v1/filters/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ return reply.send(response);
});
- //#endregion
-
- //#region Timelines
- const TLEndpoint = new ApiTimelineMastodon(fastify, this.mastoConverters, this.logger, this);
-
- // GET Endpoints
- TLEndpoint.getTL();
- TLEndpoint.getHomeTl();
- TLEndpoint.getListTL();
- TLEndpoint.getTagTl();
- TLEndpoint.getConversations();
- TLEndpoint.getList();
- TLEndpoint.getLists();
- TLEndpoint.getListAccounts();
- // POST Endpoints
- TLEndpoint.createList();
- TLEndpoint.addListAccount();
+ fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- // PUT Endpoint
- TLEndpoint.updateList();
+ const client = this.clientService.getClient(_request);
+ const data = await client.rejectFollowRequest(_request.params.id);
+ const response = convertRelationship(data.data);
- // DELETE Endpoints
- TLEndpoint.deleteList();
- TLEndpoint.rmListAccount();
+ return reply.send(response);
+ });
//#endregion
- //#region Status
- const NoteEndpoint = new ApiStatusMastodon(fastify, this.mastoConverters, this.logger, this.authenticateService, this);
-
- // GET Endpoints
- NoteEndpoint.getStatus();
- NoteEndpoint.getStatusSource();
- NoteEndpoint.getContext();
- NoteEndpoint.getHistory();
- NoteEndpoint.getReblogged();
- NoteEndpoint.getFavourites();
- NoteEndpoint.getMedia();
- NoteEndpoint.getPoll();
-
- //POST Endpoints
- NoteEndpoint.postStatus();
- NoteEndpoint.addFavourite();
- NoteEndpoint.rmFavourite();
- NoteEndpoint.reblogStatus();
- NoteEndpoint.unreblogStatus();
- NoteEndpoint.bookmarkStatus();
- NoteEndpoint.unbookmarkStatus();
- NoteEndpoint.pinStatus();
- NoteEndpoint.unpinStatus();
- NoteEndpoint.reactStatus();
- NoteEndpoint.unreactStatus();
- NoteEndpoint.votePoll();
-
- // PUT Endpoint
fastify.put<{
Params: {
id?: string,
@@ -933,29 +249,20 @@ export class MastodonApiServerService {
focus?: string,
is_sensitive?: string,
},
- }>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const options = {
- ..._request.body,
- is_sensitive: toBoolean(_request.body.is_sensitive),
- };
- const data = await client.updateMedia(_request.params.id, options);
- reply.send(convertAttachment(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`PUT /v1/media/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ }>('/v1/media/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const options = {
+ ..._request.body,
+ is_sensitive: toBoolean(_request.body.is_sensitive),
+ };
+ const client = this.clientService.getClient(_request);
+ const data = await client.updateMedia(_request.params.id, options);
+ const response = convertAttachment(data.data);
+
+ return reply.send(response);
});
- NoteEndpoint.updateStatus();
- // DELETE Endpoint
- NoteEndpoint.deleteStatus();
- //#endregion
done();
}
}
diff --git a/packages/backend/src/server/api/mastodon/MastodonClientService.ts b/packages/backend/src/server/api/mastodon/MastodonClientService.ts
new file mode 100644
index 0000000000..d7b74bb751
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonClientService.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Misskey } from 'megalodon';
+import { Injectable } from '@nestjs/common';
+import { MiLocalUser } from '@/models/User.js';
+import { AuthenticateService } from '@/server/api/AuthenticateService.js';
+import type { FastifyRequest } from 'fastify';
+
+@Injectable()
+export class MastodonClientService {
+ constructor(
+ private readonly authenticateService: AuthenticateService,
+ ) {}
+
+ /**
+ * Gets the authenticated user and API client for a request.
+ */
+ public async getAuthClient(request: FastifyRequest, accessToken?: string | null): Promise<{ client: Misskey, me: MiLocalUser | null }> {
+ const authorization = request.headers.authorization;
+ accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization);
+
+ const me = await this.getAuth(request, accessToken);
+ const client = this.getClient(request, accessToken);
+
+ return { client, me };
+ }
+
+ /**
+ * Gets the authenticated client user for a request.
+ */
+ public async getAuth(request: FastifyRequest, accessToken?: string | null): Promise<MiLocalUser | null> {
+ const authorization = request.headers.authorization;
+ accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization);
+ const [me] = await this.authenticateService.authenticate(accessToken);
+ return me;
+ }
+
+ /**
+ * Creates an authenticated API client for a request.
+ */
+ public getClient(request: FastifyRequest, accessToken?: string | null): Misskey {
+ const authorization = request.headers.authorization;
+ accessToken = accessToken !== undefined ? accessToken : getAccessToken(authorization);
+
+ // TODO pass agent?
+ const baseUrl = this.getBaseUrl(request);
+ const userAgent = request.headers['user-agent'];
+ return new Misskey(baseUrl, accessToken, userAgent);
+ }
+
+ readonly getBaseUrl = getBaseUrl;
+}
+
+/**
+ * Gets the base URL (origin) of the incoming request
+ */
+export function getBaseUrl(request: FastifyRequest): string {
+ return `${request.protocol}://${request.host}`;
+}
+
+/**
+ * Extracts the first access token from an authorization header
+ * Returns null if none were found.
+ */
+function getAccessToken(authorization: string | undefined): string | null {
+ const accessTokenArr = authorization?.split(' ') ?? [null];
+ return accessTokenArr[accessTokenArr.length - 1];
+}
diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts
index b6ff5bc59a..375ea1ef08 100644
--- a/packages/backend/src/server/api/mastodon/converters.ts
+++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts
@@ -4,8 +4,10 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import { Entity } from 'megalodon';
+import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon';
import mfm from '@transfem-org/sfm-js';
+import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js';
+import { NotificationType } from 'megalodon/lib/src/notification.js';
import { DI } from '@/di-symbols.js';
import { MfmService } from '@/core/MfmService.js';
import type { Config } from '@/config.js';
@@ -19,6 +21,8 @@ import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js';
import { GetterService } from '@/server/api/GetterService.js';
+import { appendContentWarning } from '@/misc/append-content-warning.js';
+import { isRenote } from '@/misc/is-renote.js';
// Missing from Megalodon apparently
// https://docs.joinmastodon.org/entities/StatusEdit/
@@ -47,7 +51,7 @@ export const escapeMFM = (text: string): string => text
.replace(/\r?\n/g, '<br>');
@Injectable()
-export class MastoConverters {
+export class MastodonConverters {
constructor(
@Inject(DI.config)
private readonly config: Config,
@@ -68,7 +72,6 @@ export class MastoConverters {
private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention {
let acct = u.username;
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let acctUrl = `https://${u.host || this.config.host}/@${u.username}`;
let url: string | null = null;
if (u.host) {
@@ -136,10 +139,10 @@ export class MastoConverters {
});
}
- private async encodeField(f: Entity.Field): Promise<MastodonEntity.Field> {
+ private encodeField(f: Entity.Field): MastodonEntity.Field {
return {
name: f.name,
- value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value),
+ value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value),
verified_at: null,
};
}
@@ -161,13 +164,15 @@ export class MastoConverters {
});
const fqn = `${user.username}@${user.host ?? this.config.hostname}`;
let acct = user.username;
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let acctUrl = `https://${user.host || this.config.host}/@${user.username}`;
const acctUri = `https://${this.config.host}/users/${user.id}`;
if (user.host) {
acct = `${user.username}@${user.host}`;
acctUrl = `https://${user.host}/@${user.username}`;
}
+
+ const bioText = profile?.description && this.mfmService.toMastoApiHtml(mfm.parse(profile.description));
+
return awaitAll({
id: account.id,
username: user.username,
@@ -179,16 +184,16 @@ export class MastoConverters {
followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0,
following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0,
statuses_count: user.notesCount,
- note: profile?.description ?? '',
+ note: bioText ?? '',
url: user.uri ?? acctUrl,
uri: user.uri ?? acctUri,
- avatar: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
- avatar_static: user.avatarUrl ? user.avatarUrl : 'https://dev.joinsharkey.org/static-assets/avatar.png',
- header: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
- header_static: user.bannerUrl ? user.bannerUrl : 'https://dev.joinsharkey.org/static-assets/transparent.png',
+ avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png',
+ avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png',
+ header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png',
+ header_static: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png',
emojis: emoji,
moved: null, //FIXME
- fields: Promise.all(profile?.fields.map(async p => this.encodeField(p)) ?? []),
+ fields: profile?.fields.map(p => this.encodeField(p)) ?? [],
bot: user.isBot,
discoverable: user.isExplorable,
noindex: user.noindex,
@@ -198,41 +203,56 @@ export class MastoConverters {
});
}
- public async getEdits(id: string, me?: MiLocalUser | null) {
+ public async getEdits(id: string, me: MiLocalUser | null): Promise<StatusEdit[]> {
const note = await this.mastodonDataService.getNote(id, me);
if (!note) {
return [];
}
- const noteUser = await this.getUser(note.userId).then(async (p) => await this.convertAccount(p));
+
+ const noteUser = await this.getUser(note.userId);
+ const account = await this.convertAccount(noteUser);
const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
- const history: Promise<StatusEdit>[] = [];
+ const history: StatusEdit[] = [];
+
+ const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers);
+ const renote = isRenote(note) ? await this.mastodonDataService.requireNote(note.renoteId, me) : null;
// TODO this looks wrong, according to mastodon docs
let lastDate = this.idService.parse(note.id).date;
+
for (const edit of edits) {
- const files = this.driveFileEntityService.packManyByIds(edit.fileIds);
+ // TODO avoid re-packing files for each edit
+ const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
+
+ const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
+
+ const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
+ const quoteUri = isQuote
+ ? renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`
+ : null;
+
const item = {
- account: noteUser,
- content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''),
+ account: account,
+ content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), mentionedRemoteUsers, false, quoteUri) ?? '',
created_at: lastDate.toISOString(),
- emojis: [],
- sensitive: edit.cw != null && edit.cw.length > 0,
- spoiler_text: edit.cw ?? '',
- media_attachments: files.then(files => files.length > 0 ? files.map((f) => this.encodeFile(f)) : []),
+ emojis: [], //FIXME
+ sensitive: !!cw,
+ spoiler_text: cw,
+ media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [],
};
lastDate = edit.updatedAt;
- history.push(awaitAll(item));
+ history.push(item);
}
- return await Promise.all(history);
+ return history;
}
- private async convertReblog(status: Entity.Status | null, me?: MiLocalUser | null): Promise<MastodonEntity.Status | null> {
+ private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise<MastodonEntity.Status | null> {
if (!status) return null;
return await this.convertStatus(status, me);
}
- public async convertStatus(status: Entity.Status, me?: MiLocalUser | null): Promise<MastodonEntity.Status> {
+ public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise<MastodonEntity.Status> {
const convertedAccount = this.convertAccount(status.account);
const note = await this.mastodonDataService.requireNote(status.id, me);
const noteUser = await this.getUser(status.account.id);
@@ -255,7 +275,7 @@ export class MastoConverters {
this.getUser(p)
.then(u => this.encode(u, mentionedRemoteUsers))
.catch(() => null)))
- .then(p => p.filter(m => m)) as Promise<Entity.Mention[]>;
+ .then((p: Entity.Mention[]) => p.filter(m => m));
const tags = note.tags.map(tag => {
return {
@@ -265,7 +285,6 @@ export class MastoConverters {
});
// This must mirror the usual isQuote / isPureRenote logic used elsewhere.
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId);
const renote: Promise<MiNote> | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null;
@@ -277,11 +296,11 @@ export class MastoConverters {
const text = note.text;
const content = text !== null
- ? quoteUri
- .then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quoteUri))
- .then(p => p ?? escapeMFM(text))
+ ? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
: '';
+ const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
+
const reblogged = await this.mastodonDataService.hasReblog(note.id, me);
// noinspection ES6MissingAwait
@@ -292,11 +311,12 @@ export class MastoConverters {
account: convertedAccount,
in_reply_to_id: note.replyId,
in_reply_to_account_id: note.replyUserId,
- reblog: !isQuote ? await this.convertReblog(status.reblog, me) : null,
+ reblog: !isQuote ? this.convertReblog(status.reblog, me) : null,
content: content,
content_type: 'text/x.misskeymarkdown',
text: note.text,
created_at: status.created_at,
+ edited_at: note.updatedAt?.toISOString() ?? null,
emojis: emoji,
replies_count: note.repliesCount,
reblogs_count: note.renoteCount,
@@ -304,10 +324,10 @@ export class MastoConverters {
reblogged,
favourited: status.favourited,
muted: status.muted,
- sensitive: status.sensitive,
- spoiler_text: note.cw ?? '',
+ sensitive: status.sensitive || !!cw,
+ spoiler_text: cw,
visibility: status.visibility,
- media_attachments: status.media_attachments.map(a => convertAttachment(a)),
+ media_attachments: status.media_attachments.map((a: Entity.Account) => convertAttachment(a)),
mentions: mentions,
tags: tags,
card: null, //FIXME
@@ -315,30 +335,47 @@ export class MastoConverters {
application: null, //FIXME
language: null, //FIXME
pinned: false, //FIXME
- reactions: status.emoji_reactions,
- emoji_reactions: status.emoji_reactions,
bookmarked: false, //FIXME
- quote: isQuote ? await this.convertReblog(status.reblog, me) : null,
- edited_at: note.updatedAt?.toISOString() ?? null,
+ quote_id: isQuote ? status.reblog?.id : undefined,
+ quote: isQuote ? this.convertReblog(status.reblog, me) : null,
+ reactions: status.emoji_reactions,
});
}
- public async convertConversation(conversation: Entity.Conversation, me?: MiLocalUser | null): Promise<MastodonEntity.Conversation> {
+ public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise<MastodonEntity.Conversation> {
return {
id: conversation.id,
- accounts: await Promise.all(conversation.accounts.map(a => this.convertAccount(a))),
+ accounts: await Promise.all(conversation.accounts.map((a: Entity.Account) => this.convertAccount(a))),
last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null,
unread: conversation.unread,
};
}
- public async convertNotification(notification: Entity.Notification, me?: MiLocalUser | null): Promise<MastodonEntity.Notification> {
+ public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise<MastodonEntity.Notification | null> {
+ const status = notification.status
+ ? await this.convertStatus(notification.status, me).catch(() => null)
+ : null;
+
+ // We sometimes get notifications for inaccessible notes, these should be ignored.
+ if (!status) {
+ return null;
+ }
+
return {
account: await this.convertAccount(notification.account),
created_at: notification.created_at,
id: notification.id,
- status: notification.status ? await this.convertStatus(notification.status, me) : undefined,
- type: notification.type,
+ status,
+ type: convertNotificationType(notification.type as NotificationType),
+ };
+ }
+
+ public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application {
+ return {
+ name: app.name,
+ scopes: app.permission,
+ redirect_uri: app.callbackUrl,
+ redirect_uris: [app.callbackUrl],
};
}
}
@@ -348,12 +385,26 @@ function simpleConvert<T>(data: T): T {
return Object.assign({}, data);
}
-export function convertAccount(account: Entity.Account) {
- return simpleConvert(account);
+function convertNotificationType(type: NotificationType): MastodonNotificationType {
+ switch (type) {
+ case 'emoji_reaction': return 'reaction';
+ case 'poll_vote':
+ case 'poll_expired':
+ return 'poll';
+ // Not supported by mastodon
+ case 'move':
+ return type as MastodonNotificationType;
+ default: return type;
+ }
}
-export function convertAnnouncement(announcement: Entity.Announcement) {
- return simpleConvert(announcement);
+
+export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement {
+ return {
+ ...announcement,
+ updated_at: announcement.updated_at ?? announcement.published_at,
+ };
}
+
export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment {
const { width, height } = attachment.meta?.original ?? attachment.meta ?? {};
const size = (width && height) ? `${width}x${height}` : undefined;
@@ -379,28 +430,24 @@ export function convertAttachment(attachment: Entity.Attachment): MastodonEntity
} : null,
};
}
-export function convertFilter(filter: Entity.Filter) {
+export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter {
return simpleConvert(filter);
}
-export function convertList(list: Entity.List) {
- return simpleConvert(list);
+export function convertList(list: Entity.List): MastodonEntity.List {
+ return {
+ id: list.id,
+ title: list.title,
+ replies_policy: list.replies_policy ?? 'followed',
+ };
}
-export function convertFeaturedTag(tag: Entity.FeaturedTag) {
+export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag {
return simpleConvert(tag);
}
-export function convertPoll(poll: Entity.Poll) {
+export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll {
return simpleConvert(poll);
}
-// noinspection JSUnusedGlobalSymbols
-export function convertReaction(reaction: Entity.Reaction) {
- if (reaction.accounts) {
- reaction.accounts = reaction.accounts.map(convertAccount);
- }
- return reaction;
-}
-
// Megalodon sometimes returns broken / stubbed relationship data
export function convertRelationship(relationship: Partial<Entity.Relationship> & { id: string }): MastodonEntity.Relationship {
return {
@@ -421,8 +468,3 @@ export function convertRelationship(relationship: Partial<Entity.Relationship> &
note: relationship.note ?? '',
};
}
-
-// noinspection JSUnusedGlobalSymbols
-export function convertStatusSource(status: Entity.StatusSource) {
- return simpleConvert(status);
-}
diff --git a/packages/backend/src/server/api/mastodon/MastodonDataService.ts b/packages/backend/src/server/api/mastodon/MastodonDataService.ts
index 671ecdcbed..db257756de 100644
--- a/packages/backend/src/server/api/mastodon/MastodonDataService.ts
+++ b/packages/backend/src/server/api/mastodon/MastodonDataService.ts
@@ -56,7 +56,7 @@ export class MastodonDataService {
// Restrict visibility
this.queryService.generateVisibilityQuery(query, me);
if (me) {
- this.queryService.generateBlockedUserQuery(query, me);
+ this.queryService.generateBlockedUserQueryForNotes(query, me);
}
return await query.getOne();
diff --git a/packages/backend/src/server/api/mastodon/MastodonLogger.ts b/packages/backend/src/server/api/mastodon/MastodonLogger.ts
index bb844773c4..5ea69ed151 100644
--- a/packages/backend/src/server/api/mastodon/MastodonLogger.ts
+++ b/packages/backend/src/server/api/mastodon/MastodonLogger.ts
@@ -4,36 +4,212 @@
*/
import { Injectable } from '@nestjs/common';
-import Logger, { Data } from '@/logger.js';
+import { isAxiosError } from 'axios';
+import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
+import { ApiError } from '@/server/api/error.js';
+import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js';
+import { AuthenticationError } from '@/server/api/AuthenticateService.js';
+import type { FastifyRequest } from 'fastify';
@Injectable()
export class MastodonLogger {
public readonly logger: Logger;
- constructor(loggerService: LoggerService) {
+ constructor(
+ loggerService: LoggerService,
+ ) {
this.logger = loggerService.getLogger('masto-api');
}
- public error(endpoint: string, error: Data): void {
- this.logger.error(`Error in mastodon API endpoint ${endpoint}:`, error);
+ public error(request: FastifyRequest, error: MastodonError, status: number): void {
+ const path = getPath(request);
+
+ if (status >= 400 && status <= 499) { // Client errors
+ this.logger.debug(`Error in mastodon endpoint ${request.method} ${path}:`, error);
+ } else { // Server errors
+ this.logger.error(`Error in mastodon endpoint ${request.method} ${path}:`, error);
+ }
+ }
+
+ public exception(request: FastifyRequest, ex: Error): void {
+ const path = getPath(request);
+
+ // Exceptions are always server errors, and should therefore always be logged.
+ this.logger.error(`Exception in mastodon endpoint ${request.method} ${path}:`, ex);
+ }
+}
+
+function getPath(request: FastifyRequest): string {
+ try {
+ return new URL(request.url, getBaseUrl(request)).pathname;
+ } catch {
+ return request.url;
+ }
+}
+
+// TODO move elsewhere
+export interface MastodonError {
+ error: string;
+ error_description?: string;
+}
+
+export function getErrorException(error: unknown): Error | null {
+ if (!(error instanceof Error)) {
+ return null;
+ }
+
+ // AxiosErrors need special decoding
+ if (isAxiosError(error)) {
+ // Axios errors with a response are from the remote
+ if (error.response) {
+ return null;
+ }
+
+ // This is the inner exception, basically
+ if (error.cause && !isAxiosError(error.cause)) {
+ if (!error.cause.stack) {
+ error.cause.stack = error.stack;
+ }
+
+ return error.cause;
+ }
+
+ const ex = new Error();
+ ex.name = error.name;
+ ex.stack = error.stack;
+ ex.message = error.message;
+ ex.cause = error.cause;
+ return ex;
+ }
+
+ // AuthenticationError is a client error
+ if (error instanceof AuthenticationError) {
+ return null;
+ }
+
+ return error;
+}
+
+export function getErrorData(error: unknown): MastodonError {
+ // Axios wraps errors from the backend
+ error = unpackAxiosError(error);
+
+ if (!error || typeof(error) !== 'object') {
+ return {
+ error: 'UNKNOWN_ERROR',
+ error_description: String(error),
+ };
+ }
+
+ if (error instanceof ApiError) {
+ return convertApiError(error);
+ }
+
+ if ('code' in error && typeof (error.code) === 'string') {
+ if ('message' in error && typeof (error.message) === 'string') {
+ return convertApiError(error as ApiError);
+ }
+ }
+
+ if ('error' in error && typeof (error.error) === 'string') {
+ if ('message' in error && typeof (error.message) === 'string') {
+ return convertErrorMessageError(error as { error: string, message: string });
+ }
}
+
+ if (error instanceof Error) {
+ return convertGenericError(error);
+ }
+
+ if ('error' in error && typeof(error.error) === 'string') {
+ // "error_description" is string, undefined, or not present.
+ if (!('error_description' in error) || typeof(error.error_description) === 'string' || typeof(error.error_description) === 'undefined') {
+ return convertMastodonError(error as MastodonError);
+ }
+ }
+
+ return {
+ error: 'INTERNAL_ERROR',
+ error_description: 'Internal error occurred. Please contact us if the error persists.',
+ };
}
-export function getErrorData(error: unknown): Data {
- if (error == null) return {};
- if (typeof(error) === 'string') return error;
- if (typeof(error) === 'object') {
- if ('response' in error) {
- if (typeof(error.response) === 'object' && error.response) {
- if ('data' in error.response) {
- if (typeof(error.response.data) === 'object' && error.response.data) {
- return error.response.data as Record<string, unknown>;
- }
+function unpackAxiosError(error: unknown): unknown {
+ if (isAxiosError(error)) {
+ if (error.response) {
+ if (error.response.data && typeof(error.response.data) === 'object') {
+ if ('error' in error.response.data && error.response.data.error && typeof(error.response.data.error) === 'object') {
+ return error.response.data.error;
}
+
+ return error.response.data;
+ }
+
+ // No data - this is a fallback to avoid leaking request/response details in the error
+ return undefined;
+ }
+
+ if (error.cause && !isAxiosError(error.cause)) {
+ if (!error.cause.stack) {
+ error.cause.stack = error.stack;
+ }
+
+ return error.cause;
+ }
+
+ // No data - this is a fallback to avoid leaking request/response details in the error
+ return String(error);
+ }
+
+ return error;
+}
+
+function convertApiError(apiError: ApiError): MastodonError {
+ return {
+ error: apiError.code,
+ error_description: apiError.message,
+ };
+}
+
+function convertErrorMessageError(error: { error: string, message: string }): MastodonError {
+ return {
+ error: error.error,
+ error_description: error.message,
+ };
+}
+
+function convertGenericError(error: Error): MastodonError {
+ return {
+ error: 'INTERNAL_ERROR',
+ error_description: String(error),
+ };
+}
+
+function convertMastodonError(error: MastodonError): MastodonError {
+ return {
+ error: error.error,
+ error_description: error.error_description,
+ };
+}
+
+export function getErrorStatus(error: unknown): number {
+ if (error && typeof(error) === 'object') {
+ // Axios wraps errors from the backend
+ if ('response' in error && typeof (error.response) === 'object' && error.response) {
+ if ('status' in error.response && typeof(error.response.status) === 'number') {
+ return error.response.status;
}
}
- return error as Record<string, unknown>;
+
+ if ('httpStatusCode' in error && typeof(error.httpStatusCode) === 'number') {
+ return error.httpStatusCode;
+ }
+
+ if ('statusCode' in error && typeof(error.statusCode) === 'number') {
+ return error.statusCode;
+ }
}
- return { error };
+
+ return 500;
}
diff --git a/packages/backend/src/server/api/mastodon/timelineArgs.ts b/packages/backend/src/server/api/mastodon/argsUtils.ts
index 3fba8ec57a..167d493ab6 100644
--- a/packages/backend/src/server/api/mastodon/timelineArgs.ts
+++ b/packages/backend/src/server/api/mastodon/argsUtils.ts
@@ -22,12 +22,12 @@ export interface TimelineArgs {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
export function toBoolean(value: string | undefined): boolean | undefined {
- if (value === undefined) return undefined;
+ if (!value) return undefined;
return !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
}
export function toInt(value: string | undefined): number | undefined {
- if (value === undefined) return undefined;
+ if (!value) return undefined;
return parseInt(value);
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts
deleted file mode 100644
index 085314059b..0000000000
--- a/packages/backend/src/server/api/mastodon/endpoints.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * SPDX-FileCopyrightText: marie and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ApiAuthMastodon } from './endpoints/auth.js';
-import { ApiAccountMastodon } from './endpoints/account.js';
-import { ApiSearchMastodon } from './endpoints/search.js';
-import { ApiNotifyMastodon } from './endpoints/notifications.js';
-import { ApiFilterMastodon } from './endpoints/filter.js';
-import { ApiTimelineMastodon } from './endpoints/timeline.js';
-import { ApiStatusMastodon } from './endpoints/status.js';
-
-export {
- ApiAccountMastodon,
- ApiAuthMastodon,
- ApiSearchMastodon,
- ApiNotifyMastodon,
- ApiFilterMastodon,
- ApiTimelineMastodon,
- ApiStatusMastodon,
-};
diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts
index 79cdddcb9e..6a1af62be7 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/account.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts
@@ -3,14 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Injectable } from '@nestjs/common';
-import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js';
-import { MiLocalUser } from '@/models/User.js';
-import { MastoConverters, convertRelationship } from '../converters.js';
-import type { MegalodonInterface } from 'megalodon';
-import type { FastifyRequest } from 'fastify';
+import { Inject, Injectable } from '@nestjs/common';
+import { parseTimelineArgs, TimelineArgs, toBoolean } from '@/server/api/mastodon/argsUtils.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { DriveService } from '@/core/DriveService.js';
+import { DI } from '@/di-symbols.js';
+import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
+import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
+import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js';
+import type { FastifyInstance } from 'fastify';
-export interface ApiAccountMastodonRoute {
+interface ApiAccountMastodonRoute {
Params: { id?: string },
Querystring: TimelineArgs & { acct?: string },
Body: { notifications?: boolean }
@@ -19,133 +22,270 @@ export interface ApiAccountMastodonRoute {
@Injectable()
export class ApiAccountMastodon {
constructor(
- private readonly request: FastifyRequest<ApiAccountMastodonRoute>,
- private readonly client: MegalodonInterface,
- private readonly me: MiLocalUser | null,
- private readonly mastoConverters: MastoConverters,
+ @Inject(DI.userProfilesRepository)
+ private readonly userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.accessTokensRepository)
+ private readonly accessTokensRepository: AccessTokensRepository,
+
+ private readonly clientService: MastodonClientService,
+ private readonly mastoConverters: MastodonConverters,
+ private readonly driveService: DriveService,
) {}
- public async verifyCredentials() {
- const data = await this.client.verifyAccountCredentials();
- const acct = await this.mastoConverters.convertAccount(data.data);
- return Object.assign({}, acct, {
- source: {
- note: acct.note,
- fields: acct.fields,
- privacy: '',
- sensitive: false,
- language: '',
+ public register(fastify: FastifyInstance): void {
+ fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.verifyAccountCredentials();
+ const acct = await this.mastoConverters.convertAccount(data.data);
+ const response = Object.assign({}, acct, {
+ source: {
+ note: acct.note,
+ fields: acct.fields,
+ privacy: 'public',
+ sensitive: false,
+ language: '',
+ },
+ });
+ return reply.send(response);
+ });
+
+ fastify.patch<{
+ Body: {
+ discoverable?: string,
+ bot?: string,
+ display_name?: string,
+ note?: string,
+ avatar?: string,
+ header?: string,
+ locked?: string,
+ source?: {
+ privacy?: string,
+ sensitive?: string,
+ language?: string,
+ },
+ fields_attributes?: {
+ name: string,
+ value: string,
+ }[],
},
+ }>('/v1/accounts/update_credentials', async (_request, reply) => {
+ const accessTokens = _request.headers.authorization;
+ const client = this.clientService.getClient(_request);
+ // Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it.
+ if (_request.savedRequestFiles?.length && accessTokens) {
+ const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') });
+ const avatar = _request.savedRequestFiles.find(obj => {
+ return obj.fieldname === 'avatar';
+ });
+ const header = _request.savedRequestFiles.find(obj => {
+ return obj.fieldname === 'header';
+ });
+
+ if (tokeninfo && avatar) {
+ const upload = await this.driveService.addFile({
+ user: { id: tokeninfo.userId, host: null },
+ path: avatar.filepath,
+ name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined,
+ sensitive: false,
+ });
+ if (upload.type.startsWith('image/')) {
+ _request.body.avatar = upload.id;
+ }
+ } else if (tokeninfo && header) {
+ const upload = await this.driveService.addFile({
+ user: { id: tokeninfo.userId, host: null },
+ path: header.filepath,
+ name: header.filename && header.filename !== 'file' ? header.filename : undefined,
+ sensitive: false,
+ });
+ if (upload.type.startsWith('image/')) {
+ _request.body.header = upload.id;
+ }
+ }
+ }
+
+ if (_request.body.fields_attributes) {
+ for (const field of _request.body.fields_attributes) {
+ if (!(field.name.trim() === '' && field.value.trim() === '')) {
+ if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty');
+ if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty');
+ }
+ }
+ _request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0);
+ }
+
+ const options = {
+ ..._request.body,
+ discoverable: toBoolean(_request.body.discoverable),
+ bot: toBoolean(_request.body.bot),
+ locked: toBoolean(_request.body.locked),
+ source: _request.body.source ? {
+ ..._request.body.source,
+ sensitive: toBoolean(_request.body.source.sensitive),
+ } : undefined,
+ };
+ const data = await client.updateCredentials(options);
+ const response = await this.mastoConverters.convertAccount(data.data);
+
+ return reply.send(response);
});
- }
- public async lookup() {
- if (!this.request.query.acct) throw new Error('Missing required property "acct"');
- const data = await this.client.search(this.request.query.acct, { type: 'accounts' });
- return this.mastoConverters.convertAccount(data.data.accounts[0]);
- }
+ fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => {
+ if (!_request.query.acct) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "acct"' });
- public async getRelationships(reqIds: string[]) {
- const data = await this.client.getRelationships(reqIds);
- return data.data.map(relationship => convertRelationship(relationship));
- }
+ const client = this.clientService.getClient(_request);
+ const data = await client.search(_request.query.acct, { type: 'accounts' });
+ const profile = await this.userProfilesRepository.findOneBy({ userId: data.data.accounts[0].id });
+ data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? [];
+ const response = await this.mastoConverters.convertAccount(data.data.accounts[0]);
- public async getStatuses() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.getAccountStatuses(this.request.params.id, parseTimelineArgs(this.request.query));
- return await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, this.me)));
- }
+ return reply.send(response);
+ });
- public async getFollowers() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.getAccountFollowers(
- this.request.params.id,
- parseTimelineArgs(this.request.query),
- );
- return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
- }
+ fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[] } }>('/v1/accounts/relationships', async (_request, reply) => {
+ if (!_request.query.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "id"' });
- public async getFollowing() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.getAccountFollowing(
- this.request.params.id,
- parseTimelineArgs(this.request.query),
- );
- return await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
- }
+ const client = this.clientService.getClient(_request);
+ const data = await client.getRelationships(_request.query.id);
+ const response = data.data.map(relationship => convertRelationship(relationship));
- public async addFollow() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.followAccount(this.request.params.id);
- const acct = convertRelationship(data.data);
- acct.following = true;
- return acct;
- }
+ return reply.send(response);
+ });
- public async rmFollow() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.unfollowAccount(this.request.params.id);
- const acct = convertRelationship(data.data);
- acct.following = false;
- return acct;
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- public async addBlock() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.blockAccount(this.request.params.id);
- return convertRelationship(data.data);
- }
+ const client = this.clientService.getClient(_request);
+ const data = await client.getAccount(_request.params.id);
+ const account = await this.mastoConverters.convertAccount(data.data);
- public async rmBlock() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.unblockAccount(this.request.params.id);
- return convertRelationship(data.data);
- }
+ return reply.send(account);
+ });
- public async addMute() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.muteAccount(
- this.request.params.id,
- this.request.body.notifications ?? true,
- );
- return convertRelationship(data.data);
- }
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (request, reply) => {
+ if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- public async rmMute() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.unmuteAccount(this.request.params.id);
- return convertRelationship(data.data);
- }
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const args = parseTimelineArgs(request.query);
+ const data = await client.getAccountStatuses(request.params.id, args);
+ const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me)));
- public async getBookmarks() {
- const data = await this.client.getBookmarks(parseTimelineArgs(this.request.query));
- return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me)));
- }
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
+ });
- public async getFavourites() {
- const data = await this.client.getFavourites(parseTimelineArgs(this.request.query));
- return Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, this.me)));
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- public async getMutes() {
- const data = await this.client.getMutes(parseTimelineArgs(this.request.query));
- return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
- }
+ const client = this.clientService.getClient(_request);
+ const data = await client.getFeaturedTags();
+ const response = data.data.map((tag) => convertFeaturedTag(tag));
- public async getBlocks() {
- const data = await this.client.getBlocks(parseTimelineArgs(this.request.query));
- return Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
- }
+ return reply.send(response);
+ });
- public async acceptFollow() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.acceptFollowRequest(this.request.params.id);
- return convertRelationship(data.data);
- }
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (request, reply) => {
+ if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(request);
+ const data = await client.getAccountFollowers(
+ request.params.id,
+ parseTimelineArgs(request.query),
+ );
+ const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
+ });
+
+ fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (request, reply) => {
+ if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(request);
+ const data = await client.getAccountFollowing(
+ request.params.id,
+ parseTimelineArgs(request.query),
+ );
+ const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
+ });
+
+ fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getAccountLists(_request.params.id);
+ const response = data.data.map((list) => convertList(list));
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.followAccount(_request.params.id);
+ const acct = convertRelationship(data.data);
+ acct.following = true; // TODO this is wrong, follow may not have processed immediately
+
+ return reply.send(acct);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.unfollowAccount(_request.params.id);
+ const acct = convertRelationship(data.data);
+ acct.following = false;
+
+ return reply.send(acct);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.blockAccount(_request.params.id);
+ const response = convertRelationship(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.unblockAccount(_request.params.id);
+ const response = convertRelationship(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.muteAccount(
+ _request.params.id,
+ _request.body.notifications ?? true,
+ );
+ const response = convertRelationship(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- public async rejectFollow() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.rejectFollowRequest(this.request.params.id);
- return convertRelationship(data.data);
+ const client = this.clientService.getClient(_request);
+ const data = await client.unmuteAccount(_request.params.id);
+ const response = convertRelationship(data.data);
+
+ return reply.send(response);
+ });
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/apps.ts b/packages/backend/src/server/api/mastodon/endpoints/apps.ts
new file mode 100644
index 0000000000..72b520c74a
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/apps.ts
@@ -0,0 +1,122 @@
+/*
+ * SPDX-FileCopyrightText: marie and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js';
+import type { FastifyInstance } from 'fastify';
+
+const readScope = [
+ 'read:account',
+ 'read:drive',
+ 'read:blocks',
+ 'read:favorites',
+ 'read:following',
+ 'read:messaging',
+ 'read:mutes',
+ 'read:notifications',
+ 'read:reactions',
+ 'read:pages',
+ 'read:page-likes',
+ 'read:user-groups',
+ 'read:channels',
+ 'read:gallery',
+ 'read:gallery-likes',
+];
+
+const writeScope = [
+ 'write:account',
+ 'write:drive',
+ 'write:blocks',
+ 'write:favorites',
+ 'write:following',
+ 'write:messaging',
+ 'write:mutes',
+ 'write:notes',
+ 'write:notifications',
+ 'write:reactions',
+ 'write:votes',
+ 'write:pages',
+ 'write:page-likes',
+ 'write:user-groups',
+ 'write:channels',
+ 'write:gallery',
+ 'write:gallery-likes',
+];
+
+export interface AuthPayload {
+ scopes?: string | string[],
+ redirect_uris?: string | string[],
+ client_name?: string | string[],
+ website?: string | string[],
+}
+
+// Not entirely right, but it gets TypeScript to work so *shrug*
+type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload };
+
+@Injectable()
+export class ApiAppsMastodon {
+ constructor(
+ private readonly clientService: MastodonClientService,
+ private readonly mastoConverters: MastodonConverters,
+ ) {}
+
+ public register(fastify: FastifyInstance): void {
+ fastify.post<AuthMastodonRoute>('/v1/apps', async (_request, reply) => {
+ const body = _request.body ?? _request.query;
+ if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' });
+ if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' });
+ if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' });
+ if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' });
+ if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' });
+ if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' });
+
+ let scope = body.scopes;
+ if (typeof scope === 'string') {
+ scope = scope.split(/[ +]/g);
+ }
+
+ const pushScope = new Set<string>();
+ for (const s of scope) {
+ if (s.match(/^read/)) {
+ for (const r of readScope) {
+ pushScope.add(r);
+ }
+ }
+ if (s.match(/^write/)) {
+ for (const r of writeScope) {
+ pushScope.add(r);
+ }
+ }
+ }
+
+ const client = this.clientService.getClient(_request);
+ const appData = await client.registerApp(body.client_name, {
+ scopes: Array.from(pushScope),
+ redirect_uri: body.redirect_uris,
+ website: body.website,
+ });
+
+ const response = {
+ id: Math.floor(Math.random() * 100).toString(),
+ name: appData.name,
+ website: body.website,
+ redirect_uri: body.redirect_uris,
+ client_id: Buffer.from(appData.url || '').toString('base64'),
+ client_secret: appData.clientSecret,
+ };
+
+ return reply.send(response);
+ });
+
+ fastify.get('/v1/apps/verify_credentials', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.verifyAppCredentials();
+ const response = this.mastoConverters.convertApplication(data.data);
+ return reply.send(response);
+ });
+ }
+}
+
diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts
deleted file mode 100644
index b58cc902da..0000000000
--- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * SPDX-FileCopyrightText: marie and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { MegalodonInterface } from 'megalodon';
-import type { FastifyRequest } from 'fastify';
-
-const readScope = [
- 'read:account',
- 'read:drive',
- 'read:blocks',
- 'read:favorites',
- 'read:following',
- 'read:messaging',
- 'read:mutes',
- 'read:notifications',
- 'read:reactions',
- 'read:pages',
- 'read:page-likes',
- 'read:user-groups',
- 'read:channels',
- 'read:gallery',
- 'read:gallery-likes',
-];
-
-const writeScope = [
- 'write:account',
- 'write:drive',
- 'write:blocks',
- 'write:favorites',
- 'write:following',
- 'write:messaging',
- 'write:mutes',
- 'write:notes',
- 'write:notifications',
- 'write:reactions',
- 'write:votes',
- 'write:pages',
- 'write:page-likes',
- 'write:user-groups',
- 'write:channels',
- 'write:gallery',
- 'write:gallery-likes',
-];
-
-export interface AuthPayload {
- scopes?: string | string[],
- redirect_uris?: string,
- client_name?: string,
- website?: string,
-}
-
-// Not entirely right, but it gets TypeScript to work so *shrug*
-export type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload };
-
-export async function ApiAuthMastodon(request: FastifyRequest<AuthMastodonRoute>, client: MegalodonInterface) {
- const body = request.body ?? request.query;
- if (!body.scopes) throw new Error('Missing required payload "scopes"');
- if (!body.redirect_uris) throw new Error('Missing required payload "redirect_uris"');
- if (!body.client_name) throw new Error('Missing required payload "client_name"');
-
- let scope = body.scopes;
- if (typeof scope === 'string') {
- scope = scope.split(/[ +]/g);
- }
-
- const pushScope = new Set<string>();
- for (const s of scope) {
- if (s.match(/^read/)) {
- for (const r of readScope) {
- pushScope.add(r);
- }
- }
- if (s.match(/^write/)) {
- for (const r of writeScope) {
- pushScope.add(r);
- }
- }
- }
-
- const red = body.redirect_uris;
- const appData = await client.registerApp(body.client_name, {
- scopes: Array.from(pushScope),
- redirect_uris: red,
- website: body.website,
- });
-
- return {
- id: Math.floor(Math.random() * 100).toString(),
- name: appData.name,
- website: body.website,
- redirect_uri: red,
- client_id: Buffer.from(appData.url || '').toString('base64'), // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
- client_secret: appData.clientSecret,
- };
-}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts
index 382f0a8f1f..f2bd0052d5 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts
@@ -3,12 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { toBoolean } from '@/server/api/mastodon/timelineArgs.js';
-import { convertFilter } from '../converters.js';
-import type { MegalodonInterface } from 'megalodon';
-import type { FastifyRequest } from 'fastify';
+import { Injectable } from '@nestjs/common';
+import { toBoolean } from '@/server/api/mastodon/argsUtils.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { convertFilter } from '../MastodonConverters.js';
+import type { FastifyInstance } from 'fastify';
-export interface ApiFilterMastodonRoute {
+interface ApiFilterMastodonRoute {
Params: {
id?: string,
},
@@ -21,55 +22,78 @@ export interface ApiFilterMastodonRoute {
}
}
+@Injectable()
export class ApiFilterMastodon {
constructor(
- private readonly request: FastifyRequest<ApiFilterMastodonRoute>,
- private readonly client: MegalodonInterface,
+ private readonly clientService: MastodonClientService,
) {}
- public async getFilters() {
- const data = await this.client.getFilters();
- return data.data.map((filter) => convertFilter(filter));
- }
+ public register(fastify: FastifyInstance): void {
+ fastify.get('/v1/filters', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
- public async getFilter() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.getFilter(this.request.params.id);
- return convertFilter(data.data);
- }
+ const data = await client.getFilters();
+ const response = data.data.map((filter) => convertFilter(filter));
- public async createFilter() {
- if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"');
- if (!this.request.body.context) throw new Error('Missing required payload "context"');
- const options = {
- phrase: this.request.body.phrase,
- context: this.request.body.context,
- irreversible: toBoolean(this.request.body.irreversible),
- whole_word: toBoolean(this.request.body.whole_word),
- expires_in: this.request.body.expires_in,
- };
- const data = await this.client.createFilter(this.request.body.phrase, this.request.body.context, options);
- return convertFilter(data.data);
- }
+ return reply.send(response);
+ });
- public async updateFilter() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- if (!this.request.body.phrase) throw new Error('Missing required payload "phrase"');
- if (!this.request.body.context) throw new Error('Missing required payload "context"');
- const options = {
- phrase: this.request.body.phrase,
- context: this.request.body.context,
- irreversible: toBoolean(this.request.body.irreversible),
- whole_word: toBoolean(this.request.body.whole_word),
- expires_in: this.request.body.expires_in,
- };
- const data = await this.client.updateFilter(this.request.params.id, this.request.body.phrase, this.request.body.context, options);
- return convertFilter(data.data);
- }
+ fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getFilter(_request.params.id);
+ const response = convertFilter(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiFilterMastodonRoute>('/v1/filters', async (_request, reply) => {
+ if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
+ if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });
+
+ const options = {
+ phrase: _request.body.phrase,
+ context: _request.body.context,
+ irreversible: toBoolean(_request.body.irreversible),
+ whole_word: toBoolean(_request.body.whole_word),
+ expires_in: _request.body.expires_in,
+ };
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.createFilter(_request.body.phrase, _request.body.context, options);
+ const response = convertFilter(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
+ if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });
+
+ const options = {
+ phrase: _request.body.phrase,
+ context: _request.body.context,
+ irreversible: toBoolean(_request.body.irreversible),
+ whole_word: toBoolean(_request.body.whole_word),
+ expires_in: _request.body.expires_in,
+ };
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options);
+ const response = convertFilter(data.data);
+
+ return reply.send(response);
+ });
+
+ fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.deleteFilter(_request.params.id);
- public async rmFilter() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.deleteFilter(this.request.params.id);
- return data.data;
+ return reply.send(data.data);
+ });
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/instance.ts b/packages/backend/src/server/api/mastodon/endpoints/instance.ts
new file mode 100644
index 0000000000..cfca5b1350
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/instance.ts
@@ -0,0 +1,93 @@
+/*
+ * SPDX-FileCopyrightText: marie and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
+import type { Config } from '@/config.js';
+import { DI } from '@/di-symbols.js';
+import type { MiMeta } from '@/models/_.js';
+import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { RoleService } from '@/core/RoleService.js';
+import type { FastifyInstance } from 'fastify';
+import type { MastodonEntity } from 'megalodon';
+
+@Injectable()
+export class ApiInstanceMastodon {
+ constructor(
+ @Inject(DI.meta)
+ private readonly meta: MiMeta,
+
+ @Inject(DI.config)
+ private readonly config: Config,
+
+ private readonly mastoConverters: MastodonConverters,
+ private readonly clientService: MastodonClientService,
+ private readonly roleService: RoleService,
+ ) {}
+
+ public register(fastify: FastifyInstance): void {
+ fastify.get('/v1/instance', async (_request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.getInstance();
+ const contact = this.meta.rootUser != null
+ ? await this.mastoConverters.convertAccount(this.meta.rootUser)
+ : null;
+ const roles = await this.roleService.getUserPolicies(me?.id ?? null);
+
+ const instance = data.data;
+ const response: MastodonEntity.Instance = {
+ uri: this.config.host,
+ title: this.meta.name || 'Sharkey',
+ description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
+ email: instance.email || '',
+ version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`,
+ urls: instance.urls,
+ stats: {
+ user_count: instance.stats.user_count,
+ status_count: instance.stats.status_count,
+ domain_count: instance.stats.domain_count,
+ },
+ thumbnail: this.meta.backgroundImageUrl || '/static-assets/transparent.png',
+ languages: this.meta.langs,
+ registrations: !this.meta.disableRegistration || instance.registrations,
+ approval_required: this.meta.approvalRequiredForSignup,
+ invites_enabled: instance.registrations,
+ configuration: {
+ accounts: {
+ max_featured_tags: 20,
+ max_pinned_statuses: roles.pinLimit,
+ },
+ statuses: {
+ max_characters: this.config.maxNoteLength,
+ max_media_attachments: 16,
+ characters_reserved_per_url: instance.uri.length,
+ },
+ media_attachments: {
+ supported_mime_types: FILE_TYPE_BROWSERSAFE,
+ image_size_limit: 10485760,
+ image_matrix_limit: 16777216,
+ video_size_limit: 41943040,
+ video_frame_limit: 60,
+ video_matrix_limit: 2304000,
+ },
+ polls: {
+ max_options: 10,
+ max_characters_per_option: 150,
+ min_expiration: 50,
+ max_expiration: 2629746,
+ },
+ reactions: {
+ max_reactions: 1,
+ },
+ },
+ contact_account: contact,
+ rules: instance.rules ?? [],
+ };
+
+ return reply.send(response);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
deleted file mode 100644
index 48a56138cf..0000000000
--- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * SPDX-FileCopyrightText: marie and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Entity } from 'megalodon';
-import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
-import type { Config } from '@/config.js';
-import type { MiMeta } from '@/models/Meta.js';
-
-/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
-export async function getInstance(
- response: Entity.Instance,
- contact: Entity.Account,
- config: Config,
- meta: MiMeta,
-) {
- return {
- uri: config.url,
- title: meta.name || 'Sharkey',
- short_description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
- description: meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
- email: response.email || '',
- version: `3.0.0 (compatible; Sharkey ${config.version})`,
- urls: response.urls,
- stats: {
- user_count: response.stats.user_count,
- status_count: response.stats.status_count,
- domain_count: response.stats.domain_count,
- },
- thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png',
- languages: meta.langs,
- registrations: !meta.disableRegistration || response.registrations,
- approval_required: meta.approvalRequiredForSignup,
- invites_enabled: response.registrations,
- configuration: {
- accounts: {
- max_featured_tags: 20,
- },
- statuses: {
- max_characters: config.maxNoteLength,
- max_media_attachments: 16,
- characters_reserved_per_url: response.uri.length,
- },
- media_attachments: {
- supported_mime_types: FILE_TYPE_BROWSERSAFE,
- image_size_limit: 10485760,
- image_matrix_limit: 16777216,
- video_size_limit: 41943040,
- video_frame_rate_limit: 60,
- video_matrix_limit: 2304000,
- },
- polls: {
- max_options: 10,
- max_characters_per_option: 150,
- min_expiration: 50,
- max_expiration: 2629746,
- },
- reactions: {
- max_reactions: 1,
- },
- },
- contact_account: contact,
- rules: [],
- };
-}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
index 14eee8565a..f6cc59e782 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
@@ -3,56 +3,82 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/timelineArgs.js';
-import { MiLocalUser } from '@/models/User.js';
-import { MastoConverters } from '@/server/api/mastodon/converters.js';
-import type { MegalodonInterface } from 'megalodon';
-import type { FastifyRequest } from 'fastify';
+import { Injectable } from '@nestjs/common';
+import { MastodonEntity } from 'megalodon';
+import { parseTimelineArgs, TimelineArgs } from '@/server/api/mastodon/argsUtils.js';
+import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js';
+import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
+import { MastodonClientService } from '../MastodonClientService.js';
+import type { FastifyInstance } from 'fastify';
-export interface ApiNotifyMastodonRoute {
+interface ApiNotifyMastodonRoute {
Params: {
id?: string,
},
Querystring: TimelineArgs,
}
-export class ApiNotifyMastodon {
+@Injectable()
+export class ApiNotificationsMastodon {
constructor(
- private readonly request: FastifyRequest<ApiNotifyMastodonRoute>,
- private readonly client: MegalodonInterface,
- private readonly me: MiLocalUser | null,
- private readonly mastoConverters: MastoConverters,
+ private readonly mastoConverters: MastodonConverters,
+ private readonly clientService: MastodonClientService,
) {}
- public async getNotifications() {
- const data = await this.client.getNotifications(parseTimelineArgs(this.request.query));
- return Promise.all(data.data.map(async n => {
- const converted = await this.mastoConverters.convertNotification(n, this.me);
- if (converted.type === 'reaction') {
- converted.type = 'favourite';
+ public register(fastify: FastifyInstance): void {
+ fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const data = await client.getNotifications(parseTimelineArgs(request.query));
+ const notifications = await Promise.all(data.data.map(n => this.mastoConverters.convertNotification(n, me)));
+ const response: MastodonEntity.Notification[] = [];
+ for (const notification of notifications) {
+ // Notifications for inaccessible notes will be null and should be ignored
+ if (!notification) continue;
+
+ response.push(notification);
+ if (notification.type === 'reaction') {
+ response.push({
+ ...notification,
+ type: 'favourite',
+ });
+ }
}
- return converted;
- }));
- }
- public async getNotification() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.getNotification(this.request.params.id);
- const converted = await this.mastoConverters.convertNotification(data.data, this.me);
- if (converted.type === 'reaction') {
- converted.type = 'favourite';
- }
- return converted;
- }
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
+ });
- public async rmNotification() {
- if (!this.request.params.id) throw new Error('Missing required parameter "id"');
- const data = await this.client.dismissNotification(this.request.params.id);
- return data.data;
- }
+ fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.getNotification(_request.params.id);
+ const response = await this.mastoConverters.convertNotification(data.data, me);
+
+ // Notifications for inaccessible notes will be null and should be ignored
+ if (!response) {
+ return reply.code(404).send({
+ error: 'NOT_FOUND',
+ });
+ }
+
+ return reply.send(response);
+ });
+
+ fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.dismissNotification(_request.params.id);
+
+ return reply.send(data.data);
+ });
+
+ fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', async (_request, reply) => {
+ const client = this.clientService.getClient(_request);
+ const data = await client.dismissNotifications();
- public async rmNotifications() {
- const data = await this.client.dismissNotifications();
- return data.data;
+ return reply.send(data.data);
+ });
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts
index 4850b4652f..c43d6cfc9a 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/search.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts
@@ -3,92 +3,188 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { MiLocalUser } from '@/models/User.js';
-import { MastoConverters } from '../converters.js';
-import { parseTimelineArgs, TimelineArgs } from '../timelineArgs.js';
-import Account = Entity.Account;
-import Status = Entity.Status;
-import type { MegalodonInterface } from 'megalodon';
-import type { FastifyRequest } from 'fastify';
+import { Injectable } from '@nestjs/common';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { attachMinMaxPagination, attachOffsetPagination } from '@/server/api/mastodon/pagination.js';
+import { MastodonConverters } from '../MastodonConverters.js';
+import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '../argsUtils.js';
+import { ApiError } from '../../error.js';
+import type { FastifyInstance } from 'fastify';
+import type { Entity } from 'megalodon';
-export interface ApiSearchMastodonRoute {
+interface ApiSearchMastodonRoute {
Querystring: TimelineArgs & {
- type?: 'accounts' | 'hashtags' | 'statuses';
+ type?: string;
q?: string;
+ resolve?: string;
}
}
+@Injectable()
export class ApiSearchMastodon {
constructor(
- private readonly request: FastifyRequest<ApiSearchMastodonRoute>,
- private readonly client: MegalodonInterface,
- private readonly me: MiLocalUser | null,
- private readonly BASE_URL: string,
- private readonly mastoConverters: MastoConverters,
+ private readonly mastoConverters: MastodonConverters,
+ private readonly clientService: MastodonClientService,
) {}
- public async SearchV1() {
- if (!this.request.query.q) throw new Error('Missing required property "q"');
- const query = parseTimelineArgs(this.request.query);
- const data = await this.client.search(this.request.query.q, { type: this.request.query.type, ...query });
- return data.data;
- }
+ public register(fastify: FastifyInstance): void {
+ fastify.get<ApiSearchMastodonRoute>('/v1/search', async (request, reply) => {
+ if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
+ if (!request.query.type) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "type"' });
- public async SearchV2() {
- if (!this.request.query.q) throw new Error('Missing required property "q"');
- const query = parseTimelineArgs(this.request.query);
- const type = this.request.query.type;
- const acct = !type || type === 'accounts' ? await this.client.search(this.request.query.q, { type: 'accounts', ...query }) : null;
- const stat = !type || type === 'statuses' ? await this.client.search(this.request.query.q, { type: 'statuses', ...query }) : null;
- const tags = !type || type === 'hashtags' ? await this.client.search(this.request.query.q, { type: 'hashtags', ...query }) : null;
- return {
- accounts: await Promise.all(acct?.data.accounts.map(async (account: Account) => await this.mastoConverters.convertAccount(account)) ?? []),
- statuses: await Promise.all(stat?.data.statuses.map(async (status: Status) => await this.mastoConverters.convertStatus(status, this.me)) ?? []),
- hashtags: tags?.data.hashtags ?? [],
- };
- }
+ const type = request.query.type;
+ if (type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' });
+ }
+
+ const { client, me } = await this.clientService.getAuthClient(request);
+
+ if (toBoolean(request.query.resolve) && !me) {
+ return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' });
+ }
+ if (toInt(request.query.offset) && !me) {
+ return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' });
+ }
+
+ // TODO implement resolve
+
+ const query = parseTimelineArgs(request.query);
+ const { data } = await client.search(request.query.q, { type, ...query });
+ const response = {
+ ...data,
+ accounts: await Promise.all(data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account))),
+ statuses: await Promise.all(data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me))),
+ };
+
+ if (type === 'hashtags') {
+ attachOffsetPagination(request, reply, response.hashtags);
+ } else {
+ attachMinMaxPagination(request, reply, response[type]);
+ }
+
+ return reply.send(response);
+ });
+
+ fastify.get<ApiSearchMastodonRoute>('/v2/search', async (request, reply) => {
+ if (!request.query.q) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "q"' });
+
+ const type = request.query.type;
+ if (type !== undefined && type !== 'hashtags' && type !== 'statuses' && type !== 'accounts') {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid type' });
+ }
+
+ const { client, me } = await this.clientService.getAuthClient(request);
+
+ if (toBoolean(request.query.resolve) && !me) {
+ return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "resolve" property' });
+ }
+ if (toInt(request.query.offset) && !me) {
+ return reply.code(401).send({ error: 'The access token is invalid', error_description: 'Authentication is required to use the "offset" property' });
+ }
+
+ // TODO implement resolve
+
+ const query = parseTimelineArgs(request.query);
+ const acct = !type || type === 'accounts' ? await client.search(request.query.q, { type: 'accounts', ...query }) : null;
+ const stat = !type || type === 'statuses' ? await client.search(request.query.q, { type: 'statuses', ...query }) : null;
+ const tags = !type || type === 'hashtags' ? await client.search(request.query.q, { type: 'hashtags', ...query }) : null;
+ const response = {
+ accounts: await Promise.all(acct?.data.accounts.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)) ?? []),
+ statuses: await Promise.all(stat?.data.statuses.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)) ?? []),
+ hashtags: tags?.data.hashtags ?? [],
+ };
+
+ // Pagination hack, based on "best guess" expected behavior.
+ // Mastodon doesn't document this part at all!
+ const longestResult = [response.statuses, response.hashtags]
+ .reduce((longest: unknown[], current: unknown[]) => current.length > longest.length ? current : longest, response.accounts);
- public async getStatusTrends() {
- const data = await fetch(`${this.BASE_URL}/api/notes/featured`,
- {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- i: this.request.headers.authorization?.replace('Bearer ', ''),
- }),
- })
- .then(res => res.json() as Promise<Status[]>)
- .then(data => data.map(status => this.mastoConverters.convertStatus(status, this.me)));
- return Promise.all(data);
+ // Ignore min/max pagination because how TF would that work with multiple result sets??
+ // Offset pagination is the only possible option
+ attachOffsetPagination(request, reply, longestResult);
+
+ return reply.send(response);
+ });
+
+ fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (request, reply) => {
+ const baseUrl = this.clientService.getBaseUrl(request);
+ const res = await fetch(`${baseUrl}/api/notes/featured`,
+ {
+ method: 'POST',
+ headers: {
+ ...request.headers,
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: '{}',
+ });
+
+ await verifyResponse(res);
+
+ const data = await res.json() as Entity.Status[];
+ const me = await this.clientService.getAuth(request);
+ const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
+ });
+
+ fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (request, reply) => {
+ const baseUrl = this.clientService.getBaseUrl(request);
+ const res = await fetch(`${baseUrl}/api/users`,
+ {
+ method: 'POST',
+ headers: {
+ ...request.headers,
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ limit: parseTimelineArgs(request.query).limit ?? 20,
+ origin: 'local',
+ sort: '+follower',
+ state: 'alive',
+ }),
+ });
+
+ await verifyResponse(res);
+
+ const data = await res.json() as Entity.Account[];
+ const response = await Promise.all(data.map(async entry => {
+ return {
+ source: 'global',
+ account: await this.mastoConverters.convertAccount(entry),
+ };
+ }));
+
+ attachOffsetPagination(request, reply, response);
+ return reply.send(response);
+ });
}
+}
+
+async function verifyResponse(res: Response): Promise<void> {
+ if (res.ok) return;
- public async getSuggestions() {
- const data = await fetch(`${this.BASE_URL}/api/users`,
- {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- i: this.request.headers.authorization?.replace('Bearer ', ''),
- limit: parseTimelineArgs(this.request.query).limit ?? 20,
- origin: 'local',
- sort: '+follower',
- state: 'alive',
- }),
- })
- .then(res => res.json() as Promise<Account[]>)
- .then(data => data.map((entry => ({
- source: 'global',
- account: entry,
- }))));
- return Promise.all(data.map(async suggestion => {
- suggestion.account = await this.mastoConverters.convertAccount(suggestion.account);
- return suggestion;
- }));
+ const text = await res.text();
+
+ if (res.headers.get('content-type') === 'application/json') {
+ try {
+ const json = JSON.parse(text);
+
+ if (json && typeof(json) === 'object') {
+ json.httpStatusCode = res.status;
+ return json;
+ }
+ } catch { /* ignore */ }
}
+
+ // Response is not a JSON object; treat as string
+ throw new ApiError({
+ code: 'INTERNAL_ERROR',
+ message: text || 'Internal error occurred. Please contact us if the error persists.',
+ id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac',
+ kind: 'server',
+ httpStatusCode: res.status,
+ });
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts
index 4c49a6a293..22b8a911ca 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/status.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts
@@ -4,12 +4,11 @@
*/
import querystring, { ParsedUrlQueryInput } from 'querystring';
+import { Injectable } from '@nestjs/common';
import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
-import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
-import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/timelineArgs.js';
-import { AuthenticateService } from '@/server/api/AuthenticateService.js';
-import { convertAttachment, convertPoll, MastoConverters } from '../converters.js';
-import { getAccessToken, getClient, MastodonApiServerService } from '../MastodonApiServerService.js';
+import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js';
import type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
@@ -18,167 +17,112 @@ function normalizeQuery(data: Record<string, unknown>) {
return querystring.parse(str);
}
+@Injectable()
export class ApiStatusMastodon {
constructor(
- private readonly fastify: FastifyInstance,
- private readonly mastoConverters: MastoConverters,
- private readonly logger: MastodonLogger,
- private readonly authenticateService: AuthenticateService,
- private readonly mastodon: MastodonApiServerService,
+ private readonly mastoConverters: MastodonConverters,
+ private readonly clientService: MastodonClientService,
) {}
- public getStatus() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}`, data);
- reply.code(_request.is404 ? 404 : 401).send(data);
+ public register(fastify: FastifyInstance): void {
+ fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.getStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ // Fixup - Discord ignores CWs and renders the entire post.
+ if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) {
+ response.content = '(preview disabled for sensitive content)';
+ response.media_attachments = [];
}
+
+ return reply.send(response);
});
- }
- public getStatusSource() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getStatusSource(_request.params.id);
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/source`, data);
- reply.code(_request.is404 ? 404 : 401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getStatusSource(_request.params.id);
+
+ return reply.send(data.data);
});
- }
- public getContext() {
- this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query));
- const ancestors = await Promise.all(data.ancestors.map(async status => await this.mastoConverters.convertStatus(status, me)));
- const descendants = await Promise.all(data.descendants.map(async status => await this.mastoConverters.convertStatus(status, me)));
- reply.send({ ancestors, descendants });
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/context`, data);
- reply.code(_request.is404 ? 404 : 401).send(data);
- }
+ fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const { data } = await client.getStatusContext(_request.params.id, parseTimelineArgs(_request.query));
+ const ancestors = await Promise.all(data.ancestors.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
+ const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
+ const response = { ancestors, descendants };
+
+ return reply.send(response);
});
- }
- public getHistory() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const [user] = await this.authenticateService.authenticate(getAccessToken(_request.headers.authorization));
- const edits = await this.mastoConverters.getEdits(_request.params.id, user);
- reply.send(edits);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/history`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const user = await this.clientService.getAuth(_request);
+ const edits = await this.mastoConverters.getEdits(_request.params.id, user);
+
+ return reply.send(edits);
});
- }
- public getReblogged() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getStatusRebloggedBy(_request.params.id);
- reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/reblogged_by`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getStatusRebloggedBy(_request.params.id);
+ const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
+
+ return reply.send(response);
});
- }
- public getFavourites() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getStatusFavouritedBy(_request.params.id);
- reply.send(await Promise.all(data.data.map(async (account: Entity.Account) => await this.mastoConverters.convertAccount(account))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/favourited_by`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getStatusFavouritedBy(_request.params.id);
+ const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
+
+ return reply.send(response);
});
- }
- public getMedia() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getMedia(_request.params.id);
- reply.send(convertAttachment(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/media/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getMedia(_request.params.id);
+ const response = convertAttachment(data.data);
+
+ return reply.send(response);
});
- }
- public getPoll() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.getPoll(_request.params.id);
- reply.send(convertPoll(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/polls/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getPoll(_request.params.id);
+ const response = convertPoll(data.data);
+
+ return reply.send(response);
});
- }
- public votePoll() {
- this.fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.body.choices) return reply.code(400).send({ error: 'Missing required payload "choices"' });
- const data = await client.votePoll(_request.params.id, _request.body.choices);
- reply.send(convertPoll(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/polls/${_request.params.id}/votes`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.body.choices) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "choices"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.votePoll(_request.params.id, _request.body.choices);
+ const response = convertPoll(data.data);
+
+ return reply.send(response);
});
- }
- public postStatus() {
- this.fastify.post<{
+ fastify.post<{
Body: {
media_ids?: string[],
poll?: {
@@ -202,63 +146,58 @@ export class ApiStatusMastodon {
}
}>('/v1/statuses', async (_request, reply) => {
let body = _request.body;
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]'])
- ) {
- body = normalizeQuery(body);
- }
- const text = body.status ??= ' ';
- const removed = text.replace(/@\S+/g, '').replace(/\s|/g, '');
- const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
- const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
- if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) {
- const a = await client.createEmojiReaction(
- body.in_reply_to_id,
- removed,
- );
- reply.send(a.data);
- }
- if (body.in_reply_to_id && removed === '/unreact') {
- const id = body.in_reply_to_id;
- const post = await client.getStatus(id);
- const react = post.data.emoji_reactions.filter(e => e.me)[0].name;
- const data = await client.deleteEmojiReaction(id, react);
- reply.send(data.data);
- }
- if (!body.media_ids) body.media_ids = undefined;
- if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
-
- if (body.poll && !body.poll.options) {
- return reply.code(400).send({ error: 'Missing required payload "poll.options"' });
- }
- if (body.poll && !body.poll.expires_in) {
- return reply.code(400).send({ error: 'Missing required payload "poll.expires_in"' });
- }
+ if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]'])
+ ) {
+ body = normalizeQuery(body);
+ }
+ const text = body.status ??= ' ';
+ const removed = text.replace(/@\S+/g, '').replace(/\s|/g, '');
+ const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
+ const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
- const options = {
- ...body,
- sensitive: toBoolean(body.sensitive),
- poll: body.poll ? {
- options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
- expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
- multiple: toBoolean(body.poll.multiple),
- hide_totals: toBoolean(body.poll.hide_totals),
- } : undefined,
- };
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ if ((body.in_reply_to_id && isDefaultEmoji) || (body.in_reply_to_id && isCustomEmoji)) {
+ const a = await client.createEmojiReaction(
+ body.in_reply_to_id,
+ removed,
+ );
+ return reply.send(a.data);
+ }
+ if (body.in_reply_to_id && removed === '/unreact') {
+ const id = body.in_reply_to_id;
+ const post = await client.getStatus(id);
+ const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name;
+ const data = await client.deleteEmojiReaction(id, react);
+ return reply.send(data.data);
+ }
+ if (!body.media_ids) body.media_ids = undefined;
+ if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
- const data = await client.postStatus(text, options);
- reply.send(await this.mastoConverters.convertStatus(data.data as Entity.Status, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v1/statuses', data);
- reply.code(401).send(data);
+ if (body.poll && !body.poll.options) {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.options"' });
+ }
+ if (body.poll && !body.poll.expires_in) {
+ return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "poll.expires_in"' });
}
+
+ const options = {
+ ...body,
+ sensitive: toBoolean(body.sensitive),
+ poll: body.poll ? {
+ options: body.poll.options!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
+ expires_in: toInt(body.poll.expires_in)!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
+ multiple: toBoolean(body.poll.multiple),
+ hide_totals: toBoolean(body.poll.hide_totals),
+ } : undefined,
+ };
+
+ const data = await client.postStatus(text, options);
+ const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me);
+
+ return reply.send(response);
});
- }
- public updateStatus() {
- this.fastify.put<{
+ fastify.put<{
Params: { id: string },
Body: {
status?: string,
@@ -273,201 +212,138 @@ export class ApiStatusMastodon {
},
}
}>('/v1/statuses/:id', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const body = _request.body;
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const body = _request.body;
- if (!body.media_ids || !body.media_ids.length) {
- body.media_ids = undefined;
- }
+ if (!body.media_ids || !body.media_ids.length) {
+ body.media_ids = undefined;
+ }
- const options = {
- ...body,
- sensitive: toBoolean(body.sensitive),
- poll: body.poll ? {
- options: body.poll.options,
- expires_in: toInt(body.poll.expires_in),
- multiple: toBoolean(body.poll.multiple),
- hide_totals: toBoolean(body.poll.hide_totals),
- } : undefined,
- };
+ const options = {
+ ...body,
+ sensitive: toBoolean(body.sensitive),
+ poll: body.poll ? {
+ options: body.poll.options,
+ expires_in: toInt(body.poll.expires_in),
+ multiple: toBoolean(body.poll.multiple),
+ hide_totals: toBoolean(body.poll.hide_totals),
+ } : undefined,
+ };
- const data = await client.editStatus(_request.params.id, options);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ const data = await client.editStatus(_request.params.id, options);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public addFavourite() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.createEmojiReaction(_request.params.id, '❤');
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/favorite`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.createEmojiReaction(_request.params.id, '❤');
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public rmFavourite() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.deleteEmojiReaction(_request.params.id, '❤');
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/statuses/${_request.params.id}/unfavorite`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.deleteEmojiReaction(_request.params.id, '❤');
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public reblogStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.reblogStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/reblog`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.reblogStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public unreblogStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.unreblogStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/unreblog`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.unreblogStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public bookmarkStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.bookmarkStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/bookmark`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.bookmarkStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public unbookmarkStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.unbookmarkStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/unbookmark`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.unbookmarkStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
- public pinStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.pinStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/pin`, data);
- reply.code(401).send(data);
- }
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.pinStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public unpinStatus() {
- this.fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.unpinStatus(_request.params.id);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/unpin`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.unpinStatus(_request.params.id);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public reactStatus() {
- this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.createEmojiReaction(_request.params.id, _request.params.name);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/react/${_request.params.name}`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.createEmojiReaction(_request.params.id, _request.params.name);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public unreactStatus() {
- this.fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.params.name) return reply.code(400).send({ error: 'Missing required parameter "name"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name);
- reply.send(await this.mastoConverters.convertStatus(data.data, me));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/statuses/${_request.params.id}/unreact/${_request.params.name}`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.params.name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "name"' });
+
+ const { client, me } = await this.clientService.getAuthClient(_request);
+ const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name);
+ const response = await this.mastoConverters.convertStatus(data.data, me);
+
+ return reply.send(response);
});
- }
- public deleteStatus() {
- this.fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const data = await client.deleteStatus(_request.params.id);
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`DELETE /v1/statuses/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.deleteStatus(_request.params.id);
+
+ return reply.send(data.data);
});
}
}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
index 1a732d62de..b2f7b18dc9 100644
--- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
+++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
@@ -3,232 +3,156 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { getErrorData, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
-import { convertList, MastoConverters } from '../converters.js';
-import { getClient, MastodonApiServerService } from '../MastodonApiServerService.js';
-import { parseTimelineArgs, TimelineArgs, toBoolean } from '../timelineArgs.js';
+import { Injectable } from '@nestjs/common';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
+import { convertList, MastodonConverters } from '../MastodonConverters.js';
+import { parseTimelineArgs, TimelineArgs, toBoolean } from '../argsUtils.js';
import type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
+@Injectable()
export class ApiTimelineMastodon {
constructor(
- private readonly fastify: FastifyInstance,
- private readonly mastoConverters: MastoConverters,
- private readonly logger: MastodonLogger,
- private readonly mastodon: MastodonApiServerService,
+ private readonly clientService: MastodonClientService,
+ private readonly mastoConverters: MastodonConverters,
) {}
- public getTL() {
- this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = toBoolean(_request.query.local)
- ? await client.getLocalTimeline(parseTimelineArgs(_request.query))
- : await client.getPublicTimeline(parseTimelineArgs(_request.query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/timelines/public', data);
- reply.code(401).send(data);
- }
+ public register(fastify: FastifyInstance): void {
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/public', async (request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const query = parseTimelineArgs(request.query);
+ const data = toBoolean(request.query.local)
+ ? await client.getLocalTimeline(query)
+ : await client.getPublicTimeline(query);
+ const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getHomeTl() {
- this.fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.getHomeTimeline(parseTimelineArgs(_request.query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/timelines/home', data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const query = parseTimelineArgs(request.query);
+ const data = await client.getHomeTimeline(query);
+ const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getTagTl() {
- this.fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
- try {
- if (!_request.params.hashtag) return reply.code(400).send({ error: 'Missing required parameter "hashtag"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.getTagTimeline(_request.params.hashtag, parseTimelineArgs(_request.query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/timelines/tag/${_request.params.hashtag}`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => {
+ if (!request.params.hashtag) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "hashtag"' });
+
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const query = parseTimelineArgs(request.query);
+ const data = await client.getTagTimeline(request.params.hashtag, query);
+ const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getListTL() {
- this.fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.getListTimeline(_request.params.id, parseTimelineArgs(_request.query));
- reply.send(await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me))));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/timelines/list/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => {
+ if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const query = parseTimelineArgs(request.query);
+ const data = await client.getListTimeline(request.params.id, query);
+ const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getConversations() {
- this.fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (_request, reply) => {
- try {
- const { client, me } = await this.mastodon.getAuthClient(_request);
- const data = await client.getConversationTimeline(parseTimelineArgs(_request.query));
- const conversations = await Promise.all(data.data.map(async (conversation: Entity.Conversation) => await this.mastoConverters.convertConversation(conversation, me)));
- reply.send(conversations);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/conversations', data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => {
+ const { client, me } = await this.clientService.getAuthClient(request);
+ const query = parseTimelineArgs(request.query);
+ const data = await client.getConversationTimeline(query);
+ const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getList() {
- this.fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.getList(_request.params.id);
- reply.send(convertList(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/lists/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.getList(_request.params.id);
+ const response = convertList(data.data);
+
+ return reply.send(response);
});
- }
- public getLists() {
- this.fastify.get('/v1/lists', async (_request, reply) => {
- try {
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.getLists();
- reply.send(data.data.map((list: Entity.List) => convertList(list)));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('GET /v1/lists', data);
- reply.code(401).send(data);
- }
+ fastify.get('/v1/lists', async (request, reply) => {
+ const client = this.clientService.getClient(request);
+ const data = await client.getLists();
+ const response = data.data.map((list: Entity.List) => convertList(list));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public getListAccounts() {
- this.fastify.get<{ Params: { id?: string }, Querystring: { limit?: number, max_id?: string, since_id?: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.getAccountsInList(_request.params.id, _request.query);
- const accounts = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
- reply.send(accounts);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`GET /v1/lists/${_request.params.id}/accounts`, data);
- reply.code(401).send(data);
- }
+ fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => {
+ if (!request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(request);
+ const data = await client.getAccountsInList(request.params.id, parseTimelineArgs(request.query));
+ const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
+
+ attachMinMaxPagination(request, reply, response);
+ return reply.send(response);
});
- }
- public addListAccount() {
- this.fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id);
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`POST /v1/lists/${_request.params.id}/accounts`, data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id);
+
+ return reply.send(data.data);
});
- }
- public rmListAccount() {
- this.fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.query.accounts_id) return reply.code(400).send({ error: 'Missing required property "accounts_id"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id);
- reply.send(data.data);
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`DELETE /v1/lists/${_request.params.id}/accounts`, data);
- reply.code(401).send(data);
- }
+ fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.query.accounts_id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required property "accounts_id"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id);
+
+ return reply.send(data.data);
});
- }
- public createList() {
- this.fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => {
- try {
- if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.createList(_request.body.title);
- reply.send(convertList(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error('POST /v1/lists', data);
- reply.code(401).send(data);
- }
+ fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => {
+ if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.createList(_request.body.title);
+ const response = convertList(data.data);
+
+ return reply.send(response);
});
- }
- public updateList() {
- this.fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- if (!_request.body.title) return reply.code(400).send({ error: 'Missing required payload "title"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- const data = await client.updateList(_request.params.id, _request.body.title);
- reply.send(convertList(data.data));
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`PUT /v1/lists/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+ if (!_request.body.title) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "title"' });
+
+ const client = this.clientService.getClient(_request);
+ const data = await client.updateList(_request.params.id, _request.body.title);
+ const response = convertList(data.data);
+
+ return reply.send(response);
});
- }
- public deleteList() {
- this.fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
- try {
- if (!_request.params.id) return reply.code(400).send({ error: 'Missing required parameter "id"' });
- const BASE_URL = `${_request.protocol}://${_request.host}`;
- const accessTokens = _request.headers.authorization;
- const client = getClient(BASE_URL, accessTokens);
- await client.deleteList(_request.params.id);
- reply.send({});
- } catch (e) {
- const data = getErrorData(e);
- this.logger.error(`DELETE /v1/lists/${_request.params.id}`, data);
- reply.code(401).send(data);
- }
+ fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
+ if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
+
+ const client = this.clientService.getClient(_request);
+ await client.deleteList(_request.params.id);
+
+ return reply.send({});
});
}
}
diff --git a/packages/backend/src/server/api/mastodon/pagination.ts b/packages/backend/src/server/api/mastodon/pagination.ts
new file mode 100644
index 0000000000..2cf24cfb24
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/pagination.ts
@@ -0,0 +1,170 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { FastifyReply, FastifyRequest } from 'fastify';
+import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js';
+
+interface AnyEntity {
+ readonly id: string;
+}
+
+/**
+ * Attaches Mastodon's pagination headers to a response that is paginated by min_id / max_id parameters.
+ * Results must be sorted, but can be in ascending or descending order.
+ * Attached headers will always be in descending order.
+ *
+ * @param request Fastify request object
+ * @param reply Fastify reply object
+ * @param results Results array, ordered in ascending or descending order
+ */
+export function attachMinMaxPagination(request: FastifyRequest, reply: FastifyReply, results: AnyEntity[]): void {
+ // No results, nothing to do
+ if (!hasItems(results)) return;
+
+ // "next" link - older results
+ const oldest = findOldest(results);
+ const nextUrl = createPaginationUrl(request, { max_id: oldest }); // Next page (older) has IDs less than the oldest of this page
+ const next = `<${nextUrl}>; rel="next"`;
+
+ // "prev" link - newer results
+ const newest = findNewest(results);
+ const prevUrl = createPaginationUrl(request, { min_id: newest }); // Previous page (newer) has IDs greater than the newest of this page
+ const prev = `<${prevUrl}>; rel="prev"`;
+
+ // https://docs.joinmastodon.org/api/guidelines/#pagination
+ const link = `${next}, ${prev}`;
+ reply.header('link', link);
+}
+
+/**
+ * Attaches Mastodon's pagination headers to a response that is paginated by limit / offset parameters.
+ * Results must be sorted, but can be in ascending or descending order.
+ * Attached headers will always be in descending order.
+ *
+ * @param request Fastify request object
+ * @param reply Fastify reply object
+ * @param results Results array, ordered in ascending or descending order
+ */
+export function attachOffsetPagination(request: FastifyRequest, reply: FastifyReply, results: unknown[]): void {
+ const links: string[] = [];
+
+ // Find initial offset
+ const offset = findOffset(request);
+ const limit = findLimit(request);
+
+ // "next" link - older results
+ if (hasItems(results)) {
+ const oldest = offset + results.length;
+ const nextUrl = createPaginationUrl(request, { offset: oldest }); // Next page (older) has entries less than the oldest of this page
+ links.push(`<${nextUrl}>; rel="next"`);
+ }
+
+ // "prev" link - newer results
+ // We can only paginate backwards if a limit is specified
+ if (limit) {
+ // Make sure we don't cross below 0, as that will produce an API error
+ if (limit <= offset) {
+ const newest = offset - limit;
+ const prevUrl = createPaginationUrl(request, { offset: newest }); // Previous page (newer) has entries greater than the newest of this page
+ links.push(`<${prevUrl}>; rel="prev"`);
+ } else {
+ const prevUrl = createPaginationUrl(request, { offset: 0, limit: offset }); // Previous page (newer) has entries greater than the newest of this page
+ links.push(`<${prevUrl}>; rel="prev"`);
+ }
+ }
+
+ // https://docs.joinmastodon.org/api/guidelines/#pagination
+ if (links.length > 0) {
+ const link = links.join(', ');
+ reply.header('link', link);
+ }
+}
+
+function hasItems<T>(items: T[]): items is [T, ...T[]] {
+ return items.length > 0;
+}
+
+function findOffset(request: FastifyRequest): number {
+ if (typeof(request.query) !== 'object') return 0;
+
+ const query = request.query as Record<string, string | string[] | undefined>;
+ if (!query.offset) return 0;
+
+ if (Array.isArray(query.offset)) {
+ const offsets = query.offset
+ .map(o => parseInt(o))
+ .filter(o => !isNaN(o));
+ const offset = Math.max(...offsets);
+ return isNaN(offset) ? 0 : offset;
+ }
+
+ const offset = parseInt(query.offset);
+ return isNaN(offset) ? 0 : offset;
+}
+
+function findLimit(request: FastifyRequest): number | null {
+ if (typeof(request.query) !== 'object') return null;
+
+ const query = request.query as Record<string, string | string[] | undefined>;
+ if (!query.limit) return null;
+
+ if (Array.isArray(query.limit)) {
+ const limits = query.limit
+ .map(l => parseInt(l))
+ .filter(l => !isNaN(l));
+ const limit = Math.max(...limits);
+ return isNaN(limit) ? null : limit;
+ }
+
+ const limit = parseInt(query.limit);
+ return isNaN(limit) ? null : limit;
+}
+
+function findOldest(items: [AnyEntity, ...AnyEntity[]]): string {
+ const first = items[0].id;
+ const last = items[items.length - 1].id;
+
+ return isOlder(first, last) ? first : last;
+}
+
+function findNewest(items: [AnyEntity, ...AnyEntity[]]): string {
+ const first = items[0].id;
+ const last = items[items.length - 1].id;
+
+ return isOlder(first, last) ? last : first;
+}
+
+function isOlder(a: string, b: string): boolean {
+ if (a === b) return false;
+
+ if (a.length !== b.length) {
+ return a.length < b.length;
+ }
+
+ return a < b;
+}
+
+function createPaginationUrl(request: FastifyRequest, data: {
+ min_id?: string;
+ max_id?: string;
+ offset?: number;
+ limit?: number;
+}): string {
+ const baseUrl = getBaseUrl(request);
+ const requestUrl = new URL(request.url, baseUrl);
+
+ // Remove any existing pagination
+ requestUrl.searchParams.delete('min_id');
+ requestUrl.searchParams.delete('max_id');
+ requestUrl.searchParams.delete('since_id');
+ requestUrl.searchParams.delete('offset');
+
+ if (data.min_id) requestUrl.searchParams.set('min_id', data.min_id);
+ if (data.max_id) requestUrl.searchParams.set('max_id', data.max_id);
+ if (data.offset) requestUrl.searchParams.set('offset', String(data.offset));
+ if (data.limit) requestUrl.searchParams.set('limit', String(data.limit));
+
+ return requestUrl.href;
+}
diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts
index 82ee0f47d7..85d1fd0bce 100644
--- a/packages/backend/src/server/api/openapi/gen-spec.ts
+++ b/packages/backend/src/server/api/openapi/gen-spec.ts
@@ -210,9 +210,15 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
spec.paths['/' + endpoint.name] = {
...(endpoint.meta.allowGet ? {
- get: info,
+ get: {
+ ...info,
+ operationId: 'get___' + info.operationId,
+ },
} : {}),
- post: info,
+ post: {
+ ...info,
+ operationId: 'post___' + info.operationId,
+ },
};
}
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index 83c5fcdf52..13934628a8 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -20,6 +20,8 @@ import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
+import { ChatUserChannelService } from './channels/chat-user.js';
+import { ChatRoomChannelService } from './channels/chat-room.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js';
@@ -42,6 +44,8 @@ export class ChannelsService {
private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService,
+ private chatUserChannelService: ChatUserChannelService,
+ private chatRoomChannelService: ChatRoomChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) {
@@ -65,6 +69,8 @@ export class ChannelsService {
case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService;
+ case 'chatUser': return this.chatUserChannelService;
+ case 'chatRoom': return this.chatRoomChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts
index e98e2a2f3f..e0535a2f14 100644
--- a/packages/backend/src/server/api/stream/Connection.ts
+++ b/packages/backend/src/server/api/stream/Connection.ts
@@ -7,7 +7,6 @@ import * as WebSocket from 'ws';
import type { MiUser } from '@/models/User.js';
import type { MiAccessToken } from '@/models/AccessToken.js';
import type { Packed } from '@/misc/json-schema.js';
-import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@@ -23,6 +22,7 @@ import type { EventEmitter } from 'events';
import type Channel from './channel.js';
const MAX_CHANNELS_PER_CONNECTION = 32;
+const MAX_SUBSCRIPTIONS_PER_CONNECTION = 512;
/**
* Main stream connection
@@ -31,12 +31,10 @@ const MAX_CHANNELS_PER_CONNECTION = 32;
export default class Connection {
public user?: MiUser;
public token?: MiAccessToken;
- private rateLimiter?: () => Promise<boolean>;
private wsConnection: WebSocket.WebSocket;
public subscriber: StreamEventEmitter;
- private channels: Channel[] = [];
- private subscribingNotes: Partial<Record<string, number>> = {};
- private cachedNotes: Packed<'Note'>[] = [];
+ private channels = new Map<string, Channel>();
+ private subscribingNotes = new Map<string, number>();
public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set();
@@ -45,13 +43,11 @@ export default class Connection {
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
private fetchIntervalId: NodeJS.Timeout | null = null;
- private activeRateLimitRequests = 0;
private closingConnection = false;
private logger: Logger;
constructor(
private channelsService: ChannelsService,
- private noteReadService: NoteReadService,
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
@@ -60,11 +56,10 @@ export default class Connection {
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
private ip: string,
- rateLimiter: () => Promise<boolean>,
+ private readonly rateLimiter: () => Promise<boolean>,
) {
if (user) this.user = user;
if (token) this.token = token;
- if (rateLimiter) this.rateLimiter = rateLimiter;
this.logger = loggerService.getLogger('streaming', 'coral');
}
@@ -121,25 +116,13 @@ export default class Connection {
if (this.closingConnection) return;
- if (this.rateLimiter) {
- // this 4096 should match the `max` of the `rateLimiter`, see
- // StreamingApiServerService
- if (this.activeRateLimitRequests <= 4096) {
- this.activeRateLimitRequests++;
- const shouldRateLimit = await this.rateLimiter();
- this.activeRateLimitRequests--;
+ // The rate limit is very high, so we can safely disconnect any client that hits it.
+ if (await this.rateLimiter()) {
+ this.logger.warn(`Closing a connection from ${this.ip} (user=${this.user?.id}}) due to an excessive influx of messages.`);
- if (shouldRateLimit) return;
- if (this.closingConnection) return;
- } else {
- let connectionInfo = `IP ${this.ip}`;
- if (this.user) connectionInfo += `, user ID ${this.user.id}`;
-
- this.logger.warn(`Closing a connection (${connectionInfo}) due to an excessive influx of messages.`);
- this.closingConnection = true;
- this.wsConnection.close(1008, 'Please stop spamming the streaming API.');
- return;
- }
+ this.closingConnection = true;
+ this.wsConnection.close(1008, 'Disconnected - too many requests');
+ return;
}
try {
@@ -154,7 +137,7 @@ export default class Connection {
case 'readNotification': this.onReadNotification(body); break;
case 'subNote': this.onSubscribeNote(body); break;
case 's': this.onSubscribeNote(body); break; // alias
- case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
+ case 'sr': this.onSubscribeNote(body); break;
case 'unsubNote': this.onUnsubscribeNote(body); break;
case 'un': this.onUnsubscribeNote(body); break; // alias
case 'connect': this.onChannelConnectRequested(body); break;
@@ -170,39 +153,6 @@ export default class Connection {
}
@bindThis
- public cacheNote(note: Packed<'Note'>) {
- const add = (note: Packed<'Note'>) => {
- const existIndex = this.cachedNotes.findIndex(n => n.id === note.id);
- if (existIndex > -1) {
- this.cachedNotes[existIndex] = note;
- return;
- }
-
- this.cachedNotes.unshift(note);
- if (this.cachedNotes.length > 32) {
- this.cachedNotes.splice(32);
- }
- };
-
- add(note);
- if (note.reply) add(note.reply);
- if (note.renote) add(note.renote);
- }
-
- @bindThis
- private readNote(body: JsonValue | undefined) {
- if (!isJsonObject(body)) return;
- const id = body.id;
-
- const note = this.cachedNotes.find(n => n.id === id);
- if (note == null) return;
-
- if (this.user && (note.userId !== this.user.id)) {
- this.noteReadService.read(this.user.id, [note]);
- }
- }
-
- @bindThis
private onReadNotification(payload: JsonValue | undefined) {
this.notificationService.readAllNotification(this.user!.id);
}
@@ -215,9 +165,19 @@ export default class Connection {
if (!isJsonObject(payload)) return;
if (!payload.id || typeof payload.id !== 'string') return;
- const current = this.subscribingNotes[payload.id] ?? 0;
+ const current = this.subscribingNotes.get(payload.id) ?? 0;
const updated = current + 1;
- this.subscribingNotes[payload.id] = updated;
+ this.subscribingNotes.set(payload.id, updated);
+
+ // Limit the number of distinct notes that can be subscribed to.
+ while (this.subscribingNotes.size > MAX_SUBSCRIPTIONS_PER_CONNECTION) {
+ // Map maintains insertion order, so first key is always the oldest
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const oldestKey = this.subscribingNotes.keys().next().value!;
+
+ this.subscribingNotes.delete(oldestKey);
+ this.subscriber.off(`noteStream:${oldestKey}`, this.onNoteStreamMessage);
+ }
if (updated === 1) {
this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage);
@@ -232,12 +192,12 @@ export default class Connection {
if (!isJsonObject(payload)) return;
if (!payload.id || typeof payload.id !== 'string') return;
- const current = this.subscribingNotes[payload.id];
+ const current = this.subscribingNotes.get(payload.id);
if (current == null) return;
const updated = current - 1;
- this.subscribingNotes[payload.id] = updated;
+ this.subscribingNotes.set(payload.id, updated);
if (updated <= 0) {
- delete this.subscribingNotes[payload.id];
+ this.subscribingNotes.delete(payload.id);
this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage);
}
}
@@ -304,7 +264,11 @@ export default class Connection {
*/
@bindThis
public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
- if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
+ if (this.channels.has(id)) {
+ this.disconnectChannel(id);
+ }
+
+ if (this.channels.size >= MAX_CHANNELS_PER_CONNECTION) {
return;
}
@@ -320,12 +284,16 @@ export default class Connection {
}
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
- if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
- return;
+ if (channelService.shouldShare) {
+ for (const c of this.channels.values()) {
+ if (c.chName === channel) {
+ return;
+ }
+ }
}
const ch: Channel = channelService.create(id, this);
- this.channels.push(ch);
+ this.channels.set(ch.id, ch);
ch.init(params ?? {});
if (pong) {
@@ -341,11 +309,11 @@ export default class Connection {
*/
@bindThis
public disconnectChannel(id: string) {
- const channel = this.channels.find(c => c.id === id);
+ const channel = this.channels.get(id);
if (channel) {
if (channel.dispose) channel.dispose();
- this.channels = this.channels.filter(c => c.id !== id);
+ this.channels.delete(id);
}
}
@@ -360,7 +328,7 @@ export default class Connection {
if (typeof data.type !== 'string') return;
if (typeof data.body === 'undefined') return;
- const channel = this.channels.find(c => c.id === data.id);
+ const channel = this.channels.get(data.id);
if (channel != null && channel.onMessage != null) {
channel.onMessage(data.type, data.body);
}
@@ -372,8 +340,15 @@ export default class Connection {
@bindThis
public dispose() {
if (this.fetchIntervalId) clearInterval(this.fetchIntervalId);
- for (const c of this.channels.filter(c => c.dispose)) {
+ for (const c of this.channels.values()) {
if (c.dispose) c.dispose();
}
+ for (const k of this.subscribingNotes.keys()) {
+ this.subscriber.off(`noteStream:${k}`, this.onNoteStreamMessage);
+ }
+
+ this.fetchIntervalId = null;
+ this.channels.clear();
+ this.subscribingNotes.clear();
}
}
diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts
index 7a6193ccfc..9af816dfbb 100644
--- a/packages/backend/src/server/api/stream/channel.ts
+++ b/packages/backend/src/server/api/stream/channel.ts
@@ -82,6 +82,11 @@ export default abstract class Channel {
return false;
}
+ /**
+ * This function modifies {@link note}, please make sure it has been shallow cloned.
+ * See Dakkar's comment of {@link assignMyReaction} for more
+ * @param note The note to change
+ */
protected async hideNote(note: Packed<'Note'>): Promise<void> {
if (note.renote) {
await this.hideNote(note.renote);
@@ -101,8 +106,8 @@ export default abstract class Channel {
this.noteEntityService = noteEntityService;
}
- public send(payload: { type: string, body: JsonValue }): void
- public send(type: string, payload: JsonValue): void
+ public send(payload: { type: string, body: JsonValue }): void;
+ public send(type: string, payload: JsonValue): void;
@bindThis
public send(typeOrPayload: { type: string, body: JsonValue } | string, payload?: JsonValue) {
const type = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).type : (typeOrPayload as string);
@@ -122,7 +127,6 @@ export default abstract class Channel {
public onMessage?(type: string, body: JsonValue): void;
public async assignMyReaction(note: Packed<'Note'>): Promise<Packed<'Note'>> {
- let changed = false;
// StreamingApiServerService creates a single EventEmitter per server process,
// so a new note arriving from redis gets de-serialised once per server process,
// and then that single object is passed to all active channels on each connection.
@@ -133,7 +137,6 @@ export default abstract class Channel {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
if (myReaction) {
- changed = true;
clonedNote.renote = { ...note.renote };
clonedNote.renote.myReaction = myReaction;
}
@@ -141,7 +144,6 @@ export default abstract class Channel {
if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) {
const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id);
if (myReaction) {
- changed = true;
clonedNote.renote = { ...note.renote };
clonedNote.renote.reply = { ...note.renote.reply };
clonedNote.renote.reply.myReaction = myReaction;
@@ -151,12 +153,11 @@ export default abstract class Channel {
if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) {
const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id);
if (myReaction) {
- changed = true;
clonedNote.reply = { ...note.reply };
clonedNote.reply.myReaction = myReaction;
}
}
- return changed ? clonedNote : note;
+ return clonedNote;
}
}
@@ -165,4 +166,4 @@ export type MiChannelService<T extends boolean> = {
requireCredential: T;
kind: T extends true ? string : string | null | undefined;
create: (id: string, connection: Connection) => Channel;
-}
+};
diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts
index a73d158b7f..0974dbdb25 100644
--- a/packages/backend/src/server/api/stream/channels/antenna.ts
+++ b/packages/backend/src/server/api/stream/channels/antenna.ts
@@ -43,8 +43,6 @@ class AntennaChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
- this.connection.cacheNote(note);
-
this.send('note', note);
} else {
this.send(data.type, data.body);
diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts
index 5ebbdcbb86..d29101cbc5 100644
--- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts
@@ -53,9 +53,11 @@ class BubbleTimelineChannel extends Channel {
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
- if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return;
-
+ if (note.visibility !== 'public') return;
if (note.channelId != null) return;
+ if (note.user.host == null) return;
+ if (!this.instance.bubbleInstances.includes(note.user.host)) return;
+ if (note.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
@@ -66,8 +68,6 @@ class BubbleTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts
index ec0bc7e13a..65fb8d67cb 100644
--- a/packages/backend/src/server/api/stream/channels/channel.ts
+++ b/packages/backend/src/server/api/stream/channels/channel.ts
@@ -52,8 +52,6 @@ class ChannelChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts
new file mode 100644
index 0000000000..648e407569
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-room.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatRoomChannel extends Channel {
+ public readonly chName = 'chatRoom';
+ public static shouldShare = false;
+ public static requireCredential = true as const;
+ public static kind = 'read:chat';
+ private roomId: string;
+
+ constructor(
+ private chatService: ChatService,
+
+ id: string,
+ connection: Channel['connection'],
+ noteEntityService: NoteEntityService,
+ ) {
+ super(id, connection, noteEntityService);
+ }
+
+ @bindThis
+ public async init(params: JsonObject) {
+ if (typeof params.roomId !== 'string') return;
+ this.roomId = params.roomId;
+
+ this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent);
+ }
+
+ @bindThis
+ private async onEvent(data: GlobalEvents['chatRoom']['payload']) {
+ this.send(data.type, data.body);
+ }
+
+ @bindThis
+ public onMessage(type: string, body: any) {
+ switch (type) {
+ case 'read':
+ if (this.roomId) {
+ this.chatService.readRoomChatMessage(this.user!.id, this.roomId);
+ }
+ break;
+ }
+ }
+
+ @bindThis
+ public dispose() {
+ this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
+ }
+}
+
+@Injectable()
+export class ChatRoomChannelService implements MiChannelService<true> {
+ public readonly shouldShare = ChatRoomChannel.shouldShare;
+ public readonly requireCredential = ChatRoomChannel.requireCredential;
+ public readonly kind = ChatRoomChannel.kind;
+
+ constructor(
+ private chatService: ChatService,
+ private readonly noteEntityService: NoteEntityService,
+ ) {
+ }
+
+ @bindThis
+ public create(id: string, connection: Channel['connection']): ChatRoomChannel {
+ return new ChatRoomChannel(
+ this.chatService,
+ id,
+ connection,
+ this.noteEntityService,
+ );
+ }
+}
diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts
new file mode 100644
index 0000000000..b37aef29d1
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-user.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatUserChannel extends Channel {
+ public readonly chName = 'chatUser';
+ public static shouldShare = false;
+ public static requireCredential = true as const;
+ public static kind = 'read:chat';
+ private otherId: string;
+
+ constructor(
+ private chatService: ChatService,
+
+ id: string,
+ connection: Channel['connection'],
+ noteEntityService: NoteEntityService,
+ ) {
+ super(id, connection, noteEntityService);
+ }
+
+ @bindThis
+ public async init(params: JsonObject) {
+ if (typeof params.otherId !== 'string') return;
+ this.otherId = params.otherId;
+
+ this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+ }
+
+ @bindThis
+ private async onEvent(data: GlobalEvents['chatUser']['payload']) {
+ this.send(data.type, data.body);
+ }
+
+ @bindThis
+ public onMessage(type: string, body: any) {
+ switch (type) {
+ case 'read':
+ if (this.otherId) {
+ this.chatService.readUserChatMessage(this.user!.id, this.otherId);
+ }
+ break;
+ }
+ }
+
+ @bindThis
+ public dispose() {
+ this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+ }
+}
+
+@Injectable()
+export class ChatUserChannelService implements MiChannelService<true> {
+ public readonly shouldShare = ChatUserChannel.shouldShare;
+ public readonly requireCredential = ChatUserChannel.requireCredential;
+ public readonly kind = ChatUserChannel.kind;
+
+ constructor(
+ private chatService: ChatService,
+ private readonly noteEntityService: NoteEntityService,
+ ) {
+ }
+
+ @bindThis
+ public create(id: string, connection: Channel['connection']): ChatUserChannel {
+ return new ChatUserChannel(
+ this.chatService,
+ id,
+ connection,
+ this.noteEntityService,
+ );
+ }
+}
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index 72a8a8b156..c899ad9490 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -53,6 +53,9 @@ class GlobalTimelineChannel extends Channel {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
+ if (note.user.requireSigninToViewContents && this.user == null) return;
+ if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
+ if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
@@ -63,8 +66,6 @@ class GlobalTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts
index 7c8df87721..f47a10f293 100644
--- a/packages/backend/src/server/api/stream/channels/hashtag.ts
+++ b/packages/backend/src/server/api/stream/channels/hashtag.ts
@@ -48,8 +48,6 @@ class HashtagChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts
index c87a21be82..dfdb491113 100644
--- a/packages/backend/src/server/api/stream/channels/home-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts
@@ -84,8 +84,6 @@ class HomeTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
index 95b762e2b7..6cb425ff81 100644
--- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
@@ -101,8 +101,6 @@ class HybridTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts
index b9e0a4c234..82b128eae0 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -56,6 +56,9 @@ class LocalTimelineChannel extends Channel {
if (note.user.host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
+ if (note.user.requireSigninToViewContents && this.user == null) return;
+ if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
+ if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
// 関係ない返信は除外
if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) {
@@ -73,8 +76,6 @@ class LocalTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts
index 6b144e43e4..6194bb78dd 100644
--- a/packages/backend/src/server/api/stream/channels/main.ts
+++ b/packages/backend/src/server/api/stream/channels/main.ts
@@ -39,7 +39,6 @@ class MainChannel extends Channel {
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true,
});
- this.connection.cacheNote(note);
data.body.note = note;
}
break;
@@ -52,7 +51,6 @@ class MainChannel extends Channel {
const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true,
});
- this.connection.cacheNote(note);
data.body = note;
}
break;
diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts
index 14c4d96479..78cd9bf868 100644
--- a/packages/backend/src/server/api/stream/channels/role-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts
@@ -51,8 +51,6 @@ class RoleTimelineChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
} else {
this.send(data.type, data.body);
diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts
index d09a9b8d9f..8a7c2b2633 100644
--- a/packages/backend/src/server/api/stream/channels/user-list.ts
+++ b/packages/backend/src/server/api/stream/channels/user-list.ts
@@ -114,8 +114,6 @@ class UserListChannel extends Channel {
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
- this.connection.cacheNote(clonedNote);
-
this.send('note', clonedNote);
}
diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
index 6598aa9891..01ee451297 100644
--- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts
+++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts
@@ -3,17 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import querystring from 'querystring';
import { Inject, Injectable } from '@nestjs/common';
-import megalodon, { MegalodonInterface } from 'megalodon';
import { v4 as uuid } from 'uuid';
-/* import { kinds } from '@/misc/api-permissions.js';
-import type { Config } from '@/config.js';
-import { DI } from '@/di-symbols.js'; */
-import multer from 'fastify-multer';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
+import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
+import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js';
+import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import type { FastifyInstance } from 'fastify';
const kinds = [
@@ -51,19 +48,14 @@ const kinds = [
'write:gallery-likes',
];
-function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
- const accessTokenArr = authorization?.split(' ') ?? [null];
- const accessToken = accessTokenArr[accessTokenArr.length - 1];
- const generator = (megalodon as any).default;
- const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
- return client;
-}
-
@Injectable()
export class OAuth2ProviderService {
constructor(
@Inject(DI.config)
private config: Config,
+
+ private readonly mastodonClientService: MastodonClientService,
+ private readonly serverUtilityService: ServerUtilityService,
) { }
// https://datatracker.ietf.org/doc/html/rfc8414.html
@@ -100,105 +92,58 @@ export class OAuth2ProviderService {
});
}); */
- const upload = multer({
- storage: multer.diskStorage({}),
- limits: {
- fileSize: this.config.maxFileSize || 262144000,
- files: 1,
- },
- });
-
- fastify.addHook('onRequest', (request, reply, done) => {
- reply.header('Access-Control-Allow-Origin', '*');
- done();
- });
+ this.serverUtilityService.addMultipartFormDataContentType(fastify);
+ this.serverUtilityService.addFormUrlEncodedContentType(fastify);
+ this.serverUtilityService.addCORS(fastify);
+ this.serverUtilityService.addFlattenedQueryType(fastify);
- fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => {
- let body = '';
- payload.on('data', (data) => {
- body += data;
- });
- payload.on('end', () => {
- try {
- const parsed = querystring.parse(body);
- done(null, parsed);
- } catch (e: any) {
- done(e);
- }
- });
- payload.on('error', done);
- });
+ for (const url of ['/authorize', '/authorize/']) {
+ fastify.get<{ Querystring: Record<string, string | string[] | undefined> }>(url, async (request, reply) => {
+ if (typeof(request.query.client_id) !== 'string') return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_id"' });
- fastify.register(multer.contentParser);
+ const redirectUri = new URL(Buffer.from(request.query.client_id, 'base64').toString());
+ redirectUri.searchParams.set('mastodon', 'true');
+ if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state));
+ if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri));
- fastify.get('/authorize', async (request, reply) => {
- const query: any = request.query;
- let param = "mastodon=true";
- if (query.state) param += `&state=${query.state}`;
- if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
- const client = query.client_id ? query.client_id : "";
- reply.redirect(
- `${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
- );
- });
+ return reply.redirect(redirectUri.toString());
+ });
+ }
- fastify.get('/authorize/', async (request, reply) => {
- const query: any = request.query;
- let param = "mastodon=true";
- if (query.state) param += `&state=${query.state}`;
- if (query.redirect_uri) param += `&redirect_uri=${query.redirect_uri}`;
- const client = query.client_id ? query.client_id : "";
- reply.redirect(
- `${Buffer.from(client.toString(), 'base64').toString()}?${param}`,
- );
- });
+ fastify.post<{ Body?: Record<string, string | string[] | undefined>, Querystring: Record<string, string | string[] | undefined> }>('/token', async (request, reply) => {
+ const body = request.body ?? request.query;
- fastify.post('/token', { preHandler: upload.none() }, async (request, reply) => {
- const body: any = request.body || request.query;
- if (body.grant_type === "client_credentials") {
+ if (body.grant_type === 'client_credentials') {
const ret = {
access_token: uuid(),
- token_type: "Bearer",
- scope: "read",
+ token_type: 'Bearer',
+ scope: 'read',
created_at: Math.floor(new Date().getTime() / 1000),
};
- reply.send(ret);
- }
- let client_id: any = body.client_id;
- const BASE_URL = `${request.protocol}://${request.hostname}`;
- const client = getClient(BASE_URL, '');
- let token = null;
- if (body.code) {
- //m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/);
- //if (!m.length) {
- // ctx.body = { error: "Invalid code" };
- // return;
- //}
- //token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}`
- //console.log(body.code, token);
- token = body.code;
- }
- if (client_id instanceof Array) {
- client_id = client_id.toString();
- } else if (!client_id) {
- client_id = null;
+ return reply.send(ret);
}
+
try {
- const atData = await client.fetchAccessToken(
- client_id,
- body.client_secret,
- token ? token : "",
- );
+ if (!body.client_secret) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required query "client_secret"' });
+
+ const clientId = body.client_id ? String(body.clientId) : null;
+ const secret = String(body.client_secret);
+ const code = body.code ? String(body.code) : '';
+
+ // TODO fetch the access token directly, then remove all oauth code from megalodon
+ const client = this.mastodonClientService.getClient(request);
+ const atData = await client.fetchAccessToken(clientId, secret, code);
+
const ret = {
access_token: atData.accessToken,
- token_type: "Bearer",
- scope: body.scope || "read write follow push",
- created_at: Math.floor(new Date().getTime() / 1000),
+ token_type: 'Bearer',
+ scope: atData.scope || body.scope || 'read write follow push',
+ created_at: atData.createdAt || Math.floor(new Date().getTime() / 1000),
};
- reply.send(ret);
- } catch (err: any) {
- /* console.error(err); */
- reply.code(401).send(err.response.data);
+ return reply.send(ret);
+ } catch (e: unknown) {
+ const data = getErrorData(e);
+ return reply.code(401).send(data);
}
});
}
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 3ed811e737..c40d042fa4 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -7,16 +7,12 @@ import { randomUUID } from 'node:crypto';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
-import { createBullBoard } from '@bull-board/api';
-import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
-import { FastifyAdapter as BullBoardFastifyAdapter } from '@bull-board/fastify';
import ms from 'ms';
import sharp from 'sharp';
import pug from 'pug';
import { In, IsNull } from 'typeorm';
import fastifyStatic from '@fastify/static';
import fastifyView from '@fastify/view';
-import fastifyCookie from '@fastify/cookie';
import fastifyProxy from '@fastify/http-proxy';
import vary from 'vary';
import htmlSafeJsonStringify from 'htmlescape';
@@ -226,65 +222,6 @@ export class ClientServerService {
@bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
- fastify.register(fastifyCookie, {});
-
- //#region Bull Dashboard
- const bullBoardPath = '/queue';
-
- // Authenticate
- fastify.addHook('onRequest', async (request, reply) => {
- if (request.routeOptions.url == null) {
- reply.code(404).send('Not found');
- return;
- }
-
- // %71ueueとかでリクエストされたら困るため
- const url = decodeURI(request.routeOptions.url);
- if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) {
- if (!url.startsWith(bullBoardPath + '/static/')) {
- reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
- }
-
- const token = request.cookies.token;
- if (token == null) {
- reply.code(401).send('Login required');
- return;
- }
- const user = await this.usersRepository.findOneBy({ token });
- if (user == null) {
- reply.code(403).send('No such user');
- return;
- }
- const isAdministrator = await this.roleService.isAdministrator(user);
- if (!isAdministrator) {
- reply.code(403).send('Access denied');
- return;
- }
- }
- });
-
- const bullBoardServerAdapter = new BullBoardFastifyAdapter();
-
- createBullBoard({
- queues: [
- this.systemQueue,
- this.endedPollNotificationQueue,
- this.deliverQueue,
- this.inboxQueue,
- this.dbQueue,
- this.relationshipQueue,
- this.objectStorageQueue,
- this.userWebhookDeliverQueue,
- this.systemWebhookDeliverQueue,
- this.scheduleNotePostQueue,
- ].map(q => new BullMQAdapter(q)),
- serverAdapter: bullBoardServerAdapter,
- });
-
- bullBoardServerAdapter.setBasePath(bullBoardPath);
- (fastify.register as any)(bullBoardServerAdapter.registerPlugin(), { prefix: bullBoardPath });
- //#endregion
-
fastify.register(fastifyView, {
root: _dirname + '/views',
engine: {
@@ -490,7 +427,7 @@ export class ClientServerService {
fastify.get('/robots.txt', async (request, reply) => {
if (this.meta.robotsTxt) {
reply.header('Content-Type', 'text/plain');
- return await reply.send(this.meta.robotsTxt);
+ return reply.send(this.meta.robotsTxt);
} else {
return await reply.sendFile('/robots.txt', staticAssets);
}
@@ -521,6 +458,7 @@ export class ClientServerService {
url: this.config.url,
title: this.meta.name ?? 'Sharkey',
desc: this.meta.description,
+ customHead: this.config.customHtml.head,
...await this.generateCommonPugData(this.meta),
...data,
});
@@ -620,7 +558,7 @@ export class ClientServerService {
return await reply.view('user', {
user, profile, me,
- avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
+ avatarUrl: _user.avatarUrl,
sub: request.params.sub,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
@@ -952,6 +890,7 @@ export class ClientServerService {
return await reply.view('info-card', {
version: this.config.version,
host: this.config.host,
+ url: this.config.url,
meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts
index 664f21d308..dcd4d80303 100644
--- a/packages/backend/src/server/web/FeedService.ts
+++ b/packages/backend/src/server/web/FeedService.ts
@@ -65,7 +65,7 @@ export class FeedService {
generator: 'Sharkey',
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link,
- image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
+ image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: {
json: `${author.link}.json`,
atom: `${author.link}.atom`,
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index b8d7020598..203bc908a8 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -4,9 +4,10 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import { summaly } from '@transfem-org/summaly';
-import { SummalyResult } from '@transfem-org/summaly/built/summary.js';
+import { summaly } from '@misskey-dev/summaly';
+import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import * as Redis from 'ioredis';
+import { IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -14,33 +15,81 @@ import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
-import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js';
import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
+import type { MiAccessToken, NotesRepository } from '@/models/_.js';
+import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
+import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
+import { SystemAccountService } from '@/core/SystemAccountService.js';
+import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
+import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js';
+import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
+import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
+import type { MiLocalUser } from '@/models/User.js';
+import { getIpHash } from '@/misc/get-ip-hash.js';
+import { isRetryableError } from '@/misc/is-retryable-error.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
+export type LocalSummalyResult = SummalyResult & {
+ haveNoteLocally?: boolean;
+};
+
+// Increment this to invalidate cached previews after a major change.
+const cacheFormatVersion = 3;
+
+type PreviewRoute = {
+ Querystring: {
+ url?: string
+ lang?: string,
+ fetch?: string,
+ i?: string,
+ },
+};
+
+type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string];
+
+// Up to 50 requests, then 10 / second (at 2 / 200ms rate)
+const previewLimit: Keyed<BucketRateLimit> = {
+ key: '/url',
+ type: 'bucket',
+ size: 50,
+ dripSize: 2,
+ dripRate: 200,
+};
+
@Injectable()
export class UrlPreviewService {
private logger: Logger;
- private previewCache: RedisKVCache<SummalyResult>;
+ private previewCache: RedisKVCache<LocalSummalyResult>;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
- private redisClient: Redis.Redis,
+ private readonly redisClient: Redis.Redis,
@Inject(DI.meta)
- private meta: MiMeta,
+ private readonly meta: MiMeta,
+
+ @Inject(DI.notesRepository)
+ private readonly notesRepository: NotesRepository,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
- private utilityService: UtilityService,
+ private readonly utilityService: UtilityService,
+ private readonly apUtilityService: ApUtilityService,
+ private readonly apDbResolverService: ApDbResolverService,
+ private readonly apRequestService: ApRequestService,
+ private readonly systemAccountService: SystemAccountService,
+ private readonly apNoteService: ApNoteService,
+ private readonly authenticateService: AuthenticateService,
+ private readonly rateLimiterService: SkRateLimiterService,
) {
this.logger = this.loggerService.getLogger('url-preview');
- this.previewCache = new RedisKVCache<SummalyResult>(this.redisClient, 'summaly', {
+ this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
lifetime: 1000 * 60 * 60 * 24, // 1d
memoryCacheLifetime: 1000 * 60 * 10, // 10m
fetcher: () => { throw new Error('the UrlPreview cache should never fetch'); },
@@ -51,19 +100,23 @@ export class UrlPreviewService {
@bindThis
private wrap(url?: string | null): string | null {
- return url != null
- ? `${this.config.mediaProxy}/preview.webp?${query({
- url,
- preview: '1',
- })}`
- : null;
+ if (url == null) return null;
+
+ // Don't proxy our own media
+ if (this.utilityService.isUriLocal(url)) {
+ return url;
+ }
+
+ // But proxy everything else!
+ const mediaQuery = query({ url, preview: '1' });
+ return `${this.config.mediaProxy}/preview.webp?${mediaQuery}`;
}
@bindThis
public async handle(
- request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>,
+ request: FastifyRequest<PreviewRoute>,
reply: FastifyReply,
- ): Promise<object | undefined> {
+ ): Promise<void> {
const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400);
@@ -77,81 +130,139 @@ export class UrlPreviewService {
}
if (!this.meta.urlPreviewEnabled) {
- reply.code(403);
- return {
- error: new ApiError({
+ return reply.code(403).send({
+ error: {
message: 'URL preview is disabled',
code: 'URL_PREVIEW_DISABLED',
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
- }),
- };
+ },
+ });
+ }
+
+ // Check rate limit
+ const auth = await this.authenticate(request);
+ if (!await this.checkRateLimit(auth, reply)) {
+ return;
}
- const host = new URL(url).host;
- if (this.utilityService.isBlockedHost(this.meta.blockedHosts, host)) {
- reply.code(403);
- return {
- error: new ApiError({
+ if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
+ return reply.code(403).send({
+ error: {
message: 'URL is blocked',
code: 'URL_PREVIEW_BLOCKED',
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
- }),
- };
+ },
+ });
}
- const key = `${url}@${lang}`;
- const cached = await this.previewCache.get(key);
- if (cached !== undefined) {
- this.logger.info(`Returning cache preview of ${key}`);
- // Cache 7days
- reply.header('Cache-Control', 'max-age=604800, immutable');
-
- return cached;
+ const fetch = !!request.query.fetch;
+ if (fetch && !await this.checkFetchPermissions(auth, reply)) {
+ return;
}
- this.logger.info(this.meta.urlPreviewSummaryProxyUrl
- ? `(Proxy) Getting preview of ${key} ...`
- : `Getting preview of ${key} ...`);
+ const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
+ if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
+ return;
+ }
try {
- const summary = this.meta.urlPreviewSummaryProxyUrl
+ const summary: LocalSummalyResult = this.meta.urlPreviewSummaryProxyUrl
? await this.fetchSummaryFromProxy(url, this.meta, lang)
: await this.fetchSummary(url, this.meta, lang);
- this.logger.succ(`Got preview of ${url}: ${summary.title}`);
+ this.validateUrls(summary);
- if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
- throw new Error('unsupported schema included');
+ // Repeat check, since redirects are allowed.
+ if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
+ return reply.code(403).send({
+ error: {
+ message: 'URL is blocked',
+ code: 'URL_PREVIEW_BLOCKED',
+ id: '50294652-857b-4b13-9700-8e5c7a8deae8',
+ },
+ });
}
- if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
- throw new Error('unsupported schema included');
- }
+ this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
- this.previewCache.set(key, summary);
+ // Summaly cannot always detect links to a fedi post, so do some additional tests to try and find missed cases.
+ if (!summary.activityPub) {
+ await this.inferActivityPubLink(summary);
+ }
+
+ if (summary.activityPub && !summary.haveNoteLocally) {
+ // Avoid duplicate checks in case inferActivityPubLink already set this.
+ const exists = await this.noteExists(summary.activityPub, fetch);
- // Cache 7days
- reply.header('Cache-Control', 'max-age=604800, immutable');
+ // Remove the AP flag if we encounter a permanent error fetching the note.
+ if (exists === false) {
+ summary.activityPub = null;
+ summary.haveNoteLocally = undefined;
+ } else {
+ summary.haveNoteLocally = exists ?? false;
+ }
+ }
+
+ // Await this to avoid hammering redis when a bunch of URLs are fetched at once
+ await this.previewCache.set(cacheKey, summary);
+
+ // Cache 1 day (matching redis), but only once we finalize the result
+ if (!summary.activityPub || summary.haveNoteLocally) {
+ reply.header('Cache-Control', 'public, max-age=86400');
+ }
- return summary;
+ return reply.code(200).send(summary);
} catch (err) {
- this.logger.warn(`Failed to get preview of ${url}: ${err}`);
+ this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
- reply.code(422);
- reply.header('Cache-Control', 'max-age=86400, immutable');
- return {
- error: new ApiError({
+ reply.header('Cache-Control', 'max-age=3600');
+ return reply.code(422).send({
+ error: {
message: 'Failed to get preview',
code: 'URL_PREVIEW_FAILED',
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
- }),
- };
+ },
+ });
}
}
+ private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise<boolean> {
+ const summary = await this.previewCache.get(cacheKey);
+ if (summary === undefined) {
+ return false;
+ }
+
+ // Check if note has loaded since we last cached the preview
+ if (summary.activityPub && !summary.haveNoteLocally) {
+ // Avoid duplicate checks in case inferActivityPubLink already set this.
+ const exists = await this.noteExists(summary.activityPub, fetch);
+
+ // Remove the AP flag if we encounter a permanent error fetching the note.
+ if (exists === false) {
+ summary.activityPub = null;
+ summary.haveNoteLocally = undefined;
+ } else {
+ summary.haveNoteLocally = exists ?? false;
+ }
+
+ // Persist the result once we finalize the result
+ if (!summary.activityPub || summary.haveNoteLocally) {
+ await this.previewCache.set(cacheKey, summary);
+ }
+ }
+
+ // Cache 1 day (matching redis), but only once we finalize the result
+ if (!summary.activityPub || summary.haveNoteLocally) {
+ reply.header('Cache-Control', 'public, max-age=86400');
+ }
+
+ reply.code(200).send(summary);
+ return true;
+ }
+
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy
? {
@@ -161,7 +272,7 @@ export class UrlPreviewService {
: undefined;
return summaly(url, {
- followRedirects: false,
+ followRedirects: true,
lang: lang ?? 'ja-JP',
agent: agent,
userAgent: meta.urlPreviewUserAgent ?? undefined,
@@ -172,8 +283,10 @@ export class UrlPreviewService {
}
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const proxy = meta.urlPreviewSummaryProxyUrl!;
const queryStr = query({
+ followRedirects: true,
url: url,
lang: lang ?? 'ja-JP',
userAgent: meta.urlPreviewUserAgent ?? undefined,
@@ -182,6 +295,209 @@ export class UrlPreviewService {
contentLengthRequired: meta.urlPreviewRequireContentLength,
});
- return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
+ return this.httpRequestService.getJson<LocalSummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
+ }
+
+ private validateUrls(summary: LocalSummalyResult) {
+ const urlScheme = this.utilityService.getUrlScheme(summary.url);
+ if (urlScheme !== 'http:' && urlScheme !== 'https:') {
+ throw new Error(`unsupported scheme in preview URL: "${urlScheme}"`);
+ }
+
+ if (summary.player.url) {
+ const playerScheme = this.utilityService.getUrlScheme(summary.player.url);
+ if (playerScheme !== 'http:' && playerScheme !== 'https:') {
+ this.logger.warn(`Redacting preview for ${summary.url}: player URL has unsupported scheme "${playerScheme}"`);
+ summary.player.url = null;
+ }
+ }
+
+ if (summary.icon) {
+ const iconScheme = this.utilityService.getUrlScheme(summary.icon);
+ if (iconScheme !== 'http:' && iconScheme !== 'https:') {
+ this.logger.warn(`Redacting preview for ${summary.url}: icon URL has unsupported scheme "${iconScheme}"`);
+ summary.icon = null;
+ }
+ }
+
+ if (summary.thumbnail) {
+ const thumbnailScheme = this.utilityService.getUrlScheme(summary.thumbnail);
+ if (thumbnailScheme !== 'http:' && thumbnailScheme !== 'https:') {
+ this.logger.warn(`Redacting preview for ${summary.url}: thumbnail URL has unsupported scheme "${thumbnailScheme}"`);
+ summary.thumbnail = null;
+ }
+ }
+
+ if (summary.activityPub) {
+ const activityPubScheme = this.utilityService.getUrlScheme(summary.activityPub);
+ if (activityPubScheme !== 'http:' && activityPubScheme !== 'https:') {
+ this.logger.warn(`Redacting preview for ${summary.url}: ActivityPub URL has unsupported scheme "${activityPubScheme}"`);
+ summary.activityPub = null;
+ }
+ }
+ }
+
+ private async inferActivityPubLink(summary: LocalSummalyResult) {
+ // Match canonical URI first.
+ // This covers local and remote links.
+ const isCanonicalUri = !!await this.apDbResolverService.getNoteFromApId(summary.url);
+ if (isCanonicalUri) {
+ summary.activityPub = summary.url;
+ summary.haveNoteLocally = true;
+ return;
+ }
+
+ // Try public URL next.
+ // This is necessary for Mastodon and other software with a different public URL.
+ const urlMatches = await this.notesRepository.find({
+ select: {
+ uri: true,
+ },
+ where: {
+ url: summary.url,
+ uri: Not(IsNull()),
+ },
+ }) as { uri: string }[];
+
+ // Older versions did not validate URL, so do it now to avoid impersonation.
+ const matchByUrl = urlMatches.find(({ uri }) => this.apUtilityService.haveSameAuthority(uri, summary.url));
+ if (matchByUrl) {
+ summary.activityPub = matchByUrl.uri;
+ summary.haveNoteLocally = true;
+ return;
+ }
+
+ // Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch.
+ const instanceActor = await this.systemAccountService.getInstanceActor();
+ const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null);
+ if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
+ summary.activityPub = remoteObject.id;
+ return;
+ }
+ }
+
+ // true = exists, false = does not exist (permanently), null = does not exist (temporarily)
+ private async noteExists(uri: string, fetch = false): Promise<boolean | null> {
+ try {
+ // Local note or cached remote note
+ if (await this.apDbResolverService.getNoteFromApId(uri)) {
+ return true;
+ }
+
+ // Un-cached remote note
+ if (!fetch) {
+ return null;
+ }
+
+ // Newly cached remote note
+ if (await this.apNoteService.resolveNote(uri)) {
+ return true;
+ }
+
+ // Non-existent or deleted note
+ return false;
+ } catch (err) {
+ // Errors, including invalid notes and network errors
+ return isRetryableError(err) ? null : false;
+ }
+ }
+
+ // Adapted from ApiCallService
+ private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise<AuthArray> {
+ const body = request.method === 'GET' ? request.query : request.body;
+
+ // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
+ const token = request.headers.authorization?.startsWith('Bearer ')
+ ? request.headers.authorization.slice(7)
+ : body?.['i'];
+ if (token != null && typeof token !== 'string') {
+ return [undefined, undefined, getIpHash(request.ip)];
+ }
+
+ try {
+ const auth = await this.authenticateService.authenticate(token);
+ return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)];
+ } catch (err) {
+ if (err instanceof AuthenticationError) {
+ return [undefined, undefined, getIpHash(request.ip)];
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ // Adapted from ApiCallService
+ private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
+ const [user, app] = auth;
+
+ // Authentication
+ if (user === undefined) {
+ reply.code(401).send({
+ error: {
+ message: 'Authentication failed. Please ensure your token is correct.',
+ code: 'AUTHENTICATION_FAILED',
+ id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
+ },
+ });
+ return false;
+ }
+ if (user === null) {
+ reply.code(401).send({
+ error: {
+ message: 'Credential required.',
+ code: 'CREDENTIAL_REQUIRED',
+ id: '1384574d-a912-4b81-8601-c7b1c4085df1',
+ },
+ });
+ return false;
+ }
+
+ // Authorization
+ if (user.isSuspended || user.isDeleted) {
+ reply.code(403).send({
+ error: {
+ message: 'Your account has been suspended.',
+ code: 'YOUR_ACCOUNT_SUSPENDED',
+ kind: 'permission',
+
+ id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
+ },
+ });
+ return false;
+ }
+ if (app && !app.permission.includes('read:account')) {
+ reply.code(403).send({
+ error: {
+ message: 'Your app does not have the necessary permissions to use this endpoint.',
+ code: 'PERMISSION_DENIED',
+ kind: 'permission',
+ id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
+ },
+ });
+ return false;
+ }
+
+ return true;
+ }
+
+ private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
+ const info = await this.rateLimiterService.limit(previewLimit, auth[2]);
+
+ // Always send headers, even if not blocked
+ sendRateLimitHeaders(reply, info);
+
+ if (info.blocked) {
+ reply.code(429).send({
+ error: {
+ message: 'Rate limit exceeded. Please try again later.',
+ code: 'RATE_LIMIT_EXCEEDED',
+ id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+ },
+ });
+
+ return false;
+ }
+
+ return true;
}
}
diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js
index 1af1dc545b..4f27e2fb30 100644
--- a/packages/backend/src/server/web/boot.embed.js
+++ b/packages/backend/src/server/web/boot.embed.js
@@ -103,13 +103,18 @@
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
+
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const title = locale?._bootErrors?.title || 'Failed to initialize Sharkey';
+ const reload = locale?.reload || 'Reload';
+
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
- <div class="message">読み込みに失敗しました</div>
+ <div class="message">${title}</div>
<div class="submessage">Failed to initialize Sharkey</div>
<div class="submessage">Error Code: ${code}</div>
<button onclick="location.reload(!0)">
- <div>リロード</div>
- <div><small>Reload</small></div>
+ <div>${reload}</div>
</button>`;
addStyle(`
#sharkey_app,
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index 54750e26e5..0488161513 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -142,11 +142,6 @@
document.documentElement.classList.add('useSystemFont');
}
- const wallpaper = localStorage.getItem('wallpaper');
- if (wallpaper) {
- document.documentElement.style.backgroundImage = `url(${wallpaper})`;
- }
-
const customCss = localStorage.getItem('customCss');
if (customCss && customCss.length > 0) {
const style = document.createElement('style');
@@ -166,6 +161,22 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const messages = Object.assign({
+ title: 'Failed to initialize Sharkey',
+ solution: 'The following actions may solve the problem.',
+ solution1: 'Update your os and browser',
+ solution2: 'Disable an adblocker',
+ solution3: 'Clear the browser cache',
+ solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
+ otherOption: 'Other options',
+ otherOption1: 'Clear preferences and cache',
+ otherOption2: 'Start the simple client',
+ otherOption3: 'Start the repair tool',
+ }, locale?._bootErrors || {});
+ const reload = locale?.reload || 'Reload';
+
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
@@ -175,32 +186,32 @@
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
- <h1>Failed to load<br>読み込みに失敗しました</h1>
+ <h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
- <span class="button-label-big">Reload / リロード</span>
+ <span class="button-label-big">${reload}</span>
</button>
- <p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります。</b></p>
- <p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p>
- <p>Disable an adblocker / アドブロッカーを無効にする</p>
- <p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
- <p>&#40;Tor Browser&#41; Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p>
+ <p><b>${messages.solution}</b></p>
+ <p>${messages.solution1}</p>
+ <p>${messages.solution2}</p>
+ <p>${messages.solution3}</p>
+ <p>${messages.solution4}</p>
<details style="color: #86b300;">
- <summary>Other options / その他のオプション</summary>
+ <summary>${messages.otherOption}</summary>
<a href="/flush">
<button class="button-small">
- <span class="button-label-small">Clear preferences and cache</span>
+ <span class="button-label-small">${messages.otherOption1}</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
- <span class="button-label-small">Start the simple client</span>
+ <span class="button-label-small">${messages.otherOption2}</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
- <span class="button-label-small">Start the repair tool</span>
+ <span class="button-label-small">${messages.otherOption3}</span>
</button>
</a>
</details>
diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css
index 6c96241970..8d03ceadf0 100644
--- a/packages/backend/src/server/web/error.css
+++ b/packages/backend/src/server/web/error.css
@@ -5,112 +5,107 @@
*/
* {
- font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+ font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#sharkey_app,
#splash {
- display: none !important;
+ display: none !important;
}
body,
html {
- background-color: #222;
- color: #dfddcc;
- justify-content: center;
- margin: auto;
- padding: 10px;
- text-align: center;
+ background-color: #222;
+ color: #dfddcc;
+ justify-content: center;
+ margin: auto;
+ padding: 10px;
+ text-align: center;
}
button {
- border-radius: 999px;
- padding: 0px 12px 0px 12px;
- border: none;
- cursor: pointer;
- margin-bottom: 12px;
+ border-radius: 999px;
+ padding: 0px 12px 0px 12px;
+ border: none;
+ cursor: pointer;
+ margin-bottom: 12px;
}
.button-big {
- background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
- line-height: 50px;
+ background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
+ line-height: 50px;
}
.button-big:hover {
- background: rgb(153, 204, 0);
+ background: rgb(153, 204, 0);
}
.button-small {
- background: #444;
- line-height: 40px;
+ background: #444;
+ line-height: 40px;
}
.button-small:hover {
- background: #555;
+ background: #555;
}
.button-label-big {
- color: #222;
- font-weight: bold;
- font-size: 20px;
- padding: 12px;
+ color: #222;
+ font-weight: bold;
+ font-size: 1.2em;
+ padding: 12px;
}
.button-label-small {
- color: rgb(153, 204, 0);
- font-size: 16px;
- padding: 12px;
+ color: rgb(153, 204, 0);
+ font-size: 16px;
+ padding: 12px;
}
a {
- color: rgb(134, 179, 0);
- text-decoration: none;
+ color: rgb(134, 179, 0);
+ text-decoration: none;
}
p,
li {
- font-size: 16px;
-}
-
-.dont-worry,
-#msg {
- font-size: 18px;
+ font-size: 16px;
}
.icon-warning {
- color: #dec340;
- height: 4rem;
- padding-top: 2rem;
+ color: #dec340;
+ height: 4rem;
+ padding-top: 2rem;
}
h1 {
- font-size: 32px;
+ font-size: 1.5em;
+ margin: 1em;
}
code {
- display: block;
- font-family: Fira, FiraCode, monospace;
- background: #333;
- padding: 0.5rem 1rem;
- max-width: 40rem;
- border-radius: 10px;
- justify-content: center;
- margin: auto;
- white-space: pre-wrap;
- word-break: break-word;
+ display: block;
+ font-family: Fira, FiraCode, monospace;
+ background: #333;
+ padding: 0.5rem 1rem;
+ max-width: 40rem;
+ border-radius: 10px;
+ justify-content: center;
+ margin: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
}
-summary {
- cursor: pointer;
+#errorInfo summary {
+ cursor: pointer;
}
-summary > * {
- display: inline;
- white-space: pre-wrap;
+#errorInfo summary>* {
+ display: inline;
}
@media screen and (max-width: 500px) {
- details {
- width: 50%;
- }
+ #errorInfo {
+ width: 50%;
+ }
}
diff --git a/packages/backend/src/server/web/error.js b/packages/backend/src/server/web/error.js
new file mode 100644
index 0000000000..4c6ae730b3
--- /dev/null
+++ b/packages/backend/src/server/web/error.js
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+'use strict';
+
+(() => {
+ document.addEventListener('DOMContentLoaded', () => {
+ const locale = JSON.parse(localStorage.getItem('locale') || '{}');
+
+ const messages = Object.assign({
+ title: 'Failed to initialize Sharkey',
+ serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.',
+ solution: 'The following actions may solve the problem.',
+ solution1: 'Update your os and browser',
+ solution2: 'Disable an adblocker',
+ solution3: 'Clear the browser cache',
+ solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
+ otherOption: 'Other options',
+ otherOption1: 'Clear preferences and cache',
+ otherOption2: 'Start the simple client',
+ otherOption3: 'Start the repair tool',
+ }, locale?._bootErrors || {});
+ const reload = locale?.reload || 'Reload';
+
+ const reloadEls = document.querySelectorAll('[data-i18n-reload]');
+ for (const el of reloadEls) {
+ el.textContent = reload;
+ }
+
+ const i18nEls = document.querySelectorAll('[data-i18n]');
+ for (const el of i18nEls) {
+ const key = el.dataset.i18n;
+ if (key && messages[key]) {
+ el.textContent = messages[key];
+ }
+ }
+ });
+})();
diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css
index 8094a0f6de..1c63d77e06 100644
--- a/packages/backend/src/server/web/style.css
+++ b/packages/backend/src/server/web/style.css
@@ -31,6 +31,7 @@ html {
margin: auto;
width: 64px;
height: 64px;
+ border-radius: 10px;
pointer-events: none;
}
diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css
index 5e8786cc4e..0911d562bf 100644
--- a/packages/backend/src/server/web/style.embed.css
+++ b/packages/backend/src/server/web/style.embed.css
@@ -53,6 +53,7 @@ html.embed.noborder #splash {
margin: auto;
width: 64px;
height: 64px;
+ border-radius: 10px;
pointer-events: none;
}
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index f57dbbbf4e..d9d750281d 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -45,6 +45,8 @@ html
link(rel='stylesheet' href=`/static-assets/fonts/sharkey-icons/style.css?version=${version}`)
link(rel='modulepreload' href=`/vite/${entry.file}`)
+ | !{customHead}
+
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
@@ -95,7 +97,7 @@ html
img#splashIcon(src= icon || '/static-assets/splash.png')
span#splashText
block randomMOTD
- = randomMOTD
+ != randomMOTD
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug
index 39b75abc4c..99b9634bd8 100644
--- a/packages/backend/src/server/web/views/error.pug
+++ b/packages/backend/src/server/web/views/error.pug
@@ -31,39 +31,45 @@ html
style
include ../error.css
+ script
+ include ../error.js
+
body
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
path(stroke="none", d="M0 0h24v24H0z", fill="none")
path(d="M12 9v2m0 4v.01")
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
- h1 An error has occurred!
+ h1(data-i18n="title") Failed to initialize Sharkey
button.button-big(onclick="location.reload();")
- span.button-label-big Refresh
-
- p.dont-worry Don't worry, it's (probably) not your fault.
+ span.button-label-big(data-i18n-reload) Reload
- p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
+ p(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
div#errors
code.
ERROR CODE: #{code}
ERROR ID: #{id}
- p You may also try the following options:
+ p
+ b(data-i18n="solution") The following actions may solve the problem.
- p Update your os and browser.
- p Disable an adblocker.
+ p(data-i18n="solution1") Update your os and browser
+ p(data-i18n="solution2") Disable an adblocker
+ p(data-i18n="solution3") Clear your browser cache
+ p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true
- a(href="/flush")
- button.button-small
- span.button-label-small Clear preferences and cache
- br
- a(href="/cli")
- button.button-small
- span.button-label-small Start the simple client
- br
- a(href="/bios")
- button.button-small
- span.button-label-small Start the repair tool
+ details(style="color: #86b300;")
+ summary(data-i18n="otherOption") Other options
+ a(href="/flush")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption1") Clear preferences and cache
+ br
+ a(href="/cli")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption2") Start the simple client
+ br
+ a(href="/bios")
+ button.button-small
+ span.button-label-small(data-i18n="otherOption3") Start the repair tool
diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug
index 4a9d00a596..0a95ea7b17 100644
--- a/packages/backend/src/server/web/views/info-card.pug
+++ b/packages/backend/src/server/web/views/info-card.pug
@@ -43,7 +43,7 @@ html
}
body
- a#a(href=`https://${host}` target="_blank")
+ a#a(href=url target="_blank")
header#banner(style=`background-image: url(${meta.bannerUrl})`)
div#title= meta.name || host
div#content
diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug
index 1470dbfbdf..4195ccc3a3 100644
--- a/packages/backend/src/server/web/views/oauth.pug
+++ b/packages/backend/src/server/web/views/oauth.pug
@@ -6,4 +6,6 @@ block meta
//- XXX: Remove navigation bar in auth page?
meta(name='misskey:oauth:transaction-id' content=transactionId)
meta(name='misskey:oauth:client-name' content=clientName)
+ if clientLogo
+ meta(name='misskey:oauth:client-logo' content=clientLogo)
meta(name='misskey:oauth:scope' content=scope)