Skip to content
Open
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
753 changes: 631 additions & 122 deletions docs/database.md

Large diffs are not rendered by default.

31 changes: 18 additions & 13 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppConfigModule } from './config/config.module';
import { AppConfigService } from './config/app-config.service';
import { ThrottlerModule, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerRedisModule } from './common/throttler/throttler-redis.module';
Expand Down Expand Up @@ -34,20 +35,25 @@ import { ScheduleModule } from '@nestjs/schedule';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AppConfigModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST ?? 'localhost',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
},
BullModule.forRootAsync({
imports: [AppConfigModule],
inject: [AppConfigService],
useFactory: (appConfig: AppConfigService) => ({
connection: {
host: appConfig.redisHost,
port: appConfig.redisPort,
password: appConfig.redisPassword,
},
}),
}),
PrismaModule,
ThrottlerModule.forRootAsync({
imports: [ConfigModule, ThrottlerRedisModule],
inject: [ConfigService, ThrottlerStorage],
useFactory: (config: ConfigService, storage: ThrottlerStorage) => ({
imports: [AppConfigModule, ThrottlerRedisModule],
inject: [AppConfigService, ThrottlerStorage],
useFactory: (appConfig: AppConfigService, storage: ThrottlerStorage) => ({
storage,
throttlers: [
{
Expand Down Expand Up @@ -98,9 +104,8 @@ import { ScheduleModule } from '@nestjs/schedule';
],
skipIf: (context) => {
const request = context.switchToHttp().getRequest();
const whitelist = config.get<string>('THROTTLER_WHITELIST');
if (!whitelist) return false;
const whitelistedIps = whitelist.split(',').map((ip) => ip.trim());
const whitelistedIps = appConfig.throttlerWhitelist;
if (whitelistedIps.length === 0) return false;
return whitelistedIps.includes(request.ip);
},
}),
Expand Down
21 changes: 9 additions & 12 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { GoogleStrategy } from './strategies/google.strategy';
Expand All @@ -20,25 +19,23 @@ import { EmailDeliveryProcessor } from './email-delivery.processor';
import { EncryptionModule } from '../encryption/encryption.module';
import { StellarModule } from '../stellar/stellar.module';
import { AdminGuard } from './guards/admin.guard';
import { AppConfigModule } from '../config/config.module';
import { AppConfigService } from '../config/app-config.service';

@Module({
imports: [
ConfigModule,
AppConfigModule,
PrismaModule,
EncryptionModule,
StellarModule,
PassportModule.register({ session: false }),
JwtModule.registerAsync({
useFactory: () => {
const expires =
Number(process.env.JWT_EXPIRES) && Number(process.env.JWT_EXPIRES) > 0
? Number(process.env.JWT_EXPIRES)
: 3600;
return {
secret: process.env.JWT_SECRET || 'dev_jwt_secret',
signOptions: { expiresIn: expires },
};
},
imports: [AppConfigModule],
inject: [AppConfigService],
useFactory: (appConfig: AppConfigService) => ({
secret: appConfig.jwtSecret,
signOptions: { expiresIn: appConfig.jwtExpires },
}),
}),
CsrfModule,
BullModule.registerQueue({
Expand Down
14 changes: 7 additions & 7 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,18 @@ import { LoginDto } from './dto/login.dto';
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';
import { StellarService } from '../stellar/stellar.service';
import { AppConfigService } from '../config/app-config.service';

type JwtUser = {
id: number;
email: string | null;
emailVerified?: Date | null;
};

const REFRESH_TOKEN_EXPIRES_DAYS =
Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0
? Number(process.env.JWT_REFRESH_EXPIRES_DAYS)
: 14;

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly refreshTokenExpiresDays: number;

constructor(
private readonly jwtService: JwtService,
Expand All @@ -46,7 +43,10 @@ export class AuthService {
private readonly bruteForceService: BruteForceProtectionService,
private readonly encryption: EncryptionService,
private readonly stellarService: StellarService,
) {}
appConfig: AppConfigService,
) {
this.refreshTokenExpiresDays = appConfig.jwtRefreshExpiresDays;
}

/** Generate a custodial Stellar keypair and persist it on the user record. */
private async assignStellarWallet(userId: number): Promise<void> {
Expand Down Expand Up @@ -146,7 +146,7 @@ export class AuthService {
.update(rawToken)
.digest('hex');
const expiresAt = new Date(
Date.now() + REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000,
Date.now() + this.refreshTokenExpiresDays * 24 * 60 * 60 * 1000,
);

await this.prisma.refreshToken.create({
Expand Down
19 changes: 5 additions & 14 deletions src/auth/brute-force-protection.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '../redis/redis.service';
import { AppConfigService } from '../config/app-config.service';

export interface BruteForceConfig {
maxAttempts: number;
Expand All @@ -15,23 +15,14 @@ export class BruteForceProtectionService {
private readonly redis: ReturnType<RedisService['getClient']>;

constructor(
private configService: ConfigService,
appConfig: AppConfigService,
private redisService: RedisService,
) {
this.redis = this.redisService.getClient();
this.config = {
maxAttempts: this.configService.get<number>(
'BRUTE_FORCE_MAX_ATTEMPTS',
5,
),
lockoutDuration: this.configService.get<number>(
'BRUTE_FORCE_LOCKOUT_DURATION',
900,
), // 15 minutes
windowDuration: this.configService.get<number>(
'BRUTE_FORCE_WINDOW_DURATION',
900,
), // 15 minutes
maxAttempts: appConfig.bruteForceMaxAttempts,
lockoutDuration: appConfig.bruteForceLockoutDuration,
windowDuration: appConfig.bruteForceWindowDuration,
};
}

Expand Down
22 changes: 6 additions & 16 deletions src/auth/cookie.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { AppConfigService } from '../config/app-config.service';

export interface CookieOptions {
httpOnly: boolean;
Expand All @@ -16,22 +17,11 @@ export class CookieService {
private readonly accessTokenTtlMs: number;
private readonly refreshTokenTtlMs: number;

constructor() {
this.useSecure = process.env.COOKIE_SECURE !== 'false'; // default true
const raw = (process.env.COOKIE_SAME_SITE ?? 'lax').toLowerCase();
this.sameSite = raw === 'strict' || raw === 'none' ? raw : 'lax';

const jwtExpires =
Number(process.env.JWT_EXPIRES) > 0
? Number(process.env.JWT_EXPIRES)
: 3600;
const refreshDays =
Number(process.env.JWT_REFRESH_EXPIRES_DAYS) > 0
? Number(process.env.JWT_REFRESH_EXPIRES_DAYS)
: 14;

this.accessTokenTtlMs = jwtExpires * 1000;
this.refreshTokenTtlMs = refreshDays * 24 * 60 * 60 * 1000;
constructor(private readonly appConfig: AppConfigService) {
this.useSecure = appConfig.cookieSecure;
this.sameSite = appConfig.cookieSameSite;
this.accessTokenTtlMs = appConfig.jwtExpires * 1000;
this.refreshTokenTtlMs = appConfig.jwtRefreshExpiresDays * 24 * 60 * 60 * 1000;
}

private baseOptions(maxAge: number): CookieOptions {
Expand Down
5 changes: 2 additions & 3 deletions src/auth/email-delivery.processor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Logger } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { ConfigService } from '@nestjs/config';
import { Job } from 'bullmq';
import { MailService } from './mail.service';
import { MetricsService } from '../metrics/metrics.service';
Expand All @@ -17,7 +16,7 @@ import { getBullMQWorkerConfig } from '../config/bullmq.config';
* Default: 5 concurrent jobs (email sending is I/O-bound and can handle more parallelism)
*/
@Processor(EMAIL_DELIVERY_QUEUE, {
concurrency: getBullMQWorkerConfig(new ConfigService()).emailDeliveryConcurrency,
concurrency: getBullMQWorkerConfig().emailDeliveryConcurrency,
})
export class EmailDeliveryProcessor extends WorkerHost {
private readonly logger = new Logger(EmailDeliveryProcessor.name);
Expand All @@ -27,7 +26,7 @@ export class EmailDeliveryProcessor extends WorkerHost {
private readonly metricsService: MetricsService,
) {
super();
const config = getBullMQWorkerConfig(configService);
const config = getBullMQWorkerConfig();
this.logger.log(
`Email delivery worker initialized with concurrency: ${config.emailDeliveryConcurrency}`,
);
Expand Down
19 changes: 10 additions & 9 deletions src/auth/mail.service.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { EmailDeliveryJobData } from './email-delivery.queue';
import { AppConfigService } from '../config/app-config.service';

@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: nodemailer.Transporter;

constructor() {
constructor(private readonly appConfig: AppConfigService) {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.ethereal.email',
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
host: appConfig.smtpHost,
port: appConfig.smtpPort,
secure: appConfig.smtpSecure,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
user: appConfig.smtpUser,
pass: appConfig.smtpPass,
},
});
}

async sendTemplatedEmail(job: EmailDeliveryJobData): Promise<void> {
const content = this.buildTemplate(job.template, job.context.token);
const info = await this.transporter.sendMail({
from: process.env.SMTP_FROM || '"Clips App" <noreply@clips.app>',
from: this.appConfig.smtpFrom,
to: job.to,
subject: job.subject,
text: content.text,
Expand All @@ -41,7 +42,7 @@ export class MailService {
html?: string;
}): Promise<void> {
const info = await this.transporter.sendMail({
from: process.env.SMTP_FROM || '"Clips App" <noreply@clips.app>',
from: this.appConfig.smtpFrom,
to: options.to,
subject: options.subject,
text: options.text,
Expand Down Expand Up @@ -81,7 +82,7 @@ export class MailService {
}

private buildTemplate(template: EmailDeliveryJobData['template'], token: string) {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const baseUrl = this.appConfig.appBaseUrl;
if (template === 'magic-link') {
const link = `${baseUrl}/auth/verify-magic?token=${token}`;
return {
Expand Down
14 changes: 8 additions & 6 deletions src/auth/strategies/google.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Profile } from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import { AppConfigService } from '../../config/app-config.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private readonly authService: AuthService) {
constructor(
private readonly authService: AuthService,
appConfig: AppConfigService,
) {
super({
clientID: process.env.GOOGLE_CLIENT_ID || 'google-client-id',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'google-client-secret',
callbackURL:
process.env.GOOGLE_CALLBACK_URL ||
'http://localhost:3000/auth/google/callback',
clientID: appConfig.googleClientId,
clientSecret: appConfig.googleClientSecret,
callbackURL: appConfig.googleCallbackUrl,
scope: ['profile', 'email'],
passReqToCallback: false,
});
Expand Down
5 changes: 3 additions & 2 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AppConfigService } from '../../config/app-config.service';

export type JwtPayload = {
sub: number;
Expand All @@ -10,11 +11,11 @@ export type JwtPayload = {

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
constructor(appConfig: AppConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'dev_jwt_secret',
secretOrKey: appConfig.jwtSecret,
});
}

Expand Down
7 changes: 6 additions & 1 deletion src/clips/ayrshare.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service';

export interface AyrsharePostResult {
platform: string;
Expand All @@ -16,9 +17,13 @@ export interface AyrsharePostResult {
@Injectable()
export class AyrshareService {
private readonly logger = new Logger(AyrshareService.name);
private readonly apiKey = process.env.AYRSHARE_API_KEY ?? '';
private readonly apiKey: string;
private readonly baseUrl = 'https://app.ayrshare.com/api';

constructor(appConfig: AppConfigService) {
this.apiKey = appConfig.ayrshareApiKey;
}

async post(
mediaUrl: string,
caption: string,
Expand Down
5 changes: 2 additions & 3 deletions src/clips/clip-generation.processor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Logger } from '@nestjs/common';
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { ConfigService } from '@nestjs/config';
import { Job, UnrecoverableError } from 'bullmq';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Clip } from './clip.entity';
Expand Down Expand Up @@ -80,7 +79,7 @@ const JOB_TIMEOUT_MS = 30 * 60 * 1000;
* 100% → done (DB updated, all done)
*/
@Processor(CLIP_GENERATION_QUEUE, {
concurrency: getBullMQWorkerConfig(new ConfigService()).clipGenerationConcurrency,
concurrency: getBullMQWorkerConfig().clipGenerationConcurrency,
})
export class ClipGenerationProcessor extends WorkerHost {
private readonly logger = new Logger(ClipGenerationProcessor.name);
Expand All @@ -94,7 +93,7 @@ export class ClipGenerationProcessor extends WorkerHost {
private readonly prisma: PrismaService,
) {
super();
const config = getBullMQWorkerConfig(new ConfigService());
const config = getBullMQWorkerConfig();
this.logger.log(
`Clip generation worker initialized with concurrency: ${config.clipGenerationConcurrency}`,
);
Expand Down
Loading