diff --git a/fluxer_api/src/auth/services/SudoVerificationService.ts b/fluxer_api/src/auth/services/SudoVerificationService.ts index cd7505bd..a4ed5f61 100644 --- a/fluxer_api/src/auth/services/SudoVerificationService.ts +++ b/fluxer_api/src/auth/services/SudoVerificationService.ts @@ -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'}; } diff --git a/fluxer_api/src/channel/controllers/MessageController.ts b/fluxer_api/src/channel/controllers/MessageController.ts index ed1a7c70..a5424178 100644 --- a/fluxer_api/src/channel/controllers/MessageController.ts +++ b/fluxer_api/src/channel/controllers/MessageController.ts @@ -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'); } diff --git a/fluxer_api/src/channel/controllers/MessageInteractionController.ts b/fluxer_api/src/channel/controllers/MessageInteractionController.ts index 979a2195..10ead9da 100644 --- a/fluxer_api/src/channel/controllers/MessageInteractionController.ts +++ b/fluxer_api/src/channel/controllers/MessageInteractionController.ts @@ -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'); } diff --git a/fluxer_api/src/channel/services/CallService.ts b/fluxer_api/src/channel/services/CallService.ts index 40a31296..1bd52208 100644 --- a/fluxer_api/src/channel/services/CallService.ts +++ b/fluxer_api/src/channel/services/CallService.ts @@ -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}; } diff --git a/fluxer_api/src/channel/services/DMPermissionValidator.ts b/fluxer_api/src/channel/services/DMPermissionValidator.ts index f47d8830..eeb04cf1 100644 --- a/fluxer_api/src/channel/services/DMPermissionValidator.ts +++ b/fluxer_api/src/channel/services/DMPermissionValidator.ts @@ -35,17 +35,13 @@ export class DMPermissionValidator { async validate({recipients, userId}: {recipients: Array; userId: UserID}): Promise { 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, diff --git a/fluxer_api/src/invite/InviteService.ts b/fluxer_api/src/invite/InviteService.ts index 46610ef3..a6de6439 100644 --- a/fluxer_api/src/invite/InviteService.ts +++ b/fluxer_api/src/invite/InviteService.ts @@ -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 { diff --git a/fluxer_api/src/models/User.ts b/fluxer_api/src/models/User.ts index d78cd4ba..dd3deae9 100644 --- a/fluxer_api/src/models/User.ts +++ b/fluxer_api/src/models/User.ts @@ -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; } diff --git a/fluxer_api/src/oauth/ApplicationService.ts b/fluxer_api/src/oauth/ApplicationService.ts index e74f52cb..dcc5dc2f 100644 --- a/fluxer_api/src/oauth/ApplicationService.ts +++ b/fluxer_api/src/oauth/ApplicationService.ts @@ -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'); } diff --git a/fluxer_api/src/stripe/services/StripeCheckoutService.ts b/fluxer_api/src/stripe/services/StripeCheckoutService.ts index 35194eb3..2d52b3fb 100644 --- a/fluxer_api/src/stripe/services/StripeCheckoutService.ts +++ b/fluxer_api/src/stripe/services/StripeCheckoutService.ts @@ -218,7 +218,7 @@ export class StripeCheckoutService { } validateUserCanPurchase(user: User): void { - if (!user.passwordHash && !user.isBot) { + if (user.isUnclaimedAccount()) { throw new UnclaimedAccountRestrictedError('make purchases'); } diff --git a/fluxer_api/src/user/controllers/UserAccountController.ts b/fluxer_api/src/user/controllers/UserAccountController.ts index f8a7aba3..114fa68e 100644 --- a/fluxer_api/src/user/controllers/UserAccountController.ts +++ b/fluxer_api/src/user/controllers/UserAccountController.ts @@ -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; diff --git a/fluxer_api/src/user/services/EmailChangeService.ts b/fluxer_api/src/user/services/EmailChangeService.ts index 29f63aa2..de877c29 100644 --- a/fluxer_api/src/user/services/EmailChangeService.ts +++ b/fluxer_api/src/user/services/EmailChangeService.ts @@ -60,7 +60,7 @@ export class EmailChangeService { ) {} async start(user: User): Promise { - 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.'); diff --git a/fluxer_api/src/user/services/UserAccountSecurityService.ts b/fluxer_api/src/user/services/UserAccountSecurityService.ts index 1a4fb324..6fc82e46 100644 --- a/fluxer_api/src/user/services/UserAccountSecurityService.ts +++ b/fluxer_api/src/user/services/UserAccountSecurityService.ts @@ -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); diff --git a/fluxer_api/src/user/services/UserChannelService.ts b/fluxer_api/src/user/services/UserChannelService.ts index cbed023c..fc2adf4f 100644 --- a/fluxer_api/src/user/services/UserChannelService.ts +++ b/fluxer_api/src/user/services/UserChannelService.ts @@ -480,17 +480,12 @@ export class UserChannelService { } } - private async validateDmPermission(userId: UserID, recipientId: UserID, recipientUser?: User | null): Promise { + private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise { 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, diff --git a/fluxer_api/src/user/services/UserContentService.ts b/fluxer_api/src/user/services/UserContentService.ts index 4f6fbc0d..e2831d15 100644 --- a/fluxer_api/src/user/services/UserContentService.ts +++ b/fluxer_api/src/user/services/UserContentService.ts @@ -121,7 +121,7 @@ export class UserContentService { throw new UnknownUserError(); } - if (!user.passwordHash && !user.isBot) { + if (user.isUnclaimedAccount()) { throw new UnclaimedAccountRestrictedError('create beta codes'); } diff --git a/fluxer_api/src/user/services/UserRelationshipService.ts b/fluxer_api/src/user/services/UserRelationshipService.ts index 16035fb7..d44a53bd 100644 --- a/fluxer_api/src/user/services/UserRelationshipService.ts +++ b/fluxer_api/src/user/services/UserRelationshipService.ts @@ -137,7 +137,7 @@ export class UserRelationshipService { requestCache: RequestCache; }): Promise { 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'); } diff --git a/fluxer_api/src/voice/VoiceService.ts b/fluxer_api/src/voice/VoiceService.ts index 7131255d..b839453b 100644 --- a/fluxer_api/src/voice/VoiceService.ts +++ b/fluxer_api/src/voice/VoiceService.ts @@ -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'); diff --git a/tests/integration/unclaimed_account_cannot_receive_dm_test.go b/tests/integration/unclaimed_account_can_receive_dm_test.go similarity index 64% rename from tests/integration/unclaimed_account_cannot_receive_dm_test.go rename to tests/integration/unclaimed_account_can_receive_dm_test.go index ba1adddc..850ca45a 100644 --- a/tests/integration/unclaimed_account_cannot_receive_dm_test.go +++ b/tests/integration/unclaimed_account_can_receive_dm_test.go @@ -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") }