Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/prompts/autonomy-version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"version": "2025.11"
}
8 changes: 8 additions & 0 deletions .github/prompts/autonomy.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": "2025.11",
"consent": {
"phrase": "",
"expiresMinutes": 0
},
"actions": []
}
7 changes: 7 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,11 @@ export class AuthController {
const userAgent = request.headers['user-agent'];
return this.authService.verifyInitialEmail(verifyEmailDto.token, ipAddress, userAgent);
}

@Post('email/resend')
resendVerification(@Body() data: { email: string }, @Req() request: Request) {
const ipAddress = request.ip || request.socket.remoteAddress;
const userAgent = request.headers['user-agent'];
return this.authService.resendEmailVerification(data.email, ipAddress, userAgent);
}
}
69 changes: 60 additions & 9 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ import { UserRole } from '../types/prisma.types';
import { FraudService } from '../fraud/fraud.service';

type JwtPayload = {
sub: string;
email: string;
role: UserRole;
type: 'access' | 'refresh';
jti: string;
family?: string; // Token rotation family ID
exp?: number;
};
sub: string;
email: string;
role: UserRole;
type: 'access' | 'refresh';
jti: string;
family?: string;
exp?: number;
};

@Injectable()
export class AuthService {
Expand Down Expand Up @@ -150,7 +150,7 @@ export class AuthService {
},
});

// Send verification email
// Send verification email and await result
await this.emailService
.sendEmail({
to: user.email,
Expand All @@ -164,9 +164,12 @@ export class AuthService {
this.logger.error('Failed to queue verification email:', err?.message || err);
});

// Only issue short-lived verification token after email is sent
// Full token pair is issued in verifyInitialEmail after verification succeeds
return {
user: sanitizeUser(user),
message: 'Registration successful. Please check your email to verify your account.',
verificationToken,
};
}

Expand Down Expand Up @@ -1538,4 +1541,52 @@ export class AuthService {
...tokens,
};
}

async resendEmailVerification(email: string, ipAddress?: string, userAgent?: string) {
const user = await this.usersService.findByEmail(email);
if (!user) {
return;
}

if (user.isBlocked) {
return;
}

if (user.isVerified) {
throw new BadRequestException('Email already verified');
}

const verificationToken = randomToken(32);
const verificationExpiresAt = new Date(
Date.now() +
parseDuration(
this.configService.get<string>('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h',
24 * 60 * 60,
) *
1000,
);

await this.prisma.user.update({
where: { id: user.id },
data: {
emailVerificationToken: verificationToken,
emailVerificationExpires: verificationExpiresAt,
},
});

await this.emailService
.sendEmail({
to: user.email,
subject: 'Verify your email - PropChain',
template: 'email-verification',
context: { token: verificationToken },
userId: user.id,
emailType: 'email_verification',
})
.catch((err) => {
this.logger.error('Failed to queue verification email:', err?.message || err);
});

this.logger.log(`Verification email resent for user ${user.id}`);
}
}
4 changes: 4 additions & 0 deletions src/auth/rate-limit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export const ENDPOINT_RATE_LIMITS: Record<string, { windowMs: number; max: numbe
windowMs: 60 * 60 * 1000, // 1 hour
max: 20, // 20 refreshes per hour
},
'POST /auth/email/resend': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 resends per hour
},
'POST /auth/request-password-reset': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 requests per hour
Expand Down
9 changes: 7 additions & 2 deletions src/users/avatar-upload.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-nocheck

import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { promises as fs } from 'fs';
import { isAbsolute, join, relative, resolve } from 'path';
Expand Down Expand Up @@ -59,11 +59,16 @@ export class AvatarUploadService {

// Create user-specific directory
const userDir = join(this.uploadDir, userId);
const resolvedUserDir = resolve(userDir);
await fs.mkdir(userDir, { recursive: true });

// Save original file
const originalPath = join(userDir, filename);
await fs.writeFile(originalPath, file.buffer);
const resolvedOriginalPath = resolve(originalPath);
if (!resolvedOriginalPath.startsWith(resolvedUserDir)) {
throw new ForbiddenException('Avatar path traversal not allowed');
}
await fs.writeFile(resolvedOriginalPath, file.buffer);

// Generate different sizes (simplified version - in production you'd use sharp)
await this.generateAvatarSizes(originalPath, userDir, filename);
Expand Down
Loading