Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions migrations/20260330160219-add-mail-access-limit.js
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should not be part of Drive, rather than from payments.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use strict';

const { v4 } = require('uuid');

const LIMIT_LABEL = 'mail-access';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const disabledLimitId = v4();
const enabledLimitId = v4();

await queryInterface.bulkInsert(
'limits',
[
{
id: disabledLimitId,
label: LIMIT_LABEL,
type: 'boolean',
value: 'false',
created_at: new Date(),
updated_at: new Date(),
},
{
id: enabledLimitId,
label: LIMIT_LABEL,
type: 'boolean',
value: 'true',
created_at: new Date(),
updated_at: new Date(),
},
],
{ transaction },
);

const [tiers] = await queryInterface.sequelize.query(
`SELECT id, label FROM tiers`,
{ transaction },
);

const tierLimitRelations = tiers.map((tier) => ({
id: v4(),
tier_id: tier.id,
limit_id: disabledLimitId,
created_at: new Date(),
updated_at: new Date(),
}));

await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, {
transaction,
});

await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},

async down(queryInterface) {
const transaction = await queryInterface.sequelize.transaction();
try {
const [limits] = await queryInterface.sequelize.query(
`SELECT id FROM limits WHERE label = :limitLabel`,
{ replacements: { limitLabel: LIMIT_LABEL }, transaction },
);

const limitIds = limits.map((l) => l.id);

if (limitIds.length > 0) {
await queryInterface.sequelize.query(
`DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`,
{ replacements: { limitIds }, transaction },
);
}

await queryInterface.sequelize.query(
`DELETE FROM limits WHERE label = :limitLabel`,
{ replacements: { limitLabel: LIMIT_LABEL }, transaction },
);

await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AuthGuard } from './modules/auth/auth.guard';
import { CacheManagerModule } from './modules/cache-manager/cache-manager.module';
import { ReferralModule } from './modules/referral/referral.module';
import { HealthModule } from './infrastructure/health/health.module';
import { MailModule } from './modules/mail/mail.module';

const isCronjobInstance = process.env.EXECUTE_JOBS === 'true';
const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server';
Expand Down Expand Up @@ -152,6 +153,7 @@ const appName = isCronjobInstance ? 'drive-server-cronjob' : 'drive-server';
CacheManagerModule,
ReferralModule,
HealthModule,
MailModule,
],
controllers: [],
providers: [
Expand Down
2 changes: 2 additions & 0 deletions src/common/audit-logs/audit-logs.attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export enum AuditAction {
AccountReset = 'account-reset',
AccountRecovery = 'account-recovery',
AccountDeactivated = 'account-deactivated',
MailSetup = 'mail-setup',
// Workspace actions
WorkspaceCreated = 'workspace-created',
WorkspaceDeleted = 'workspace-deleted',
Expand All @@ -48,6 +49,7 @@ export const AUDIT_ENTITY_ACTIONS: Record<AuditEntityType, AuditAction[]> = {
AuditAction.AccountReset,
AuditAction.AccountRecovery,
AuditAction.AccountDeactivated,
AuditAction.MailSetup,
],
[AuditEntityType.Workspace]: [
AuditAction.WorkspaceCreated,
Expand Down
3 changes: 3 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export default () => ({
payments: {
url: process.env.PAYMENTS_API_URL,
},
mail: {
url: process.env.MAIL_API_URL,
},
},
apn: {
url: process.env.APN_URL,
Expand Down
12 changes: 12 additions & 0 deletions src/externals/mail/mail.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpClientModule } from '../http/http.module';
import { MailService } from './mail.service';

@Module({
imports: [ConfigModule, HttpClientModule],
controllers: [],
providers: [MailService],
exports: [MailService],
})
export class MailServiceModule {}
91 changes: 91 additions & 0 deletions src/externals/mail/mail.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ConflictException, Inject, Injectable } from '@nestjs/common';
import { sign } from 'jsonwebtoken';
import { ConfigService } from '@nestjs/config';
import { HttpClient } from '../http/http.service';

interface CreateAccountPayload {
userId: string;
address: string;
domain: string;
displayName: string;
}

interface CreateAccountResponse {
address: string;
domain: string;
}

function signToken(duration: string, secret: string, isDevelopment?: boolean) {
return sign({}, Buffer.from(secret, 'base64').toString('utf8'), {
algorithm: 'RS256',
expiresIn: duration,
...(isDevelopment ? { allowInsecureKeySizes: true } : null),
});
}

@Injectable()
export class MailService {
constructor(
@Inject(ConfigService)
private readonly configService: ConfigService,
@Inject(HttpClient)
private readonly httpClient: HttpClient,
) {}

private getAuthHeaders() {
const isDevelopment = this.configService.get('isDevelopment');
const jwt = signToken(
'5m',
this.configService.get('secrets.gateway'),
isDevelopment,
);

return {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
};
}

async findUserIdByAddress(address: string): Promise<string | null> {
const baseUrl = this.configService.get('apis.mail.url');
const headers = this.getAuthHeaders();

try {
const res = await this.httpClient.get(
`${baseUrl}/gateway/addresses/${encodeURIComponent(address)}`,
{ headers },
);

return res.data?.userId ?? null;
} catch (error) {
if (error?.response?.status === 404) {
return null;
}
throw error;
}
}

async createAccount(
payload: CreateAccountPayload,
): Promise<CreateAccountResponse> {
const baseUrl = this.configService.get('apis.mail.url');
const headers = this.getAuthHeaders();

try {
const res = await this.httpClient.post(
`${baseUrl}/gateway/accounts`,
payload,
{ headers },
);

return res.data;
} catch (error) {
if (error?.response?.status === 409) {
throw new ConflictException(
error.response.data?.message || 'Mail account already exists',
);
}
throw error;
}
}
}
21 changes: 20 additions & 1 deletion src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { FeatureLimitService } from '../feature-limit/feature-limit.service';
import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception';
import { Client } from '../../common/decorators/client.decorator';
import { type ClientEnum } from '../../common/enums/platform.enum';
import { MailService } from '../../externals/mail/mail.service';
import { isManagedMailDomain } from './managed-mail-domains';

@ApiTags('Auth')
@Controller('auth')
Expand All @@ -60,8 +62,19 @@ export class AuthController {
private readonly twoFactorAuthService: TwoFactorAuthService,
private readonly authUseCases: AuthUsecases,
private readonly featureLimitService: FeatureLimitService,
private readonly mailService: MailService,
) {}

private async resolveLoginEmail(email: string): Promise<string> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part of the login logic, I will add it to the auth.service, not in the controller, as this applies to the login itself, with independence of the web login or the CLI one

if (!isManagedMailDomain(email)) return email;

const userId = await this.mailService.findUserIdByAddress(email);
if (!userId) return email;

const user = await this.userUseCases.findByUuid(userId).catch(() => null);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't hide errors, log or let them propagate

return user?.email ?? email;
}

@Post('/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand All @@ -70,7 +83,7 @@ export class AuthController {
@ApiOkResponse({ description: 'Retrieve details', type: LoginResponseDto })
@Public()
async login(@Body() body: LoginDto): Promise<LoginResponseDto> {
const email = body.email.toLowerCase();
const email = await this.resolveLoginEmail(body.email.toLowerCase());

const user = await this.userUseCases.findByEmail(email);

Expand Down Expand Up @@ -128,8 +141,11 @@ export class AuthController {
revocationKey: body.revocateKey,
});

const email = await this.resolveLoginEmail(body.email.toLowerCase());

const result = await this.userUseCases.loginAccess({
...body,
email,
keys: { kyber, ecc },
});

Expand Down Expand Up @@ -299,8 +315,11 @@ export class AuthController {
revocationKey: body.revocateKey,
});

const email = await this.resolveLoginEmail(body.email.toLowerCase());

const result = await this.userUseCases.loginAccess({
...body,
email,
keys: { kyber, ecc },
platform,
});
Expand Down
2 changes: 2 additions & 0 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AuthUsecases } from './auth.usecase';
import { CaptchaService } from '../../externals/captcha/captcha.service';
import { FeatureLimitModule } from '../feature-limit/feature-limit.module';
import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module';
import { MailServiceModule } from '../../externals/mail/mail.module';

@Module({
imports: [
Expand All @@ -41,6 +42,7 @@ import { AuditLogsModule } from '../../common/audit-logs/audit-logs.module';
CacheManagerModule,
FeatureLimitModule,
AuditLogsModule,
MailServiceModule,
],
providers: [
CaptchaService,
Expand Down
9 changes: 9 additions & 0 deletions src/modules/auth/managed-mail-domains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const MANAGED_MAIL_DOMAINS: ReadonlySet<string> = new Set([
'inxt.eu',
'inxt.me',
]);

export function isManagedMailDomain(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase();
return !!domain && MANAGED_MAIL_DOMAINS.has(domain);
}
1 change: 1 addition & 0 deletions src/modules/feature-limit/limits.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum LimitLabels {
RcloneAccess = 'rclone-access',
TrashRetentionDays = 'trash-retention-days',
ReferralAccess = 'referral-access',
MailAccess = 'mail-access',
}

export enum LimitTypes {
Expand Down
24 changes: 24 additions & 0 deletions src/modules/mail/dto/create-mail-account.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateMailAccountDto {
@ApiProperty({ example: 'john' })
@IsNotEmpty()
@IsString()
address: string;

@ApiProperty({ example: 'inxt.eu' })
@IsNotEmpty()
@IsString()
domain: string;

@ApiProperty({ example: 'John Doe' })
@IsNotEmpty()
@IsString()
displayName: string;

@ApiProperty({ description: 'Encrypted password for re-authentication' })
@IsNotEmpty()
@IsString()
password: string;
}
Loading
Loading