initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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);
},
);
};

View 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);
},
);
};

View 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);
},
);
};

View 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);
},
);
};

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

View 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}));
},
);
};

View File

@@ -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);
},
);
};