initial commit
This commit is contained in:
711
fluxer_api/src/user/controllers/UserAccountController.ts
Normal file
711
fluxer_api/src/user/controllers/UserAccountController.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
/*
|
||||
* 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, type SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import {createChannelID, createGuildID, createUserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {
|
||||
AccountSuspiciousActivityError,
|
||||
InputValidationError,
|
||||
MissingAccessError,
|
||||
UnauthorizedError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {
|
||||
createStringType,
|
||||
DiscriminatorType,
|
||||
Int64Type,
|
||||
QueryBooleanType,
|
||||
SudoVerificationSchema,
|
||||
URLType,
|
||||
UsernameType,
|
||||
z,
|
||||
} from '~/Schema';
|
||||
import {getCachedUserPartialResponse, mapUserToPartialResponseWithCache} from '~/user/UserCacheHelpers';
|
||||
import {
|
||||
mapGuildMemberToProfileResponse,
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToOAuthResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const EmailTokenType = createStringType(1, 256);
|
||||
|
||||
const UserUpdateWithVerificationRequest = UserUpdateRequest.merge(
|
||||
z.object({
|
||||
email_token: EmailTokenType.optional(),
|
||||
}),
|
||||
)
|
||||
.merge(SudoVerificationSchema)
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.email !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Email must be changed via email_token',
|
||||
path: ['email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
|
||||
type UserUpdatePayload = Omit<
|
||||
UserUpdateWithVerificationRequestData,
|
||||
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
|
||||
>;
|
||||
|
||||
const requiresSensitiveUserVerification = (
|
||||
user: User,
|
||||
data: UserUpdateRequest,
|
||||
emailTokenProvided: boolean,
|
||||
): boolean => {
|
||||
const isUnclaimed = !user.passwordHash;
|
||||
const usernameChanged = data.username !== undefined && data.username !== user.username;
|
||||
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
|
||||
const emailChanged = data.email !== undefined && data.email !== user.email;
|
||||
const newPasswordProvided = data.new_password !== undefined;
|
||||
|
||||
if (isUnclaimed) {
|
||||
return usernameChanged || discriminatorChanged;
|
||||
}
|
||||
|
||||
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
|
||||
};
|
||||
|
||||
const EmailChangeTicketSchema = z.object({
|
||||
ticket: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeCodeSchema = EmailChangeTicketSchema.extend({
|
||||
code: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeRequestNewSchema = EmailChangeTicketSchema.extend({
|
||||
new_email: createStringType(),
|
||||
original_proof: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeVerifyNewSchema = EmailChangeCodeSchema.extend({
|
||||
original_proof: createStringType(),
|
||||
});
|
||||
|
||||
export const UserAccountController = (app: HonoApp) => {
|
||||
const enforceUserAccess = (user: User): void => {
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
|
||||
}
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) !== 0n) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreloadMessages = async (ctx: Context<HonoEnv>, channels: ReadonlyArray<bigint>) => {
|
||||
const channelIds = channels.map(createChannelID);
|
||||
|
||||
const messages = await ctx.get('userService').preloadDMMessages({
|
||||
userId: ctx.get('user').id,
|
||||
channelIds,
|
||||
});
|
||||
|
||||
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
|
||||
const mappedMessage = message
|
||||
? await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
currentUserId: ctx.get('user').id,
|
||||
})
|
||||
: null;
|
||||
return [channelId, mappedMessage] as const;
|
||||
});
|
||||
|
||||
const mappedEntries = await Promise.all(mappingPromises);
|
||||
const mappedMessages = Object.fromEntries(mappedEntries);
|
||||
|
||||
return ctx.json(mappedMessages);
|
||||
};
|
||||
|
||||
app.get('/users/@me', RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET), async (ctx) => {
|
||||
const tokenType = ctx.get('authTokenType');
|
||||
|
||||
if (tokenType === 'bearer') {
|
||||
const scopes = ctx.get('oauthBearerScopes');
|
||||
const bearerUser = ctx.get('user');
|
||||
if (!scopes || !bearerUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
enforceUserAccess(bearerUser);
|
||||
const includeEmail = scopes.has('email');
|
||||
return ctx.json(mapUserToOAuthResponse(bearerUser, {includeEmail}));
|
||||
}
|
||||
|
||||
const maybeUser = ctx.get('user');
|
||||
if (maybeUser) {
|
||||
enforceUserAccess(maybeUser);
|
||||
return ctx.json(mapUserToPrivateResponse(maybeUser));
|
||||
}
|
||||
|
||||
throw new UnauthorizedError();
|
||||
});
|
||||
|
||||
app.patch(
|
||||
'/users/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_UPDATE_SELF),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', UserUpdateWithVerificationRequest),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const oldEmail = user.email;
|
||||
const rawBody: UserUpdateWithVerificationRequestData = ctx.req.valid('json');
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
webauthn_response: _webauthnResponse,
|
||||
webauthn_challenge: _webauthnChallenge,
|
||||
email_token: emailToken,
|
||||
...userUpdateDataRest
|
||||
} = rawBody;
|
||||
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
|
||||
if (userUpdateData.email !== undefined) {
|
||||
throw InputValidationError.create('email', 'Email must be changed via email_token');
|
||||
}
|
||||
const emailTokenProvided = emailToken !== undefined;
|
||||
const isUnclaimed = !user.passwordHash;
|
||||
if (isUnclaimed) {
|
||||
const allowed = new Set(['new_password']);
|
||||
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
|
||||
if (disallowedField) {
|
||||
throw InputValidationError.create(
|
||||
disallowedField,
|
||||
'Unclaimed accounts can only set a new email via email_token and a new password',
|
||||
);
|
||||
}
|
||||
}
|
||||
let emailFromToken: string | null = null;
|
||||
let emailVerifiedViaToken = false;
|
||||
|
||||
const needsVerification = requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
|
||||
let sudoResult: SudoVerificationResult | null = null;
|
||||
if (needsVerification) {
|
||||
sudoResult = await requireSudoMode(ctx, user, rawBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
}
|
||||
|
||||
if (emailTokenProvided && emailToken) {
|
||||
emailFromToken = await ctx.get('emailChangeService').consumeToken(user.id, emailToken);
|
||||
userUpdateData = {...userUpdateData, email: emailFromToken};
|
||||
emailVerifiedViaToken = true;
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.get('userService').update({
|
||||
user,
|
||||
oldAuthSession: ctx.get('authSession'),
|
||||
data: userUpdateData,
|
||||
request: ctx.req.raw,
|
||||
sudoContext: sudoResult ?? undefined,
|
||||
emailVerifiedViaToken,
|
||||
});
|
||||
|
||||
if (
|
||||
emailFromToken &&
|
||||
oldEmail &&
|
||||
updatedUser.email &&
|
||||
oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()
|
||||
) {
|
||||
try {
|
||||
await ctx.get('authService').issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
|
||||
} catch (error) {
|
||||
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
|
||||
}
|
||||
}
|
||||
return ctx.json(mapUserToPrivateResponse(updatedUser));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/start',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_START),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({}).optional()),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const result = await ctx.get('emailChangeService').start(user);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/resend-original',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_ORIGINAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeTicketSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await ctx.get('emailChangeService').resendOriginal(user, body.ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/verify-original',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_ORIGINAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeCodeSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await ctx.get('emailChangeService').verifyOriginal(user, body.ticket, body.code);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/request-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_REQUEST_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeRequestNewSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await ctx
|
||||
.get('emailChangeService')
|
||||
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/resend-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeTicketSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await ctx.get('emailChangeService').resendNew(user, body.ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/verify-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeVerifyNewSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const emailToken = await ctx
|
||||
.get('emailChangeService')
|
||||
.verifyNew(user, body.ticket, body.code, body.original_proof);
|
||||
return ctx.json({email_token: emailToken});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/check-tag',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHECK_TAG),
|
||||
LoginRequired,
|
||||
Validator('query', z.object({username: UsernameType, discriminator: DiscriminatorType})),
|
||||
async (ctx) => {
|
||||
const {username, discriminator} = ctx.req.valid('query');
|
||||
const currentUser = ctx.get('user');
|
||||
if (
|
||||
username.toLowerCase() === currentUser.username.toLowerCase() &&
|
||||
discriminator === currentUser.discriminator
|
||||
) {
|
||||
return ctx.json({taken: false});
|
||||
}
|
||||
const taken = await ctx.get('userService').checkUsernameDiscriminatorAvailability({username, discriminator});
|
||||
return ctx.json({taken});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userResponse = await getCachedUserPartialResponse({
|
||||
userId: createUserID(ctx.req.valid('param').user_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(userResponse);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/:target_id/profile',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GET_PROFILE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
guild_id: Int64Type.optional(),
|
||||
with_mutual_friends: QueryBooleanType,
|
||||
with_mutual_guilds: QueryBooleanType,
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {target_id} = ctx.req.valid('param');
|
||||
const {guild_id, with_mutual_friends, with_mutual_guilds} = ctx.req.valid('query');
|
||||
const currentUserId = ctx.get('user').id;
|
||||
const targetUserId = createUserID(target_id);
|
||||
const guildId = guild_id ? createGuildID(guild_id) : undefined;
|
||||
|
||||
const profile = await ctx.get('userService').getUserProfile({
|
||||
userId: currentUserId,
|
||||
targetId: targetUserId,
|
||||
guildId,
|
||||
withMutualFriends: with_mutual_friends,
|
||||
withMutualGuilds: with_mutual_guilds,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
|
||||
const userProfile = mapUserToProfileResponse(profile.user);
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
|
||||
|
||||
const mutualFriends = profile.mutualFriends
|
||||
? await Promise.all(
|
||||
profile.mutualFriends.map((user) =>
|
||||
mapUserToPartialResponseWithCache({
|
||||
user,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ctx.json({
|
||||
user: await mapUserToPartialResponseWithCache({
|
||||
user: profile.user,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
user_profile: userProfile,
|
||||
guild_member: profile.guildMember ?? undefined,
|
||||
guild_member_profile: guildMemberProfile ?? undefined,
|
||||
premium_type: profile.premiumType,
|
||||
premium_since: profile.premiumSince?.toISOString(),
|
||||
premium_lifetime_sequence: profile.premiumLifetimeSequence,
|
||||
mutual_friends: mutualFriends,
|
||||
mutual_guilds: profile.mutualGuilds,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const settings = await ctx.get('userService').findSettings(ctx.get('user').id);
|
||||
return ctx.json(mapUserSettingsToResponse({settings}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', UserSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const updatedSettings = await ctx.get('userService').updateSettings({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(
|
||||
mapUserSettingsToResponse({
|
||||
settings: updatedSettings,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/notes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const notes = await ctx.get('userService').getUserNotes(ctx.get('user').id);
|
||||
return ctx.json(notes);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/notes/:target_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const note = await ctx.get('userService').getUserNote({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').target_id),
|
||||
});
|
||||
if (!note) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
return ctx.json(note);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/notes/:target_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
Validator('json', z.object({note: createStringType(1, 256).nullish()})),
|
||||
async (ctx) => {
|
||||
const {target_id} = ctx.req.valid('param');
|
||||
const {note} = ctx.req.valid('json');
|
||||
await ctx.get('userService').setUserNote({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(target_id),
|
||||
note: note ?? null,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/guilds/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', UserGuildSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const settings = await ctx.get('userService').updateGuildSettings({
|
||||
userId: ctx.get('user').id,
|
||||
guildId: null,
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(mapUserGuildSettingsToResponse(settings));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/guilds/:guild_id/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({guild_id: Int64Type})),
|
||||
Validator('json', UserGuildSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const settings = await ctx.get('userService').updateGuildSettings({
|
||||
userId: ctx.get('user').id,
|
||||
guildId: createGuildID(guild_id),
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(mapUserGuildSettingsToResponse(settings));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const userService = ctx.get('userService');
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await userService.selfDisable(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const userService = ctx.get('userService');
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await userService.selfDelete(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/push/subscribe',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_SUBSCRIBE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
endpoint: URLType,
|
||||
keys: z.object({
|
||||
p256dh: createStringType(1, 1024),
|
||||
auth: createStringType(1, 1024),
|
||||
}),
|
||||
user_agent: createStringType(1, 1024).optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const subscription = await ctx.get('userService').registerPushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/push/subscriptions',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const subscriptions = await ctx.get('userService').listPushSubscriptions(ctx.get('user').id);
|
||||
return ctx.json({
|
||||
subscriptions: subscriptions.map((sub) => ({
|
||||
subscription_id: sub.subscriptionId,
|
||||
user_agent: sub.userAgent,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/push/subscriptions/:subscription_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_UNSUBSCRIBE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({subscription_id: createStringType(1, 256)})),
|
||||
async (ctx) => {
|
||||
const {subscription_id} = ctx.req.valid('param');
|
||||
await ctx.get('userService').deletePushSubscription(ctx.get('user').id, subscription_id);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/preload-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
|
||||
LoginRequired,
|
||||
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
|
||||
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/channels/messages/preload',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
|
||||
LoginRequired,
|
||||
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
|
||||
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
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'));
|
||||
await ctx.get('userService').requestBulkMessageDeletion({userId: user.id});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await ctx.get('userService').cancelBulkMessageDeletion(user.id);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/messages/delete/test',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
if (!(user.flags & UserFlags.STAFF)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
await ctx.get('userService').requestBulkMessageDeletion({
|
||||
userId: user.id,
|
||||
delayMs: 60 * 1000,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
331
fluxer_api/src/user/controllers/UserAuthController.ts
Normal file
331
fluxer_api/src/user/controllers/UserAuthController.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* 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 {RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {HonoApp} from '~/App';
|
||||
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, PasswordType, PhoneNumberType, SudoVerificationSchema, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const DisableTotpSchema = z
|
||||
.object({code: createStringType(), password: PasswordType.optional()})
|
||||
.merge(SudoVerificationSchema);
|
||||
|
||||
const MfaBackupCodesSchema = z
|
||||
.object({regenerate: z.boolean(), password: PasswordType.optional()})
|
||||
.merge(SudoVerificationSchema);
|
||||
|
||||
export const UserAuthController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({secret: createStringType(), code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {secret, code} = ctx.req.valid('json');
|
||||
const backupCodes = await ctx.get('userService').enableMfaTotp({
|
||||
user: ctx.get('user'),
|
||||
secret,
|
||||
code,
|
||||
});
|
||||
return ctx.json({
|
||||
backup_codes: backupCodes.map((bc) => ({
|
||||
code: bc.code,
|
||||
consumed: bc.consumed,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', DisableTotpSchema),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userService').disableMfaTotp({
|
||||
user,
|
||||
code: body.code,
|
||||
sudoContext: sudoResult,
|
||||
password: body.password,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/backup-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', MfaBackupCodesSchema),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
const backupCodes = await ctx.get('userService').getMfaBackupCodes({
|
||||
user,
|
||||
regenerate: body.regenerate,
|
||||
sudoContext: sudoResult,
|
||||
password: body.password,
|
||||
});
|
||||
return ctx.json({
|
||||
backup_codes: backupCodes.map((bc) => ({
|
||||
code: bc.code,
|
||||
consumed: bc.consumed,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/send-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({phone: PhoneNumberType})),
|
||||
async (ctx) => {
|
||||
const {phone} = ctx.req.valid('json');
|
||||
await ctx.get('authService').sendPhoneVerificationCode(phone, ctx.get('user').id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/verify',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_VERIFY_CODE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({phone: PhoneNumberType, code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {phone, code} = ctx.req.valid('json');
|
||||
const phoneToken = await ctx.get('authService').verifyPhoneCode(phone, code, ctx.get('user').id);
|
||||
return ctx.json({phone_token: phoneToken});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_ADD),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', z.object({phone_token: createStringType()}).merge(SudoVerificationSchema)),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {phone_token, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').addPhoneToAccount(user.id, phone_token);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_REMOVE),
|
||||
LoginRequired,
|
||||
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'));
|
||||
await ctx.get('authService').removePhoneFromAccount(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_ENABLE),
|
||||
LoginRequired,
|
||||
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'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('authService').enableSmsMfa(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_DISABLE),
|
||||
LoginRequired,
|
||||
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'));
|
||||
await ctx.get('authService').disableSmsMfa(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const credentials = await ctx.get('userRepository').listWebAuthnCredentials(ctx.get('user').id);
|
||||
return ctx.json(
|
||||
credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
|
||||
LoginRequired,
|
||||
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'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
const options = await ctx.get('authService').generateWebAuthnRegistrationOptions(user.id);
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator(
|
||||
'json',
|
||||
z
|
||||
.object({
|
||||
response: z.custom<RegistrationResponseJSON>(),
|
||||
challenge: createStringType(),
|
||||
name: createStringType(1, 100),
|
||||
})
|
||||
.merge(SudoVerificationSchema),
|
||||
),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {response, challenge, name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('authService').verifyWebAuthnRegistration(user.id, response, challenge, name);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({credential_id: createStringType()})),
|
||||
Validator('json', z.object({name: createStringType(1, 100)}).merge(SudoVerificationSchema)),
|
||||
SudoModeMiddleware,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const {name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').renameWebAuthnCredential(user.id, credential_id, name);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({credential_id: createStringType()})),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').deleteWebAuthnCredential(user.id, credential_id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/sudo/mfa-methods',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const methods = await ctx.get('authMfaService').getAvailableMfaMethods(ctx.get('user').id);
|
||||
return ctx.json(methods);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/mfa/sms/send',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_SMS_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
await ctx.get('authService').sendSmsMfaCode(ctx.get('user').id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/webauthn/authentication-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const options = await ctx.get('authMfaService').generateWebAuthnOptionsForSudo(ctx.get('user').id);
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
};
|
||||
111
fluxer_api/src/user/controllers/UserChannelController.ts
Normal file
111
fluxer_api/src/user/controllers/UserChannelController.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 {HonoApp} from '~/App';
|
||||
import {createChannelID} from '~/BrandedTypes';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {CreatePrivateChannelRequest} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const UserChannelController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channels = await ctx.get('userService').getPrivateChannels(userId);
|
||||
const responses = await Promise.all(
|
||||
channels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', CreatePrivateChannelRequest),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channel = await ctx.get('userService').createOrOpenDMChannel({
|
||||
userId,
|
||||
data: ctx.req.valid('json'),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(
|
||||
await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('userService').pinDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('userService').unpinDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
266
fluxer_api/src/user/controllers/UserContentController.ts
Normal file
266
fluxer_api/src/user/controllers/UserContentController.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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 {HonoApp} from '~/App';
|
||||
import {createChannelID, createMessageID, type UserID} from '~/BrandedTypes';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createQueryIntegerType, createStringType, Int64Type, QueryBooleanType, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {mapBetaCodeToResponse} from '~/user/UserModel';
|
||||
import type {SavedMessageEntryResponse} from '~/user/UserTypes';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const createUserPartialResolver =
|
||||
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
|
||||
export const UserContentController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/beta-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const userService = ctx.get('userService');
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
|
||||
const [betaCodes, allowanceInfo] = await Promise.all([
|
||||
userService.getBetaCodes(userId),
|
||||
userService.getBetaCodeAllowanceInfo(userId),
|
||||
]);
|
||||
|
||||
const responses = await Promise.all(
|
||||
betaCodes.map((betaCode) => mapBetaCodeToResponse({betaCode, userPartialResolver})),
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
beta_codes: responses,
|
||||
allowance: allowanceInfo.allowance,
|
||||
next_reset_at: allowanceInfo.nextResetAt?.toISOString() ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/beta-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const betaCode = await ctx.get('userService').createBetaCode(ctx.get('user').id);
|
||||
return ctx.json(await mapBetaCodeToResponse({betaCode, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/beta-codes/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').deleteBetaCode({
|
||||
userId: ctx.get('user').id,
|
||||
code: ctx.req.valid('param').code,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/mentions',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}),
|
||||
roles: QueryBooleanType.optional().default(true),
|
||||
everyone: QueryBooleanType.optional().default(true),
|
||||
guilds: QueryBooleanType.optional().default(true),
|
||||
before: Int64Type.optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {limit, roles, everyone, guilds, before} = ctx.req.valid('query');
|
||||
const userId = ctx.get('user').id;
|
||||
const messages = await ctx.get('userService').getRecentMentions({
|
||||
userId,
|
||||
limit,
|
||||
everyone,
|
||||
roles,
|
||||
guilds,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
messages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mentions/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').deleteRecentMention({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('query', z.object({limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25})})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const entries = await ctx.get('userService').getSavedMessages({
|
||||
userId,
|
||||
limit: ctx.req.valid('query').limit,
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const response: SavedMessageEntryResponse = {
|
||||
id: entry.messageId.toString(),
|
||||
channel_id: entry.channelId.toString(),
|
||||
message_id: entry.messageId.toString(),
|
||||
status: entry.status,
|
||||
message: entry.message
|
||||
? await mapMessageToResponse({
|
||||
message: entry.message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
})
|
||||
: null,
|
||||
};
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
return ctx.json(responses, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('json');
|
||||
await ctx.get('userService').saveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(channel_id),
|
||||
messageId: createMessageID(message_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/saved-messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').unsaveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/harvest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_DATA_HARVEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('userService').requestDataHarvest(ctx.get('user').id);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/latest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_LATEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvest = await ctx.get('userService').getLatestHarvest(ctx.get('user').id);
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_STATUS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvestId = BigInt(ctx.req.param('harvestId'));
|
||||
const harvest = await ctx.get('userService').getHarvestStatus(ctx.get('user').id, harvestId);
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvestId = BigInt(ctx.req.param('harvestId'));
|
||||
const result = await ctx
|
||||
.get('userService')
|
||||
.getHarvestDownloadUrl(ctx.get('user').id, harvestId, ctx.get('storageService'));
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
};
|
||||
35
fluxer_api/src/user/controllers/UserController.ts
Normal file
35
fluxer_api/src/user/controllers/UserController.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {HonoApp} from '~/App';
|
||||
import {UserAccountController} from './UserAccountController';
|
||||
import {UserAuthController} from './UserAuthController';
|
||||
import {UserChannelController} from './UserChannelController';
|
||||
import {UserContentController} from './UserContentController';
|
||||
import {UserRelationshipController} from './UserRelationshipController';
|
||||
import {UserScheduledMessageController} from './UserScheduledMessageController';
|
||||
|
||||
export const UserController = (app: HonoApp) => {
|
||||
UserAccountController(app);
|
||||
UserAuthController(app);
|
||||
UserRelationshipController(app);
|
||||
UserChannelController(app);
|
||||
UserContentController(app);
|
||||
UserScheduledMessageController(app);
|
||||
};
|
||||
160
fluxer_api/src/user/controllers/UserRelationshipController.ts
Normal file
160
fluxer_api/src/user/controllers/UserRelationshipController.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 {HonoApp} from '~/App';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {
|
||||
FriendRequestByTagRequest,
|
||||
mapRelationshipToResponse,
|
||||
RelationshipNicknameUpdateRequest,
|
||||
} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const createUserPartialResolver =
|
||||
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
|
||||
export const UserRelationshipController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIPS_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationships = await ctx.get('userService').getRelationships(ctx.get('user').id);
|
||||
const responses = await Promise.all(
|
||||
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', FriendRequestByTagRequest),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationship = await ctx.get('userService').sendFriendRequestByTag({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationship = await ctx.get('userService').sendFriendRequest({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_ACCEPT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
Validator('json', z.object({type: z.number().optional()}).optional()),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const body = ctx.req.valid('json');
|
||||
const targetId = createUserID(ctx.req.valid('param').user_id);
|
||||
if (body?.type === RelationshipTypes.BLOCKED) {
|
||||
const relationship = await ctx.get('userService').blockUser({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
} else {
|
||||
const relationship = await ctx.get('userService').acceptFriendRequest({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').removeRelationship({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
Validator('json', RelationshipNicknameUpdateRequest),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const targetId = createUserID(ctx.req.valid('param').user_id);
|
||||
const requestBody = ctx.req.valid('json');
|
||||
const relationship = await ctx.get('userService').updateFriendNickname({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
nickname: requestBody.nickname ?? null,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 {createMessageID} from '~/BrandedTypes';
|
||||
import {parseScheduledMessageInput} from '~/channel/controllers/ScheduledMessageParsing';
|
||||
import {UnknownMessageError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const UserScheduledMessageController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx: Context<HonoEnv>) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessages = await scheduledMessageService.listScheduledMessages(userId);
|
||||
|
||||
return ctx.json(
|
||||
scheduledMessages.map((message) => message.toResponse()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessage = await scheduledMessageService.getScheduledMessage(userId, scheduledMessageId);
|
||||
|
||||
if (!scheduledMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
await scheduledMessageService.cancelScheduledMessage(userId, scheduledMessageId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
|
||||
const existingMessage = await scheduledMessageService.getScheduledMessage(user.id, scheduledMessageId);
|
||||
if (!existingMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const channelId = existingMessage.channelId;
|
||||
|
||||
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
|
||||
ctx,
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const scheduledMessage = await scheduledMessageService.updateScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
scheduledMessageId,
|
||||
existing: existingMessage,
|
||||
});
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user