fix(api): exempt bots from being considered unclaimed users (#45)

This commit is contained in:
hampus-fluxer 2026-01-06 03:45:28 +01:00 committed by GitHub
parent 1cef2290fe
commit 6f21a7e37b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 45 additions and 51 deletions

View File

@ -97,7 +97,7 @@ async function verifySudoMode(
return {verified: true, sudoToken, method: 'mfa'}; return {verified: true, sudoToken, method: 'mfa'};
} }
const isUnclaimedAccount = !user.passwordHash; const isUnclaimedAccount = user.isUnclaimedAccount();
if (isUnclaimedAccount && !hasMfa) { if (isUnclaimedAccount && !hasMfa) {
return {verified: true, method: 'password'}; return {verified: true, method: 'password'};
} }

View File

@ -306,7 +306,7 @@ export const MessageController = (app: HonoApp) => {
const channelId = createChannelID(ctx.req.valid('param').channel_id); const channelId = createChannelID(ctx.req.valid('param').channel_id);
const requestCache = ctx.get('requestCache'); const requestCache = ctx.get('requestCache');
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) { if (user.isUnclaimedAccount() && !isPersonalNotesChannel({userId: user.id, channelId})) {
throw new UnclaimedAccountRestrictedError('send messages'); throw new UnclaimedAccountRestrictedError('send messages');
} }

View File

@ -166,7 +166,7 @@ export const MessageInteractionController = (app: HonoApp) => {
const sessionId = ctx.req.valid('query').session_id; const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache'); const requestCache = ctx.get('requestCache');
if (!user.passwordHash && !isPersonalNotesChannel({userId: user.id, channelId})) { if (user.isUnclaimedAccount() && !isPersonalNotesChannel({userId: user.id, channelId})) {
throw new UnclaimedAccountRestrictedError('add reactions'); throw new UnclaimedAccountRestrictedError('add reactions');
} }

View File

@ -73,7 +73,7 @@ export class CallService {
} }
const caller = await this.userRepository.findUnique(userId); const caller = await this.userRepository.findUnique(userId);
const isUnclaimedCaller = caller != null && !caller.passwordHash && !caller.isBot; const isUnclaimedCaller = caller?.isUnclaimedAccount() ?? false;
if (isUnclaimedCaller && channel.type === ChannelTypes.DM) { if (isUnclaimedCaller && channel.type === ChannelTypes.DM) {
return {ringable: false}; return {ringable: false};
} }

View File

@ -35,17 +35,13 @@ export class DMPermissionValidator {
async validate({recipients, userId}: {recipients: Array<User>; userId: UserID}): Promise<void> { async validate({recipients, userId}: {recipients: Array<User>; userId: UserID}): Promise<void> {
const senderUser = await this.deps.userRepository.findUnique(userId); const senderUser = await this.deps.userRepository.findUnique(userId);
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) { if (senderUser && senderUser.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('send direct messages'); throw new UnclaimedAccountRestrictedError('send direct messages');
} }
const targetUser = recipients.find((recipient) => recipient.id !== userId); const targetUser = recipients.find((recipient) => recipient.id !== userId);
if (!targetUser) return; if (!targetUser) return;
if (!targetUser.passwordHash && !targetUser.isBot) {
throw new UnclaimedAccountRestrictedError('receive direct messages');
}
const senderBlockedTarget = await this.deps.userRepository.getRelationship( const senderBlockedTarget = await this.deps.userRepository.getRelationship(
userId, userId,
targetUser.id, targetUser.id,

View File

@ -51,7 +51,7 @@ import type {
PackInviteMetadataResponse, PackInviteMetadataResponse,
} from '~/invite/InviteModel'; } from '~/invite/InviteModel';
import {Logger} from '~/Logger'; import {Logger} from '~/Logger';
import type {Channel, Invite} from '~/Models'; import {Channel, Invite} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware'; import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {PackRepository, PackType} from '~/pack/PackRepository'; import type {PackRepository, PackType} from '~/pack/PackRepository';
import type {PackService} from '~/pack/PackService'; import type {PackService} from '~/pack/PackService';
@ -352,7 +352,7 @@ export class InviteService {
if (!invite.channelId) throw new UnknownInviteError(); if (!invite.channelId) throw new UnknownInviteError();
const user = await this.userRepository.findUnique(userId); const user = await this.userRepository.findUnique(userId);
if (user && !user.passwordHash) { if (user && user.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('join group DMs'); throw new UnclaimedAccountRestrictedError('join group DMs');
} }
@ -376,7 +376,7 @@ export class InviteService {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(inviteCode);
} }
return invite; return this.cloneInviteWithUses(invite, newUses);
} }
if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) { if (invite.type === InviteTypes.EMOJI_PACK || invite.type === InviteTypes.STICKER_PACK) {
@ -389,7 +389,7 @@ export class InviteService {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(inviteCode);
} }
return invite; return this.cloneInviteWithUses(invite, newUses);
} }
if (!invite.guildId) throw new UnknownInviteError(); if (!invite.guildId) throw new UnknownInviteError();
@ -406,7 +406,7 @@ export class InviteService {
if (guild.features.has(GuildFeatures.DISALLOW_UNCLAIMED_ACCOUNTS)) { if (guild.features.has(GuildFeatures.DISALLOW_UNCLAIMED_ACCOUNTS)) {
const user = await this.userRepository.findUnique(userId); const user = await this.userRepository.findUnique(userId);
if (user && !user.passwordHash) { if (user && user.isUnclaimedAccount()) {
throw new GuildDisallowsUnclaimedAccountsError(); throw new GuildDisallowsUnclaimedAccountsError();
} }
} }
@ -455,7 +455,15 @@ export class InviteService {
await this.inviteRepository.delete(inviteCode); await this.inviteRepository.delete(inviteCode);
} }
return invite; return this.cloneInviteWithUses(invite, newUses);
}
private cloneInviteWithUses(invite: Invite, uses: number): Invite {
const row = invite.toRow();
return new Invite({
...row,
uses,
});
} }
async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise<void> { async deleteInvite({userId, inviteCode}: DeleteInviteParams, auditLogReason?: string | null): Promise<void> {

View File

@ -139,6 +139,10 @@ export class User {
return checkIsPremium(this); return checkIsPremium(this);
} }
isUnclaimedAccount(): boolean {
return this.passwordHash === null && !this.isBot;
}
canUseGlobalExpressions(): boolean { canUseGlobalExpressions(): boolean {
return this.isPremium() || this.isBot; return this.isPremium() || this.isBot;
} }

View File

@ -135,7 +135,7 @@ export class ApplicationService {
const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId); const owner = await this.deps.userRepository.findUniqueAssert(args.ownerUserId);
const botIsPublic = args.botPublic ?? true; const botIsPublic = args.botPublic ?? true;
if (!owner.passwordHash && !owner.isBot) { if (owner.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('create applications'); throw new UnclaimedAccountRestrictedError('create applications');
} }

View File

@ -218,7 +218,7 @@ export class StripeCheckoutService {
} }
validateUserCanPurchase(user: User): void { validateUserCanPurchase(user: User): void {
if (!user.passwordHash && !user.isBot) { if (user.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('make purchases'); throw new UnclaimedAccountRestrictedError('make purchases');
} }

View File

@ -90,7 +90,7 @@ const requiresSensitiveUserVerification = (
data: UserUpdateRequest, data: UserUpdateRequest,
emailTokenProvided: boolean, emailTokenProvided: boolean,
): boolean => { ): boolean => {
const isUnclaimed = !user.passwordHash; const isUnclaimed = user.isUnclaimedAccount();
const usernameChanged = data.username !== undefined && data.username !== user.username; const usernameChanged = data.username !== undefined && data.username !== user.username;
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator; const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
const emailChanged = data.email !== undefined && data.email !== user.email; const emailChanged = data.email !== undefined && data.email !== user.email;
@ -204,7 +204,7 @@ export const UserAccountController = (app: HonoApp) => {
throw InputValidationError.create('email', 'Email must be changed via email_token'); throw InputValidationError.create('email', 'Email must be changed via email_token');
} }
const emailTokenProvided = emailToken !== undefined; const emailTokenProvided = emailToken !== undefined;
const isUnclaimed = !user.passwordHash; const isUnclaimed = user.isUnclaimedAccount();
if (isUnclaimed) { if (isUnclaimed) {
const {username: _ignoredUsername, discriminator: _ignoredDiscriminator, ...rest} = userUpdateData; const {username: _ignoredUsername, discriminator: _ignoredDiscriminator, ...rest} = userUpdateData;
userUpdateData = rest; userUpdateData = rest;

View File

@ -60,7 +60,7 @@ export class EmailChangeService {
) {} ) {}
async start(user: User): Promise<StartEmailChangeResult> { async start(user: User): Promise<StartEmailChangeResult> {
const isUnclaimed = !user.passwordHash; const isUnclaimed = user.isUnclaimedAccount();
const hasEmail = !!user.email; const hasEmail = !!user.email;
if (!hasEmail && !isUnclaimed) { if (!hasEmail && !isUnclaimed) {
throw InputValidationError.create('email', 'You must have an email to change it.'); throw InputValidationError.create('email', 'You must have an email to change it.');

View File

@ -60,7 +60,7 @@ export class UserAccountSecurityService {
invalidateAuthSessions: false, invalidateAuthSessions: false,
}; };
const isUnclaimedAccount = !user.passwordHash; const isUnclaimedAccount = user.isUnclaimedAccount();
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token'; const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext?.method === 'password'; const identityVerifiedViaPassword = sudoContext?.method === 'password';
const hasMfa = userHasMfa(user); const hasMfa = userHasMfa(user);

View File

@ -480,17 +480,12 @@ export class UserChannelService {
} }
} }
private async validateDmPermission(userId: UserID, recipientId: UserID, recipientUser?: User | null): Promise<void> { private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise<void> {
const senderUser = await this.userAccountRepository.findUnique(userId); const senderUser = await this.userAccountRepository.findUnique(userId);
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) { if (senderUser && senderUser.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('send direct messages'); throw new UnclaimedAccountRestrictedError('send direct messages');
} }
const resolvedRecipient = recipientUser ?? (await this.userAccountRepository.findUnique(recipientId));
if (resolvedRecipient && !resolvedRecipient.passwordHash && !resolvedRecipient.isBot) {
throw new UnclaimedAccountRestrictedError('receive direct messages');
}
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship( const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
userId, userId,
recipientId, recipientId,

View File

@ -121,7 +121,7 @@ export class UserContentService {
throw new UnknownUserError(); throw new UnknownUserError();
} }
if (!user.passwordHash && !user.isBot) { if (user.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('create beta codes'); throw new UnclaimedAccountRestrictedError('create beta codes');
} }

View File

@ -137,7 +137,7 @@ export class UserRelationshipService {
requestCache: RequestCache; requestCache: RequestCache;
}): Promise<Relationship> { }): Promise<Relationship> {
const user = await this.userAccountRepository.findUnique(userId); const user = await this.userAccountRepository.findUnique(userId);
if (user && !user.passwordHash) { if (user && user.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('accept friend requests'); throw new UnclaimedAccountRestrictedError('accept friend requests');
} }
@ -341,7 +341,7 @@ export class UserRelationshipService {
} }
const requesterUser = await this.userAccountRepository.findUnique(userId); const requesterUser = await this.userAccountRepository.findUnique(userId);
if (requesterUser && !requesterUser.passwordHash) { if (requesterUser && requesterUser.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('send friend requests'); throw new UnclaimedAccountRestrictedError('send friend requests');
} }

View File

@ -116,7 +116,7 @@ export class VoiceService {
throw new UnknownChannelError(); throw new UnknownChannelError();
} }
const isUnclaimed = !user.passwordHash && !user.isBot; const isUnclaimed = user.isUnclaimedAccount();
if (isUnclaimed) { if (isUnclaimed) {
if (channel.type === ChannelTypes.DM) { if (channel.type === ChannelTypes.DM) {
throw new UnclaimedAccountRestrictedError('join 1:1 voice calls'); throw new UnclaimedAccountRestrictedError('join 1:1 voice calls');

View File

@ -20,14 +20,13 @@
package integration package integration
import ( import (
"encoding/json"
"net/http" "net/http"
"testing" "testing"
) )
// TestUnclaimedAccountCannotReceiveDM verifies that claimed accounts // TestUnclaimedAccountCanReceiveDM verifies that claimed accounts can send DMs
// cannot send DMs to unclaimed accounts. // to unclaimed accounts.
func TestUnclaimedAccountCannotReceiveDM(t *testing.T) { func TestUnclaimedAccountCanReceiveDM(t *testing.T) {
client := newTestClient(t) client := newTestClient(t)
claimedAccount := createTestAccount(t, client) claimedAccount := createTestAccount(t, client)
@ -47,21 +46,13 @@ func TestUnclaimedAccountCannotReceiveDM(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest { assertStatus(t, resp, http.StatusOK)
t.Fatalf("expected 400 for DM to unclaimed account, got %d: %s", resp.StatusCode, readResponseBody(resp))
var channel minimalChannelResponse
decodeJSONResponse(t, resp, &channel)
if channel.ID == "" {
t.Fatalf("expected channel ID in response")
} }
// Verify the error code is UNCLAIMED_ACCOUNT_RESTRICTED t.Log("Unclaimed account can receive DM test passed")
var errorResp struct {
Code string `json:"code"`
}
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
t.Fatalf("failed to decode error response: %v", err)
}
if errorResp.Code != "UNCLAIMED_ACCOUNT_RESTRICTED" {
t.Fatalf("expected error code UNCLAIMED_ACCOUNT_RESTRICTED, got %s", errorResp.Code)
}
t.Log("Unclaimed account cannot receive DM test passed")
} }