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'};
}
const isUnclaimedAccount = !user.passwordHash;
const isUnclaimedAccount = user.isUnclaimedAccount();
if (isUnclaimedAccount && !hasMfa) {
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 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');
}

View File

@ -166,7 +166,7 @@ export const MessageInteractionController = (app: HonoApp) => {
const sessionId = ctx.req.valid('query').session_id;
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');
}

View File

@ -73,7 +73,7 @@ export class CallService {
}
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) {
return {ringable: false};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@ export class UserAccountSecurityService {
invalidateAuthSessions: false,
};
const isUnclaimedAccount = !user.passwordHash;
const isUnclaimedAccount = user.isUnclaimedAccount();
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
const identityVerifiedViaPassword = sudoContext?.method === 'password';
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);
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) {
if (senderUser && senderUser.isUnclaimedAccount()) {
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(
userId,
recipientId,

View File

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

View File

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

View File

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

View File

@ -20,14 +20,13 @@
package integration
import (
"encoding/json"
"net/http"
"testing"
)
// TestUnclaimedAccountCannotReceiveDM verifies that claimed accounts
// cannot send DMs to unclaimed accounts.
func TestUnclaimedAccountCannotReceiveDM(t *testing.T) {
// TestUnclaimedAccountCanReceiveDM verifies that claimed accounts can send DMs
// to unclaimed accounts.
func TestUnclaimedAccountCanReceiveDM(t *testing.T) {
client := newTestClient(t)
claimedAccount := createTestAccount(t, client)
@ -47,21 +46,13 @@ func TestUnclaimedAccountCannotReceiveDM(t *testing.T) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for DM to unclaimed account, got %d: %s", resp.StatusCode, readResponseBody(resp))
assertStatus(t, resp, http.StatusOK)
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
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")
t.Log("Unclaimed account can receive DM test passed")
}