fluxer/fluxer_api/src/oauth/OAuth2ApplicationsController.ts
2026-01-01 21:05:54 +00:00

352 lines
11 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {HonoApp, HonoEnv} from '~/App';
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
import {createApplicationID} from '~/BrandedTypes';
import {AVATAR_MAX_SIZE} from '~/Constants';
import {AccessDeniedError, BotUserNotFoundError, InvalidClientError, UnknownApplicationError} from '~/Errors';
import {UsernameNotAvailableError} from '~/infrastructure/DiscriminatorService';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
import type {Application} from '~/models/Application';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {
createBase64StringType,
createStringType,
DiscriminatorType,
SudoVerificationSchema,
UsernameType,
z,
} from '~/Schema';
import {Validator} from '~/Validator';
import {ApplicationNotOwnedError} from './ApplicationService';
import {mapApplicationToResponse, mapBotProfileToResponse, mapBotTokenResetResponse} from './OAuth2Mappers';
import {OAuth2RedirectURICreateType, OAuth2RedirectURIUpdateType} from './OAuth2RedirectURI';
export const OAuth2ApplicationsController = (app: HonoApp) => {
const listApplicationsHandler = async (ctx: Context<HonoEnv>) => {
const userId = ctx.get('user').id;
const applications: Array<Application> = await ctx.get('applicationService').listApplicationsByOwner(userId);
const botUserIds = applications
.filter((app: Application) => app.hasBotUser())
.map((app: Application) => app.getBotUserId()!);
const botUsers = await Promise.all(botUserIds.map((botUserId) => ctx.get('userRepository').findUnique(botUserId)));
const botUserMap = new Map(botUsers.filter((u) => u !== null).map((u) => [u!.id, u!]));
return ctx.json(
applications.map((app: Application) => {
const botUser = app.hasBotUser() && app.getBotUserId() ? botUserMap.get(app.getBotUserId()!) : null;
return mapApplicationToResponse(app, {botUser: botUser ?? undefined});
}),
);
};
app.get(
'/users/@me/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
listApplicationsHandler,
);
app.get(
'/oauth2/applications/@me',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
listApplicationsHandler,
);
app.post(
'/oauth2/applications',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_CREATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
name: createStringType(1, 100),
redirect_uris: z
.array(OAuth2RedirectURICreateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => value ?? []),
bot_public: z.boolean().optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const body = ctx.req.valid('json');
const result = await ctx.get('applicationService').createApplication({
ownerUserId: userId,
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
});
return ctx.json(
mapApplicationToResponse(result.application, {
botUser: result.botUser,
botToken: result.botToken,
clientSecret: result.clientSecret,
}),
);
},
);
app.get(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
if (application.ownerUserId !== userId) {
throw new AccessDeniedError();
}
let botUser = null;
if (application.hasBotUser()) {
const botUserId = application.getBotUserId();
if (botUserId) {
botUser = await ctx.get('userRepository').findUnique(botUserId);
}
}
return ctx.json(mapApplicationToResponse(application, {botUser}));
},
);
app.patch(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
name: createStringType(1, 100).optional(),
redirect_uris: z
.array(OAuth2RedirectURIUpdateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => (value === undefined ? undefined : (value ?? []))),
bot_public: z.boolean().optional(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const body = ctx.req.valid('json');
try {
const updated = await ctx.get('applicationService').updateApplication({
userId,
applicationId,
name: body.name,
redirectUris: body.redirect_uris,
botPublic: body.bot_public,
});
let botUser = null;
if (updated.hasBotUser()) {
const botUserId = updated.getBotUserId();
if (botUserId) {
botUser = await ctx.get('userRepository').findUnique(botUserId);
}
}
return ctx.json(mapApplicationToResponse(updated, {botUser: botUser ?? undefined}));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
},
);
app.delete(
'/oauth2/applications/:id',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_DELETE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
await ctx.get('applicationService').deleteApplication(user.id, applicationId);
return ctx.body(null, 204);
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof UnknownApplicationError) {
throw err;
}
throw err;
}
},
);
app.post(
'/oauth2/applications/:id/bot/reset-token',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {token} = await ctx.get('applicationService').rotateBotToken(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application || !application.botUserId) {
throw new BotUserNotFoundError();
}
const botUser = await ctx.get('userRepository').findUnique(application.botUserId);
if (!botUser) {
throw new BotUserNotFoundError();
}
return ctx.json(mapBotTokenResetResponse(botUser, token));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.post(
'/oauth2/applications/:id/client-secret/reset',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_ROTATE_SECRET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', SudoVerificationSchema),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
try {
const {clientSecret} = await ctx.get('applicationService').rotateClientSecret(user.id, applicationId);
const application = await ctx.get('applicationRepository').getApplication(applicationId);
if (!application) {
throw new UnknownApplicationError();
}
return ctx.json(mapApplicationToResponse(application, {clientSecret}));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
throw err;
}
},
);
app.patch(
'/oauth2/applications/:id/bot',
RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENT_UPDATE),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
Validator(
'json',
z.object({
username: UsernameType.optional(),
discriminator: DiscriminatorType.optional(),
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
bio: createStringType(0, 1024).nullish(),
}),
),
async (ctx) => {
const userId = ctx.get('user').id;
const applicationId = createApplicationID(BigInt(ctx.req.param('id')));
const body = ctx.req.valid('json');
try {
const result = await ctx.get('applicationService').updateBotProfile(userId, applicationId, {
username: body.username,
discriminator: body.discriminator,
avatar: body.avatar,
banner: body.banner,
bio: body.bio,
});
return ctx.json(mapBotProfileToResponse(result.user));
} catch (err) {
if (err instanceof ApplicationNotOwnedError) {
throw new AccessDeniedError();
}
if (err instanceof BotUserNotFoundError) {
throw err;
}
if (err instanceof InvalidClientError || err instanceof UnknownApplicationError) {
throw new UnknownApplicationError();
}
if (err instanceof UsernameNotAvailableError) {
throw err;
}
throw err;
}
},
);
};