feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {requireOAuth2BearerToken, requireOAuth2Scope} from '@app/api/middleware/OAuth2ScopeMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -124,6 +125,8 @@ export function OAuth2Controller(app: HonoApp) {
await ctx.get('oauth2RequestService').authorizeConsent({
body,
userId: user.id,
clientFeatures: ctx.get('clientFeatures'),
viewer: viewerFromCtx(ctx),
requestCache: ctx.get('requestCache'),
}),
);
@@ -5,7 +5,9 @@ import type {SudoVerificationBody} from '@app/api/auth/services/SudoVerification
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createApplicationID, createChannelID, createGuildID, createRoleID, type UserID} from '@app/api/BrandedTypes';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {guildActive, type ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
import type {GuildService} from '@app/api/guild/services/GuildService';
import {resolveProtectedBitActor, stripThreadPermissionBits} from '@app/api/guild/services/ThreadPermissionBits';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {ApplicationService} from '@app/api/oauth/ApplicationService';
@@ -26,7 +28,9 @@ import {isSignInRefused} from '@app/api/user/UserHelpers';
import {mapUserToOAuthResponse, mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {verifyPassword} from '@app/api/utils/PasswordUtils';
import {canAuthorizeBotInvite, normalizeBotInvitePermissions} from '@fluxer/constants/src/BotPermissionUtils';
import {ALL_PERMISSIONS} from '@fluxer/constants/src/ChannelConstants';
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
import {THREAD_AWARE_ALL_PERMISSIONS, withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InvalidPermissionsIntegerError} from '@fluxer/errors/src/domains/core/InvalidPermissionsIntegerError';
@@ -196,6 +200,8 @@ export class OAuth2RequestService {
async authorizeConsent(params: {
body: AuthorizeConsentRequest;
userId: UserID;
clientFeatures: ReadonlySet<string>;
viewer: ThreadViewer;
requestCache: RequestCache;
}): Promise<OAuth2ConsentResponse> {
const scopeStr = params.body.scope;
@@ -207,6 +213,7 @@ export class OAuth2RequestService {
if (guildId && channelId) {
throw InputValidationError.create('channel_id', 'channel_id cannot be used with guild_id');
}
const botPermissionMask = guildId && guildActive(guildId) ? THREAD_AWARE_ALL_PERMISSIONS : ALL_PERMISSIONS;
let requestedPermissions: bigint | null = null;
if (params.body.permissions !== undefined) {
try {
@@ -217,7 +224,17 @@ export class OAuth2RequestService {
if (requestedPermissions < 0) {
throw new InvalidPermissionsNegativeError();
}
requestedPermissions = normalizeBotInvitePermissions(requestedPermissions);
requestedPermissions = normalizeBotInvitePermissions(requestedPermissions, botPermissionMask);
if (guildId && botPermissionMask !== ALL_PERMISSIONS) {
const actor = await resolveProtectedBitActor({
guildId,
userId: params.userId,
clientFeatures: params.clientFeatures,
viewer: params.viewer,
isBot: async () => false,
});
if (!actor.threadBits?.writer) requestedPermissions = stripThreadPermissionBits(requestedPermissions);
}
}
if (!isBotOnly && responseType !== 'code') {
throw new InvalidResponseTypeForNonBotError();
@@ -258,8 +275,10 @@ export class OAuth2RequestService {
});
if (
!canAuthorizeBotInvite({
userPermissions,
userPermissions:
botPermissionMask === ALL_PERMISSIONS ? userPermissions : withImplicitThreadBits(userPermissions),
requestedPermissions,
mask: botPermissionMask,
})
) {
throw new MissingPermissionsError();