Skip to content

Commit

Permalink
feat(backend): adding s3 supports, and also expose uploading project …
Browse files Browse the repository at this point in the history
…images (#154)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- **Enhanced Project Photo Upload:** Users can now update project photos
directly through file uploads for a smoother, more reliable experience.
- **Expanded Image Flexibility:** The application now supports loading
images from any domain, broadening your content sourcing options.
- **Improved Upload Performance:** Upgraded file handling ensures
consistent and efficient processing for a better overall experience.
- **New Configuration Options:** A new example configuration file has
been added to guide users on setting up environment variables.
  
- **Bug Fixes**
- **Updated Project Photo Mutation:** The mutation for updating project
photos has been streamlined, enhancing functionality and usability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: NarwhalChen <125920907+NarwhalChen@users.noreply.github.com>
  • Loading branch information
3 people authored Mar 5, 2025
1 parent b616285 commit 1173bef
Show file tree
Hide file tree
Showing 25 changed files with 1,826 additions and 225 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ models/

*/**/database.sqlite
./backend/src/database.sqlite
.codefox
.codefox

.env
5 changes: 0 additions & 5 deletions backend/.env

This file was deleted.

5 changes: 0 additions & 5 deletions backend/.env.development

This file was deleted.

23 changes: 23 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Server Configuration
PORT=8080

# DEV PROD OR TEST
NODE_ENV="DEV"
# JWT Configuration
JWT_SECRET="your_jwt_secret_here"
JWT_REFRESH="your_jwt_refresh_secret_here"
SALT_ROUNDS=10

# OpenAI Configuration
OPENAI_BASE_URI="http://localhost:3001"

# S3/Cloudflare R2 Configuration (Optional)
# If not provided, local file storage will be used
S3_ACCESS_KEY_ID="your_s3_access_key_id" # Must be 32 characters for Cloudflare R2
S3_SECRET_ACCESS_KEY="your_s3_secret_access_key"
S3_REGION="auto" # Use 'auto' for Cloudflare R2
S3_BUCKET_NAME="your_bucket_name"
S3_ENDPOINT="https://<account_id>.r2.cloudflarestorage.com" # Cloudflare R2 endpoint
S3_ACCOUNT_ID="your_cloudflare_account_id" # Your Cloudflare account ID
S3_PUBLIC_URL="https://pub-xxx.r2.dev" # Your R2 public bucket URL

3 changes: 2 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ log-*/


# Backend
/backend/package-lock.json
/backend/package-lock.json
.env
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"dependencies": {
"@apollo/server": "^4.11.0",
"@aws-sdk/client-s3": "^3.758.0",
"@huggingface/hub": "latest",
"@huggingface/transformers": "latest",
"@nestjs/apollo": "^12.2.0",
Expand All @@ -45,6 +46,7 @@
"@types/toposort": "^2.0.7",
"axios": "^1.7.7",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"eslint-plugin-unused-imports": "^4.1.4",
Expand All @@ -53,6 +55,7 @@
"gpt-3-encoder": "^1.1.4",
"graphql": "^16.9.0",
"graphql-subscriptions": "^2.0.0",
"graphql-upload-minimal": "^1.6.1",
"graphql-ws": "^5.16.0",
"lodash": "^4.17.21",
"markdown-to-txt": "^2.0.1",
Expand Down
34 changes: 34 additions & 0 deletions backend/src/config/config.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { AppConfigService } from './config.service';
import { EnvironmentVariables } from './env.validation';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';

const validate = (config: Record<string, unknown>) => {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});

const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});

if (errors.length > 0) {
throw new Error(errors.toString());
}

return validatedConfig;
};

@Module({
imports: [
NestConfigModule.forRoot({
validate,
isGlobal: true,
}),
],
providers: [AppConfigService],
exports: [AppConfigService],
})
export class AppConfigModule {}
71 changes: 71 additions & 0 deletions backend/src/config/config.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
import { EnvironmentVariables } from './env.validation';

@Injectable()
export class AppConfigService {
constructor(private configService: NestConfigService<EnvironmentVariables>) {}

/**
* Get server port from environment
*/
get port(): number {
return this.configService.get('PORT');
}

/**
* Get JWT secret key for token generation
*/
get jwtSecret(): string {
return this.configService.get('JWT_SECRET');
}

/**
* Get JWT refresh token secret
*/
get jwtRefresh(): string {
return this.configService.get('JWT_REFRESH');
}

/**
* Get password hashing salt rounds
*/
get saltRounds(): number {
return this.configService.get('SALT_ROUNDS');
}

/**
* Get OpenAI API base URI
*/
get openaiBaseUri(): string {
return this.configService.get('OPENAI_BASE_URI');
}

/**
* Get S3/Cloudflare R2 configuration object
*/
get s3Config() {
return {
accessKeyId: this.configService.get('S3_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('S3_SECRET_ACCESS_KEY'),
region: this.configService.get('S3_REGION'),
bucketName: this.configService.get('S3_BUCKET_NAME'),
endpoint: this.configService.get('S3_ENDPOINT'),
accountId: this.configService.get('S3_ACCOUNT_ID'),
publicUrl: this.configService.get('S3_PUBLIC_URL'),
};
}

/**
* Check if S3 storage is properly configured
*/
get hasS3Configured(): boolean {
const config = this.s3Config;
return !!(
config.accessKeyId &&
config.secretAccessKey &&
config.region &&
(config.endpoint || config.accountId)
);
}
}
51 changes: 51 additions & 0 deletions backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { IsOptional, IsString, IsNumber, IsIn } from 'class-validator';

export class EnvironmentVariables {
@IsNumber()
PORT: number;

@IsString()
@IsIn(['DEV', 'PROD', 'TEST'])
NODE_ENV: string;

@IsString()
JWT_SECRET: string;

@IsString()
JWT_REFRESH: string;

@IsNumber()
SALT_ROUNDS: number;

@IsString()
OPENAI_BASE_URI: string;

// S3/Cloudflare R2 Configuration - all optional
@IsOptional()
@IsString()
S3_ACCESS_KEY_ID?: string;

@IsOptional()
@IsString()
S3_SECRET_ACCESS_KEY?: string;

@IsOptional()
@IsString()
S3_REGION?: string;

@IsOptional()
@IsString()
S3_BUCKET_NAME?: string;

@IsOptional()
@IsString()
S3_ENDPOINT?: string;

@IsOptional()
@IsString()
S3_ACCOUNT_ID?: string;

@IsOptional()
@IsString()
S3_PUBLIC_URL?: string;
}
3 changes: 3 additions & 0 deletions backend/src/guard/project.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { JwtService } from '@nestjs/jwt';

import { ProjectService } from '../project/project.service';

/**
* This guard checks if the user is authorized to access a project.
*/
@Injectable()
export class ProjectGuard implements CanActivate {
private readonly logger = new Logger('ProjectGuard');
Expand Down
7 changes: 7 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppModule } from './app.module';
import 'reflect-metadata';
import * as dotenv from 'dotenv';
import { Logger } from '@nestjs/common';
import { graphqlUploadExpress } from 'graphql-upload-minimal';

async function bootstrap() {
const logger = new Logger('Bootstrap');
Expand All @@ -18,10 +19,16 @@ async function bootstrap() {
'Authorization',
'Access-Control-Allow-Origin',
'Access-Control-Allow-Credentials',
'Apollo-Require-Preflight',
'x-refresh-token',
],
});

app.use(
'/graphql',
graphqlUploadExpress({ maxFileSize: 50000000, maxFiles: 10 }),
);

console.log('process.env.PORT:', process.env.PORT);
const server = await app.listen(process.env.PORT ?? 8080);
logger.log(`Application is running on port ${process.env.PORT ?? 8080}`);
Expand Down
12 changes: 12 additions & 0 deletions backend/src/project/dto/project.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { InputType, Field, ID, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID, IsOptional } from 'class-validator';
import { Project } from '../project.model';
import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal';

/**
* @deprecated We don't need project upsert
Expand Down Expand Up @@ -118,3 +119,14 @@ export class FetchPublicProjectsInputs {
@Field()
size: number;
}

@InputType()
export class UpdateProjectPhotoInput {
@IsString()
@Field(() => ID)
projectId: string;

@IsOptional()
@Field(() => GraphQLUpload)
file: FileUpload;
}
6 changes: 5 additions & 1 deletion backend/src/project/project.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import { ProjectGuard } from '../guard/project.guard';
import { ChatService } from 'src/chat/chat.service';
import { User } from 'src/user/user.model';
import { Chat } from 'src/chat/chat.model';
import { AppConfigModule } from 'src/config/config.module';
import { UploadModule } from 'src/upload/upload.module';

@Module({
imports: [
TypeOrmModule.forFeature([Project, Chat, User, ProjectPackages]),
AuthModule, // Import AuthModule to provide JwtService to the ProjectGuard
AuthModule,
AppConfigModule,
UploadModule,
],
providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard],
exports: [ProjectService, ProjectGuard],
Expand Down
28 changes: 20 additions & 8 deletions backend/src/project/project.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CreateProjectInput,
FetchPublicProjectsInputs,
IsValidProjectInput,
UpdateProjectPhotoInput,
} from './dto/project.input';
import { Logger, UseGuards } from '@nestjs/common';
import { ProjectGuard } from '../guard/project.guard';
Expand Down Expand Up @@ -87,22 +88,33 @@ export class ProjectsResolver {
return this.projectService.subscribeToProject(userId, projectId);
}

@UseGuards(ProjectGuard)
@Mutation(() => Project)
async updateProjectPhotoUrl(
async updateProjectPhoto(
@GetUserIdFromToken() userId: string,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('photoUrl') photoUrl: string,
@Args('input') input: UpdateProjectPhotoInput,
): Promise<Project> {
this.logger.log(
`User ${userId} updating photo URL for project ${projectId}`,
);
const { projectId, file } = input;
this.logger.log(`User ${userId} uploading photo for project ${projectId}`);

// Extract the file data
const { createReadStream, mimetype } = await file;

// Buffer the file content
const chunks = [];
for await (const chunk of createReadStream()) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);

// Call the service with the extracted buffer and mimetype
return this.projectService.updateProjectPhotoUrl(
userId,
projectId,
photoUrl,
buffer,
mimetype,
);
}

@Mutation(() => Project)
async updateProjectPublicStatus(
@GetUserIdFromToken() userId: string,
Expand Down
Loading

0 comments on commit 1173bef

Please sign in to comment.